From 2d26c8100e156012a4fd03c73b14a16f994dceec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 01:28:47 +0200 Subject: [PATCH] CultCache TS: nothing at the path needs both looks to agree; the second look is a test seam readStoreBytes reports an absent store only when the read and the lstat both find nothing. Any other lstat failure is thrown, and an entry the lstat finds that is not a link is reported as appearing during the read. The manifest walk also runs a soft pushAll over every vector. Co-Authored-By: Claude Sonnet 5.5 --- .../single-file-messagepack-backing-store.ts | 16 +++++-- packages/cultcache-ts/test/cult-cache.test.ts | 47 +++++++++++++++---- 2 files changed, 52 insertions(+), 11 deletions(-) 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}`);