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.
|
* 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.":
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user