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 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-12 03:32:57 +00:00
commit 5aa164a8b4
3 changed files with 114 additions and 33 deletions

View file

@ -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.

View file

@ -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<TDefinition>; 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<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
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<TDefinition>; 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<void> {
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<boolean> {
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));

View file

@ -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<string, unknown> = {}) =>
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);
});