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 01/17] 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"); + }); +}); From bca2f2dcc6c2f77a1af586a49bee5d21f3e97f5c Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:25:16 +0300 Subject: [PATCH 02/17] fix(replication): publish pending counts and reflection failures --- src/common/events.ts | 3 ++ .../replication/ReplicateResultProcessor.ts | 7 ++++ .../ReplicateResultProcessor.unit.spec.ts | 33 +++++++++++++++++++ src/serviceFeatures/replication/index.ts | 3 +- 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/common/events.ts b/src/common/events.ts index d358f997..220c420e 100644 --- a/src/common/events.ts +++ b/src/common/events.ts @@ -4,6 +4,8 @@ import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; export const EVENT_PLUGIN_LOADED = "plugin-loaded"; export const EVENT_PLUGIN_UNLOADED = "plugin-unloaded"; export const EVENT_FILE_SAVED = "file-saved"; +/** A received note could not be reflected from the local database into the Vault. */ +export const EVENT_VAULT_REFLECTION_FAILED = "vault-reflection-failed"; export const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed"; export const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings"; @@ -28,6 +30,7 @@ declare global { interface LSEvents { [EVENT_PLUGIN_LOADED]: undefined; [EVENT_PLUGIN_UNLOADED]: undefined; + [EVENT_VAULT_REFLECTION_FAILED]: undefined; [EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined; [EVENT_REQUEST_OPEN_SETTINGS]: undefined; [EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined; diff --git a/src/serviceFeatures/replication/ReplicateResultProcessor.ts b/src/serviceFeatures/replication/ReplicateResultProcessor.ts index 523043d9..075ebd46 100644 --- a/src/serviceFeatures/replication/ReplicateResultProcessor.ts +++ b/src/serviceFeatures/replication/ReplicateResultProcessor.ts @@ -58,6 +58,7 @@ type ReplicateResultProcessorServices = Pick< * database would be invalid. */ interface ReplicateResultProcessorContext { + readonly onVaultReflectionFailure?: () => void; readonly currentSettings: () => ReplicateResultProcessorSettings; readonly getKeyValueDB: () => LiveSyncBaseCore["kvDB"]; readonly getLocalDatabase: () => LiveSyncBaseCore["localDatabase"]; @@ -87,6 +88,7 @@ export class ReplicateResultProcessor { Logger(e, LOG_LEVEL_VERBOSE); } private reportVaultReflectionFailure(entry: MetaEntry, cause?: unknown) { + this.context.onVaultReflectionFailure?.(); this.log( `Live replication could not reflect ${this.getPath(entry)} from the local database to the Vault; this path remains eligible for a later Vault scan.`, LOG_LEVEL_VERBOSE @@ -383,6 +385,7 @@ export class ReplicateResultProcessor { } // Enqueue the change this._queuedChanges.push(doc); + this.reportStatus(); this.updateProcessingActivity(); this.triggerTakeSnapshot(); this.triggerProcessQueue(); @@ -491,6 +494,9 @@ export class ReplicateResultProcessor { } finally { // Remove from processing queue this._processingChanges = this._processingChanges.filter((e) => e !== change); + // Queue visibility must not wait for persisting a recovery snapshot. Consumers + // use this count to decide whether the reflected Vault is ready for interaction. + this.reportStatus(); try { if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) { try { @@ -548,6 +554,7 @@ export class ReplicateResultProcessor { : await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true); if (!doc) { // Failed to gather content + this.context.onVaultReflectionFailure?.(); this.log(`Failed to gather content of ${docNote}`, LOG_LEVEL_NOTICE); return; } diff --git a/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts b/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts index 5ed7bf31..c1bf5680 100644 --- a/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts +++ b/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts @@ -37,6 +37,7 @@ function note(id: string): PouchDB.Core.ExistingDocument { } type SetupOptions = { + onVaultReflectionFailure?: () => void; applicationReady?: boolean; databaseReady?: boolean; maxMTimeForReflectEvents?: number; @@ -95,6 +96,7 @@ function setup(options: SetupOptions = {}) { }, }; const processor = new ReplicateResultProcessor({ + onVaultReflectionFailure: options.onVaultReflectionFailure, currentSettings: () => ({ maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0, suspendParseReplicationResult: false, @@ -117,12 +119,43 @@ function setup(options: SetupOptions = {}) { isValidPath, onCloseActiveReplication, processor, + replicationResultCount: core.services.replication.replicationResultCount, processSynchroniseResult, runBoundedLocalApplicationActivity, }; } describe("ReplicateResultProcessor", () => { + it("publishes pending received documents synchronously even if snapshot persistence stalls", () => { + const { processor, replicationResultCount, processSynchroniseResult } = setup({ + applicationReady: false, + setSnapshot: async () => new Promise(() => undefined), + }); + processor.enqueueAll([note("pending")]); + expect(replicationResultCount.value).toBe(1); + expect(processSynchroniseResult).not.toHaveBeenCalled(); + }); + + it("clears the received-document counter before awaiting an unresponsive snapshot write", async () => { + const { processor, replicationResultCount, processSynchroniseResult } = setup({ + setSnapshot: async () => new Promise(() => undefined), + }); + processor.enqueueAll([note("pending")]); + expect(replicationResultCount.value).toBe(1); + await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(replicationResultCount.value).toBe(0)); + }); + + it("reports a failed Vault reflection instead of treating an empty queue as success", async () => { + const onVaultReflectionFailure = vi.fn(); + const { processor, processSynchroniseResult } = setup({ + onVaultReflectionFailure, + processSynchroniseResult: async () => false, + }); + processor.enqueueAll([note("unreflected")]); + await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledTimes(1)); + }); it("does not add a permanent application block when snapshot recovery fails", async () => { const { processor } = setup({ getSnapshot: async () => { diff --git a/src/serviceFeatures/replication/index.ts b/src/serviceFeatures/replication/index.ts index 37eb5f52..473c7bb7 100644 --- a/src/serviceFeatures/replication/index.ts +++ b/src/serviceFeatures/replication/index.ts @@ -4,7 +4,7 @@ import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/servic import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { fireAndForget } from "octagonal-wheels/promises"; import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -import { EVENT_APPLICATION_READY } from "@/common/events"; +import { EVENT_APPLICATION_READY, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events"; import { createAutomaticReplicationTriggers } from "./automaticTriggers"; import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery"; import { createOnlineReplicationPreflight, createSecuritySeedPreflight } from "./preflight"; @@ -38,6 +38,7 @@ export function useReplicationFeature services.context.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED), currentSettings: () => services.setting.currentSettings(), getKeyValueDB: () => services.keyValueDB.kvDB, getLocalDatabase: () => core.localDatabase, 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 03/17] 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; +} From 95c7d6cb69011a3c91e6efd37c248371b3e59f21 Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:25:35 +0300 Subject: [PATCH 04/17] feat(settings): expose opt-in SyncGuard in both Obsidian settings UIs --- docs/settings.md | 14 ++++++++++++++ .../SettingDialogue/GeneralSettingSpecs.ts | 2 ++ ...dianLiveSyncSettingTab.declarative.unit.spec.ts | 2 ++ .../SettingDialogue/SettingSpec.unit.spec.ts | 2 ++ updates.md | 4 ++++ 5 files changed, 24 insertions(+) diff --git a/docs/settings.md b/docs/settings.md index 0885589c..12baec3d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -200,6 +200,20 @@ Setting key: networkWarningStyle How to display network errors when the sync server is unreachable. +#### Protect the workspace during a large catch-up (opt-in) + +Setting key: syncGuardEnabled + +When enabled on this device, the workspace temporarily blocks interaction after a large batch of incoming note changes is detected during CouchDB LiveSync startup or resume. Incoming changes continue to be received and applied while the workspace is protected. The overlay can be bypassed at any time, including when synchronisation fails or the remote is unavailable. The preference is device-local; it does not affect other synchronising devices. + +The threshold avoids interruption for ordinary small transfers. However, the number of changes cannot be known before they arrive: some notes may be updated before the guard activates. The guard is a user-interface aid, not an atomic Vault snapshot or a restriction on other plug-ins and external tools. It does not apply to other remote types or to ordinary live changes after the initial catch-up. + +#### Minimum received files to protect the workspace + +Setting key: syncGuardMinimumFiles + +The number of distinct incoming note Metadata documents required to activate the guard during a catch-up (default: 20). Chunk documents and repeated revisions of the same note do not increase the count. After activation, the guard remains until the network catch-up completes successfully and all observed local application work settles. If the catch-up fails, use **Continue without waiting** to work offline. + ### 2. Logging #### Show only notifications diff --git a/src/modules/features/SettingDialogue/GeneralSettingSpecs.ts b/src/modules/features/SettingDialogue/GeneralSettingSpecs.ts index a839a2e1..e3af3906 100644 --- a/src/modules/features/SettingDialogue/GeneralSettingSpecs.ts +++ b/src/modules/features/SettingDialogue/GeneralSettingSpecs.ts @@ -35,6 +35,8 @@ export function createGeneralSettingSpecGroups({ }, { key: "showStatusOnStatusbar", control: { type: "toggle" } }, { key: "hideFileWarningNotice", control: { type: "toggle" } }, + { key: "syncGuardEnabled", control: { type: "toggle" } }, + { key: "syncGuardMinimumFiles", control: { type: "number", min: 1 } }, { key: "networkWarningStyle", control: { diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts index d45582bb..2ee177d7 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts @@ -333,6 +333,8 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { "showOnlyIconsOnEditor", "showStatusOnStatusbar", "hideFileWarningNotice", + "syncGuardEnabled", + "syncGuardMinimumFiles", "networkWarningStyle", ]); expect( diff --git a/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts b/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts index 62958bc4..0702baf0 100644 --- a/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts +++ b/src/modules/features/SettingDialogue/SettingSpec.unit.spec.ts @@ -130,6 +130,8 @@ describe("General setting specifications", () => { "showOnlyIconsOnEditor", "showStatusOnStatusbar", "hideFileWarningNotice", + "syncGuardEnabled", + "syncGuardMinimumFiles", "networkWarningStyle", "lessInformationInLog", "showVerboseLog", diff --git a/updates.md b/updates.md index ca2e3497..b0bc4486 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Synchronisation + +- Added an optional, device-local editing guard for substantial CouchDB LiveSync catch-up transfers. When enough incoming notes are detected, it covers the workspace until the transfer and local application queues have settled. The overlay can always be bypassed, and ordinary small transfers remain uninterrupted. + ## 1.0.39 20th October, 2026 From d527fa9764580f6f4d263e5d32c06bb8dc9f2dbc Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:53 +0300 Subject: [PATCH 05/17] fix(replication): flag additional Vault reflection failures --- .../replication/ReplicateResultProcessor.ts | 3 +++ .../ReplicateResultProcessor.unit.spec.ts | 26 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/serviceFeatures/replication/ReplicateResultProcessor.ts b/src/serviceFeatures/replication/ReplicateResultProcessor.ts index 075ebd46..8c481064 100644 --- a/src/serviceFeatures/replication/ReplicateResultProcessor.ts +++ b/src/serviceFeatures/replication/ReplicateResultProcessor.ts @@ -521,6 +521,8 @@ export class ReplicateResultProcessor { releaser = await this._semaphore.acquire(); await this._applyToDatabase(doc); } catch (e) { + // An exception during application must not be mistaken for a fully reflected Vault. + this.context.onVaultReflectionFailure?.(); this.log(`Error while processing replication result`, LOG_LEVEL_NOTICE); this.logError(e); } finally { @@ -630,6 +632,7 @@ export class ReplicateResultProcessor { // Note that this is not a common case. return true; } else { + this.context.onVaultReflectionFailure?.(); this.log( `Failed to get existing document for ${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)}) `, LOG_LEVEL_NOTICE diff --git a/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts b/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts index c1bf5680..54141175 100644 --- a/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts +++ b/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts @@ -38,6 +38,8 @@ function note(id: string): PouchDB.Core.ExistingDocument { type SetupOptions = { onVaultReflectionFailure?: () => void; + getRaw?: (id: string) => Promise; + getDBEntryFromMeta?: (entry: object) => Promise; applicationReady?: boolean; databaseReady?: boolean; maxMTimeForReflectEvents?: number; @@ -58,7 +60,7 @@ function setup(options: SetupOptions = {}) { const onCloseActiveReplication = vi.fn(async () => true); const isReady = vi.fn(() => options.applicationReady ?? true); const isValidPath = vi.fn(options.isValidPath ?? (() => true)); - const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" })); + const getDBEntryFromMeta = vi.fn(options.getDBEntryFromMeta ?? (async (entry: object) => ({ ...entry, data: "x" }))); const localPhysicalDatabase = { ...(options.databaseId ? { id: vi.fn(async () => options.databaseId) } : {}), } as PouchDB.Database; @@ -90,6 +92,7 @@ function setup(options: SetupOptions = {}) { if (options.localVersionInfo === undefined) throw { status: 404 }; return options.localVersionInfo; } + if (options.getRaw) return await options.getRaw(id); return { _id: id, _rev: "1-test" }; }), getDBEntryFromMeta, @@ -126,6 +129,27 @@ function setup(options: SetupOptions = {}) { } describe("ReplicateResultProcessor", () => { + it("reports an IndexedDB read error as failed reflection", async () => { + const onVaultReflectionFailure = vi.fn(); + const { processor, processSynchroniseResult } = setup({ + onVaultReflectionFailure, + getRaw: async () => { throw new Error("IndexedDB read failed"); }, + }); + processor.enqueueAll([note("unreadable")]); + await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledOnce()); + expect(processSynchroniseResult).not.toHaveBeenCalled(); + }); + + it("reports a thrown document reconstruction as failed reflection", async () => { + const onVaultReflectionFailure = vi.fn(); + const { processor } = setup({ + onVaultReflectionFailure, + getDBEntryFromMeta: async () => { throw new Error("Cannot read chunks"); }, + }); + processor.enqueueAll([note("missing-chunks")]); + await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledOnce()); + }); + it("publishes pending received documents synchronously even if snapshot persistence stalls", () => { const { processor, replicationResultCount, processSynchroniseResult } = setup({ applicationReady: false, From ee0272a65f5c57b6250ca243645cfab807d45801 Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:53 +0300 Subject: [PATCH 06/17] fix(syncguard): prevent mobile input behind workspace overlay --- .../syncGuard/SyncGuardOverlay.ts | 16 +++++++++++++ .../syncGuard/SyncGuardOverlay.unit.spec.ts | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/serviceFeatures/syncGuard/SyncGuardOverlay.ts b/src/serviceFeatures/syncGuard/SyncGuardOverlay.ts index d569588a..c7cd090d 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardOverlay.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardOverlay.ts @@ -57,6 +57,10 @@ export class SyncGuardOverlay { const modalFocused = focused instanceof Element && focused.closest(".modal-container") !== null; this.previousFocus = !modalFocused && focused instanceof HTMLElement ? focused : undefined; doc.addEventListener("keydown", this.blockKeyboard, true); + // Mobile input methods, dictation, and paste may mutate an editor without a keydown. + doc.addEventListener("beforeinput", this.blockEditorInput, true); + doc.addEventListener("paste", this.blockEditorInput, true); + doc.addEventListener("drop", this.blockEditorInput, true); // Do not steal focus from an Obsidian conflict or setup dialogue which was already open. if (!modalFocused) bypass.focus(); } @@ -83,12 +87,24 @@ export class SyncGuardOverlay { if (event.key === "Tab") this.bypassButton?.focus(); }; + private readonly blockEditorInput = (event: Event): 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)) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + 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); + doc.removeEventListener("beforeinput", this.blockEditorInput, true); + doc.removeEventListener("paste", this.blockEditorInput, true); + doc.removeEventListener("drop", this.blockEditorInput, true); this.root.remove(); this.root = undefined; this.stage = undefined; diff --git a/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts index 46de5ff1..e6743285 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardOverlay.unit.spec.ts @@ -188,4 +188,28 @@ describe("SyncGuardOverlay", () => { expect(doc.activeElement).toBe(button); overlay.dispose(); }); + + it("blocks mobile beforeinput and paste into hidden editors but permits input in Obsidian dialogues", () => { + const { overlay, doc, editor } = setup(); + overlay.update({ phase: "transferring", receivedFiles: 30 }); + const modal = new FakeElement(doc, "div"); + modal.className = "modal-container"; + const input = modal.createEl("input"); + for (const eventType of ["beforeinput", "paste", "drop"]) { + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + doc.listeners.get(eventType)?.({ + target: editor, preventDefault, stopImmediatePropagation, + } as unknown as KeyboardEvent); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + + doc.listeners.get(eventType)?.({ + target: input, preventDefault, stopImmediatePropagation, + } as unknown as KeyboardEvent); + expect(preventDefault).toHaveBeenCalledOnce(); + } + overlay.dispose(); + expect(doc.listeners.size).toBe(0); + }); }); From bc185d8bd06c4da95134881312a537660396a185 Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:44:17 +0300 Subject: [PATCH 07/17] fix(syncguard): isolate CouchDB catch-up from P2P activity --- src/serviceFeatures/syncGuard/index.ts | 8 +-- .../syncGuard/syncGuardFeature.unit.spec.ts | 54 ++++++++++++------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 0a4de0ab..f8a0ee0b 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -73,14 +73,14 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard return; } if (guard.presentation.phase === "observing" && - services.replicator.finiteReplicationActivityCount.value > 0) { + services.replicator.couchDbCatchUpActivityCount.value > 0) { return; } guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles); - guard.finiteActivityChanged(services.replicator.finiteReplicationActivityCount.value > 0, readPendingCount(), Date.now()); + guard.finiteActivityChanged(services.replicator.couchDbCatchUpActivityCount.value > 0, readPendingCount(), Date.now()); }; const onFiniteChanged = () => { - const active = services.replicator.finiteReplicationActivityCount.value > 0; + 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(); @@ -101,7 +101,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard onSettingsChanged(); }); - const finite = services.replicator.finiteReplicationActivityCount; + const finite = services.replicator.couchDbCatchUpActivityCount; const replication = services.replicator.replicationStatics; const pendingSources = [ services.replication.replicationResultCount, diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index 6fc9750c..3601009b 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -28,6 +28,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { const onSettingRealised: Array<() => Promise> = []; const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument[]) => Promise> = []; const priority = vi.fn(); + const couchDbCatchUpActivityCount = reactiveSource(0); const finiteReplicationActivityCount = reactiveSource(0); const replicationStatics = reactiveSource({ syncStatus: "NOT_CONNECTED" }); const replicationResultCount = reactiveSource(0); @@ -61,7 +62,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { }, }, }, - replicator: { finiteReplicationActivityCount, replicationStatics }, + replicator: { couchDbCatchUpActivityCount, finiteReplicationActivityCount, replicationStatics }, fileProcessing: { processing, totalQueued, batched }, conflict: { conflictProcessQueueCount }, appLifecycle: { @@ -92,6 +93,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { onSettingRealised, parseHandlers, priority, + couchDbCatchUpActivityCount, finiteReplicationActivityCount, replicationStatics, replicationResultCount, @@ -113,12 +115,12 @@ describe("useSyncGuardFeature", () => { const h = setup(); expect(h.priority).toHaveBeenCalledWith(-100); await h.onResumed[0](); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.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; + h.couchDbCatchUpActivityCount.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"); @@ -129,10 +131,24 @@ describe("useSyncGuardFeature", () => { expect(h.dispose).toHaveBeenCalledOnce(); }); + it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => { + const h = setup({ minimumFiles: 2 }); + await h.onResumed[0](); + h.finiteReplicationActivityCount.value = 1; // P2P uses only the shared finite counter. + await h.parseHandlers[0]([receivedNote("peer-first"), receivedNote("peer-second")]); + h.finiteReplicationActivityCount.value = 0; + expect(h.updates.some((p) => p.phase === "transferring" || p.phase === "interrupted")).toBe(false); + // A real CouchDB catch-up must still arm normally in the same session. + h.couchDbCatchUpActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("db-first"), receivedNote("db-second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + await h.onUnload[0](); + }); + 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; + h.couchDbCatchUpActivityCount.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](); @@ -141,9 +157,9 @@ describe("useSyncGuardFeature", () => { 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; + h.couchDbCatchUpActivityCount.value = 1; await h.parseHandlers[0]([receivedNote("first")]); - h.finiteReplicationActivityCount.value = 0; + h.couchDbCatchUpActivityCount.value = 0; expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); h.bypass(); expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); @@ -155,14 +171,14 @@ describe("useSyncGuardFeature", () => { it("never shows a guard when disabled and releases an active guard when the setting is disabled", async () => { const h = setup({ enabled: false, minimumFiles: 1 }); await h.onResumed[0](); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.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; + enabled.couchDbCatchUpActivityCount.value = 1; await enabled.parseHandlers[0]([receivedNote("first")]); expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("transferring"); enabled.settings.syncGuardEnabled = false; @@ -173,11 +189,11 @@ describe("useSyncGuardFeature", () => { it("observes an internal LiveSync reconnect even without an application resume notification", async () => { const h = setup({ minimumFiles: 1 }); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.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; + h.couchDbCatchUpActivityCount.value = 0; expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); await h.onUnload[0](); }); @@ -185,13 +201,13 @@ describe("useSyncGuardFeature", () => { it("preserves an active guard across duplicate resume notifications", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.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; + h.couchDbCatchUpActivityCount.value = 0; expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); await h.onUnload[0](); }); @@ -199,7 +215,7 @@ describe("useSyncGuardFeature", () => { it("preserves accumulated files before the threshold when resume fires twice during one pull", async () => { const h = setup({ minimumFiles: 3 }); await h.onResumed[0](); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.value = 1; await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); await h.onResumed[0](); await h.parseHandlers[0]([receivedNote("three")]); @@ -213,11 +229,11 @@ describe("useSyncGuardFeature", () => { it("keeps the guard after a received note could not be written to the Vault", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.value = 1; await h.parseHandlers[0]([receivedNote("failed")]); h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED); h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.finiteReplicationActivityCount.value = 0; + h.couchDbCatchUpActivityCount.value = 0; expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); await h.onUnload[0](); }); @@ -225,7 +241,7 @@ describe("useSyncGuardFeature", () => { it("responds immediately to settings saved by the UI without setting-realised", async () => { const h = setup({ minimumFiles: 20 }); await h.onResumed[0](); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.value = 1; await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("observing"); h.settings.syncGuardMinimumFiles = 2; @@ -240,12 +256,12 @@ describe("useSyncGuardFeature", () => { it("does not re-lock after manual bypass when an internal reconnect starts", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.finiteReplicationActivityCount.value = 1; + h.couchDbCatchUpActivityCount.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; + h.couchDbCatchUpActivityCount.value = 0; + h.couchDbCatchUpActivityCount.value = 1; await h.parseHandlers[0]([receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); await h.onUnload[0](); From 83814629501e2aec71950951ec1cf0651cabb01f Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:49:24 +0300 Subject: [PATCH 08/17] fix(syncguard): preserve bypass during duplicate resume --- src/serviceFeatures/syncGuard/index.ts | 5 +++++ .../syncGuard/syncGuardFeature.unit.spec.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index f8a0ee0b..379de0d1 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -66,6 +66,11 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard guard.disable(); return; } + // A duplicate resume in the same active catch-up must honour a user's + // explicit decision to continue editing rather than silently re-lock. + if (guard.wasBypassed && services.replicator.couchDbCatchUpActivityCount.value > 0) { + 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" || diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index 3601009b..a5a82230 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -266,4 +266,17 @@ describe("useSyncGuardFeature", () => { expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); await h.onUnload[0](); }); + + it("preserves manual bypass across duplicate resume while the same CouchDB pull remains active", async () => { + const h = setup({ minimumFiles: 2 }); + await h.onResumed[0](); + h.couchDbCatchUpActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + h.bypass(); + await h.onResumed[0](); + await h.parseHandlers[0]([receivedNote("third"), receivedNote("fourth")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.onUnload[0](); + }); }); From 101fe958f5e7b89b9ea01fb18e38962890be205b Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:34:45 +0300 Subject: [PATCH 09/17] refactor(syncguard): use published CouchDB catch-up phases --- .../syncGuard/SyncGuardState.ts | 26 +++--- .../syncGuard/SyncGuardState.unit.spec.ts | 61 +++++++------ src/serviceFeatures/syncGuard/index.ts | 48 +++++++---- .../syncGuard/syncGuardFeature.unit.spec.ts | 86 +++++++++++++------ 4 files changed, 134 insertions(+), 87 deletions(-) diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts index 11c4c338..41f9bce0 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -14,7 +14,7 @@ export class SyncGuardState { private phase: SyncGuardPhase = "idle"; private receivedIds = new Set(); private minimumFiles = 20; - private finiteActive = false; + private catchUpActive = false; private observedCompletion = false; private hadReflectionFailure = false; private bypassed = false; @@ -37,7 +37,7 @@ export class SyncGuardState { resume(enabled: boolean, minimumFiles: number): void { this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20; this.receivedIds.clear(); - this.finiteActive = false; + this.catchUpActive = false; this.observedCompletion = false; this.hadReflectionFailure = false; this.bypassed = false; @@ -48,24 +48,24 @@ export class SyncGuardState { /** 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) { + if (this.phase === "observing" && this.catchUpActive && 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; + /** The CouchDB catch-up phase ends before continuous replication and Vault reflection finish. */ + catchUpActivityChanged(active: boolean, pendingCount: number, now: number): void { + if (this.phase === "idle" || this.bypassed || this.catchUpActive === active) return; if (active) { // 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.catchUpActive = true; this.observedCompletion = false; this.quiescentSince = undefined; if (this.isBlocking()) this.setPhase("transferring"); return; } - this.finiteActive = false; + this.catchUpActive = false; if (!this.isBlocking()) { this.setPhase("idle"); } 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. */ - replicationStatusChanged(status: string): void { - if (this.phase === "idle" || this.bypassed || !this.finiteActive) return; - if (status === "COMPLETED") this.observedCompletion = true; + /** Only the catch-up → continuous transition proves that the initial pull succeeded. */ + catchUpSucceeded(): void { + if (this.phase === "idle" || this.bypassed || !this.catchUpActive) return; + this.observedCompletion = true; } /** 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. */ 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 previousCount = this.receivedIds.size; for (const id of ids) { diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts index 8e9c9f5f..14578577 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts @@ -12,25 +12,25 @@ describe("SyncGuardState", () => { it("never locks for a small incoming change batch", () => { const { guard } = setup(); guard.resume(true, 20); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one", "f:two"]); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(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.catchUpActivityChanged(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.receivedDocuments(["f:four", "f:four"]); expect(guard.presentation.receivedFiles).toBe(4); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 2, 10); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 2, 10); expect(guard.presentation.phase).toBe("applying"); guard.tick(0, 20); guard.tick(1, 2000); @@ -44,10 +44,9 @@ describe("SyncGuardState", () => { 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.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one", "f:two"]); - guard.replicationStatusChanged("ERRORED"); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpActivityChanged(false, 0, 10); expect(guard.presentation.phase).toBe("interrupted"); guard.bypass(); 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", () => { const { guard } = setup(); guard.resume(true, 1); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:first"]); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 1); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 1); expect(guard.presentation.phase).toBe("applying"); - guard.finiteActivityChanged(true, 0, 100); + guard.catchUpActivityChanged(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); + guard.catchUpActivityChanged(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.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:first"]); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpActivityChanged(false, 0, 10); expect(guard.presentation.phase).toBe("interrupted"); - guard.finiteActivityChanged(true, 0, 20); + guard.catchUpActivityChanged(true, 0, 20); expect(guard.presentation.phase).toBe("transferring"); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 30); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 30); guard.tick(0, 2030); 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", () => { const { guard } = setup(); guard.resume(true, 1); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:first"]); guard.reflectionFailed(); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(false, 0, 10); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(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", () => { + it("does not treat a stale completion as success for a new catch-up", () => { const { guard } = setup(); guard.resume(true, 1); - guard.replicationStatusChanged("COMPLETED"); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one"]); - guard.finiteActivityChanged(false, 0, 1); + guard.catchUpActivityChanged(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.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("idle"); guard.resume(true, 1); - guard.finiteActivityChanged(true, 0, 10); + guard.catchUpActivityChanged(true, 0, 10); guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("transferring"); guard.disable(); @@ -127,11 +126,11 @@ describe("SyncGuardState", () => { it("handles NaN and negative thresholds conservatively", () => { const { guard } = setup(); guard.resume(true, NaN); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one"]); expect(guard.presentation.phase).toBe("observing"); guard.resume(true, -2); - guard.finiteActivityChanged(true, 0, 10); + guard.catchUpActivityChanged(true, 0, 10); guard.receivedDocuments(["f:one"]); 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", () => { const { guard } = setup(); guard.resume(true, 20); - guard.finiteActivityChanged(true, 0, 0); + guard.catchUpActivityChanged(true, 0, 0); guard.receivedDocuments(["f:one", "f:two", "f:three"]); guard.updateThreshold(3); expect(guard.presentation.phase).toBe("transferring"); diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 379de0d1..93b1f919 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -17,6 +17,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard const { services } = host; let disposed = false; let quietTimer: number | undefined; + let terminalPhaseTimer: number | undefined; const readPendingCount = () => { const counts = [ services.replication.replicationResultCount.value, @@ -36,6 +37,10 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard if (quietTimer !== undefined) compatGlobal.clearTimeout(quietTimer); quietTimer = undefined; }; + const stopTerminalPhaseTimer = () => { + if (terminalPhaseTimer !== undefined) compatGlobal.clearTimeout(terminalPhaseTimer); + terminalPhaseTimer = undefined; + }; let guard: SyncGuardState; 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 && settings.syncGuardEnabled; }; + const isCouchDbCatchUp = () => services.replicator.replicationStatics.value.couchDB?.phase === "catch-up"; const beginCatchUp = () => { const settings = services.setting.currentSettings(); 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 // explicit decision to continue editing rather than silently re-lock. - if (guard.wasBypassed && services.replicator.couchDbCatchUpActivityCount.value > 0) { + if (guard.wasBypassed && isCouchDbCatchUp()) { return; } // 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") { return; } - if (guard.presentation.phase === "observing" && - services.replicator.couchDbCatchUpActivityCount.value > 0) { + if (guard.presentation.phase === "observing" && isCouchDbCatchUp()) { return; } guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles); - guard.finiteActivityChanged(services.replicator.couchDbCatchUpActivityCount.value > 0, 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()); + guard.catchUpActivityChanged(isCouchDbCatchUp(), readPendingCount(), Date.now()); }; 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 = () => { if (!isEnabled()) guard.disable(); @@ -106,7 +126,6 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard onSettingsChanged(); }); - const finite = services.replicator.couchDbCatchUpActivityCount; const replication = services.replicator.replicationStatics; const pendingSources = [ services.replication.replicationResultCount, @@ -119,7 +138,6 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard collectingChunks, chunkFetchCounts, ]; - finite.onChanged(onFiniteChanged); replication.onChanged(onReplicationChanged); for (const source of pendingSources) source.onChanged(onPendingChanged); @@ -144,7 +162,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard services.appLifecycle.onUnload.addHandler(() => { disposed = true; stopTimer(); - finite.offChanged(onFiniteChanged); + stopTerminalPhaseTimer(); replication.offChanged(onReplicationChanged); for (const source of pendingSources) source.offChanged(onPendingChanged); overlay.dispose(); diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index a5a82230..bf8d5b4b 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -1,5 +1,6 @@ import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; import { REMOTE_COUCHDB, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { CouchDBReplicationPhase, ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events"; import { describe, afterEach, expect, it, vi } from "vitest"; @@ -28,9 +29,19 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { const onSettingRealised: Array<() => Promise> = []; const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument[]) => Promise> = []; const priority = vi.fn(); - const couchDbCatchUpActivityCount = reactiveSource(0); const finiteReplicationActivityCount = reactiveSource(0); - const replicationStatics = reactiveSource({ syncStatus: "NOT_CONNECTED" }); + const replicationStatics = reactiveSource({ + sent: 0, + arrived: 0, + maxPullSeq: 0, + maxPushSeq: 0, + lastSyncPullSeq: 0, + lastSyncPushSeq: 0, + syncStatus: "NOT_CONNECTED", + }); + const setCouchDbPhase = (phase: CouchDBReplicationPhase) => { + replicationStatics.value = { ...replicationStatics.value, couchDB: { phase } }; + }; const replicationResultCount = reactiveSource(0); const databaseQueueCount = reactiveSource(0); const storageApplyingCount = reactiveSource(0); @@ -62,7 +73,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { }, }, }, - replicator: { couchDbCatchUpActivityCount, finiteReplicationActivityCount, replicationStatics }, + replicator: { finiteReplicationActivityCount, replicationStatics }, fileProcessing: { processing, totalQueued, batched }, conflict: { conflictProcessQueueCount }, appLifecycle: { @@ -93,7 +104,7 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { onSettingRealised, parseHandlers, priority, - couchDbCatchUpActivityCount, + setCouchDbPhase, finiteReplicationActivityCount, replicationStatics, replicationResultCount, @@ -115,12 +126,12 @@ describe("useSyncGuardFeature", () => { const h = setup(); expect(h.priority).toHaveBeenCalledWith(-100); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); h.databaseQueueCount.value = 1; - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); vi.advanceTimersByTime(5_000); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); @@ -131,6 +142,23 @@ describe("useSyncGuardFeature", () => { expect(h.dispose).toHaveBeenCalledOnce(); }); + it("does not mistake a transient progress-observer idle for a failed catch-up", async () => { + vi.useFakeTimers(); + const h = setup({ minimumFiles: 1 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("first")]); + h.databaseQueueCount.value = 1; + h.setCouchDbPhase("idle"); // PouchDB's per-attempt observer is disposed before the continuous hand-off. + h.setCouchDbPhase("continuous"); + await vi.advanceTimersByTimeAsync(1); + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + h.databaseQueueCount.value = 0; + await vi.advanceTimersByTimeAsync(2_150); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.onUnload[0](); + }); + it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); @@ -139,7 +167,7 @@ describe("useSyncGuardFeature", () => { h.finiteReplicationActivityCount.value = 0; expect(h.updates.some((p) => p.phase === "transferring" || p.phase === "interrupted")).toBe(false); // A real CouchDB catch-up must still arm normally in the same session. - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("db-first"), receivedNote("db-second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); await h.onUnload[0](); @@ -148,18 +176,20 @@ describe("useSyncGuardFeature", () => { it("never blocks normal transfers and ignores chunks and duplicate note revisions", async () => { const h = setup({ minimumFiles: 3 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("first"), { _id: "h:chunk", _rev: "1-a", type: "leaf" } as never]); expect(h.updates.some((p) => p.phase === "transferring")).toBe(false); await h.onUnload[0](); }); it("preserves offline access through manual bypass and never re-locks that catch-up", async () => { + vi.useFakeTimers(); const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first")]); - h.couchDbCatchUpActivityCount.value = 0; + h.setCouchDbPhase("idle"); + await vi.advanceTimersByTimeAsync(1); expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); h.bypass(); expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); @@ -171,14 +201,14 @@ describe("useSyncGuardFeature", () => { it("never shows a guard when disabled and releases an active guard when the setting is disabled", async () => { const h = setup({ enabled: false, minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first")]); expect(h.updates.some((p) => p.phase === "transferring")).toBe(false); await h.onUnload[0](); const enabled = setup({ minimumFiles: 1 }); await enabled.onResumed[0](); - enabled.couchDbCatchUpActivityCount.value = 1; + enabled.setCouchDbPhase("catch-up"); await enabled.parseHandlers[0]([receivedNote("first")]); expect(enabled.updates[enabled.updates.length - 1]?.phase).toBe("transferring"); enabled.settings.syncGuardEnabled = false; @@ -189,11 +219,11 @@ describe("useSyncGuardFeature", () => { it("observes an internal LiveSync reconnect even without an application resume notification", async () => { const h = setup({ minimumFiles: 1 }); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("after-reconnect")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); await h.onUnload[0](); }); @@ -201,13 +231,13 @@ describe("useSyncGuardFeature", () => { it("preserves an active guard across duplicate resume notifications", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); await h.onResumed[0](); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); await h.onUnload[0](); }); @@ -215,7 +245,7 @@ describe("useSyncGuardFeature", () => { it("preserves accumulated files before the threshold when resume fires twice during one pull", async () => { const h = setup({ minimumFiles: 3 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); await h.onResumed[0](); await h.parseHandlers[0]([receivedNote("three")]); @@ -229,11 +259,11 @@ describe("useSyncGuardFeature", () => { it("keeps the guard after a received note could not be written to the Vault", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("failed")]); h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED); - h.replicationStatics.value = { syncStatus: "COMPLETED" }; - h.couchDbCatchUpActivityCount.value = 0; + h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" }; + h.setCouchDbPhase("continuous"); expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted"); await h.onUnload[0](); }); @@ -241,7 +271,7 @@ describe("useSyncGuardFeature", () => { it("responds immediately to settings saved by the UI without setting-realised", async () => { const h = setup({ minimumFiles: 20 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("observing"); h.settings.syncGuardMinimumFiles = 2; @@ -256,12 +286,12 @@ describe("useSyncGuardFeature", () => { it("does not re-lock after manual bypass when an internal reconnect starts", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); h.bypass(); - h.couchDbCatchUpActivityCount.value = 0; - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("continuous"); + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); await h.onUnload[0](); @@ -270,7 +300,7 @@ describe("useSyncGuardFeature", () => { it("preserves manual bypass across duplicate resume while the same CouchDB pull remains active", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); - h.couchDbCatchUpActivityCount.value = 1; + h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); h.bypass(); From b552c2a455386b350dafbe158b6f683b97fb826d Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:37:01 +0300 Subject: [PATCH 10/17] test(syncguard): protect catch-up handoff while finite pull settles --- src/serviceFeatures/syncGuard/index.ts | 15 +++++++++++++-- .../syncGuard/syncGuardFeature.unit.spec.ts | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 93b1f919..468bbd2c 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -102,9 +102,14 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard guard.catchUpSucceeded(); guard.catchUpActivityChanged(false, readPendingCount(), Date.now()); } else if (guard.presentation.phase !== "idle" && terminalPhaseTimer === undefined) { + // The one-shot PouchDB progress observer can report 'idle' before the + // enclosing finite CouchDB pull has finished releasing its resources. + // This counter is not used to identify the provider; CouchDB phase owns that. + if (phase === "idle" && services.replicator.finiteReplicationActivityCount.value > 0) return; // 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. + // before Commonlib publishes 'continuous'. When the enclosing finite + // transfer settles, 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; @@ -119,6 +124,9 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard else guard.updateThreshold(services.setting.currentSettings().syncGuardMinimumFiles); }; const onPendingChanged = () => checkForUnlock(); + const onFiniteChanged = () => { + if (services.replicator.finiteReplicationActivityCount.value === 0) onReplicationChanged(); + }; const removeReflectionFailureHandler = services.context.events.onEvent(EVENT_VAULT_REFLECTION_FAILED, () => { guard.reflectionFailed(); }); @@ -127,6 +135,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard }); const replication = services.replicator.replicationStatics; + const finite = services.replicator.finiteReplicationActivityCount; const pendingSources = [ services.replication.replicationResultCount, services.replication.databaseQueueCount, @@ -139,6 +148,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard chunkFetchCounts, ]; replication.onChanged(onReplicationChanged); + finite.onChanged(onFiniteChanged); for (const source of pendingSources) source.onChanged(onPendingChanged); // Register before the ordinary replication scheduling handler so the first finite pull @@ -164,6 +174,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard stopTimer(); stopTerminalPhaseTimer(); replication.offChanged(onReplicationChanged); + finite.offChanged(onFiniteChanged); for (const source of pendingSources) source.offChanged(onPendingChanged); overlay.dispose(); removeSettingSavedHandler(); diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index bf8d5b4b..26495757 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -159,6 +159,23 @@ describe("useSyncGuardFeature", () => { await h.onUnload[0](); }); + it("waits for the finite pull to settle when progress temporarily reports idle", async () => { + vi.useFakeTimers(); + const h = setup({ minimumFiles: 1 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("first")]); + h.setCouchDbPhase("idle"); + await vi.advanceTimersByTimeAsync(5_000); // Resource disposal may be asynchronous. + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + h.finiteReplicationActivityCount.value = 0; + h.setCouchDbPhase("continuous"); + await vi.advanceTimersByTimeAsync(1); + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + await h.onUnload[0](); + }); + it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); From 2d3a5e5ff8960ee1e752cafd98405192d6539a1e Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:51:05 +0300 Subject: [PATCH 11/17] fix(syncguard): preserve catch-up state across transient idle resume --- .../syncGuard/SyncGuardState.ts | 4 +++ src/serviceFeatures/syncGuard/index.ts | 11 +++--- .../syncGuard/syncGuardFeature.unit.spec.ts | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts index 41f9bce0..d3484064 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -33,6 +33,10 @@ export class SyncGuardState { return this.bypassed; } + get isCatchUpActive(): boolean { + return this.catchUpActive; + } + /** 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; diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 468bbd2c..fc7b83e0 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -72,9 +72,12 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard guard.disable(); return; } - // A duplicate resume in the same active catch-up must honour a user's - // explicit decision to continue editing rather than silently re-lock. - if (guard.wasBypassed && isCouchDbCatchUp()) { + // PouchDB briefly reports 'idle' when disposing its progress observer, while + // the enclosing CouchDB catch-up still owns the previous guard generation. + // Do not reset that generation on duplicate resume, even before the threshold. + const sameCatchUp = guard.isCatchUpActive || isCouchDbCatchUp(); + // An explicit bypass must survive this same transient 'idle'. + if (guard.wasBypassed && sameCatchUp) { return; } // Duplicate resume notifications or a visibility transition must not discard an @@ -83,7 +86,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard guard.presentation.phase === "interrupted") { return; } - if (guard.presentation.phase === "observing" && isCouchDbCatchUp()) { + if (guard.presentation.phase === "observing" && sameCatchUp) { return; } guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles); diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index 26495757..108d7644 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -176,6 +176,41 @@ describe("useSyncGuardFeature", () => { await h.onUnload[0](); }); + it("keeps received-note counting across duplicate resume during transient idle with finite pull active", async () => { + vi.useFakeTimers(); + const h = setup({ minimumFiles: 3 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); + h.setCouchDbPhase("idle"); + await h.onResumed[0](); // Must not reset the already observed two notes. + await h.parseHandlers[0]([receivedNote("third")]); + expect(h.updates[h.updates.length - 1]).toMatchObject({ + phase: "transferring", + receivedFiles: 3, + }); + h.finiteReplicationActivityCount.value = 0; + h.setCouchDbPhase("continuous"); + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + await h.onUnload[0](); + }); + + it("honours manual bypass across duplicate resume while catch-up reports transient idle", async () => { + vi.useFakeTimers(); + const h = setup({ minimumFiles: 1 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("first")]); + h.bypass(); + h.setCouchDbPhase("idle"); + await h.onResumed[0](); + await h.parseHandlers[0]([receivedNote("second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + await h.onUnload[0](); + }); + it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); From 47828e934b18666c9863eeab9c655f2c52159990 Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:56:01 +0300 Subject: [PATCH 12/17] fix(syncguard): retire bypass on catch-up completion and close CSS rule --- .../syncGuard/SyncGuardState.ts | 11 ++++- .../syncGuard/SyncGuardState.unit.spec.ts | 22 +++++++++ src/serviceFeatures/syncGuard/index.ts | 2 +- .../syncGuard/syncGuardFeature.unit.spec.ts | 49 ++++++++++++++++++- styles.css | 2 + test/syncGuardCss.unit.spec.ts | 18 +++++++ 6 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 test/syncGuardCss.unit.spec.ts diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts index d3484064..2e93c6d8 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -59,7 +59,16 @@ export class SyncGuardState { /** The CouchDB catch-up phase ends before continuous replication and Vault reflection finish. */ catchUpActivityChanged(active: boolean, pendingCount: number, now: number): void { - if (this.phase === "idle" || this.bypassed || this.catchUpActive === active) return; + if (this.catchUpActive === active) return; + // A manual bypass lasts only for this catch-up generation. Even while the + // overlay is closed, observe its terminal transition so that a later + // reconnect can protect the workspace again. + if (this.bypassed) { + this.catchUpActive = active; + if (!active) this.bypassed = false; + return; + } + if (this.phase === "idle") return; if (active) { // 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. diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts index 14578577..7cf58ec4 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts @@ -54,6 +54,28 @@ describe("SyncGuardState", () => { expect(guard.presentation.phase).toBe("idle"); }); + it("expires the manual bypass when its catch-up completes and protects the next catch-up", () => { + const { guard } = setup(); + guard.resume(true, 2); + guard.catchUpActivityChanged(true, 0, 0); + guard.receivedDocuments(["first", "second"]); + expect(guard.presentation.phase).toBe("transferring"); + guard.bypass(); + expect(guard.wasBypassed).toBe(true); + guard.catchUpActivityChanged(true, 0, 5); + expect(guard.isCatchUpActive).toBe(true); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 10); + expect(guard.isCatchUpActive).toBe(false); + expect(guard.wasBypassed).toBe(false); + expect(guard.presentation.phase).toBe("idle"); + + guard.resume(true, 2); + guard.catchUpActivityChanged(true, 0, 20); + guard.receivedDocuments(["another-first", "another-second"]); + expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 2 }); + }); + it("does not unlock when a new pull starts while applying an earlier completed transfer", () => { const { guard } = setup(); guard.resume(true, 1); diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index fc7b83e0..3b0ff5e4 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -104,7 +104,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard // 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) { + } else if ((guard.presentation.phase !== "idle" || guard.isCatchUpActive) && terminalPhaseTimer === undefined) { // The one-shot PouchDB progress observer can report 'idle' before the // enclosing finite CouchDB pull has finished releasing its resources. // This counter is not used to identify the provider; CouchDB phase owns that. diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index 108d7644..1d63c58e 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -211,6 +211,51 @@ describe("useSyncGuardFeature", () => { await h.onUnload[0](); }); + it("re-arms protection for a new substantial catch-up after bypass, completion, suspension and resume", async () => { + const h = setup({ minimumFiles: 2 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + h.bypass(); + + // A duplicate resume before the first transfer finishes must honour the bypass. + await h.onResumed[0](); + await h.parseHandlers[0]([receivedNote("third"), receivedNote("fourth")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + + // Successful hand-off to Continuous retires the bypassed generation. + h.setCouchDbPhase("continuous"); + await h.onSuspending[0](); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("new-first"), receivedNote("new-second")]); + expect(h.updates[h.updates.length - 1]).toMatchObject({ + phase: "transferring", + receivedFiles: 2, + }); + await h.onUnload[0](); + }); + + it("re-arms protection after bypassed catch-up fails and later restarts", async () => { + vi.useFakeTimers(); + const h = setup({ minimumFiles: 1 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("failed")]); + h.bypass(); + h.setCouchDbPhase("idle"); + h.finiteReplicationActivityCount.value = 0; + await vi.advanceTimersByTimeAsync(1); + await h.onSuspending[0](); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("after-restart")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + await h.onUnload[0](); + }); + it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => { const h = setup({ minimumFiles: 2 }); await h.onResumed[0](); @@ -335,7 +380,7 @@ describe("useSyncGuardFeature", () => { await h.onUnload[0](); }); - it("does not re-lock after manual bypass when an internal reconnect starts", async () => { + it("re-locks on a new internal catch-up after a bypassed transfer has completed", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); h.setCouchDbPhase("catch-up"); @@ -345,7 +390,7 @@ describe("useSyncGuardFeature", () => { h.setCouchDbPhase("continuous"); h.setCouchDbPhase("catch-up"); 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("transferring"); await h.onUnload[0](); }); diff --git a/styles.css b/styles.css index 5bdce64b..5068f752 100644 --- a/styles.css +++ b/styles.css @@ -873,6 +873,8 @@ body.is-mobile .livesync-compatibility-review-notice { .sls-sync-guard-bypass { min-height: 44px; min-width: 44px; +} + .livesync-stat-item { font-variant-numeric: tabular-nums; } diff --git a/test/syncGuardCss.unit.spec.ts b/test/syncGuardCss.unit.spec.ts new file mode 100644 index 00000000..3e6b85dc --- /dev/null +++ b/test/syncGuardCss.unit.spec.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const stylesheet = readFileSync(fileURLToPath(new URL("../styles.css", import.meta.url)), "utf8"); + +describe("SyncGuard CSS", () => { + it("closes its bypass rule before the existing status display rules", () => { + const bypassStart = stylesheet.indexOf(".sls-sync-guard-bypass {"); + const statusStart = stylesheet.indexOf(".livesync-stat-item {", bypassStart); + expect(bypassStart).toBeGreaterThanOrEqual(0); + expect(statusStart).toBeGreaterThan(bypassStart); + const bypassRule = stylesheet.slice(bypassStart, statusStart); + expect(bypassRule).toMatch(/min-height:\s*44px;/); + expect(bypassRule).toMatch(/min-width:\s*44px;/); + expect(bypassRule).toMatch(/}\s*$/); + }); +}); From 9b47310a3cd1ecedbf1dcfd006a21d4abcb7d9bd Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:05:28 +0300 Subject: [PATCH 13/17] feat(settings): keep SyncGuard preferences in device-local storage --- docs/settings.md | 2 + ...iveSyncSettingTab.declarative.unit.spec.ts | 43 ++++++- .../ObsidianLiveSyncSettingTab.ts | 37 ++++-- .../SettingDialogue/settingConstants.ts | 61 +++++++--- .../services/ObsidianSettingService.ts | 90 +++++++++++++- .../ObsidianSettingService.unit.spec.ts | 112 +++++++++++++++++- 6 files changed, 309 insertions(+), 36 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index cdbc5889..707cade8 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -206,6 +206,8 @@ Setting key: syncGuardEnabled When enabled on this device, the workspace temporarily blocks interaction after a large batch of incoming note changes is detected during CouchDB LiveSync startup or resume. Incoming changes continue to be received and applied while the workspace is protected. The overlay can be bypassed at any time, including when synchronisation fails or the remote is unavailable. The preference is device-local; it does not affect other synchronising devices. +Both SyncGuard preferences are stored in device-local storage for this Vault, separately from the main plug-in settings. They are not transferred through Setup URIs. Existing values saved by earlier test builds are migrated to device-local storage when the plug-in starts. + The threshold avoids interruption for ordinary small transfers. However, the number of changes cannot be known before they arrive: some notes may be updated before the guard activates. The guard is a user-interface aid, not an atomic Vault snapshot or a restriction on other plug-ins and external tools. It does not apply to other remote types or to ordinary live changes after the initial catch-up. #### Minimum received files to protect the workspace diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts index 029741af..afe7986f 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.declarative.unit.spec.ts @@ -1,6 +1,7 @@ import { MemoryValueStore } from "@vrtmrz/value-store"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { DEFAULT_SYNC_GUARD_SETTINGS, type SyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts"; import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian"; import type { PageFunctions } from "./SettingPane.ts"; @@ -152,6 +153,7 @@ type SettingsTabOptions = { }; function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab { + const syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS }; const core = { settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, confirm: { @@ -160,6 +162,10 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe services: { setting: { deviceValues: new MemoryValueStore(), + syncGuardSettings, + setSyncGuardSetting: vi.fn(async (key: keyof SyncGuardSettings, value: boolean | number) => { + Object.assign(syncGuardSettings, { [key]: value }); + }), getDeviceAndVaultName: vi.fn(() => ""), saveSettingData: vi.fn(async () => undefined), }, @@ -179,8 +185,8 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe }; const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); Object.assign(tab, { - _editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, - initialSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, + _editingSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true }, + initialSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true }, }); return tab; } @@ -347,6 +353,39 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { ).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]); }); + it("persists declarative SyncGuard controls through Obsidian's local settings service", async () => { + const tab = createSettingsTab(); + const setting = tab.services.setting as typeof tab.services.setting & { + setSyncGuardSetting: ReturnType; + }; + const initialCoreSettings = { ...tab.core.settings }; + + await tab.setControlValue("syncGuardEnabled", true); + await tab.setControlValue("syncGuardMinimumFiles", 6); + + expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(1, "syncGuardEnabled", true); + expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(2, "syncGuardMinimumFiles", 6); + expect(tab.getControlValue("syncGuardEnabled")).toBe(true); + expect(tab.getControlValue("syncGuardMinimumFiles")).toBe(6); + expect(tab.core.settings).toEqual(initialCoreSettings); + expect(setting.saveSettingData).not.toHaveBeenCalled(); + }); + + it("refreshes active settings when local SyncGuard preferences change elsewhere", async () => { + const tab = createSettingsTab(); + tab.isShown = true; + const refresh = vi.spyOn(tab, "requestPageRefresh").mockImplementation(() => undefined); + const setting = tab.services.setting as typeof tab.services.setting & { syncGuardSettings: SyncGuardSettings }; + setting.syncGuardSettings.syncGuardEnabled = true; + setting.syncGuardSettings.syncGuardMinimumFiles = 13; + + await tab.requestReload(); + + expect(tab.editingSettings.syncGuardEnabled).toBe(true); + expect(tab.editingSettings.syncGuardMinimumFiles).toBe(13); + expect(refresh).toHaveBeenCalledOnce(); + }); + it("omits the old Setup child page and keeps standard General and Advanced pages native", () => { const tab = createSettingsTab(); const pages = collectPages(tab.getSettingDefinitions()); diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index a3c72a31..e5680d8e 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -77,6 +77,11 @@ import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from import { SetupManager } from "@/modules/features/SetupManager.ts"; import { isP2PMainRemote } from "@/common/remoteConfiguration.ts"; import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts"; +import { + getSyncGuardSettings, + isSyncGuardSettingKey, + setSyncGuardSetting, +} from "@/modules/services/ObsidianSettingService.ts"; // For creating a document // const toc = new Set(); @@ -138,6 +143,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { if ( this.isReconstructionRequiredSetting(k) || this.isConfigurationEncryptionSetting(k) || + isSyncGuardSettingKey(k) || k in OnDialogSettingsDefault ) { // //@ts-ignore @@ -157,6 +163,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { (e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e) ); await this.saveConfigurationEncryptionIfDirty(changedKeys); + for (const key of changedKeys.filter(isSyncGuardSettingKey)) { + await setSyncGuardSetting(this.services.setting, key, this.editingSettings[key]); + this.copySettingValue(this.initialSettings, this.editingSettings, key); + } this.applySetting(changedKeys); this.reloadAllSettings(); } @@ -177,6 +187,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { await this.saveConfigurationEncryptionIfDirty(appliedKeys); for (const k of appliedKeys) { if (this.isConfigurationEncryptionSetting(k)) continue; + if (isSyncGuardSettingKey(k)) { + await setSyncGuardSetting(this.services.setting, k, this.editingSettings[k]); + this.copySettingValue(this.initialSettings, this.editingSettings, k); + continue; + } if (k in OnDialogSettingsDefault) { await this.saveLocalSetting(k as keyof OnDialogSettings); this.copySettingValue(this.initialSettings, this.editingSettings, k); @@ -307,7 +322,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { ret.configPassphrase = this.localConfigurationPassphrase; ret.preset = ""; ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName(); - return ret; + return { ...ret, ...getSyncGuardSettings(this.services.setting) }; } computeAllLocalSettings(): Partial { const syncMode = this.editingSettings?.liveSync @@ -375,7 +390,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { return; } const localSetting = this.reloadAllLocalSettings(); - if (key in this.core.settings) { + if (isSyncGuardSettingKey(key) || key in this.core.settings) { if (key in localSetting) { this.copySettingValue(this.initialSettings, localSetting, key); this.copySettingValue(this.editingSettings, localSetting, key); @@ -567,25 +582,25 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { const nativeTabIsShown = this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown(); if (this.isShown || nativeTabIsShown) { - const newConf = this.core.settings; - const keys = Object.keys(newConf) as (keyof ObsidianLiveSyncSettings)[]; + const newConf = { ...this.core.settings, ...getSyncGuardSettings(this.services.setting) }; + const keys = Object.keys(newConf) as AllSettingItemKey[]; let hasLoaded = false; let catalogueVisibilityChanged = false; for (const k of keys) { - if (isObjectDifferent(newConf[k], this.initialSettings?.[k])) { + if (isObjectDifferent(Reflect.get(newConf, k), this.initialSettings?.[k])) { // Something has changed - if (this.isDirty(k as AllSettingItemKey)) { + if (this.isDirty(k)) { // And modified. this.core.confirm.askInPopup( `config-reloaded-${k}`, $msg("obsidianLiveSyncSettingTab.msgSettingModified", { - setting: getConfName(k as AllSettingItemKey), + setting: getConfName(k), }), (anchor) => { anchor.text = $msg("obsidianLiveSyncSettingTab.optionHere"); anchor.addEventListener("click", () => { - this.refreshSetting(k as AllSettingItemKey); - if (this.changesPageCatalogue(k as AllSettingItemKey)) { + this.refreshSetting(k); + if (this.changesPageCatalogue(k)) { this.requestCatalogueRefresh(); } else { this.requestPageRefresh(); @@ -595,12 +610,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { ); } else { // not modified - this.refreshSetting(k as AllSettingItemKey); + this.refreshSetting(k); if (k in OnDialogSettingsDefault) { continue; } hasLoaded = true; - if (this.changesPageCatalogue(k as AllSettingItemKey)) { + if (this.changesPageCatalogue(k)) { catalogueVisibilityChanged = true; } } diff --git a/src/modules/features/SettingDialogue/settingConstants.ts b/src/modules/features/SettingDialogue/settingConstants.ts index 495f8f76..a3a5c201 100644 --- a/src/modules/features/SettingDialogue/settingConstants.ts +++ b/src/modules/features/SettingDialogue/settingConstants.ts @@ -1,25 +1,50 @@ -export { - AllSettingDefault, - OnDialogSettingsDefault, - SettingInformation, -} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants"; -export type { - AllSettings, - AllSettingItemKey, - AllStringItemKey, - AllNumericItemKey, - AllBooleanItemKey, - OnDialogSettings, - ValueOf, -} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants"; +export { OnDialogSettingsDefault } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants"; +export type { OnDialogSettings } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants"; import { + AllSettingDefault as CommonlibAllSettingDefault, + SettingInformation as CommonlibSettingInformation, getConfig as getCommonlibConfig, getConfName as getCommonlibConfName, - type AllSettingItemKey, + type AllSettings as CommonlibAllSettings, + type AllSettingItemKey as CommonlibAllSettingItemKey, + type AllStringItemKey as CommonlibAllStringItemKey, + type AllNumericItemKey as CommonlibAllNumericItemKey, + type AllBooleanItemKey as CommonlibAllBooleanItemKey, } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants"; +import type { ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context"; import { translateLiveSyncMessage } from "@/common/translation"; +import { + DEFAULT_SYNC_GUARD_SETTINGS, + isSyncGuardSettingKey, + type SyncGuardSettingKey, + type SyncGuardSettings, +} from "@/modules/services/ObsidianSettingService"; + +export type AllSettings = Omit & SyncGuardSettings; +export type AllStringItemKey = CommonlibAllStringItemKey; +export type AllNumericItemKey = Exclude | "syncGuardMinimumFiles"; +export type AllBooleanItemKey = Exclude | "syncGuardEnabled"; +export type AllSettingItemKey = Exclude | SyncGuardSettingKey; +export type ValueOf = AllSettings[T]; + +const syncGuardSettingInformation: Record = { + syncGuardEnabled: { + name: "Enable SyncGuard", + desc: "Enable device-local SyncGuard protection during synchronisation.", + }, + syncGuardMinimumFiles: { + name: "SyncGuard minimum files", + desc: "Minimum number of Vault files required for SyncGuard protection to apply.", + }, +}; + +export const AllSettingDefault = { ...CommonlibAllSettingDefault, ...DEFAULT_SYNC_GUARD_SETTINGS }; +export const SettingInformation: Partial> = { + ...CommonlibSettingInformation, + ...syncGuardSettingInformation, +}; // Commonlib defaults `translate` to its English-only translator, so every caller which omits // it silently renders English regardless of `displayLanguage`. Default it to the LiveSync @@ -28,10 +53,12 @@ import { translateLiveSyncMessage } from "@/common/translation"; /** `getConfig` with the LiveSync catalogue applied by default. */ export function getConfig(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) { - return getCommonlibConfig(key, translate); + if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key]; + return getCommonlibConfig(key as CommonlibAllSettingItemKey, translate); } /** `getConfName` with the LiveSync catalogue applied by default. See `getConfig`. */ export function getConfName(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) { - return getCommonlibConfName(key, translate); + if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key].name; + return getCommonlibConfName(key as CommonlibAllSettingItemKey, translate); } diff --git a/src/modules/services/ObsidianSettingService.ts b/src/modules/services/ObsidianSettingService.ts index 87b93b59..0eabbc69 100644 --- a/src/modules/services/ObsidianSettingService.ts +++ b/src/modules/services/ObsidianSettingService.ts @@ -9,12 +9,48 @@ import { } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService"; import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +export type SyncGuardSettings = { + syncGuardEnabled: boolean; + syncGuardMinimumFiles: number; +}; + +export type SyncGuardSettingKey = keyof SyncGuardSettings; + +export const DEFAULT_SYNC_GUARD_SETTINGS: Readonly = { + syncGuardEnabled: false, + syncGuardMinimumFiles: 20, +}; + +export function isSyncGuardSettingKey(key: string): key is SyncGuardSettingKey { + return key === "syncGuardEnabled" || key === "syncGuardMinimumFiles"; +} + +/** Access the Obsidian host preferences from a service exposed through the shared hub type. */ +export function getSyncGuardSettings(setting: SettingService): Readonly { + return (setting as ObsidianSettingService).syncGuardSettings; +} + +export async function setSyncGuardSetting( + setting: SettingService, + key: K, + value: SyncGuardSettings[K] +): Promise { + await (setting as ObsidianSettingService).setSyncGuardSetting(key, value); +} + export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSettings | undefined { if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined; return data as ObsidianLiveSyncSettings; } export class ObsidianSettingService extends SettingService { + private _syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS }; + private legacySyncGuardSettings: Partial = {}; + + get syncGuardSettings(): Readonly { + return this._syncGuardSettings; + } + constructor(context: T, dependencies: SettingServiceDependencies) { super(context, dependencies); this.onSettingSaved.addHandler((settings) => { @@ -26,10 +62,60 @@ export class ObsidianSettingService extends Se return Promise.resolve(true); }); } + + override async loadSettings(): Promise { + await super.loadSettings(); + const storedEnabled = await this.config.getObject("syncGuardEnabled"); + const storedMinimumFiles = await this.config.getObject("syncGuardMinimumFiles"); + const enabled = typeof storedEnabled === "boolean" ? storedEnabled : this.legacySyncGuardSettings.syncGuardEnabled; + const minimumFiles = + typeof storedMinimumFiles === "number" && Number.isFinite(storedMinimumFiles) && storedMinimumFiles >= 1 + ? storedMinimumFiles + : this.legacySyncGuardSettings.syncGuardMinimumFiles; + + this._syncGuardSettings = { + syncGuardEnabled: enabled ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardEnabled, + syncGuardMinimumFiles: minimumFiles ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardMinimumFiles, + }; + if (storedEnabled === undefined && enabled !== undefined) { + await this.config.setObject("syncGuardEnabled", enabled); + } + if (storedMinimumFiles === undefined && minimumFiles !== undefined) { + await this.config.setObject("syncGuardMinimumFiles", minimumFiles); + } + } + + async setSyncGuardSetting(key: K, value: SyncGuardSettings[K]): Promise { + if (key === "syncGuardMinimumFiles" && (typeof value !== "number" || !Number.isInteger(value) || value < 1)) { + throw new RangeError("SyncGuard minimum files must be a positive integer"); + } + if (this._syncGuardSettings[key] === value) return; + await this.config.setObject(key, value); + this._syncGuardSettings = { ...this._syncGuardSettings, [key]: value }; + this.context.events.emitEvent(EVENT_SETTING_SAVED, this.currentSettings()); + } + protected override async saveData(data: ObsidianLiveSyncSettings): Promise { - return await this.context.liveSyncPlugin.saveData(data); + const persisted: Record = { ...data }; + delete persisted.syncGuardEnabled; + delete persisted.syncGuardMinimumFiles; + return await this.context.liveSyncPlugin.saveData(persisted); } protected override async loadData(): Promise { - return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData()); + const data = normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData()); + if (!data) return undefined; + const persisted: Record = { ...data }; + const { syncGuardEnabled, syncGuardMinimumFiles } = persisted; + this.legacySyncGuardSettings = { + ...(typeof syncGuardEnabled === "boolean" ? { syncGuardEnabled } : {}), + ...(typeof syncGuardMinimumFiles === "number" && + Number.isFinite(syncGuardMinimumFiles) && + syncGuardMinimumFiles >= 1 + ? { syncGuardMinimumFiles } + : {}), + }; + delete persisted.syncGuardEnabled; + delete persisted.syncGuardMinimumFiles; + return persisted as unknown as ObsidianLiveSyncSettings; } } diff --git a/src/modules/services/ObsidianSettingService.unit.spec.ts b/src/modules/services/ObsidianSettingService.unit.spec.ts index 6df8404d..db7bfa2d 100644 --- a/src/modules/services/ObsidianSettingService.unit.spec.ts +++ b/src/modules/services/ObsidianSettingService.unit.spec.ts @@ -1,20 +1,28 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; -import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import { MemoryValueStore } from "@vrtmrz/value-store"; import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts"; -import { ObsidianSettingService } from "./ObsidianSettingService.ts"; +import { getSyncGuardSettings, ObsidianSettingService, setSyncGuardSetting } from "./ObsidianSettingService.ts"; function createSettingService( vaultName: string, - deviceValues: MemoryValueStore + deviceValues: MemoryValueStore, + savedData?: unknown, + saveData = vi.fn(async (_settings: unknown) => undefined) ): ObsidianSettingService { const context = createServiceContext() as unknown as ObsidianServiceContext; + context.liveSyncPlugin = { + loadData: vi.fn(async () => savedData), + saveData, + } as unknown as ObsidianServiceContext["liveSyncPlugin"]; const APIService = { addLog: () => undefined, getSystemVaultName: () => vaultName, + getAppID: () => "test-app", } as unknown as IAPIService; return new ObsidianSettingService(context, { APIService, deviceValues }); } @@ -52,3 +60,99 @@ describe("normaliseObsidianSettingsData", () => { await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config"); }); }); + +describe("SyncGuard device-local settings", () => { + const savedSettings = { ...DEFAULT_SETTINGS, isConfigured: true, additionalSuffixOfDatabaseName: "" }; + + it("loads safe defaults when no local values were saved", async () => { + const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings); + + await setting.loadSettings(); + + expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 }); + }); + + it("migrates preferences stored by earlier builds and excludes them from plugin data", async () => { + const localValues = new MemoryValueStore(); + const saveData = vi.fn(async (_data: unknown) => undefined); + const setting = createSettingService( + "first-vault", + localValues, + { ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 7 }, + saveData + ); + + await setting.loadSettings(); + await setting.saveSettingData(); + + expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 7 }); + await expect(setting.config.getObject("syncGuardEnabled")).resolves.toBe(true); + await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(7); + expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardEnabled"); + expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardMinimumFiles"); + }); + + it("keeps existing device-local values authoritative over legacy plugin data", async () => { + const localValues = new MemoryValueStore(); + const setting = createSettingService( + "first-vault", + localValues, + { ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 5 } + ); + await localValues.setObject("first-vault--syncGuardEnabled", false); + await localValues.setObject("first-vault--syncGuardMinimumFiles", 30); + + await setting.loadSettings(); + + expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 30 }); + }); + + it("restores saved preferences after recreating the service", async () => { + const localValues = new MemoryValueStore(); + const first = createSettingService("first-vault", localValues, savedSettings); + await first.loadSettings(); + await setSyncGuardSetting(first, "syncGuardEnabled", true); + await setSyncGuardSetting(first, "syncGuardMinimumFiles", 12); + + const reloaded = createSettingService("first-vault", localValues, savedSettings); + await reloaded.loadSettings(); + + expect(getSyncGuardSettings(reloaded)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 12 }); + }); + + it("rejects invalid file thresholds without changing local settings", async () => { + const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings); + await setting.loadSettings(); + const storedBefore = await setting.config.getObject("syncGuardMinimumFiles"); + + for (const invalid of [0, -1, 1.5, NaN, Infinity]) { + await expect(setSyncGuardSetting(setting, "syncGuardMinimumFiles", invalid)).rejects.toThrow(RangeError); + } + + expect(getSyncGuardSettings(setting).syncGuardMinimumFiles).toBe(20); + await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(storedBefore); + }); + + it("persists edits only in this Vault's device store and notifies the guard", async () => { + const localValues = new MemoryValueStore(); + const saveData = vi.fn(async (_data: unknown) => undefined); + const first = createSettingService("first-vault", localValues, savedSettings, saveData); + const second = createSettingService("second-vault", localValues, savedSettings); + await first.loadSettings(); + await second.loadSettings(); + const initialSaveCount = saveData.mock.calls.length; + const changed = vi.fn(); + first.context.events.onEvent(EVENT_SETTING_SAVED, changed); + + await setSyncGuardSetting(first, "syncGuardEnabled", true); + await setSyncGuardSetting(first, "syncGuardMinimumFiles", 8); + await setSyncGuardSetting(first, "syncGuardEnabled", true); + + expect(getSyncGuardSettings(first)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 8 }); + expect(getSyncGuardSettings(second)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 }); + await expect(localValues.getObject("first-vault--syncGuardEnabled")).resolves.toBe(true); + await expect(localValues.getObject("first-vault--syncGuardMinimumFiles")).resolves.toBe(8); + expect(changed).toHaveBeenCalledTimes(2); + expect(saveData).toHaveBeenCalledTimes(initialSaveCount); + }); +}); From 7c1385fd79a57c9a56fc27aef2a1de404ee9fdfe Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:05:33 +0300 Subject: [PATCH 14/17] refactor(syncguard): read host-local preferences --- src/serviceFeatures/syncGuard/index.ts | 8 ++++---- .../syncGuard/syncGuardFeature.unit.spec.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 3b0ff5e4..72f3544d 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -3,6 +3,7 @@ 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 { getSyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts"; import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events"; import type { SyncGuardOverlay } from "./SyncGuardOverlay"; import { SyncGuardState } from "./SyncGuardState"; @@ -63,11 +64,10 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard const isEnabled = () => { const settings = services.setting.currentSettings(); return settings.isConfigured && settings.liveSync && settings.remoteType === REMOTE_COUCHDB && - settings.syncGuardEnabled; + getSyncGuardSettings(services.setting).syncGuardEnabled; }; const isCouchDbCatchUp = () => services.replicator.replicationStatics.value.couchDB?.phase === "catch-up"; const beginCatchUp = () => { - const settings = services.setting.currentSettings(); if (!isEnabled()) { guard.disable(); return; @@ -89,7 +89,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard if (guard.presentation.phase === "observing" && sameCatchUp) { return; } - guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles); + guard.resume(Boolean(isEnabled()), getSyncGuardSettings(services.setting).syncGuardMinimumFiles); guard.catchUpActivityChanged(isCouchDbCatchUp(), readPendingCount(), Date.now()); }; const onReplicationChanged = () => { @@ -124,7 +124,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard }; const onSettingsChanged = () => { if (!isEnabled()) guard.disable(); - else guard.updateThreshold(services.setting.currentSettings().syncGuardMinimumFiles); + else guard.updateThreshold(getSyncGuardSettings(services.setting).syncGuardMinimumFiles); }; const onPendingChanged = () => checkForUnlock(); const onFiniteChanged = () => { diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index 1d63c58e..abc6d1db 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -50,17 +50,20 @@ function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) { const batched = reactiveSource(0); const conflictProcessQueueCount = reactiveSource(0); const settings = { + syncGuardEnabled: overrides.enabled ?? true, + syncGuardMinimumFiles: overrides.minimumFiles ?? 2, + }; + const sharedSettings = { isConfigured: true, liveSync: true, remoteType: REMOTE_COUCHDB, - syncGuardEnabled: overrides.enabled ?? true, - syncGuardMinimumFiles: overrides.minimumFiles ?? 2, }; const host = { services: { context: createServiceContext(), setting: { - currentSettings: () => settings, + currentSettings: () => sharedSettings, + syncGuardSettings: settings, onSettingRealised: { addHandler: (fn: () => Promise) => onSettingRealised.push(fn) }, }, replication: { From ed6408cc5a4641d1b041250bf6997b823ec855d0 Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:09:26 +0300 Subject: [PATCH 15/17] fix(syncguard): notify local preference changes without reapplying sync settings --- src/common/events.ts | 3 +++ src/modules/services/ObsidianSettingService.ts | 5 ++++- .../services/ObsidianSettingService.unit.spec.ts | 6 +++++- src/serviceFeatures/syncGuard/index.ts | 11 ++++++++++- .../syncGuard/syncGuardFeature.unit.spec.ts | 10 +++++++--- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/common/events.ts b/src/common/events.ts index 220c420e..a03fb1da 100644 --- a/src/common/events.ts +++ b/src/common/events.ts @@ -6,6 +6,8 @@ export const EVENT_PLUGIN_UNLOADED = "plugin-unloaded"; export const EVENT_FILE_SAVED = "file-saved"; /** A received note could not be reflected from the local database into the Vault. */ export const EVENT_VAULT_REFLECTION_FAILED = "vault-reflection-failed"; +/** Device-local SyncGuard preferences changed without changing replication settings. */ +export const EVENT_SYNC_GUARD_PREFERENCES_CHANGED = "sync-guard-preferences-changed"; export const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed"; export const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings"; @@ -31,6 +33,7 @@ declare global { [EVENT_PLUGIN_LOADED]: undefined; [EVENT_PLUGIN_UNLOADED]: undefined; [EVENT_VAULT_REFLECTION_FAILED]: undefined; + [EVENT_SYNC_GUARD_PREFERENCES_CHANGED]: undefined; [EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined; [EVENT_REQUEST_OPEN_SETTINGS]: undefined; [EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined; diff --git a/src/modules/services/ObsidianSettingService.ts b/src/modules/services/ObsidianSettingService.ts index 0eabbc69..7ef5b2a1 100644 --- a/src/modules/services/ObsidianSettingService.ts +++ b/src/modules/services/ObsidianSettingService.ts @@ -8,6 +8,7 @@ import { type SettingServiceDependencies, } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService"; import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts"; export type SyncGuardSettings = { syncGuardEnabled: boolean; @@ -92,7 +93,9 @@ export class ObsidianSettingService extends Se if (this._syncGuardSettings[key] === value) return; await this.config.setObject(key, value); this._syncGuardSettings = { ...this._syncGuardSettings, [key]: value }; - this.context.events.emitEvent(EVENT_SETTING_SAVED, this.currentSettings()); + // These preferences change only the workspace overlay. Re-applying all + // replication settings would unnecessarily interrupt an active sync. + this.context.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED); } protected override async saveData(data: ObsidianLiveSyncSettings): Promise { diff --git a/src/modules/services/ObsidianSettingService.unit.spec.ts b/src/modules/services/ObsidianSettingService.unit.spec.ts index db7bfa2d..74cd4977 100644 --- a/src/modules/services/ObsidianSettingService.unit.spec.ts +++ b/src/modules/services/ObsidianSettingService.unit.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts"; import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import { MemoryValueStore } from "@vrtmrz/value-store"; import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; @@ -142,7 +143,9 @@ describe("SyncGuard device-local settings", () => { await second.loadSettings(); const initialSaveCount = saveData.mock.calls.length; const changed = vi.fn(); - first.context.events.onEvent(EVENT_SETTING_SAVED, changed); + const unrelatedReplicationSettingsSaved = vi.fn(); + first.context.events.onEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED, changed); + first.context.events.onEvent(EVENT_SETTING_SAVED, unrelatedReplicationSettingsSaved); await setSyncGuardSetting(first, "syncGuardEnabled", true); await setSyncGuardSetting(first, "syncGuardMinimumFiles", 8); @@ -153,6 +156,7 @@ describe("SyncGuard device-local settings", () => { await expect(localValues.getObject("first-vault--syncGuardEnabled")).resolves.toBe(true); await expect(localValues.getObject("first-vault--syncGuardMinimumFiles")).resolves.toBe(8); expect(changed).toHaveBeenCalledTimes(2); + expect(unrelatedReplicationSettingsSaved).not.toHaveBeenCalled(); expect(saveData).toHaveBeenCalledTimes(initialSaveCount); }); }); diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 72f3544d..d9522fe5 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -4,7 +4,11 @@ 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 { getSyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts"; -import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events"; +import { + EVENT_SETTING_SAVED, + EVENT_SYNC_GUARD_PREFERENCES_CHANGED, + EVENT_VAULT_REFLECTION_FAILED, +} from "@/common/events"; import type { SyncGuardOverlay } from "./SyncGuardOverlay"; import { SyncGuardState } from "./SyncGuardState"; @@ -136,6 +140,10 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard const removeSettingSavedHandler = services.context.events.onEvent(EVENT_SETTING_SAVED, () => { onSettingsChanged(); }); + const removeSyncGuardPreferencesHandler = services.context.events.onEvent( + EVENT_SYNC_GUARD_PREFERENCES_CHANGED, + onSettingsChanged + ); const replication = services.replicator.replicationStatics; const finite = services.replicator.finiteReplicationActivityCount; @@ -181,6 +189,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard for (const source of pendingSources) source.offChanged(onPendingChanged); overlay.dispose(); removeSettingSavedHandler(); + removeSyncGuardPreferencesHandler(); removeReflectionFailureHandler(); return Promise.resolve(true); }); diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index abc6d1db..3c0f8725 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -2,7 +2,11 @@ import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; import { REMOTE_COUCHDB, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { CouchDBReplicationPhase, ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; -import { EVENT_SETTING_SAVED, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events"; +import { + EVENT_SETTING_SAVED, + EVENT_SYNC_GUARD_PREFERENCES_CHANGED, + EVENT_VAULT_REFLECTION_FAILED, +} from "@/common/events"; import { describe, afterEach, expect, it, vi } from "vitest"; import { useSyncGuardFeature } from "./index"; import type { SyncGuardPresentation } from "./SyncGuardState"; @@ -375,10 +379,10 @@ describe("useSyncGuardFeature", () => { 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); + h.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED); expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); h.settings.syncGuardEnabled = false; - h.events.emitEvent(EVENT_SETTING_SAVED, h.settings as never); + h.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED); expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); await h.onUnload[0](); }); From 03a1523a58a0ef1eec1e708a9c5d93988bed322b Mon Sep 17 00:00:00 2001 From: Artem Ovchinnikov <53350796+Akilaydin@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:12:03 +0300 Subject: [PATCH 16/17] style(settings): remove unnecessary type assertions --- src/modules/features/SettingDialogue/settingConstants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/features/SettingDialogue/settingConstants.ts b/src/modules/features/SettingDialogue/settingConstants.ts index a3a5c201..725ae2ea 100644 --- a/src/modules/features/SettingDialogue/settingConstants.ts +++ b/src/modules/features/SettingDialogue/settingConstants.ts @@ -54,11 +54,11 @@ export const SettingInformation: Partial Date: Fri, 30 Oct 2026 06:38:32 +0000 Subject: [PATCH 17/17] docs: clarify SyncGuard bypass and local preference scope --- docs/settings.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/settings.md b/docs/settings.md index 9739cad9..e197d669 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -208,10 +208,14 @@ Setting key: syncGuardEnabled When enabled on this device, the workspace temporarily blocks interaction after a large batch of incoming note changes is detected during CouchDB LiveSync startup or resume. Incoming changes continue to be received and applied while the workspace is protected. The overlay can be bypassed at any time, including when synchronisation fails or the remote is unavailable. The preference is device-local; it does not affect other synchronising devices. -Both SyncGuard preferences are stored in device-local storage for this Vault, separately from the main plug-in settings. They are not transferred through Setup URIs. Existing values saved by earlier test builds are migrated to device-local storage when the plug-in starts. +Both SyncGuard preferences are stored locally for this device, Vault, and [Database suffix](#database-suffix), separately from the main plug-in settings. They are not transferred through Setup URIs. Existing values saved by earlier test builds are migrated to device-local storage when the plug-in starts. + +After changing the Database suffix, restart Obsidian, then check both SyncGuard preferences before relying on the overlay. Until Obsidian restarts, the settings screen can still show the previous suffix's values. The threshold avoids interruption for ordinary small transfers. However, the number of changes cannot be known before they arrive: some notes may be updated before the guard activates. The guard is a user-interface aid, not an atomic Vault snapshot or a restriction on other plug-ins and external tools. It does not apply to other remote types or to ordinary live changes after the initial catch-up. +**Continue without waiting** releases the workspace while synchronisation continues, so some notes may still be out of date. SyncGuard is primarily a start-up or resume safeguard; after a manual bypass, an internal reconnection does not necessarily show the overlay again. Check the synchronisation status when you need to know whether later changes have settled. + #### Minimum received files to protect the workspace Setting key: syncGuardMinimumFiles @@ -1188,6 +1192,8 @@ Do not use internal API Setting key: additionalSuffixOfDatabaseName LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured. +After changing this value, restart Obsidian and check the [SyncGuard preferences](#protect-the-workspace-during-a-large-catch-up-opt-in) for the selected suffix. + #### The Hash algorithm for chunk IDs (compatibility) Setting key: hashAlg