import type { CultCacheDocumentFormatter, CultCacheSchemaCatalogMember } from "./types"; import { readWire, WireFloat, writeWire } from "./wire"; // A codec turns a value into the 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. `typeName` is the reference's name for the type // (CultSchemaTypeNames.FromType): a store's schema catalog carries it per slot, and the reference refuses a store whose // slot names another type. The leaves know theirs; a union or a record the codec cannot name from C# is given it. export interface Codec { encode(value: TValue): unknown; decode(raw: unknown): TValue; readonly typeName?: string; // True for a type the reference makes Nullable when the slot may be nil (int, long, float, double, bool). readonly valueType?: boolean; } export interface SlotsCodec extends Codec { // Throws when a slot's codec has no typeName: the catalog cannot be derived without the reference's names. readonly members: readonly CultCacheSchemaCatalogMember[]; readonly names: readonly string[]; } function kindOf(raw: unknown): string { if (raw === null || raw === undefined) return "nil"; if (Array.isArray(raw)) return "array"; if (raw instanceof WireFloat) return "float"; if (typeof raw === "bigint") return "integer"; return typeof raw; } function refuse(what: string, found: string): never { throw new Error(`expected ${what}, found ${found}`); } function expect(raw: unknown, what: string, ok: boolean): asserts ok { if (!ok) refuse(what, kindOf(raw)); } export const string: Codec = { typeName: "System.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 boolean: Codec = { typeName: "System.Boolean", valueType: true, encode: (value) => (expect(value, "a boolean", typeof value === "boolean"), value), decode: (raw) => (expect(raw, "a boolean", typeof raw === "boolean"), raw as boolean), }; // The reference's byte[] reads a MessagePack str as its bytes and writes bin (an old-spec writer's output), so a str is // accepted and re-encodes as bin. The wire reader has already decoded the str, so a str that held invalid UTF-8 (it // reads back U+FFFD) is refused rather than turned into other bytes. export const bytes: Codec = { typeName: "System.Byte[]", encode: (value) => (expect(value, "bytes", value instanceof Uint8Array), value), decode(raw) { if (typeof raw === "string") { if (raw.includes("�")) refuse("bytes", "a string that is not valid UTF-8"); return new TextEncoder().encode(raw); } expect(raw, "bytes", raw instanceof Uint8Array); return raw as Uint8Array; }, }; // A C# integer slot: written as the smallest MessagePack integer and read from any integer encoding in range. A float is // refused even when it holds a whole number, as the reference refuses it. function integerCodec(typeName: string, what: string, low: bigint, high: bigint): Codec { const inRange = (value: number | bigint) => value >= low && value <= high; return { typeName, valueType: true, encode(value) { if (typeof value === "bigint") { if (!inRange(value)) refuse(what, "an integer out of range"); return value; } if (typeof value !== "number") refuse(what, kindOf(value)); if (!Number.isSafeInteger(value)) { refuse(what, Number.isInteger(value) ? "a number beyond 2^53, which needs a bigint" : "a non-integer number"); } if (!inRange(value)) refuse(what, "an integer out of range"); return value; }, decode(raw) { if (typeof raw !== "number" && typeof raw !== "bigint") refuse(what, kindOf(raw)); if (!inRange(raw)) refuse(what, "an integer out of range"); return raw; }, }; } // C# int. Always a number. export const int32 = integerCodec("System.Int32", "an int32", -(2n ** 31n), 2n ** 31n - 1n) as Codec; // C# long. A number when the value is a safe integer, a bigint beyond 2^53 (so 64-bit values never lose precision). export const int64 = integerCodec("System.Int64", "an int64", -(2n ** 63n), 2n ** 63n - 1n); // A 64-bit integer to the nearest float32 in one rounding, ties to even, as the reference's cast does. Number(value) // first would round to a double and then again, and the two roundings can disagree. A bigint reaches here only beyond // 2^53 (the wire reader returns a number for any smaller integer), so at least 30 bits are rounded away. function bigintToFloat32(value: bigint): number { const magnitude = value < 0n ? -value : value; const excess = magnitude.toString(2).length - 24; const kept = magnitude >> BigInt(excess); const rest = magnitude - (kept << BigInt(excess)); const half = 1n << BigInt(excess - 1); const rounded = Number(rest > half || (rest === half && (kept & 1n) === 1n) ? kept + 1n : kept) * 2 ** excess; return value < 0n ? -rounded : rounded; } function floatCodec(typeName: string, wide: boolean): Codec { const what = wide ? "a float64" : "a float32"; return { typeName, valueType: true, encode(value) { expect(value, what, typeof value === "number"); return new WireFloat(value, wide); }, decode(raw) { if (raw instanceof WireFloat) { // A JS number has no NaN payload, so a signalling float32 NaN cannot be kept; refusing beats changing its bytes. if (raw.signalling && !wide) refuse(what, "a signalling NaN"); return wide ? raw.value : Math.fround(raw.value); } if (typeof raw === "number") return wide ? raw : Math.fround(raw); if (typeof raw === "bigint") return wide ? Number(raw) : bigintToFloat32(raw); return refuse(what, kindOf(raw)); }, }; } // C# float, written as MessagePack float 32. export const float32 = floatCodec("System.Single", false); // C# double, written as MessagePack float 64. export const float64 = floatCodec("System.Double", true); function sequence(item: Codec, nameOf: (itemName: string) => string): Codec { return { get typeName() { return item.typeName === undefined ? undefined : nameOf(item.typeName); }, 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))); }, }; } // C# List. export function list(item: Codec): Codec { return sequence(item, (name) => `System.Collections.Generic.List<${name}>`); } // C# T[]. The same wire as list. export function array(item: Codec): Codec { return sequence(item, (name) => `${name}[]`); } // A slot that may be nil. For a value type the reference's name is Nullable; for a reference type it is unchanged. export function nullable(inner: Codec): Codec { return { get typeName() { return inner.typeName !== undefined && inner.valueType ? `System.Nullable<${inner.typeName}>` : inner.typeName; }, encode: (value) => (value === null || value === undefined ? null : inner.encode(value)), decode: (raw) => (raw === null ? null : inner.decode(raw)), }; } export function lazy(get: () => Codec): Codec { let resolved: Codec | undefined; const codec = () => (resolved ??= get()); return { get typeName() { return codec().typeName; }, get valueType() { return codec().valueType; }, 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(where: string, run: () => TResult): TResult { try { return run(); } catch (error) { throw new Error(`${where}: ${(error as Error).message}`); } } type SlotSpec = | readonly [name: TName, codec: Codec] | readonly [name: TName, codec: Codec, defaultValue: TValue | (() => TValue)]; type SlotsSpec = Readonly }[keyof TValue & string]>>; const INT32_MIN = -(2 ** 31); const INT32_MAX = 2 ** 31 - 1; // The largest slot key. A slot record is written as a dense array of key+1 slots (the reference writes the same, nil // in every gap), so a key is a payload length: the cap keeps a definition's typo from allocating without bound. export const MAX_SLOT_KEY = 65535; // The declared keys, ascending. The reference's keys are Int32: a union arm may be any (a negative one too), a slot is // 0 to MAX_SLOT_KEY, and a key outside its range is refused at definition rather than allocated for. function slotKeys(spec: Readonly>, owner: string, low: number, high: number): number[] { return Object.keys(spec) .map((text) => { if (!/^-?(0|[1-9][0-9]*)$/u.test(text) || (low >= 0 && text.startsWith("-"))) { throw new Error(`${owner} declares the key "${text}", which is not ${low < 0 ? "an" : "a non-negative"} integer.`); } const key = BigInt(text); if (key < BigInt(low) || key > BigInt(high)) { throw new Error(`${owner} declares the key ${text}, which is ${high === INT32_MAX ? "outside Int32" : `above the slot key limit ${high}`}.`); } return Number(key); }) // Object.keys lists negative keys after the others, in the order they were written: sort them numerically. .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 default fills only a // missing trailing slot, never a nil: as in the reference, a nil in a reference slot (string, bytes, record, union, // list) reads as null and writes back as nil, so a record the reference wrote re-encodes byte-identical, and a nil in // a value slot (int, float, bool) is refused unless the slot is nullable. export function slots(spec: SlotsSpec, typeName?: string): SlotsCodec { const keys = slotKeys(spec, "A slot record", 0, MAX_SLOT_KEY); 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>>)[key]; return { key, name, codec, hasDefault: rest.length > 0, defaultValue: rest[0] }; }); const seen = new Set(); 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), typeName, get members() { return fields.map((field) => { if (field.codec.typeName === undefined) { throw new Error(`${field.name} (slot ${field.key}) has no C# type name for the schema catalog; give its union or record a typeName, or give the document its members.`); } return { slot: field.key, memberName: pascalCase(field.name), typeName: field.codec.typeName }; }); }, encode(value) { expect(value, "a record", typeof value === "object" && value !== null); const out: unknown[] = new Array(length).fill(null); for (const field of fields) { const member = (value as Record)[field.name]; out[field.key] = member === null && !field.codec.valueType ? null : at(`${field.name} (slot ${field.key})`, () => field.codec.encode(member)); } return out; }, decode(raw) { expect(raw, "an array of slots", Array.isArray(raw)); const input = raw as unknown[]; const out: Record = {}; for (const field of fields) { const present = field.key < input.length; if (!present && !field.hasDefault) { throw new Error(`${field.name} (slot ${field.key}): the slot is missing and declares no default.`); } out[field.name] = !present ? defaultOf(field) : input[field.key] === null && !field.codec.valueType ? null : at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key])); } return out as TValue; }, }; } // An arm with no slots. It is written [key, []], never [key, nil]. export interface UnitArm { readonly unit: true; } export const unit: UnitArm = Object.freeze({ unit: true }); type ArmSpec = readonly [armName: TKind, body: SlotsCodec | UnitArm]; // 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( name: string, arms: Readonly>>, typeName?: string, ): Codec { const keys = slotKeys(arms, `Union ${name}`, INT32_MIN, INT32_MAX); if (keys.length === 0) { throw new Error(`Union ${name} declares no arms.`); } const byKey = new Map | UnitArm }>(); const byName = new Map(); 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 as SlotsCodec).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 { typeName, 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 as SlotsCodec).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" && typeof key !== "bigint") { throw new Error(`Union ${name}: the arm key is not an integer (declared keys: ${declared}).`); } if (key < INT32_MIN || key > INT32_MAX) { throw new Error(`Union ${name}: the arm key is outside Int32 (declared keys: ${declared}).`); } const arm = byKey.get(Number(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).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(codec: SlotsCodec): CultCacheDocumentFormatter { return { encode: (value) => writeWire(codec.encode(value)), decode: (payload) => codec.decode(readWire(payload)), }; }