From 101fe958f5e7b89b9ea01fb18e38962890be205b Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:34:45 +0300 Subject: [PATCH] refactor(syncguard): use published CouchDB catch-up phases --- .../syncGuard/SyncGuardState.ts | 26 +++--- .../syncGuard/SyncGuardState.unit.spec.ts | 61 +++++++------ src/serviceFeatures/syncGuard/index.ts | 48 +++++++---- .../syncGuard/syncGuardFeature.unit.spec.ts | 86 +++++++++++++------ 4 files changed, 134 insertions(+), 87 deletions(-) diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts index 11c4c338..41f9bce0 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -14,7 +14,7 @@ export class SyncGuardState { private phase: SyncGuardPhase = "idle"; private receivedIds = new Set(); private minimumFiles = 20; - private finiteActive = false; + private catchUpActive = false; private observedCompletion = false; private hadReflectionFailure = false; private bypassed = false; @@ -37,7 +37,7 @@ export class SyncGuardState { resume(enabled: boolean, minimumFiles: number): void { this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20; this.receivedIds.clear(); - this.finiteActive = false; + this.catchUpActive = false; this.observedCompletion = false; this.hadReflectionFailure = false; this.bypassed = false; @@ -48,24 +48,24 @@ export class SyncGuardState { /** A changed setting takes effect during observation, without cancelling an active protection. */ updateThreshold(minimumFiles: number): void { this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20; - if (this.phase === "observing" && this.finiteActive && this.receivedIds.size >= this.minimumFiles) { + if (this.phase === "observing" && this.catchUpActive && this.receivedIds.size >= this.minimumFiles) { this.setPhase("transferring"); } } - /** A completed finite pull is expected before the long-running live connection begins. */ - finiteActivityChanged(active: boolean, pendingCount: number, now: number): void { - if (this.phase === "idle" || this.bypassed || this.finiteActive === active) return; + /** The CouchDB catch-up phase ends before continuous replication and Vault reflection finish. */ + catchUpActivityChanged(active: boolean, pendingCount: number, now: number): void { + if (this.phase === "idle" || this.bypassed || this.catchUpActive === active) return; if (active) { // A retry or a second pull can begin while the Vault is still applying an earlier batch. // Do not let the previous successful completion unlock an active newer transfer. - this.finiteActive = true; + this.catchUpActive = true; this.observedCompletion = false; this.quiescentSince = undefined; if (this.isBlocking()) this.setPhase("transferring"); return; } - this.finiteActive = false; + this.catchUpActive = false; if (!this.isBlocking()) { this.setPhase("idle"); } else if (!this.observedCompletion || this.hadReflectionFailure) { @@ -76,10 +76,10 @@ export class SyncGuardState { } } - /** Completion is observed while the finite catch-up is still in flight, not inferred from an idle queue. */ - replicationStatusChanged(status: string): void { - if (this.phase === "idle" || this.bypassed || !this.finiteActive) return; - if (status === "COMPLETED") this.observedCompletion = true; + /** Only the catch-up → continuous transition proves that the initial pull succeeded. */ + catchUpSucceeded(): void { + if (this.phase === "idle" || this.bypassed || !this.catchUpActive) return; + this.observedCompletion = true; } /** A network-complete batch is not a safe unlock when reflecting a received note failed. */ @@ -91,7 +91,7 @@ export class SyncGuardState { /** Count distinct incoming note Metadata, not Chunks or repeated revisions of a note. */ receivedDocuments(ids: readonly string[]): void { - if (!this.finiteActive || this.bypassed || this.phase === "idle") return; + if (!this.catchUpActive || this.bypassed || this.phase === "idle") return; const wasBlocking = this.isBlocking(); const previousCount = this.receivedIds.size; for (const id of ids) { diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts index 8e9c9f5f..14578577 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts @@ -12,25 +12,25 @@ describe("SyncGuardState", () => { it("never locks for a small incoming change batch", () => { const { guard } = setup(); guard.resume(true, 20); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one", "f:two"]); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 10); expect(guard.presentation.phase).toBe("idle"); }); it("counts distinct Metadata rather than repeated changes and waits for local application", () => { const { guard } = setup(); guard.resume(true, 3); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one", "f:one", "f:two"]); expect(guard.presentation.phase).toBe("observing"); guard.receivedDocuments(["f:three"]); expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 3 }); guard.receivedDocuments(["f:four", "f:four"]); expect(guard.presentation.receivedFiles).toBe(4); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 2, 10); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 2, 10); expect(guard.presentation.phase).toBe("applying"); guard.tick(0, 20); guard.tick(1, 2000); @@ -44,10 +44,9 @@ describe("SyncGuardState", () => { it("keeps the lock with an interrupted transfer until the user bypasses it", () => { const { guard } = setup(); guard.resume(true, 2); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one", "f:two"]); - guard.replicationStatusChanged("ERRORED"); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpActivityChanged(false, 0, 10); expect(guard.presentation.phase).toBe("interrupted"); guard.bypass(); expect(guard.presentation.phase).toBe("idle"); @@ -58,30 +57,30 @@ describe("SyncGuardState", () => { it("does not unlock when a new pull starts while applying an earlier completed transfer", () => { const { guard } = setup(); guard.resume(true, 1); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:first"]); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 1); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 1); expect(guard.presentation.phase).toBe("applying"); - guard.finiteActivityChanged(true, 0, 100); + guard.catchUpActivityChanged(true, 0, 100); expect(guard.presentation.phase).toBe("transferring"); guard.tick(0, 10_000); expect(guard.presentation.phase).toBe("transferring"); - guard.finiteActivityChanged(false, 0, 10_001); + guard.catchUpActivityChanged(false, 0, 10_001); expect(guard.presentation.phase).toBe("interrupted"); }); it("can recover an interrupted catch-up when a new pull succeeds", () => { const { guard } = setup(); guard.resume(true, 1); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:first"]); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpActivityChanged(false, 0, 10); expect(guard.presentation.phase).toBe("interrupted"); - guard.finiteActivityChanged(true, 0, 20); + guard.catchUpActivityChanged(true, 0, 20); expect(guard.presentation.phase).toBe("transferring"); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 30); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 30); guard.tick(0, 2030); expect(guard.presentation.phase).toBe("idle"); }); @@ -89,35 +88,35 @@ describe("SyncGuardState", () => { it("does not declare the Vault ready after a failed received-file reflection", () => { const { guard } = setup(); guard.resume(true, 1); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:first"]); guard.reflectionFailed(); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 10); guard.tick(0, 10_000); expect(guard.presentation.phase).toBe("interrupted"); guard.bypass(); expect(guard.presentation.phase).toBe("idle"); }); - it("does not confuse a stale completed status with this catch-up", () => { + it("does not treat a stale completion as success for a new catch-up", () => { const { guard } = setup(); guard.resume(true, 1); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one"]); - guard.finiteActivityChanged(false, 0, 1); + guard.catchUpActivityChanged(false, 0, 1); expect(guard.presentation.phase).toBe("interrupted"); }); it("resets after resume and is opt-in", () => { const { guard } = setup(); guard.resume(false, 1); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("idle"); guard.resume(true, 1); - guard.finiteActivityChanged(true, 0, 10); + guard.catchUpActivityChanged(true, 0, 10); guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("transferring"); guard.disable(); @@ -127,11 +126,11 @@ describe("SyncGuardState", () => { it("handles NaN and negative thresholds conservatively", () => { const { guard } = setup(); guard.resume(true, NaN); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("observing"); guard.resume(true, -2); - guard.finiteActivityChanged(true, 0, 10); + guard.catchUpActivityChanged(true, 0, 10); guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("transferring"); }); @@ -139,7 +138,7 @@ describe("SyncGuardState", () => { it("applies a lowered threshold while observing without unlocking an already protected Vault", () => { const { guard } = setup(); guard.resume(true, 20); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one", "f:two", "f:three"]); guard.updateThreshold(3); expect(guard.presentation.phase).toBe("transferring"); diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 379de0d1..93b1f919 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -17,6 +17,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard const { services } = host; let disposed = false; let quietTimer: number | undefined; + let terminalPhaseTimer: number | undefined; const readPendingCount = () => { const counts = [ services.replication.replicationResultCount.value, @@ -36,6 +37,10 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard if (quietTimer !== undefined) compatGlobal.clearTimeout(quietTimer); quietTimer = undefined; }; + const stopTerminalPhaseTimer = () => { + if (terminalPhaseTimer !== undefined) compatGlobal.clearTimeout(terminalPhaseTimer); + terminalPhaseTimer = undefined; + }; let guard: SyncGuardState; const overlay = createOverlay(() => guard.bypass()); @@ -60,6 +65,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard return settings.isConfigured && settings.liveSync && settings.remoteType === REMOTE_COUCHDB && settings.syncGuardEnabled; }; + const isCouchDbCatchUp = () => services.replicator.replicationStatics.value.couchDB?.phase === "catch-up"; const beginCatchUp = () => { const settings = services.setting.currentSettings(); if (!isEnabled()) { @@ -68,7 +74,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard } // A duplicate resume in the same active catch-up must honour a user's // explicit decision to continue editing rather than silently re-lock. - if (guard.wasBypassed && services.replicator.couchDbCatchUpActivityCount.value > 0) { + if (guard.wasBypassed && isCouchDbCatchUp()) { return; } // Duplicate resume notifications or a visibility transition must not discard an @@ -77,22 +83,36 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard guard.presentation.phase === "interrupted") { return; } - if (guard.presentation.phase === "observing" && - services.replicator.couchDbCatchUpActivityCount.value > 0) { + if (guard.presentation.phase === "observing" && isCouchDbCatchUp()) { return; } guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles); - guard.finiteActivityChanged(services.replicator.couchDbCatchUpActivityCount.value > 0, readPendingCount(), Date.now()); - }; - const onFiniteChanged = () => { - const active = services.replicator.couchDbCatchUpActivityCount.value > 0; - // Continuous replication can restart its finite pull following an internal reconnect, - // without an Obsidian onResumed notification. A new bounded transfer gets its own window. - if (active && guard.presentation.phase === "idle" && !guard.wasBypassed && isEnabled()) beginCatchUp(); - guard.finiteActivityChanged(active, readPendingCount(), Date.now()); + guard.catchUpActivityChanged(isCouchDbCatchUp(), readPendingCount(), Date.now()); }; const onReplicationChanged = () => { - guard.replicationStatusChanged(services.replicator.replicationStatics.value.syncStatus); + const phase = services.replicator.replicationStatics.value.couchDB?.phase; + if (phase === "catch-up") { + stopTerminalPhaseTimer(); + // Also arm after an internal CouchDB reconnect without an Obsidian resume event. + if (guard.presentation.phase === "idle" && !guard.wasBypassed && isEnabled()) beginCatchUp(); + guard.catchUpActivityChanged(true, readPendingCount(), Date.now()); + } else if (phase === "continuous") { + stopTerminalPhaseTimer(); + // Commonlib enters continuous only after a successful pull-only catch-up. + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, readPendingCount(), Date.now()); + } else if (guard.presentation.phase !== "idle" && terminalPhaseTimer === undefined) { + // PouchDB disposes its per-attempt progress observer with phase 'idle' just + // before Commonlib publishes 'continuous'. Defer the terminal observation by + // one event-loop turn so that this internal hand-off never looks like a failure. + terminalPhaseTimer = compatGlobal.setTimeout(() => { + terminalPhaseTimer = undefined; + const current = services.replicator.replicationStatics.value.couchDB?.phase; + if (!disposed && current !== "catch-up" && current !== "continuous") { + guard.catchUpActivityChanged(false, readPendingCount(), Date.now()); + } + }, 0); + } }; const onSettingsChanged = () => { if (!isEnabled()) guard.disable(); @@ -106,7 +126,6 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard onSettingsChanged(); }); - const finite = services.replicator.couchDbCatchUpActivityCount; const replication = services.replicator.replicationStatics; const pendingSources = [ services.replication.replicationResultCount, @@ -119,7 +138,6 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard collectingChunks, chunkFetchCounts, ]; - finite.onChanged(onFiniteChanged); replication.onChanged(onReplicationChanged); for (const source of pendingSources) source.onChanged(onPendingChanged); @@ -144,7 +162,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard services.appLifecycle.onUnload.addHandler(() => { disposed = true; stopTimer(); - finite.offChanged(onFiniteChanged); + stopTerminalPhaseTimer(); replication.offChanged(onReplicationChanged); for (const source of pendingSources) source.offChanged(onPendingChanged); overlay.dispose(); diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index a5a82230..bf8d5b4b 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -1,5 +1,6 @@ import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; import { REMOTE_COUCHDB, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { CouchDBReplicationPhase, ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events"; import { describe, afterEach, expect, it, vi } from "vitest"; @@ -28,9 +29,19 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { const onSettingRealised: Array<() => Promise> = []; const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument[]) => Promise> = []; const priority = vi.fn(); - const couchDbCatchUpActivityCount = reactiveSource(0); const finiteReplicationActivityCount = reactiveSource(0); - const replicationStatics = reactiveSource({ syncStatus: "NOT_CONNECTED" }); + const replicationStatics = reactiveSource({ + sent: 0, + arrived: 0, + maxPullSeq: 0, + maxPushSeq: 0, + lastSyncPullSeq: 0, + lastSyncPushSeq: 0, + syncStatus: "NOT_CONNECTED", + }); + const setCouchDbPhase = (phase: CouchDBReplicationPhase) => { + replicationStatics.value = { ...replicationStatics.value, couchDB: { phase } }; + }; const replicationResultCount = reactiveSource(0); const databaseQueueCount = reactiveSource(0); const storageApplyingCount = reactiveSource(0); @@ -62,7 +73,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { }, }, }, - replicator: { couchDbCatchUpActivityCount, finiteReplicationActivityCount, replicationStatics }, + replicator: { finiteReplicationActivityCount, replicationStatics }, fileProcessing: { processing, totalQueued, batched }, conflict: { conflictProcessQueueCount }, appLifecycle: { @@ -93,7 +104,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { onSettingRealised, parseHandlers, priority, - couchDbCatchUpActivityCount, + setCouchDbPhase, finiteReplicationActivityCount, replicationStatics, replicationResultCount, @@ -115,12 +126,12 @@ describe("useSyncGuardFeature", () => { const h = setup(); expect(h.priority).toHaveBeenCalledWith(-100); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); h.databaseQueueCount.value = 1; - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); vi.advanceTimersByTime(5_000); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); @@ -131,6 +142,23 @@ describe("useSyncGuardFeature", () => { expect(h.dispose).toHaveBeenCalledOnce(); }); + it("does not mistake a transient progress-observer idle for a failed catch-up", async () => { + vi.useFakeTimers(); + const h = setup({ minimumFiles: 1 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("first")]); + h.databaseQueueCount.value = 1; + h.setCouchDbPhase("idle"); // PouchDB's per-attempt observer is disposed before the continuous hand-off. + h.setCouchDbPhase("continuous"); + await vi.advanceTimersByTimeAsync(1); + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + h.databaseQueueCount.value = 0; + await vi.advanceTimersByTimeAsync(2_150); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.onUnload[0](); + }); + it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); @@ -139,7 +167,7 @@ describe("useSyncGuardFeature", () => { h.finiteReplicationActivityCount.value = 0; expect(h.updates.some((p) => p.phase === "transferring" || p.phase === "interrupted")).toBe(false); // A real CouchDB catch-up must still arm normally in the same session. - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("db-first"), receivedNote("db-second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); await h.onUnload[0](); @@ -148,18 +176,20 @@ describe("useSyncGuardFeature", () => { it("never blocks normal transfers and ignores chunks and duplicate note revisions", async () => { const h = setup({ minimumFiles: 3 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("first"), { _id: "h:chunk", _rev: "1-a", type: "leaf" } as never]); expect(h.updates.some((p) => p.phase === "transferring")).toBe(false); await h.onUnload[0](); }); it("preserves offline access through manual bypass and never re-locks that catch-up", async () => { + vi.useFakeTimers(); const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first")]); - h.couchDbCatchUpActivityCount.value = 0; + h.setCouchDbPhase("idle"); + await vi.advanceTimersByTimeAsync(1); expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); h.bypass(); expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); @@ -171,14 +201,14 @@ describe("useSyncGuardFeature", () => { it("never shows a guard when disabled and releases an active guard when the setting is disabled", async () => { const h = setup({ enabled: false, minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first")]); expect(h.updates.some((p) => p.phase === "transferring")).toBe(false); await h.onUnload[0](); const enabled = setup({ minimumFiles: 1 }); await enabled.onResumed[0](); - enabled.couchDbCatchUpActivityCount.value = 1; + enabled.setCouchDbPhase("catch-up"); await enabled.parseHandlers[0]([receivedNote("first")]); expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("transferring"); enabled.settings.syncGuardEnabled = false; @@ -189,11 +219,11 @@ describe("useSyncGuardFeature", () => { it("observes an internal LiveSync reconnect even without an application resume notification", async () => { const h = setup({ minimumFiles: 1 }); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("after-reconnect")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); await h.onUnload[0](); }); @@ -201,13 +231,13 @@ describe("useSyncGuardFeature", () => { it("preserves an active guard across duplicate resume notifications", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); await h.onResumed[0](); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); await h.onUnload[0](); }); @@ -215,7 +245,7 @@ describe("useSyncGuardFeature", () => { it("preserves accumulated files before the threshold when resume fires twice during one pull", async () => { const h = setup({ minimumFiles: 3 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); await h.onResumed[0](); await h.parseHandlers[0]([receivedNote("three")]); @@ -229,11 +259,11 @@ describe("useSyncGuardFeature", () => { it("keeps the guard after a received note could not be written to the Vault", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("failed")]); h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED); - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); await h.onUnload[0](); }); @@ -241,7 +271,7 @@ describe("useSyncGuardFeature", () => { it("responds immediately to settings saved by the UI without setting-realised", async () => { const h = setup({ minimumFiles: 20 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("observing"); h.settings.syncGuardMinimumFiles = 2; @@ -256,12 +286,12 @@ describe("useSyncGuardFeature", () => { it("does not re-lock after manual bypass when an internal reconnect starts", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); h.bypass(); - h.couchDbCatchUpActivityCount.value = 0; - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("continuous"); + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); await h.onUnload[0](); @@ -270,7 +300,7 @@ describe("useSyncGuardFeature", () => { it("preserves manual bypass across duplicate resume while the same CouchDB pull remains active", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); h.bypass();