From 5aa164a8b4e949cb9e736ad92f8caca8df5b0e9f Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 03:32:57 +0000 Subject: [PATCH] cultcache-ts: one judge for write, removal and isWritable; README states the consent case #judge (foreign key, global key, retype and its home, unread slots) is thrown by #writeNow and #deleteNow and asked by isWritable, so the query cannot disagree with put. A matrix test pins isWritable against put per key state. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/README.md | 4 +- packages/cultcache-ts/src/cult-cache.ts | 74 +++++++++++-------- .../cultcache-ts/test/slot-readonly.test.ts | 69 ++++++++++++++++- 3 files changed, 114 insertions(+), 33 deletions(-) diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index cffd352b..6fea306a 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -373,7 +373,9 @@ const sceneDocument = defineDocumentType({ - A slot is `[name, codec, default?]`. A gap is written nil and skipped on read; an extra trailing slot is read past and marks the record read-only (every write or removal of its key is refused with `SchemaConflictError`, `cache.isWritable` - asks first); a slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one. + asks first). The one writable case is a depth-0 extra slot of a record that arrived under a schema id other than the reader's own, which the + reader lists in `compatibleSchemaIds`, whose catalog entry lists those slots: declaring that id consents to the loss, and a write restamps + the record. A nested slot, or a writer that keeps the reader's own id, is never consented to. A slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one. - A union refuses an undeclared arm key on read, naming the union, the key and the declared keys. Nothing falls back to a default arm. An arm with no slots is `codecs.unit` and is written `[key, []]`. - A refusal names the union, the field and the slot; it never echoes the value it refused. diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 3f15d378..801fb037 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -538,7 +538,6 @@ export class CultCache { if (!envelope.storedAt || envelope.storedAt.trim().length === 0) { throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`); } - this.#requireGlobalKey(registered, envelope.key); const payload = this.#cloneBytes(envelope.payload); const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue; unreadSlots?: NotedSlots }; @@ -611,24 +610,22 @@ export class CultCache { } /** - * Whether a removal of the key, or a write of the record's own type, would be let through: the write's own check, asked of - * the record the cache holds (false when a store carries the key foreign, or when the record carries unread slots nothing - * consents to). Live and advisory: the write still decides. + * Whether a removal of the key, or a write of the record's own type, would be let through: the write's own check: it asks the one judge #writeNow asks (a key a store carries foreign, a record of another type the + * write does not own or cannot replace, a global at another key, a record carrying unread slots nothing consents to). Live and + * advisory: the write still decides. */ isWritable(definition: AnyCultCacheDocumentDefinition, key: string): boolean { const registered = this.#requireDefinition(definition); - const type = registered.definition.type; - if (this.#hydrated.foreign.some((record) => record.key === key) - || this.#unreadRefusal(this.#hydrated.entries.get(this.#entryId(type, key))) !== undefined) { - return false; - } - // The record of another type a write would restamp (or be refused by): the write's own questions, writeNow's #refuseRetype. - const other = this.#heldUnderOtherType(this.#hydrated, type, key); const written: CultCacheEnvelope = { - key, type, payload: documentPayloadFromBytes(new Uint8Array(0)), storedAt: "", + key, type: registered.definition.type, payload: documentPayloadFromBytes(new Uint8Array(0)), storedAt: "", schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, }; - return other === undefined || (writtenOwns(written, schemaIdForEnvelope(other)) && this.#unreadRefusal(other) === undefined); + try { + this.#judge(registered, key, written); + return true; + } catch { + return false; + } } // What the last pull found that no registered type claims, by key ordinal. A later registration claims a record at the next pull. @@ -726,7 +723,6 @@ export class CultCache { value: CultCacheDocumentValue, ): Promise> { const registered = this.#requireDefinition(definition); - this.#requireGlobalKey(registered, key); const payload = registered.formatter.encode(value); const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue; unreadSlots?: NotedSlots }; const entry: CultCacheEnvelope = { @@ -745,19 +741,7 @@ export class CultCache { // is touched; once the store accepts, applying to the cache has no fallible step. async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, unreadSlots?: NotedSlots): Promise { const home = this.#homeStore(entry.type); - const retyped = this.#refuseRetype(entry); - this.#refuseUnread(this.#hydrated.entries.get(this.#entryId(entry.type, entry.key))); - this.#refuseUnread(retyped); - if (retyped !== undefined && this.#homeStore(retyped.type) !== home) { - // The write owns the held record's id and would replace it, but the held record is in another store; the push - // reaches only this type's home, so the old record would stay in its own file and load as a second holder. - throw new SchemaConflictError( - schemaIdForEnvelope(entry), - [retyped.type, entry.type], - entry.key, - `CultCache holds record "${entry.key}" as type "${retyped.type}" in another store than the home of type "${entry.type}", so the rename cannot replace it; remove the record first.`, - ); - } + const retyped = this.#judge(registered, entry.key, entry); const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value, unreadSlots); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); @@ -773,6 +757,35 @@ export class CultCache { this.#installEntry(this.#hydrated, registered, hydrated); } + // The one judge of whether a key may be written (a written envelope) or removed (none), before any store is touched: it throws the + // refusal, and #writeNow, #deleteNow and isWritable all ask it, so the query cannot disagree with the write. For a write it + // returns the held record of another type that the write owns and so restamps, if any. A refusal names keys and schemas, + // never a payload or a slot value. + #judge(registered: RegisteredDefinition, key: string, written?: CultCacheEnvelope): HydratedEntry | undefined { + const type = registered.definition.type; + const held = this.#hydrated.entries.get(this.#entryId(type, key)); + if (written === undefined) { + this.#refuseForeignKey(key, type, "removed"); + this.#refuseUnread(held); + return undefined; + } + this.#requireGlobalKey(registered, key); + const retyped = this.#refuseRetype(written); + this.#refuseUnread(held); + this.#refuseUnread(retyped); + if (retyped !== undefined && this.#homeStore(retyped.type) !== this.#homeStore(type)) { + // The write owns the held record's id and would replace it, but the held record is in another store; the push + // reaches only this type's home, so the old record would stay in its own file and load as a second holder. + throw new SchemaConflictError( + schemaIdForEnvelope(written), + [retyped.type, type], + key, + `CultCache holds record "${key}" as type "${retyped.type}" in another store than the home of type "${type}", so the rename cannot replace it; remove the record first.`, + ); + } + return retyped; + } + // The one refusal of a record held read-only for the slots its payload carries past this build's: a write or a removal of // its key. It names the type and the counts, never a slot value. A mark that is explained (see #admitEntry) is consented to. #unreadRefusal(held: HydratedEntry | undefined): SchemaConflictError | undefined { @@ -854,14 +867,13 @@ export class CultCache { key: string, ): Promise { const registered = this.#requireDefinition(definition); + // variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held under + // another type or by nobody is the existing false. + this.#judge(registered, key); const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); if (!entry) { - // variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held under - // another type or by nobody is the existing false. - this.#refuseForeignKey(key, definition.type, "removed"); return false; } - this.#refuseUnread(entry); const home = this.#homeStore(definition.type); await home?.delete(this.#hydrated.legacyGlobals.get(definition.type) ?? this.#toEnvelope(entry)); diff --git a/packages/cultcache-ts/test/slot-readonly.test.ts b/packages/cultcache-ts/test/slot-readonly.test.ts index 364adc67..53b1b134 100644 --- a/packages/cultcache-ts/test/slot-readonly.test.ts +++ b/packages/cultcache-ts/test/slot-readonly.test.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import { codecs } from "../src"; import { CultCache } from "../src/cult-cache"; -import { defineDocumentType, documentPayloadBytes } from "../src/document"; +import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; import type { CultCacheUnreadSlots } from "../src/types"; @@ -292,3 +292,70 @@ test("a record that arrived under an id the reader does not list compatible is n assert.equal(cache.unreadSlots(reader, "consented")?.explained, false); assert.equal(cache.isWritable(reader, "consented"), false); }); + +// isWritable is the write's own judge, not a second one: for every key state it answers what put does. +test("isWritable equals the outcome of put for every key state the write judges", async () => { + const typed = (type: string, schemaId: string, extra: Record = {}) => + defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: surfaceSchema, codec: surfaceCodec, name: "id", ...extra }); + const older = typed("tests.surface_older", "tests.surface_older.v1"); + const newer = typed("tests.surface_newer", "tests.surface_newer.v1", { compatibleSchemaIds: ["tests.surface_older.v1"] }); + const stranger = typed("tests.surface_stranger", "tests.surface_stranger.v1"); + const global = typed("tests.surface_global", "tests.surface_global.v1", { global: true }); + const clean = (key: string, type: string, schemaId: string) => ({ ...envelopeOf(new Uint8Array()), key, type, schemaId }); + const held = async (key: string, file: string, type: string, schemaId: string) => + ({ ...envelopeOf(await read(file)), key, type, schemaId }); + + type Row = { name: string; build: () => Promise<{ cache: CultCache; target: typeof older; key: string }> }; + const rows: Row[] = [ + { name: "owned clean record in the writer's own store", build: async () => { + const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer) + .withGenericStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)])).build(); + return { cache, target: newer, key: "k" }; + } }, + { name: "owned record held in a store other than the writer's home", build: async () => { + const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer) + .withBackingStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)]), older) + .withBackingStore(memoryStore([]), newer).build(); + return { cache, target: newer, key: "k" }; + } }, + { name: "a record of a type that does not own the writer's id", build: async () => { + const cache = CultCache.builder().withDocumentType(older).withDocumentType(stranger) + .withGenericStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)])).build(); + return { cache, target: stranger, key: "k" }; + } }, + { name: "a global type at a key other than the global key", build: async () => { + const cache = CultCache.builder().withDocumentType(global).withGenericStore(memoryStore([])).build(); + return { cache, target: global, key: "x" }; + } }, + { name: "a key a store carries foreign", build: async () => { + const cache = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([{ ...clean("k", "tests.unknown", "tests.unknown.v1"), payload: documentPayloadFromBytes(await read("union-surface.msgpack")) }])).build(); + return { cache, target: older, key: "k" }; + } }, + { name: "a marked record of the writer's own type", build: async () => { + const cache = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([await held("k", "extra-slot-record.msgpack", older.type, older.schemaId!)])).build(); + return { cache, target: older, key: "k" }; + } }, + { name: "an unheld key", build: async () => ({ cache: CultCache.builder().withDocumentType(older).withGenericStore(memoryStore([])).build(), target: older, key: "k" }) }, + ]; + const outcomes: [string, boolean, boolean][] = []; + for (const row of rows) { + const { cache, target, key } = await row.build(); + await cache.pullAllBackingStores(); + const predicted = cache.isWritable(target, key); + const landed = await cache.put(target, key === "x" ? "x" : key, golden).then(() => true, () => false); + assert.equal(predicted, landed, row.name); + outcomes.push([row.name, predicted, landed]); + } + assert.deepEqual(outcomes.map(([, writable]) => writable), [true, false, false, false, false, false, true]); + // The same judge refuses a removal of a key held read-only or carried foreign. + const removals = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([await held("k", "extra-slot-record.msgpack", older.type, older.schemaId!)])).build(); + await removals.pullAllBackingStores(); + await assert.rejects(() => removals.delete(older, "k"), SchemaConflictError); + const stranger2 = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([{ ...envelopeOf(await read("union-surface.msgpack")), key: "f", type: "tests.unknown", schemaId: "tests.unknown.v1" }])).build(); + await stranger2.pullAllBackingStores(); + await assert.rejects(() => stranger2.delete(older, "f"), SchemaConflictError); +});