cultcache-ts: slot record and union codecs, held to the union-parity golden

Unverified first pass; not yet compiled or run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 03:50:23 +02:00
commit 499887484c
7 changed files with 622 additions and 3 deletions

View file

@ -23,7 +23,7 @@
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js"
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js dist-test/test/union-parity.test.js"
},
"keywords": [
"messagepack",

View file

@ -0,0 +1,229 @@
import { decode, encode } from "@msgpack/msgpack";
import type { CultCacheDocumentFormatter, CultCacheSchemaCatalogMember } from "./types";
// A codec turns a value into the MessagePack-ready shape the reference runtimes write and back. Slot records are
// positional arrays with nil gaps; a union value is { kind: armName, ...armFields }, written [armKey, [armSlots...]].
// Refusals name the union or the slot and the key or code, and never echo a decoded value.
export interface Codec<TValue> {
encode(value: TValue): unknown;
decode(raw: unknown): TValue;
}
export interface SlotsCodec<TValue extends object> extends Codec<TValue> {
readonly members: readonly CultCacheSchemaCatalogMember[];
readonly names: readonly string[];
}
function expect(raw: unknown, what: string, ok: boolean): asserts ok {
if (!ok) {
throw new Error(`expected ${what}, found ${raw === null ? "nil" : Array.isArray(raw) ? "array" : typeof raw}`);
}
}
export const string: Codec<string> = {
encode: (value) => (expect(value, "a string", typeof value === "string"), value),
decode: (raw) => (expect(raw, "a string", typeof raw === "string"), raw as string),
};
export const number: Codec<number> = {
encode: (value) => (expect(value, "a number", typeof value === "number" && Number.isFinite(value)), value),
decode: (raw) => (expect(raw, "a number", typeof raw === "number" && Number.isFinite(raw)), raw as number),
};
export const boolean: Codec<boolean> = {
encode: (value) => (expect(value, "a boolean", typeof value === "boolean"), value),
decode: (raw) => (expect(raw, "a boolean", typeof raw === "boolean"), raw as boolean),
};
export const bytes: Codec<Uint8Array> = {
encode: (value) => (expect(value, "bytes", value instanceof Uint8Array), value),
decode: (raw) => (expect(raw, "bytes", raw instanceof Uint8Array), raw as Uint8Array),
};
export function list<TItem>(item: Codec<TItem>): Codec<TItem[]> {
return {
encode(value) {
expect(value, "an array", Array.isArray(value));
return value.map((element, index) => at(`element ${index}`, () => item.encode(element)));
},
decode(raw) {
expect(raw, "an array", Array.isArray(raw));
return (raw as unknown[]).map((element, index) => at(`element ${index}`, () => item.decode(element)));
},
};
}
export function nullable<TValue>(inner: Codec<TValue>): Codec<TValue | null> {
return {
encode: (value) => (value === null || value === undefined ? null : inner.encode(value)),
decode: (raw) => (raw === null ? null : inner.decode(raw)),
};
}
export function lazy<TValue>(get: () => Codec<TValue>): Codec<TValue> {
let resolved: Codec<TValue> | undefined;
const codec = () => (resolved ??= get());
return {
encode: (value) => codec().encode(value),
decode: (raw) => codec().decode(raw),
};
}
// Prefixes a refusal with where it happened, so a nested refusal still names its field and its union.
function at<TResult>(where: string, run: () => TResult): TResult {
try {
return run();
} catch (error) {
throw new Error(`${where}: ${(error as Error).message}`);
}
}
type SlotSpec<TValue, TName extends string = string> =
| readonly [name: TName, codec: Codec<TValue>]
| readonly [name: TName, codec: Codec<TValue>, defaultValue: TValue | (() => TValue)];
type SlotsSpec<TValue> = Readonly<Record<number, { [K in keyof TValue & string]: SlotSpec<TValue[K], K> }[keyof TValue & string]>>;
function slotKeys(spec: Readonly<Record<string, unknown>>, owner: string): number[] {
const keys = Object.keys(spec).map((text) => {
const key = Number(text);
if (!Number.isInteger(key) || key < 0 || String(key) !== text) {
throw new Error(`${owner} declares the key "${text}", which is not a non-negative integer.`);
}
return key;
});
return keys.sort((left, right) => left - right);
}
function pascalCase(name: string): string {
return name
.split(/[^A-Za-z0-9]+/u)
.filter((part) => part.length > 0)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join("");
}
// A positional record: slot `key` holds field `name`. Gaps are written nil and skipped on read, extra trailing slots
// are skipped, and a slot missing from the payload takes its declared default or is refused. A nil in a slot that
// declares a default also takes the default (cultcache-py's rule).
export function slots<TValue extends object>(spec: SlotsSpec<TValue>): SlotsCodec<TValue> {
const keys = slotKeys(spec, "A slot record");
if (keys.length === 0) {
throw new Error("A slot record declares no slots.");
}
const fields = keys.map((key) => {
const [name, codec, ...rest] = (spec as unknown as Readonly<Record<number, SlotSpec<unknown>>>)[key];
return { key, name, codec, hasDefault: rest.length > 0, defaultValue: rest[0] };
});
const seen = new Set<string>();
for (const field of fields) {
if (seen.has(field.name)) {
throw new Error(`A slot record declares the field "${field.name}" twice.`);
}
seen.add(field.name);
}
const length = keys[keys.length - 1] + 1;
const defaultOf = (field: (typeof fields)[number]): unknown =>
typeof field.defaultValue === "function" ? (field.defaultValue as () => unknown)() : field.defaultValue;
return {
names: fields.map((field) => field.name),
members: fields.map((field) => ({ slot: field.key, memberName: pascalCase(field.name), typeName: "System.Object" })),
encode(value) {
expect(value, "a record", typeof value === "object" && value !== null);
const out: unknown[] = new Array(length).fill(null);
for (const field of fields) {
out[field.key] = at(`${field.name} (slot ${field.key})`, () =>
field.codec.encode((value as Record<string, unknown>)[field.name]));
}
return out;
},
decode(raw) {
expect(raw, "an array of slots", Array.isArray(raw));
const input = raw as unknown[];
const out: Record<string, unknown> = {};
for (const field of fields) {
const present = field.key < input.length && !(input[field.key] === null && field.hasDefault);
if (!present && !field.hasDefault) {
throw new Error(`${field.name} (slot ${field.key}): the slot is missing and declares no default.`);
}
out[field.name] = present
? at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key]))
: defaultOf(field);
}
return out as TValue;
},
};
}
// An arm with no slots. It is written [key, []], never [key, nil].
export const unit = Symbol("cultcache.unit-arm");
type ArmSpec<TKind extends string> = readonly [armName: TKind, body: SlotsCodec<any> | typeof unit];
// A tagged union written [armKey, [armSlots...]]. The reader refuses an undeclared key, naming the union, the key and
// the declared keys; there is no fallback arm.
export function union<TValue extends { kind: string }>(
name: string,
arms: Readonly<Record<number, ArmSpec<TValue["kind"]>>>,
): Codec<TValue | null> {
const keys = slotKeys(arms, `Union ${name}`);
if (keys.length === 0) {
throw new Error(`Union ${name} declares no arms.`);
}
const byKey = new Map<number, { armName: string; body: SlotsCodec<any> | typeof unit }>();
const byName = new Map<string, number>();
for (const key of keys) {
const [armName, body] = arms[key];
if (byName.has(armName)) {
throw new Error(`Union ${name} declares the arm "${armName}" twice.`);
}
if (body !== unit && body.names.includes("kind")) {
throw new Error(`Union ${name} arm "${armName}" declares a slot named kind, which is the union's own tag.`);
}
byKey.set(key, { armName, body });
byName.set(armName, key);
}
const declared = keys.join(", ");
return {
encode(value) {
if (value === null || value === undefined) {
return null;
}
const key = byName.get((value as { kind?: string }).kind as string);
if (key === undefined) {
throw new Error(`Union ${name}: the value's kind is not one of its arms (${[...byName.keys()].join(", ")}).`);
}
const { armName, body } = byKey.get(key)!;
return [key, body === unit ? [] : at(`arm ${armName}`, () => body.encode(value))];
},
decode(raw) {
if (raw === null) {
return null;
}
if (!Array.isArray(raw) || raw.length !== 2 || !Array.isArray(raw[1])) {
throw new Error(`Union ${name}: expected [key, slots] and found something else.`);
}
const key: unknown = raw[0];
if (typeof key !== "number" || !Number.isInteger(key)) {
throw new Error(`Union ${name}: the arm key is not an integer (declared keys: ${declared}).`);
}
const arm = byKey.get(key);
if (arm === undefined) {
throw new Error(`Union ${name}: unknown arm key ${key} (declared keys: ${declared}).`);
}
const fields = arm.body === unit ? {} : at(`arm ${arm.armName}`, () => (arm.body as SlotsCodec<any>).decode(raw[1]));
return { kind: arm.armName, ...fields } as unknown as TValue;
},
};
}
// The formatter a document gets from a slot codec: the codec's value as one MessagePack payload.
export function codecFormatter<TValue extends object>(codec: SlotsCodec<TValue>): CultCacheDocumentFormatter<TValue> {
return {
encode: (value) => encode(codec.encode(value)),
decode: (payload) => codec.decode(decode(payload)),
};
}

View file

@ -1,5 +1,6 @@
import { decode, encode } from "@msgpack/msgpack";
import { codecFormatter } from "./codec";
import type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
@ -676,10 +677,10 @@ export class CultCache {
}
#createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter<unknown> {
const userFormatter = definition.formatter ?? {
const userFormatter = definition.formatter ?? (definition.codec ? codecFormatter(definition.codec) : {
encode: (value: unknown) => encode(value),
decode: (payload: Uint8Array) => decode(payload),
};
});
return {
encode: (value: unknown) => {

View file

@ -66,6 +66,23 @@ export function defineDocumentType<TSchema extends CultCacheSchema>(
}
}
if (definition.codec) {
const codec = definition.codec;
if (definition.formatter) {
throw new Error(`CultCache document type "${definition.type}" declares both a codec and a formatter.`);
}
const indexAlias = (slotName: string) =>
!Array.isArray(definition.indexes) && definition.indexes
? Object.entries(definition.indexes).find(([, accessor]) => accessor === slotName)?.[0] ?? null
: null;
const members = definition.members ?? codec.members.map((member, index) => ({
...member,
isName: definition.name === codec.names[index],
indexAlias: indexAlias(codec.names[index]),
}));
return Object.freeze({ ...definition, members });
}
return Object.freeze({ ...definition });
}

