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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 16:59:08 +02:00
commit 9adea36680
2 changed files with 71 additions and 7 deletions

View file

@ -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,

View file

@ -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-"));