From f7c7ef364ca5fc209532398a79a9c51739ca23d8 Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:25:25 +0300 Subject: [PATCH] feat(syncguard): protect workspace during substantial CouchDB catch-up --- .../messages/LiveSyncProvisionalMessages.ts | 8 + src/main.ts | 3 + .../syncGuard/SyncGuardOverlay.ts | 105 ++++++++ .../syncGuard/SyncGuardOverlay.unit.spec.ts | 191 +++++++++++++ .../syncGuard/SyncGuardState.ts | 35 ++- .../syncGuard/SyncGuardState.unit.spec.ts | 58 ++++ src/serviceFeatures/syncGuard/index.ts | 150 +++++++++++ .../syncGuard/syncGuardFeature.unit.spec.ts | 253 ++++++++++++++++++ styles.css | 35 +++ 9 files changed, 832 insertions(+), 6 deletions(-) create mode 100644 src/serviceFeatures/syncGuard/SyncGuardOverlay.ts create mode 100644 src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts create mode 100644 src/serviceFeatures/syncGuard/index.ts create mode 100644 src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 34ae100b..e7e75091 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -7,6 +7,14 @@ * remove it from this map in the same change. */ export const liveSyncProvisionalEnglishMessages = { + "Synchronisation protection": "Synchronisation protection", + "Synchronising your Vault": "Synchronising your Vault", + "Receiving changes from the server…": "Receiving changes from the server…", + "Applying received changes to local notes…": "Applying received changes to local notes…", + "Synchronisation was interrupted. You can continue locally, but some notes may be out of date.": + "Synchronisation was interrupted. You can continue locally, but some notes may be out of date.", + "At least ${COUNT} changed files received": "At least ${COUNT} changed files received", + "Continue without waiting": "Continue without waiting", "Preparing follow-up report…": "Preparing follow-up report…", "Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.", "Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.": diff --git a/src/main.ts b/src/main.ts index 4dcec641..8a66f5d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,6 +50,8 @@ import { createFileReflectionProvenance } from "./serviceModules/FileReflectionP import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution"; import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts"; import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts"; +import { useSyncGuardFeature } from "./serviceFeatures/syncGuard"; +import { SyncGuardOverlay } from "./serviceFeatures/syncGuard/SyncGuardOverlay"; import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle"; import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics"; export type LiveSyncCore = LiveSyncBaseCore; @@ -196,6 +198,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { useP2PReplicatorCommands(core, replicator); useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator)); useObsidianReplicationRibbonFeature(core); + useSyncGuardFeature(core, (onBypass) => new SyncGuardOverlay(this.app.workspace.containerEl, onBypass)); useDatabaseDiagnosticsFeature(core); useRemoteConfiguration(core); diff --git a/src/serviceFeatures/syncGuard/SyncGuardOverlay.ts b/src/serviceFeatures/syncGuard/SyncGuardOverlay.ts new file mode 100644 index 00000000..d569588a --- /dev/null +++ b/src/serviceFeatures/syncGuard/SyncGuardOverlay.ts @@ -0,0 +1,105 @@ +import type { SyncGuardPresentation } from "./SyncGuardState"; +import { $msg } from "@/common/translation"; + +/** Obsidian-owned presentation: blocks workspace input but does not pause replication or other dialogues. */ +export class SyncGuardOverlay { + private root?: HTMLDivElement; + private stage?: HTMLParagraphElement; + private count?: HTMLParagraphElement; + private bypassButton?: HTMLButtonElement; + private previousFocus?: HTMLElement; + + constructor( + private readonly workspace: HTMLElement, + private readonly onBypass: () => void + ) {} + + update({ phase, receivedFiles }: SyncGuardPresentation): void { + if (phase === "idle" || phase === "observing") { + this.close(); + return; + } + this.open(); + if (this.stage) { + this.stage.textContent = + phase === "interrupted" + ? $msg("Synchronisation was interrupted. You can continue locally, but some notes may be out of date.") + : phase === "transferring" + ? $msg("Receiving changes from the server…") + : $msg("Applying received changes to local notes…"); + } + if (this.count) { + this.count.textContent = $msg("At least ${COUNT} changed files received", { COUNT: `${receivedFiles}` }); + } + } + + private open(): void { + if (this.root) return; + const doc = this.workspace.ownerDocument; + const root = this.workspace.createDiv({ cls: "sls-sync-guard" }); + root.setAttribute("role", "region"); + root.setAttribute("aria-label", $msg("Synchronisation protection")); + const panel = root.createEl("section", { cls: "sls-sync-guard-panel" }); + const title = panel.createEl("h2"); + title.textContent = $msg("Synchronising your Vault"); + const stage = panel.createEl("p", { cls: "sls-sync-guard-stage" }); + stage.setAttribute("aria-live", "polite"); + const count = panel.createEl("p", { cls: "sls-sync-guard-count" }); + const bypass = panel.createEl("button", { cls: "mod-cta sls-sync-guard-bypass" }); + bypass.type = "button"; + bypass.textContent = $msg("Continue without waiting"); + bypass.addEventListener("click", this.onBypass); + this.root = root; + this.stage = stage; + this.count = count; + this.bypassButton = bypass; + const focused = doc.activeElement; + const modalFocused = focused instanceof Element && focused.closest(".modal-container") !== null; + this.previousFocus = !modalFocused && focused instanceof HTMLElement ? focused : undefined; + doc.addEventListener("keydown", this.blockKeyboard, true); + // Do not steal focus from an Obsidian conflict or setup dialogue which was already open. + if (!modalFocused) bypass.focus(); + } + + /** Prevent editor shortcuts while allowing our own controls and Obsidian's conflict/setup dialogues. */ + private readonly blockKeyboard = (event: KeyboardEvent): void => { + if (!this.root) return; + const target = event.target; + if (target instanceof Element && target.closest(".modal-container")) return; + if ( + target instanceof Node && + this.root.contains(target) && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + (event.key === "Enter" || event.key === " ") + ) { + return; + } + // The guard has a single focusable control. Keep Tab/Shift+Tab inside it; + // otherwise focus could move behind the overlay with no keyboard route back. + event.preventDefault(); + event.stopImmediatePropagation(); + if (event.key === "Tab") this.bypassButton?.focus(); + }; + + close(): void { + if (!this.root) return; + const doc = this.workspace.ownerDocument; + const focused = doc.activeElement; + const ownsFocus = focused instanceof Node && this.root.contains(focused); + doc.removeEventListener("keydown", this.blockKeyboard, true); + this.root.remove(); + this.root = undefined; + this.stage = undefined; + this.count = undefined; + this.bypassButton = undefined; + // A newer dialogue may own focus now. Never move it back to an old editor. + if (ownsFocus && this.previousFocus?.isConnected) this.previousFocus.focus(); + this.previousFocus = undefined; + } + + dispose(): void { + this.close(); + } +} diff --git a/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts new file mode 100644 index 00000000..46de5ff1 --- /dev/null +++ b/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SyncGuardOverlay } from "./SyncGuardOverlay"; + +class FakeDocument { + activeElement: FakeElement | undefined; + listeners = new Map void>(); + + addEventListener(type: string, listener: (event: KeyboardEvent) => void): void { + this.listeners.set(type, listener); + } + + removeEventListener(type: string): void { + this.listeners.delete(type); + } +} + +class FakeElement { + readonly children: FakeElement[] = []; + readonly handlers = new Map void>(); + readonly attributes = new Map(); + className = ""; + textContent = ""; + type = ""; + isConnected = true; + parent: FakeElement | undefined; + + constructor( + readonly ownerDocument: FakeDocument, + readonly tagName: string + ) {} + + createEl(tagName: string, options?: { cls?: string }): FakeElement { + return this.append(new FakeElement(this.ownerDocument, tagName), options); + } + + createDiv(options?: { cls?: string }): FakeElement { + return this.createEl("div", options); + } + + private append(child: FakeElement, options?: { cls?: string }): FakeElement { + child.className = options?.cls ?? ""; + child.parent = this; + this.children.push(child); + return child; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + addEventListener(name: string, callback: () => void): void { + this.handlers.set(name, callback); + } + + contains(target: FakeElement): boolean { + return target === this || this.children.some((child) => child.contains(target)); + } + + closest(selector: string): FakeElement | null { + if (selector !== ".modal-container") return null; + return this.className === "modal-container" ? this : (this.parent?.closest(selector) ?? null); + } + + focus(): void { + this.ownerDocument.activeElement = this; + } + + remove(): void { + if (!this.parent) return; + const index = this.parent.children.indexOf(this); + if (index >= 0) this.parent.children.splice(index, 1); + this.isConnected = false; + } +} + +function setup() { + vi.stubGlobal("HTMLElement", FakeElement); + vi.stubGlobal("Element", FakeElement); + vi.stubGlobal("Node", FakeElement); + const doc = new FakeDocument(); + const workspace = new FakeElement(doc, "workspace"); + const editor = workspace.createEl("textarea"); + editor.focus(); + const bypass = vi.fn(); + const overlay = new SyncGuardOverlay(workspace as unknown as HTMLElement, bypass); + return { overlay, doc, workspace, editor, bypass }; +} + +describe("SyncGuardOverlay", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("covers only the workspace, exposes a keyboard-operable bypass, and restores focus", () => { + const { overlay, doc, workspace, editor, bypass } = setup(); + overlay.update({ phase: "observing", receivedFiles: 0 }); + expect(workspace.children).toHaveLength(1); + overlay.update({ phase: "transferring", receivedFiles: 20 }); + const root = workspace.children[1]; + const panel = root.children[0]; + const button = panel.children[3]; + expect(root.attributes.get("role")).toBe("region"); + expect(button.tagName).toBe("button"); + expect(button.type).toBe("button"); + expect(doc.activeElement).toBe(button); + expect(panel.children[2].textContent).toContain("20 changed files"); + button.handlers.get("click")?.(); + expect(bypass).toHaveBeenCalledOnce(); + overlay.update({ phase: "idle", receivedFiles: 20 }); + expect(workspace.children).toHaveLength(1); + expect(doc.activeElement).toBe(editor); + expect(doc.listeners.size).toBe(0); + }); + + it("prevents editor keyboard shortcuts while allowing navigation inside the guard", () => { + const { overlay, doc, editor, workspace } = setup(); + overlay.update({ phase: "applying", receivedFiles: 25 }); + const button = workspace.children[1].children[0].children[3]; + const block = doc.listeners.get("keydown"); + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + block?.({ target: editor, key: "x", preventDefault, stopImmediatePropagation } as unknown as KeyboardEvent); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + block?.({ target: button, key: "Enter", preventDefault, stopImmediatePropagation } as unknown as KeyboardEvent); + expect(preventDefault).toHaveBeenCalledOnce(); + overlay.dispose(); + expect(doc.listeners.size).toBe(0); + }); + + it("presents a safe offline bypass when the catch-up is interrupted", () => { + const { overlay, workspace } = setup(); + overlay.update({ phase: "interrupted", receivedFiles: 30 }); + const panel = workspace.children[1].children[0]; + expect(panel.children[1].textContent).toContain("some notes may be out of date"); + overlay.dispose(); + }); + + it("does not steal focus from an existing Obsidian dialogue", () => { + const { overlay, doc, workspace } = setup(); + const modal = new FakeElement(doc, "div"); + modal.className = "modal-container"; + const modalInput = modal.createEl("input"); + modalInput.focus(); + overlay.update({ phase: "transferring", receivedFiles: 20 }); + expect(doc.activeElement).toBe(modalInput); + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + doc.listeners.get("keydown")?.({ + target: modalInput, key: "Escape", preventDefault, stopImmediatePropagation, + } as unknown as KeyboardEvent); + expect(preventDefault).not.toHaveBeenCalled(); + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + overlay.dispose(); + expect(workspace.children).toHaveLength(1); + expect(doc.activeElement).toBe(modalInput); + }); + + it("does not restore editor focus if a dialogue opened while the guard was active", () => { + const { overlay, doc } = setup(); + overlay.update({ phase: "transferring", receivedFiles: 20 }); + const modal = new FakeElement(doc, "div"); + modal.className = "modal-container"; + const input = modal.createEl("input"); + input.focus(); + overlay.update({ phase: "idle", receivedFiles: 20 }); + expect(doc.activeElement).toBe(input); + }); + + it("keeps Tab and Shift+Tab on the bypass button rather than an editor behind the guard", () => { + const { overlay, doc, workspace, editor } = setup(); + overlay.update({ phase: "transferring", receivedFiles: 20 }); + const button = workspace.children[1].children[0].children[3]; + for (const shiftKey of [false, true]) { + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + doc.listeners.get("keydown")?.({ + target: button, key: "Tab", shiftKey, preventDefault, stopImmediatePropagation, + } as unknown as KeyboardEvent); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(doc.activeElement).toBe(button); + } + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + doc.listeners.get("keydown")?.({ + target: editor, key: "Tab", preventDefault, stopImmediatePropagation, + } as unknown as KeyboardEvent); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(doc.activeElement).toBe(button); + overlay.dispose(); + }); +}); diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts index 760fffd2..11c4c338 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -14,9 +14,9 @@ export class SyncGuardState { private phase: SyncGuardPhase = "idle"; private receivedIds = new Set(); private minimumFiles = 20; - private started = false; private finiteActive = false; private observedCompletion = false; + private hadReflectionFailure = false; private bypassed = false; private quiescentSince: number | undefined; @@ -29,31 +29,46 @@ export class SyncGuardState { return { phase: this.phase, receivedFiles: this.receivedIds.size }; } + get wasBypassed(): boolean { + return this.bypassed; + } + /** 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.hadReflectionFailure = false; this.bypassed = false; this.quiescentSince = undefined; this.setPhase(enabled ? "observing" : "idle"); } + /** 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) { + 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; if (active) { - if (this.started) return; // Only the first finite transfer of this resume generation is relevant. - this.started = true; + // 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.observedCompletion = false; + this.quiescentSince = undefined; + if (this.isBlocking()) this.setPhase("transferring"); return; } this.finiteActive = false; if (!this.isBlocking()) { this.setPhase("idle"); - } else if (!this.observedCompletion) { + } else if (!this.observedCompletion || this.hadReflectionFailure) { this.setPhase("interrupted"); } else { this.setPhase("applying"); @@ -67,15 +82,23 @@ export class SyncGuardState { if (status === "COMPLETED") this.observedCompletion = true; } + /** A network-complete batch is not a safe unlock when reflecting a received note failed. */ + reflectionFailed(): void { + if (this.phase === "idle" || this.bypassed) return; + this.hadReflectionFailure = true; + if (this.phase === "applying") this.setPhase("interrupted"); + } + /** 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(); + const previousCount = this.receivedIds.size; 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"); + else if (wasBlocking && this.receivedIds.size !== previousCount) this.onChange(this.presentation); } /** Unlock only after successful transfer and a stable empty local application queue. */ diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts index 42bb45c0..8e9c9f5f 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts @@ -27,6 +27,8 @@ describe("SyncGuardState", () => { 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); expect(guard.presentation.phase).toBe("applying"); @@ -53,6 +55,51 @@ describe("SyncGuardState", () => { expect(guard.presentation.phase).toBe("idle"); }); + 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.receivedDocuments(["f:first"]); + guard.replicationStatusChanged("COMPLETED"); + guard.finiteActivityChanged(false, 0, 1); + expect(guard.presentation.phase).toBe("applying"); + guard.finiteActivityChanged(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); + 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.receivedDocuments(["f:first"]); + guard.finiteActivityChanged(false, 0, 10); + expect(guard.presentation.phase).toBe("interrupted"); + guard.finiteActivityChanged(true, 0, 20); + expect(guard.presentation.phase).toBe("transferring"); + guard.replicationStatusChanged("COMPLETED"); + guard.finiteActivityChanged(false, 0, 30); + guard.tick(0, 2030); + expect(guard.presentation.phase).toBe("idle"); + }); + + 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.receivedDocuments(["f:first"]); + guard.reflectionFailed(); + guard.replicationStatusChanged("COMPLETED"); + guard.finiteActivityChanged(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", () => { const { guard } = setup(); guard.resume(true, 1); @@ -88,4 +135,15 @@ describe("SyncGuardState", () => { guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("transferring"); }); + + 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.receivedDocuments(["f:one", "f:two", "f:three"]); + guard.updateThreshold(3); + expect(guard.presentation.phase).toBe("transferring"); + guard.updateThreshold(200); + expect(guard.presentation.phase).toBe("transferring"); + }); }); diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts new file mode 100644 index 00000000..0a4de0ab --- /dev/null +++ b/src/serviceFeatures/syncGuard/index.ts @@ -0,0 +1,150 @@ +import { collectingChunks, chunkFetchCounts } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores"; +import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { isAnyNote } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import type { LiveSyncCore } from "@/main.ts"; +import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events"; +import type { SyncGuardOverlay } from "./SyncGuardOverlay"; +import { SyncGuardState } from "./SyncGuardState"; + +export type SyncGuardOverlayFactory = (onBypass: () => void) => Pick; + +/** + * Obsidian-only UI policy. The synchronisation service remains fully writable so remote + * result reflection, conflict resolution, and file-system access can make progress. + */ +export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuardOverlayFactory): void { + const { services } = host; + let disposed = false; + let quietTimer: number | undefined; + const readPendingCount = () => { + const counts = [ + services.replication.replicationResultCount.value, + services.replication.databaseQueueCount.value, + services.replication.storageApplyingCount.value, + services.fileProcessing.processing.value, + services.fileProcessing.totalQueued.value, + services.fileProcessing.batched.value, + services.conflict.conflictProcessQueueCount.value, + collectingChunks.value, + chunkFetchCounts.value.initial, + chunkFetchCounts.value.retrying, + ]; + return counts.reduce((total, count) => total + Math.max(0, count), 0); + }; + const stopTimer = () => { + if (quietTimer !== undefined) compatGlobal.clearTimeout(quietTimer); + quietTimer = undefined; + }; + + let guard: SyncGuardState; + const overlay = createOverlay(() => guard.bypass()); + const checkForUnlock = () => { + if (disposed) return; + stopTimer(); + if (guard.presentation.phase !== "applying") return; + const pending = readPendingCount(); + guard.tick(pending, Date.now()); + if (guard.presentation.phase === "applying" && pending === 0) { + quietTimer = compatGlobal.setTimeout(checkForUnlock, 2_100); + } + }; + guard = new SyncGuardState((presentation) => { + if (disposed) return; + overlay.update(presentation); + checkForUnlock(); + }); + + const isEnabled = () => { + const settings = services.setting.currentSettings(); + return settings.isConfigured && settings.liveSync && settings.remoteType === REMOTE_COUCHDB && + settings.syncGuardEnabled; + }; + const beginCatchUp = () => { + const settings = services.setting.currentSettings(); + if (!isEnabled()) { + guard.disable(); + return; + } + // Duplicate resume notifications or a visibility transition must not discard an + // already active guard and expose a partly reflected Vault to the user. + if (guard.presentation.phase === "transferring" || guard.presentation.phase === "applying" || + guard.presentation.phase === "interrupted") { + return; + } + if (guard.presentation.phase === "observing" && + services.replicator.finiteReplicationActivityCount.value > 0) { + return; + } + guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles); + guard.finiteActivityChanged(services.replicator.finiteReplicationActivityCount.value > 0, readPendingCount(), Date.now()); + }; + const onFiniteChanged = () => { + const active = services.replicator.finiteReplicationActivityCount.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 = () => { + guard.replicationStatusChanged(services.replicator.replicationStatics.value.syncStatus); + }; + const onSettingsChanged = () => { + if (!isEnabled()) guard.disable(); + else guard.updateThreshold(services.setting.currentSettings().syncGuardMinimumFiles); + }; + const onPendingChanged = () => checkForUnlock(); + const removeReflectionFailureHandler = services.context.events.onEvent(EVENT_VAULT_REFLECTION_FAILED, () => { + guard.reflectionFailed(); + }); + const removeSettingSavedHandler = services.context.events.onEvent(EVENT_SETTING_SAVED, () => { + onSettingsChanged(); + }); + + const finite = services.replicator.finiteReplicationActivityCount; + const replication = services.replicator.replicationStatics; + const pendingSources = [ + services.replication.replicationResultCount, + services.replication.databaseQueueCount, + services.replication.storageApplyingCount, + services.fileProcessing.processing, + services.fileProcessing.totalQueued, + services.fileProcessing.batched, + services.conflict.conflictProcessQueueCount, + collectingChunks, + chunkFetchCounts, + ]; + finite.onChanged(onFiniteChanged); + replication.onChanged(onReplicationChanged); + for (const source of pendingSources) source.onChanged(onPendingChanged); + + // Register before the ordinary replication scheduling handler so the first finite pull + // belongs to the new resume generation even if it starts synchronously. + services.appLifecycle.onResumed.addHandler(() => { + beginCatchUp(); + return Promise.resolve(true); + }, -100); + services.appLifecycle.onSuspending.addHandler(() => { + guard.interrupted(); + return Promise.resolve(true); + }); + services.setting.onSettingRealised.addHandler(() => { + onSettingsChanged(); + return Promise.resolve(true); + }); + services.replication.parseSynchroniseResult.addHandler((documents) => { + if (!disposed) guard.receivedDocuments(documents.filter(isAnyNote).map((doc) => doc._id)); + return Promise.resolve(true); + }); + services.appLifecycle.onUnload.addHandler(() => { + disposed = true; + stopTimer(); + finite.offChanged(onFiniteChanged); + replication.offChanged(onReplicationChanged); + for (const source of pendingSources) source.offChanged(onPendingChanged); + overlay.dispose(); + removeSettingSavedHandler(); + removeReflectionFailureHandler(); + return Promise.resolve(true); + }); +} diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts new file mode 100644 index 00000000..6fc9750c --- /dev/null +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -0,0 +1,253 @@ +import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; +import { REMOTE_COUCHDB, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types"; +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"; +import { useSyncGuardFeature } from "./index"; +import type { SyncGuardPresentation } from "./SyncGuardState"; + +function receivedNote(id: string): PouchDB.Core.ExistingDocument { + return { + _id: id, + _rev: "1-received", + path: `${id}.md`, + ctime: 1, + mtime: 2, + size: 1, + children: [], + datatype: "plain", + type: "plain", + eden: {}, + } as unknown as PouchDB.Core.ExistingDocument; +} + +function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { + const onResumed: Array<() => Promise> = []; + const onSuspending: Array<() => Promise> = []; + const onUnload: Array<() => Promise> = []; + const onSettingRealised: Array<() => Promise> = []; + const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument[]) => Promise> = []; + const priority = vi.fn(); + const finiteReplicationActivityCount = reactiveSource(0); + const replicationStatics = reactiveSource({ syncStatus: "NOT_CONNECTED" }); + const replicationResultCount = reactiveSource(0); + const databaseQueueCount = reactiveSource(0); + const storageApplyingCount = reactiveSource(0); + const processing = reactiveSource(0); + const totalQueued = reactiveSource(0); + const batched = reactiveSource(0); + const conflictProcessQueueCount = reactiveSource(0); + const settings = { + isConfigured: true, + liveSync: true, + remoteType: REMOTE_COUCHDB, + syncGuardEnabled: overrides.enabled ?? true, + syncGuardMinimumFiles: overrides.minimumFiles ?? 2, + }; + const host = { + services: { + context: createServiceContext(), + setting: { + currentSettings: () => settings, + onSettingRealised: { addHandler: (fn: () => Promise) => onSettingRealised.push(fn) }, + }, + replication: { + replicationResultCount, + databaseQueueCount, + storageApplyingCount, + parseSynchroniseResult: { + addHandler: (fn: (docs: PouchDB.Core.ExistingDocument[]) => Promise) => { + parseHandlers.push(fn); + }, + }, + }, + replicator: { finiteReplicationActivityCount, replicationStatics }, + fileProcessing: { processing, totalQueued, batched }, + conflict: { conflictProcessQueueCount }, + appLifecycle: { + onResumed: { + addHandler: (fn: () => Promise, prio?: number) => { + priority(prio); + onResumed.push(fn); + }, + }, + onSuspending: { addHandler: (fn: () => Promise) => onSuspending.push(fn) }, + onUnload: { addHandler: (fn: () => Promise) => onUnload.push(fn) }, + }, + }, + }; + const updates: SyncGuardPresentation[] = []; + const dispose = vi.fn(); + let bypass: () => void = () => { throw new Error("Guard not composed"); }; + useSyncGuardFeature(host as never, (onBypass) => { + bypass = onBypass; + return { update: (p) => updates.push(p), dispose }; + }); + return { + settings, + events: host.services.context.events, + onResumed, + onSuspending, + onUnload, + onSettingRealised, + parseHandlers, + priority, + finiteReplicationActivityCount, + replicationStatics, + replicationResultCount, + databaseQueueCount, + storageApplyingCount, + updates, + bypass: () => bypass(), + dispose, + }; +} + +describe("useSyncGuardFeature", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("observes the initial CouchDB pull ahead of scheduling and unlocks after local queues settle", async () => { + vi.useFakeTimers(); + const h = setup(); + expect(h.priority).toHaveBeenCalledWith(-100); + await h.onResumed[0](); + h.finiteReplicationActivityCount.value = 1; + 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.finiteReplicationActivityCount.value = 0; + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + vi.advanceTimersByTime(5_000); + 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](); + expect(h.dispose).toHaveBeenCalledOnce(); + }); + + it("never blocks normal transfers and ignores chunks and duplicate note revisions", async () => { + const h = setup({ minimumFiles: 3 }); + await h.onResumed[0](); + h.finiteReplicationActivityCount.value = 1; + 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 () => { + const h = setup({ minimumFiles: 1 }); + await h.onResumed[0](); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("first")]); + h.finiteReplicationActivityCount.value = 0; + expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); + h.bypass(); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.parseHandlers[0]([receivedNote("second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.onUnload[0](); + }); + + 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.finiteReplicationActivityCount.value = 1; + 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.finiteReplicationActivityCount.value = 1; + await enabled.parseHandlers[0]([receivedNote("first")]); + expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("transferring"); + enabled.settings.syncGuardEnabled = false; + await enabled.onSettingRealised[0](); + expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("idle"); + await enabled.onUnload[0](); + }); + + it("observes an internal LiveSync reconnect even without an application resume notification", async () => { + const h = setup({ minimumFiles: 1 }); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("after-reconnect")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + h.replicationStatics.value = { syncStatus: "COMPLETED" }; + h.finiteReplicationActivityCount.value = 0; + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + await h.onUnload[0](); + }); + + it("preserves an active guard across duplicate resume notifications", async () => { + const h = setup({ minimumFiles: 2 }); + await h.onResumed[0](); + h.finiteReplicationActivityCount.value = 1; + 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.finiteReplicationActivityCount.value = 0; + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + await h.onUnload[0](); + }); + + 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.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); + await h.onResumed[0](); + await h.parseHandlers[0]([receivedNote("three")]); + expect(h.updates[h.updates.length - 1]).toMatchObject({ + phase: "transferring", + receivedFiles: 3, + }); + await h.onUnload[0](); + }); + + 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.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("failed")]); + h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED); + h.replicationStatics.value = { syncStatus: "COMPLETED" }; + h.finiteReplicationActivityCount.value = 0; + expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); + await h.onUnload[0](); + }); + + it("responds immediately to settings saved by the UI without setting-realised", async () => { + const h = setup({ minimumFiles: 20 }); + await h.onResumed[0](); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("observing"); + h.settings.syncGuardMinimumFiles = 2; + h.events.emitEvent(EVENT_SETTING_SAVED, h.settings as never); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + h.settings.syncGuardEnabled = false; + h.events.emitEvent(EVENT_SETTING_SAVED, h.settings as never); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.onUnload[0](); + }); + + it("does not re-lock after manual bypass when an internal reconnect starts", async () => { + const h = setup({ minimumFiles: 1 }); + await h.onResumed[0](); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("first")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + h.bypass(); + h.finiteReplicationActivityCount.value = 0; + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.onUnload[0](); + }); +}); diff --git a/styles.css b/styles.css index 462eab07..b521c749 100644 --- a/styles.css +++ b/styles.css @@ -839,3 +839,38 @@ body.is-mobile .livesync-compatibility-review-notice { outline-offset: 1px; border-radius: 2px; } + +/* An optional, workspace-only barrier during a substantial catch-up. Other Obsidian dialogues remain usable. */ +.sls-sync-guard { + position: absolute; + inset: 0; + z-index: var(--layer-cover, 30); + display: grid; + place-items: center; + padding: var(--size-4-4); + background: var(--background-primary); + color: var(--text-normal); + pointer-events: auto; +} + +.sls-sync-guard-panel { + width: min(100%, 30rem); + padding: var(--size-4-6); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-l); + background: var(--background-secondary); + text-align: center; +} + +.sls-sync-guard-panel h2 { + margin-block-start: 0; +} + +.sls-sync-guard-count { + color: var(--text-muted); +} + +.sls-sync-guard-bypass { + min-height: 44px; + min-width: 44px; +}