mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-31 13:28:18 +00:00
refactor(syncguard): use published CouchDB catch-up phases
This commit is contained in:
1 parent
acd3b0ade6
commit
101fe958f5
4 files changed
+134
-87
No files matched your search
@@ -14,7 +14,7 @@ export class SyncGuardState {
|
|||||||
private phase: SyncGuardPhase = "idle";
|
private phase: SyncGuardPhase = "idle";
|
||||||
private receivedIds = new Set<string>();
|
private receivedIds = new Set<string>();
|
||||||
private minimumFiles = 20;
|
private minimumFiles = 20;
|
||||||
private finiteActive = false;
|
private catchUpActive = false;
|
||||||
private observedCompletion = false;
|
private observedCompletion = false;
|
||||||
private hadReflectionFailure = false;
|
private hadReflectionFailure = false;
|
||||||
private bypassed = false;
|
private bypassed = false;
|
||||||
@@ -37,7 +37,7 @@ export class SyncGuardState {
|
|||||||
resume(enabled: boolean, minimumFiles: number): void {
|
resume(enabled: boolean, minimumFiles: number): void {
|
||||||
this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20;
|
this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20;
|
||||||
this.receivedIds.clear();
|
this.receivedIds.clear();
|
||||||
this.finiteActive = false;
|
this.catchUpActive = false;
|
||||||
this.observedCompletion = false;
|
this.observedCompletion = false;
|
||||||
this.hadReflectionFailure = false;
|
this.hadReflectionFailure = false;
|
||||||
this.bypassed = false;
|
this.bypassed = false;
|
||||||
@@ -48,24 +48,24 @@ export class SyncGuardState {
|
|||||||
/** A changed setting takes effect during observation, without cancelling an active protection. */
|
/** A changed setting takes effect during observation, without cancelling an active protection. */
|
||||||
updateThreshold(minimumFiles: number): void {
|
updateThreshold(minimumFiles: number): void {
|
||||||
this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20;
|
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");
|
this.setPhase("transferring");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A completed finite pull is expected before the long-running live connection begins. */
|
/** The CouchDB catch-up phase ends before continuous replication and Vault reflection finish. */
|
||||||
finiteActivityChanged(active: boolean, pendingCount: number, now: number): void {
|
catchUpActivityChanged(active: boolean, pendingCount: number, now: number): void {
|
||||||
if (this.phase === "idle" || this.bypassed || this.finiteActive === active) return;
|
if (this.phase === "idle" || this.bypassed || this.catchUpActive === active) return;
|
||||||
if (active) {
|
if (active) {
|
||||||
// A retry or a second pull can begin while the Vault is still applying an earlier batch.
|
// 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.
|
// Do not let the previous successful completion unlock an active newer transfer.
|
||||||
this.finiteActive = true;
|
this.catchUpActive = true;
|
||||||
this.observedCompletion = false;
|
this.observedCompletion = false;
|
||||||
this.quiescentSince = undefined;
|
this.quiescentSince = undefined;
|
||||||
if (this.isBlocking()) this.setPhase("transferring");
|
if (this.isBlocking()) this.setPhase("transferring");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.finiteActive = false;
|
this.catchUpActive = false;
|
||||||
if (!this.isBlocking()) {
|
if (!this.isBlocking()) {
|
||||||
this.setPhase("idle");
|
this.setPhase("idle");
|
||||||
} else if (!this.observedCompletion || this.hadReflectionFailure) {
|
} 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. */
|
/** Only the catch-up → continuous transition proves that the initial pull succeeded. */
|
||||||
replicationStatusChanged(status: string): void {
|
catchUpSucceeded(): void {
|
||||||
if (this.phase === "idle" || this.bypassed || !this.finiteActive) return;
|
if (this.phase === "idle" || this.bypassed || !this.catchUpActive) return;
|
||||||
if (status === "COMPLETED") this.observedCompletion = true;
|
this.observedCompletion = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A network-complete batch is not a safe unlock when reflecting a received note failed. */
|
/** 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. */
|
/** Count distinct incoming note Metadata, not Chunks or repeated revisions of a note. */
|
||||||
receivedDocuments(ids: readonly string[]): void {
|
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 wasBlocking = this.isBlocking();
|
||||||
const previousCount = this.receivedIds.size;
|
const previousCount = this.receivedIds.size;
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
|||||||
@@ -12,25 +12,25 @@ describe("SyncGuardState", () => {
|
|||||||
it("never locks for a small incoming change batch", () => {
|
it("never locks for a small incoming change batch", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, 20);
|
guard.resume(true, 20);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:one", "f:two"]);
|
guard.receivedDocuments(["f:one", "f:two"]);
|
||||||
guard.replicationStatusChanged("COMPLETED");
|
guard.catchUpSucceeded();
|
||||||
guard.finiteActivityChanged(false, 0, 10);
|
guard.catchUpActivityChanged(false, 0, 10);
|
||||||
expect(guard.presentation.phase).toBe("idle");
|
expect(guard.presentation.phase).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("counts distinct Metadata rather than repeated changes and waits for local application", () => {
|
it("counts distinct Metadata rather than repeated changes and waits for local application", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, 3);
|
guard.resume(true, 3);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:one", "f:one", "f:two"]);
|
guard.receivedDocuments(["f:one", "f:one", "f:two"]);
|
||||||
expect(guard.presentation.phase).toBe("observing");
|
expect(guard.presentation.phase).toBe("observing");
|
||||||
guard.receivedDocuments(["f:three"]);
|
guard.receivedDocuments(["f:three"]);
|
||||||
expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 3 });
|
expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 3 });
|
||||||
guard.receivedDocuments(["f:four", "f:four"]);
|
guard.receivedDocuments(["f:four", "f:four"]);
|
||||||
expect(guard.presentation.receivedFiles).toBe(4);
|
expect(guard.presentation.receivedFiles).toBe(4);
|
||||||
guard.replicationStatusChanged("COMPLETED");
|
guard.catchUpSucceeded();
|
||||||
guard.finiteActivityChanged(false, 2, 10);
|
guard.catchUpActivityChanged(false, 2, 10);
|
||||||
expect(guard.presentation.phase).toBe("applying");
|
expect(guard.presentation.phase).toBe("applying");
|
||||||
guard.tick(0, 20);
|
guard.tick(0, 20);
|
||||||
guard.tick(1, 2000);
|
guard.tick(1, 2000);
|
||||||
@@ -44,10 +44,9 @@ describe("SyncGuardState", () => {
|
|||||||
it("keeps the lock with an interrupted transfer until the user bypasses it", () => {
|
it("keeps the lock with an interrupted transfer until the user bypasses it", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, 2);
|
guard.resume(true, 2);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:one", "f:two"]);
|
guard.receivedDocuments(["f:one", "f:two"]);
|
||||||
guard.replicationStatusChanged("ERRORED");
|
guard.catchUpActivityChanged(false, 0, 10);
|
||||||
guard.finiteActivityChanged(false, 0, 10);
|
|
||||||
expect(guard.presentation.phase).toBe("interrupted");
|
expect(guard.presentation.phase).toBe("interrupted");
|
||||||
guard.bypass();
|
guard.bypass();
|
||||||
expect(guard.presentation.phase).toBe("idle");
|
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", () => {
|
it("does not unlock when a new pull starts while applying an earlier completed transfer", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, 1);
|
guard.resume(true, 1);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:first"]);
|
guard.receivedDocuments(["f:first"]);
|
||||||
guard.replicationStatusChanged("COMPLETED");
|
guard.catchUpSucceeded();
|
||||||
guard.finiteActivityChanged(false, 0, 1);
|
guard.catchUpActivityChanged(false, 0, 1);
|
||||||
expect(guard.presentation.phase).toBe("applying");
|
expect(guard.presentation.phase).toBe("applying");
|
||||||
guard.finiteActivityChanged(true, 0, 100);
|
guard.catchUpActivityChanged(true, 0, 100);
|
||||||
expect(guard.presentation.phase).toBe("transferring");
|
expect(guard.presentation.phase).toBe("transferring");
|
||||||
guard.tick(0, 10_000);
|
guard.tick(0, 10_000);
|
||||||
expect(guard.presentation.phase).toBe("transferring");
|
expect(guard.presentation.phase).toBe("transferring");
|
||||||
guard.finiteActivityChanged(false, 0, 10_001);
|
guard.catchUpActivityChanged(false, 0, 10_001);
|
||||||
expect(guard.presentation.phase).toBe("interrupted");
|
expect(guard.presentation.phase).toBe("interrupted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can recover an interrupted catch-up when a new pull succeeds", () => {
|
it("can recover an interrupted catch-up when a new pull succeeds", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, 1);
|
guard.resume(true, 1);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:first"]);
|
guard.receivedDocuments(["f:first"]);
|
||||||
guard.finiteActivityChanged(false, 0, 10);
|
guard.catchUpActivityChanged(false, 0, 10);
|
||||||
expect(guard.presentation.phase).toBe("interrupted");
|
expect(guard.presentation.phase).toBe("interrupted");
|
||||||
guard.finiteActivityChanged(true, 0, 20);
|
guard.catchUpActivityChanged(true, 0, 20);
|
||||||
expect(guard.presentation.phase).toBe("transferring");
|
expect(guard.presentation.phase).toBe("transferring");
|
||||||
guard.replicationStatusChanged("COMPLETED");
|
guard.catchUpSucceeded();
|
||||||
guard.finiteActivityChanged(false, 0, 30);
|
guard.catchUpActivityChanged(false, 0, 30);
|
||||||
guard.tick(0, 2030);
|
guard.tick(0, 2030);
|
||||||
expect(guard.presentation.phase).toBe("idle");
|
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", () => {
|
it("does not declare the Vault ready after a failed received-file reflection", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, 1);
|
guard.resume(true, 1);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:first"]);
|
guard.receivedDocuments(["f:first"]);
|
||||||
guard.reflectionFailed();
|
guard.reflectionFailed();
|
||||||
guard.replicationStatusChanged("COMPLETED");
|
guard.catchUpSucceeded();
|
||||||
guard.finiteActivityChanged(false, 0, 10);
|
guard.catchUpActivityChanged(false, 0, 10);
|
||||||
guard.tick(0, 10_000);
|
guard.tick(0, 10_000);
|
||||||
expect(guard.presentation.phase).toBe("interrupted");
|
expect(guard.presentation.phase).toBe("interrupted");
|
||||||
guard.bypass();
|
guard.bypass();
|
||||||
expect(guard.presentation.phase).toBe("idle");
|
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();
|
const { guard } = setup();
|
||||||
guard.resume(true, 1);
|
guard.resume(true, 1);
|
||||||
guard.replicationStatusChanged("COMPLETED");
|
guard.catchUpSucceeded();
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:one"]);
|
guard.receivedDocuments(["f:one"]);
|
||||||
guard.finiteActivityChanged(false, 0, 1);
|
guard.catchUpActivityChanged(false, 0, 1);
|
||||||
expect(guard.presentation.phase).toBe("interrupted");
|
expect(guard.presentation.phase).toBe("interrupted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets after resume and is opt-in", () => {
|
it("resets after resume and is opt-in", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(false, 1);
|
guard.resume(false, 1);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:one"]);
|
guard.receivedDocuments(["f:one"]);
|
||||||
expect(guard.presentation.phase).toBe("idle");
|
expect(guard.presentation.phase).toBe("idle");
|
||||||
guard.resume(true, 1);
|
guard.resume(true, 1);
|
||||||
guard.finiteActivityChanged(true, 0, 10);
|
guard.catchUpActivityChanged(true, 0, 10);
|
||||||
guard.receivedDocuments(["f:one"]);
|
guard.receivedDocuments(["f:one"]);
|
||||||
expect(guard.presentation.phase).toBe("transferring");
|
expect(guard.presentation.phase).toBe("transferring");
|
||||||
guard.disable();
|
guard.disable();
|
||||||
@@ -127,11 +126,11 @@ describe("SyncGuardState", () => {
|
|||||||
it("handles NaN and negative thresholds conservatively", () => {
|
it("handles NaN and negative thresholds conservatively", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, NaN);
|
guard.resume(true, NaN);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:one"]);
|
guard.receivedDocuments(["f:one"]);
|
||||||
expect(guard.presentation.phase).toBe("observing");
|
expect(guard.presentation.phase).toBe("observing");
|
||||||
guard.resume(true, -2);
|
guard.resume(true, -2);
|
||||||
guard.finiteActivityChanged(true, 0, 10);
|
guard.catchUpActivityChanged(true, 0, 10);
|
||||||
guard.receivedDocuments(["f:one"]);
|
guard.receivedDocuments(["f:one"]);
|
||||||
expect(guard.presentation.phase).toBe("transferring");
|
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", () => {
|
it("applies a lowered threshold while observing without unlocking an already protected Vault", () => {
|
||||||
const { guard } = setup();
|
const { guard } = setup();
|
||||||
guard.resume(true, 20);
|
guard.resume(true, 20);
|
||||||
guard.finiteActivityChanged(true, 0, 0);
|
guard.catchUpActivityChanged(true, 0, 0);
|
||||||
guard.receivedDocuments(["f:one", "f:two", "f:three"]);
|
guard.receivedDocuments(["f:one", "f:two", "f:three"]);
|
||||||
guard.updateThreshold(3);
|
guard.updateThreshold(3);
|
||||||
expect(guard.presentation.phase).toBe("transferring");
|
expect(guard.presentation.phase).toBe("transferring");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
|
|||||||
const { services } = host;
|
const { services } = host;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let quietTimer: number | undefined;
|
let quietTimer: number | undefined;
|
||||||
|
let terminalPhaseTimer: number | undefined;
|
||||||
const readPendingCount = () => {
|
const readPendingCount = () => {
|
||||||
const counts = [
|
const counts = [
|
||||||
services.replication.replicationResultCount.value,
|
services.replication.replicationResultCount.value,
|
||||||
@@ -36,6 +37,10 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
|
|||||||
if (quietTimer !== undefined) compatGlobal.clearTimeout(quietTimer);
|
if (quietTimer !== undefined) compatGlobal.clearTimeout(quietTimer);
|
||||||
quietTimer = undefined;
|
quietTimer = undefined;
|
||||||
};
|
};
|
||||||
|
const stopTerminalPhaseTimer = () => {
|
||||||
|
if (terminalPhaseTimer !== undefined) compatGlobal.clearTimeout(terminalPhaseTimer);
|
||||||
|
terminalPhaseTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
let guard: SyncGuardState;
|
let guard: SyncGuardState;
|
||||||
const overlay = createOverlay(() => guard.bypass());
|
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 &&
|
return settings.isConfigured && settings.liveSync && settings.remoteType === REMOTE_COUCHDB &&
|
||||||
settings.syncGuardEnabled;
|
settings.syncGuardEnabled;
|
||||||
};
|
};
|
||||||
|
const isCouchDbCatchUp = () => services.replicator.replicationStatics.value.couchDB?.phase === "catch-up";
|
||||||
const beginCatchUp = () => {
|
const beginCatchUp = () => {
|
||||||
const settings = services.setting.currentSettings();
|
const settings = services.setting.currentSettings();
|
||||||
if (!isEnabled()) {
|
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
|
// A duplicate resume in the same active catch-up must honour a user's
|
||||||
// explicit decision to continue editing rather than silently re-lock.
|
// explicit decision to continue editing rather than silently re-lock.
|
||||||
if (guard.wasBypassed && services.replicator.couchDbCatchUpActivityCount.value > 0) {
|
if (guard.wasBypassed && isCouchDbCatchUp()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Duplicate resume notifications or a visibility transition must not discard an
|
// 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") {
|
guard.presentation.phase === "interrupted") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (guard.presentation.phase === "observing" &&
|
if (guard.presentation.phase === "observing" && isCouchDbCatchUp()) {
|
||||||
services.replicator.couchDbCatchUpActivityCount.value > 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles);
|
guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles);
|
||||||
guard.finiteActivityChanged(services.replicator.couchDbCatchUpActivityCount.value > 0, readPendingCount(), Date.now());
|
guard.catchUpActivityChanged(isCouchDbCatchUp(), 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());
|
|
||||||
};
|
};
|
||||||
const onReplicationChanged = () => {
|
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 = () => {
|
const onSettingsChanged = () => {
|
||||||
if (!isEnabled()) guard.disable();
|
if (!isEnabled()) guard.disable();
|
||||||
@@ -106,7 +126,6 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
|
|||||||
onSettingsChanged();
|
onSettingsChanged();
|
||||||
});
|
});
|
||||||
|
|
||||||
const finite = services.replicator.couchDbCatchUpActivityCount;
|
|
||||||
const replication = services.replicator.replicationStatics;
|
const replication = services.replicator.replicationStatics;
|
||||||
const pendingSources = [
|
const pendingSources = [
|
||||||
services.replication.replicationResultCount,
|
services.replication.replicationResultCount,
|
||||||
@@ -119,7 +138,6 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
|
|||||||
collectingChunks,
|
collectingChunks,
|
||||||
chunkFetchCounts,
|
chunkFetchCounts,
|
||||||
];
|
];
|
||||||
finite.onChanged(onFiniteChanged);
|
|
||||||
replication.onChanged(onReplicationChanged);
|
replication.onChanged(onReplicationChanged);
|
||||||
for (const source of pendingSources) source.onChanged(onPendingChanged);
|
for (const source of pendingSources) source.onChanged(onPendingChanged);
|
||||||
|
|
||||||
@@ -144,7 +162,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
|
|||||||
services.appLifecycle.onUnload.addHandler(() => {
|
services.appLifecycle.onUnload.addHandler(() => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
finite.offChanged(onFiniteChanged);
|
stopTerminalPhaseTimer();
|
||||||
replication.offChanged(onReplicationChanged);
|
replication.offChanged(onReplicationChanged);
|
||||||
for (const source of pendingSources) source.offChanged(onPendingChanged);
|
for (const source of pendingSources) source.offChanged(onPendingChanged);
|
||||||
overlay.dispose();
|
overlay.dispose();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||||
import { REMOTE_COUCHDB, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
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 { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events";
|
import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events";
|
||||||
import { describe, afterEach, expect, it, vi } from "vitest";
|
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 onSettingRealised: Array<() => Promise<boolean>> = [];
|
||||||
const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>> = [];
|
const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>> = [];
|
||||||
const priority = vi.fn();
|
const priority = vi.fn();
|
||||||
const couchDbCatchUpActivityCount = reactiveSource(0);
|
|
||||||
const finiteReplicationActivityCount = 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 replicationResultCount = reactiveSource(0);
|
||||||
const databaseQueueCount = reactiveSource(0);
|
const databaseQueueCount = reactiveSource(0);
|
||||||
const storageApplyingCount = 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 },
|
fileProcessing: { processing, totalQueued, batched },
|
||||||
conflict: { conflictProcessQueueCount },
|
conflict: { conflictProcessQueueCount },
|
||||||
appLifecycle: {
|
appLifecycle: {
|
||||||
@@ -93,7 +104,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) {
|
|||||||
onSettingRealised,
|
onSettingRealised,
|
||||||
parseHandlers,
|
parseHandlers,
|
||||||
priority,
|
priority,
|
||||||
couchDbCatchUpActivityCount,
|
setCouchDbPhase,
|
||||||
finiteReplicationActivityCount,
|
finiteReplicationActivityCount,
|
||||||
replicationStatics,
|
replicationStatics,
|
||||||
replicationResultCount,
|
replicationResultCount,
|
||||||
@@ -115,12 +126,12 @@ describe("useSyncGuardFeature", () => {
|
|||||||
const h = setup();
|
const h = setup();
|
||||||
expect(h.priority).toHaveBeenCalledWith(-100);
|
expect(h.priority).toHaveBeenCalledWith(-100);
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||||
h.databaseQueueCount.value = 1;
|
h.databaseQueueCount.value = 1;
|
||||||
h.replicationStatics.value = { syncStatus: "COMPLETED" };
|
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||||
h.couchDbCatchUpActivityCount.value = 0;
|
h.setCouchDbPhase("continuous");
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||||
vi.advanceTimersByTime(5_000);
|
vi.advanceTimersByTime(5_000);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||||
@@ -131,6 +142,23 @@ describe("useSyncGuardFeature", () => {
|
|||||||
expect(h.dispose).toHaveBeenCalledOnce();
|
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 () => {
|
it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => {
|
||||||
const h = setup({ minimumFiles: 2 });
|
const h = setup({ minimumFiles: 2 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
@@ -139,7 +167,7 @@ describe("useSyncGuardFeature", () => {
|
|||||||
h.finiteReplicationActivityCount.value = 0;
|
h.finiteReplicationActivityCount.value = 0;
|
||||||
expect(h.updates.some((p) => p.phase === "transferring" || p.phase === "interrupted")).toBe(false);
|
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.
|
// 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")]);
|
await h.parseHandlers[0]([receivedNote("db-first"), receivedNote("db-second")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||||
await h.onUnload[0]();
|
await h.onUnload[0]();
|
||||||
@@ -148,18 +176,20 @@ describe("useSyncGuardFeature", () => {
|
|||||||
it("never blocks normal transfers and ignores chunks and duplicate note revisions", async () => {
|
it("never blocks normal transfers and ignores chunks and duplicate note revisions", async () => {
|
||||||
const h = setup({ minimumFiles: 3 });
|
const h = setup({ minimumFiles: 3 });
|
||||||
await h.onResumed[0]();
|
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]);
|
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);
|
expect(h.updates.some((p) => p.phase === "transferring")).toBe(false);
|
||||||
await h.onUnload[0]();
|
await h.onUnload[0]();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves offline access through manual bypass and never re-locks that catch-up", async () => {
|
it("preserves offline access through manual bypass and never re-locks that catch-up", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
const h = setup({ minimumFiles: 1 });
|
const h = setup({ minimumFiles: 1 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("first")]);
|
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");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted");
|
||||||
h.bypass();
|
h.bypass();
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
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 () => {
|
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 });
|
const h = setup({ enabled: false, minimumFiles: 1 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("first")]);
|
await h.parseHandlers[0]([receivedNote("first")]);
|
||||||
expect(h.updates.some((p) => p.phase === "transferring")).toBe(false);
|
expect(h.updates.some((p) => p.phase === "transferring")).toBe(false);
|
||||||
await h.onUnload[0]();
|
await h.onUnload[0]();
|
||||||
|
|
||||||
const enabled = setup({ minimumFiles: 1 });
|
const enabled = setup({ minimumFiles: 1 });
|
||||||
await enabled.onResumed[0]();
|
await enabled.onResumed[0]();
|
||||||
enabled.couchDbCatchUpActivityCount.value = 1;
|
enabled.setCouchDbPhase("catch-up");
|
||||||
await enabled.parseHandlers[0]([receivedNote("first")]);
|
await enabled.parseHandlers[0]([receivedNote("first")]);
|
||||||
expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("transferring");
|
expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("transferring");
|
||||||
enabled.settings.syncGuardEnabled = false;
|
enabled.settings.syncGuardEnabled = false;
|
||||||
@@ -189,11 +219,11 @@ describe("useSyncGuardFeature", () => {
|
|||||||
|
|
||||||
it("observes an internal LiveSync reconnect even without an application resume notification", async () => {
|
it("observes an internal LiveSync reconnect even without an application resume notification", async () => {
|
||||||
const h = setup({ minimumFiles: 1 });
|
const h = setup({ minimumFiles: 1 });
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("after-reconnect")]);
|
await h.parseHandlers[0]([receivedNote("after-reconnect")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||||
h.replicationStatics.value = { syncStatus: "COMPLETED" };
|
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||||
h.couchDbCatchUpActivityCount.value = 0;
|
h.setCouchDbPhase("continuous");
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||||
await h.onUnload[0]();
|
await h.onUnload[0]();
|
||||||
});
|
});
|
||||||
@@ -201,13 +231,13 @@ describe("useSyncGuardFeature", () => {
|
|||||||
it("preserves an active guard across duplicate resume notifications", async () => {
|
it("preserves an active guard across duplicate resume notifications", async () => {
|
||||||
const h = setup({ minimumFiles: 2 });
|
const h = setup({ minimumFiles: 2 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||||
h.replicationStatics.value = { syncStatus: "COMPLETED" };
|
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||||
h.couchDbCatchUpActivityCount.value = 0;
|
h.setCouchDbPhase("continuous");
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
|
||||||
await h.onUnload[0]();
|
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 () => {
|
it("preserves accumulated files before the threshold when resume fires twice during one pull", async () => {
|
||||||
const h = setup({ minimumFiles: 3 });
|
const h = setup({ minimumFiles: 3 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]);
|
await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]);
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
await h.parseHandlers[0]([receivedNote("three")]);
|
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 () => {
|
it("keeps the guard after a received note could not be written to the Vault", async () => {
|
||||||
const h = setup({ minimumFiles: 1 });
|
const h = setup({ minimumFiles: 1 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("failed")]);
|
await h.parseHandlers[0]([receivedNote("failed")]);
|
||||||
h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED);
|
h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED);
|
||||||
h.replicationStatics.value = { syncStatus: "COMPLETED" };
|
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
|
||||||
h.couchDbCatchUpActivityCount.value = 0;
|
h.setCouchDbPhase("continuous");
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted");
|
||||||
await h.onUnload[0]();
|
await h.onUnload[0]();
|
||||||
});
|
});
|
||||||
@@ -241,7 +271,7 @@ describe("useSyncGuardFeature", () => {
|
|||||||
it("responds immediately to settings saved by the UI without setting-realised", async () => {
|
it("responds immediately to settings saved by the UI without setting-realised", async () => {
|
||||||
const h = setup({ minimumFiles: 20 });
|
const h = setup({ minimumFiles: 20 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]);
|
await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("observing");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("observing");
|
||||||
h.settings.syncGuardMinimumFiles = 2;
|
h.settings.syncGuardMinimumFiles = 2;
|
||||||
@@ -256,12 +286,12 @@ describe("useSyncGuardFeature", () => {
|
|||||||
it("does not re-lock after manual bypass when an internal reconnect starts", async () => {
|
it("does not re-lock after manual bypass when an internal reconnect starts", async () => {
|
||||||
const h = setup({ minimumFiles: 1 });
|
const h = setup({ minimumFiles: 1 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("first")]);
|
await h.parseHandlers[0]([receivedNote("first")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||||
h.bypass();
|
h.bypass();
|
||||||
h.couchDbCatchUpActivityCount.value = 0;
|
h.setCouchDbPhase("continuous");
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("second")]);
|
await h.parseHandlers[0]([receivedNote("second")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
|
||||||
await h.onUnload[0]();
|
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 () => {
|
it("preserves manual bypass across duplicate resume while the same CouchDB pull remains active", async () => {
|
||||||
const h = setup({ minimumFiles: 2 });
|
const h = setup({ minimumFiles: 2 });
|
||||||
await h.onResumed[0]();
|
await h.onResumed[0]();
|
||||||
h.couchDbCatchUpActivityCount.value = 1;
|
h.setCouchDbPhase("catch-up");
|
||||||
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
|
||||||
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
|
||||||
h.bypass();
|
h.bypass();
|
||||||
|
|||||||
Reference in new issue
Block a user