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:
parent
0408768d35
commit
8dd5a45378
7 changed files with 130 additions and 41 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue