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:
parent
b0f4057704
commit
499887484c
7 changed files with 622 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
229
packages/cultcache-ts/src/codec.ts
Normal file
229
packages/cultcache-ts/src/codec.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
367
packages/cultcache-ts/test/union-parity.test.ts
Normal file
367
packages/cultcache-ts/test/union-parity.test.ts
Normal 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() {},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue