cultcache-ts: export StoreLinkedError from the package entry; pin lstat errors other than ENOENT are rethrown; changelog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-12 22:30:28 +00:00
commit d86f2158f9
3 changed files with 36 additions and 1 deletions

View file

@ -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

View file

@ -1,2 +1,3 @@
export * from "./browser";
export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store";
export { StoreLinkedError } from "./store-file";

View file

@ -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 });
}
});