mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-31 13:28:18 +00:00
feat(syncguard): protect workspace during substantial CouchDB catch-up
This commit is contained in:
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.
|
||||
*/
|
||||
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.":
|
||||
|
||||
@@ -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<ObsidianServiceContext, LiveSyncCommands>;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 receivedIds = new Set<string>();
|
||||
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. */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user