From c7d59c78d948b34cd053ca65e5503769bec931e5 Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:58:39 +0300 Subject: [PATCH] feat(syncguard): model bounded catch-up editing guard --- .../syncGuard/SyncGuardState.ts | 117 ++++++++++++++++++ .../syncGuard/SyncGuardState.unit.spec.ts | 91 ++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 src/serviceFeatures/syncGuard/SyncGuardState.ts create mode 100644 src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts new file mode 100644 index 00000000..760fffd2 --- /dev/null +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -0,0 +1,117 @@ +/** The guard observes catch-up work without changing replication or Vault writes. */ +export type SyncGuardPhase = "idle" | "observing" | "transferring" | "applying" | "interrupted"; + +export interface SyncGuardPresentation { + phase: SyncGuardPhase; + receivedFiles: number; +} + +/** + * One resume-triggered CouchDB catch-up attempt. Small transfers never lock the workspace. + * Network completion alone does not unlock it: received documents still need reflecting to the Vault. + */ +export class SyncGuardState { + private phase: SyncGuardPhase = "idle"; + private receivedIds = new Set(); + private minimumFiles = 20; + private started = false; + private finiteActive = false; + private observedCompletion = false; + private bypassed = false; + private quiescentSince: number | undefined; + + constructor( + private readonly onChange: (presentation: SyncGuardPresentation) => void, + private readonly quietPeriodMs = 2_000 + ) {} + + get presentation(): SyncGuardPresentation { + return { phase: this.phase, receivedFiles: this.receivedIds.size }; + } + + /** Begins a fresh, initially non-blocking observation window on app resume. */ + resume(enabled: boolean, minimumFiles: number): void { + this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20; + this.receivedIds.clear(); + this.started = false; + this.finiteActive = false; + this.observedCompletion = false; + this.bypassed = false; + this.quiescentSince = undefined; + this.setPhase(enabled ? "observing" : "idle"); + } + + /** 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; + if (active) { + if (this.started) return; // Only the first finite transfer of this resume generation is relevant. + this.started = true; + this.finiteActive = true; + return; + } + this.finiteActive = false; + if (!this.isBlocking()) { + this.setPhase("idle"); + } else if (!this.observedCompletion) { + this.setPhase("interrupted"); + } else { + this.setPhase("applying"); + this.tick(pendingCount, now); + } + } + + /** 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; + } + + /** 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; + const wasBlocking = this.isBlocking(); + for (const id of ids) { + if (id) this.receivedIds.add(id); + if (this.receivedIds.size >= this.minimumFiles) break; + } + if (!wasBlocking && this.receivedIds.size >= this.minimumFiles) this.setPhase("transferring"); + } + + /** Unlock only after successful transfer and a stable empty local application queue. */ + tick(pendingCount: number, now: number): void { + if (this.phase !== "applying") return; + if (pendingCount > 0) { + this.quiescentSince = undefined; + return; + } + this.quiescentSince ??= now; + if (now - this.quiescentSince >= this.quietPeriodMs) this.setPhase("idle"); + } + + /** An offline or suspended attempt can be bypassed rather than trapping the user. */ + interrupted(): void { + if (this.isBlocking()) this.setPhase("interrupted"); + else this.setPhase("idle"); + } + + bypass(): void { + this.bypassed = true; + this.setPhase("idle"); + } + + disable(): void { + this.bypassed = true; + this.setPhase("idle"); + } + + private isBlocking(): boolean { + return this.phase === "transferring" || this.phase === "applying" || this.phase === "interrupted"; + } + + private setPhase(next: SyncGuardPhase): void { + if (this.phase === next) return; + this.phase = next; + this.onChange(this.presentation); + } +} diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts new file mode 100644 index 00000000..42bb45c0 --- /dev/null +++ b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { SyncGuardState, type SyncGuardPresentation } from "./SyncGuardState"; + +describe("SyncGuardState", () => { + function setup() { + const presentations: SyncGuardPresentation[] = []; + const onChange = vi.fn((value: SyncGuardPresentation) => presentations.push(value)); + const guard = new SyncGuardState(onChange, 2000); + return { guard, onChange, presentations }; + } + + it("never locks for a small incoming change batch", () => { + const { guard } = setup(); + guard.resume(true, 20); + guard.finiteActivityChanged(true, 0, 0); + guard.receivedDocuments(["f:one", "f:two"]); + guard.replicationStatusChanged("COMPLETED"); + guard.finiteActivityChanged(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.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.replicationStatusChanged("COMPLETED"); + guard.finiteActivityChanged(false, 2, 10); + expect(guard.presentation.phase).toBe("applying"); + guard.tick(0, 20); + guard.tick(1, 2000); + guard.tick(0, 2100); + guard.tick(0, 4099); + expect(guard.presentation.phase).toBe("applying"); + guard.tick(0, 4100); + expect(guard.presentation.phase).toBe("idle"); + }); + + 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.receivedDocuments(["f:one", "f:two"]); + guard.replicationStatusChanged("ERRORED"); + guard.finiteActivityChanged(false, 0, 10); + expect(guard.presentation.phase).toBe("interrupted"); + guard.bypass(); + expect(guard.presentation.phase).toBe("idle"); + guard.receivedDocuments(["f:other"]); + expect(guard.presentation.phase).toBe("idle"); + }); + + it("does not confuse a stale completed status with this catch-up", () => { + const { guard } = setup(); + guard.resume(true, 1); + guard.replicationStatusChanged("COMPLETED"); + guard.finiteActivityChanged(true, 0, 0); + guard.receivedDocuments(["f:one"]); + guard.finiteActivityChanged(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.receivedDocuments(["f:one"]); + expect(guard.presentation.phase).toBe("idle"); + guard.resume(true, 1); + guard.finiteActivityChanged(true, 0, 10); + guard.receivedDocuments(["f:one"]); + expect(guard.presentation.phase).toBe("transferring"); + guard.disable(); + expect(guard.presentation.phase).toBe("idle"); + }); + + it("handles NaN and negative thresholds conservatively", () => { + const { guard } = setup(); + guard.resume(true, NaN); + guard.finiteActivityChanged(true, 0, 0); + guard.receivedDocuments(["f:one"]); + expect(guard.presentation.phase).toBe("observing"); + guard.resume(true, -2); + guard.finiteActivityChanged(true, 0, 10); + guard.receivedDocuments(["f:one"]); + expect(guard.presentation.phase).toBe("transferring"); + }); +});