CultNet TS: the registry resolves only ids a definition declares
Soul F1, F2 and F3 on the registry. The F2 principle (operator, 2026-09-30): "CultCache never destroys or relabels a record it doesn't understand; keeps it byte-for-byte, refuses writes that would overwrite it." - F1: the registry derives every binding's identity with cultcache-ts's schemaIdentityOf, so it stamps, refuses and resolves exactly as the cache does. P1 (A with schema name "p1.shared", B with schema id "p1.shared") is now refused by both. - F2a: an id nobody owns resolves to the one binding that lists it as compatible. Several listers and no owner is a SchemaConflictError naming the id and the listers, whatever order they were registered in; an owner beats every lister. This is refused at resolution, as the cache refuses it at load, not at registration: refusing at registration would make the outcome depend on whether the owner was registered before the listers. - F2b: version inference is deleted. "p3.note.v7" no longer resolves to the type named "p3.note" and gets relabelled "p3.note.v1". - F2c: a schema version string, and a schema name, are not ids. Snapshot filters, typed puts and deletes, and raw puts match only declared ids. - F3: a raw record under an id no binding declares is refused. The registry no longer tries each binding's parser and stores the record under the first that accepts it (P5: an identity parser registered first took everything). - The dead resolvePayloadSchemaVersion helper is gone. Tests: the three shape- and alias-routing tests are replaced by P1, P2, P3/P4 and P5 as behaviour tests. The one-type-per-schema test registers two bindings ahead of the holder (F6/M11). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
426c956a40
commit
0ee98e8c2e
2 changed files with 180 additions and 246 deletions
|
|
@ -7,6 +7,7 @@ import {
|
|||
type CultCacheDocumentValue,
|
||||
type CultCacheEnvelope,
|
||||
SchemaConflictError,
|
||||
schemaIdentityOf,
|
||||
} from "@gamecult/cultcache-ts";
|
||||
|
||||
import {
|
||||
|
|
@ -39,7 +40,9 @@ export function defineCultNetDocumentBinding<
|
|||
|
||||
export class CultNetDocumentRegistry {
|
||||
readonly #bindings = new Map<string, CultNetDocumentBinding>();
|
||||
readonly #schemaBindings = new Map<string, CultNetDocumentBinding>();
|
||||
// The binding that owns each schema id, and every binding listing an id as compatible, in registration order.
|
||||
readonly #owners = new Map<string, CultNetDocumentBinding>();
|
||||
readonly #listers = new Map<string, CultNetDocumentBinding[]>();
|
||||
|
||||
constructor(bindings: Iterable<CultNetDocumentBinding> = []) {
|
||||
for (const binding of bindings) {
|
||||
|
|
@ -48,26 +51,32 @@ 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()) {
|
||||
// One definition owns a type, a schema id and a schema name, derived as CultCache derives them. Another definition
|
||||
// claiming any of them is refused, so a schema never resolves to two types.
|
||||
const identity = schemaIdentityOf(binding.definition);
|
||||
for (const holder of this.#bindings.values()) {
|
||||
if (holder.definition === binding.definition) {
|
||||
continue;
|
||||
}
|
||||
const held = schemaIdentityOf(holder.definition);
|
||||
const claimed =
|
||||
holder.definition.type === binding.definition.type ? `type "${binding.definition.type}"`
|
||||
: schemaIdForBinding(holder) === schemaId ? `schema id "${schemaId}"`
|
||||
: schemaNameForBinding(holder) === schemaName ? `schema name "${schemaName}"`
|
||||
: held.schemaId === identity.schemaId ? `schema id "${identity.schemaId}"`
|
||||
: held.schemaName === identity.schemaName ? `schema name "${identity.schemaName}"`
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
throw new SchemaConflictError(schemaId, [schemaNameForBinding(holder), schemaName], "",
|
||||
`CultNet ${claimed} is already bound to type "${holder.definition.type}"; type "${binding.definition.type}" (schema id "${schemaId}") cannot also be bound.`);
|
||||
throw new SchemaConflictError(identity.schemaId, [held.schemaName, identity.schemaName], "",
|
||||
`CultNet ${claimed} is already bound to type "${holder.definition.type}"; type "${binding.definition.type}" (schema id "${identity.schemaId}") cannot also be bound.`);
|
||||
}
|
||||
}
|
||||
this.#bindings.set(binding.definition.type, binding);
|
||||
this.#schemaBindings.set(schemaIdForBinding(binding), binding);
|
||||
this.#owners.set(identity.schemaId, binding);
|
||||
for (const schemaId of identity.compatibleSchemaIds) {
|
||||
const listers = this.#listers.get(schemaId) ?? [];
|
||||
if (schemaId !== identity.schemaId && !listers.some(lister => lister.definition === binding.definition)) {
|
||||
this.#listers.set(schemaId, [...listers, binding]);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -75,16 +84,22 @@ export class CultNetDocumentRegistry {
|
|||
return this.#bindings.get(documentType);
|
||||
}
|
||||
|
||||
// The binding a record under this schema id belongs to: the id's owner, else the one binding that lists it as
|
||||
// compatible. An id nothing declares resolves to nothing. An id that several bindings list and none owns is refused,
|
||||
// as the cache refuses it, whatever order the bindings were registered in.
|
||||
getBySchemaId(schemaId: string): CultNetDocumentBinding | undefined {
|
||||
const exact = this.#schemaBindings.get(schemaId);
|
||||
if (exact) {
|
||||
return exact;
|
||||
const owner = this.#owners.get(schemaId);
|
||||
if (owner) {
|
||||
return owner;
|
||||
}
|
||||
|
||||
const requestedSchemaIds = new Set([schemaId]);
|
||||
return this.#uniqueBindings().find(binding =>
|
||||
schemaMatchesBinding(schemaIdForBinding(binding), binding, requestedSchemaIds),
|
||||
);
|
||||
const listers = this.#listers.get(schemaId) ?? [];
|
||||
if (listers.length > 1) {
|
||||
const types = listers.map(lister => lister.definition.type).sort();
|
||||
throw new SchemaConflictError(schemaId, listers.map(lister => schemaIdentityOf(lister.definition).schemaName).sort(), "",
|
||||
`CultNet schema id "${schemaId}" is owned by no bound type and declared compatible by several (${types.join(", ")}), ` +
|
||||
"so it resolves to none. Bind the type that owns the id, or declare it on one type.");
|
||||
}
|
||||
return listers[0];
|
||||
}
|
||||
|
||||
createDocumentPutMessage<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
|
|
@ -186,7 +201,7 @@ export class CultNetDocumentRegistry {
|
|||
const binding = this.#requireBinding(envelope.type);
|
||||
const schemaId = schemaIdForEnvelope(envelope, binding);
|
||||
|
||||
if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) {
|
||||
if (requestedSchemaIds && !answersTo(schemaId, binding, requestedSchemaIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +238,7 @@ export class CultNetDocumentRegistry {
|
|||
const binding = this.#requireBinding(envelope.type);
|
||||
const schemaId = schemaIdForEnvelope(envelope, binding);
|
||||
|
||||
if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) {
|
||||
if (requestedSchemaIds && !answersTo(schemaId, binding, requestedSchemaIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +280,7 @@ export class CultNetDocumentRegistry {
|
|||
cache: CultCache,
|
||||
message: CultNetDocumentPutRawMessage,
|
||||
): Promise<unknown> {
|
||||
const binding = this.#resolveRawDocumentBinding(message.document);
|
||||
const binding = this.#requireSchemaBinding(message.document.schemaId);
|
||||
return cache.putEnvelope(binding.definition, {
|
||||
key: message.document.recordKey,
|
||||
type: binding.definition.type,
|
||||
|
|
@ -319,33 +334,6 @@ export class CultNetDocumentRegistry {
|
|||
return binding;
|
||||
}
|
||||
|
||||
// routes a replicated raw record to a local binding; the store always persists the local schema id; this is not store-reader recovery.
|
||||
#resolveRawDocumentBinding(document: CultNetRawDocumentRecord): CultNetDocumentBinding {
|
||||
const exact = this.getBySchemaId(document.schemaId);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const payload = new Uint8Array(document.payload);
|
||||
for (const binding of this.#uniqueBindings()) {
|
||||
try {
|
||||
decodeDocumentValue(binding.definition, payload);
|
||||
return binding;
|
||||
} catch {
|
||||
// Keep looking; a foreign schema id is acceptable only when the payload validates locally.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No CultNet document binding is registered for schema "${document.schemaId}".`);
|
||||
}
|
||||
|
||||
#uniqueBindings(): CultNetDocumentBinding[] {
|
||||
return [...new Set([
|
||||
...this.#schemaBindings.values(),
|
||||
...this.#bindings.values(),
|
||||
])];
|
||||
}
|
||||
|
||||
#createRawDocumentRecord(envelope: CultCacheEnvelope): CultNetRawDocumentRecord {
|
||||
const binding = this.#requireBinding(envelope.type);
|
||||
return {
|
||||
|
|
@ -359,47 +347,17 @@ export class CultNetDocumentRegistry {
|
|||
}
|
||||
|
||||
function schemaIdForBinding(binding: CultNetDocumentBinding): string {
|
||||
return binding.definition.schemaId ?? binding.definition.type;
|
||||
return schemaIdentityOf(binding.definition).schemaId;
|
||||
}
|
||||
|
||||
function schemaNameForBinding(binding: CultNetDocumentBinding): string {
|
||||
return binding.definition.schemaName ?? binding.definition.type;
|
||||
}
|
||||
|
||||
function schemaMatchesBinding(
|
||||
// A held record matches a snapshot filter when the filter names the id it carries or any id its binding answers to.
|
||||
function answersTo(
|
||||
schemaId: string,
|
||||
binding: CultNetDocumentBinding,
|
||||
requestedSchemaIds: Set<string>,
|
||||
): boolean {
|
||||
if (requestedSchemaIds.has(schemaId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const definition = binding.definition;
|
||||
if (definition.schemaId && requestedSchemaIds.has(definition.schemaId)) {
|
||||
return true;
|
||||
}
|
||||
if (definition.schemaName && requestedSchemaIds.has(definition.schemaName)) {
|
||||
return true;
|
||||
}
|
||||
if (definition.schemaVersion && requestedSchemaIds.has(definition.schemaVersion)) {
|
||||
return true;
|
||||
}
|
||||
if (definition.compatibleSchemaIds?.some(candidate => requestedSchemaIds.has(candidate))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.from(requestedSchemaIds)
|
||||
.some(candidate => inferSchemaName(candidate) === definition.schemaName);
|
||||
}
|
||||
|
||||
function inferSchemaName(schemaId: string): string | undefined {
|
||||
const marker = schemaId.lastIndexOf(".v");
|
||||
if (marker <= 0 || marker + 2 >= schemaId.length) {
|
||||
return undefined;
|
||||
}
|
||||
const version = schemaId.slice(marker + 2);
|
||||
return /^\d+$/u.test(version) ? schemaId.slice(0, marker) : undefined;
|
||||
return requestedSchemaIds.has(schemaId)
|
||||
|| schemaIdentityOf(binding.definition).compatibleSchemaIds.some(candidate => requestedSchemaIds.has(candidate));
|
||||
}
|
||||
|
||||
function schemaIdForEnvelope(
|
||||
|
|
@ -409,17 +367,6 @@ function schemaIdForEnvelope(
|
|||
return envelope.schemaId ?? schemaIdForBinding(binding);
|
||||
}
|
||||
|
||||
function resolvePayloadSchemaVersion<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
binding: CultNetDocumentBinding<TDefinition>,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
): string | undefined {
|
||||
if (typeof binding.payloadSchemaVersion === "function") {
|
||||
return binding.payloadSchemaVersion(value);
|
||||
}
|
||||
|
||||
return binding.payloadSchemaVersion;
|
||||
}
|
||||
|
||||
function decodeDocumentValue<TDefinition extends CultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
payload: Uint8Array,
|
||||
|
|
|
|||
|
|
@ -1589,57 +1589,145 @@ test("rudp sequence-neutral acknowledgements interoperate with ordered receivers
|
|||
assert.deepEqual(receiver.receive(request).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["snapshot"]);
|
||||
});
|
||||
|
||||
test("CultNet raw replication applies compatible foreign-schema snapshots", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-foreign-schema-raw-"));
|
||||
// A replicated record whose schema id no binding declares is refused, whatever its payload parses as: the registry never
|
||||
// guesses a type from a payload's shape and never relabels a record it does not understand.
|
||||
test("CultNet raw replication refuses a record under a schema id no binding declares, whatever its shape", async () => {
|
||||
const note = defineDocumentType({
|
||||
type: "p5.note", schemaId: "p5.note.v1", schemaName: "p5.note", schema: z.object({ body: z.string() }),
|
||||
});
|
||||
// Registered first, and accepts every payload: shape routing would store the record under it.
|
||||
const anything = defineDocumentType({ type: "p5.anything", schema: z.any() });
|
||||
const registry = new CultNetDocumentRegistry([
|
||||
defineCultNetDocumentBinding({ definition: anything }),
|
||||
defineCultNetDocumentBinding({ definition: note }),
|
||||
]);
|
||||
const origin = CultCache.builder().withDocumentType(note).build();
|
||||
await origin.put(note, "k", { body: "from a peer" });
|
||||
const snapshot = registry.createRawSnapshotResponse(origin, "p5");
|
||||
assert.equal(snapshot.documents[0]?.schemaId, "p5.note.v1");
|
||||
|
||||
try {
|
||||
const documentDefinition = defineDocumentType({
|
||||
type: "cultnet.note",
|
||||
schemaId: "cultnet.note.v1",
|
||||
schemaName: "cultnet.note",
|
||||
schemaVersion: "cultnet.note.v1",
|
||||
schema: z.object({
|
||||
schema_version: z.string(),
|
||||
noteId: z.string(),
|
||||
body: z.string(),
|
||||
for (const schemaId of ["runtime.generated.p5.note.ui.99", "p5.note.v7", "p5.note"]) {
|
||||
const target = CultCache.builder().withDocumentType(note).withDocumentType(anything).build();
|
||||
await assert.rejects(
|
||||
registry.applyRawSnapshotResponse(target, {
|
||||
...snapshot,
|
||||
documents: snapshot.documents.map(document => ({ ...document, schemaId })),
|
||||
}),
|
||||
name: "noteId",
|
||||
});
|
||||
const registry = new CultNetDocumentRegistry([
|
||||
defineCultNetDocumentBinding({ definition: documentDefinition }),
|
||||
]);
|
||||
const originCache = CultCache.builder()
|
||||
.withDocumentType(documentDefinition)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "origin.msgpack")))
|
||||
.build();
|
||||
const targetCache = CultCache.builder()
|
||||
.withDocumentType(documentDefinition)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack")))
|
||||
.build();
|
||||
|
||||
await originCache.put(documentDefinition, "note:foreign-schema", {
|
||||
schema_version: "cultnet.note.v1",
|
||||
noteId: "note:foreign-schema",
|
||||
body: "accepted by payload shape",
|
||||
});
|
||||
const rawSnapshot = registry.createRawSnapshotResponse(originCache, "foreign-schema-raw");
|
||||
rawSnapshot.documents[0]!.schemaId = "runtime.generated.cultnet.note.ui.99";
|
||||
|
||||
await registry.applyRawSnapshotResponse(targetCache, rawSnapshot);
|
||||
|
||||
assert.equal(
|
||||
targetCache.getRequired(documentDefinition, "note:foreign-schema").body,
|
||||
"accepted by payload shape",
|
||||
(error: unknown) => error instanceof Error
|
||||
&& error.message === `No CultNet document binding is registered for schema "${schemaId}".`,
|
||||
);
|
||||
assert.equal(
|
||||
targetCache.getRequiredEnvelope(documentDefinition, "note:foreign-schema").schemaId,
|
||||
"cultnet.note.v1",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
assert.deepEqual(target.snapshot(), [], `nothing was stored for ${schemaId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// The registry stamps and resolves a definition's schema id exactly as CultCache does, so a record a cache wrote is read
|
||||
// back as the type that wrote it on every peer.
|
||||
test("CultNet registry derives a definition's schema id as the cache does, and refuses what the cache refuses", async () => {
|
||||
const shape = z.object({ body: z.string() });
|
||||
const a = defineDocumentType({ type: "p1.a", schemaName: "p1.shared", schema: shape });
|
||||
const b = defineDocumentType({ type: "p1.b", schemaId: "p1.shared", schemaName: "p1.b", schema: shape });
|
||||
const fillers = [0, 1].map(index => defineCultNetDocumentBinding({
|
||||
definition: defineDocumentType({ type: `p1.filler${index}`, schema: shape }),
|
||||
}));
|
||||
|
||||
// The cache refuses B beside A: A's records carry "p1.shared".
|
||||
await assert.rejects(CultCache.builder().withDocumentType(a).build().registerDocumentType(b), SchemaConflictError);
|
||||
for (const [first, second] of [[a, b], [b, a]]) {
|
||||
// Two bindings stand ahead of the holder, so a check that looks only at the first holder misses it.
|
||||
const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: first })]);
|
||||
assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: second })), (error: unknown) => {
|
||||
assert.ok(error instanceof SchemaConflictError, String(error));
|
||||
assert.equal(error.schemaId, "p1.shared");
|
||||
return true;
|
||||
});
|
||||
assert.equal(registry.get(second.type), undefined);
|
||||
}
|
||||
|
||||
const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: a })]);
|
||||
const origin = CultCache.builder().withDocumentType(a).build();
|
||||
await origin.put(a, "k", { body: "written by a" });
|
||||
const stamped = origin.getRequiredEnvelope(a, "k").schemaId;
|
||||
assert.equal(stamped, "p1.shared");
|
||||
assert.equal(registry.createDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped);
|
||||
assert.equal(registry.createRawDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped);
|
||||
assert.equal(registry.getBySchemaId(stamped!)?.definition, a);
|
||||
|
||||
const peer = CultCache.builder().withDocumentType(a).build();
|
||||
await registry.applyRawSnapshotResponse(peer, registry.createRawSnapshotResponse(origin, "p1"));
|
||||
assert.deepEqual(peer.getRequired(a, "k"), { body: "written by a" });
|
||||
assert.equal(peer.getRequiredEnvelope(a, "k").schemaId, stamped);
|
||||
});
|
||||
|
||||
// An id nobody owns resolves to the one binding that lists it as compatible. Several listers and no owner resolve to
|
||||
// none, and the refusal does not depend on the order they were registered in. An owner beats every lister.
|
||||
test("CultNet registry refuses an unowned schema id that several bindings list, in any order", async () => {
|
||||
const shape = z.object({ body: z.string() });
|
||||
const first = defineDocumentType({ type: "p2.first", schemaId: "p2.first", compatibleSchemaIds: ["p2.old"], schema: shape });
|
||||
const second = defineDocumentType({ type: "p2.second", schemaId: "p2.second", compatibleSchemaIds: ["p2.old"], schema: shape });
|
||||
const owner = defineDocumentType({ type: "p2.owner", schemaId: "p2.old", schema: shape });
|
||||
const bind = (definition: typeof first) => defineCultNetDocumentBinding({ definition });
|
||||
|
||||
for (const order of [[first, second], [second, first]]) {
|
||||
const registry = new CultNetDocumentRegistry(order.map(bind));
|
||||
assert.throws(() => registry.getBySchemaId("p2.old"), (error: unknown) => {
|
||||
assert.ok(error instanceof SchemaConflictError, String(error));
|
||||
assert.equal(error.schemaId, "p2.old");
|
||||
assert.deepEqual(error.schemaNames, ["p2.first", "p2.second"]);
|
||||
assert.ok(error.message.includes("(p2.first, p2.second)"), error.message);
|
||||
return true;
|
||||
});
|
||||
const cache = CultCache.builder().withDocumentType(first).withDocumentType(second).build();
|
||||
const origin = CultCache.builder().withDocumentType(first).build();
|
||||
await origin.put(first, "k", { body: "old" });
|
||||
const document = { ...registry.createRawSnapshotResponse(origin, "p2").documents[0]!, schemaId: "p2.old" };
|
||||
await assert.rejects(
|
||||
registry.applyRawDocumentPutMessage(cache, { schemaVersion: "cultnet.document_put_raw.v0", messageId: "p2", document }),
|
||||
SchemaConflictError,
|
||||
);
|
||||
assert.deepEqual(cache.snapshot(), []);
|
||||
|
||||
for (const withOwner of [[...order, owner], [owner, ...order]]) {
|
||||
assert.equal(new CultNetDocumentRegistry(withOwner.map(bind)).getBySchemaId("p2.old")?.definition, owner);
|
||||
}
|
||||
}
|
||||
assert.equal(new CultNetDocumentRegistry([bind(first)]).getBySchemaId("p2.old")?.definition, first);
|
||||
});
|
||||
|
||||
// A schema id is only ever an id a definition declares. A version string, a schema name, or an id that differs from a
|
||||
// declared one by its version suffix names nothing.
|
||||
test("CultNet registry resolves only declared schema ids, never a version, a name or an inferred version", async () => {
|
||||
const note = defineDocumentType({
|
||||
type: "p3.note",
|
||||
schemaId: "p3.note.v1",
|
||||
schemaName: "p3.note",
|
||||
schemaVersion: "p4.note.v3",
|
||||
schema: z.object({ body: z.string() }),
|
||||
});
|
||||
const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]);
|
||||
const cache = CultCache.builder().withDocumentType(note).build();
|
||||
await cache.put(note, "k", { body: "held" });
|
||||
|
||||
assert.equal(registry.getBySchemaId("p3.note.v1")?.definition, note);
|
||||
for (const undeclared of ["p3.note.v7", "p4.note.v3", "p3.note"]) {
|
||||
assert.equal(registry.getBySchemaId(undeclared), undefined, undeclared);
|
||||
const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: [undeclared] };
|
||||
assert.deepEqual(registry.createSnapshotResponse(cache, "f", filter).documents, [], undeclared);
|
||||
assert.deepEqual(registry.createRawSnapshotResponse(cache, "f", filter).documents, [], undeclared);
|
||||
await assert.rejects(registry.applyDocumentPutMessage(cache, {
|
||||
schemaVersion: "cultnet.document_put.v0",
|
||||
messageId: "put",
|
||||
document: { schemaId: undeclared, recordKey: "k", storedAt: "2026-09-30T00:00:00.000Z", payload: { body: "relabelled" } },
|
||||
}), /No CultNet document binding/);
|
||||
await assert.rejects(registry.applyDocumentDeleteMessage(cache, {
|
||||
schemaVersion: "cultnet.document_delete.v0", messageId: "delete", schemaId: undeclared, recordKey: "k",
|
||||
}), /No CultNet document binding/);
|
||||
assert.deepEqual(cache.getRequired(note, "k"), { body: "held" });
|
||||
}
|
||||
const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: ["p3.note.v1"] };
|
||||
assert.equal(registry.createSnapshotResponse(cache, "f", filter).documents.length, 1);
|
||||
assert.equal(registry.createRawSnapshotResponse(cache, "f", filter).documents.length, 1);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
|
@ -1656,7 +1744,13 @@ test("CultNet document registry binds one type per schema and refuses a second,
|
|||
[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] })]);
|
||||
// Two bindings are registered ahead of the holder, so a conflict loop that checks only the first holder fails.
|
||||
const registry = new CultNetDocumentRegistry([
|
||||
...["tests.filler-a", "tests.filler-b"].map(type => defineCultNetDocumentBinding({
|
||||
definition: defineDocumentType({ type, schema: noteShape }),
|
||||
})),
|
||||
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;
|
||||
|
|
@ -1681,113 +1775,6 @@ test("CultNet document registry binds one type per schema and refuses a second,
|
|||
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-"));
|
||||
|
||||
try {
|
||||
const documentDefinition = defineDocumentType({
|
||||
type: "cultnet.alias_note",
|
||||
schemaId: "sha256:cultnet-alias-note",
|
||||
schemaName: "cultnet.alias_note",
|
||||
schemaVersion: "cultnet.alias_note.v1",
|
||||
schema: z.object({
|
||||
schema_version: z.string(),
|
||||
noteId: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
name: "noteId",
|
||||
});
|
||||
const registry = new CultNetDocumentRegistry([
|
||||
defineCultNetDocumentBinding({ definition: documentDefinition }),
|
||||
]);
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(documentDefinition)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "alias.msgpack")))
|
||||
.build();
|
||||
|
||||
await cache.put(documentDefinition, "note:alias", {
|
||||
schema_version: "cultnet.alias_note.v1",
|
||||
noteId: "note:alias",
|
||||
body: "filtered by alias",
|
||||
});
|
||||
|
||||
const typedSnapshot = registry.createSnapshotResponse(cache, "typed-alias", {
|
||||
schemaVersion: "cultnet.snapshot_request.v0",
|
||||
messageId: "typed-alias",
|
||||
schemaIds: ["cultnet.alias_note.v1"],
|
||||
});
|
||||
const rawSnapshot = registry.createRawSnapshotResponse(cache, "raw-alias", {
|
||||
schemaVersion: "cultnet.snapshot_request.v0",
|
||||
messageId: "raw-alias",
|
||||
schemaIds: ["cultnet.alias_note.v1"],
|
||||
});
|
||||
|
||||
assert.equal(typedSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note");
|
||||
assert.equal(typedSnapshot.documents[0]?.recordKey, "note:alias");
|
||||
assert.equal(rawSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note");
|
||||
assert.equal(rawSnapshot.documents[0]?.recordKey, "note:alias");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CultNet document registry applies typed put and delete messages by schema alias", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-typed-"));
|
||||
|
||||
try {
|
||||
const documentDefinition = defineDocumentType({
|
||||
type: "cultnet.typed_alias_note",
|
||||
schemaId: "sha256:cultnet-typed-alias-note",
|
||||
schemaName: "cultnet.typed_alias_note",
|
||||
schemaVersion: "cultnet.typed_alias_note.v1",
|
||||
schema: z.object({
|
||||
schema_version: z.string(),
|
||||
noteId: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
name: "noteId",
|
||||
});
|
||||
const registry = new CultNetDocumentRegistry([
|
||||
defineCultNetDocumentBinding({ definition: documentDefinition }),
|
||||
]);
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(documentDefinition)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "typed-alias.msgpack")))
|
||||
.build();
|
||||
|
||||
await registry.applyDocumentPutMessage(cache, {
|
||||
schemaVersion: "cultnet.document_put.v0",
|
||||
messageId: "typed-alias-put",
|
||||
document: {
|
||||
schemaId: "cultnet.typed_alias_note.v1",
|
||||
recordKey: "note:typed-alias",
|
||||
storedAt: "2026-06-27T00:00:00.000Z",
|
||||
payload: {
|
||||
schema_version: "cultnet.typed_alias_note.v1",
|
||||
noteId: "note:typed-alias",
|
||||
body: "typed alias applied",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
cache.getRequired(documentDefinition, "note:typed-alias").body,
|
||||
"typed alias applied",
|
||||
);
|
||||
assert.equal(registry.getBySchemaId("cultnet.typed_alias_note.v1")?.definition.type, documentDefinition.type);
|
||||
|
||||
assert.equal(await registry.applyDocumentDeleteMessage(cache, {
|
||||
schemaVersion: "cultnet.document_delete.v0",
|
||||
messageId: "typed-alias-delete",
|
||||
schemaId: "cultnet.typed_alias_note.v1",
|
||||
recordKey: "note:typed-alias",
|
||||
}), true);
|
||||
assert.equal(cache.get(documentDefinition, "note:typed-alias"), undefined);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => {
|
||||
const note: InteropNote = {
|
||||
schemaVersion: INTEROP_SCHEMA_VERSION,
|
||||
|
|
|
|||
Loading…
Reference in a new issue