feat(syncguard): model bounded catch-up editing guard

This commit is contained in:
Artem Ovchinnikov committed 2026-10-21 20:58:39 +03:00
1 parent ce0524d49f
commit c7d59c78d9
2 files changed
+208

No files matched your search

@@ -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<string>();
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);
}
}
@@ -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");
});
});