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 { decode, encode } from "@msgpack/msgpack";
|
||||||
import type {
|
import {
|
||||||
AnyCultCacheDocumentDefinition,
|
type AnyCultCacheDocumentDefinition,
|
||||||
CultCache,
|
type CultCache,
|
||||||
CultCacheDocumentDefinition,
|
type CultCacheDocumentDefinition,
|
||||||
CultCacheDocumentFormatter,
|
type CultCacheDocumentFormatter,
|
||||||
CultCacheDocumentValue,
|
type CultCacheDocumentValue,
|
||||||
CultCacheEnvelope,
|
type CultCacheEnvelope,
|
||||||
|
SchemaConflictError,
|
||||||
} from "@gamecult/cultcache-ts";
|
} from "@gamecult/cultcache-ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -47,6 +48,24 @@ export class CultNetDocumentRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
register(binding: CultNetDocumentBinding): this {
|
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.#bindings.set(binding.definition.type, binding);
|
||||||
this.#schemaBindings.set(schemaIdForBinding(binding), binding);
|
this.#schemaBindings.set(schemaIdForBinding(binding), binding);
|
||||||
return this;
|
return this;
|
||||||
|
|
@ -343,6 +362,10 @@ function schemaIdForBinding(binding: CultNetDocumentBinding): string {
|
||||||
return binding.definition.schemaId ?? binding.definition.type;
|
return binding.definition.schemaId ?? binding.definition.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function schemaNameForBinding(binding: CultNetDocumentBinding): string {
|
||||||
|
return binding.definition.schemaName ?? binding.definition.type;
|
||||||
|
}
|
||||||
|
|
||||||
function schemaMatchesBinding(
|
function schemaMatchesBinding(
|
||||||
schemaId: string,
|
schemaId: string,
|
||||||
binding: CultNetDocumentBinding,
|
binding: CultNetDocumentBinding,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { z } from "zod";
|
||||||
import { encode } from "@msgpack/msgpack";
|
import { encode } from "@msgpack/msgpack";
|
||||||
import {
|
import {
|
||||||
CultCache,
|
CultCache,
|
||||||
|
SchemaConflictError,
|
||||||
SingleFileMessagePackBackingStore,
|
SingleFileMessagePackBackingStore,
|
||||||
defineDocumentType,
|
defineDocumentType,
|
||||||
} from "@gamecult/cultcache-ts";
|
} 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 () => {
|
test("CultNet document registry filters snapshots by schema aliases", async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-snapshot-"));
|
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-snapshot-"));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue