diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 888f36d5..539682fb 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -163,7 +163,15 @@ export function readSingleFileStore(path: string): SingleFileStoreRead { } } -function readStoreBytes(path: string): Uint8Array | undefined { +/** + * The bytes at `path`, or undefined when nothing is there. Nothing is there only when both looks agree: the read found no + * file and the follow-up lstat found no entry. Every other outcome is an error, because the path may have changed + * between the two calls (a link in a parent swapped for a file, a store created or removed): an lstat failure other than + * ENOENT is thrown as it is, and an entry the lstat does find, a dangling link or one that appeared since the read, is + * thrown with code ENOENT rather than read as an empty store. `lstat` is a seam so a test can stage the second look; + * this module exports the function for that test, and the package index does not. + */ +export function readStoreBytes(path: string, lstat: (path: string) => { isSymbolicLink(): boolean } = lstatSync): Uint8Array | undefined { try { return readFileSync(path); } catch (error) { @@ -172,8 +180,9 @@ function readStoreBytes(path: string): Uint8Array | undefined { } } + let entry: { isSymbolicLink(): boolean }; try { - lstatSync(path); + entry = lstat(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return undefined; @@ -182,7 +191,8 @@ function readStoreBytes(path: string): Uint8Array | undefined { throw error; } - throw Object.assign(new Error(`CultCache store ${path} is a symbolic link whose target does not exist.`), { code: "ENOENT" }); + const what = entry.isSymbolicLink() ? "is a symbolic link whose target does not exist" : "appeared while it was being read"; + throw Object.assign(new Error(`CultCache store ${path} ${what}.`), { code: "ENOENT" }); } async function renameWithRetry(source: string, destination: string): Promise { diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 9890814a..ebd4ceca 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -14,7 +14,7 @@ import { z } from "zod"; import { CultCache } from "../src/cult-cache"; import { inspectCultCacheBytes } from "../src/cult-cache-inspector"; import { defineDocumentRegistry, defineDocumentType } from "../src/document"; -import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; +import { SingleFileMessagePackBackingStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; @@ -2153,6 +2153,29 @@ test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path a assert.deepEqual(await readdir(volume), []); }); +// The second look of the read, staged: nothing is at the path only when the read and the lstat both find nothing. A path +// that changed between them (a link in a parent swapped for a file, a store that appeared) is never an empty store. +test("readStoreBytes reports nothing at the path only when the lstat finds nothing too", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-second-look-")); + const file = join(dir, "store.cc"); + const failing = (code: string) => () => { + throw Object.assign(new Error(code), { code }); + }; + assert.equal(readStoreBytes(file, failing("ENOENT")), undefined); + for (const code of ["ENOTDIR", "EACCES", "ELOOP"]) { + assert.throws(() => readStoreBytes(file, failing(code)), (error: NodeJS.ErrnoException) => error.code === code, code); + } + + assert.throws( + () => readStoreBytes(file, () => ({ isSymbolicLink: () => true })), + (error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link"), + ); + assert.throws( + () => readStoreBytes(file, () => ({ isSymbolicLink: () => false })), + (error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("appeared while it was being read"), + ); +}); + test("SingleFileMessagePackBackingStore record refusal names only a string key", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-record-key-")); const file = join(dir, "store.cc"); @@ -2294,7 +2317,7 @@ test("SingleFileMessagePackBackingStore pushAll refuses a store whose header it }); // A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by pullAll, -// pushAll, push and delete alike. The bytes and every runtime's verdict: tests/vectors/document-variants-c2a/readability. +// pushAll, soft pushAll, push and delete alike; a soft flush leaves the files that read as they are. The bytes and every runtime's verdict: tests/vectors/document-variants-c2a/readability. test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", async () => { const root = join(c2aVectors, "readability"); const rows = (await readFile(join(root, "manifest.txt"), "utf8")) @@ -2310,24 +2333,32 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", for (const [vector, , , typescript] of rows) { const reads = typescript === "reads"; const bytes = await readFile(join(root, vector)); - for (const operation of ["pullAll", "pushAll", "push", "delete"] as const) { + for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) { const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`); await writeFile(file, bytes); const store = new SingleFileMessagePackBackingStore(file); const run = () => operation === "pullAll" ? store.pullAll() - : operation === "pushAll" - ? store.pushAll(envelopes) - : operation === "push" - ? store.push(envelopes[0]!) - : store.delete(envelopes[0]!); + : operation === "softPushAll" + ? store.pushAll(envelopes, { soft: true }) + : operation === "pushAll" + ? store.pushAll(envelopes) + : operation === "push" + ? store.push(envelopes[0]!) + : store.delete(envelopes[0]!); if (reads) { await run(); if (operation === "pullAll") { continue; } + // A soft flush leaves a store it reads byte for byte: nothing is written where something is. + if (operation === "softPushAll") { + assert.ok(bytes.equals(await readFile(file)), `${vector} ${operation} rewrote a store it reads`); + continue; + } + // The replaced file is a store: it carries the header its old content decides (a v3 store keeps its marker, all else // is v1), and it holds what the operation wrote. assert.equal((decode(await readFile(file)) as unknown[])[0], vector.includes("v3") ? "cultcache.store.v3" : "cultcache.store.v1", `${vector} ${operation}`);