From d86f2158f92983c2c4d2bfa187249265fce4b811 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 22:30:28 +0000 Subject: [PATCH] cultcache-ts: export StoreLinkedError from the package entry; pin lstat errors other than ENOENT are rethrown; changelog Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/CHANGELOG.md | 2 +- packages/cultcache-ts/src/index.ts | 1 + packages/cultcache-ts/test/store-file.test.ts | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index fea670db..c1a91df6 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Breaking -- `SingleFileMessagePackBackingStore.pushAll` through a dangling symbolic link at the store path is refused with `ENOENT`, as `push` and `pullAll` are, and writes nothing; it used to replace the link with a new store. The reader owns the verdict on what is at the path. +- A write at a single-file store path that is a symbolic link, live or dangling, is refused with the new `StoreLinkedError` (exported from the package entry; `error.path` names the path) before the lock and the replace, and writes nothing: `push`, `pushAll`, `delete` and a soft flush alike. Before, a write at a live link replaced the link with a regular file, a dangling link failed with `ENOENT` (or, for `pushAll`, was replaced), and a soft flush at a link skipped. A caller catching `ENOENT` around a write at a dangling link now catches `StoreLinkedError`; configure the link's target as the store path. Reads through a live link work. An `lstat` failure other than `ENOENT` (`ENOTDIR`, `EACCES`, `ELOOP`) is rethrown as it is. - `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush is unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. - A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank diff --git a/packages/cultcache-ts/src/index.ts b/packages/cultcache-ts/src/index.ts index f55dd049..8d726e3d 100644 --- a/packages/cultcache-ts/src/index.ts +++ b/packages/cultcache-ts/src/index.ts @@ -1,2 +1,3 @@ export * from "./browser"; export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store"; +export { StoreLinkedError } from "./store-file"; diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 39436e30..485df058 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -10,6 +10,7 @@ import { promisify } from "node:util"; import { decode, encode } from "@msgpack/msgpack"; +import { StoreLinkedError as PackageStoreLinkedError } from "../src/index"; import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; @@ -23,6 +24,7 @@ import { replaceStoreFile, replaceStoreFileWindows, windowsKernel, + refuseLinkedStore, withStoreWriteLock, } from "../src/store-file"; import type { ReplaceKernel, StoreFileOps } from "../src/store-file"; @@ -818,3 +820,35 @@ test("opening writes nothing: a missing store leaves no lock file, a legacy stor await rm(directory, { recursive: true, force: true }); } }); + +test("StoreLinkedError is importable from the package entry and is what a write at a link throws", { skip: process.platform === "win32" }, async () => { + const directory = await scratch(); + try { + const target = join(directory, "target.cc"); + await writeFile(target, "x"); + const link = join(directory, "link.cc"); + await fsp.symlink(target, link); + await assert.rejects( + () => refuseLinkedStore(link), + (error: unknown) => error instanceof PackageStoreLinkedError && error.path === link && error.name === "StoreLinkedError", + ); + await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([]), (error: unknown) => error instanceof PackageStoreLinkedError); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("refuseLinkedStore lets a path that is absent pass and rethrows any other lstat failure", { skip: process.platform === "win32" }, async () => { + const directory = await scratch(); + try { + await refuseLinkedStore(join(directory, "absent.cc")); + const file = join(directory, "file"); + await writeFile(file, "x"); + await assert.rejects( + () => refuseLinkedStore(join(file, "child.cc")), + (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOTDIR", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +});