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": {
"name": "@gamecult/cultcache-ts",
"version": "0.13.5",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
@ -865,7 +865,7 @@
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.13.5",
"@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.2",
"cultnet-ts": "^0.2.0"
},
@ -882,7 +882,7 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.13.5",
"@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.3",
"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`,
`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` and `indexes` can be declared on the document definition:

View file

@ -1,6 +1,6 @@
{
"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.",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View file

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

View file

@ -533,6 +533,62 @@ test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was
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 () => {
await buildInteropPeers();
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"));
}
}
// 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 () => {

View file

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

View file

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