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:
parent
92a6389dd1
commit
5aa164a8b4
3 changed files with 114 additions and 33 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue