TypeScript: a definition listing an id registers beside the one owning it; registration refuses typed
Owner beats lister, as in C# and Python. The cache kept one definition per id, own or compatible, and refused a second with a plain Error, so a v1 definition could not stay registered beside a v2 declaring v1's id (Soul F8, cult-cache.ts registration). Now a definition owns its schema id and any number may list it: a record resolves to the owner, else to the one lister. Several listers and no owner name no single definition, and the load is refused with SchemaConflictError naming the id, the record key and the listers. Every registration refusal is now SchemaConflictError (Soul F10): a second owner of one id, a second definition with one schema name, and a different definition for one type. Each names the new definition's id and both schema names. SchemaConflictError takes an optional message for these uses. Tests: an owner and a lister register in both orders and each reads its own records, and a lister alone reads the owner's id; two listers register in both orders, a record under their id is refused typed without its owner and is the owner's with it, registered before or after them; each of the three refusals is a SchemaConflictError in both registration orders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
84246b685a
commit
2204ce23e9
3 changed files with 124 additions and 17 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
import { SchemaConflictError } from "./store-format";
|
||||
import type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
|
|
@ -149,7 +150,9 @@ export class CultCache {
|
|||
}
|
||||
|
||||
readonly #definitions = new Map<string, RegisteredDefinition>();
|
||||
// The definition that owns each schema id, and every definition listing an id as compatible, in registration order.
|
||||
readonly #schemaIdDefinitions = new Map<string, RegisteredDefinition>();
|
||||
readonly #listingDefinitions = new Map<string, RegisteredDefinition[]>();
|
||||
readonly #schemaNameDefinitions = new Map<string, RegisteredDefinition>();
|
||||
#hydrated = emptyHydratedState();
|
||||
readonly #stores: CultCacheStoreRegistration[] = [];
|
||||
|
|
@ -525,9 +528,13 @@ export class CultCache {
|
|||
}
|
||||
|
||||
#registerNow(definition: AnyCultCacheDocumentDefinition): void {
|
||||
// Every registration refusal is a SchemaConflictError naming the new definition's schema id and both schema names.
|
||||
const refuse = (holder: RegisteredDefinition, claimed: CultCacheSchemaCatalogEntry, what: string) =>
|
||||
new SchemaConflictError(claimed.schemaId, [holder.catalogEntry.schemaName, claimed.schemaName], "",
|
||||
`CultCache ${what} is already registered for type "${holder.definition.type}" and cannot also be claimed by type "${definition.type}".`);
|
||||
const existing = this.#definitions.get(definition.type);
|
||||
if (existing && existing.definition !== definition) {
|
||||
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
|
||||
throw refuse(existing, this.#createCatalogEntry(definition), `type "${definition.type}"`);
|
||||
}
|
||||
|
||||
const registered: RegisteredDefinition = existing ?? {
|
||||
|
|
@ -547,25 +554,28 @@ export class CultCache {
|
|||
indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor));
|
||||
}
|
||||
|
||||
const schemaIds = registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId];
|
||||
for (const schemaId of schemaIds) {
|
||||
const existingBySchema = this.#schemaIdDefinitions.get(schemaId);
|
||||
if (existingBySchema && existingBySchema !== registered) {
|
||||
throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`);
|
||||
}
|
||||
// One definition owns each schema id. Others may list it as compatible beside its owner: a record under the id resolves to
|
||||
// its owner, and to a lister only when nothing owns it. Neither depends on the order definitions were registered in.
|
||||
const ownId = registered.catalogEntry.schemaId;
|
||||
const listed = (registered.catalogEntry.compatibleSchemaIds ?? []).filter((schemaId) => schemaId !== ownId);
|
||||
const owner = this.#schemaIdDefinitions.get(ownId);
|
||||
if (owner && owner !== registered) {
|
||||
throw refuse(owner, registered.catalogEntry, `schema id "${ownId}"`);
|
||||
}
|
||||
const existingBySchemaName = this.#schemaNameDefinitions.get(registered.catalogEntry.schemaName);
|
||||
if (existingBySchemaName && existingBySchemaName !== registered) {
|
||||
throw new Error(
|
||||
`CultCache schema name "${registered.catalogEntry.schemaName}" is already registered for type "${existingBySchemaName.definition.type}".`,
|
||||
);
|
||||
throw refuse(existingBySchemaName, registered.catalogEntry, `schema name "${registered.catalogEntry.schemaName}"`);
|
||||
}
|
||||
const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors);
|
||||
|
||||
// Every check and accessor has run; install.
|
||||
this.#definitions.set(definition.type, registered);
|
||||
for (const schemaId of schemaIds) {
|
||||
this.#schemaIdDefinitions.set(schemaId, registered);
|
||||
this.#schemaIdDefinitions.set(ownId, registered);
|
||||
for (const schemaId of listed) {
|
||||
const listers = this.#listingDefinitions.get(schemaId) ?? [];
|
||||
if (!listers.includes(registered)) {
|
||||
this.#listingDefinitions.set(schemaId, [...listers, registered]);
|
||||
}
|
||||
}
|
||||
this.#schemaNameDefinitions.set(registered.catalogEntry.schemaName, registered);
|
||||
this.#installAccessors(registered, nameAccessor, indexAccessors, derived);
|
||||
|
|
@ -808,7 +818,16 @@ export class CultCache {
|
|||
|
||||
#resolveDefinitionForEnvelope(entry: CultCacheEnvelope): RegisteredDefinition | undefined {
|
||||
if (entry.schemaId) {
|
||||
return this.#schemaIdDefinitions.get(entry.schemaId)
|
||||
const listers = this.#listingDefinitions.get(entry.schemaId) ?? [];
|
||||
const owner = this.#schemaIdDefinitions.get(entry.schemaId);
|
||||
if (!owner && listers.length > 1) {
|
||||
const types = listers.map((lister) => lister.definition.type).sort();
|
||||
throw new SchemaConflictError(entry.schemaId, listers.map((lister) => lister.catalogEntry.schemaName).sort(), entry.key,
|
||||
`CultCache schema id "${entry.schemaId}" of record "${entry.key}" is owned by no registered type and declared compatible by ` +
|
||||
`several (${types.join(", ")}), so it resolves to none. Register the type that owns the id, or declare it on one type.`);
|
||||
}
|
||||
return owner
|
||||
?? listers[0]
|
||||
?? this.#schemaNameDefinitions.get(entry.type)
|
||||
?? this.#definitions.get(entry.type);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,15 +35,16 @@ export function isStoreSnapshot(decoded: unknown): decoded is [StoreFormat, ...u
|
|||
/**
|
||||
* A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id and
|
||||
* disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names involved and
|
||||
* a record key.
|
||||
* a record key. A cache throws it too, with an empty record key, when a definition cannot register beside one it already holds,
|
||||
* and when a record's schema id names no single registered definition.
|
||||
*/
|
||||
export class SchemaConflictError extends Error {
|
||||
readonly schemaId: string;
|
||||
readonly schemaNames: string[];
|
||||
readonly recordKey: string;
|
||||
|
||||
constructor(schemaId: string, schemaNames: string[], recordKey: string) {
|
||||
super(`Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`);
|
||||
constructor(schemaId: string, schemaNames: string[], recordKey: string, message?: string) {
|
||||
super(message ?? `Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`);
|
||||
this.name = "SchemaConflictError";
|
||||
this.schemaId = schemaId;
|
||||
this.schemaNames = schemaNames;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { inspectCultCacheBytes } from "../src/cult-cache-inspector";
|
|||
import { defineDocumentRegistry, defineDocumentType } from "../src/document";
|
||||
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
|
||||
import { SchemaConflictError, StoreUnreadableError } from "../src/store-format";
|
||||
import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
|
||||
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
|
|
@ -2095,3 +2095,90 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Owner beats lister: a definition that owns an id and one that lists it as compatible register together in either order. A record
|
||||
// under the id is the owner's, and a lister's only when nothing owns it; two listers and no owner name no single definition.
|
||||
const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" });
|
||||
const listsId = defineDocumentType({
|
||||
type: "tests.lists",
|
||||
schema: z.object({ name: z.string() }),
|
||||
schemaId: "id.lister",
|
||||
schemaName: "tests.lists",
|
||||
compatibleSchemaIds: ["id.owned"],
|
||||
});
|
||||
const alsoListsId = defineDocumentType({
|
||||
type: "tests.also-lists",
|
||||
schema: z.object({ name: z.string() }),
|
||||
schemaId: "id.also-lister",
|
||||
schemaName: "tests.also-lists",
|
||||
compatibleSchemaIds: ["id.owned"],
|
||||
});
|
||||
|
||||
async function storeUnder(records: [string, string][]): Promise<string> {
|
||||
const seed = CultCache.builder().withDocumentType(ownsId).build();
|
||||
await seed.put(ownsId, "seed", { name: "n" });
|
||||
const payload = seed.getRequiredEnvelope(ownsId, "seed").payload;
|
||||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-owner-lister-")), "store.msgpack");
|
||||
const ids = [...new Set(records.map(([, schemaId]) => schemaId))];
|
||||
await writeFile(file, encode(["cultcache.store.v1",
|
||||
ids.map((schemaId) => [schemaId, schemaId, `${schemaId}.v1`, schemaId, "", [schemaId], []]),
|
||||
records.map(([key, schemaId]) => [key, schemaId, "2026-09-30T00:00:00.0000000Z", payload])]));
|
||||
return file;
|
||||
}
|
||||
|
||||
async function openWith(file: string, ...definitions: AnyCultCacheDocumentDefinition[]): Promise<CultCache> {
|
||||
let builder = CultCache.builder();
|
||||
for (const definition of definitions) builder = builder.withDocumentType(definition);
|
||||
const cache = builder.withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
await cache.pullAllBackingStores();
|
||||
return cache;
|
||||
}
|
||||
|
||||
test("a definition listing an id registers beside the one owning it in either order, and the owner wins", async () => {
|
||||
const file = await storeUnder([["o", "id.owned"], ["l", "id.lister"]]);
|
||||
for (const order of [[ownsId, listsId], [listsId, ownsId]]) {
|
||||
const cache = await openWith(file, ...order);
|
||||
assert.deepEqual(cache.getRequired(ownsId, "o"), { name: "n" });
|
||||
assert.equal(cache.get(listsId, "o"), undefined);
|
||||
assert.deepEqual(cache.getRequired(listsId, "l"), { name: "n" });
|
||||
}
|
||||
const listerOnly = await openWith(await storeUnder([["o", "id.owned"]]), listsId);
|
||||
assert.deepEqual(listerOnly.getRequired(listsId, "o"), { name: "n" });
|
||||
});
|
||||
|
||||
test("two definitions listing one id register, and a record under it needs its owner", async () => {
|
||||
const file = await storeUnder([["k", "id.owned"]]);
|
||||
for (const listers of [[listsId, alsoListsId], [alsoListsId, listsId]]) {
|
||||
await assert.rejects(openWith(file, ...listers), (error: unknown) => {
|
||||
assert.ok(error instanceof SchemaConflictError);
|
||||
assert.equal(error.schemaId, "id.owned");
|
||||
assert.equal(error.recordKey, "k");
|
||||
assert.deepEqual(error.schemaNames, ["tests.also-lists", "tests.lists"]);
|
||||
return true;
|
||||
});
|
||||
for (const order of [[...listers, ownsId], [ownsId, ...listers]]) {
|
||||
assert.deepEqual((await openWith(file, ...order)).getRequired(ownsId, "k"), { name: "n" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every registration refusal is a SchemaConflictError naming the id and both schema names", () => {
|
||||
const sameId = defineDocumentType({ type: "tests.owns-too", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns-too" });
|
||||
const sameName = defineDocumentType({ type: "tests.named-too", schema: z.object({ name: z.string() }), schemaId: "id.other", schemaName: "tests.owns" });
|
||||
const sameType = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.third", schemaName: "tests.third" });
|
||||
for (const [claimant, schemaId, names] of [
|
||||
[sameId, "id.owned", ["tests.owns", "tests.owns-too"]],
|
||||
[sameName, "id.other", ["tests.owns", "tests.owns"]],
|
||||
[sameType, "id.third", ["tests.owns", "tests.third"]],
|
||||
] as const) {
|
||||
for (const order of [[ownsId, claimant], [claimant, ownsId]]) {
|
||||
assert.throws(() => CultCache.builder().withDocumentType(order[0]).withDocumentType(order[1]).build(), (error: unknown) => {
|
||||
assert.ok(error instanceof SchemaConflictError, String(error));
|
||||
const first = order[0] === ownsId;
|
||||
assert.equal(error.schemaId, first ? schemaId : ownsId.schemaId);
|
||||
assert.deepEqual(error.schemaNames, first ? names : [...names].reverse());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue