mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Merge main into shared runtime foundations
Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
commit
bd05dde39c
83 files changed
+6313
-1507
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user