feat(syncguard): protect workspace during substantial CouchDB catch-up

This commit is contained in:
Artem Ovchinnikov committed 2026-10-21 21:25:25 +03:00
1 parent bca2f2dcc6
commit f7c7ef364c
9 files changed
+832 -6

No files matched your search

@@ -7,6 +7,14 @@
* remove it from this map in the same change. * remove it from this map in the same change.
*/ */
export const liveSyncProvisionalEnglishMessages = { 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…", "Preparing follow-up report…": "Preparing follow-up report…",
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.", "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.": "Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
+3
View File
@@ -50,6 +50,8 @@ import { createFileReflectionProvenance } from "./serviceModules/FileReflectionP
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution"; import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts"; import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts"; import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
import { useSyncGuardFeature } from "./serviceFeatures/syncGuard";
import { SyncGuardOverlay } from "./serviceFeatures/syncGuard/SyncGuardOverlay";
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle"; import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics"; import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>; export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
@@ -196,6 +198,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
useP2PReplicatorCommands(core, replicator); useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator)); useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
useObsidianReplicationRibbonFeature(core); useObsidianReplicationRibbonFeature(core);
useSyncGuardFeature(core, (onBypass) => new SyncGuardOverlay(this.app.workspace.containerEl, onBypass));
useDatabaseDiagnosticsFeature(core); useDatabaseDiagnosticsFeature(core);
useRemoteConfiguration(core); useRemoteConfiguration(core);
@@ -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();
}
}
@@ -0,0 +1,191 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SyncGuardOverlay } from "./SyncGuardOverlay";
class FakeDocument {
activeElement: FakeElement | undefined;
listeners = new Map<string, (event: KeyboardEvent) => 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<string, () => void>();
readonly attributes = new Map<string, string>();
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();
});
});
@@ -14,9 +14,9 @@ export class SyncGuardState {
private phase: SyncGuardPhase = "idle"; private phase: SyncGuardPhase = "idle";
private receivedIds = new Set<string>(); private receivedIds = new Set<string>();
private minimumFiles = 20; private minimumFiles = 20;
private started = false;
private finiteActive = false; private finiteActive = false;
private observedCompletion = false; private observedCompletion = false;
private hadReflectionFailure = false;
private bypassed = false; private bypassed = false;
private quiescentSince: number | undefined; private quiescentSince: number | undefined;
@@ -29,31 +29,46 @@ export class SyncGuardState {
return { phase: this.phase, receivedFiles: this.receivedIds.size }; 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. */ /** Begins a fresh, initially non-blocking observation window on app resume. */
resume(enabled: boolean, minimumFiles: number): void { resume(enabled: boolean, minimumFiles: number): void {
this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20; this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20;
this.receivedIds.clear(); this.receivedIds.clear();
this.started = false;
this.finiteActive = false; this.finiteActive = false;
this.observedCompletion = false; this.observedCompletion = false;
this.hadReflectionFailure = false;
this.bypassed = false; this.bypassed = false;
this.quiescentSince = undefined; this.quiescentSince = undefined;
this.setPhase(enabled ? "observing" : "idle"); 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. */ /** A completed finite pull is expected before the long-running live connection begins. */
finiteActivityChanged(active: boolean, pendingCount: number, now: number): void { finiteActivityChanged(active: boolean, pendingCount: number, now: number): void {
if (this.phase === "idle" || this.bypassed || this.finiteActive === active) return; if (this.phase === "idle" || this.bypassed || this.finiteActive === active) return;
if (active) { if (active) {
if (this.started) return; // Only the first finite transfer of this resume generation is relevant. // A retry or a second pull can begin while the Vault is still applying an earlier batch.
this.started = true; // Do not let the previous successful completion unlock an active newer transfer.
this.finiteActive = true; this.finiteActive = true;
this.observedCompletion = false;
this.quiescentSince = undefined;
if (this.isBlocking()) this.setPhase("transferring");
return; return;
} }
this.finiteActive = false; this.finiteActive = false;
if (!this.isBlocking()) { if (!this.isBlocking()) {
this.setPhase("idle"); this.setPhase("idle");
} else if (!this.observedCompletion) { } else if (!this.observedCompletion || this.hadReflectionFailure) {
this.setPhase("interrupted"); this.setPhase("interrupted");
} else { } else {
this.setPhase("applying"); this.setPhase("applying");
@@ -67,15 +82,23 @@ export class SyncGuardState {
if (status === "COMPLETED") this.observedCompletion = true; 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. */ /** Count distinct incoming note Metadata, not Chunks or repeated revisions of a note. */
receivedDocuments(ids: readonly string[]): void { receivedDocuments(ids: readonly string[]): void {
if (!this.finiteActive || this.bypassed || this.phase === "idle") return; if (!this.finiteActive || this.bypassed || this.phase === "idle") return;
const wasBlocking = this.isBlocking(); const wasBlocking = this.isBlocking();
const previousCount = this.receivedIds.size;
for (const id of ids) { for (const id of ids) {
if (id) this.receivedIds.add(id); if (id) this.receivedIds.add(id);
if (this.receivedIds.size >= this.minimumFiles) break;
} }
if (!wasBlocking && this.receivedIds.size >= this.minimumFiles) this.setPhase("transferring"); 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. */ /** Unlock only after successful transfer and a stable empty local application queue. */
@@ -27,6 +27,8 @@ describe("SyncGuardState", () => {
expect(guard.presentation.phase).toBe("observing"); expect(guard.presentation.phase).toBe("observing");
guard.receivedDocuments(["f:three"]); guard.receivedDocuments(["f:three"]);
expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 3 }); expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 3 });
guard.receivedDocuments(["f:four", "f:four"]);
expect(guard.presentation.receivedFiles).toBe(4);
guard.replicationStatusChanged("COMPLETED"); guard.replicationStatusChanged("COMPLETED");
guard.finiteActivityChanged(false, 2, 10); guard.finiteActivityChanged(false, 2, 10);
expect(guard.presentation.phase).toBe("applying"); expect(guard.presentation.phase).toBe("applying");
@@ -53,6 +55,51 @@ describe("SyncGuardState", () => {
expect(guard.presentation.phase).toBe("idle"); 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", () => { it("does not confuse a stale completed status with this catch-up", () => {
const { guard } = setup(); const { guard } = setup();
guard.resume(true, 1); guard.resume(true, 1);
@@ -88,4 +135,15 @@ describe("SyncGuardState", () => {
guard.receivedDocuments(["f:one"]); guard.receivedDocuments(["f:one"]);
expect(guard.presentation.phase).toBe("transferring"); 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");
});
}); });
+150
View File
@@ -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<SyncGuardOverlay, "update" | "dispose">;
/**
* 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);
});
}
@@ -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<EntryDoc> {
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<EntryDoc>;
}
function setup(overrides: { enabled?: boolean; minimumFiles?: number } = {}) {
const onResumed: Array<() => Promise<boolean>> = [];
const onSuspending: Array<() => Promise<boolean>> = [];
const onUnload: Array<() => Promise<boolean>> = [];
const onSettingRealised: Array<() => Promise<boolean>> = [];
const parseHandlers: Array<(docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>> = [];
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<boolean>) => onSettingRealised.push(fn) },
},
replication: {
replicationResultCount,
databaseQueueCount,
storageApplyingCount,
parseSynchroniseResult: {
addHandler: (fn: (docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>) => {
parseHandlers.push(fn);
},
},
},
replicator: { finiteReplicationActivityCount, replicationStatics },
fileProcessing: { processing, totalQueued, batched },
conflict: { conflictProcessQueueCount },
appLifecycle: {
onResumed: {
addHandler: (fn: () => Promise<boolean>, prio?: number) => {
priority(prio);
onResumed.push(fn);
},
},
onSuspending: { addHandler: (fn: () => Promise<boolean>) => onSuspending.push(fn) },
onUnload: { addHandler: (fn: () => Promise<boolean>) => 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]();
});
});
+35
View File
@@ -839,3 +839,38 @@ body.is-mobile .livesync-compatibility-review-notice {
outline-offset: 1px; outline-offset: 1px;
border-radius: 2px; 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;
}