View file

@ -1,3 +1,5 @@
export * as codecs from "./codec";
export type { Codec, SlotsCodec } from "./codec";
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector";
export { defineDocumentRegistry, defineDocumentType } from "./document";

View file

@ -1,3 +1,5 @@
import type { SlotsCodec } from "./codec";
export type CultCacheIndexScalar = string | number | boolean | bigint;
export interface CultCacheSchema<TValue = unknown> {
@ -60,6 +62,7 @@ export interface CultCacheDocumentDefinition<TSchema extends CultCacheSchema = C
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>;
codec?: SlotsCodec<InferCultCacheSchemaValue<TSchema> & object>;
}
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = InferCultCacheSchemaValue<

View file

@ -0,0 +1,367 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import { codecs } from "../src";
import { CultCache } from "../src/cult-cache";
import { defineDocumentType } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
import type { CacheBackingStore, CultCacheEnvelope } from "../src/types";
const { boolean, bytes, lazy, list, nullable, number, slots, string, union, unit } = codecs;
// The golden is tests/vectors/union-parity, written by the C# reference. Everything below that is bytes or an expected
// outcome is read from those committed files; only the value the payload must decode to is spelled here.
const vectors = resolve(__dirname, "../../../../tests/vectors/union-parity");
const read = async (name: string) => new Uint8Array(await readFile(join(vectors, name)));
// The string the C# writer put inside every refusal payload, to prove a refusal never echoes the payload.
const CANARY = "CANARY-union-golden-4c2a";
type Node =
| { kind: "Box"; id: string; children: (Node | null)[] }
| { kind: "Text"; id: string; content: string }
| { kind: "Use"; id: string; composition: Composition; slots: (Node | null)[] }
| { kind: "Image"; id: string; uri: string; alt: string };
interface Composition { name: string; body: Node | null }
type Outcome = { kind: "Pending"; message: string } | { kind: "Completed"; sourceVersion: number } | { kind: "Denied" };
type Item = { kind: "Gem"; name: string } | { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
type CraftedItem = { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
interface Surface {
id: string;
version: number;
root: Node | null;
outcomes: (Outcome | null)[];
part: Item | null;
crafted: CraftedItem | null;
missing: Node | null;
}
const composition = slots<Composition>({ 0: ["name", string], 1: ["body", lazy(() => node)] });
const node = union<Node>("Node", {
0: ["Box", slots<{ id: string; children: (Node | null)[] }>({ 0: ["id", string], 1: ["children", list(lazy(() => node))] })],
1: ["Text", slots<{ id: string; content: string }>({ 0: ["id", string], 1: ["content", string] })],
2: ["Use", slots<{ id: string; composition: Composition; slots: (Node | null)[] }>({
0: ["id", string], 1: ["composition", composition], 2: ["slots", list(lazy(() => node))],
})],
5: ["Image", slots<{ id: string; uri: string; alt: string }>({ 0: ["id", string], 1: ["uri", string], 3: ["alt", string] })],
});
const outcome = union<Outcome>("Outcome", {
0: ["Pending", slots<{ message: string }>({ 0: ["message", string] })],
1: ["Completed", slots<{ sourceVersion: number }>({ 0: ["sourceVersion", number] })],
2: ["Denied", unit],
});
const toolArm = slots<{ name: string; uses: number }>({ 0: ["name", string], 1: ["uses", number] });
const partArm = slots<{ name: string; count: number }>({ 0: ["name", string], 1: ["count", number] });
const item = union<Item>("Item", {
0: ["Gem", slots<{ name: string }>({ 0: ["name", string] })],
1: ["Tool", toolArm],
2: ["Part", partArm],
});
const craftedItem = union<CraftedItem>("CraftedItem", { 0: ["Tool", toolArm], 1: ["Part", partArm] });
const surfaceCodec = slots<Surface>({
0: ["id", string],
1: ["version", number],
2: ["root", node],
3: ["outcomes", list(outcome)],
4: ["part", item],
5: ["crafted", craftedItem],
6: ["missing", node, null],
});
const nodeSchema: z.ZodType<Node | null> = z.lazy(() =>
z.union([
z.null(),
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Box"), id: z.string(), children: z.array(nodeSchema) }),
z.object({ kind: z.literal("Text"), id: z.string(), content: z.string() }),
z.object({
kind: z.literal("Use"),
id: z.string(),
composition: z.object({ name: z.string(), body: nodeSchema }),
slots: z.array(nodeSchema),
}),
z.object({ kind: z.literal("Image"), id: z.string(), uri: z.string(), alt: z.string() }),
]),
]));
const surfaceSchema = z.object({
id: z.string(),
version: z.number(),
root: nodeSchema,
outcomes: z.array(z.union([
z.null(),
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Pending"), message: z.string() }),
z.object({ kind: z.literal("Completed"), sourceVersion: z.number() }),
z.object({ kind: z.literal("Denied") }),
]),
])),
part: z.union([z.null(), z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Gem"), name: z.string() }),
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
])]),
crafted: z.union([z.null(), z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
])]),
missing: nodeSchema,
}) as unknown as z.ZodType<Surface>;
function surfaceDocument(schemaId?: string) {
return defineDocumentType({
type: "tests.union_surface",
schemaId: schemaId ?? "tests.union_surface.v1",
schemaName: "tests.union_surface",
schemaVersion: "tests.union_surface.v1",
schema: surfaceSchema,
codec: surfaceCodec,
name: "id",
});
}
const golden: Surface = {
id: "surface",
version: 3,
root: {
kind: "Box",
id: "root",
children: [
{ kind: "Text", id: "t1", content: "hello" },
{
kind: "Use",
id: "u1",
composition: {
name: "comp",
body: {
kind: "Box",
id: "inner",
children: [
{ kind: "Text", id: "deep", content: "x" },
{ kind: "Image", id: "img", uri: "uri://a", alt: "alt" },
],
},
},
slots: [{ kind: "Text", id: "s1", content: "slot" }, null, { kind: "Box", id: "b2", children: [] }],
},
{ kind: "Image", id: "i2", uri: "uri://b", alt: "" },
],
},
outcomes: [{ kind: "Pending", message: "wait" }, { kind: "Completed", sourceVersion: 7 }, { kind: "Denied" }],
part: { kind: "Tool", name: "hammer", uses: 12 },
crafted: { kind: "Part", name: "gear", count: 4 },
missing: null,
};
const hex = (data: Uint8Array) => Buffer.from(data).toString("hex");
async function outcomeEntries() {
const entries = decode(await read("outcomes.msgpack")) as [string, string, string, number | null, string][];
return entries.map(([file, outcome, unionName, key, detail]) => ({ file, outcome, unionName, key, detail }));
}
test("the union surface vector decodes to the constructed value and re-encodes byte-identical", async () => {
const committed = await read("union-surface.msgpack");
const decoded = surfaceCodec.decode(decode(committed));
assert.deepEqual(decoded, golden);
assert.equal(hex(encode(surfaceCodec.encode(decoded))), hex(committed));
assert.equal(hex(encode(surfaceCodec.encode(golden))), hex(committed));
});
test("a unit arm is written [key, []] and its payload must be an array", () => {
assert.deepEqual(outcome.encode({ kind: "Denied" }), [2, []]);
assert.throws(() => outcome.decode([2, null]), /Union Outcome/u);
});
test("union-surface.cc reads through SingleFileMessagePackBackingStore and writes the same payload", async () => {
const dir = await mkdtemp(join(tmpdir(), "union-parity-"));
const file = join(dir, "union-surface.cc");
await writeFile(file, await read("union-surface.cc"));
const [envelope] = await new SingleFileMessagePackBackingStore(file).pullAll();
assert.equal(hex(envelope.payload), hex(await read("union-surface.msgpack")));
const document = surfaceDocument(envelope.schemaId);
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
await cache.pullAllBackingStores();
assert.deepEqual(cache.get(document, envelope.key), golden);
const written = join(dir, "written.cc");
const fresh = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(written)).build();
await fresh.put(document, envelope.key, golden);
const [rewritten] = await new SingleFileMessagePackBackingStore(written).pullAll();
assert.equal(hex(rewritten.payload), hex(envelope.payload));
});
test("every outcome vector is listed and holds: refusals name the union and key, never the payload", async () => {
const entries = await outcomeEntries();
assert.equal(entries.length, 9);
const document = surfaceDocument();
let refused = 0;
let readOnly = 0;
for (const { file, outcome: expected, unionName, key, detail } of entries) {
const payload = await read(file);
if (expected === "refused") {
refused += 1;
assert.ok(Buffer.from(payload).includes(CANARY) || detail === "arity", `${file} carries the canary`);
assert.throws(
() => surfaceCodec.decode(decode(payload)),
(error: Error) => {
assert.ok(!error.message.includes(CANARY), `${file}: the refusal echoed the payload`);
if (detail === "arity") {
return error.message.includes(`Union ${unionName}`) && error.message.includes("expected [key, slots]");
}
return error.message.includes(`Union ${unionName}:`) && error.message.includes(`key ${key} `);
},
file,
);
const refusingCache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore([envelopeOf(payload)])).build();
await assert.rejects(() => refusingCache.pullAllBackingStores(), new RegExp(`Union ${unionName}`, "u"), file);
} else {
readOnly += 1;
assert.equal(expected, "read-only");
assert.deepEqual(surfaceCodec.decode(decode(payload)), golden, `${file}: the extra slot is read past`);
}
}
assert.deepEqual([refused, readOnly], [5, 4]);
});
test("a refusal lists the declared keys, so a key only the outer union declares is unknown to the inner", async () => {
const entry = (await outcomeEntries()).find((candidate) => candidate.file === "inner-union-outer-key.msgpack")!;
const payload = decode(await read(entry.file)) as unknown[];
assert.throws(
() => surfaceCodec.decode(payload),
/Union CraftedItem: unknown arm key 2 \(declared keys: 0, 1\)\./u,
);
});
test("an arm that is not [key, slots] is refused naming the union, and a non-integer key is not echoed", () => {
for (const bad of [[1], [1, null], [1, [], []], "x", 7, {}]) {
assert.throws(() => node.decode(bad), /^Error: Union Node: expected \[key, slots\]/u);
}
assert.throws(
() => node.decode([CANARY, []]),
(error: Error) => error.message.includes("Union Node") && error.message.includes("not an integer") && !error.message.includes(CANARY),
);
});
test("an arm key that is not a declared key is refused, with no fallback", () => {
assert.throws(() => node.decode([4, []]), /Union Node: unknown arm key 4 \(declared keys: 0, 1, 2, 5\)/u);
assert.throws(() => outcome.decode([3, []]), /Union Outcome: unknown arm key 3/u);
});
test("a union encodes by kind and refuses a kind it does not declare without echoing it", () => {
assert.deepEqual(node.encode({ kind: "Text", id: "a", content: "b" }), [1, ["a", "b"]]);
assert.equal(node.encode(null), null);
assert.throws(
() => node.encode({ kind: CANARY, id: "a" } as unknown as Node),
(error: Error) => error.message.includes("Union Node") && !error.message.includes(CANARY),
);
});
test("slots: a gap writes nil, a missing slot reads its default, one without a default is refused naming the slot", () => {
const record = slots<{ a: string; c: string; d: number }>({ 0: ["a", string], 2: ["c", string], 4: ["d", number, 9] });
assert.deepEqual(record.encode({ a: "x", c: "y", d: 1 }), ["x", null, "y", null, 1]);
assert.deepEqual(record.decode(["x", null, "y"]), { a: "x", c: "y", d: 9 });
assert.deepEqual(record.decode(["x", 5, "y", null, null]), { a: "x", c: "y", d: 9 });
assert.throws(() => record.decode(["x"]), /c \(slot 2\): the slot is missing and declares no default/u);
assert.deepEqual(record.decode(["x", null, "y", null, 1, "extra", "slots"]), { a: "x", c: "y", d: 1 });
assert.throws(() => record.decode({ a: "x" }), /expected an array of slots/u);
});
test("slots: a default may be a function, and nil in a defaulted slot takes it", () => {
const record = slots<{ tags: string[] }>({ 0: ["tags", list(string), () => ["fresh"]] });
assert.deepEqual(record.decode([]), { tags: ["fresh"] });
assert.deepEqual(record.decode([null]), { tags: ["fresh"] });
});
test("a slot refusal names the field and slot and never the value", () => {
const record = slots<{ name: string }>({ 3: ["name", string] });
assert.throws(
() => record.decode([null, null, null, { secret: CANARY }]),
(error: Error) => error.message.startsWith("name (slot 3):") && !error.message.includes(CANARY),
);
});
test("leaf codecs hold their types", () => {
assert.throws(() => number.encode(Number.NaN), /expected a number/u);
assert.throws(() => string.decode(5), /expected a string, found number/u);
assert.throws(() => boolean.decode("true"), /expected a boolean/u);
assert.equal(boolean.decode(true), true);
});
test("definitions are refused at definition time", () => {
assert.throws(() => union<{ kind: "A" }>("U", { 0: ["A", slots<{ kind: string }>({ 0: ["kind", string] })] }), /Union U arm "A" declares a slot named kind/u);
assert.throws(() => union<{ kind: "A" | "B" }>("U", { 0: ["A", unit], 1: ["A" as "B", unit] }), /declares the arm "A" twice/u);
assert.throws(() => union<{ kind: "A" }>("U", { x: ["A", unit] } as never), /not a non-negative integer/u);
assert.throws(() => union<{ kind: "A" }>("U", {} as never), /declares no arms/u);
assert.throws(() => slots<{ a: string; b: string }>({ 0: ["a", string], 1: ["a" as "b", string] }), /declares the field "a" twice/u);
assert.throws(() => slots<{ a: string }>({}), /declares no slots/u);
assert.throws(() => slots<{ a: string }>({ [-1]: ["a", string] }), /not a non-negative integer/u);
assert.throws(
() => defineDocumentType({
type: "t",
schema: surfaceSchema,
codec: surfaceCodec,
formatter: { encode: () => new Uint8Array(), decode: () => golden },
}),
/declares both a codec and a formatter/u,
);
});
test("a codec supplies the document's catalog members unless it is given its own", () => {
const document = defineDocumentType({
type: "t",
schema: z.object({ a: z.string(), the_b: z.string() }),
codec: slots<{ a: string; the_b: string }>({ 0: ["a", string], 3: ["the_b", string] }),
name: "a",
indexes: { byB: "the_b" },
});
assert.deepEqual(document.members, [
{ slot: 0, memberName: "A", typeName: "System.Object", isName: true, indexAlias: null },
{ slot: 3, memberName: "TheB", typeName: "System.Object", isName: false, indexAlias: "byB" },
]);
const given = [{ slot: 9, memberName: "Mine", typeName: "System.String" }];
const own = defineDocumentType({ type: "t", schema: z.object({ a: z.string() }), codec: slots<{ a: string }>({ 0: ["a", string] }), members: given });
assert.deepEqual(own.members, given);
});
test("schema.parse still runs on a decoded value, and a document without a codec still writes a map", async () => {
const strict = defineDocumentType({
type: "t",
schema: z.object({ a: z.string().min(3) }),
codec: slots<{ a: string }>({ 0: ["a", string] }),
});
const store = memoryStore([{ key: "k", type: "t", payload: encode(["xy"]), storedAt: new Date().toISOString() }]);
await assert.rejects(() => CultCache.builder().withDocumentType(strict).withGenericStore(store).build().pullAllBackingStores());
const plain = defineDocumentType({ type: "p", schema: z.object({ id: z.string() }) });
const holder = memoryStore([]);
await CultCache.builder().withDocumentType(plain).withGenericStore(holder).build().put(plain, "k", { id: "d" });
assert.deepEqual(decode(holder.entries[0].payload), { id: "d" });
});
test("bytes and nullable round-trip through a slot record", () => {
const record = slots<{ blob: Uint8Array; maybe: string | null }>({ 0: ["blob", bytes], 1: ["maybe", nullable(string)] });
const wire = encode(record.encode({ blob: new Uint8Array([1, 2]), maybe: null }));
assert.deepEqual(record.decode(decode(wire)), { blob: new Uint8Array([1, 2]), maybe: null });
assert.throws(() => record.decode([new Uint8Array(), 4]), /maybe \(slot 1\): expected a string/u);
});
function envelopeOf(payload: Uint8Array): CultCacheEnvelope {
return { key: "surface", type: "tests.union_surface", payload, storedAt: new Date().toISOString() };
}
function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { entries: CultCacheEnvelope[] } {
return {
entries,
async pullAll() { return entries.map((entry) => ({ ...entry })); },
async push(entry) { entries.push(entry); },
async delete() {},
};
}