vault-link/frontend/test-support/recovery.test.ts
2026-09-21 21:29:56 +01:00

580 lines
22 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { MemoryDisk, MemoryPersistence } from "./storage";
import {
Database,
emptyState,
type StoredDatabase
} from "../sync-client/src/persistence/database";
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
import type { ServerConfig } from "../sync-client/src/services/server-config";
import { mergeFileManifests } from "../sync-client/src/sync-operations/file-manifest";
import { assertManifest, pathDecision } from "./oracles";
import {
scanLocalFiles,
type LocalChange
} from "../sync-client/src/sync-operations/scan";
const bytes = (text: string) => new TextEncoder().encode(text);
const config = {
getConfig: async () => ({ mergeableFileExtensions: ["md"] })
} as ServerConfig;
async function fixture(
contents: Record<string, { path: string; text: string }> = {
a: { path: "a.md", text: "A" },
b: { path: "b.md", text: "B" }
}
) {
const disk = new MemoryDisk();
const initial = emptyState("recovery-test");
for (const [id, { path, text }] of Object.entries(contents)) {
await disk.userWrite(path, bytes(text));
initial.local[id] = path;
}
for (const [id, path] of Object.entries(initial.local))
initial.documents[id] = {
materialized: true,
observedHash: (
await toStoredSnapshot((await disk.readSnapshot(path))!)
).hash
};
const persistence = new MemoryPersistence({ database: initial });
const restart = (changes: LocalChange[] = []) => {
const database = new Database(
persistence.snapshot().database,
async (next) => persistence.save({ database: next }),
"recovery-test",
async () => (await persistence.load()).database as StoredDatabase
);
return {
database,
files: new FileOperations(
disk.session(),
database,
config,
undefined,
{ entries: () => changes, flush: async () => {} }
)
};
};
return {
disk,
persistence,
restart,
initial,
permanentFault: (_label: string): void => {},
expectedLocal: undefined as Record<string, string> | undefined
};
}
async function swapFixture() {
const current = await fixture();
const next = structuredClone(current.initial);
next.local = { a: "b.md", b: "a.md", c: "nested/c.md" };
next.documents.c = { materialized: false };
const writes = {
c: { replacement: await toStoredSnapshot({ content: bytes("C") }) }
};
const expected = new Map([
["a.md", bytes("B")],
["b.md", bytes("A")],
["nested/c.md", bytes("C")]
]);
return { ...current, next, writes, expected };
}
async function directoryCycleFixture() {
const current = await fixture({
a: { path: "a", text: "A" },
b: { path: "b/deep/child", text: "B" }
});
const next = structuredClone(current.initial);
next.local = { a: "b", b: "a/deep/child" };
return {
...current,
next,
writes: {},
expected: new Map([
["b", bytes("A")],
["a/deep/child", bytes("B")]
])
};
}
async function crossDirectoryFixture() {
const current = await fixture({
a: { path: "src/a.md", text: "A" },
// Keep src nonempty: deleting an empty source directory would mask an
// unflushed source unlink in the staging rename.
b: { path: "src/keep.md", text: "untouched sibling" }
});
const next = structuredClone(current.initial);
next.local.a = "dst/a.md";
const expected = new Map([
["dst/a.md", bytes("A")],
["src/keep.md", bytes("untouched sibling")]
]);
return { ...current, next, writes: {}, expected };
}
async function mergeFixture() {
const base = "first paragraph\n\nlast paragraph\n";
const local = "local first paragraph\n\nlast paragraph\n";
const remote = "first paragraph\n\nremote last paragraph\n";
const current = await fixture({ a: { path: "note.md", text: local } });
return {
...current,
next: structuredClone(current.initial),
writes: {
a: {
expected: await toStoredSnapshot({ content: bytes(base) }),
replacement: await toStoredSnapshot({ content: bytes(remote) })
}
},
expected: new Map([
[
"note.md",
bytes("local first paragraph\n\nremote last paragraph\n")
]
])
};
}
async function deletionFixture() {
const current = await fixture({
a: { path: "a.bin", text: "private binary edit\0" },
b: { path: "deleted.md", text: "unsent deleted bytes" }
});
const next = structuredClone(current.initial);
delete next.local.b;
return {
...current,
next,
writes: {
a: {
replacement: await toStoredSnapshot({
content: bytes("remote binary\0")
})
}
},
expected: new Map([["a.bin", bytes("remote binary\0")]])
};
}
async function permanentDestinationFixture() {
const current = await fixture();
const next = structuredClone(current.initial);
next.local = { a: "unsupported/a.md", b: "good.md" };
return {
...current,
next,
permanentFault: (label: string) => {
if (label === "before:mkdir:unsupported")
throw Object.assign(new Error("Unsupported destination"), {
code: "ENAMETOOLONG"
});
},
writes: {
a: {
expected: await toStoredSnapshot({ content: bytes("A") }),
replacement: await toStoredSnapshot({
content: bytes("REMOTE")
})
}
},
expectedLocal: { a: "Recovered a.md", b: "good.md" },
expected: new Map([
["Recovered a.md", bytes("REMOTE")],
["good.md", bytes("B")]
])
};
}
for (const [name, create] of [
["swap and create", swapFixture],
["file/directory cycle", directoryCycleFixture],
["cross-directory move", crossDirectoryFixture],
["content merge", mergeFixture],
["binary replacement and deletion", deletionFixture],
["permanent destination fallback", permanentDestinationFixture]
] as const) {
test(`${name}: interrupted operations leave metadata usable by ordinary scans`, async (t) => {
const baseline = await create();
const labels: string[] = [];
const trace = (label: string) => {
if (!label.startsWith("read:")) labels.push(label);
};
baseline.disk.boundary = (label) => {
baseline.permanentFault(label);
trace(label);
};
baseline.persistence.boundary = trace;
await baseline.restart().files.apply(baseline.next, baseline.writes);
assert.deepEqual(baseline.disk.userFiles(), baseline.expected);
assert.deepEqual(
baseline.persistence.snapshot().database!.local,
baseline.expectedLocal ?? baseline.next.local
);
assert(labels.length > 0);
for (const powerLoss of [false, true])
for (let cut = 0; cut < labels.length; cut++) {
await t.test(
`${powerLoss ? "power" : "process"} interruption at ${labels[cut]}`,
async () => {
const current = await create();
let seen = 0;
let fired = false;
const interrupt = (label: string) => {
if (label.startsWith("read:")) return;
if (seen++ === cut) {
fired = true;
current.disk.crash(powerLoss);
throw new Error("interrupted");
}
};
current.disk.boundary = (label) => {
current.permanentFault(label);
interrupt(label);
};
current.persistence.boundary = interrupt;
await assert.rejects(
current
.restart()
.files.apply(current.next, current.writes),
/interrupted/
);
assert(fired);
current.disk.boundary = () => {};
current.persistence.boundary = () => {};
// The user may edit after the interruption, including a partial
// write or a file whose metadata was never saved.
await current.disk.userWrite(
"after-restart.md",
bytes("user edit")
);
const runtime = current.restart();
const observed = current.disk.userFiles();
await scanChanges(runtime, []);
assert.deepEqual(
current.disk.userFiles(),
observed,
"A scan must not replay old filesystem intentions"
);
assertManifest(runtime.database.state.local);
assert.deepEqual(
new Set(
Object.values(runtime.database.state.local)
),
new Set(observed.keys())
);
for (const [id, path] of Object.entries(
runtime.database.state.local
)) {
assert(
runtime.database.state.documents[id]
.materialized
);
assert.equal(
runtime.database.state.documents[id]
.observedHash,
(await runtime.files.snapshot(path))!.hash
);
}
assert(
(await runtime.files.listFilesRecursively()).every(
(path) => !path.startsWith(".vault-link-sync/")
)
);
const state = structuredClone(runtime.database.state);
await scanChanges(current.restart(), []);
assert.deepEqual(
current.persistence.snapshot().database,
state,
"Second startup must be stable"
);
}
);
}
});
}
test("production manifest merge obeys independently specified per-document policy", () => {
const paths = [undefined, "a.md", "b.md", "nested/c.md"];
const map = (path: string | undefined): Record<string, string> =>
path === undefined
? {}
: { "00000000-0000-4000-8000-000000000001": path };
for (const base of paths)
for (const local of paths)
for (const remote of paths) {
assert.deepEqual(
mergeFileManifests(map(base), map(local), map(remote)),
map(pathDecision(base, local, remote))
);
}
const conflicts = mergeFileManifests(
{},
{ a: "folder" },
{ b: "folder/note.md" }
);
assertManifest(conflicts);
assert.equal(
conflicts.b,
"folder/note.md",
"Canonical path must win collision"
);
assert.equal(
Object.keys(conflicts).length,
2,
"Collision must not discard an ID"
);
});
test("exhaustive two-document manifest states preserve IDs and canonical collision priority", () => {
const ids = [
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002"
];
const states: Record<string, string>[] = [];
for (const a of [undefined, "a.md", "b.md"])
for (const b of [undefined, "a.md", "b.md"]) {
if (a && a === b) continue;
states.push({
...(a ? { [ids[0]]: a } : {}),
...(b ? { [ids[1]]: b } : {})
});
}
for (const base of states)
for (const local of states)
for (const remote of states) {
const expected = Object.fromEntries(
ids
.map((id) => [
id,
pathDecision(base[id], local[id], remote[id])
])
.filter(([, value]) => value !== undefined)
);
const merged = mergeFileManifests(base, local, remote);
assertManifest(merged);
assert.deepEqual(
Object.keys(merged).sort(),
Object.keys(expected).sort(),
"Merge discarded or invented an identity"
);
for (const id of ids)
if (
expected[id] !== undefined &&
remote[id] === expected[id]
)
assert.equal(
merged[id],
remote[id],
"Canonical claimant lost its path"
);
assert.deepEqual(
mergeFileManifests(remote, merged, remote),
merged,
"Manifest merge is not idempotent"
);
}
});
test("scan failures cannot masquerade as deletions", async () => {
for (const point of ["read:list:", "read:snapshot:a.md"]) {
const current = await fixture();
const runtime = current.restart();
const original = structuredClone(runtime.database.state);
let fired = false;
current.disk.boundary = (label) => {
if (label === point) {
fired = true;
throw new Error("permission denied");
}
};
await assert.rejects(
scanLocalFiles({
next: structuredClone(original),
changes: [],
files: runtime.files,
guard: () => {},
ignored: () => false,
oversized: () => false,
commit: (next) => runtime.database.commit(next)
}),
/permission denied/
);
assert(fired);
assert.deepEqual(runtime.database.state, original);
assert.deepEqual(current.persistence.snapshot().database, original);
}
});
for (const phase of ["before", "durable"]) {
test(`delete/recreate identity survives ${phase}-save failure and retry`, async () => {
const current = await fixture();
await current.disk.userDelete("a.md");
await current.disk.userWrite("a.md", bytes("new incarnation"));
const changes: LocalChange[] = [
{ type: "delete", path: "a.md" },
{ type: "create", path: "a.md" }
];
const scan = async () => {
const runtime = current.restart();
await scanLocalFiles({
next: structuredClone(runtime.database.state),
changes,
files: runtime.files,
guard: () => {},
ignored: () => false,
oversized: () => false,
commit: (next) => runtime.database.commit(next)
});
return runtime.database.state;
};
current.persistence.boundary = (label) => {
if (label === `${phase}:save`) throw new Error("save failure");
};
await assert.rejects(scan(), /save failure/);
current.persistence.boundary = () => {};
const state = await scan();
assert.equal(
state.local.a,
undefined,
"Retry reused the deleted document's UUID"
);
assert.equal(
Object.values(state.local).filter((path) => path === "a.md").length,
1
);
});
}
async function scanChanges(
runtime: { database: Database; files: FileOperations },
changes: LocalChange[]
): Promise<void> {
await runtime.database.recoverPersistence();
await scanLocalFiles({
next: structuredClone(runtime.database.state),
changes,
files: runtime.files,
guard: () => {},
ignored: () => false,
oversized: () => false,
commit: (next) => runtime.database.commit(next)
});
}
for (const editedContent of ["new bytes", "B"]) {
test(`explicit rename then edit to ${JSON.stringify(editedContent)} preserves both UUIDs`, async () => {
const current = await fixture();
const runtime = current.restart();
await current.disk.userRename("a.md", "moved.md");
await current.disk.userWrite("moved.md", bytes(editedContent));
const changes: LocalChange[] = [
{ type: "move", oldPath: "a.md", relativePath: "moved.md" }
];
await scanChanges(runtime, changes);
assert.deepEqual(
runtime.database.state.local,
{ a: "moved.md", b: "b.md" },
"Matching another live document's bytes must not steal its UUID"
);
assert.deepEqual(
current.disk.userFiles(),
new Map([
["moved.md", bytes(editedContent)],
["b.md", bytes("B")]
])
);
assert.equal(changes.length, 0);
});
}
for (const equalContent of [false, true]) {
for (const phase of ["before", "durable"]) {
test(`${equalContent ? "equal" : "different"}-content swap retains UUIDs after ${phase}-save rejection`, async () => {
const current = await fixture({
a: { path: "a.md", text: "A" },
b: { path: "b.md", text: equalContent ? "A" : "B" }
});
const runtime = current.restart();
const changes: LocalChange[] = [];
for (const [oldPath, relativePath] of [
["a.md", "temporary.md"],
["b.md", "a.md"],
["temporary.md", "b.md"]
]) {
await current.disk.userRename(oldPath, relativePath);
changes.push({ type: "move", oldPath, relativePath });
}
const expected = { a: "b.md", b: "a.md" };
const expectedFiles = new Map([
["a.md", bytes(equalContent ? "A" : "B")],
["b.md", bytes("A")]
]);
let fired = false;
current.persistence.boundary = (label) => {
if (!fired && label === `${phase}:save`) {
fired = true;
throw new Error("uncertain save");
}
};
await assert.rejects(
scanChanges(runtime, changes),
/uncertain save/
);
assert(fired, "The requested save failure must be exercised");
assert.deepEqual(
current.persistence.snapshot().database!.local,
phase === "durable" ? expected : current.initial.local,
"The fault must occur on the intended side of the durable save"
);
current.persistence.boundary = () => {};
// Model an uncertain save in a live client: durable state reloads,
// but the original editor notifications remain in memory. Do not
// clear, reconstruct or deduplicate that queue in the harness.
await scanChanges(runtime, changes);
assert.deepEqual(
runtime.database.state.local,
expected,
"Retry replayed an already committed swap and reversed its identities"
);
assert.equal(changes.length, 0);
await scanChanges(runtime, changes);
assert.deepEqual(runtime.database.state.local, expected);
const restarted = current.restart();
await scanChanges(restarted, []);
assert.deepEqual(restarted.database.state.local, expected);
assert.deepEqual(current.disk.userFiles(), expectedFiles);
});
}
}
test("normalization-sensitive disk does not lose a distinct decomposed filename", async () => {
const current = await fixture();
await current.disk.userWrite("é.md", bytes("composed"));
const runtime = current.restart();
const next = structuredClone(runtime.database.state);
next.local.composed = "é.md";
next.documents.composed = { materialized: true };
await runtime.database.commit(next);
await current.disk.userWrite("e\u0301.md", bytes("decomposed"));
await scanLocalFiles({
next: structuredClone(runtime.database.state),
changes: [],
files: runtime.files,
guard: () => {},
ignored: () => false,
oversized: () => false,
commit: (next) => runtime.database.commit(next)
});
assert.equal(
Object.keys(runtime.database.state.local).length,
4,
"Distinct Unicode file vanished from the scan"
);
});