From 6311d599ad3adc6bf4e6e49587ec8d3d4d85568c Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 22:34:49 +0000 Subject: [PATCH] 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 }); + } +});