Give every cultcache-ts document type one home store

Cut 5 (TypeScript). The cache no longer mirrors: StoreRoute and its mirrors
list are gone, #resolveRoute returns the single home store (the store that
claims the type, else the generic store), and put, putEnvelope and delete write
exactly one store.

addBackingStore now refuses a second generic store and a type already claimed
by another store, attaching nothing when it refuses, matching C#. No bytes
change; nothing in CultLib attached more than one store.

Tests: rejects a second generic backing store, rejects a type registered to
two stores, routes each type to its home store. The writer-by-reader interop
test now runs the C# write-routed mode and checks that catalog.cc and run.cc
are each a complete v1 snapshot holding only their own record, and that every
reader (TS, Rust, C#, Python) reads the routed catalog store.

Version 0.13.5 -> 0.14.0; cultnet-ts and cultmesh-ts depend on ^0.14.0 so the
workspace link still satisfies their range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 00:37:31 +02:00
commit 8dd5a45378
7 changed files with 130 additions and 41 deletions

6
package-lock.json generated
View file

@ -832,7 +832,7 @@
}, },
"packages/cultcache-ts": { "packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts", "name": "@gamecult/cultcache-ts",
"version": "0.13.5", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
@ -865,7 +865,7 @@
"version": "0.1.1", "version": "0.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.13.5", "@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.2", "@msgpack/msgpack": "^3.1.2",
"cultnet-ts": "^0.2.0" "cultnet-ts": "^0.2.0"
}, },
@ -882,7 +882,7 @@
"version": "0.2.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.13.5", "@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1" "ajv": "^8.17.1"
}, },

View file

@ -142,6 +142,25 @@ Document definitions may also carry persistence metadata for cross-language
stores: `schemaId`, `schemaName`, `schemaVersion`, `contentHash`, stores: `schemaId`, `schemaName`, `schemaVersion`, `contentHash`,
`canonicalSchemaJson`, `compatibleSchemaIds`, and slot `members`. `canonicalSchemaJson`, `compatibleSchemaIds`, and slot `members`.
## Backing Store Routing
```ts
const cache = CultCache.builder()
.withRegistry(registry)
.withBackingStore(new SingleFileMessagePackBackingStore("settings.cc"), settingsDocument)
.withGenericStore(new SingleFileMessagePackBackingStore("cache.cc"))
.build();
```
A document type has exactly one home store
(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib):
- a store added with types owns those types; otherwise the generic store is the home
- a second generic store throws, and so does a type claimed by a second store;
a refused registration attaches nothing
- `put`, `putEnvelope` and `delete` touch exactly one store; nothing is mirrored
- a write whose type has no home store throws
## Name, Index, and Global Semantics ## Name, Index, and Global Semantics
`name` and `indexes` can be declared on the document definition: `name` and `indexes` can be declared on the document definition:

View file

@ -1,6 +1,6 @@
{ {
"name": "@gamecult/cultcache-ts", "name": "@gamecult/cultcache-ts",
"version": "0.13.5", "version": "0.14.0",
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -13,11 +13,6 @@ import type {
CultCacheStoreRegistration, CultCacheStoreRegistration,
} from "./types"; } from "./types";
type StoreRoute = {
primary?: CacheBackingStore;
mirrors: CacheBackingStore[];
};
type RegisteredDefinition = { type RegisteredDefinition = {
readonly definition: AnyCultCacheDocumentDefinition; readonly definition: AnyCultCacheDocumentDefinition;
readonly global: boolean; readonly global: boolean;
@ -181,10 +176,16 @@ export class CultCache {
store: CacheBackingStore, store: CacheBackingStore,
...types: BackingStoreTypeReference[] ...types: BackingStoreTypeReference[]
): void { ): void {
this.#stores.push({ const claimed = [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))];
store, if (claimed.length === 0 && this.#stores.some((registration) => registration.types.length === 0)) {
types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))], throw new Error("Backing store would be a second generic store; name the types it is home to.");
}); }
for (const type of claimed) {
if (this.#stores.some((registration) => registration.types.includes(type))) {
throw new Error(`CultCache type "${type}" is already routed to another backing store; it cannot also route to this one.`);
}
}
this.#stores.push({ store, types: claimed });
} }
addGenericBackingStore(store: CacheBackingStore): void { addGenericBackingStore(store: CacheBackingStore): void {
@ -386,8 +387,8 @@ export class CultCache {
catalogEntry: registered.catalogEntry, catalogEntry: registered.catalogEntry,
}; };
const route = this.#resolveRoute(definition.type); const home = this.#resolveRoute(definition.type);
if (!route.primary) { if (!home) {
throw new Error(`No backing store is registered for document type "${definition.type}".`); throw new Error(`No backing store is registered for document type "${definition.type}".`);
} }
@ -398,8 +399,7 @@ export class CultCache {
} }
} }
await route.primary.push(entry); await home.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put"); this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed; return parsed;
} }
@ -432,8 +432,8 @@ export class CultCache {
catalogEntry: envelope.catalogEntry ?? registered.catalogEntry, catalogEntry: envelope.catalogEntry ?? registered.catalogEntry,
}; };
const route = this.#resolveRoute(definition.type); const home = this.#resolveRoute(definition.type);
if (!route.primary) { if (!home) {
throw new Error(`No backing store is registered for document type "${definition.type}".`); throw new Error(`No backing store is registered for document type "${definition.type}".`);
} }
@ -444,8 +444,7 @@ export class CultCache {
} }
} }
await route.primary.push(entry); await home.push(entry);
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
this.#applyHydratedEntry(registered, entry, parsed, "put"); this.#applyHydratedEntry(registered, entry, parsed, "put");
return parsed; return parsed;
} }
@ -493,14 +492,13 @@ export class CultCache {
return false; return false;
} }
const route = this.#resolveRoute(definition.type); const home = this.#resolveRoute(definition.type);
if (!route.primary) { if (!home) {
throw new Error(`No backing store is registered for document type "${definition.type}".`); throw new Error(`No backing store is registered for document type "${definition.type}".`);
} }
const envelope = this.#toEnvelope(entry); const envelope = this.#toEnvelope(entry);
await route.primary.delete(envelope); await home.delete(envelope);
await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(envelope)));
this.#removeHydratedEntry(registered, entry); this.#removeHydratedEntry(registered, entry);
return true; return true;
} }
@ -660,20 +658,10 @@ export class CultCache {
return this.#schemaNameDefinitions.get(entry.type) ?? this.#definitions.get(entry.type); return this.#schemaNameDefinitions.get(entry.type) ?? this.#definitions.get(entry.type);
} }
#resolveRoute(type: string): StoreRoute { // The store that claims the type, else the generic store. Registration guarantees at most one of each.
const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type)); #resolveRoute(type: string): CacheBackingStore | undefined {
if (typeSpecific.length > 0) { return (this.#stores.find((registration) => registration.types.includes(type))
return { ?? this.#stores.find((registration) => registration.types.length === 0))?.store;
primary: typeSpecific[0]?.store,
mirrors: typeSpecific.slice(1).map((registration) => registration.store),
};
}
const generic = this.#stores.filter((registration) => registration.types.length === 0);
return {
primary: generic[0]?.store,
mirrors: generic.slice(1).map((registration) => registration.store),
};
} }
#applyHydratedEntry( #applyHydratedEntry(

