Coverage Summary for Class: PullRemoteChangesUseCaseImpl (com.georgeci.moneysurfer.data.sync)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| PullRemoteChangesUseCaseImpl |
100%
(15/15)
|
87.2%
(75/86)
|
93.5%
(172/184)
|
95.8%
(1623/1694)
|
| PullRemoteChangesUseCaseImpl$applyBatch$1 |
|
| PullRemoteChangesUseCaseImpl$Companion |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/1)
|
| PullRemoteChangesUseCaseImpl$invoke$1 |
|
| PullRemoteChangesUseCaseImpl$pluginsInScope$$inlined$sortedBy$1 |
0%
(0/1)
|
|
| PullRemoteChangesUseCaseImpl$pullBatches$1 |
|
| PullRemoteChangesUseCaseImpl$pullCollection$2 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(28/28)
|
| PullRemoteChangesUseCaseImpl$pullCollection$2$1 |
100%
(1/1)
|
|
100%
(5/5)
|
100%
(19/19)
|
| PullRemoteChangesUseCaseImpl$pullInvitedWorkspaces$1 |
|
| PullRemoteChangesUseCaseImpl$pullInvitesForUser$2 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(28/28)
|
| PullRemoteChangesUseCaseImpl$pullInvitesForUser$2$1 |
100%
(1/1)
|
|
100%
(5/5)
|
100%
(19/19)
|
| PullRemoteChangesUseCaseImpl$pullMemberWorkspaces$1 |
|
| PullRemoteChangesUseCaseImpl$pullOne$1 |
|
| PullRemoteChangesUseCaseImpl$pullOne$collectionName$1$doc$1 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(23/23)
|
| PullRemoteChangesUseCaseImpl$pullWorkspace$1 |
|
| PullRemoteChangesUseCaseImpl$readRemote$1 |
|
| PullRemoteChangesUseCaseImpl$WhenMappings |
|
| PullRemoteChangesUseCaseImpl$workspaceIdsForScope$1 |
|
| Total |
90.9%
(20/22)
|
87.2%
(75/86)
|
93.4%
(185/198)
|
96%
(1740/1812)
|
package com.georgeci.moneysurfer.data.sync
import arrow.core.raise.Raise
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.PullRemoteChangesUseCase
import com.georgeci.moneysurfer.domain.sync.PullSummary
import com.georgeci.moneysurfer.sync.api.SyncCancelToken
import com.georgeci.moneysurfer.sync.api.SyncCollection
import com.georgeci.moneysurfer.sync.api.SyncError
import com.georgeci.moneysurfer.sync.api.SyncResult
import com.georgeci.moneysurfer.sync.api.SyncScope
import com.georgeci.moneysurfer.sync.plugin.EntityApplyResult
import com.georgeci.moneysurfer.sync.plugin.PullScope
import com.georgeci.moneysurfer.sync.plugin.RemoteDocument
import com.georgeci.moneysurfer.sync.plugin.SyncEntityPlugin
import com.georgeci.moneysurfer.sync.repository.SyncMetaRepository
import kotlinx.coroutines.flow.first
import org.koin.core.annotation.Single
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Cursor-based incremental pull. For each workspace in scope and each plugin that exposes a
* [SyncEntityPlugin.firestoreCollectionName]:
* 1. read cursor from [SyncMetaRepository]
* 2. query `WHERE updatedAt > cursor ORDER BY updatedAt LIMIT BATCH` via [WorkspaceCollectionReader]
* 3. dispatch each doc to the plugin's [SyncEntityPlugin.applyDoc]
* 4. advance the cursor to `max(updatedAt)` of the batch
* 5. repeat from (2) while the batch came back full, so one pull drains the collection instead
* of leaving the tail to the next sync cycle
*
* Scope determines which workspaces are pulled:
* - [SyncScope.ActiveWorkspace] → current workspace only (`session.currentWorkspaceId`)
* - [SyncScope.AllUserData] / [SyncScope.ChangedSinceLastSync] → all workspaces from
* [UserWorkspacesProvider] (fetches `users/{uid}.workspaceIds` from remote)
* - [SyncScope.UploadOnly] → no pull
*
* Members and invites are pulled first so membership rows exist before
* queries that gate on them.
*
* Phase 3 (every scope that pulls at all): collections under `users/{uid}` rather than under a
* workspace — the per-user settings. Cursorless and read whole; it lives in [UserScopedPullPhase],
* which shares no state with the workspace phases.
*
* Phase 2 ([SyncScope.AllUserData] / [SyncScope.ChangedSinceLastSync] only): pulls the
* `invites` collection from workspaces listed in `users/{uid}.invitedWorkspaceIds` — workspaces
* the user has been invited to but has not yet joined. This replaces the previous
* `collectionGroup("invites")` approach in [IncomingInviteRemoteRepository].
*
* A workspace whose *remote reads* fail is logged and skipped rather than failing the pull: a
* `users/{uid}.workspaceIds` entry can point at a workspace this user cannot read (a stale ref
* left behind by an aborted create), and one such entry used to make sign-in impossible for the
* whole account (issue #342). Plugin failures are a different matter — those are local writes,
* and they still abort.
*/
@Single(binds = [PullRemoteChangesUseCase::class])
class PullRemoteChangesUseCaseImpl(
private val collectionReader: WorkspaceCollectionReader,
private val userScopedPull: UserScopedPullPhase,
private val syncMeta: SyncMetaRepository,
private val plugins: List<SyncEntityPlugin>,
private val session: SessionPointers,
private val userWorkspacesProvider: UserWorkspacesProvider,
) : PullRemoteChangesUseCase {
private val log = Logger.withTag("PullRemoteChanges")
override suspend fun invoke(
scope: SyncScope,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): SyncResult<PullSummary> = either {
val memberWorkspaces = pullMemberWorkspaces(
workspaceIds = workspaceIdsForScope(scope),
scope = scope,
onProgress = onProgress,
cancelToken = cancelToken,
)
val invited = pullInvitedWorkspaces(
scope = scope,
onProgress = onProgress,
cancelToken = cancelToken,
)
val userScoped = userScopedPull(
scope = scope,
onProgress = onProgress,
cancelToken = cancelToken,
).bind()
PullSummary(
downloadedCount = memberWorkspaces.downloadedCount +
invited.downloadedCount +
userScoped.downloadedCount,
conflictCount = memberWorkspaces.conflictCount +
invited.conflictCount +
userScoped.conflictCount,
)
}
/** Phase 1: every workspace the user is a member of, in [SyncEntityPlugin.pullPriority] order. */
private suspend fun Raise<SyncError>.pullMemberWorkspaces(
workspaceIds: List<String>,
scope: SyncScope,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): PullSummary {
var totalDownloaded = 0
var totalConflicts = 0
for (workspaceId in workspaceIds) {
val summary = pullWorkspace(
workspaceId = workspaceId,
scope = scope,
onProgress = onProgress,
cancelToken = cancelToken,
)
totalDownloaded += summary.downloadedCount
totalConflicts += summary.conflictCount
}
return PullSummary(downloadedCount = totalDownloaded, conflictCount = totalConflicts)
}
/**
* Runs every in-scope plugin against one workspace. Stops early — keeping whatever already
* landed — when the workspace turns out to be unreadable, so a single stale entry in
* `users/{uid}.workspaceIds` cannot block sign-in for the whole account.
*/
private suspend fun Raise<SyncError>.pullWorkspace(
workspaceId: String,
scope: SyncScope,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): PullSummary {
val pluginSummaries = mutableListOf<PullSummary>()
for (plugin in pluginsInScope(scope)) {
cancelToken.throwIfCancelled()
val collectionName = plugin.firestoreCollectionName
val (downloaded, conflicts) = try {
pullOne(
workspaceId = workspaceId,
plugin = plugin,
onProgress = onProgress,
cancelToken = cancelToken,
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (unreadable: WorkspaceUnreadableException) {
log.w(throwable = unreadable.cause) {
"[pull] wid=$workspaceId unreadable at " +
"collection=${collectionName ?: "root"} — treating it as a stale " +
"workspaceIds ref and skipping the workspace"
}
break
} catch (
@Suppress("TooGenericExceptionCaught")
failure: Throwable,
) {
log.e(throwable = failure) {
"[pull] failed entity=${plugin.entityType} " +
"collection=${collectionName ?: "root"} wid=$workspaceId"
}
raise(failure.toSyncError())
}
pluginSummaries += PullSummary(downloadedCount = downloaded, conflictCount = conflicts)
}
return PullSummary(
downloadedCount = pluginSummaries.sumOf(PullSummary::downloadedCount),
conflictCount = pluginSummaries.sumOf(PullSummary::conflictCount),
)
}
/**
* One plugin against one workspace. A root-doc plugin
* ([SyncEntityPlugin.firestoreCollectionName] == null) owns the `workspaces/{wid}` document
* itself and is applied directly, which is what guarantees the local `WorkspaceEntity` exists
* before subcollection plugins run — `AccountEntity` / `CategoryEntity` / `TransactionEntity`
* all carry a Room FK `workspaceId → WorkspaceEntity` (SQLite error 787 otherwise).
*/
private suspend fun pullOne(
workspaceId: String,
plugin: SyncEntityPlugin,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): Pair<Int, Int> {
val collectionName = plugin.firestoreCollectionName
?: run {
val doc = readRemote(workspaceId) { collectionReader.fetchWorkspaceDoc(workspaceId) }
val applied = doc != null && plugin.applyDoc(doc, workspaceId).applied
return if (applied) 1 to 0 else 0 to 0
}
return pullCollection(
workspaceId = workspaceId,
plugin = plugin,
collectionName = collectionName,
onProgress = onProgress,
cancelToken = cancelToken,
)
}
/**
* Phase 2: pull the `invites` collection from workspaces the user is invited to but has not
* yet joined. Only for scopes that do a full user-data fetch.
*
* Uses [WorkspaceCollectionReader.fetchInvitesForUser] (which adds a `targetUserId` filter)
* because the caller is not yet a workspace member and a general list query would be
* PERMISSION_DENIED.
*/
private suspend fun Raise<SyncError>.pullInvitedWorkspaces(
scope: SyncScope,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): PullSummary {
val fullFetch = scope == SyncScope.AllUserData || scope == SyncScope.ChangedSinceLastSync
val uid = session.currentFirebaseUid.first()
val invitePlugin = plugins.firstOrNull {
it.firestoreCollectionName == SyncCollection.WORKSPACE_INVITES
}
if (!fullFetch || uid == null || invitePlugin == null) return EMPTY_SUMMARY
var totalDownloaded = 0
var totalConflicts = 0
for (workspaceId in userWorkspacesProvider.invitedWorkspaceIds()) {
cancelToken.throwIfCancelled()
val (downloaded, conflicts) = try {
pullInvitesForUser(
workspaceId = workspaceId,
uid = uid,
plugin = invitePlugin,
onProgress = onProgress,
cancelToken = cancelToken,
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (unreadable: WorkspaceUnreadableException) {
log.w(throwable = unreadable.cause) {
"[pull] invited wid=$workspaceId unreadable — skipping (stale invited ref)"
}
continue
} catch (
@Suppress("TooGenericExceptionCaught")
failure: Throwable,
) {
log.e(throwable = failure) { "[pull] failed (phase2/invites) wid=$workspaceId" }
raise(failure.toSyncError())
}
totalDownloaded += downloaded
totalConflicts += conflicts
}
return PullSummary(downloadedCount = totalDownloaded, conflictCount = totalConflicts)
}
private suspend fun workspaceIdsForScope(scope: SyncScope): List<String> = when (scope) {
SyncScope.UploadOnly -> emptyList()
SyncScope.ActiveWorkspace -> {
session.currentWorkspaceId.first()?.value
?.let { listOf(it) } ?: emptyList()
}
SyncScope.AllUserData -> userWorkspacesProvider.workspaceIds()
SyncScope.ChangedSinceLastSync -> {
val ids = userWorkspacesProvider.workspaceIds()
ids.ifEmpty {
// Fallback for incremental sync: provider returned nothing (e.g. first
// launch before workspaceIds has been pushed to Firestore). Use the
// locally-known active workspace so at least one workspace syncs.
// NOT used for AllUserData — a full sync should only pull what the
// remote says the user owns; using currentWorkspaceId for AllUserData
// causes PERMISSION_DENIED when the user is only an invitee of that wid.
session.currentWorkspaceId.first()?.value
?.let { listOf(it) } ?: emptyList()
}
}
}
private suspend fun pullCollection(
workspaceId: String,
plugin: SyncEntityPlugin,
collectionName: String,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): Pair<Int, Int> = pullBatches(
workspaceId = workspaceId,
collectionName = collectionName,
plugin = plugin,
onProgress = onProgress,
cancelToken = cancelToken,
) { sinceMillis ->
readRemote(workspaceId) {
collectionReader.fetchUpdatedSince(
workspaceId = workspaceId,
collectionName = collectionName,
sinceMillis = sinceMillis,
limit = BATCH_SIZE,
)
}
}
/**
* Pulls invites from a workspace the caller is invited to but has not joined.
* Uses [WorkspaceCollectionReader.fetchInvitesForUser] which adds a `targetUserId == uid`
* filter, making the query satisfy Firestore rules for non-members.
*/
private suspend fun pullInvitesForUser(
workspaceId: String,
uid: String,
plugin: SyncEntityPlugin,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): Pair<Int, Int> = pullBatches(
workspaceId = workspaceId,
collectionName = SyncCollection.WORKSPACE_INVITES,
plugin = plugin,
onProgress = onProgress,
cancelToken = cancelToken,
) { sinceMillis ->
readRemote(workspaceId) {
collectionReader.fetchInvitesForUser(
workspaceId = workspaceId,
uid = uid,
sinceMillis = sinceMillis,
limit = BATCH_SIZE,
)
}
}
/**
* Cursor-based paged pull shared by [pullCollection] and [pullInvitesForUser]: reads the
* cursor, then repeatedly runs [fetch] for everything updated since it, applies each doc via
* the [plugin] and advances the cursor to `max(updatedAt)` — until a batch comes back short
* of [BATCH_SIZE]. Only the query differs between callers, so it is supplied as [fetch].
*
* Draining here rather than leaving the tail to the next sync cycle is what makes a fresh
* sign-in show correct balances: the background ticker only fires once a minute, so a
* single-batch pull left an account with a few thousand transactions wrong for tens of
* minutes (issue #342).
*
* Two independent stops guard against an endless loop: [MAX_BATCHES_PER_COLLECTION], and a
* cursor that failed to advance — which happens when more than [BATCH_SIZE] documents share
* one `updatedAt` millisecond, since the query is a strict `>` on the cursor.
*
* @return `downloaded to conflicts` across every batch.
*/
@Suppress("LongParameterList")
private suspend fun pullBatches(
workspaceId: String,
collectionName: String,
plugin: SyncEntityPlugin,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
fetch: suspend (sinceMillis: Long) -> List<RemoteDocument>,
): Pair<Int, Int> {
syncMeta.markAttempt(workspaceId, collectionName, Clock.System.now())
var cursorMillis = syncMeta.cursor(workspaceId, collectionName)?.toEpochMilliseconds() ?: 0L
var downloaded = 0
var conflicts = 0
var batches = 0
var draining = true
while (draining) {
cancelToken.throwIfCancelled()
val docs = fetch(cursorMillis)
val batch = applyBatch(
workspaceId = workspaceId,
collectionName = collectionName,
plugin = plugin,
docs = docs,
cursorMillis = cursorMillis,
onProgress = onProgress,
cancelToken = cancelToken,
)
downloaded += batch.downloaded
conflicts += batch.conflicts
batches++
val cursorAdvanced = batch.maxUpdatedAt > cursorMillis
if (cursorAdvanced) {
cursorMillis = batch.maxUpdatedAt
syncMeta.setCursor(
scopeKey = workspaceId,
collection = collectionName,
cursor = Instant.fromEpochMilliseconds(cursorMillis),
)
}
val full = docs.size == BATCH_SIZE
draining = full && cursorAdvanced && batches < MAX_BATCHES_PER_COLLECTION
logStopReason(workspaceId, collectionName, docs.size, full, cursorAdvanced, batches)
}
syncMeta.markSuccess(workspaceId, collectionName, Clock.System.now())
return downloaded to conflicts
}
/** Applies one batch and reports what it did, including how far the cursor can move. */
@Suppress("LongParameterList")
private suspend fun applyBatch(
workspaceId: String,
collectionName: String,
plugin: SyncEntityPlugin,
docs: List<RemoteDocument>,
cursorMillis: Long,
onProgress: suspend (PullProgress) -> Unit,
cancelToken: SyncCancelToken,
): BatchOutcome {
var downloaded = 0
var conflicts = 0
var maxUpdatedAt = cursorMillis
docs.forEachIndexed { index, doc ->
cancelToken.throwIfCancelled()
val result: EntityApplyResult = plugin.applyDoc(doc, workspaceId)
if (result.applied) downloaded++
if (result.wasConflict) conflicts++
val docUpdatedAt = doc.getLong("updatedAt") ?: 0L
if (docUpdatedAt > maxUpdatedAt) maxUpdatedAt = docUpdatedAt
onProgress(
PullProgress(collection = collectionName, current = index + 1, total = docs.size),
)
}
return BatchOutcome(downloaded = downloaded, conflicts = conflicts, maxUpdatedAt = maxUpdatedAt)
}
/** Notes the two ways a full batch can stop the drain early; a short batch is the normal end. */
@Suppress("LongParameterList")
private fun logStopReason(
workspaceId: String,
collectionName: String,
batchSize: Int,
full: Boolean,
cursorAdvanced: Boolean,
batches: Int,
) {
if (full && !cursorAdvanced) {
log.w {
"[pull] cursor stuck for wid=$workspaceId collection=$collectionName — all " +
"$batchSize docs in the batch carry the same (or no) updatedAt; stopping to " +
"avoid re-reading the same batch forever"
}
}
if (full && cursorAdvanced && batches >= MAX_BATCHES_PER_COLLECTION) {
log.w {
"[pull] hit the $MAX_BATCHES_PER_COLLECTION-batch ceiling for wid=$workspaceId " +
"collection=$collectionName — the rest is left to the next sync, which " +
"resumes from the stored cursor"
}
}
}
/**
* Runs a remote read. A **denial** is re-thrown as [WorkspaceUnreadableException] so callers
* can skip the workspace; everything else propagates untouched and still aborts the pull.
*
* The distinction matters: a denied read means this workspace will never be readable for this
* user, so skipping it is the whole point. A network drop or an expired token means the data
* is simply not here *yet* — swallowing those would let the pull report a successful sync that
* downloaded nothing, and the UI would tell the user they are up to date when they are not.
*/
private suspend fun <T> readRemote(workspaceId: String, read: suspend () -> T): T = try {
read()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (
@Suppress("TooGenericExceptionCaught")
failure: Throwable,
) {
if (failure.isPermissionDenied()) {
throw WorkspaceUnreadableException(workspaceId, failure)
}
throw failure
}
/**
* Returns the workspace-scoped plugins in pull order ([SyncEntityPlugin.pullPriority], lower
* first).
*
* Includes plugins with [SyncEntityPlugin.firestoreCollectionName] == null — those
* own workspace root documents and are dispatched via
* [WorkspaceCollectionReader.fetchWorkspaceDoc] rather than [pullCollection].
* See [SyncEntityPlugin] kdoc for tier assignments.
*
* [PullScope.User] plugins are filtered out here and run once in phase 3 instead: this loop
* runs per workspace, and their documents do not live under a workspace at all.
*/
private fun pluginsInScope(scope: SyncScope): List<SyncEntityPlugin> {
if (scope == SyncScope.UploadOnly) return emptyList()
return plugins
.filter { it.pullScope == PullScope.Workspace }
.sortedBy { it.pullPriority }
}
private companion object {
const val BATCH_SIZE: Int = 100
/**
* Ceiling on batches per collection per workspace per sync — 50 × 100 = 5 000 documents.
* Not a correctness bound (the cursor is persisted, so the next sync resumes where this
* one stopped); it just keeps one pathological collection from monopolising a sync.
*/
const val MAX_BATCHES_PER_COLLECTION: Int = 50
val EMPTY_SUMMARY = PullSummary(downloadedCount = 0, conflictCount = 0)
}
}
/** What one batch applied, and the newest `updatedAt` it saw. */
private data class BatchOutcome(
val downloaded: Int,
val conflicts: Int,
val maxUpdatedAt: Long,
)
/**
* A workspace whose remote documents could not be read — typically PERMISSION_DENIED on a
* `users/{uid}.workspaceIds` entry whose `workspaces/{wid}` document was never created. Private
* to the pull: it never escapes [PullRemoteChangesUseCaseImpl].
*/
private class WorkspaceUnreadableException(
workspaceId: String,
override val cause: Throwable,
) : RuntimeException("Workspace $workspaceId is not readable", cause)