mor
This commit is contained in:
parent
ff1f474324
commit
e151208e15
1 changed files with 2933 additions and 784 deletions
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue