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:
parent
98999997ce
commit
9adea36680
2 changed files with 71 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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-"));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue