This commit is contained in:
Andras Schmelczer 2026-09-27 13:58:54 +01:00
commit e151208e15

View file

@ -35,10 +35,7 @@ async function fixture(
}
for (const [id, path] of Object.entries(initial.local))
initial.documents[id] = {
observedHash: (
await hashSnapshot((await disk.read(path))!)
).hash
observedHash: (await hashSnapshot((await disk.read(path))!)).hash
};
const persistence = new MemoryPersistence({ database: initial });
const restart = (changes: LocalChange[] = []) => {
@ -55,7 +52,7 @@ async function fixture(
database,
config,
undefined,
{ entries: () => changes, flush: async () => { } }
{ entries: () => changes, flush: async () => {} }
)
};
};
@ -64,7 +61,10 @@ async function fixture(
persistence,
restart,
initial,
permanentFault: (_label: string): void => { },
allowedInterruptedContent: undefined as
| { path: string; variants: Uint8Array[] }
| undefined,
permanentFault: (_label: string): void => {},
expectedLocal: undefined as Record<string, string> | undefined
};
}
@ -126,6 +126,13 @@ async function mergeFixture() {
const current = await fixture({ a: { path: "note.md", text: local } });
return {
...current,
allowedInterruptedContent: {
path: "note.md",
variants: [
bytes(local),
bytes("local first paragraph\n\nremote last paragraph\n")
]
},
next: structuredClone(current.initial),
writes: {
a: {
@ -201,7 +208,27 @@ for (const [name, create] of [
["permanent destination fallback", permanentDestinationFixture]
] as const) {
test(`${name}: interrupted operations leave metadata usable by ordinary scans`, async (t) => {
const baseline = await create();
const protectedFixture = async () => {
const current = await create();
const content = bytes("UNTOUCHED DURABLE BYTES\0\xff");
await current.disk.userWrite("untouched.bin", content);
const stored = current.persistence.snapshot();
stored.database!.local!.protected = "untouched.bin";
stored.database!.documents!.protected = {
observedHash: (await hashSnapshot({ content })).hash
};
await current.persistence.save(stored);
current.next.local.protected = "untouched.bin";
current.next.documents.protected = {
observedHash: (await hashSnapshot({ content })).hash
};
if (current.expectedLocal)
(current.expectedLocal as Record<string, string>).protected =
"untouched.bin";
current.expected.set("untouched.bin", content);
return current;
};
const baseline = await protectedFixture();
const labels: string[] = [];
const trace = (label: string) => {
if (!label.startsWith("read:")) labels.push(label);
@ -211,7 +238,9 @@ for (const [name, create] of [
trace(label);
};
baseline.persistence.boundary = trace;
await baseline.restart().files.applyChanges(baseline.next, baseline.writes);
await baseline
.restart()
.files.applyChanges(baseline.next, baseline.writes);
assert.deepEqual(baseline.disk.userFiles(), baseline.expected);
assert.deepEqual(
baseline.persistence.snapshot().database!.local,
@ -223,7 +252,55 @@ for (const [name, create] of [
await t.test(
`${powerLoss ? "power" : "process"} interruption at ${labels[cut]}`,
async () => {
const current = await create();
const current = await protectedFixture();
// Moving a file does not authorize losing its bytes. Writes
// and explicit deletions have separate replacement policies.
const required = Object.entries(
current.persistence.snapshot().database!.local!
)
.filter(
([id]) =>
id in current.next.local &&
!(id in current.writes)
)
.map(
([, path]) =>
current.disk.userFiles().get(path)!
);
const preserve = () => {
const allowed = current.allowedInterruptedContent;
if (allowed) {
const content = current.disk
.userFiles()
.get(allowed.path);
assert(
content &&
allowed.variants.some((variant) =>
Buffer.from(variant).equals(
Buffer.from(content)
)
),
"Interrupted merge lost or corrupted the user's pre-existing edit"
);
}
const files = [
...current.disk.userFiles().values()
].map((value) => Buffer.from(value));
for (const content of required)
assert.equal(
files.filter((value) =>
value.equals(Buffer.from(content))
).length,
1,
`Interrupted ${name} lost or duplicated existing bytes at ${labels[cut]}`
);
assert.deepEqual(
current.disk
.userFiles()
.get("after-restart.md"),
bytes("user edit")
);
};
let seen = 0;
let fired = false;
const interrupt = (label: string) => {
@ -242,18 +319,22 @@ for (const [name, create] of [
await assert.rejects(
current
.restart()
.files.applyChanges(current.next, current.writes),
.files.applyChanges(
current.next,
current.writes
),
/interrupted/
);
assert(fired);
current.disk.boundary = () => { };
current.persistence.boundary = () => { };
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")
);
preserve();
const runtime = current.restart();
const observed = current.disk.userFiles();
await scanChanges(runtime, []);
@ -263,6 +344,12 @@ for (const [name, create] of [
"A scan must not replay old filesystem intentions"
);
assertManifest(runtime.database.state.local);
preserve();
assert.equal(
runtime.database.state.local.protected,
"untouched.bin",
"Unrelated durable identity changed"
);
assert.deepEqual(
new Set(
Object.values(runtime.database.state.local)
@ -294,6 +381,7 @@ for (const [name, create] of [
state,
"Second startup must be stable"
);
preserve();
}
);
}
@ -399,7 +487,7 @@ test("scan failures cannot masquerade as deletions", async () => {
database: runtime.database,
changes: [],
files: runtime.files,
guard: () => { },
guard: () => {},
ignored: () => false,
oversized: () => false
}),
@ -426,7 +514,7 @@ for (const phase of ["before", "durable"]) {
database: runtime.database,
changes,
files: runtime.files,
guard: () => { },
guard: () => {},
ignored: () => false,
oversized: () => false
});
@ -436,7 +524,7 @@ for (const phase of ["before", "durable"]) {
if (label === `${phase}:save`) throw new Error("save failure");
};
await assert.rejects(scan(), /save failure/);
current.persistence.boundary = () => { };
current.persistence.boundary = () => {};
const state = await scan();
assert.equal(
state.local.a,
@ -459,7 +547,7 @@ async function scanChanges(
database: runtime.database,
changes,
files: runtime.files,
guard: () => { },
guard: () => {},
ignored: () => false,
oversized: () => false
});
@ -540,7 +628,7 @@ for (const equalContent of [false, true]) {
phase === "durable" ? expected : current.initial.local,
"The fault must occur on the intended side of the durable save"
);
current.persistence.boundary = () => { };
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.
@ -574,7 +662,7 @@ test("normalization-sensitive disk does not lose a distinct decomposed filename"
database: runtime.database,
changes: [],
files: runtime.files,
guard: () => { },
guard: () => {},
ignored: () => false,
oversized: () => false
});