TS: the one store reader is exported and decides soft pushAll; absent-read and forged-header tests

pushAll({soft}) asked readStore, so it skipped every file the reader refuses. It now asks
#readDisk: absent writes, readable skips, unreadable refuses. The reader is readSingleFileStore,
synchronous and exported, so a consumer reads a store through it instead of decoding one itself.
New tests: soft pushAll over absent, readable and five unreadable files; reading an absent store
writes nothing; a forged header that does not start at the prefix is never echoed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-02 22:12:15 +02:00
commit 0122761a74
3 changed files with 104 additions and 35 deletions

View file

@ -1,7 +1,7 @@
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector";
export { defineDocumentRegistry, defineDocumentType, schemaIdentityOf, type CultCacheSchemaIdentity } from "./document";
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store";
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
export type {
AnyCultCacheDocumentDefinition,

View file

@ -1,4 +1,5 @@
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { lstatSync, readFileSync } from "node:fs";
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
@ -46,7 +47,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
async pullAll(): Promise<CultCacheEnvelope[]> {
// The disk decides the header: a file that is gone or legacy is not marked.
this.#format = STORE_FORMAT_VERSION;
const disk = await this.#readDisk();
const disk = this.#readDisk();
this.#format = disk.format;
if (disk.repairedLegacyPayload) {
await this.#writeAll([], disk.envelopes);
@ -77,20 +78,22 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
await this.#enqueue(async () => {
if (options.soft && (await readStore(this.filePath)) !== undefined) {
return;
}
// A flush of the whole store writes the header the file on disk carries, read now: a file marked for element ids
// stays marked, and one that is not (or is gone or legacy) is written unmarked. The file is read by the same
// reader as `pullAll`, so one it would refuse (not exactly one store, a variant, a body it cannot decode) is refused
// and left as it is.
// and left as it is. A soft flush asks that reader too: nothing at the path is written, a store is left alone.
try {
this.#format = (await this.#readDisk()).format;
const disk = this.#readDisk();
if (options.soft && !disk.absent) {
return;
}
this.#format = disk.format;
} catch (error) {
// Only a dangling link reaches here with ENOENT: it holds no store, so a whole-store flush has no header to keep and
// replaces the link (R3 pins this). Every merging writer has already refused it when it read the current entries.
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
// replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every merging writer
// has already refused the link when it read the current entries.
if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
@ -101,22 +104,9 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
});
}
// The one reader of the store file, asked by open, push, delete and flush alike. A file that is gone is an
// empty unmarked store; a zero-byte file is not a store and is refused. Anything else must decode completely
// or the read throws StoreUnreadableError.
async #readDisk(): Promise<DiskStore> {
// An empty file is not a store, since no CultCache writer leaves one, so it is decoded and refused.
// Only a read that finds nothing at the path is an empty store.
const data = await readStore(this.filePath);
if (data === undefined) {
return { format: STORE_FORMAT_VERSION, envelopes: [], repairedLegacyPayload: false };
}
try {
return decodeStoreFile(data);
} catch (error) {
throw new StoreUnreadableError(this.filePath, error);
}
// The one reader of the store file, asked by open, push, delete and flush alike; see readSingleFileStore.
#readDisk(): SingleFileStoreRead {
return readSingleFileStore(this.filePath);
}
async #enqueue<T>(operation: () => Promise<T>): Promise<T> {
@ -153,13 +143,29 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
}
/**
* Reads a store file, or returns undefined when nothing is at its path. A dangling symbolic
* link is something: reading it as empty would let the writer rename a file over the link
* and move the store off its volume. Any other failure to reach the file is thrown.
* The one reader of a CultCache single-file store, for this package's store and for any consumer that must read a
* store file (an authority bundle, say) rather than decode it itself. A path with nothing at it is an absent, empty,
* unmarked store. A dangling symbolic link is something: reading it as empty would let a writer rename a file over
* the link and move the store off its volume, so it throws with code ENOENT, as does any other failure to reach the
* file. A zero-byte file is not a store and, like anything that does not decode completely, throws StoreUnreadableError.
* The read is synchronous so a consumer that loads authority at start-up needs no second reader to stay synchronous.
*/
async function readStore(path: string): Promise<Uint8Array | undefined> {
export function readSingleFileStore(path: string): SingleFileStoreRead {
const data = readStoreBytes(path);
if (data === undefined) {
return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [], repairedLegacyPayload: false };
}
try {
return await readFile(path);
return { absent: false, ...decodeStoreFile(data) };
} catch (error) {
throw new StoreUnreadableError(path, error);
}
}
function readStoreBytes(path: string): Uint8Array | undefined {
try {
return readFileSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
@ -167,7 +173,7 @@ async function readStore(path: string): Promise<Uint8Array | undefined> {
}
try {
await lstat(path);
lstatSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
@ -395,14 +401,16 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
];
}
type DiskStore = {
export type SingleFileStoreRead = {
// True when nothing is at the path; the other fields then describe an empty unmarked store.
absent: boolean;
format: StoreFormat;
envelopes: CultCacheEnvelope[];
// A legacy file whose payloads were not bytes: reading it repairs them, and the store rewrites the file.
repairedLegacyPayload: boolean;
};
function decodeStoreFile(data: Uint8Array): DiskStore {
function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent"> {
const decoded = decode(data);
const snapshot = decodeSnapshot(decoded);
if (snapshot) {

View file

@ -2035,6 +2035,67 @@ test("SingleFileMessagePackBackingStore reads only a missing store as empty", as
assert.deepEqual(await new SingleFileMessagePackBackingStore(legacyEmpty).pullAll(), []);
});
test("reading a store that is not there writes nothing, and a read of a store that is readable does not rewrite it", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-read-writes-nothing-"));
const missing = join(dir, "store.cc");
assert.deepEqual(await new SingleFileMessagePackBackingStore(missing).pullAll(), []);
assert.equal(existsSync(missing), false);
assert.deepEqual(await readdir(dir), []);
const written = join(dir, "written.cc");
const envelope = { key: "k", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
await new SingleFileMessagePackBackingStore(written).push(envelope);
const before = await readFile(written);
await new SingleFileMessagePackBackingStore(written).pullAll();
assert.deepEqual(await readFile(written), before);
});
test("a soft pushAll writes where nothing is, leaves a readable store alone, and refuses what the reader refuses", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-soft-"));
const envelope = { key: "k", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
const other = { key: "other", type: "t", payload: Uint8Array.of(0x91, 0x01), storedAt: "2026-09-30T00:00:00Z" };
// Absent: the soft write seeds the store.
const absent = join(dir, "absent.cc");
await new SingleFileMessagePackBackingStore(absent).pushAll([envelope], { soft: true });
assert.deepEqual((await new SingleFileMessagePackBackingStore(absent).pullAll()).map((entry) => entry.key), ["k"]);
// Readable: the soft write is skipped, byte for byte.
const seeded = await readFile(absent);
await new SingleFileMessagePackBackingStore(absent).pushAll([other], { soft: true });
assert.deepEqual(await readFile(absent), seeded);
// Unreadable: refused, as pullAll refuses it, and the file is left exactly as it was.
const refused: Array<[string, Uint8Array]> = [
["zero-byte", new Uint8Array()],
["unknown-format", encode(["cultcache.store.v9", [], []])],
["bin-header", Uint8Array.of(0x93, 0xc4, 0x01, 0x76, 0x90, 0x90)],
["truncated", seeded.subarray(0, seeded.length - 1)],
["garbage", Uint8Array.of(0xc1)],
];
for (const [name, bytes] of refused) {
const file = join(dir, `${name}.cc`);
await writeFile(file, bytes);
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), StoreUnreadableError, name);
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pushAll([other], { soft: true }), StoreUnreadableError, name);
assert.deepEqual(await readFile(file), Buffer.from(bytes), name);
}
});
test("a header that does not start at the store prefix is not echoed whole, though it ends like a version", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-forged-header-"));
const file = join(dir, "store.cc");
for (const header of ["cultcache.store.SECRETcultcache.store.v9", "cultcache.store.
cultcache.store.v9", "SECRETcultcache.store.v9"]) {
await writeFile(file, encode([header, [], []]));
await assert.rejects(
() => new SingleFileMessagePackBackingStore(file).pullAll(),
(error: Error) => error instanceof StoreUnreadableError && !error.message.includes("SECRET") && !error.message.includes(header),
header,
);
}
});
test("SingleFileMessagePackBackingStore reports a store it cannot reach as an I/O error", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-unreachable-"));
const loop = join(dir, "loop.cc");