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": {
|
"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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue