tests/durability.test.ts
Rama main · réplica de lectura del repositorio original.
// Durable note writes (server/vault.ts).
//
// The guarantee under test is not "writeNote writes" — the old bare
// `fs.writeFile` did that too. It is that the note on disk is ALWAYS either
// the old one or the new one. `fs.writeFile` opens with O_TRUNC, so between
// that call and the last byte the note is zero bytes long; every save in the
// product went through that window, hundreds of times an hour, on the one file
// the user was promised was safe to keep for ten years.
//
// Atomicity itself cannot be observed from inside the process that performs
// it, so these tests pin the four things that CAN be observed and that a naive
// write-then-rename would each have got wrong.
import assert from "node:assert/strict";
import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync } from "node:fs";
import path from "node:path";
import { after, describe, it } from "node:test";
import { VaultError, initVault, isIgnoredSegment, noteMtime, readNote, writeNote } from "../server/vault.ts";
import { makeVault, removeVault } from "./helpers/vault.ts";
const root = makeVault({
"Note.md": "# original\n",
"folder/Inner.md": "# inner\n",
"locked/Held.md": "# held\n",
"target/Real.md": "# real\n",
});
// A note kept as a symlink to another note inside the vault. CONTRACTS states
// such a link "still resolves and still works", and `fs.writeFile` followed it;
// a rename over the link itself would have replaced it with a regular file.
symlinkSync(path.join(root, "target/Real.md"), path.join(root, "Linked.md"));
initVault(root);
after(() => {
try {
chmodSync(path.join(root, "locked"), 0o755); // or the rm cannot descend
} catch {
/* already writable */
}
removeVault(root);
});
describe("durable note writes", () => {
it("writes the content and reports the published file's mtime", async () => {
const before = Date.now();
const written = await writeNote("Note.md", "# rewritten\n");
assert.equal(written.content, "# rewritten\n");
assert.equal(readFileSync(path.join(root, "Note.md"), "utf8"), "# rewritten\n");
// The mtime is read back AFTER the rename, so it describes the file a
// reader would now open — which is what a write precondition compares.
assert.ok(written.mtimeMs >= before - 2000);
assert.equal(written.mtimeMs, statSync(path.join(root, "Note.md")).mtimeMs);
});
it("leaves no temp file behind on success", async () => {
await writeNote("folder/Inner.md", "# changed\n");
assert.deepEqual(readdirSync(path.join(root, "folder")), ["Inner.md"]);
});
it("names its temp file something the tree, indexer and watcher all skip", () => {
// The dot prefix is why a save never flickers a ghost note through the
// sidebar or into the search index. isIgnoredSegment is the single rule
// all three of those walks consult.
assert.equal(isIgnoredSegment(`.Note.md.${process.pid}.tmp`), true);
});
it("leaves the previous note intact when the write fails", async (t) => {
// The whole point, and the one thing the old implementation could not do:
// it had already truncated the file before it could discover the failure.
if (process.getuid?.() === 0) {
t.skip("running as root: mode bits do not deny the write");
return;
}
const dir = path.join(root, "locked");
chmodSync(dir, 0o555); // no new files in this directory
await assert.rejects(() => writeNote("locked/Held.md", "# clobbered\n"));
chmodSync(dir, 0o755);
assert.equal(readFileSync(path.join(dir, "Held.md"), "utf8"), "# held\n");
assert.deepEqual(readdirSync(dir), ["Held.md"]);
});
it("carries the target's mode across the rename", async (t) => {
if (process.getuid?.() === 0) {
t.skip("running as root: umask and mode bits behave differently");
return;
}
// A rename hands the target the TEMP file's permissions, so without the
// chmod a note the owner had narrowed to 0600 would silently widen.
const abs = path.join(root, "Note.md");
chmodSync(abs, 0o600);
await writeNote("Note.md", "# again\n");
assert.equal(statSync(abs).mode & 0o777, 0o600);
});
it("follows a symlinked note instead of replacing the link", async () => {
await writeNote("Linked.md", "# through the link\n");
assert.equal(lstatSync(path.join(root, "Linked.md")).isSymbolicLink(), true);
assert.equal(readFileSync(path.join(root, "target/Real.md"), "utf8"), "# through the link\n");
const back = await readNote("Linked.md");
assert.equal(back.content, "# through the link\n");
});
});
describe("the write precondition", () => {
// `NoteData` has carried `mtimeMs` since the beginning and no writer ever
// read it back, so saving was unconditional last-write-wins. That is fine
// with one editor and stops being fine the moment a second pane, a second
// window, Obsidian or a `git pull` can reach the same file.
it("accepts a write whose base mtime still matches", async () => {
const first = await writeNote("Note.md", "# one\n");
const second = await writeNote("Note.md", "# two\n", first.mtimeMs);
assert.equal(second.content, "# two\n");
assert.equal(readFileSync(path.join(root, "Note.md"), "utf8"), "# two\n");
});
it("refuses a stale write, and touches nothing", async () => {
const mine = await writeNote("Note.md", "# mine\n");
// Somebody else — another window, Obsidian, a git pull — writes after me.
await writeNote("Note.md", "# theirs\n");
await assert.rejects(
() => writeNote("Note.md", "# mine, later\n", mine.mtimeMs),
(err: unknown) => {
assert.ok(err instanceof VaultError);
assert.equal(err.status, 409);
// A STABLE code, not prose: the client translates the code and keeps
// the English message only as the fallback for what is unnamed.
assert.equal(err.code, "stale");
return true;
},
);
// The refusal must be total. A precondition that half-writes is worse than
// none, because the file it leaves behind is neither version.
assert.equal(readFileSync(path.join(root, "Note.md"), "utf8"), "# theirs\n");
});
it("writes when the file is GONE, rather than refusing into the void", async () => {
// Deleted since the caller read it. Recreating is kinder than refusing to
// save work into a file somebody else removed — and the caller hears about
// the deletion from the watcher regardless.
const before = await writeNote("Ghost.md", "# here\n");
rmSync(path.join(root, "Ghost.md"));
const again = await writeNote("Ghost.md", "# back\n", before.mtimeMs);
assert.equal(again.content, "# back\n");
});
it("is opt-in: a writer that passes no base mtime is unconditional", async () => {
// BACKWARD COMPATIBILITY IS THE POINT, not an accident of the signature.
// An older client, a shell script, a `curl` in somebody's cron, and the
// rename/folder-move link rewrites (which cannot fail half-way through a
// gesture that has already moved the file) all write with no base, and all
// keep last-writer-wins. The precondition is opt-in BY THE PRESENCE OF THE
// FIELD — there is no flag to forget to set and no default to argue about.
await writeNote("Note.md", "# a\n");
const out = await writeNote("Note.md", "# b\n");
assert.equal(out.content, "# b\n");
});
});
describe("the disk state a woken client compares against", () => {
// `GET /api/note/state` answers "is what I am holding still the file?" for a
// client's open tabs, and `noteMtime` is the whole of its answer. Two
// servers over one vault is why it exists: each watcher announces to its own
// subscribers, so a client of server A can hold a buffer for days across a
// write made through server B and never be told.
it("reports the same mtime the write handed back", async () => {
// THIS IS THE LOAD-BEARING AGREEMENT. The probe's number is compared
// against a number `writeNote` produced, so if the two ever came from
// different stats the feature would either cry wolf on every save or go
// silent altogether.
const written = await writeNote("Note.md", "# probed\n");
assert.equal(await noteMtime("Note.md"), written.mtimeMs);
const read = await readNote("Note.md");
assert.equal(read.mtimeMs, written.mtimeMs);
});
it("answers null for a note that is not there, rather than throwing", async () => {
// A tab open on a note somebody deleted is an ordinary state, and the
// client reads null as "not mine to adopt": blanking the reader's document
// is not what "the file is gone" should mean.
assert.equal(await noteMtime("NoSuchNote.md"), null);
});
it("moves when the file moves, so a stale hold is detectable at all", async () => {
const first = await writeNote("Note.md", "# one\n");
// Somebody else — the other server, Obsidian, a git pull.
await writeNote("Note.md", "# two\n");
const now = await noteMtime("Note.md");
assert.notEqual(now, first.mtimeMs);
// And the write precondition agrees with the probe about which of the two
// versions the caller is holding.
await assert.rejects(() => writeNote("Note.md", "# one, later\n", first.mtimeMs));
assert.equal((await writeNote("Note.md", "# three\n", now!)).content, "# three\n");
});
});