From 4a6602390156f489854c2367b6130be66d4879ce Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 22:32:27 +0000 Subject: [PATCH 1/5] cultcache-ts: walk the key-identity vector (red: memory retype, foreign carry, key in two stores) Co-Authored-By: Claude Sonnet 5.5 --- .../test/key-identity-vector.test.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 packages/cultcache-ts/test/key-identity-vector.test.ts diff --git a/packages/cultcache-ts/test/key-identity-vector.test.ts b/packages/cultcache-ts/test/key-identity-vector.test.ts new file mode 100644 index 00000000..e30c19df --- /dev/null +++ b/packages/cultcache-ts/test/key-identity-vector.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +import { decode, encode } from "@msgpack/msgpack"; +import { z } from "zod"; + +import { CultCache } from "../src/cult-cache"; +import { defineDocumentType, documentPayloadBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store"; +import { SchemaConflictError } from "../src/store-format"; +import type { AnyCultCacheDocumentDefinition, CultCacheEnvelope } from "../src/types"; + +// variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit. The vectors in +// tests/vectors/key-identity are written by the C# reference (see its README for the rule and the row shapes); this walks +// every row against the TS cache. A leaf keeps no verdict of its own. + +function findVectors(from: string): string { + for (let dir = from; ; dir = resolve(dir, "..")) { + if (existsSync(join(dir, "tests", "vectors", "key-identity"))) return join(dir, "tests", "vectors", "key-identity"); + if (resolve(dir, "..") === dir) throw new Error("tests/vectors/key-identity not found above the test."); + } +} +const vectors = findVectors(__dirname); + +// One string slot [0: Text]; the schema ids are the ones C# wrote (README). The type is the schema name, the name a record +// read back from a store carries as its type (the store's delete matches on it). +function kiType(type: string, id: string) { + const schemaName = `tests.ki_${type}`; + return defineDocumentType({ + type: schemaName, + schemaId: `sha256:${id}`, + schemaName, + schemaVersion: `${schemaName}.v1`, + schema: z.object({ text: z.string() }), + formatter: { + encode: (value: { text: string }) => encode([value.text]), + decode: (payload: Uint8Array) => ({ text: (decode(payload) as [string])[0] }), + }, + }); +} +const types: Record = { + alpha: kiType("alpha", "b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe"), + beta: kiType("beta", "dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94"), + gamma: kiType("gamma", "fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d"), +}; + +type Op = [string, ...unknown[]]; +type Row = [string, string[], Op[], [[string] | null, [string, string, string][], [string, string][] | null, string[], string[]]]; + +const compare = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); +const textOf = (envelope: CultCacheEnvelope) => (decode(documentPayloadBytes(envelope.payload)) as [string])[0]; +const stored = (path: string) => new Map(readSingleFileStore(path).envelopes.map((e) => [e.key, e])); +const sameRecord = (left: CultCacheEnvelope | undefined, right: CultCacheEnvelope | undefined) => + left !== undefined && right !== undefined && left.schemaId === right.schemaId && left.storedAt === right.storedAt && + Buffer.compare(Buffer.from(documentPayloadBytes(left.payload)), Buffer.from(documentPayloadBytes(right.payload))) === 0; + +test("vectors rows.msgpack: every row's refusal, held records, file, foreign list and kept bytes", async () => { + const rows = decode(await readFile(join(vectors, "rows.msgpack"))) as Row[]; + assert.equal(rows.length, 11); + let walked = 0; + const failures: string[] = []; + for (const [label, registered, ops, expect] of rows) { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const [refusal, held, file, foreign, kept] = expect; + let counter = 0; + let path: string | undefined; + let cache!: CultCache; + let before: Map | undefined; + const attached: string[] = []; + const build = (store: string | undefined) => { + let builder = CultCache.builder(); + for (const name of registered) builder = builder.withDocumentType(types[name]!); + if (store !== undefined) builder = builder.withGenericStore(new SingleFileMessagePackBackingStore(store)); + return builder.build(); + }; + const fresh = async (name: string) => { + const copy = join(dir, `${counter++}-${name}`); + await copyFile(join(vectors, name), copy); + return copy; + }; + + let refused: unknown; + let failed = false; + let beforeBytes: Buffer | undefined; + let beforeCache: CultCacheEnvelope[] = []; + for (const [index, op] of ops.entries()) { + const last = index === ops.length - 1; + if (last) { + beforeCache = cache.snapshot(); + beforeBytes = path !== undefined ? await readFile(path) : undefined; + } + try { + switch (op[0]) { + case "open": { + const arg = op[1] as string | null; + path = arg === "memory" ? undefined : arg === null ? join(dir, "fresh.cc") : await fresh(arg); + if (path !== undefined && arg !== null) before = stored(path); + cache = build(path); + await cache.pullAllBackingStores(); + break; + } + case "attach": { + const copy = await fresh(op[1] as string); + attached.push(copy); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(copy), ...(op[2] as string[]).map((name) => types[name]!)); + await cache.pullAllBackingStores(); + break; + } + case "reopen": + cache = build(path); + await cache.pullAllBackingStores(); + break; + case "put": + await cache.put(types[op[1] as string]!, op[2] as string, { text: op[3] as string } as never); + break; + case "delete": + await cache.delete(types[op[1] as string]!, op[2] as string); + break; + default: + assert.fail(`${label}: unknown op ${op[0]}`); + } + } catch (error) { + if (!last) throw error; + refused = error; + failed = true; + } + } + + if (refusal === null) { + assert.equal(failed, false, `${label}: the last op lands (${String(refused)})`); + } else { + assert.equal(failed, true, `${label}: the last op is refused`); + const [key] = refusal; + if (label === "key-in-two-stores-refused") { + assert.ok((refused as Error).message.includes(key), `${label}: the refusal names the key`); + for (const copy of attached) { + assert.ok(sameRecord(stored(copy).get(key), stored(copy).get(key))); + } + } else { + assert.ok(refused instanceof SchemaConflictError, `${label}: a typed schema conflict, got ${String(refused)}`); + assert.equal((refused as SchemaConflictError).recordKey, key, `${label}: names the key`); + assert.deepEqual(cache.snapshot(), beforeCache, `${label}: the cache is unchanged`); + if (path !== undefined) assert.deepEqual(await readFile(path), beforeBytes, `${label}: the store is byte-identical`); + } + } + + assert.deepEqual( + cache.snapshot() + .map((e) => [e.type.replace("tests.ki_", ""), e.key, textOf(e)] as [string, string, string]) + .sort((l, r) => compare(l[0], r[0]) || compare(l[1], r[1])), + held, + `${label}: held`, + ); + if (path === undefined) { + assert.equal(file, null, `${label}: no store`); + } else { + assert.deepEqual( + [...stored(path).values()] + .map((e) => [e.key, e.catalogEntry?.schemaName ?? e.type] as [string, string]) + .sort((l, r) => compare(l[0], r[0])), + file, + `${label}: file`, + ); + if (before !== undefined) { + const after = stored(path); + assert.deepEqual(foreign.filter((key) => sameRecord(before!.get(key), after.get(key))), kept, `${label}: kept`); + } + } + assert.deepEqual(cache.foreignRecords().map((record: { key: string }) => record.key), foreign, `${label}: foreign`); + walked++; + } catch (error) { + failures.push(`${label}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + assert.deepEqual(failures, [], "every row holds its verdict"); + assert.equal(walked, 11); +}); -- 2.47.3 From 6311d599ad3adc6bf4e6e49587ec8d3d4d85568c Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 22:34:49 +0000 Subject: [PATCH 2/5] cultcache-ts: one record per key; a store's unregistered records load as foreign The cache judge #refuseRetype runs before every admission; the unresolved-type throw in the load is replaced by the foreign list; writtenOwns moves to store-format.ts as the one predicate of both judges. Co-Authored-By: Claude Sonnet 5.5 --- docs/write-ownership-map.md | 4 +- packages/cultcache-ts/CHANGELOG.md | 6 ++ packages/cultcache-ts/README.md | 10 ++ packages/cultcache-ts/src/browser.ts | 2 +- packages/cultcache-ts/src/cult-cache.ts | 92 +++++++++++++++++-- .../single-file-messagepack-backing-store.ts | 14 +-- packages/cultcache-ts/src/store-format.ts | 18 ++++ packages/cultcache-ts/test/cult-cache.test.ts | 8 +- .../test/key-identity-vector.test.ts | 92 ++++++++++++++++++- 9 files changed, 215 insertions(+), 31 deletions(-) diff --git a/docs/write-ownership-map.md b/docs/write-ownership-map.md index e1f046cf..be188c3c 100644 --- a/docs/write-ownership-map.md +++ b/docs/write-ownership-map.md @@ -33,8 +33,8 @@ Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (o | Probe | C# | TypeScript | Rust | Python | |---|---|---|---|---| -| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | C, and **both** records live at k | C, both live at k | C, both live at k | -| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R at load: "No schema is registered for persisted schema id" | R at load, same | R at load: "Unknown persisted document type" | +| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | C, both live at k | C, both live at k | +| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R at load, same | R at load: "Unknown persisted document type" | | P3 cacheless `WriteDocument` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write | | P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a | | P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a | diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 8a4dd9c4..73d49fc5 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -18,6 +18,12 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following - A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. - Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins. - A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. +- The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it. Two stores holding one key refuse the load and admit nothing. +- A store holding a record no registered type claims now loads (it used to throw "No schema is registered..."): the cache does not serve the record, lists it in `foreignRecords()`, keeps its bytes through every write, and refuses a `put` or a `delete` at its key with `SchemaConflictError` (variants:ruling:unheld-removal-refused-at-commit). Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. + +### Added + +- `CultCache.foreignRecords()`: the `{key, schemaId, schemaName, storedAt}` of every record the last pull carried and no registered type claims, sorted by key ordinal. ### Changed diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 21e76701..2096d258 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -231,6 +231,16 @@ its exact decimal expansion; NaN and infinities have no value. `roles` name sele member's catalog `indexAlias`, so the catalog bytes are the same either way); they imply nothing about uniqueness and are never indexed for lookup, so `getByIndex` on a role answers `undefined`. An alias is declared in one of the two. +### One record per key, and foreign records + +A cache holds one record per key whatever its type (variants:ruling:cache-refuses-retype). A write of a type that does not +own the held record's schema id (it carries that id as its own or lists it compatible) is refused with +`SchemaConflictError` naming the key, with or without a store; retyping a key is `delete` then `put`. A store that holds a +record no registered type claims still loads: the cache does not serve it, `foreignRecords()` lists it (`key`, +`schemaId`, `schemaName`, `storedAt`, by key ordinal), every write keeps its bytes, and a `put` or `delete` at its key is +refused. Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. Two stores that hold +one key refuse the load and admit nothing. + ### Migrating an existing `indexes` key Apply per key, and do it BEFORE the CultLib checkout your tools load is rebuilt or updated. A consumer that loads diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts index bf62976f..668fc0b8 100644 --- a/packages/cultcache-ts/src/browser.ts +++ b/packages/cultcache-ts/src/browser.ts @@ -2,7 +2,7 @@ // (node:fs, koffi). Bring your own CacheBackingStore, or none. export * as codecs from "./codec"; export type { Codec, SlotsCodec } from "./codec"; -export { CultCache, CultCacheBuilder } from "./cult-cache"; +export { CultCache, CultCacheBuilder, type CultCacheForeignRecord } from "./cult-cache"; export { inspectCultCacheBytes } from "./cult-cache-inspector"; export { decodeDocumentPayload, diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index c5bb663d..11afedf2 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -9,7 +9,7 @@ import { } from "./document"; import { resolveDocumentFormatter } from "./document-formatter"; import type { DocumentPayload } from "./document"; -import { SchemaConflictError } from "./store-format"; +import { SchemaConflictError, schemaIdForEnvelope, writtenOwns } from "./store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, @@ -64,6 +64,17 @@ type HydratedState = { // delete of that global removes it from the home store. Remove once no store holds a global under // a key other than GLOBAL_KEY. readonly legacyGlobals: Map; + // The records the last pull found that no registered type claims. Derived from the pull and replaced with the state; it + // decides nothing but the refusal of a write or a removal at its keys. + readonly foreign: CultCacheForeignRecord[]; +}; + +/** A record a store carries that no registered type claims: the cache does not serve it, keeps its bytes, and refuses a write or a removal at its key. */ +export type CultCacheForeignRecord = { + readonly key: string; + readonly schemaId: string; + readonly schemaName: string; + readonly storedAt: string; }; // Lookups for every held record of one type under candidate accessors, derived before anything is installed. @@ -81,6 +92,7 @@ function emptyHydratedState(): HydratedState { indexToKeyMaps: new Map(), globalKeys: new Map(), legacyGlobals: new Map(), + foreign: [], }; } @@ -280,11 +292,14 @@ export class CultCache { for (const entry of entries) { const registered = this.#resolveDefinitionForEnvelope(entry); if (!registered) { - throw new Error( - entry.schemaId - ? `No schema is registered for persisted schema id "${entry.schemaId}".` - : `No schema is registered for persisted document type "${entry.type}".`, - ); + // A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load. + next.foreign.push({ + key: entry.key, + schemaId: schemaIdForEnvelope(entry), + schemaName: entry.catalogEntry?.schemaName ?? entry.type, + storedAt: entry.storedAt, + }); + continue; } const type = registered.definition.type; @@ -313,10 +328,21 @@ export class CultCache { } } const hydrated = this.#admitEntry(next, registered, admitted, value); + const other = this.#heldUnderOtherType(next, type, hydrated.key); + if (other !== undefined) { + throw new Error(`CultCache record "${hydrated.key}" is held by two stores, as type "${other.type}" and as type "${type}".`); + } this.#installEntry(next, registered, hydrated); } } + for (const record of next.foreign) { + const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined); + if (other !== undefined) { + throw new Error(`CultCache record "${record.key}" is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); + } + } + // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the // order of the stores and of the entries in them. refuseLeastIndexSharing(function* () { @@ -553,6 +579,11 @@ export class CultCache { }); } + // What the last pull found that no registered type claims, by key ordinal. A later registration claims a record at the next pull. + foreignRecords(): readonly CultCacheForeignRecord[] { + return [...this.#hydrated.foreign].sort((left, right) => compareOrdinal(left.key, right.key)); + } + snapshot(): CultCacheEnvelope[] { return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry)); } @@ -662,6 +693,7 @@ export class CultCache { // is touched; once the store accepts, applying to the cache has no fallible step. async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { const home = this.#homeStore(entry.type); + const retyped = this.#refuseRetype(entry); const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); @@ -670,9 +702,54 @@ export class CultCache { await this.#retireLegacyGlobal(home, legacy, entry); } this.#hydrated.legacyGlobals.delete(entry.type); + if (retyped !== undefined) { + // The record the write restamped (it owns the held id) is the same record: one key, one held type. + this.#removeEntry(this.#hydrated, this.#definitions.get(retyped.type)!, retyped); + } this.#installEntry(this.#hydrated, registered, hydrated); } + // The one key-identity judge of the cache: may this write land at its key, given what the cache holds there under + // every registered type and the keys its stores carry foreign. The store judges the file with the same predicate + // (writtenOwns); this judges the cache, which a store-less cache has no other judge for. Returns the held record of + // another type the write owns and so replaces, if any. A refusal names the key and schemas, never a payload. + #refuseRetype(entry: CultCacheEnvelope): HydratedEntry | undefined { + this.#refuseForeignKey(entry.key, entry.type, "written"); + const held = this.#heldUnderOtherType(this.#hydrated, entry.type, entry.key); + if (held === undefined || writtenOwns(entry, schemaIdForEnvelope(held))) { + return held; + } + throw new SchemaConflictError( + schemaIdForEnvelope(entry), + [held.type, entry.type], + entry.key, + `CultCache holds record "${entry.key}" as type "${held.type}", so type "${entry.type}" cannot be written under the same key; remove the record first.`, + ); + } + + #refuseForeignKey(key: string, type: string, what: "written" | "removed"): void { + const foreign = this.#hydrated.foreign.find((record) => record.key === key); + if (foreign !== undefined) { + throw new SchemaConflictError( + foreign.schemaId, + [foreign.schemaName], + key, + `CultCache store carries record "${key}" as schema "${foreign.schemaName}" (${foreign.schemaId}), which no registered type claims, so type "${type}" cannot be ${what} at that key.`, + ); + } + } + + // The record another registered type holds at the key, if any. + #heldUnderOtherType(state: HydratedState, type: string, key: string): HydratedEntry | undefined { + for (const other of this.#definitions.keys()) { + const held = other === type ? undefined : state.entries.get(this.#entryId(other, key)); + if (held !== undefined) { + return held; + } + } + return undefined; + } + // The first write of a global loaded from a legacy key removes that record in the same write. If the // removal fails, the new record is taken back out, so the store keeps only the legacy record. async #retireLegacyGlobal(home: CacheBackingStore, legacy: CultCacheEnvelope, written: CultCacheEnvelope): Promise { @@ -691,6 +768,9 @@ export class CultCache { const registered = this.#requireDefinition(definition); const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); if (!entry) { + // variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held under + // another type or by nobody is the existing false. + this.#refuseForeignKey(key, definition.type, "removed"); return false; } diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 3f484ad8..a6eda8ae 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { documentPayloadBytes, documentPayloadFromBytes } from "./document"; import { commitStoreFile, withStoreWriteLock } from "./store-file"; -import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format"; +import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots, schemaIdForEnvelope, writtenOwns } from "./store-format"; import type { CacheBackingStore, CultCacheEnvelope, @@ -138,14 +138,6 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } -// As C# WrittenOwns' declared half: the written type owns the stored schema id when its catalog entry carries that id as its own -// or lists it compatible, so a type renamed under a stable id restamps the record it already holds. (The C# half that lets an -// undeclared older id of the same schema name through when it reads whole is not ported; a same-named type keeps passing.) -function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean { - const entry = written.catalogEntry; - return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId)); -} - // A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a // key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete // of the old type followed by a put of the new one. @@ -383,10 +375,6 @@ function defaultEntryFor(entry: CultCacheEnvelope): CultCacheSchemaCatalogEntry }; } -function schemaIdForEnvelope(entry: CultCacheEnvelope): string { - return entry.schemaId ?? entry.type; -} - function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] { return [ entry.schemaId, diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index 4efab504..acd10207 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -1,5 +1,7 @@ import { decode } from "@msgpack/msgpack"; +import type { CultCacheEnvelope } from "./types"; + export const STORE_FORMAT_VERSION = "cultcache.store.v1"; /** @@ -151,3 +153,19 @@ export function requireStoreSlots(decoded: unknown[]): void { throw new Error(`CultCache store has ${decoded.length} top-level slots; this runtime reads ${STORE_SLOTS} (header, schema catalog, records).`); } } + +/** The schema id a record is stored under: the one it carries, else its type. */ +export function schemaIdForEnvelope(entry: CultCacheEnvelope): string { + return entry.schemaId ?? entry.type; +} + +/** + * As C# WrittenOwns' declared half: the written type owns the stored schema id when its catalog entry carries that id as its own + * or lists it compatible, so a type renamed under a stable id restamps the record it already holds. (The C# half that lets an + * undeclared older id of the same schema name through when it reads whole is not ported; a same-named type keeps passing.) + * The one predicate of the key-identity rule: the store judges the file with it and the cache judges what it holds. + */ +export function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean { + const entry = written.catalogEntry; + return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId)); +} diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 76f3d084..31b13d3b 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -266,10 +266,10 @@ test("CultCache fails closed on unknown or illegal persisted polymorphic state", ])) .build(); - await assert.rejects( - async () => cacheWithUnknownType.pullAllBackingStores(), - /No schema is registered for persisted document type "forbidden"\./, - ); + // A record no registered type claims is carried, not a reason to refuse the load. + await cacheWithUnknownType.pullAllBackingStores(); + assert.deepEqual(cacheWithUnknownType.snapshot(), []); + assert.deepEqual(cacheWithUnknownType.foreignRecords().map((record) => [record.key, record.schemaName]), [["mystery", "forbidden"]]); const cacheWithDuplicateGlobal = CultCache.builder() .withDocumentType(settingsDocument) diff --git a/packages/cultcache-ts/test/key-identity-vector.test.ts b/packages/cultcache-ts/test/key-identity-vector.test.ts index e30c19df..5c25db05 100644 --- a/packages/cultcache-ts/test/key-identity-vector.test.ts +++ b/packages/cultcache-ts/test/key-identity-vector.test.ts @@ -9,7 +9,7 @@ import { decode, encode } from "@msgpack/msgpack"; import { z } from "zod"; import { CultCache } from "../src/cult-cache"; -import { defineDocumentType, documentPayloadBytes } from "../src/document"; +import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError } from "../src/store-format"; import type { AnyCultCacheDocumentDefinition, CultCacheEnvelope } from "../src/types"; @@ -88,10 +88,12 @@ test("vectors rows.msgpack: every row's refusal, held records, file, foreign lis let failed = false; let beforeBytes: Buffer | undefined; let beforeCache: CultCacheEnvelope[] = []; + let beforeAttached: Buffer[] = []; for (const [index, op] of ops.entries()) { const last = index === ops.length - 1; if (last) { - beforeCache = cache.snapshot(); + beforeCache = cache?.snapshot() ?? []; + beforeAttached = await Promise.all(attached.map((copy) => readFile(copy))); beforeBytes = path !== undefined ? await readFile(path) : undefined; } try { @@ -138,9 +140,8 @@ test("vectors rows.msgpack: every row's refusal, held records, file, foreign lis const [key] = refusal; if (label === "key-in-two-stores-refused") { assert.ok((refused as Error).message.includes(key), `${label}: the refusal names the key`); - for (const copy of attached) { - assert.ok(sameRecord(stored(copy).get(key), stored(copy).get(key))); - } + assert.deepEqual(cache.snapshot(), beforeCache, `${label}: the earlier pulled state survives`); + assert.deepEqual(await Promise.all(attached.slice(0, beforeAttached.length).map((copy) => readFile(copy))), beforeAttached, `${label}: the attached files are byte-identical`); } else { assert.ok(refused instanceof SchemaConflictError, `${label}: a typed schema conflict, got ${String(refused)}`); assert.equal((refused as SchemaConflictError).recordKey, key, `${label}: names the key`); @@ -182,3 +183,84 @@ test("vectors rows.msgpack: every row's refusal, held records, file, foreign lis assert.deepEqual(failures, [], "every row holds its verdict"); assert.equal(walked, 11); }); + +// Beyond the vector: what the walker's string-typed rows cannot say. +const CANARY = "CANARY-ki-77c1"; +const plain = (type: string, schemaId: string, compatibleSchemaIds?: string[]) => + defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: z.object({ v: z.string() }), ...(compatibleSchemaIds ? { compatibleSchemaIds } : {}) }); + +test("a refusal names the key and schemas and never echoes the written value", async () => { + const alpha = plain("cn.alpha", "cn.alpha.v1"); + const beta = plain("cn.beta", "cn.beta.v1"); + const cache = CultCache.builder().withDocumentType(alpha).withDocumentType(beta).build(); + await cache.put(alpha, "k", { v: "first" }); + await assert.rejects(cache.put(beta, "k", { v: CANARY }), (error: Error) => { + assert.ok(error instanceof SchemaConflictError && error.recordKey === "k"); + assert.ok(!error.message.includes(CANARY)); + return true; + }); + assert.deepEqual(cache.get(alpha, "k"), { v: "first" }); + assert.equal(cache.get(beta, "k"), undefined); + await assert.rejects(cache.putEnvelope(beta, { key: "k", type: "cn.beta", payload: documentPayloadFromBytes(encode({ v: CANARY })), storedAt: "2026-01-01T00:00:00Z" }), SchemaConflictError); + assert.equal(cache.snapshot().length, 1); +}); + +test("a type that owns the held record's id replaces it: one record at the key, no second type held", async () => { + const old = plain("cn.old", "cn.id.v1"); + const renamed = plain("cn.new", "cn.id.v2", ["cn.id.v2", "cn.id.v1"]); + const cache = CultCache.builder().withDocumentType(old).withDocumentType(renamed).build(); + await cache.put(old, "k", { v: "a" }); + await cache.put(renamed, "k", { v: "b" }); + assert.deepEqual(cache.snapshot().map((e) => [e.type, e.key]), [["cn.new", "k"]]); + assert.equal(cache.get(old, "k"), undefined); +}); + +test("a store carrying an unregistered record loads, lists it sorted, keeps it through writes of other keys, and a later registration claims it", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const path = join(dir, "s.cc"); + await copyFile(join(vectors, "foreign-store.cc"), path); + const open = (...names: string[]) => { + let builder = CultCache.builder(); + for (const name of names) builder = builder.withDocumentType(types[name]!); + return builder.withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + }; + const cache = open("alpha"); + await cache.pullAllBackingStores(); + const record = stored(path).get("k")!; + assert.deepEqual(cache.foreignRecords(), [ + { key: "k", schemaId: "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d", schemaName: "tests.ki_gamma", storedAt: record.storedAt }, + ]); + await cache.put(types.alpha!, "z", { text: "z" } as never); + await cache.delete(types.alpha!, "j"); + assert.ok(sameRecord(stored(path).get("k"), record), "the foreign record's stored bytes survive"); + assert.equal(await cache.delete(types.alpha!, "nobody"), false); + + const claimed = open("alpha", "gamma"); + await claimed.pullAllBackingStores(); + assert.deepEqual(claimed.foreignRecords(), []); + assert.equal(claimed.snapshot().length, 2); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a foreign key one store carries and another holds under a registered type refuses the load and admits nothing", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const foreign = join(dir, "foreign.cc"); + const catalog = join(dir, "catalog.cc"); + await copyFile(join(vectors, "catalog-store.cc"), catalog); + const writer = CultCache.builder().withDocumentType(types.gamma!).withGenericStore(new SingleFileMessagePackBackingStore(foreign)).build(); + await writer.put(types.gamma!, "k", { text: "g" } as never); + const cache = CultCache.builder().withDocumentType(types.alpha!).build(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(catalog), types.alpha!); + await cache.pullAllBackingStores(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(foreign)); + await assert.rejects(cache.pullAllBackingStores(), /held by two stores/u); + assert.deepEqual(cache.snapshot().map((e) => e.key), ["k"], "the earlier pulled state survives"); + assert.deepEqual(cache.foreignRecords(), []); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); -- 2.47.3 From a622570eb10bd33ee29e1b2644e25cc50bc781cb Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 22:36:21 +0000 Subject: [PATCH 3/5] cultcache-ts: pin the cache-side foreign refusal and foreign ordering apart from the store judge Co-Authored-By: Claude Sonnet 5.5 --- .../test/key-identity-vector.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/cultcache-ts/test/key-identity-vector.test.ts b/packages/cultcache-ts/test/key-identity-vector.test.ts index 5c25db05..82b4c1b6 100644 --- a/packages/cultcache-ts/test/key-identity-vector.test.ts +++ b/packages/cultcache-ts/test/key-identity-vector.test.ts @@ -264,3 +264,32 @@ test("a foreign key one store carries and another holds under a registered type await rm(dir, { recursive: true, force: true }); } }); + +// The cache judge answers for what the cache holds whatever the store is: a store that never judges (it only records +// what it is asked) is asked nothing at a foreign key, and foreign records list by key ordinal. +test("a foreign key is refused by the cache before any store is asked, and foreign records list sorted", async () => { + const calls: string[] = []; + const record = (key: string, schemaId: string): CultCacheEnvelope => ({ + key, + type: "unclaimed", + schemaId, + storedAt: "2026-01-01T00:00:00Z", + payload: documentPayloadFromBytes(encode(["x"])), + }); + const store = { + pullAll: async () => [record("m", "unclaimed.m.v1"), record("b", "unclaimed.b.v1")], + push: async (entry: CultCacheEnvelope) => void calls.push(`push ${entry.key}`), + delete: async (entry: CultCacheEnvelope) => void calls.push(`delete ${entry.key}`), + }; + const cache = CultCache.builder().withDocumentType(types.alpha!).withGenericStore(store).build(); + await cache.pullAllBackingStores(); + assert.deepEqual(cache.foreignRecords().map((r) => [r.key, r.schemaId, r.schemaName]), [["b", "unclaimed.b.v1", "unclaimed"], ["m", "unclaimed.m.v1", "unclaimed"]]); + await assert.rejects(cache.put(types.alpha!, "m", { text: CANARY } as never), (error: Error) => { + assert.ok(error instanceof SchemaConflictError && error.recordKey === "m"); + assert.ok(!error.message.includes(CANARY)); + return true; + }); + await assert.rejects(cache.delete(types.alpha!, "b"), (error: Error) => error instanceof SchemaConflictError && error.recordKey === "b"); + assert.equal(await cache.delete(types.alpha!, "nobody"), false); + assert.deepEqual(calls, []); +}); -- 2.47.3 From 1993d3640a4fb02672ff24ba22b47b4e2f8fcce9 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 22:50:21 +0000 Subject: [PATCH 4/5] cultcache-ts: the store push test makes each envelope in its own cache (a cache holds one record per key) Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/test/cult-cache.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 31b13d3b..b575170f 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2653,19 +2653,21 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", // One key is held once: a push whose type does not own the stored schema id is refused, so the writer cannot emit a store its own // reader refuses; one whose type owns it (its own id, or one it lists compatible, as C# WrittenOwns) restamps the record in place. test("SingleFileMessagePackBackingStore push asks whether the written type owns the stored id, not what the types are called", async () => { - const seed = CultCache.builder().withDocumentType(ownsId).withDocumentType(listsId).build(); - await seed.put(ownsId, "k", { name: "owner" }); - await seed.put(listsId, "k", { name: "lister" }); + // One cache holds one record per key, so each envelope is made in a cache of its own. + const owner = CultCache.builder().withDocumentType(ownsId).build(); + const lister = CultCache.builder().withDocumentType(listsId).build(); + await owner.put(ownsId, "k", { name: "owner" }); + await lister.put(listsId, "k", { name: "lister" }); const file = join(await mkdtemp(join(tmpdir(), "cultcache-push-key-")), "store.msgpack"); const store = new SingleFileMessagePackBackingStore(file); const idsOf = async () => ((decode(await readFile(file)) as unknown[][])[2] as unknown[][]).map((record) => [record[0], record[1]]); - await store.push(seed.getRequiredEnvelope(listsId, "k")); - await assert.rejects(() => store.push(seed.getRequiredEnvelope(ownsId, "k")), SchemaConflictError); + await store.push(lister.getRequiredEnvelope(listsId, "k")); + await assert.rejects(() => store.push(owner.getRequiredEnvelope(ownsId, "k")), SchemaConflictError); assert.deepEqual(await idsOf(), [["k", "id.lister"]]); - await store.pushAll([seed.getRequiredEnvelope(listsId, "k")]); - await store.push(seed.getRequiredEnvelope(listsId, "k")); + await store.pushAll([lister.getRequiredEnvelope(listsId, "k")]); + await store.push(lister.getRequiredEnvelope(listsId, "k")); assert.equal((await store.pullAll()).length, 1); }); -- 2.47.3 From 35c5834b6b99161000af424a87cc4910a1b9100e Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 23:21:00 +0000 Subject: [PATCH 5/5] cultcache-ts: store delete matches the stored schema id; cross-store owned rename and two-stores-one-foreign-key refused; walk 12 rows Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/CHANGELOG.md | 2 +- packages/cultcache-ts/src/cult-cache.ts | 20 +++- .../single-file-messagepack-backing-store.ts | 4 +- .../test/key-identity-vector.test.ts | 92 ++++++++++++++++++- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 73d49fc5..e71821c8 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following - A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. - Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins. - A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. -- The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it. Two stores holding one key refuse the load and admit nothing. +- The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it, unless the held record is in a store other than the written type's home: that rename is refused with `SchemaConflictError` (remove the record first). Two stores holding one key, registered or foreign, refuse the load and admit nothing. A store's `delete` removes the record whose stored schema id the removal's type owns, whatever the arrived record's `type` string. - A store holding a record no registered type claims now loads (it used to throw "No schema is registered..."): the cache does not serve the record, lists it in `foreignRecords()`, keeps its bytes through every write, and refuses a `put` or a `delete` at its key with `SchemaConflictError` (variants:ruling:unheld-removal-refused-at-commit). Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. ### Added diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 11afedf2..443c0c18 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -330,16 +330,22 @@ export class CultCache { const hydrated = this.#admitEntry(next, registered, admitted, value); const other = this.#heldUnderOtherType(next, type, hydrated.key); if (other !== undefined) { - throw new Error(`CultCache record "${hydrated.key}" is held by two stores, as type "${other.type}" and as type "${type}".`); + throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); } this.#installEntry(next, registered, hydrated); } } + const foreignByKey = new Map(); for (const record of next.foreign) { + const first = foreignByKey.get(record.key); + if (first !== undefined) { + throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`); + } + foreignByKey.set(record.key, record.schemaName); const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined); if (other !== undefined) { - throw new Error(`CultCache record "${record.key}" is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); + throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); } } @@ -694,6 +700,16 @@ export class CultCache { async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { const home = this.#homeStore(entry.type); const retyped = this.#refuseRetype(entry); + if (retyped !== undefined && this.#homeStore(retyped.type) !== home) { + // The write owns the held record's id and would replace it, but the held record is in another store; the push + // reaches only this type's home, so the old record would stay in its own file and load as a second holder. + throw new SchemaConflictError( + schemaIdForEnvelope(entry), + [retyped.type, entry.type], + entry.key, + `CultCache holds record "${entry.key}" as type "${retyped.type}" in another store than the home of type "${entry.type}", so the rename cannot replace it; remove the record first.`, + ); + } const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index a6eda8ae..5dfcfc74 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -71,8 +71,10 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { const disk = this.#readDisk(); this.#format = disk.format; const existing = disk.envelopes; + // The record the file stores at the key is the one removed when the removal's schema owns its stored id: the + // arrived record's type is its schema's name, which a type of another name does not carry. const filtered = existing.filter( - (candidate) => !(candidate.type === entry.type && candidate.key === entry.key), + (candidate) => !(candidate.key === entry.key && (schemaIdForEnvelope(candidate) === schemaIdForEnvelope(entry) || writtenOwns(entry, schemaIdForEnvelope(candidate)))), ); await this.#writeAll([], filtered); }); diff --git a/packages/cultcache-ts/test/key-identity-vector.test.ts b/packages/cultcache-ts/test/key-identity-vector.test.ts index 82b4c1b6..372614fd 100644 --- a/packages/cultcache-ts/test/key-identity-vector.test.ts +++ b/packages/cultcache-ts/test/key-identity-vector.test.ts @@ -60,7 +60,7 @@ const sameRecord = (left: CultCacheEnvelope | undefined, right: CultCacheEnvelop test("vectors rows.msgpack: every row's refusal, held records, file, foreign list and kept bytes", async () => { const rows = decode(await readFile(join(vectors, "rows.msgpack"))) as Row[]; - assert.equal(rows.length, 11); + assert.equal(rows.length, 12); let walked = 0; const failures: string[] = []; for (const [label, registered, ops, expect] of rows) { @@ -139,7 +139,12 @@ test("vectors rows.msgpack: every row's refusal, held records, file, foreign lis assert.equal(failed, true, `${label}: the last op is refused`); const [key] = refusal; if (label === "key-in-two-stores-refused") { - assert.ok((refused as Error).message.includes(key), `${label}: the refusal names the key`); + // The key is read out of the refusal as a delimited token: the message opens "Record is held by" and the key + // occurs nowhere else in it (not inside a schema name). + const message = (refused as Error).message; + const named = /^Record (\S+) is held by/u.exec(message); + assert.equal(named?.[1], key, `${label}: the refusal opens with the key`); + assert.equal(message.split(/[^A-Za-z0-9_.]+/u).filter((token) => token === key).length, 1, `${label}: the key occurs only as a delimited token`); assert.deepEqual(cache.snapshot(), beforeCache, `${label}: the earlier pulled state survives`); assert.deepEqual(await Promise.all(attached.slice(0, beforeAttached.length).map((copy) => readFile(copy))), beforeAttached, `${label}: the attached files are byte-identical`); } else { @@ -181,7 +186,7 @@ test("vectors rows.msgpack: every row's refusal, held records, file, foreign lis } } assert.deepEqual(failures, [], "every row holds its verdict"); - assert.equal(walked, 11); + assert.equal(walked, 12); }); // Beyond the vector: what the walker's string-typed rows cannot say. @@ -252,13 +257,13 @@ test("a foreign key one store carries and another holds under a registered type const catalog = join(dir, "catalog.cc"); await copyFile(join(vectors, "catalog-store.cc"), catalog); const writer = CultCache.builder().withDocumentType(types.gamma!).withGenericStore(new SingleFileMessagePackBackingStore(foreign)).build(); - await writer.put(types.gamma!, "k", { text: "g" } as never); + await writer.put(types.gamma!, "qx", { text: "g" } as never); const cache = CultCache.builder().withDocumentType(types.alpha!).build(); await cache.addBackingStore(new SingleFileMessagePackBackingStore(catalog), types.alpha!); await cache.pullAllBackingStores(); await cache.addBackingStore(new SingleFileMessagePackBackingStore(foreign)); await assert.rejects(cache.pullAllBackingStores(), /held by two stores/u); - assert.deepEqual(cache.snapshot().map((e) => e.key), ["k"], "the earlier pulled state survives"); + assert.deepEqual(cache.snapshot().map((e) => e.key), ["qx"], "the earlier pulled state survives"); assert.deepEqual(cache.foreignRecords(), []); } finally { await rm(dir, { recursive: true, force: true }); @@ -293,3 +298,80 @@ test("a foreign key is refused by the cache before any store is asked, and forei assert.equal(await cache.delete(types.alpha!, "nobody"), false); assert.deepEqual(calls, []); }); + +// The store's delete matches the record the file stores by its schema id, not by the type string: a record read back is +// typed by its schema's name, which a type of another name does not carry. +test("a delete removes the record the file stores although the type differs from the schema name, and the key retypes across a reload", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const path = join(dir, "s.cc"); + const named = (type: string, schemaName: string, schemaId: string) => + defineDocumentType({ type, schemaId, schemaName, schemaVersion: `${schemaName}.v1`, schema: z.object({ v: z.string() }) }); + const first = named("app.first", "wire.first", "wire.first.v1"); + const second = named("app.second", "wire.second", "wire.second.v1"); + const open = () => CultCache.builder().withDocumentType(first).withDocumentType(second).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + const writer = open(); + await writer.put(first, "k", { v: "a" }); + const reader = open(); + await reader.pullAllBackingStores(); + assert.equal(await reader.delete(first, "k"), true); + assert.deepEqual([...stored(path).keys()], [], "the record is gone from the file"); + await reader.put(second, "k", { v: "b" }); + assert.deepEqual([...stored(path).values()].map((e) => [e.key, e.catalogEntry?.schemaName]), [["k", "wire.second"]]); + const again = open(); + await again.pullAllBackingStores(); + assert.deepEqual(again.snapshot().map((e) => [e.type, e.key]), [["app.second", "k"]]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("an owned rename whose two types home in different stores is refused, and neither store changes", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const oldPath = join(dir, "old.cc"); + const newPath = join(dir, "new.cc"); + const old = plain("rn.old", "rn.id.v1"); + const renamed = plain("rn.new", "rn.id.v2", ["rn.id.v2", "rn.id.v1"]); + const cache = CultCache.builder().withDocumentType(old).withDocumentType(renamed).build(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(oldPath), old); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(newPath), renamed); + await cache.put(old, "k", { v: "a" }); + const before = await readFile(oldPath); + await assert.rejects(cache.put(renamed, "k", { v: CANARY }), (error: Error) => { + assert.ok(error instanceof SchemaConflictError && error.recordKey === "k"); + assert.ok(!error.message.includes(CANARY)); + return true; + }); + assert.deepEqual(await readFile(oldPath), before); + assert.equal(existsSync(newPath), false, "the new home was never written"); + assert.deepEqual(cache.snapshot().map((e) => [e.type, e.key]), [["rn.old", "k"]]); + // Same home: the rename replaces. + const together = CultCache.builder().withDocumentType(old).withDocumentType(renamed).withGenericStore(new SingleFileMessagePackBackingStore(join(dir, "one.cc"))).build(); + await together.put(old, "k", { v: "a" }); + await together.put(renamed, "k", { v: "b" }); + assert.deepEqual(together.snapshot().map((e) => [e.type, e.key]), [["rn.new", "k"]]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("two stores each carrying an unregistered record at one key refuse the load and admit nothing", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const paths = [join(dir, "a.cc"), join(dir, "b.cc")]; + for (const path of paths) { + const writer = CultCache.builder().withDocumentType(types.gamma!).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await writer.put(types.gamma!, "k", { text: "g" } as never); + } + // Neither store's records are claimed: gamma is not registered here. + const cache = CultCache.builder().withDocumentType(types.alpha!).withDocumentType(types.beta!).build(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(paths[0]!), types.alpha!); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(paths[1]!), types.beta!); + await assert.rejects(cache.pullAllBackingStores(), /^Error: Record k is held by two stores/u); + assert.deepEqual(cache.foreignRecords(), []); + assert.deepEqual(cache.snapshot(), []); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); -- 2.47.3