Merge main into shared runtime foundations

Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
vorotamoroz committed 2026-10-30 15:36:57 +00:00
commit bd05dde39c
83 files changed
+6313 -1507

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import {
FlagFilesHumanReadable,
FlagFilesOriginal,
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import { readFetchFlagState, writeFetchFlagState, type FetchFlagState } from "./redFlag.fetchStateMarkdown";
type FetchStateHost = NecessaryServices<never, "storageAccess">;
function endpointDescription(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
} catch {
return "";
}
}
/** Describe the current destination for confirmation, without connection credentials. */
export function describeFetchRemote(settings: ObsidianLiveSyncSettings): string {
switch (settings.remoteType) {
case REMOTE_COUCHDB:
return ["CouchDB", endpointDescription(settings.couchDB_URI), settings.couchDB_DBNAME].join(" / ");
case REMOTE_MINIO:
return [
"Object Storage",
endpointDescription(settings.endpoint),
settings.region,
settings.bucket,
settings.bucketPrefix,
]
.filter(Boolean)
.join(" / ");
case REMOTE_P2P:
return [
"P2P",
settings.P2P_AppID,
settings.P2P_roomID,
...settings.P2P_relays.split(",").map((relay) => endpointDescription(relay.trim())),
]
.filter(Boolean)
.join(" / ");
default:
throw new Error("The selected remote type cannot use Fast Setup.");
}
}
async function readFetchFlag(host: FetchStateHost) {
const storage = host.serviceModules.storageAccess;
// A newly scheduled fetch writes the human-readable flag, even if an older alias remains.
for (const name of [FlagFilesHumanReadable.FETCH_ALL, FlagFilesOriginal.FETCH_ALL]) {
const path = storage.normalisePath(name);
if (await storage.isExists(path)) {
return { path, markdown: await storage.readFileText(path) };
}
}
throw new Error("The scheduled fetch flag is missing.");
}
async function writeVerified(host: FetchStateHost, path: string, markdown: string) {
const storage = host.serviceModules.storageAccess;
if (!(await storage.writeFileAuto(path, markdown)) || (await storage.readFileText(path)) !== markdown) {
throw new Error("The Fast Setup choices could not be saved in the fetch flag.");
}
}
export async function readFetchSelection(host: FetchStateHost): Promise<FetchFlagState | "empty" | "unreadable"> {
const { markdown } = await readFetchFlag(host);
if (!markdown.trim()) return "empty";
return readFetchFlagState(markdown) ?? "unreadable";
}
export async function saveFetchSelection(host: FetchStateHost, stage1: string, stage2: string): Promise<void> {
const { path, markdown } = await readFetchFlag(host);
const state: FetchFlagState = {
type: "livesync-fast-setup",
version: 1,
stage1,
stage2,
};
const updated = writeFetchFlagState(markdown, state);
if (updated !== markdown) await writeVerified(host, path, updated);
}
export async function clearFetchSelection(host: FetchStateHost): Promise<void> {
const { path, markdown } = await readFetchFlag(host);
const cleared = writeFetchFlagState(markdown, undefined);
if (cleared !== markdown) await writeVerified(host, path, cleared);
}
/** Remove both aliases, retaining the authoritative flag until the other alias is gone. */
export async function deleteFetchFlags(host: NecessaryServices<never, "storageAccess">): Promise<void> {
const storage = host.serviceModules.storageAccess;
for (const name of [FlagFilesOriginal.FETCH_ALL, FlagFilesHumanReadable.FETCH_ALL]) {
const path = storage.normalisePath(name);
if (await storage.isExists(path)) await storage.delete(path, true);
if (await storage.isExists(path)) throw new Error(`Could not remove ${name}.`);
}
}
@@ -0,0 +1,202 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import {
FlagFilesHumanReadable,
FlagFilesOriginal,
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import {
clearFetchSelection,
deleteFetchFlags,
describeFetchRemote,
readFetchSelection,
saveFetchSelection,
} from "./redFlag.fetchState";
import { readFetchFlagState, writeFetchFlagState } from "./redFlag.fetchStateMarkdown";
import {
askSimpleFetchMode,
SIMPLE_FETCH_STAGE1_REMOTE_WINS,
SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE,
} from "./redFlag.simpleFetch";
const modern = FlagFilesHumanReadable.FETCH_ALL;
const legacy = FlagFilesOriginal.FETCH_ALL;
const stage1 = SIMPLE_FETCH_STAGE1_REMOTE_WINS;
const stage2 = SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE;
function fixture() {
const files = new Map<string, string>([[modern, ""]]);
const settings = { ...DEFAULT_SETTINGS, couchDB_URI: "https://example.invalid", couchDB_DBNAME: "vault" };
const storage = {
normalisePath: (path: string) => path,
isExists: vi.fn(async (path: string) => files.has(path)),
readFileText: vi.fn(async (path: string) => {
if (!files.has(path)) throw new Error("missing file");
return files.get(path)!;
}),
writeFileAuto: vi.fn(async (path: string, value: string) => {
files.set(path, value);
return true;
}),
delete: vi.fn(async (path: string) => {
files.delete(path);
}),
};
const confirm = vi.fn(async (_title: string, _message: string, buttons: string[]) => buttons[1]);
const host = {
services: { setting: { currentSettings: () => settings }, UI: { confirm: { confirmWithMessage: confirm } } },
serviceModules: { storageAccess: storage },
} as any;
return { host, files, settings, storage, confirm };
}
describe("Fetch flag state", () => {
it("preserves Markdown and reuses the selection independently of the database suffix", async () => {
const { host, files, settings } = fixture();
await saveFetchSelection(host, stage1, stage2);
const prose = "# My recovery notes\n\nKeep this explanation.\n\n";
files.set(modern, prose + files.get(modern)!);
await saveFetchSelection(host, stage1, stage2);
expect(files.get(modern)).toContain(prose);
settings.additionalSuffixOfDatabaseName = "changed-by-rebuild";
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
await clearFetchSelection(host);
expect(files.get(modern)).toContain(prose);
expect(readFetchFlagState(files.get(modern)!)).toBeUndefined();
});
it("reads and updates the legacy filename when it is the only flag", async () => {
const { host, files } = fixture();
files.delete(modern);
files.set(legacy, "");
await saveFetchSelection(host, stage1, stage2);
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
expect(files.has(modern)).toBe(false);
await deleteFetchFlags(host);
expect(files.size).toBe(0);
});
it("treats a new empty canonical flag as a new operation even when a legacy selection remains", async () => {
const { host, files, confirm } = fixture();
await saveFetchSelection(host, stage1, stage2);
files.set(legacy, files.get(modern)!);
files.set(modern, "");
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledTimes(2);
await deleteFetchFlags(host);
expect(files.size).toBe(0);
});
it("does not fall back to the legacy flag when reading the canonical flag fails", async () => {
const { host, files, storage } = fixture();
files.set(legacy, "");
storage.readFileText.mockRejectedValueOnce(new Error("read failed"));
await expect(readFetchSelection(host)).rejects.toThrow("read failed");
expect(storage.readFileText).toHaveBeenCalledOnce();
});
it("does not create an unsolicited fetch flag when none exists", async () => {
const { host, files, storage } = fixture();
files.clear();
await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("flag is missing");
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
it("rejects a save when the storage silently retains different content", async () => {
const { host, storage } = fixture();
storage.writeFileAuto.mockResolvedValueOnce(true);
await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("could not be saved");
});
it("retains the authoritative flag when removing the legacy alias fails", async () => {
const { host, files, storage } = fixture();
files.set(legacy, "");
storage.delete.mockRejectedValueOnce(new Error("delete failed"));
await expect(deleteFetchFlags(host)).rejects.toThrow("delete failed");
expect(files.has(modern)).toBe(true);
expect(storage.delete).toHaveBeenCalledOnce();
});
it("detects a deletion which returned without removing the flag", async () => {
const { host, files, storage } = fixture();
storage.delete.mockResolvedValueOnce(undefined);
await expect(deleteFetchFlags(host)).rejects.toThrow("Could not remove");
expect(files.has(modern)).toBe(true);
});
it.each([
{ stage1: "unknown", stage2 },
{ stage1, stage2: "Delete local files if deleted on remote" },
{ stage1: "Use the detailed flow", stage2 },
])("asks again for an invalid combination: %j", async (choices) => {
const { host, files, storage, confirm } = fixture();
files.set(
modern,
writeFetchFlagState("", {
type: "livesync-fast-setup",
version: 1,
...choices,
})
);
const original = files.get(modern);
confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledTimes(3);
expect(files.get(modern)).toBe(original);
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
it.each([
{ remoteType: "" as const, couchDB_DBNAME: "different-vault" },
{ remoteType: REMOTE_MINIO, endpoint: "https://s3.invalid", bucket: "other-bucket" },
{ remoteType: REMOTE_P2P, P2P_roomID: "other-room" },
])("requires explicit confirmation for the currently selected remote: %j", async (remote) => {
const { host, settings, confirm } = fixture();
await saveFetchSelection(host, stage1, stage2);
Object.assign(settings, remote);
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledOnce();
expect(confirm.mock.calls[0][1]).toContain("Current remote:");
});
});
describe("Fetch destination description", () => {
it("omits URL credentials, query parameters, fragments, and connection secrets", () => {
const { settings } = fixture();
Object.assign(settings, {
couchDB_URI: "https://alice:secret@example.invalid/path?token=secret#secret",
couchDB_USER: "alice",
couchDB_PASSWORD: "secret",
jwtKey: "secret",
passphrase: "secret",
additionalSuffixOfDatabaseName: "another-device",
});
expect(describeFetchRemote(settings)).toBe("CouchDB / https://example.invalid/path / vault");
});
it("shows Object Storage prefixes and P2P rooms without relay credentials", () => {
const { settings } = fixture();
Object.assign(settings, {
remoteType: REMOTE_MINIO,
endpoint: "https://s3.invalid",
region: "region",
bucket: "vault",
bucketPrefix: "one/",
});
expect(describeFetchRemote(settings)).toContain("vault / one/");
Object.assign(settings, {
remoteType: REMOTE_P2P,
P2P_roomID: "group",
P2P_passphrase: "secret",
P2P_relays: "wss://alice:secret@relay.invalid?token=secret,wss://other.invalid",
});
const description = describeFetchRemote(settings);
expect(description).toContain("group");
expect(description).toContain("wss://relay.invalid");
expect(description).toContain("wss://other.invalid");
expect(description).not.toContain("secret");
expect(description).not.toContain("alice");
});
});
@@ -0,0 +1,131 @@
import MarkdownIt from "markdown-it";
export interface FetchFlagState {
type: "livesync-fast-setup";
version: 1;
stage1: string;
stage2: string;
}
const STATE_TYPE = "livesync-fast-setup";
const markdownParser = new MarkdownIt({ html: true });
type MarkdownStateBlock = {
start: number;
end: number;
state: FetchFlagState | undefined;
};
function parseFetchFlagState(value: unknown): FetchFlagState | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (
record.type !== STATE_TYPE ||
record.version !== 1 ||
typeof record.stage1 !== "string" ||
typeof record.stage2 !== "string"
) {
return undefined;
}
return {
type: STATE_TYPE,
version: 1,
stage1: record.stage1,
stage2: record.stage2,
};
}
function getLineStartOffsets(markdown: string): number[] {
const starts = [0];
for (let index = 0; index < markdown.length; index++) {
if (markdown[index] === "\r") {
if (markdown[index + 1] === "\n") index++;
starts.push(index + 1);
} else if (markdown[index] === "\n") {
starts.push(index + 1);
}
}
return starts;
}
function isFenceClosed(markdown: string, map: [number, number] | null, markup: string): boolean {
if (!map) return false;
const closingLine = markdown.split(/\r\n|\n|\r/)[map[1] - 1] ?? "";
const closing = /^ {0,3}(`+|~+)[\t ]*$/.exec(closingLine);
return closing !== null && closing[1][0] === markup[0] && closing[1].length >= markup.length;
}
function getStateBlocks(markdown: string): MarkdownStateBlock[] | undefined {
const tokens = markdownParser.parse(markdown, {});
const topLevelFences = tokens.filter((token) => token.type === "fence" && token.level === 0);
if (topLevelFences.some((token) => !isFenceClosed(markdown, token.map, token.markup))) return undefined;
const starts = getLineStartOffsets(markdown);
const blocks: MarkdownStateBlock[] = [];
for (const token of topLevelFences) {
if (token.info.trim() !== "json") continue;
let parsed: unknown;
try {
parsed = JSON.parse(token.content);
} catch {
return undefined;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
const record = parsed as Record<string, unknown>;
if (record.type !== STATE_TYPE) continue;
if (!token.map) return undefined;
const start = starts[token.map[0]];
const end = starts[token.map[1]] ?? markdown.length;
if (start === undefined || end < start) return undefined;
blocks.push({ start, end, state: parseFetchFlagState(parsed) });
}
return blocks;
}
function getLineEnding(markdown: string): string {
return markdown.match(/\r\n|\n|\r/)?.[0] ?? "\n";
}
function formatStateBlock(state: FetchFlagState, lineEnding: string): string {
const json = JSON.stringify(
{
type: state.type,
version: state.version,
stage1: state.stage1,
stage2: state.stage2,
},
null,
2
);
return `\`\`\`json${lineEnding}${json}${lineEnding}\`\`\``;
}
function createFastSetupNote(state: FetchFlagState): string {
return [
"# Fast Setup",
"",
"This file keeps the Fast Setup choices while the fetch is incomplete. It is removed when the fetch completes.",
"",
formatStateBlock(state, "\n"),
].join("\n");
}
export function readFetchFlagState(markdown: string): FetchFlagState | undefined {
const blocks = getStateBlocks(markdown);
if (!blocks || blocks.length !== 1) return undefined;
return blocks[0].state;
}
export function writeFetchFlagState(markdown: string, state: FetchFlagState | undefined): string {
if (markdown.trim().length === 0) return state ? createFastSetupNote(state) : markdown;
const blocks = getStateBlocks(markdown);
if (!blocks || blocks.length !== 1 || !blocks[0].state) return markdown;
const block = blocks[0];
const originalBlock = markdown.slice(block.start, block.end);
const trailingLineEnding = originalBlock.match(/(?:\r\n|\n|\r)$/)?.[0] ?? "";
const replacement = state ? formatStateBlock(state, getLineEnding(markdown)) + trailingLineEnding : "";
return markdown.slice(0, block.start) + replacement + markdown.slice(block.end);
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { readFetchFlagState, writeFetchFlagState, type FetchFlagState } from "./redFlag.fetchStateMarkdown";
const state: FetchFlagState = {
type: "livesync-fast-setup",
version: 1,
stage1: "remote-only",
stage2: "keep-local",
};
function stateBlock(value: unknown = state): string {
return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
}
describe("Fast Setup Markdown state codec", () => {
it("reads a valid top-level JSON state fence", () => {
expect(readFetchFlagState(stateBlock())).toEqual(state);
});
it("ignores a legacy remote field", () => {
expect(readFetchFlagState(stateBlock({ ...state, remote: "old-remote-hash" }))).toEqual(state);
});
it("creates a Fast Setup note only for an empty or whitespace-only document", () => {
const markdown = writeFetchFlagState("", state);
expect(markdown).toContain("# Fast Setup");
expect(markdown).toContain("This file keeps the Fast Setup choices while the fetch is incomplete.");
expect(markdown).toContain("It is removed when the fetch completes.");
expect(readFetchFlagState(markdown)).toEqual(state);
const whitespace = " \n\t";
expect(readFetchFlagState(writeFetchFlagState(whitespace, state))).toEqual(state);
expect(writeFetchFlagState(whitespace, undefined)).toBe(whitespace);
});
it("updates one readable state while preserving prose and unrelated code fences", () => {
const unrelatedFence = '```json\n{\n "type": "example"\n}\n```';
const markdown = `Opening note.\n\n${unrelatedFence}\n\n${stateBlock()}\n\nClosing note.`;
const updated = { ...state, stage1: "new-choice" };
const result = writeFetchFlagState(markdown, updated);
expect(result).toContain("Opening note.");
expect(result).toContain(unrelatedFence);
expect(result).toContain("Closing note.");
expect(readFetchFlagState(result)).toEqual(updated);
});
it("clears one readable state while preserving surrounding prose", () => {
const markdown = `Before.\n${stateBlock()}\nAfter.`;
expect(writeFetchFlagState(markdown, undefined)).toBe("Before.\nAfter.");
});
it("does not read or write nested code examples", () => {
const example = [
"> Example state:",
"> ```json",
`> ${JSON.stringify(state)}`,
"> ```",
"",
"````markdown",
"```json",
JSON.stringify(state),
"```",
"````",
].join("\n");
expect(readFetchFlagState(example)).toBeUndefined();
expect(writeFetchFlagState(example, state)).toBe(example);
expect(writeFetchFlagState(example, undefined)).toBe(example);
});
it("does not change plain prose, malformed JSON, or an unsupported version", () => {
const malformed = '```json\n{"type":"livesync-fast-setup",\n```';
const unsupportedVersion = stateBlock({ ...state, version: 2 });
for (const markdown of ["Ordinary prose only.", malformed, unsupportedVersion]) {
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
expect(writeFetchFlagState(markdown, undefined)).toBe(markdown);
}
});
it("does not change duplicate state candidates", () => {
const markdown = `Start.\n\n${stateBlock()}\n\n${stateBlock({ ...state, stage1: "second" })}\n\nEnd.`;
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
expect(writeFetchFlagState(markdown, undefined)).toBe(markdown);
});
it("does not repair malformed marker blocks", () => {
const malformed = [
"Before.",
"",
"```json",
"{",
' "type": "livesync-fast-setup",',
' "version": 1,',
' "remote":',
"}",
"```",
"",
"After.",
].join("\n");
expect(readFetchFlagState(malformed)).toBeUndefined();
expect(writeFetchFlagState(malformed, state)).toBe(malformed);
expect(writeFetchFlagState(malformed, undefined)).toBe(malformed);
});
it("does not write state into malformed Markdown without a readable state block", () => {
const markdown = "Opening note.\n\n```text\nThis note remains inside an unclosed fence.";
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
});
it("does not write when a state block is inside an unclosed fence", () => {
const markdown = `Opening note.\n\n\`\`\`json\n${JSON.stringify(state)}`;
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
});
it("does not write into unclosed raw HTML comments and blocks", () => {
const markdownExamples = [
"Opening note.\n\n<!--\nThis comment remains open.",
"Opening note.\n\n<script>\nconst rawBlockRemainsOpen = true;",
`<!--\n${stateBlock()}`,
];
for (const markdown of markdownExamples) {
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
expect(writeFetchFlagState(markdown, undefined)).toBe(markdown);
}
});
it("does not treat a marker-like string value as a state candidate", () => {
const unrelated = '```json\n{"description":"\\\"type\\\":\\\"livesync-fast-setup\\\""}\n```';
expect(readFetchFlagState(unrelated)).toBeUndefined();
expect(writeFetchFlagState(unrelated, state)).toBe(unrelated);
});
});
+49 -34
View File
@@ -13,6 +13,7 @@ import {
import { adjustSettingToRemoteIfNeeded, cancelScheduledInitialisation, processVaultInitialisation } from "./redFlag";
import { $msg } from "@/common/translation";
import type { AllMessageKeys } from "@/common/rosetta";
import { readFetchSelection, saveFetchSelection, clearFetchSelection, describeFetchRemote } from "./redFlag.fetchState";
export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files";
export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer";
@@ -26,8 +27,6 @@ export const SIMPLE_FETCH_STAGE2_NEWER_CLEANUP = "Delete local files if deleted
export const SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL = "Keep local files even if deleted on remote";
export const STAGE2_ABORT = "Cancel all and reboot";
const SIMPLE_FETCH_MODE_KEY = "simple-fetch-mode";
function buildSimpleFetchResult(stage1: string, stage2?: string) {
if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) {
return { mode: "detailed", options: {} };
@@ -63,27 +62,8 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
return undefined;
}
async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
}
async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
if (!saved) return undefined;
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
try {
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
} catch {
// Clear below; the saved choice is optional and can be rebuilt by asking again.
}
if (remembered) return remembered;
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
return undefined;
}
async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
function escapeMarkdownText(value: string): string {
return value.replace(/[\\`*_{}[\]()<>|]/g, "\\$&").replace(/[\r\n]/g, " ");
}
async function confirmTranslatedChoice(
@@ -101,10 +81,45 @@ async function confirmTranslatedChoice(
}
export async function askSimpleFetchMode(
host: NecessaryServices<"UI" | "setting", never>
host: NecessaryServices<"UI" | "setting", "storageAccess">
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
const remembered = await getRememberedSimpleFetchMode(host);
if (remembered) return remembered;
const saved = await readFetchSelection(host);
const remembered =
typeof saved === "object" && saved.stage1 !== SIMPLE_FETCH_STAGE1_DETAILED
? buildSimpleFetchResult(saved.stage1, saved.stage2)
: undefined;
const canSave = saved === "empty" || remembered !== undefined;
const remote = escapeMarkdownText(describeFetchRemote(host.services.setting.currentSettings()));
if (remembered && typeof saved === "object") {
const decision = await confirmTranslatedChoice(
host,
$msg("Resume scheduled fetch?"),
$msg(
"Apply these saved choices to the current remote?\n\nCurrent remote: ${REMOTE}\n\n- ${STAGE1}\n- ${STAGE2}\n\nThe fetch flag may remain after an interruption or an incomplete cleanup. Continue only if this fetch is still intended.",
{
REMOTE: remote,
STAGE1: $msg(saved.stage1 as AllMessageKeys),
STAGE2: $msg(saved.stage2 as AllMessageKeys),
}
),
["Continue with these choices", "Choose again", SIMPLE_FETCH_STAGE1_CANCEL],
SIMPLE_FETCH_STAGE1_CANCEL
);
if (decision === "Continue with these choices") return remembered;
if (decision !== "Choose again") return "cancelled";
} else if (saved !== "empty") {
const decision = await confirmTranslatedChoice(
host,
$msg("Saved fetch choices could not be read"),
$msg(
"The fetch flag does not contain one supported selection. Choose new options and continue with the current remote?\n\nCurrent remote: ${REMOTE}\n\nThe flag contents will remain unchanged. The new choices apply only to this attempt; another restart will ask again. Completing or cancelling the fetch removes the flag.",
{ REMOTE: remote }
),
["Choose new options and continue", SIMPLE_FETCH_STAGE1_CANCEL],
SIMPLE_FETCH_STAGE1_CANCEL
);
if (decision !== "Choose new options and continue") return "cancelled";
}
const msg = $msg(
`We are about to retrieve the remote data.
@@ -140,6 +155,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
if (!stage1 || stage1 === SIMPLE_FETCH_STAGE1_CANCEL) return "cancelled";
if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) {
if (canSave) await clearFetchSelection(host);
return buildSimpleFetchResult(stage1)!;
}
@@ -166,9 +182,10 @@ Firstly, how shall we handle the data retrieved from this remote source?
);
if (!stage2) return "cancelled";
if (stage2 === STAGE2_ABORT) {
if (canSave) await clearFetchSelection(host);
return "aborted";
}
await rememberSimpleFetchMode(host, stage1, stage2);
if (canSave) await saveFetchSelection(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!;
}
@@ -195,9 +212,10 @@ Firstly, how shall we handle the data retrieved from this remote source?
);
if (!stage2) return "cancelled";
if (stage2 === STAGE2_ABORT) {
if (canSave) await clearFetchSelection(host);
return "aborted";
}
await rememberSimpleFetchMode(host, stage1, stage2);
if (canSave) await saveFetchSelection(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!;
}
@@ -233,18 +251,15 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
LOG_LEVEL_NOTICE
);
await clearRememberedSimpleFetchMode(host);
return undefined;
}
const result = await askSimpleFetchMode(host);
if (result === "cancelled") {
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
if (result === "aborted") {
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
await clearRememberedSimpleFetchMode(host);
host.services.appLifecycle.performRestart();
return false;
}
@@ -255,7 +270,6 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
const settings = host.services.setting.currentSettings();
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
@@ -292,9 +306,10 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
return false;
}
}
await host.serviceModules.rebuilder.finishRebuild();
if (!(await host.serviceModules.rebuilder.finishRebuild())) {
throw new Error("Fast Setup could not finalise the rebuilt database.");
}
await cleanupFlag();
await clearRememberedSimpleFetchMode(host);
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
return true;
};
@@ -3,11 +3,15 @@ import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interf
import { FullScanModes } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { $msg, setLang } from "@/common/translation";
import { askSimpleFetchMode } from "./redFlag.simpleFetch";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { FlagFilesHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { readFetchFlagState, writeFetchFlagState } from "./redFlag.fetchStateMarkdown";
const REMOTE_WINS = "Overwrite all with remote files";
const KEEP_LOCAL = "Keep local files even if not on remote";
function createHost(savedChoice?: string) {
async function createHost(savedChoice?: string) {
const smallConfig = new Map<string, string>();
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
const config = {
@@ -19,6 +23,24 @@ function createHost(savedChoice?: string) {
smallConfig.delete(key);
}),
};
const settings = { ...DEFAULT_SETTINGS, couchDB_URI: "https://example.invalid", couchDB_DBNAME: "vault" };
const flagPath = FlagFilesHumanReadable.FETCH_ALL;
const files = new Map<string, string>([[flagPath, ""]]);
const storage = {
normalisePath: (path: string) => path,
isExists: vi.fn(async (path: string) => files.has(path)),
readFileText: vi.fn(async (path: string) => files.get(path)!),
writeFileAuto: vi.fn(async (path: string, text: string) => {
files.set(path, text);
return true;
}),
};
const rememberedMarkdown = writeFetchFlagState("", {
type: "livesync-fast-setup",
version: 1,
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
const confirmWithMessage =
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
const host = {
@@ -26,17 +48,19 @@ function createHost(savedChoice?: string) {
UI: { confirm: { confirmWithMessage } },
setting: {
config,
currentSettings: () => settings,
},
},
} as unknown as NecessaryServices<"UI" | "setting", never>;
return { host, confirmWithMessage, smallConfig, config };
serviceModules: { storageAccess: storage },
} as unknown as NecessaryServices<"UI" | "setting", "storageAccess">;
return { host, confirmWithMessage, smallConfig, config, files, storage, flagPath, rememberedMarkdown, settings };
}
afterEach(() => setLang("def"));
describe("Fast Setup translated choices", () => {
it("uses the current language after the module has loaded and saves stable choice values", async () => {
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
setLang("es");
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
@@ -51,14 +75,14 @@ describe("Fast Setup translated choices", () => {
expect(buttons[1]).not.toBe(REMOTE_WINS);
expect(message).toContain($msg(REMOTE_WINS));
expect(confirmWithMessage.mock.calls[1][2][1]).toBe($msg(KEEP_LOCAL));
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
});
it("waits for the saved choice before returning it to the fetch workflow", async () => {
const { host, confirmWithMessage, smallConfig, config } = createHost();
const { host, confirmWithMessage, files, flagPath, storage } = await createHost();
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
@@ -67,81 +91,77 @@ describe("Fast Setup translated choices", () => {
const save = new Promise<void>((resolve) => {
finishSave = resolve;
});
config.setValue.mockImplementation(async (key, value) => {
storage.writeFileAuto.mockImplementation(async (key, value) => {
markSaveStarted();
await save;
smallConfig.set(key, value);
files.set(key, value);
return true;
});
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
const asking = askSimpleFetchMode(host);
await saveStarted;
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
expect(files.get(flagPath)).toBe("");
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
finishSave();
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
});
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
const { host, confirmWithMessage, config } = createHost();
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
const { host, confirmWithMessage, storage } = await createHost();
storage.readFileText.mockRejectedValueOnce(new Error("choice read failed"));
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
expect(confirmWithMessage).not.toHaveBeenCalled();
});
it("waits for an invalid optional choice to be deleted before asking again", async () => {
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
let markDeleteStarted!: () => void;
const deleteStarted = new Promise<void>((resolve) => {
markDeleteStarted = resolve;
});
let finishDelete!: () => void;
const deletion = new Promise<void>((resolve) => {
finishDelete = resolve;
});
config.delete.mockImplementation(async (key) => {
markDeleteStarted();
await deletion;
smallConfig.delete(key);
});
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
const asking = askSimpleFetchMode(host);
await deleteStarted;
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
finishDelete();
await expect(asking).resolves.toBe("cancelled");
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
it("asks before proceeding with an invalid selection block and leaves it unchanged", async () => {
const { host, confirmWithMessage, files, flagPath, storage } = await createHost();
const markdown = '```json\n{"type":"livesync-fast-setup", broken}\n```\n';
files.set(flagPath, markdown);
confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][0]).toBe("Saved fetch choices could not be read");
expect(files.get(flagPath)).toBe(markdown);
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
it("confirms stable flag choices while displaying Spanish", async () => {
const { host, confirmWithMessage, files, flagPath, rememberedMarkdown } = await createHost();
files.set(flagPath, rememberedMarkdown);
setLang("es");
confirmWithMessage.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await expect(askSimpleFetchMode(host)).resolves.toEqual({
mode: "remote-only",
options: { mode: FullScanModes.DB_APPLY, extraOnRemote: undefined },
});
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][1]).toContain($msg(REMOTE_WINS));
expect(confirmWithMessage.mock.calls[0][1]).toContain($msg(KEEP_LOCAL));
expect(files.get(flagPath)).toBe(rememberedMarkdown);
});
expect(confirmWithMessage).not.toHaveBeenCalled();
it("asks again instead of migrating the previous localStorage choice", async () => {
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
const { host, confirmWithMessage, config, smallConfig } = await createHost(savedChoice);
setLang("es");
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(config.getValue).not.toHaveBeenCalled();
expect(smallConfig.get("simple-fetch-mode")).toBe(savedChoice);
});
it("uses the new language in the second dialogue without changing the first choice", async () => {
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
setLang("def");
confirmWithMessage
.mockImplementationOnce(async (_title, _message, buttons) => {
@@ -154,21 +174,21 @@ describe("Fast Setup translated choices", () => {
expect(confirmWithMessage.mock.calls[0][2][1]).toBe(REMOTE_WINS);
expect(confirmWithMessage.mock.calls[1][2][1]).toBe($msg(KEEP_LOCAL));
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
});
it("recognises the translated cancellation choice", async () => {
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
setLang("es");
confirmWithMessage.mockImplementationOnce(async (_title, _message, buttons) => buttons[3]);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage.mock.calls[0][2][3]).toBe($msg("Cancel"));
expect(smallConfig.size).toBe(0);
expect(files.get(flagPath)).toBe("");
});
it("saves English-compatible values when the module initially loads in Spanish", async () => {
@@ -176,12 +196,12 @@ describe("Fast Setup translated choices", () => {
const translation = await import("@/common/translation");
translation.setLang("es");
const { askSimpleFetchMode: askAfterSpanishStartup } = await import("./redFlag.simpleFetch");
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
try {
await expect(askAfterSpanishStartup(host)).resolves.toMatchObject({ mode: "remote-only" });
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
@@ -190,3 +210,77 @@ describe("Fast Setup translated choices", () => {
}
});
});
describe("Fetch restart confirmation regressions", () => {
it("requires explicit confirmation even when the saved selection is valid", async () => {
const { host, files, flagPath, rememberedMarkdown, confirmWithMessage } = await createHost();
files.set(flagPath, rememberedMarkdown);
confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][1]).toContain("example.invalid");
expect(confirmWithMessage.mock.calls[0][1]).toContain(REMOTE_WINS);
expect(confirmWithMessage.mock.calls[0][1]).toContain(KEEP_LOCAL);
expect(files.get(flagPath)).toBe(rememberedMarkdown);
});
it("asks before applying saved choices after only the P2P passphrase changes", async () => {
const { host, files, flagPath, confirmWithMessage, settings } = await createHost();
Object.assign(settings, {
remoteType: REMOTE_P2P,
P2P_roomID: "group",
P2P_passphrase: "old-private-room-passphrase",
});
const { saveFetchSelection } = await import("./redFlag.fetchState");
await saveFetchSelection(host, REMOTE_WINS, KEEP_LOCAL);
settings.P2P_passphrase = "new-private-room-passphrase";
const original = files.get(flagPath);
confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][1]).toContain("group");
expect(confirmWithMessage.mock.calls[0][1]).not.toContain("new-private-room-passphrase");
expect(files.get(flagPath)).toBe(original);
});
it("uses new choices only for this attempt when the flag cannot be read as a selection", async () => {
const { host, files, flagPath, confirmWithMessage, storage } = await createHost();
const markdown = "# Recovery notes\n\n<!-- unfinished comment";
files.set(flagPath, markdown);
confirmWithMessage
.mockImplementationOnce(async (_title, _message, buttons) => buttons[0])
.mockResolvedValueOnce(REMOTE_WINS)
.mockResolvedValueOnce(KEEP_LOCAL);
await expect(askSimpleFetchMode(host)).resolves.toMatchObject({ mode: "remote-only" });
expect(confirmWithMessage).toHaveBeenCalledTimes(3);
expect(files.get(flagPath)).toBe(markdown);
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
});
describe("Fetch resume decisions", () => {
it("replaces a valid saved choice only after choosing new options", async () => {
const { host, files, flagPath, rememberedMarkdown, confirmWithMessage } = await createHost();
files.set(flagPath, rememberedMarkdown);
confirmWithMessage
.mockResolvedValueOnce("Choose again")
.mockResolvedValueOnce("Compare time and take newer")
.mockResolvedValueOnce("Keep local files even if deleted on remote");
await expect(askSimpleFetchMode(host)).resolves.toMatchObject({ mode: "newer-wins" });
expect(confirmWithMessage).toHaveBeenCalledTimes(3);
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: "Compare time and take newer",
stage2: "Keep local files even if deleted on remote",
});
});
it("requires confirmation again after a successful explicit reuse", async () => {
const { host, files, flagPath, rememberedMarkdown, confirmWithMessage } = await createHost();
files.set(flagPath, rememberedMarkdown);
confirmWithMessage.mockResolvedValueOnce("Continue with these choices").mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toMatchObject({ mode: "remote-only" });
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
expect(confirmWithMessage.mock.calls[0][3]).toBe("Cancel");
});
});
+44 -20
View File
@@ -15,6 +15,7 @@ import type {
RebuildEverythingResult,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes";
import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch";
import { deleteFetchFlags } from "./redFlag.fetchState";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isP2PMainRemote } from "@/common/remoteConfiguration";
@@ -136,10 +137,7 @@ export function createFetchAllFlagHandler(
(await isFlagFileExist(host, FlagFilesHumanReadable.FETCH_ALL));
// Cleanup fetch all flag files
const cleanupFlag = async () => {
await deleteFlagFile(host, log, FlagFilesOriginal.FETCH_ALL);
await deleteFlagFile(host, log, FlagFilesHumanReadable.FETCH_ALL);
};
const cleanupFlag = () => deleteFetchFlags(host);
// Handle the fetch all scheduled operation
const onScheduled = async () => {
@@ -189,27 +187,45 @@ export function createFetchAllFlagHandler(
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
return await processVaultInitialisation(host, log, async () => {
const vaultStateToAction = mapVaultStateToAction[vault];
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
log(
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
LOG_LEVEL_INFO
);
await host.serviceModules.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
await cleanupFlag();
log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
return true;
});
return await processVaultInitialisation(
host,
log,
async () => {
const vaultStateToAction = mapVaultStateToAction[vault];
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
log(
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
LOG_LEVEL_INFO
);
await host.serviceModules.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
await cleanupFlag();
log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
return true;
},
"resume-on-success"
);
};
return {
priority: 10,
check: () => isFlagActive(),
handle: async () => {
const res = await onScheduled();
if (res) {
return await verifyAndUnlockSuspension(host, log);
try {
const res = await onScheduled();
if (res) return await verifyAndUnlockSuspension(host, log);
} catch (ex) {
log(
"Could not prepare or finish the scheduled fetch. File and database reflection remain suspended.",
LOG_LEVEL_NOTICE
);
log(ex, LOG_LEVEL_VERBOSE);
await host.services.setting.applyPartial(
{
suspendFileWatching: true,
suspendParseReplicationResult: true,
},
true
);
}
return false;
},
@@ -373,7 +389,7 @@ export async function cancelScheduledInitialisation(
return false;
}
type InitialisationSuspensionPolicy = "resume" | "keep" | "keep-on-failure";
type InitialisationSuspensionPolicy = "resume" | "keep" | "keep-on-failure" | "resume-on-success";
/**
* Process Vault initialisation with file watching and synchronisation suspended.
@@ -417,6 +433,14 @@ export async function processVaultInitialisation(
await host.services.setting.applyPartial({ suspendFileWatching: false }, true);
} else if (suspensionPolicy === "keep") {
await host.services.setting.applyPartial({ suspendFileWatching: true }, true);
} else if (suspensionPolicy === "resume-on-success") {
// Detailed Fetch owns its database reflection state on success or cancellation.
await host.services.setting.applyPartial(
completed
? { suspendFileWatching: false }
: { suspendFileWatching: true, suspendParseReplicationResult: true },
true
);
} else {
// Fast Setup owns both directions at this boundary. Reasserting the
// outcome also covers a late failure after finishRebuild started to
+324 -42
View File
@@ -1,3 +1,6 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
import { describe, it, expect, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
@@ -74,12 +77,24 @@ const availableRemoteTweaks = (values: Record<string, unknown>) => ({ status: "a
const createStorageAccessMock = () => {
const files: Set<string> = new Set();
const contents = new Map<string, string>();
return {
files,
contents,
readFileText: vi.fn(async (path: string) => {
if (!files.has(path)) throw new Error("Missing flag");
return contents.get(path) ?? "";
}),
writeFileAuto: vi.fn(async (path: string, text: string) => {
files.add(path);
contents.set(path, text);
return true;
}),
isExists: vi.fn((path: string) => Promise.resolve(files.has(path))),
normalisePath: vi.fn((path: string) => path),
delete: vi.fn((path: string, _recursive?: boolean) => {
files.delete(path);
contents.delete(path);
return Promise.resolve();
}),
getFileNames: vi.fn(() => Array.from(files)),
@@ -88,10 +103,13 @@ const createStorageAccessMock = () => {
const createSettingServiceMock = () => {
const settings: any = {
...DEFAULT_SETTINGS,
couchDB_URI: "https://example.invalid",
couchDB_DBNAME: "vault",
batchSave: true,
suspendFileWatching: false,
writeLogToTheFile: false,
remoteType: "CouchDB",
remoteType: "",
};
const smallConfig = new Map<string, string>();
return {
@@ -149,7 +167,7 @@ const createRebuilderMock = () => {
$fetchLocal: vi.fn(async () => {}),
$fetchLocalDBFast: vi.fn(async () => {}),
$rebuildEverything: vi.fn(async () => {}),
finishRebuild: vi.fn(async () => {}),
finishRebuild: vi.fn(async () => true),
};
};
@@ -161,8 +179,9 @@ const createTweakValueMock = () => {
};
};
const createHostMock = () => {
const createHostMock = (withFetchFlag = false) => {
const storageAccessMock = createStorageAccessMock();
if (withFetchFlag) storageAccessMock.files.add(FlagFilesHumanReadable.FETCH_ALL);
const settingMock = createSettingServiceMock();
const appLifecycleMock = createAppLifecycleMock();
const uiMock = createUIServiceMock();
@@ -677,21 +696,21 @@ describe("Red Flag Feature", () => {
describe("askSimpleFetchMode", () => {
it("should return cancelled when stage1 is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled");
});
it("selects the detailed Fetch flow", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "detailed", options: {} });
});
it("should return remote-only with keep-local option", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE);
@@ -706,7 +725,7 @@ describe("Red Flag Feature", () => {
});
it("should return cancelled when remote-only stage2 is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(false);
@@ -715,7 +734,7 @@ describe("Red Flag Feature", () => {
});
it("should return aborted when remote-only stage2 aborts", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(STAGE2_ABORT);
@@ -724,7 +743,7 @@ describe("Red Flag Feature", () => {
});
it("should return newer-wins cleanup option", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
@@ -739,7 +758,7 @@ describe("Red Flag Feature", () => {
});
it("should return newer-wins keep-all option", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL);
@@ -754,7 +773,7 @@ describe("Red Flag Feature", () => {
});
it("should return cancelled when newer-wins stage2 is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(false);
@@ -763,7 +782,7 @@ describe("Red Flag Feature", () => {
});
it("should return aborted when newer-wins stage2 aborts", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(STAGE2_ABORT);
@@ -774,9 +793,11 @@ describe("Red Flag Feature", () => {
describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
it("uses the detailed flow instead of Simple Fetch while remediation mode is active", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
Object.assign(host.mocks.setting.settings, {
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
@@ -787,14 +808,16 @@ describe("Red Flag Feature", () => {
);
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
expect(host.mocks.setting.config.getValue).not.toHaveBeenCalled();
expect(cleanupFlag).not.toHaveBeenCalled();
});
it("releases both reflection suspensions after Fast Setup succeeds", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
Object.assign(host.mocks.setting.settings, {
doNotSuspendOnFetching: true,
@@ -814,9 +837,11 @@ describe("Red Flag Feature", () => {
});
it("keeps Vault reflection suspended and preserves recovery state when Fast Fetch fails", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
@@ -832,13 +857,17 @@ describe("Red Flag Feature", () => {
});
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
expect(cleanupFlag).not.toHaveBeenCalled();
expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
"livesync-fast-setup"
);
});
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
@@ -864,28 +893,33 @@ describe("Red Flag Feature", () => {
expect(cleanupFlag).not.toHaveBeenCalled();
});
it("should remember quick flow choices while the scheduled fetch is pending", async () => {
const host = createHostMock();
it("should confirm remembered quick flow choices while the scheduled fetch is pending", async () => {
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP)
.mockResolvedValueOnce("Continue with these choices");
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(3);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalledTimes(2);
});
it("should clear remembered quick flow choices after completion", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -894,13 +928,15 @@ describe("Red Flag Feature", () => {
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
});
it("should return false and cleanup when quick flow is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
@@ -912,9 +948,11 @@ describe("Red Flag Feature", () => {
});
it("should return false without cleanup when quick flow is aborted", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -928,9 +966,11 @@ describe("Red Flag Feature", () => {
});
it("leaves the detailed Fetch flow to its existing handler", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
@@ -941,8 +981,10 @@ describe("Red Flag Feature", () => {
});
it("should preserve automatic synchronisation choices and enter Scram when quick Fetch is cancelled", async () => {
const host = createHostMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const host = createHostMock(true);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
Object.assign(host.mocks.setting.settings, {
liveSync: true,
periodicReplication: true,
@@ -1000,9 +1042,11 @@ describe("Red Flag Feature", () => {
});
it("should reboot and return false when sync has failures and user chooses rerun", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -1022,9 +1066,11 @@ describe("Red Flag Feature", () => {
});
it("should continue and finalise when sync has failures but user releases flag", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -2000,3 +2046,239 @@ describe("Red Flag Feature", () => {
});
});
});
describe("Issue 1244: completed Fetch followed by another Join", () => {
it.each(["receiver-app", "imported-source-app"])(
"asks again after successful Fetch with initial database suffix %s",
async (initialSuffix) => {
const host = createHostMock();
const context = createServiceContext();
class FixtureSettings extends SettingService {
protected async saveData() {}
protected async loadData() {
return undefined;
}
}
const setting = new FixtureSettings(context, {
deviceValues: new MemoryValueStore(),
APIService: {
getSystemVaultName: () => "receiver-vault",
getAppID: () => "receiver-app",
addLog: vi.fn(),
} as any,
});
Object.assign(host.mocks.setting.settings, DEFAULT_SETTINGS, {
additionalSuffixOfDatabaseName: initialSuffix,
couchDB_URI: "https://example.invalid",
couchDB_DBNAME: "vault",
});
setting.settings = host.mocks.setting.settings;
host.services.setting.config = setting.config as any;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
host.mocks.ui.confirm.confirmWithMessage.mockImplementation(
async (_title, _message, buttons) => buttons[0]
);
const reset = {
API: { getAppID: () => "receiver-app" },
setting: host.services.setting,
database: { resetDatabaseForCurrentSettings: vi.fn(async () => true) },
databaseEvents: {},
replicator: {},
events: context.events,
};
host.mocks.rebuilder.$fetchLocalDBFast.mockImplementation(async () => {
await ServiceRebuilder.prototype.resetLocalDatabase.call(reset as any);
});
const cleanup = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, vi.fn(), cleanup)).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
expect(host.mocks.setting.settings.additionalSuffixOfDatabaseName).toBe("receiver-app");
expect(cleanup).toHaveBeenCalledOnce();
await host.services.setting.applyPartial({ additionalSuffixOfDatabaseName: initialSuffix });
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, vi.fn(), cleanup)).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(4);
expect(cleanup).toHaveBeenCalledTimes(2);
}
);
});
describe("Fetch flag selection lifecycle", () => {
function scheduledFetch() {
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[0]);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
return { host, handler: createFetchAllFlagHandler(host as any, vi.fn()) };
}
it("ignores a stale localStorage choice and records the new selection in Markdown", async () => {
const { host, handler } = scheduledFetch();
await host.services.setting.config.setValue(
"simple-fetch-mode",
JSON.stringify({
stage1: SIMPLE_FETCH_STAGE1_REMOTE_WINS,
stage2: SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL,
})
);
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain("```json");
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
SIMPLE_FETCH_STAGE1_NEWER_WINS
);
});
it("confirms the same selection after the database suffix changes and Fetch fails", async () => {
const { host, handler } = scheduledFetch();
host.mocks.rebuilder.$fetchLocalDBFast.mockImplementationOnce(async () => {
host.mocks.setting.settings.additionalSuffixOfDatabaseName = "receiver-app";
throw new Error("offline after resetting database");
});
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
"livesync-fast-setup"
);
await expect(createFetchAllFlagHandler(host as any, vi.fn()).handle()).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(3);
expect(host.mocks.ui.confirm.confirmWithMessage.mock.calls[2][0]).toBe("Resume scheduled fetch?");
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
});
it("does not fetch or reflect files when saving the choice returns false", async () => {
const { host, handler } = scheduledFetch();
host.mocks.storageAccess.writeFileAuto.mockResolvedValueOnce(false);
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
});
it("retains the choices when rebuild finalisation returns false", async () => {
const { host, handler } = scheduledFetch();
host.mocks.rebuilder.finishRebuild.mockResolvedValueOnce(false);
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
"livesync-fast-setup"
);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
it("keeps reflection suspended if the flag cannot be removed after completion", async () => {
const { host, handler } = scheduledFetch();
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("read-only storage"));
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
});
describe("Detailed Fetch cancellation regression", () => {
it("preserves an existing parse suspension when Commonlib cancels restricted fetch", async () => {
const host = createHostMock(true);
Object.assign(host.mocks.setting.settings, {
maxMTimeForReflectEvents: 123456789,
suspendParseReplicationResult: true,
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "identical",
extra: { preventFetchingConfig: true },
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel operation");
const runBoundedRemoteActivity = vi.fn();
const rebuilder = {
checkPathIdentitiesBeforeReconstruction: vi.fn(),
checkRemotePathIdentityBeforeFetch: vi.fn(),
setting: host.services.setting,
UI: host.services.UI,
replicator: { runBoundedRemoteActivity },
};
host.mocks.rebuilder.$fetchLocal.mockImplementation(async () => {
await ServiceRebuilder.prototype.fetchLocal.call(rebuilder as any, false, false);
});
await createFetchAllFlagHandler(host as any, vi.fn()).handle();
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ title: "Confirm restricted fetch" })
);
expect(runBoundedRemoteActivity).not.toHaveBeenCalled();
expect(host.mocks.setting.settings.suspendParseReplicationResult).toBe(true);
});
});
describe("Fetch confirmation lifecycle", () => {
it("cancels a retained completed flag without fetching again", async () => {
const host = createHostMock(true);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE)
.mockResolvedValueOnce("Cancel");
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("deletion interrupted"));
const handler = createFetchAllFlagHandler(host as any, vi.fn());
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalledOnce();
expect(host.mocks.ui.confirm.confirmWithMessage.mock.calls[2][0]).toBe("Resume scheduled fetch?");
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
it("retains unreadable Markdown after interruption and asks again on the next attempt", async () => {
const host = createHostMock(true);
const markdown = "# Recovery notes\n\n<!-- unfinished comment";
host.mocks.storageAccess.contents.set(FlagFilesHumanReadable.FETCH_ALL, markdown);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
for (let i = 0; i < 2; i++) {
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce("Choose new options and continue")
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE);
}
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
const handler = createFetchAllFlagHandler(host as any, vi.fn());
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toBe(markdown);
expect(host.mocks.storageAccess.writeFileAuto).not.toHaveBeenCalled();
await expect(handler.handle()).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(6);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
});
it("keeps both reflection directions suspended when detailed Fetch cannot delete its flag", async () => {
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "identical",
extra: { preventFetchingConfig: true },
});
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("read-only storage"));
await expect(createFetchAllFlagHandler(host as any, vi.fn()).handle()).resolves.toBe(false);
expect(host.mocks.rebuilder.$fetchLocal).toHaveBeenCalledOnce();
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
});
@@ -59,6 +59,7 @@ type ReplicateResultProcessorServices = Pick<
* database would be invalid.
*/
interface ReplicateResultProcessorContext {
readonly onVaultReflectionFailure?: () => void;
readonly currentSettings: () => ReplicateResultProcessorSettings;
readonly getKeyValueDB: () => LiveSyncBaseCore["kvDB"];
readonly getLocalDatabase: () => LiveSyncBaseCore["localDatabase"];
@@ -88,6 +89,7 @@ export class ReplicateResultProcessor {
Logger(e, LOG_LEVEL_VERBOSE);
}
private reportVaultReflectionFailure(entry: MetaEntry, cause?: unknown) {
this.context.onVaultReflectionFailure?.();
this.log(
`Live replication could not reflect ${this.getPath(entry)} from the local database to the Vault; this path remains eligible for a later Vault scan.`,
LOG_LEVEL_VERBOSE
@@ -379,6 +381,7 @@ export class ReplicateResultProcessor {
}
// Enqueue the change
this._queuedChanges.push(doc);
this.reportStatus();
this.updateProcessingActivity();
this.triggerTakeSnapshot();
this.triggerProcessQueue();
@@ -487,6 +490,9 @@ export class ReplicateResultProcessor {
} finally {
// Remove from processing queue
this._processingChanges = this._processingChanges.filter((e) => e !== change);
// Queue visibility must not wait for persisting a recovery snapshot. Consumers
// use this count to decide whether the reflected Vault is ready for interaction.
this.reportStatus();
try {
if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) {
try {
@@ -511,6 +517,8 @@ export class ReplicateResultProcessor {
releaser = await this._semaphore.acquire();
await this._applyToDatabase(doc);
} catch (e) {
// An exception during application must not be mistaken for a fully reflected Vault.
this.context.onVaultReflectionFailure?.();
this.log(`Error while processing replication result`, LOG_LEVEL_NOTICE);
this.logError(e);
} finally {
@@ -544,6 +552,7 @@ export class ReplicateResultProcessor {
: await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true);
if (!doc) {
// Failed to gather content
this.context.onVaultReflectionFailure?.();
this.log(`Failed to gather content of ${docNote}`, LOG_LEVEL_NOTICE);
return;
}
@@ -619,6 +628,7 @@ export class ReplicateResultProcessor {
// Note that this is not a common case.
return true;
} else {
this.context.onVaultReflectionFailure?.();
this.log(
`Failed to get existing document for ${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)}) `,
LOG_LEVEL_NOTICE
@@ -37,6 +37,9 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
}
type SetupOptions = {
onVaultReflectionFailure?: () => void;
getRaw?: (id: string) => Promise<unknown>;
getDBEntryFromMeta?: (entry: object) => Promise<unknown>;
applicationReady?: boolean;
databaseReady?: boolean;
maxMTimeForReflectEvents?: number;
@@ -57,7 +60,7 @@ function setup(options: SetupOptions = {}) {
const onCloseActiveReplication = vi.fn(async () => true);
const isReady = vi.fn(() => options.applicationReady ?? true);
const isValidPath = vi.fn(options.isValidPath ?? (() => true));
const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" }));
const getDBEntryFromMeta = vi.fn(options.getDBEntryFromMeta ?? (async (entry: object) => ({ ...entry, data: "x" })));
const localPhysicalDatabase = {
...(options.databaseId ? { id: vi.fn(async () => options.databaseId) } : {}),
} as PouchDB.Database<EntryDoc>;
@@ -89,12 +92,14 @@ function setup(options: SetupOptions = {}) {
if (options.localVersionInfo === undefined) throw { status: 404 };
return options.localVersionInfo;
}
if (options.getRaw) return await options.getRaw(id);
return { _id: id, _rev: "1-test" };
}),
getDBEntryFromMeta,
},
};
const processor = new ReplicateResultProcessor({
onVaultReflectionFailure: options.onVaultReflectionFailure,
currentSettings: () => ({
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
suspendParseReplicationResult: false,
@@ -117,12 +122,64 @@ function setup(options: SetupOptions = {}) {
isValidPath,
onCloseActiveReplication,
processor,
replicationResultCount: core.services.replication.replicationResultCount,
processSynchroniseResult,
runBoundedLocalApplicationActivity,
};
}
describe("ReplicateResultProcessor", () => {
it("reports an IndexedDB read error as failed reflection", async () => {
const onVaultReflectionFailure = vi.fn();
const { processor, processSynchroniseResult } = setup({
onVaultReflectionFailure,
getRaw: async () => { throw new Error("IndexedDB read failed"); },
});
processor.enqueueAll([note("unreadable")]);
await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledOnce());
expect(processSynchroniseResult).not.toHaveBeenCalled();
});
it("reports a thrown document reconstruction as failed reflection", async () => {
const onVaultReflectionFailure = vi.fn();
const { processor } = setup({
onVaultReflectionFailure,
getDBEntryFromMeta: async () => { throw new Error("Cannot read chunks"); },
});
processor.enqueueAll([note("missing-chunks")]);
await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledOnce());
});
it("publishes pending received documents synchronously even if snapshot persistence stalls", () => {
const { processor, replicationResultCount, processSynchroniseResult } = setup({
applicationReady: false,
setSnapshot: async () => new Promise<never>(() => undefined),
});
processor.enqueueAll([note("pending")]);
expect(replicationResultCount.value).toBe(1);
expect(processSynchroniseResult).not.toHaveBeenCalled();
});
it("clears the received-document counter before awaiting an unresponsive snapshot write", async () => {
const { processor, replicationResultCount, processSynchroniseResult } = setup({
setSnapshot: async () => new Promise<never>(() => undefined),
});
processor.enqueueAll([note("pending")]);
expect(replicationResultCount.value).toBe(1);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(replicationResultCount.value).toBe(0));
});
it("reports a failed Vault reflection instead of treating an empty queue as success", async () => {
const onVaultReflectionFailure = vi.fn();
const { processor, processSynchroniseResult } = setup({
onVaultReflectionFailure,
processSynchroniseResult: async () => false,
});
processor.enqueueAll([note("unreflected")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(onVaultReflectionFailure).toHaveBeenCalledTimes(1));
});
it("does not add a permanent application block when snapshot recovery fails", async () => {
const { processor } = setup({
getSnapshot: async () => {
+2 -1
View File
@@ -4,7 +4,7 @@ import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/servic
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { fireAndForget } from "octagonal-wheels/promises";
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { EVENT_APPLICATION_READY } from "@/common/events";
import { EVENT_APPLICATION_READY, EVENT_VAULT_REFLECTION_FAILED } from "@/common/events";
import { createAutomaticReplicationTriggers } from "./automaticTriggers";
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
import { createOnlineReplicationPreflight, createSecuritySeedPreflight } from "./preflight";
@@ -38,6 +38,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
? services.replicator
: undefined;
const resultProcessor = new ReplicateResultProcessor({
onVaultReflectionFailure: () => services.context.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED),
currentSettings: () => services.setting.currentSettings(),
getKeyValueDB: () => services.keyValueDB.kvDB,
getLocalDatabase: () => core.localDatabase,
@@ -0,0 +1,121 @@
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);
// Mobile input methods, dictation, and paste may mutate an editor without a keydown.
doc.addEventListener("beforeinput", this.blockEditorInput, true);
doc.addEventListener("paste", this.blockEditorInput, true);
doc.addEventListener("drop", this.blockEditorInput, true);
// Do not steal focus from an Obsidian conflict or setup dialogue which was already open.
if (!modalFocused) bypass.focus();
}
/** 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();
};
private readonly blockEditorInput = (event: Event): void => {
if (!this.root) return;
const target = event.target;
if (target instanceof Element && target.closest(".modal-container")) return;
if (target instanceof Node && this.root.contains(target)) return;
event.preventDefault();
event.stopImmediatePropagation();
};
close(): void {
if (!this.root) return;
const doc = this.workspace.ownerDocument;
const focused = doc.activeElement;
const ownsFocus = focused instanceof Node && this.root.contains(focused);
doc.removeEventListener("keydown", this.blockKeyboard, true);
doc.removeEventListener("beforeinput", this.blockEditorInput, true);
doc.removeEventListener("paste", this.blockEditorInput, true);
doc.removeEventListener("drop", this.blockEditorInput, true);
this.root.remove();
this.root = undefined;
this.stage = undefined;
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,215 @@
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();
});
it("blocks mobile beforeinput and paste into hidden editors but permits input in Obsidian dialogues", () => {
const { overlay, doc, editor } = setup();
overlay.update({ phase: "transferring", receivedFiles: 30 });
const modal = new FakeElement(doc, "div");
modal.className = "modal-container";
const input = modal.createEl("input");
for (const eventType of ["beforeinput", "paste", "drop"]) {
const preventDefault = vi.fn();
const stopImmediatePropagation = vi.fn();
doc.listeners.get(eventType)?.({
target: editor, preventDefault, stopImmediatePropagation,
} as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
doc.listeners.get(eventType)?.({
target: input, preventDefault, stopImmediatePropagation,
} as unknown as KeyboardEvent);
expect(preventDefault).toHaveBeenCalledOnce();
}
overlay.dispose();
expect(doc.listeners.size).toBe(0);
});
});
@@ -0,0 +1,153 @@
/** The guard observes catch-up work without changing replication or Vault writes. */
export type SyncGuardPhase = "idle" | "observing" | "transferring" | "applying" | "interrupted";
export interface SyncGuardPresentation {
phase: SyncGuardPhase;
receivedFiles: number;
}
/**
* One resume-triggered CouchDB catch-up attempt. Small transfers never lock the workspace.
* Network completion alone does not unlock it: received documents still need reflecting to the Vault.
*/
export class SyncGuardState {
private phase: SyncGuardPhase = "idle";
private receivedIds = new Set<string>();
private minimumFiles = 20;
private catchUpActive = false;
private observedCompletion = false;
private hadReflectionFailure = false;
private bypassed = false;
private quiescentSince: number | undefined;
constructor(
private readonly onChange: (presentation: SyncGuardPresentation) => void,
private readonly quietPeriodMs = 2_000
) {}
get presentation(): SyncGuardPresentation {
return { phase: this.phase, receivedFiles: this.receivedIds.size };
}
get wasBypassed(): boolean {
return this.bypassed;
}
get isCatchUpActive(): boolean {
return this.catchUpActive;
}
/** Begins a fresh, initially non-blocking observation window on app resume. */
resume(enabled: boolean, minimumFiles: number): void {
this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20;
this.receivedIds.clear();
this.catchUpActive = 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.catchUpActive && this.receivedIds.size >= this.minimumFiles) {
this.setPhase("transferring");
}
}
/** The CouchDB catch-up phase ends before continuous replication and Vault reflection finish. */
catchUpActivityChanged(active: boolean, pendingCount: number, now: number): void {
if (this.catchUpActive === active) return;
// A manual bypass lasts only for this catch-up generation. Even while the
// overlay is closed, observe its terminal transition so that a later
// reconnect can protect the workspace again.
if (this.bypassed) {
this.catchUpActive = active;
if (!active) this.bypassed = false;
return;
}
if (this.phase === "idle") return;
if (active) {
// A retry or a second pull can begin while the Vault is still applying an earlier batch.
// Do not let the previous successful completion unlock an active newer transfer.
this.catchUpActive = true;
this.observedCompletion = false;
this.quiescentSince = undefined;
if (this.isBlocking()) this.setPhase("transferring");
return;
}
this.catchUpActive = false;
if (!this.isBlocking()) {
this.setPhase("idle");
} else if (!this.observedCompletion || this.hadReflectionFailure) {
this.setPhase("interrupted");
} else {
this.setPhase("applying");
this.tick(pendingCount, now);
}
}
/** Only the catch-up → continuous transition proves that the initial pull succeeded. */
catchUpSucceeded(): void {
if (this.phase === "idle" || this.bypassed || !this.catchUpActive) return;
this.observedCompletion = true;
}
/** A network-complete batch is not a safe unlock when reflecting a received note failed. */
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.catchUpActive || 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 (!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. */
tick(pendingCount: number, now: number): void {
if (this.phase !== "applying") return;
if (pendingCount > 0) {
this.quiescentSince = undefined;
return;
}
this.quiescentSince ??= now;
if (now - this.quiescentSince >= this.quietPeriodMs) this.setPhase("idle");
}
/** An offline or suspended attempt can be bypassed rather than trapping the user. */
interrupted(): void {
if (this.isBlocking()) this.setPhase("interrupted");
else this.setPhase("idle");
}
bypass(): void {
this.bypassed = true;
this.setPhase("idle");
}
disable(): void {
this.bypassed = true;
this.setPhase("idle");
}
private isBlocking(): boolean {
return this.phase === "transferring" || this.phase === "applying" || this.phase === "interrupted";
}
private setPhase(next: SyncGuardPhase): void {
if (this.phase === next) return;
this.phase = next;
this.onChange(this.presentation);
}
}
@@ -0,0 +1,170 @@
import { describe, expect, it, vi } from "vitest";
import { SyncGuardState, type SyncGuardPresentation } from "./SyncGuardState";
describe("SyncGuardState", () => {
function setup() {
const presentations: SyncGuardPresentation[] = [];
const onChange = vi.fn((value: SyncGuardPresentation) => presentations.push(value));
const guard = new SyncGuardState(onChange, 2000);
return { guard, onChange, presentations };
}
it("never locks for a small incoming change batch", () => {
const { guard } = setup();
guard.resume(true, 20);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:one", "f:two"]);
guard.catchUpSucceeded();
guard.catchUpActivityChanged(false, 0, 10);
expect(guard.presentation.phase).toBe("idle");
});
it("counts distinct Metadata rather than repeated changes and waits for local application", () => {
const { guard } = setup();
guard.resume(true, 3);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:one", "f:one", "f:two"]);
expect(guard.presentation.phase).toBe("observing");
guard.receivedDocuments(["f:three"]);
expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 3 });
guard.receivedDocuments(["f:four", "f:four"]);
expect(guard.presentation.receivedFiles).toBe(4);
guard.catchUpSucceeded();
guard.catchUpActivityChanged(false, 2, 10);
expect(guard.presentation.phase).toBe("applying");
guard.tick(0, 20);
guard.tick(1, 2000);
guard.tick(0, 2100);
guard.tick(0, 4099);
expect(guard.presentation.phase).toBe("applying");
guard.tick(0, 4100);
expect(guard.presentation.phase).toBe("idle");
});
it("keeps the lock with an interrupted transfer until the user bypasses it", () => {
const { guard } = setup();
guard.resume(true, 2);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:one", "f:two"]);
guard.catchUpActivityChanged(false, 0, 10);
expect(guard.presentation.phase).toBe("interrupted");
guard.bypass();
expect(guard.presentation.phase).toBe("idle");
guard.receivedDocuments(["f:other"]);
expect(guard.presentation.phase).toBe("idle");
});
it("expires the manual bypass when its catch-up completes and protects the next catch-up", () => {
const { guard } = setup();
guard.resume(true, 2);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["first", "second"]);
expect(guard.presentation.phase).toBe("transferring");
guard.bypass();
expect(guard.wasBypassed).toBe(true);
guard.catchUpActivityChanged(true, 0, 5);
expect(guard.isCatchUpActive).toBe(true);
guard.catchUpSucceeded();
guard.catchUpActivityChanged(false, 0, 10);
expect(guard.isCatchUpActive).toBe(false);
expect(guard.wasBypassed).toBe(false);
expect(guard.presentation.phase).toBe("idle");
guard.resume(true, 2);
guard.catchUpActivityChanged(true, 0, 20);
guard.receivedDocuments(["another-first", "another-second"]);
expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 2 });
});
it("does not unlock when a new pull starts while applying an earlier completed transfer", () => {
const { guard } = setup();
guard.resume(true, 1);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:first"]);
guard.catchUpSucceeded();
guard.catchUpActivityChanged(false, 0, 1);
expect(guard.presentation.phase).toBe("applying");
guard.catchUpActivityChanged(true, 0, 100);
expect(guard.presentation.phase).toBe("transferring");
guard.tick(0, 10_000);
expect(guard.presentation.phase).toBe("transferring");
guard.catchUpActivityChanged(false, 0, 10_001);
expect(guard.presentation.phase).toBe("interrupted");
});
it("can recover an interrupted catch-up when a new pull succeeds", () => {
const { guard } = setup();
guard.resume(true, 1);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:first"]);
guard.catchUpActivityChanged(false, 0, 10);
expect(guard.presentation.phase).toBe("interrupted");
guard.catchUpActivityChanged(true, 0, 20);
expect(guard.presentation.phase).toBe("transferring");
guard.catchUpSucceeded();
guard.catchUpActivityChanged(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.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:first"]);
guard.reflectionFailed();
guard.catchUpSucceeded();
guard.catchUpActivityChanged(false, 0, 10);
guard.tick(0, 10_000);
expect(guard.presentation.phase).toBe("interrupted");
guard.bypass();
expect(guard.presentation.phase).toBe("idle");
});
it("does not treat a stale completion as success for a new catch-up", () => {
const { guard } = setup();
guard.resume(true, 1);
guard.catchUpSucceeded();
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:one"]);
guard.catchUpActivityChanged(false, 0, 1);
expect(guard.presentation.phase).toBe("interrupted");
});
it("resets after resume and is opt-in", () => {
const { guard } = setup();
guard.resume(false, 1);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:one"]);
expect(guard.presentation.phase).toBe("idle");
guard.resume(true, 1);
guard.catchUpActivityChanged(true, 0, 10);
guard.receivedDocuments(["f:one"]);
expect(guard.presentation.phase).toBe("transferring");
guard.disable();
expect(guard.presentation.phase).toBe("idle");
});
it("handles NaN and negative thresholds conservatively", () => {
const { guard } = setup();
guard.resume(true, NaN);
guard.catchUpActivityChanged(true, 0, 0);
guard.receivedDocuments(["f:one"]);
expect(guard.presentation.phase).toBe("observing");
guard.resume(true, -2);
guard.catchUpActivityChanged(true, 0, 10);
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.catchUpActivityChanged(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");
});
});
+196
View File
@@ -0,0 +1,196 @@
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 { getSyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts";
import {
EVENT_SETTING_SAVED,
EVENT_SYNC_GUARD_PREFERENCES_CHANGED,
EVENT_VAULT_REFLECTION_FAILED,
} from "@/common/events";
import type { SyncGuardOverlay } from "./SyncGuardOverlay";
import { SyncGuardState } from "./SyncGuardState";
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;
let terminalPhaseTimer: 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;
};
const stopTerminalPhaseTimer = () => {
if (terminalPhaseTimer !== undefined) compatGlobal.clearTimeout(terminalPhaseTimer);
terminalPhaseTimer = 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 &&
getSyncGuardSettings(services.setting).syncGuardEnabled;
};
const isCouchDbCatchUp = () => services.replicator.replicationStatics.value.couchDB?.phase === "catch-up";
const beginCatchUp = () => {
if (!isEnabled()) {
guard.disable();
return;
}
// PouchDB briefly reports 'idle' when disposing its progress observer, while
// the enclosing CouchDB catch-up still owns the previous guard generation.
// Do not reset that generation on duplicate resume, even before the threshold.
const sameCatchUp = guard.isCatchUpActive || isCouchDbCatchUp();
// An explicit bypass must survive this same transient 'idle'.
if (guard.wasBypassed && sameCatchUp) {
return;
}
// Duplicate resume notifications or a visibility transition must not discard an
// 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" && sameCatchUp) {
return;
}
guard.resume(Boolean(isEnabled()), getSyncGuardSettings(services.setting).syncGuardMinimumFiles);
guard.catchUpActivityChanged(isCouchDbCatchUp(), readPendingCount(), Date.now());
};
const onReplicationChanged = () => {
const phase = services.replicator.replicationStatics.value.couchDB?.phase;
if (phase === "catch-up") {
stopTerminalPhaseTimer();
// Also arm after an internal CouchDB reconnect without an Obsidian resume event.
if (guard.presentation.phase === "idle" && !guard.wasBypassed && isEnabled()) beginCatchUp();
guard.catchUpActivityChanged(true, readPendingCount(), Date.now());
} else if (phase === "continuous") {
stopTerminalPhaseTimer();
// Commonlib enters continuous only after a successful pull-only catch-up.
guard.catchUpSucceeded();
guard.catchUpActivityChanged(false, readPendingCount(), Date.now());
} else if ((guard.presentation.phase !== "idle" || guard.isCatchUpActive) && terminalPhaseTimer === undefined) {
// The one-shot PouchDB progress observer can report 'idle' before the
// enclosing finite CouchDB pull has finished releasing its resources.
// This counter is not used to identify the provider; CouchDB phase owns that.
if (phase === "idle" && services.replicator.finiteReplicationActivityCount.value > 0) return;
// PouchDB disposes its per-attempt progress observer with phase 'idle' just
// before Commonlib publishes 'continuous'. When the enclosing finite
// transfer settles, defer the terminal observation by one event-loop
// turn so that this internal hand-off never looks like a failure.
terminalPhaseTimer = compatGlobal.setTimeout(() => {
terminalPhaseTimer = undefined;
const current = services.replicator.replicationStatics.value.couchDB?.phase;
if (!disposed && current !== "catch-up" && current !== "continuous") {
guard.catchUpActivityChanged(false, readPendingCount(), Date.now());
}
}, 0);
}
};
const onSettingsChanged = () => {
if (!isEnabled()) guard.disable();
else guard.updateThreshold(getSyncGuardSettings(services.setting).syncGuardMinimumFiles);
};
const onPendingChanged = () => checkForUnlock();
const onFiniteChanged = () => {
if (services.replicator.finiteReplicationActivityCount.value === 0) onReplicationChanged();
};
const removeReflectionFailureHandler = services.context.events.onEvent(EVENT_VAULT_REFLECTION_FAILED, () => {
guard.reflectionFailed();
});
const removeSettingSavedHandler = services.context.events.onEvent(EVENT_SETTING_SAVED, () => {
onSettingsChanged();
});
const removeSyncGuardPreferencesHandler = services.context.events.onEvent(
EVENT_SYNC_GUARD_PREFERENCES_CHANGED,
onSettingsChanged
);
const replication = services.replicator.replicationStatics;
const finite = services.replicator.finiteReplicationActivityCount;
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,
];
replication.onChanged(onReplicationChanged);
finite.onChanged(onFiniteChanged);
for (const source of pendingSources) source.onChanged(onPendingChanged);
// Register before the ordinary replication scheduling handler so the first finite pull
// 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();
stopTerminalPhaseTimer();
replication.offChanged(onReplicationChanged);
finite.offChanged(onFiniteChanged);
for (const source of pendingSources) source.offChanged(onPendingChanged);
overlay.dispose();
removeSettingSavedHandler();
removeSyncGuardPreferencesHandler();
removeReflectionFailureHandler();
return Promise.resolve(true);
});
}
@@ -0,0 +1,416 @@
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { REMOTE_COUCHDB, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { CouchDBReplicationPhase, ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import {
EVENT_SETTING_SAVED,
EVENT_SYNC_GUARD_PREFERENCES_CHANGED,
EVENT_VAULT_REFLECTION_FAILED,
} from "@/common/events";
import { describe, afterEach, expect, it, vi } from "vitest";
import { useSyncGuardFeature } from "./index";
import type { SyncGuardPresentation } from "./SyncGuardState";
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<ReplicationStatics>({
sent: 0,
arrived: 0,
maxPullSeq: 0,
maxPushSeq: 0,
lastSyncPullSeq: 0,
lastSyncPushSeq: 0,
syncStatus: "NOT_CONNECTED",
});
const setCouchDbPhase = (phase: CouchDBReplicationPhase) => {
replicationStatics.value = { ...replicationStatics.value, couchDB: { phase } };
};
const replicationResultCount = reactiveSource(0);
const databaseQueueCount = reactiveSource(0);
const storageApplyingCount = reactiveSource(0);
const processing = reactiveSource(0);
const totalQueued = reactiveSource(0);
const batched = reactiveSource(0);
const conflictProcessQueueCount = reactiveSource(0);
const settings = {
syncGuardEnabled: overrides.enabled ?? true,
syncGuardMinimumFiles: overrides.minimumFiles ?? 2,
};
const sharedSettings = {
isConfigured: true,
liveSync: true,
remoteType: REMOTE_COUCHDB,
};
const host = {
services: {
context: createServiceContext(),
setting: {
currentSettings: () => sharedSettings,
syncGuardSettings: 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,
setCouchDbPhase,
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.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.databaseQueueCount.value = 1;
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
h.setCouchDbPhase("continuous");
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
vi.advanceTimersByTime(5_000);
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
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("does not mistake a transient progress-observer idle for a failed catch-up", async () => {
vi.useFakeTimers();
const h = setup({ minimumFiles: 1 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first")]);
h.databaseQueueCount.value = 1;
h.setCouchDbPhase("idle"); // PouchDB's per-attempt observer is disposed before the continuous hand-off.
h.setCouchDbPhase("continuous");
await vi.advanceTimersByTimeAsync(1);
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
h.databaseQueueCount.value = 0;
await vi.advanceTimersByTimeAsync(2_150);
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
await h.onUnload[0]();
});
it("waits for the finite pull to settle when progress temporarily reports idle", async () => {
vi.useFakeTimers();
const h = setup({ minimumFiles: 1 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
h.finiteReplicationActivityCount.value = 1;
await h.parseHandlers[0]([receivedNote("first")]);
h.setCouchDbPhase("idle");
await vi.advanceTimersByTimeAsync(5_000); // Resource disposal may be asynchronous.
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.finiteReplicationActivityCount.value = 0;
h.setCouchDbPhase("continuous");
await vi.advanceTimersByTimeAsync(1);
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
await h.onUnload[0]();
});
it("keeps received-note counting across duplicate resume during transient idle with finite pull active", async () => {
vi.useFakeTimers();
const h = setup({ minimumFiles: 3 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
h.finiteReplicationActivityCount.value = 1;
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
h.setCouchDbPhase("idle");
await h.onResumed[0](); // Must not reset the already observed two notes.
await h.parseHandlers[0]([receivedNote("third")]);
expect(h.updates[h.updates.length - 1]).toMatchObject({
phase: "transferring",
receivedFiles: 3,
});
h.finiteReplicationActivityCount.value = 0;
h.setCouchDbPhase("continuous");
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
await h.onUnload[0]();
});
it("honours manual bypass across duplicate resume while catch-up reports transient idle", async () => {
vi.useFakeTimers();
const h = setup({ minimumFiles: 1 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
h.finiteReplicationActivityCount.value = 1;
await h.parseHandlers[0]([receivedNote("first")]);
h.bypass();
h.setCouchDbPhase("idle");
await h.onResumed[0]();
await h.parseHandlers[0]([receivedNote("second")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
await h.onUnload[0]();
});
it("re-arms protection for a new substantial catch-up after bypass, completion, suspension and resume", async () => {
const h = setup({ minimumFiles: 2 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.bypass();
// A duplicate resume before the first transfer finishes must honour the bypass.
await h.onResumed[0]();
await h.parseHandlers[0]([receivedNote("third"), receivedNote("fourth")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
// Successful hand-off to Continuous retires the bypassed generation.
h.setCouchDbPhase("continuous");
await h.onSuspending[0]();
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("new-first"), receivedNote("new-second")]);
expect(h.updates[h.updates.length - 1]).toMatchObject({
phase: "transferring",
receivedFiles: 2,
});
await h.onUnload[0]();
});
it("re-arms protection after bypassed catch-up fails and later restarts", async () => {
vi.useFakeTimers();
const h = setup({ minimumFiles: 1 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
h.finiteReplicationActivityCount.value = 1;
await h.parseHandlers[0]([receivedNote("failed")]);
h.bypass();
h.setCouchDbPhase("idle");
h.finiteReplicationActivityCount.value = 0;
await vi.advanceTimersByTimeAsync(1);
await h.onSuspending[0]();
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("after-restart")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
await h.onUnload[0]();
});
it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => {
const h = setup({ minimumFiles: 2 });
await h.onResumed[0]();
h.finiteReplicationActivityCount.value = 1; // P2P uses only the shared finite counter.
await h.parseHandlers[0]([receivedNote("peer-first"), receivedNote("peer-second")]);
h.finiteReplicationActivityCount.value = 0;
expect(h.updates.some((p) => p.phase === "transferring" || p.phase === "interrupted")).toBe(false);
// A real CouchDB catch-up must still arm normally in the same session.
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("db-first"), receivedNote("db-second")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
await h.onUnload[0]();
});
it("never blocks normal transfers and ignores chunks and duplicate note revisions", async () => {
const h = setup({ minimumFiles: 3 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first"), receivedNote("first"), { _id: "h:chunk", _rev: "1-a", type: "leaf" } as never]);
expect(h.updates.some((p) => p.phase === "transferring")).toBe(false);
await h.onUnload[0]();
});
it("preserves offline access through manual bypass and never re-locks that catch-up", async () => {
vi.useFakeTimers();
const h = setup({ minimumFiles: 1 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first")]);
h.setCouchDbPhase("idle");
await vi.advanceTimersByTimeAsync(1);
expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted");
h.bypass();
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
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.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first")]);
expect(h.updates.some((p) => p.phase === "transferring")).toBe(false);
await h.onUnload[0]();
const enabled = setup({ minimumFiles: 1 });
await enabled.onResumed[0]();
enabled.setCouchDbPhase("catch-up");
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.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("after-reconnect")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
h.setCouchDbPhase("continuous");
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
await h.onUnload[0]();
});
it("preserves an active guard across duplicate resume notifications", async () => {
const h = setup({ minimumFiles: 2 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
await h.onResumed[0]();
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
h.setCouchDbPhase("continuous");
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
await h.onUnload[0]();
});
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.setCouchDbPhase("catch-up");
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.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("failed")]);
h.events.emitEvent(EVENT_VAULT_REFLECTION_FAILED);
h.replicationStatics.value = { ...h.replicationStatics.value, syncStatus: "COMPLETED" };
h.setCouchDbPhase("continuous");
expect(h.updates[h.updates.length - 1]?.phase).toBe("interrupted");
await h.onUnload[0]();
});
it("responds immediately to settings saved by the UI without setting-realised", async () => {
const h = setup({ minimumFiles: 20 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("one"), receivedNote("two")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("observing");
h.settings.syncGuardMinimumFiles = 2;
h.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.settings.syncGuardEnabled = false;
h.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED);
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
await h.onUnload[0]();
});
it("re-locks on a new internal catch-up after a bypassed transfer has completed", async () => {
const h = setup({ minimumFiles: 1 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.bypass();
h.setCouchDbPhase("continuous");
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("second")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
await h.onUnload[0]();
});
it("preserves manual bypass across duplicate resume while the same CouchDB pull remains active", async () => {
const h = setup({ minimumFiles: 2 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.bypass();
await h.onResumed[0]();
await h.parseHandlers[0]([receivedNote("third"), receivedNote("fourth")]);
expect(h.updates[h.updates.length - 1]?.phase).toBe("idle");
await h.onUnload[0]();
});
});