Coverage Summary for Class: UserScopedPullPhase (com.georgeci.moneysurfer.data.sync)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| UserScopedPullPhase |
100%
(6/6)
|
80.8%
(21/26)
|
96.7%
(59/61)
|
96%
(549/572)
|
| UserScopedPullPhase$applyAll$1 |
|
| UserScopedPullPhase$Companion |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/1)
|
| UserScopedPullPhase$fetch$1 |
|
| UserScopedPullPhase$invoke$1 |
|
| UserScopedPullPhase$pluginsWithCollections$$inlined$sortedBy$1 |
0%
(0/1)
|
|
| Total |
75%
(6/8)
|
80.8%
(21/26)
|
95.2%
(59/62)
|
95.8%
(549/573)
|
package com.georgeci.moneysurfer.data.sync
import arrow.core.raise.either
import co.touchlab.kermit.Logger
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.sync.PullProgress
import com.georgeci.moneysurfer.domain.sync.PullSummary
import com.georgeci.moneysurfer.sync.api.SyncCancelToken
import com.georgeci.moneysurfer.sync.api.SyncResult
import com.georgeci.moneysurfer.sync.api.SyncScope
import com.georgeci.moneysurfer.sync.plugin.PullScope
import com.georgeci.moneysurfer.sync.plugin.RemoteDocument
import com.georgeci.moneysurfer.sync.plugin.SyncEntityPlugin
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Single
import kotlin.coroutines.cancellation.CancellationException
/**
* Phase 3 of the pull: collections that hang off `users/{uid}` rather than off a workspace — today
* only `config`, the per-user settings.
*
* Its own class rather than two more methods on [PullRemoteChangesUseCaseImpl], which is at the
* size where every further branch makes the workspace loop harder to read. The split is also
* honest: nothing here shares state with the workspace phases — no cursor, no `SyncMetaRepository`,
* no per-workspace bookkeeping.
*
* It runs for every scope that pulls at all, because this is the **only** channel by which a
* setting changed on another device reaches this one: there is no listener, and one query returning
* about ten tiny documents is not worth scoping down further.
*/
@Single
class UserScopedPullPhase(
private val reader: UserCollectionReader,
private val plugins: List<SyncEntityPlugin>,
private val session: SessionPointers,
) {
private val log = Logger.withTag(TAG)
suspend operator fun invoke(
scope: SyncScope,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): SyncResult<PullSummary> = either {
if (scope == SyncScope.UploadOnly) return@either EMPTY_SUMMARY
val uid = session.currentFirebaseUid.first() ?: return@either EMPTY_SUMMARY
val appliedSummaries = mutableListOf<PullSummary>()
for ((plugin, collectionName) in pluginsWithCollections()) {
cancelToken.throwIfCancelled()
val applied = try {
fetch(uid, collectionName)?.let { docs ->
applyAll(
plugin = plugin,
collectionName = collectionName,
docs = docs,
scopeKey = uid,
onProgress = onProgress,
cancelToken = cancelToken,
)
} ?: continue
} catch (cancelled: CancellationException) {
throw cancelled
} catch (
@Suppress("TooGenericExceptionCaught")
failure: Throwable,
) {
// Everything that reaches here is either a non-denial read failure or a local write
// that failed: both abort the pull rather than reporting a sync that stored nothing.
log.e(throwable = failure) { "[pull] failed (phase3) collection=$collectionName" }
raise(failure.toSyncError())
}
appliedSummaries += applied
}
PullSummary(
downloadedCount = appliedSummaries.sumOf(PullSummary::downloadedCount),
conflictCount = appliedSummaries.sumOf(PullSummary::conflictCount),
)
}
/** A user-scoped plugin without a collection has nothing to be pulled from; it is not fatal. */
private fun pluginsWithCollections(): List<Pair<SyncEntityPlugin, String>> = plugins
.filter { it.pullScope == PullScope.User }
.sortedBy { it.pullPriority }
.mapNotNull { plugin ->
val collectionName = plugin.firestoreCollectionName
if (collectionName == null) {
log.w { "[pull] user-scoped plugin ${plugin.entityType} has no collection — skipping" }
null
} else {
plugin to collectionName
}
}
/**
* `null` means "skip this collection". A denial is the one failure treated that way: these
* documents are private to one user and hold nothing another device cannot re-derive, whereas
* the rules for a brand-new subcollection are exactly the kind of thing that can lag a client
* release — and failing the pull would turn that into "nobody can sign in".
*/
@Suppress("TooGenericExceptionCaught") // Classified below; anything but a denial is re-thrown.
private suspend fun fetch(uid: String, collectionName: String): List<RemoteDocument>? = try {
reader.fetchAll(uid, collectionName, limit = MAX_DOCS_PER_COLLECTION).also { docs ->
if (docs.size >= MAX_DOCS_PER_COLLECTION) {
// Truncation is a bug to report, not a page to fetch: this collection holds one
// document per settings key and cannot legitimately reach the cap. Applying the
// prefix is still strictly better than applying nothing.
log.e {
"[pull] users/{uid}/$collectionName hit the $MAX_DOCS_PER_COLLECTION-document " +
"cap — the rest of the collection is not being applied"
}
}
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Throwable) {
if (!failure.isPermissionDenied()) throw failure
log.w(throwable = failure) {
"[pull] users/{uid}/$collectionName is not readable — skipping the collection"
}
null
}
/** Plugin failures still propagate — those are local writes, and swallowing one would report a
* clean sync that stored nothing. */
@Suppress("LongParameterList")
private suspend fun applyAll(
plugin: SyncEntityPlugin,
collectionName: String,
docs: List<RemoteDocument>,
scopeKey: String,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): PullSummary {
var downloaded = 0
var conflicts = 0
docs.forEachIndexed { index, doc ->
cancelToken.throwIfCancelled()
val result = plugin.applyDoc(doc, scopeKey)
if (result.applied) downloaded++
if (result.wasConflict) conflicts++
onProgress(
PullProgress(collection = collectionName, current = index + 1, total = docs.size),
)
}
return PullSummary(downloadedCount = downloaded, conflictCount = conflicts)
}
private companion object {
const val TAG = "UserScopedPull"
/**
* Ceiling on one cursorless read. Two orders of magnitude above the roughly ten documents a
* healthy `config` collection holds, so it can only be reached by drift or by a bug —
* which is exactly when an uncapped read would hurt most, since it runs on every foreground
* sync on every device.
*/
const val MAX_DOCS_PER_COLLECTION: Int = 500
val EMPTY_SUMMARY = PullSummary(downloadedCount = 0, conflictCount = 0)
}
}