refactor(syncguard): use published CouchDB catch-up phases

This commit is contained in:
Artem Ovchinnikov committed 2026-10-27 15:34:45 +03:00
1 parent acd3b0ade6
commit 101fe958f5
4 files changed
+134 -87

No files matched your search

@@ -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<boolean>> = [];
const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>> = [];
const priority = vi.fn();
const couchDbCatchUpActivityCount = reactiveSource(0);
const finiteReplicationActivityCount = reactiveSource(0);
const replicationStatics = reactiveSource({ syncStatus: "NOT_CONNECTED" });
const replicationStatics = reactiveSource<ReplicationStatics>({
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();