Ctrl K
ComunidadGuías…

tests/durability.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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");
  });
});