From 8dd5a4537803c695b3b236a30a75a5869d33f7e6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 00:37:31 +0200 Subject: [PATCH] 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 --- package-lock.json | 6 +- packages/cultcache-ts/README.md | 19 +++++ packages/cultcache-ts/package.json | 2 +- packages/cultcache-ts/src/cult-cache.ts | 58 ++++++------- packages/cultcache-ts/test/cult-cache.test.ts | 82 +++++++++++++++++++ packages/cultmesh-ts/package.json | 2 +- packages/cultnet-ts/package.json | 2 +- 7 files changed, 130 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 391f8dfe..f027ecd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 4a696103..2b85c6df 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -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: diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index 93db0237..67f8d5ed 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -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", diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 157e0934..d2bb0b4a 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -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( diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 5463191f..a3a93d76 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -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 () => { diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index eff7fec4..0ca8afb3 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -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": { diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index f94f0bfa..2f4b74f1 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -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",