power loss
This commit is contained in:
parent
0f6b618c0b
commit
d56084e4bc
1 changed files with 18667 additions and 3902 deletions
612
frontend/test-support/journal-protocol.test.ts
Normal file
612
frontend/test-support/journal-protocol.test.ts
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { fixture, bytes, randomUUID } from "./sync-server-fixture";
|
||||
|
||||
for (const exclusion of ["size", "ignore"] as const)
|
||||
for (const phase of [
|
||||
"planned",
|
||||
...(exclusion === "size" ? ["staged", "prepared"] : []),
|
||||
"installed"
|
||||
])
|
||||
for (const recovery of [
|
||||
"none",
|
||||
"restart",
|
||||
"before:save",
|
||||
"durable:save"
|
||||
] as const)
|
||||
test(`reenabling ${exclusion} exclusion at ${phase} preserves the content base through ${recovery}`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const installed = phase === "installed";
|
||||
const id = randomUUID();
|
||||
const base = await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("BASE\n")
|
||||
);
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.client.setSettings({
|
||||
maxFileSizeMB:
|
||||
exclusion === "size" ? 100 / (1024 * 1024) : 1,
|
||||
ignorePatterns: ["private.md"]
|
||||
});
|
||||
const remote = await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("REMOTE\nBASE\n", base.vaultUpdateId)
|
||||
);
|
||||
const local = "BASE\n" + "LOCAL\n".repeat(30);
|
||||
const path = exclusion === "size" ? "a.md" : "private.md";
|
||||
let injected = false,
|
||||
armed = false,
|
||||
crashed = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
const atBoundary =
|
||||
phase === "installed"
|
||||
? label.startsWith("durable:rename:") &&
|
||||
label.endsWith(".output->a.md")
|
||||
: phase === "staged"
|
||||
? label.startsWith("durable:rename:a.md->")
|
||||
: phase === "prepared"
|
||||
? label.startsWith("before:write:") &&
|
||||
label.endsWith(".output")
|
||||
: label.startsWith(
|
||||
"before:mkdir:.vault-link-sync/transactions/"
|
||||
);
|
||||
if (injected || !atBoundary) return;
|
||||
injected = true;
|
||||
if (exclusion === "ignore")
|
||||
await f.disk.userRename("a.md", path);
|
||||
// When installation already completed, deleting REMOTE
|
||||
// is a subsequent local edit, which must not be undone.
|
||||
await f.disk.userWrite(path, bytes(local));
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
relativePath: path,
|
||||
...(exclusion === "ignore"
|
||||
? { oldPath: "a.md" }
|
||||
: {})
|
||||
});
|
||||
armed = true;
|
||||
};
|
||||
f.store.boundary = (label) => {
|
||||
if (!armed || crashed || label !== recovery) return;
|
||||
crashed = true;
|
||||
f.disk.crash(true);
|
||||
throw new Error("interrupted exclusion save");
|
||||
};
|
||||
if (recovery.endsWith(":save")) {
|
||||
await assert.rejects(
|
||||
f.client.setSetting("isSyncEnabled", true),
|
||||
/interrupted exclusion save/
|
||||
);
|
||||
assert(crashed);
|
||||
} else await f.client.setSetting("isSyncEnabled", true);
|
||||
assert(injected);
|
||||
f.disk.boundary = f.store.boundary = () => {};
|
||||
if (recovery !== "none") {
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot(path))?.content,
|
||||
bytes(local)
|
||||
);
|
||||
const stored = f.store.snapshot().database!;
|
||||
assert.equal(
|
||||
stored.documents![id].base?.vaultUpdateId,
|
||||
installed ? remote.vaultUpdateId : base.vaultUpdateId
|
||||
);
|
||||
assert.equal(
|
||||
stored.remoteHeads![id].vaultUpdateId,
|
||||
remote.vaultUpdateId
|
||||
);
|
||||
assert.equal(
|
||||
(await f.get("/documents/" + id)).contentBase64,
|
||||
Buffer.from("REMOTE\nBASE\n").toString("base64")
|
||||
);
|
||||
await f.client.setSettings({
|
||||
maxFileSizeMB: 1,
|
||||
ignorePatterns: []
|
||||
});
|
||||
const expected = installed ? local : "REMOTE\n" + local;
|
||||
const verify = async () => {
|
||||
assert.deepEqual(
|
||||
(await f.get("/file-manifest")).entries,
|
||||
{ [id]: path }
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot(path))?.content,
|
||||
bytes(expected)
|
||||
);
|
||||
assert.equal(
|
||||
(await f.get("/documents/" + id)).contentBase64,
|
||||
Buffer.from(expected).toString("base64")
|
||||
);
|
||||
};
|
||||
await verify();
|
||||
await f.client.reset();
|
||||
await verify();
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
for (const at of [undefined, "before:save", "durable:save"])
|
||||
for (const powerLoss of at ? [false, true] : [false])
|
||||
test(`repeated saves retain only the latest local edit through ${at ?? "completion"} and powerLoss=${powerLoss}`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const base = await f.put("/documents/" + id, f.content("base"));
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("base REMOTE", base.vaultUpdateId)
|
||||
);
|
||||
let saves = 0,
|
||||
armed = false,
|
||||
crashed = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (
|
||||
saves === 0 &&
|
||||
label.startsWith("before:rename:a.md->")
|
||||
) {
|
||||
saves++;
|
||||
await f.disk.userWrite("a.md", bytes("base ONE"));
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
relativePath: "a.md"
|
||||
});
|
||||
} else if (
|
||||
saves === 1 &&
|
||||
label.startsWith("before:write:") &&
|
||||
label.endsWith(".output")
|
||||
) {
|
||||
saves++;
|
||||
await f.disk.userWrite("a.md", bytes("base TWO"));
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
relativePath: "a.md"
|
||||
});
|
||||
armed = true;
|
||||
}
|
||||
};
|
||||
f.store.boundary = (label) => {
|
||||
if (!armed || crashed || label !== at) return;
|
||||
crashed = true;
|
||||
f.disk.crash(powerLoss);
|
||||
throw new Error("injected repeated-save crash");
|
||||
};
|
||||
if (at) {
|
||||
await assert.rejects(
|
||||
f.client.setSetting("isSyncEnabled", true),
|
||||
/injected repeated-save crash/
|
||||
);
|
||||
assert(crashed);
|
||||
f.disk.boundary = () => {};
|
||||
f.store.boundary = () => {};
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
} else await f.client.setSetting("isSyncEnabled", true);
|
||||
assert.equal(saves, 2);
|
||||
const verify = async () => {
|
||||
assert.deepEqual((await f.get("/file-manifest")).entries, {
|
||||
[id]: "a.md"
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot("a.md"))?.content,
|
||||
bytes("base TWO REMOTE")
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.get("/documents/" + id)).contentBase64,
|
||||
"base64"
|
||||
).toString(),
|
||||
"base TWO REMOTE"
|
||||
);
|
||||
};
|
||||
await verify();
|
||||
await f.client.reset();
|
||||
await verify();
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
for (const timing of ["before", "durable"])
|
||||
for (const crash of [false, true])
|
||||
test(`oversized save ${timing} staging stays local across crash=${crash}`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = randomUUID(),
|
||||
large = "PRIVATE".repeat(100);
|
||||
const base = await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("PUBLIC")
|
||||
);
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.bin" }
|
||||
});
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.client.setSetting("maxFileSizeMB", 100 / (1024 * 1024));
|
||||
await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("REMOTE", base.vaultUpdateId)
|
||||
);
|
||||
let injected = false,
|
||||
armed = false,
|
||||
crashed = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (
|
||||
injected ||
|
||||
!label.startsWith(
|
||||
`${timing}:rename:a.bin->.vault-link-sync/transactions/`
|
||||
)
|
||||
)
|
||||
return;
|
||||
injected = true;
|
||||
await f.disk.userWrite("a.bin", bytes(large));
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
relativePath: "a.bin"
|
||||
});
|
||||
armed = true;
|
||||
};
|
||||
f.store.boundary = (label) => {
|
||||
if (!crash || !armed || crashed || label !== "durable:save")
|
||||
return;
|
||||
crashed = true;
|
||||
f.disk.crash(true);
|
||||
throw new Error("injected oversized-save crash");
|
||||
};
|
||||
if (crash) {
|
||||
await assert.rejects(
|
||||
f.client.setSetting("isSyncEnabled", true),
|
||||
/injected oversized-save crash/
|
||||
);
|
||||
assert(crashed);
|
||||
f.disk.boundary = () => {};
|
||||
f.store.boundary = () => {};
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
} else await f.client.setSetting("isSyncEnabled", true);
|
||||
assert(injected);
|
||||
const verify = async () => {
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot("a.bin"))?.content,
|
||||
bytes(large)
|
||||
);
|
||||
assert.deepEqual((await f.get("/file-manifest")).entries, {
|
||||
[id]: "a.bin"
|
||||
});
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await f.get("/documents/" + id)).contentBase64,
|
||||
"base64"
|
||||
).toString(),
|
||||
"REMOTE"
|
||||
);
|
||||
};
|
||||
await verify();
|
||||
await f.client.reset();
|
||||
await verify();
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a tracked file moved into an ignored path during remote application never uploads its new bytes", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = randomUUID(),
|
||||
b = randomUUID();
|
||||
await f.put("/documents/" + a, f.content("PUBLIC"));
|
||||
await f.put("/documents/" + b, f.content("OTHER"));
|
||||
const m = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [a]: "a.md", [b]: "b.md" }
|
||||
});
|
||||
await f.client.setSetting("ignorePatterns", ["private.md"]);
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: m.fileManifestId,
|
||||
entries: { [a]: "a.md", [b]: "remote-b.md" }
|
||||
});
|
||||
let injected = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (
|
||||
injected ||
|
||||
!label.startsWith("before:mkdir:.vault-link-sync/transactions/")
|
||||
)
|
||||
return;
|
||||
injected = true;
|
||||
await f.disk.userRename("a.md", "private.md");
|
||||
await f.disk.userWrite("private.md", bytes("SECRET"));
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "private.md"
|
||||
});
|
||||
};
|
||||
await f.client.setSetting("isSyncEnabled", true);
|
||||
assert(injected);
|
||||
const manifest = await f.get("/file-manifest");
|
||||
const contents = await Promise.all(
|
||||
Object.keys(manifest.entries).map((id) => f.get("/documents/" + id))
|
||||
);
|
||||
assert(
|
||||
!contents.some(
|
||||
(c) =>
|
||||
Buffer.from(c.contentBase64, "base64").toString() ===
|
||||
"SECRET"
|
||||
)
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot("private.md"))?.content,
|
||||
bytes("SECRET")
|
||||
);
|
||||
assert.equal(manifest.entries[a], "a.md");
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
test("remote creation plus unrelated notification must not delete remote membership on retry", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
const id = randomUUID();
|
||||
await f.put("/documents/" + id, f.content("REMOTE"));
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "remote.md" }
|
||||
});
|
||||
let injected = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (
|
||||
injected ||
|
||||
!label.startsWith("before:mkdir:.vault-link-sync/transactions/")
|
||||
)
|
||||
return;
|
||||
injected = true;
|
||||
await f.disk.userWrite("local.md", bytes("LOCAL"));
|
||||
await f.client.syncLocallyCreatedFile("local.md");
|
||||
};
|
||||
await f.client.setSetting("isSyncEnabled", true);
|
||||
assert(injected);
|
||||
f.disk.boundary = () => {};
|
||||
await f.client.reset();
|
||||
const manifest = await f.get("/file-manifest");
|
||||
|
||||
assert.equal(manifest.entries[id], "remote.md");
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
test("notified ignored occupant during remote rename must never upload its bytes", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = randomUUID();
|
||||
await f.put("/documents/" + id, f.content("REMOTE"));
|
||||
const m = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
await f.client.setSetting("ignorePatterns", ["private"]);
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: m.fileManifestId,
|
||||
entries: { [id]: "private/note.md" }
|
||||
});
|
||||
let injected = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (
|
||||
injected ||
|
||||
!label.startsWith("before:mkdir:.vault-link-sync/transactions/")
|
||||
)
|
||||
return;
|
||||
injected = true;
|
||||
await f.disk.userWrite("private", bytes("SECRET"));
|
||||
await f.client.syncLocallyCreatedFile("private");
|
||||
};
|
||||
await f.client.setSetting("isSyncEnabled", true);
|
||||
f.disk.boundary = () => {};
|
||||
const manifest = await f.get("/file-manifest");
|
||||
const contents = await Promise.all(
|
||||
Object.keys(manifest.entries).map((id) => f.get("/documents/" + id))
|
||||
);
|
||||
|
||||
assert(injected);
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot("private"))?.content,
|
||||
bytes("SECRET")
|
||||
);
|
||||
assert(
|
||||
!contents.some(
|
||||
(c) =>
|
||||
Buffer.from(c.contentBase64, "base64").toString() ===
|
||||
"SECRET"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
test("notified delete/recreate before staging retains replacement file", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const h = await f.put("/documents/" + id, f.content("ORIGINAL"));
|
||||
const m = await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("ORIGINAL REMOTE", h.vaultUpdateId)
|
||||
);
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: m.fileManifestId,
|
||||
entries: { [id]: "remote.md" }
|
||||
});
|
||||
let injected = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
if (
|
||||
injected ||
|
||||
!label.startsWith(
|
||||
"before:rename:a.md->.vault-link-sync/transactions/"
|
||||
)
|
||||
)
|
||||
return;
|
||||
injected = true;
|
||||
await f.disk.userDelete("a.md");
|
||||
await f.client.syncLocallyDeletedFile("a.md");
|
||||
await f.disk.userWrite("a.md", bytes("REPLACEMENT"));
|
||||
await f.client.syncLocallyCreatedFile("a.md");
|
||||
};
|
||||
await f.client.setSetting("isSyncEnabled", true);
|
||||
f.disk.boundary = () => {};
|
||||
const manifest = await f.get("/file-manifest");
|
||||
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot("a.md"))?.content,
|
||||
bytes("REPLACEMENT")
|
||||
);
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
for (const mode of ["delete", "move", "save"] as const)
|
||||
for (const powerLoss of [false, true])
|
||||
for (const at of ["before:save", "durable:save"])
|
||||
test(`${mode} during staging survives ${at} and ${powerLoss ? "power loss" : "process replacement"}`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const h = await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("ORIGINAL")
|
||||
);
|
||||
await f.put("/file-manifest", {
|
||||
requestId: randomUUID(),
|
||||
parentFileManifestId: 0,
|
||||
entries: { [id]: "a.md" }
|
||||
});
|
||||
await f.client.start();
|
||||
await f.client.setSetting("isSyncEnabled", false);
|
||||
await f.put(
|
||||
"/documents/" + id,
|
||||
f.content("ORIGINAL REMOTE", h.vaultUpdateId)
|
||||
);
|
||||
let injected = false,
|
||||
armed = false,
|
||||
crashed = false;
|
||||
f.disk.boundary = async (label) => {
|
||||
const point = mode === "save" ? "durable" : "before";
|
||||
if (
|
||||
injected ||
|
||||
!label.startsWith(
|
||||
`${point}:rename:a.md->.vault-link-sync/transactions/`
|
||||
)
|
||||
)
|
||||
return;
|
||||
injected = true;
|
||||
if (mode === "save") {
|
||||
await f.disk.userWrite(
|
||||
"a.md",
|
||||
bytes("ORIGINAL LOCAL")
|
||||
);
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
relativePath: "a.md"
|
||||
});
|
||||
} else {
|
||||
if (mode === "delete") {
|
||||
await f.disk.userDelete("a.md");
|
||||
await f.client.syncLocallyDeletedFile("a.md");
|
||||
} else {
|
||||
await f.disk.userRename("a.md", "mine.md");
|
||||
await f.client.syncLocallyUpdatedFile({
|
||||
oldPath: "a.md",
|
||||
relativePath: "mine.md"
|
||||
});
|
||||
}
|
||||
await f.disk.userWrite(
|
||||
"a.md",
|
||||
bytes("REPLACEMENT")
|
||||
);
|
||||
await f.client.syncLocallyCreatedFile("a.md");
|
||||
}
|
||||
armed = true;
|
||||
};
|
||||
f.store.boundary = (label) => {
|
||||
if (!armed || crashed || label !== at) return;
|
||||
crashed = true;
|
||||
f.disk.crash(powerLoss);
|
||||
throw new Error("injected journal crash");
|
||||
};
|
||||
await assert.rejects(
|
||||
f.client.setSetting("isSyncEnabled", true),
|
||||
/injected journal crash/
|
||||
);
|
||||
assert(injected && crashed);
|
||||
f.disk.boundary = () => {};
|
||||
f.store.boundary = () => {};
|
||||
await f.reopenClient();
|
||||
await f.client.start();
|
||||
const manifest = await f.get("/file-manifest");
|
||||
if (mode === "save") {
|
||||
assert.deepEqual(manifest.entries, { [id]: "a.md" });
|
||||
const actual = Buffer.from(
|
||||
(await f.disk.readSnapshot("a.md"))!.content
|
||||
).toString();
|
||||
assert.equal(actual, "ORIGINAL LOCAL REMOTE");
|
||||
} else {
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot("a.md"))?.content,
|
||||
bytes("REPLACEMENT")
|
||||
);
|
||||
assert.notEqual(
|
||||
Object.keys(manifest.entries).find(
|
||||
(key) => manifest.entries[key] === "a.md"
|
||||
),
|
||||
id
|
||||
);
|
||||
if (mode === "move") {
|
||||
assert.equal(manifest.entries[id], "mine.md");
|
||||
assert.deepEqual(
|
||||
(await f.disk.readSnapshot("mine.md"))?.content,
|
||||
bytes("ORIGINAL REMOTE")
|
||||
);
|
||||
} else assert.equal(manifest.entries[id], undefined);
|
||||
}
|
||||
await f.client.reset();
|
||||
assert.deepEqual(
|
||||
(await f.get("/file-manifest")).entries,
|
||||
manifest.entries
|
||||
);
|
||||
} finally {
|
||||
await f.dispose();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue