diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 5958a4f7..a4cec535 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -3,6 +3,7 @@ import { decode, encode } from "@msgpack/msgpack"; import { CultCache, + SchemaConflictError, SingleFileMessagePackBackingStore, type AnyCultCacheDocumentDefinition, type CacheBackingStore, @@ -1473,6 +1474,7 @@ export class CultMeshDocumentCatalog { } public add(document: CultMeshDocumentHandle): this { + cultMeshRefuseSecondCatalogType(document.schema, this.#byType, this.#bySchemaId, this.#bySchemaNameVersion); this.#byDocumentId.set(document.documentId, document as CultMeshDocumentHandle); if (document.schema.type) { this.#byType.set(document.schema.type, document as CultMeshDocumentHandle); @@ -1684,6 +1686,7 @@ export class CultMeshCollectionCatalog { } public add(collection: CultMeshCollectionHandle): this { + cultMeshRefuseSecondCatalogType(collection.schema, this.#byType, this.#bySchemaId, this.#bySchemaNameVersion); this.#byCollectionId.set(collection.collectionId, collection as CultMeshCollectionHandle); if (collection.schema.type) { this.#byType.set(collection.schema.type, collection as CultMeshCollectionHandle); @@ -6193,6 +6196,32 @@ function normalizeCultMeshDocumentSchema( return normalized; } +// One type owns a schema in a catalog, as in a registry. A handle whose type differs from the one already holding its +// schema id or schema name and version, or whose type already holds another schema, is refused. Handles of one type +// for one schema may repeat (one per record), and a handle without a type claims no type. +function cultMeshRefuseSecondCatalogType( + schema: CultMeshDocumentSchemaDescriptor, + byType: ReadonlyMap, + bySchemaId: ReadonlyMap, + bySchemaNameVersion: ReadonlyMap, +): void { + if (!schema.type) { + return; + } + const key = cultMeshSchemaNameVersionKey(schema); + const sameType = byType.get(schema.type)?.schema; + const holder = + (sameType?.schemaId && schema.schemaId && sameType.schemaId !== schema.schemaId ? sameType : undefined) ?? + [schema.schemaId ? bySchemaId.get(schema.schemaId) : undefined, key ? bySchemaNameVersion.get(key) : undefined] + .map(held => held?.schema) + .find(held => held?.type !== undefined && held.type !== schema.type); + if (holder) { + const name = (held: CultMeshDocumentSchemaDescriptor) => held.schemaName ?? held.type ?? held.schemaId ?? ""; + throw new SchemaConflictError(schema.schemaId ?? schema.type, [name(holder), name(schema)], "", + `CultMesh catalog schema ${cultMeshSchemaLabel(schema)} is held by type "${holder.type}" and cannot also be held by type "${schema.type}".`); + } +} + function cultMeshSchemaNameVersionKey( schema: CultMeshDocumentSchemaDescriptor, ): string | undefined { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 943d2a85..a665ea00 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -25,6 +25,7 @@ const noteDocument = defineDocumentType({ }); type Note = z.infer; +type CultMeshDocumentSchemaDescriptorLike = { type?: string; schemaId?: string; schemaName?: string; schemaVersion?: string }; // Another type claiming noteDocument's schema. It is never registered beside noteDocument: one type owns a schema. const otherTypeForNoteSchema = defineDocumentType({ @@ -185,6 +186,49 @@ test("CultMesh TS catalogs infer a lookup's value type from its document definit void typeOnly; }); +test("CultMesh TS catalogs hold one type per schema and refuse a second, typed", () => { + const sameNameVersion = { type: "cultmesh.note.named", schemaId: "sha256:note-named", schemaName: "cultmesh.note", schemaVersion: "cultmesh.note.v0" }; + const sameTypeOtherSchema = { type: "cultmesh.note", schemaId: "cultmesh.note.v9" }; + const note = { noteId: "note:held", body: "held" }; + const kinds = [ + { + catalog: (...schemas: CultMeshDocumentSchemaDescriptorLike[]) => + CultMesh.documents(...schemas.map((schema, index) => CultMesh.document(`doc:${index}`, schema, async () => note))), + handle: (schema: CultMeshDocumentSchemaDescriptorLike, id: string) => CultMesh.document(id, schema, async () => note), + find: (catalog: any, schema: CultMeshDocumentSchemaDescriptorLike) => catalog.document(schema).documentId, + }, + { + catalog: (...schemas: CultMeshDocumentSchemaDescriptorLike[]) => + CultMesh.collections(...schemas.map((schema, index) => CultMesh.collection(`doc:${index}`, schema, async () => [note]))), + handle: (schema: CultMeshDocumentSchemaDescriptorLike, id: string) => CultMesh.collection(id, schema, async () => [note]), + find: (catalog: any, schema: CultMeshDocumentSchemaDescriptorLike) => catalog.collection(schema).collectionId, + }, + ]; + for (const kind of kinds) { + for (const claimant of [otherTypeForNoteSchema, sameNameVersion, sameTypeOtherSchema]) { + for (const [first, second] of [[noteDocument, claimant], [claimant, noteDocument]] as const) { + const catalog = kind.catalog(first); + assert.throws(() => catalog.add(kind.handle(second, "doc:second") as any), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + assert.equal(error.schemaId, second.schemaId); + assert.equal(error.recordKey, ""); + return true; + }); + assert.throws(() => kind.catalog(first, second), SchemaConflictError); + // The refused handle left nothing behind: the first type still answers. + assert.equal(kind.find(catalog, first), "doc:0"); + assert.equal(kind.find(catalog, { schemaId: first.schemaId }), "doc:0"); + } + } + // Handles of one type for one schema may repeat, the same handle may be added again, and an untyped handle claims no type. + const held = kind.handle(noteDocument, "doc:held"); + const catalog = kind.catalog(noteDocument); + catalog.add(held as any).add(held as any); + catalog.add(kind.handle({ schemaId: "cultmesh.note.v0" }, "doc:untyped") as any); + assert.equal(kind.find(catalog, noteDocument), "doc:held"); + } +}); + test("CultMesh TS document handles submit predictions through configured authority hooks", async () => { let current = { noteId: "note:prediction",