From 9adea366807b7560346cc81cbefa6b6e886614f7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 16:59:08 +0200 Subject: [PATCH] CultNet TS: a second type for one schema is refused, typed F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." CultNetDocumentRegistry.register silently replaced the binding for a schema id, so two types could claim one schema and the last one registered won. - register refuses a binding whose definition claims a type, schema id or schema name another bound definition already holds, with the SchemaConflictError CultCache uses for the same refusal: the new definition's schema id, both schema names, an empty record key. - Binding the same definition again still replaces its binding, and a definition listing another's id as compatible is not a second owner. - Test: identical-id, same-name and same-type claimants refused in both registration orders, leaving the first binding in place. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-ts/src/replication.ts | 37 +++++++++++++++++---- packages/cultnet-ts/test/cultnet.test.ts | 41 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 01f39e13..6bfb6bab 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -1,11 +1,12 @@ import { decode, encode } from "@msgpack/msgpack"; -import type { - AnyCultCacheDocumentDefinition, - CultCache, - CultCacheDocumentDefinition, - CultCacheDocumentFormatter, - CultCacheDocumentValue, - CultCacheEnvelope, +import { + type AnyCultCacheDocumentDefinition, + type CultCache, + type CultCacheDocumentDefinition, + type CultCacheDocumentFormatter, + type CultCacheDocumentValue, + type CultCacheEnvelope, + SchemaConflictError, } from "@gamecult/cultcache-ts"; import { @@ -47,6 +48,24 @@ export class CultNetDocumentRegistry { } register(binding: CultNetDocumentBinding): this { + // One definition owns a type, a schema id and a schema name. Another definition claiming any of them is + // refused, so a schema never resolves to two types. + const schemaId = schemaIdForBinding(binding); + const schemaName = schemaNameForBinding(binding); + for (const holder of this.#uniqueBindings()) { + if (holder.definition === binding.definition) { + continue; + } + const claimed = + holder.definition.type === binding.definition.type ? `type "${binding.definition.type}"` + : schemaIdForBinding(holder) === schemaId ? `schema id "${schemaId}"` + : schemaNameForBinding(holder) === schemaName ? `schema name "${schemaName}"` + : undefined; + if (claimed) { + throw new SchemaConflictError(schemaId, [schemaNameForBinding(holder), schemaName], "", + `CultNet ${claimed} is already bound to type "${holder.definition.type}" and cannot also be bound to type "${binding.definition.type}".`); + } + } this.#bindings.set(binding.definition.type, binding); this.#schemaBindings.set(schemaIdForBinding(binding), binding); return this; @@ -343,6 +362,10 @@ function schemaIdForBinding(binding: CultNetDocumentBinding): string { return binding.definition.schemaId ?? binding.definition.type; } +function schemaNameForBinding(binding: CultNetDocumentBinding): string { + return binding.definition.schemaName ?? binding.definition.type; +} + function schemaMatchesBinding( schemaId: string, binding: CultNetDocumentBinding, diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index fb347110..f0565b5b 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import { encode } from "@msgpack/msgpack"; import { CultCache, + SchemaConflictError, SingleFileMessagePackBackingStore, defineDocumentType, } from "@gamecult/cultcache-ts"; @@ -1639,6 +1640,46 @@ test("CultNet raw replication applies compatible foreign-schema snapshots", asyn } }); +test("CultNet document registry binds one type per schema and refuses a second, typed", () => { + const noteShape = z.object({ noteId: z.string() }); + const owner = defineDocumentType({ type: "tests.note", schemaId: "id.note", schemaName: "tests.note", schema: noteShape }); + const sameId = defineDocumentType({ type: "tests.note-ui", schemaId: "id.note", schemaName: "tests.note-ui", schema: noteShape }); + const sameName = defineDocumentType({ type: "tests.note-named", schemaId: "id.other", schemaName: "tests.note", schema: noteShape }); + const sameType = defineDocumentType({ type: "tests.note", schemaId: "id.third", schemaName: "tests.third", schema: noteShape }); + const lister = defineDocumentType({ + type: "tests.note-lister", schemaId: "id.lister", schemaName: "tests.note-lister", compatibleSchemaIds: ["id.note"], schema: noteShape, + }); + + for (const [claimant, schemaId, names] of [ + [sameId, "id.note", ["tests.note", "tests.note-ui"]], + [sameName, "id.other", ["tests.note", "tests.note"]], + [sameType, "id.third", ["tests.note", "tests.third"]], + ] as const) { + for (const order of [[owner, claimant], [claimant, owner]]) { + const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: order[0] })]); + assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: order[1] })), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + const ownerFirst = order[0] === owner; + assert.equal(error.schemaId, ownerFirst ? schemaId : "id.note"); + assert.deepEqual(error.schemaNames, ownerFirst ? names : [...names].reverse()); + assert.equal(error.recordKey, ""); + return true; + }); + // The refused binding left nothing behind: the schema still resolves to the first type. + assert.equal(registry.getBySchemaId(order[0].schemaId!)?.definition, order[0]); + assert.equal(registry.get(order[1].type)?.definition, order[1].type === order[0].type ? order[0] : undefined); + } + } + + // The same definition may be bound again, and a definition listing the id as compatible is not a second owner. + const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: owner })]); + registry.register(defineCultNetDocumentBinding({ definition: owner, payloadSchemaVersion: "tests.note.v2" })); + registry.register(defineCultNetDocumentBinding({ definition: lister })); + assert.equal(registry.get("tests.note")?.payloadSchemaVersion, "tests.note.v2"); + assert.equal(registry.getBySchemaId("id.note")?.definition, owner); + assert.equal(registry.getBySchemaId("id.lister")?.definition, lister); +}); + test("CultNet document registry filters snapshots by schema aliases", async () => { const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-snapshot-"));