diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index d9ae6e7c..cf410d31 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -1,5 +1,6 @@ import { decode, encode } from "@msgpack/msgpack"; +import { SchemaConflictError } from "./store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, @@ -149,7 +150,9 @@ export class CultCache { } readonly #definitions = new Map(); + // The definition that owns each schema id, and every definition listing an id as compatible, in registration order. readonly #schemaIdDefinitions = new Map(); + readonly #listingDefinitions = new Map(); readonly #schemaNameDefinitions = new Map(); #hydrated = emptyHydratedState(); readonly #stores: CultCacheStoreRegistration[] = []; @@ -525,9 +528,13 @@ export class CultCache { } #registerNow(definition: AnyCultCacheDocumentDefinition): void { + // Every registration refusal is a SchemaConflictError naming the new definition's schema id and both schema names. + const refuse = (holder: RegisteredDefinition, claimed: CultCacheSchemaCatalogEntry, what: string) => + new SchemaConflictError(claimed.schemaId, [holder.catalogEntry.schemaName, claimed.schemaName], "", + `CultCache ${what} is already registered for type "${holder.definition.type}" and cannot also be claimed by type "${definition.type}".`); const existing = this.#definitions.get(definition.type); if (existing && existing.definition !== definition) { - throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`); + throw refuse(existing, this.#createCatalogEntry(definition), `type "${definition.type}"`); } const registered: RegisteredDefinition = existing ?? { @@ -547,25 +554,28 @@ export class CultCache { indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); } - const schemaIds = registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]; - for (const schemaId of schemaIds) { - const existingBySchema = this.#schemaIdDefinitions.get(schemaId); - if (existingBySchema && existingBySchema !== registered) { - throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`); - } + // One definition owns each schema id. Others may list it as compatible beside its owner: a record under the id resolves to + // its owner, and to a lister only when nothing owns it. Neither depends on the order definitions were registered in. + const ownId = registered.catalogEntry.schemaId; + const listed = (registered.catalogEntry.compatibleSchemaIds ?? []).filter((schemaId) => schemaId !== ownId); + const owner = this.#schemaIdDefinitions.get(ownId); + if (owner && owner !== registered) { + throw refuse(owner, registered.catalogEntry, `schema id "${ownId}"`); } const existingBySchemaName = this.#schemaNameDefinitions.get(registered.catalogEntry.schemaName); if (existingBySchemaName && existingBySchemaName !== registered) { - throw new Error( - `CultCache schema name "${registered.catalogEntry.schemaName}" is already registered for type "${existingBySchemaName.definition.type}".`, - ); + throw refuse(existingBySchemaName, registered.catalogEntry, `schema name "${registered.catalogEntry.schemaName}"`); } const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors); // Every check and accessor has run; install. this.#definitions.set(definition.type, registered); - for (const schemaId of schemaIds) { - this.#schemaIdDefinitions.set(schemaId, registered); + this.#schemaIdDefinitions.set(ownId, registered); + for (const schemaId of listed) { + const listers = this.#listingDefinitions.get(schemaId) ?? []; + if (!listers.includes(registered)) { + this.#listingDefinitions.set(schemaId, [...listers, registered]); + } } this.#schemaNameDefinitions.set(registered.catalogEntry.schemaName, registered); this.#installAccessors(registered, nameAccessor, indexAccessors, derived); @@ -808,7 +818,16 @@ export class CultCache { #resolveDefinitionForEnvelope(entry: CultCacheEnvelope): RegisteredDefinition | undefined { if (entry.schemaId) { - return this.#schemaIdDefinitions.get(entry.schemaId) + const listers = this.#listingDefinitions.get(entry.schemaId) ?? []; + const owner = this.#schemaIdDefinitions.get(entry.schemaId); + if (!owner && listers.length > 1) { + const types = listers.map((lister) => lister.definition.type).sort(); + throw new SchemaConflictError(entry.schemaId, listers.map((lister) => lister.catalogEntry.schemaName).sort(), entry.key, + `CultCache schema id "${entry.schemaId}" of record "${entry.key}" is owned by no registered type and declared compatible by ` + + `several (${types.join(", ")}), so it resolves to none. Register the type that owns the id, or declare it on one type.`); + } + return owner + ?? listers[0] ?? this.#schemaNameDefinitions.get(entry.type) ?? this.#definitions.get(entry.type); } diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index 37d12761..abf92487 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -35,15 +35,16 @@ export function isStoreSnapshot(decoded: unknown): decoded is [StoreFormat, ...u /** * A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id and * disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names involved and - * a record key. + * a record key. A cache throws it too, with an empty record key, when a definition cannot register beside one it already holds, + * and when a record's schema id names no single registered definition. */ export class SchemaConflictError extends Error { readonly schemaId: string; readonly schemaNames: string[]; readonly recordKey: string; - constructor(schemaId: string, schemaNames: string[], recordKey: string) { - super(`Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`); + constructor(schemaId: string, schemaNames: string[], recordKey: string, message?: string) { + super(message ?? `Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`); this.name = "SchemaConflictError"; this.schemaId = schemaId; this.schemaNames = schemaNames; diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index b4c75ee2..71ffeb5f 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -16,7 +16,7 @@ import { inspectCultCacheBytes } from "../src/cult-cache-inspector"; import { defineDocumentRegistry, defineDocumentType } from "../src/document"; import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; -import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; +import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; const execFileAsync = promisify(execFile); const execAsync = promisify(exec); @@ -2095,3 +2095,90 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", } } }); + +// Owner beats lister: a definition that owns an id and one that lists it as compatible register together in either order. A record +// under the id is the owner's, and a lister's only when nothing owns it; two listers and no owner name no single definition. +const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" }); +const listsId = defineDocumentType({ + type: "tests.lists", + schema: z.object({ name: z.string() }), + schemaId: "id.lister", + schemaName: "tests.lists", + compatibleSchemaIds: ["id.owned"], +}); +const alsoListsId = defineDocumentType({ + type: "tests.also-lists", + schema: z.object({ name: z.string() }), + schemaId: "id.also-lister", + schemaName: "tests.also-lists", + compatibleSchemaIds: ["id.owned"], +}); + +async function storeUnder(records: [string, string][]): Promise { + const seed = CultCache.builder().withDocumentType(ownsId).build(); + await seed.put(ownsId, "seed", { name: "n" }); + const payload = seed.getRequiredEnvelope(ownsId, "seed").payload; + const file = join(await mkdtemp(join(tmpdir(), "cultcache-owner-lister-")), "store.msgpack"); + const ids = [...new Set(records.map(([, schemaId]) => schemaId))]; + await writeFile(file, encode(["cultcache.store.v1", + ids.map((schemaId) => [schemaId, schemaId, `${schemaId}.v1`, schemaId, "", [schemaId], []]), + records.map(([key, schemaId]) => [key, schemaId, "2026-09-30T00:00:00.0000000Z", payload])])); + return file; +} + +async function openWith(file: string, ...definitions: AnyCultCacheDocumentDefinition[]): Promise { + let builder = CultCache.builder(); + for (const definition of definitions) builder = builder.withDocumentType(definition); + const cache = builder.withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + await cache.pullAllBackingStores(); + return cache; +} + +test("a definition listing an id registers beside the one owning it in either order, and the owner wins", async () => { + const file = await storeUnder([["o", "id.owned"], ["l", "id.lister"]]); + for (const order of [[ownsId, listsId], [listsId, ownsId]]) { + const cache = await openWith(file, ...order); + assert.deepEqual(cache.getRequired(ownsId, "o"), { name: "n" }); + assert.equal(cache.get(listsId, "o"), undefined); + assert.deepEqual(cache.getRequired(listsId, "l"), { name: "n" }); + } + const listerOnly = await openWith(await storeUnder([["o", "id.owned"]]), listsId); + assert.deepEqual(listerOnly.getRequired(listsId, "o"), { name: "n" }); +}); + +test("two definitions listing one id register, and a record under it needs its owner", async () => { + const file = await storeUnder([["k", "id.owned"]]); + for (const listers of [[listsId, alsoListsId], [alsoListsId, listsId]]) { + await assert.rejects(openWith(file, ...listers), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError); + assert.equal(error.schemaId, "id.owned"); + assert.equal(error.recordKey, "k"); + assert.deepEqual(error.schemaNames, ["tests.also-lists", "tests.lists"]); + return true; + }); + for (const order of [[...listers, ownsId], [ownsId, ...listers]]) { + assert.deepEqual((await openWith(file, ...order)).getRequired(ownsId, "k"), { name: "n" }); + } + } +}); + +test("every registration refusal is a SchemaConflictError naming the id and both schema names", () => { + const sameId = defineDocumentType({ type: "tests.owns-too", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns-too" }); + const sameName = defineDocumentType({ type: "tests.named-too", schema: z.object({ name: z.string() }), schemaId: "id.other", schemaName: "tests.owns" }); + const sameType = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.third", schemaName: "tests.third" }); + for (const [claimant, schemaId, names] of [ + [sameId, "id.owned", ["tests.owns", "tests.owns-too"]], + [sameName, "id.other", ["tests.owns", "tests.owns"]], + [sameType, "id.third", ["tests.owns", "tests.third"]], + ] as const) { + for (const order of [[ownsId, claimant], [claimant, ownsId]]) { + assert.throws(() => CultCache.builder().withDocumentType(order[0]).withDocumentType(order[1]).build(), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + const first = order[0] === ownsId; + assert.equal(error.schemaId, first ? schemaId : ownsId.schemaId); + assert.deepEqual(error.schemaNames, first ? names : [...names].reverse()); + return true; + }); + } + } +});