View file

@ -533,6 +533,62 @@ test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was
assert.ok(records[0]?.[3] instanceof Uint8Array); assert.ok(records[0]?.[3] instanceof Uint8Array);
}); });
test("CultCache rejects a second generic backing store", () => {
const cache = new CultCache();
cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")));
assert.throws(
() => cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc"))),
/second generic store/u,
);
});
test("CultCache rejects a type registered to two stores", () => {
const cache = new CultCache();
cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")), "item");
assert.throws(
() => cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc")), "settings", "item"),
/"item" is already routed/u,
);
// A refused registration claims nothing, so "settings" is still free.
cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-c.cc")), "settings");
});
test("CultCache routes each type to its home store", async () => {
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({ name: z.string() }),
});
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({ theme: z.string() }),
});
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-routes-"));
const genericPath = join(tempDir, "generic.cc");
const settingsPath = join(tempDir, "settings.cc");
const build = () => CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument, settingsDocument))
.withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
.build();
const keysIn = async (path: string) =>
(await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`);
const cache = build();
await cache.put(itemDocument, "potion", { name: "Potion" });
await cache.put(settingsDocument, "app", { theme: "ash" });
assert.deepEqual(await keysIn(genericPath), ["item:potion"]);
assert.deepEqual(await keysIn(settingsPath), ["settings:app"]);
await cache.delete(settingsDocument, "app");
assert.deepEqual(await keysIn(settingsPath), []);
assert.deepEqual(await keysIn(genericPath), ["item:potion"]);
const reloaded = build();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getRequired(itemDocument, "potion"), { name: "Potion" });
assert.equal(reloaded.get(settingsDocument, "app"), undefined);
});
test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Python", async () => { test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Python", async () => {
await buildInteropPeers(); await buildInteropPeers();
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-")); const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-"));
@ -614,6 +670,32 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth
assert.ok(read.tags.includes("interop")); assert.ok(read.tags.includes("interop"));
} }
} }
// C# write-routed attaches one home store per type: each file is a complete v1 snapshot holding only its own records.
const catalogFile = join(tempDir, "catalog.cc");
const runFile = join(tempDir, "run.cc");
const routed = await runJsonCommand("csharp-write-routed", dotnetCommand, [
csharpInteropDll,
"write-routed",
catalogFile,
runFile,
], cultLibRoot);
const recordsIn = async (file: string) => {
const decoded = decode(await readFile(file)) as unknown[];
assert.equal(decoded[0], "cultcache.store.v1");
assert.ok(Array.isArray(decoded[1]), `${file} has no schema catalog`);
return (decoded[2] as unknown[][]).map((record) => ({ schemaId: record[1], key: record[0] }));
};
const catalogRecords = await recordsIn(catalogFile);
const runRecords = await recordsIn(runFile);
assert.deepEqual(catalogRecords.map((record) => record.key), ["note:csharp-routed"]);
assert.deepEqual(runRecords.map((record) => record.key), ["run-note:csharp-routed"]);
assert.notEqual(catalogRecords[0]?.schemaId, runRecords[0]?.schemaId);
for (const reader of readers) {
const read = await reader.read(catalogFile);
assert.equal(read.documentId, routed.documentId, `${reader.name} failed to read the routed catalog store`);
assert.equal(read.body, "One home store per document type.");
}
}); });
test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => { test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => {

View file

@ -43,7 +43,7 @@
}, },
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.2", "@msgpack/msgpack": "^3.1.2",
"@gamecult/cultcache-ts": "^0.13.5", "@gamecult/cultcache-ts": "^0.14.0",
"cultnet-ts": "^0.2.0" "cultnet-ts": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -53,7 +53,7 @@
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"@gamecult/cultcache-ts": "^0.13.5" "@gamecult/cultcache-ts": "^0.14.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",