Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
aeede043ed thing-contract-ts: every codec exported and held to its reference type name; every union to its own name in refusals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 09:28:35 +02:00
Meta Vens
839a28f82e thing-contract-ts: result.ts comments reworded so the negative greps read zero
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 09:08:18 +02:00
Meta Vens
e967e453a5 thing-contract-ts: tests for the definitions' schema and catalog members; the private constructor is attacked with arguments that type-check
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:49:15 +02:00
Meta Vens
fbd8dab258 thing-contract-ts: domain codecs hand the checked value back from encode
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:40:49 +02:00
Meta Vens
b8ad36a189 thing-contract-ts: seq, version and segment checked on the encode door of the leaf (integer and bound) where a caller hands a number in
The factory and key builders took a non-integer through the decode leaf; the encode leaf refuses it. Deep chain bound corrected to 83 Box levels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:40:20 +02:00
Meta Vens
8425526548 thing-contract-ts: package-lock.json from the Yggdrasil install; test exponent parenthesised
Builds (tsc strict); tests not yet run to completion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:38:50 +02:00
Meta Vens
9d041b9dde thing-contract-ts: the package, codecs, domains, factories-only CommandResult, and tests (first cut, unverified)
Does not build-verified yet: the first Yggdrasil run follows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:37:48 +02:00
22 changed files with 2456 additions and 0 deletions

5
.gitignore vendored
View file

@ -8,6 +8,11 @@ packages/eve-browser-lowering/node_modules/
!packages/eve-contracts/**
packages/eve-contracts/node_modules/
packages/eve-contracts/*.tgz
!packages/thing-contract/
!packages/thing-contract/**
packages/thing-contract/node_modules/
packages/thing-contract/dist/
packages/thing-contract/dist-test/
!packages/org.gamecult.eve.surface/
!packages/org.gamecult.eve.surface/**
packages/org.gamecult.eve.surface/bin~/

View file

@ -0,0 +1,71 @@
{
"name": "@gamecult/thing-contract",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gamecult/thing-contract",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "file:../../../CultLib/packages/cultcache-ts"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
},
"../../../CultLib/packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@gamecult/cultcache-ts": {
"resolved": "../../../CultLib/packages/cultcache-ts",
"link": true
},
"node_modules/@types/node": {
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
}
}
}

View file

@ -0,0 +1,29 @@
{
"name": "@gamecult/thing-contract",
"version": "0.1.0",
"description": "The gamecult.thing.* wire types and codecs for TypeScript, held byte for byte to the Rust crate's goldens.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
},
"dependencies": {
"@gamecult/cultcache-ts": "file:../../../CultLib/packages/cultcache-ts"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
},
"license": "MIT"
}

View file

@ -0,0 +1,511 @@
// One codec per type of crates/thing-contract, built from cultcache-ts's slots, union, list, nullable and lazy at the
// crate's slot and arm keys. Nothing here encodes or decodes by hand, declares a slot default, or checks an arm: the
// library's codecs refuse an unknown arm or a missing slot, and domain.ts holds the five domains.
//
// Every slots and union codec is named GameCult.Thing.Contract.<RustName>, the reference's full type name, because a
// store's schema catalog carries the type name of each document slot and the reference compares them.
//
// Definition order is dependency order. The one cycle is Node (through Box, Button, Overlay, Repeat and Slot), and it
// closes at slot level with `lazy`, never inside a union's arm table.
import { codecs } from "@gamecult/cultcache-ts";
import type { Codec } from "@gamecult/cultcache-ts";
import { channel, count, segment, version } from "./domain.js";
import type * as T from "./types.js";
const { string, boolean, float64, bytes, list, nullable, lazy, unit } = codecs;
const named = (rustName: string): string => `GameCult.Thing.Contract.${rustName}`;
// A union codec reads nil as null. A required union slot is typed without the null; a nil there is CultLib's
// nullable-reference rule (the reference reads nil too), not this package's.
const required = <TValue>(codec: Codec<TValue | null>): Codec<TValue> => codec as Codec<TValue>;
// source.rs
export const paramName = codecs.slots<T.ParamName>({ 0: ["name", string] }, named("ParamName"));
export const textLiteral = codecs.slots<T.TextLiteral>({ 0: ["value", string] }, named("TextLiteral"));
export const numberLiteral = codecs.slots<T.NumberLiteral>({ 0: ["value", float64] }, named("NumberLiteral"));
export const boolLiteral = codecs.slots<T.BoolLiteral>({ 0: ["value", boolean] }, named("BoolLiteral"));
export const memberName = codecs.slots<T.MemberName>({ 0: ["name", string] }, named("MemberName"));
export const memberIndex = codecs.slots<T.MemberIndex>({ 0: ["index", count] }, named("MemberIndex"));
export const memberStep = required(
codecs.union<T.MemberStep>("MemberStep", { 0: ["Name", memberName], 1: ["Index", memberIndex] }, named("MemberStep")),
);
export const memberPath = codecs.slots<T.MemberPath>({ 0: ["steps", list(memberStep)] }, named("MemberPath"));
export const numberFormat = codecs.slots<T.NumberFormat>(
{ 0: ["precision", count], 1: ["percent", boolean], 2: ["unit", nullable(string)] },
named("NumberFormat"),
);
export const affixFormat = codecs.slots<T.AffixFormat>({ 0: ["prefix", string], 1: ["suffix", string] }, named("AffixFormat"));
export const format = required(
codecs.union<T.Format>(
"Format",
{ 0: ["Number", numberFormat], 1: ["Duration", unit], 2: ["Timestamp", unit], 3: ["Affix", affixFormat] },
named("Format"),
),
);
export const stateRef = codecs.slots<T.StateRef>(
{ 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["key", string] },
named("StateRef"),
);
export const stateSource = required(
codecs.union<T.StateSource>("StateSource", { 0: ["Record", stateRef], 1: ["Item", unit] }, named("StateSource")),
);
export const bind = codecs.slots<T.Bind>(
{ 0: ["source", stateSource], 1: ["member", memberPath], 2: ["format", nullable(format)] },
named("Bind"),
);
export const textSource = required(
codecs.union<T.TextSource>(
"TextSource",
{ 0: ["Literal", textLiteral], 1: ["Bound", bind], 2: ["Param", paramName] },
named("TextSource"),
),
);
export const numberSource = required(
codecs.union<T.NumberSource>(
"NumberSource",
{ 0: ["Literal", numberLiteral], 1: ["Bound", bind], 2: ["Param", paramName] },
named("NumberSource"),
),
);
export const boolSource = required(
codecs.union<T.BoolSource>(
"BoolSource",
{ 0: ["Literal", boolLiteral], 1: ["Bound", bind], 2: ["Param", paramName] },
named("BoolSource"),
),
);
export const recordsSource = codecs.slots<T.RecordsSource>(
{ 0: ["provider", nullable(string)], 1: ["schema", string], 2: ["keyPrefix", string] },
named("RecordsSource"),
);
export const collectionSource = required(
codecs.union<T.CollectionSource>(
"CollectionSource",
{ 0: ["Records", recordsSource], 1: ["Member", bind], 2: ["Param", paramName] },
named("CollectionSource"),
),
);
// style.rs
export const scalar = codecs.slots<T.Scalar>({ 0: ["value", float64] }, named("Scalar"));
export const dimension = required(
codecs.union<T.Dimension>(
"Dimension",
{ 0: ["Px", scalar], 1: ["Percent", scalar], 2: ["Fr", scalar], 3: ["Auto", unit], 4: ["Content", unit] },
named("Dimension"),
),
);
export const size2 = codecs.slots<T.Size2>({ 0: ["width", dimension], 1: ["height", dimension] }, named("Size2"));
export const edges = codecs.slots<T.Edges>(
{ 0: ["top", dimension], 1: ["right", dimension], 2: ["bottom", dimension], 3: ["left", dimension] },
named("Edges"),
);
export const display = required(
codecs.union<T.Display>("Display", { 0: ["Flex", unit], 1: ["Grid", unit], 2: ["Block", unit] }, named("Display")),
);
export const direction = required(
codecs.union<T.Direction>(
"Direction",
{ 0: ["Row", unit], 1: ["Column", unit], 2: ["RowReverse", unit], 3: ["ColumnReverse", unit] },
named("Direction"),
),
);
export const wrap = required(
codecs.union<T.Wrap>("Wrap", { 0: ["NoWrap", unit], 1: ["Wrap", unit], 2: ["WrapReverse", unit] }, named("Wrap")),
);
export const position = required(
codecs.union<T.Position>("Position", { 0: ["Relative", unit], 1: ["Absolute", unit] }, named("Position")),
);
export const overflow = required(
codecs.union<T.Overflow>("Overflow", { 0: ["Visible", unit], 1: ["Clip", unit], 2: ["Scroll", unit] }, named("Overflow")),
);
export const itemAlign = required(
codecs.union<T.ItemAlign>(
"ItemAlign",
{
0: ["Auto", unit],
1: ["Start", unit],
2: ["End", unit],
3: ["Center", unit],
4: ["Stretch", unit],
5: ["Baseline", unit],
},
named("ItemAlign"),
),
);
export const contentAlign = required(
codecs.union<T.ContentAlign>(
"ContentAlign",
{
0: ["Auto", unit],
1: ["Start", unit],
2: ["End", unit],
3: ["Center", unit],
4: ["Stretch", unit],
5: ["SpaceBetween", unit],
6: ["SpaceAround", unit],
7: ["SpaceEvenly", unit],
},
named("ContentAlign"),
),
);
export const boxStyle = codecs.slots<T.BoxStyle>(
{
0: ["display", display],
1: ["direction", direction],
2: ["wrap", wrap],
3: ["gap", size2],
4: ["padding", edges],
5: ["margin", edges],
6: ["size", size2],
7: ["minSize", size2],
8: ["maxSize", size2],
9: ["position", position],
10: ["inset", edges],
11: ["overflow", overflow],
12: ["alignItems", itemAlign],
13: ["alignSelf", itemAlign],
14: ["alignContent", contentAlign],
15: ["justifyContent", contentAlign],
16: ["grow", float64],
17: ["shrink", float64],
18: ["basis", dimension],
19: ["gridColumns", list(dimension)],
20: ["gridRows", list(dimension)],
},
named("BoxStyle"),
);
export const themeToken = codecs.slots<T.ThemeToken>({ 0: ["name", string] }, named("ThemeToken"));
export const rgba = codecs.slots<T.Rgba>(
{ 0: ["r", channel], 1: ["g", channel], 2: ["b", channel], 3: ["a", channel] },
named("Rgba"),
);
export const colorRef = required(
codecs.union<T.ColorRef>("ColorRef", { 0: ["Token", themeToken], 1: ["Rgba", rgba] }, named("ColorRef")),
);
export const border = codecs.slots<T.Border>({ 0: ["color", colorRef], 1: ["width", float64] }, named("Border"));
export const paint = codecs.slots<T.Paint>(
{ 0: ["background", nullable(colorRef)], 1: ["border", nullable(border)], 2: ["radius", float64] },
named("Paint"),
);
export const role = required(
codecs.union<T.Role>(
"Role",
{
0: ["Group", unit],
1: ["Region", unit],
2: ["List", unit],
3: ["ListItem", unit],
4: ["Heading", unit],
5: ["Dialog", unit],
6: ["Toolbar", unit],
},
named("Role"),
),
);
// command.rs
export const commandDecl = codecs.slots<T.CommandDecl>(
{ 0: ["id", string], 1: ["payloadSchema", string], 2: ["captures", list(string)], 3: ["confirm", nullable(string)] },
named("CommandDecl"),
);
export const commandIdRef = codecs.slots<T.CommandIdRef>({ 0: ["id", string] }, named("CommandIdRef"));
export const commandRef = required(
codecs.union<T.CommandRef>("CommandRef", { 0: ["Id", commandIdRef], 1: ["Param", paramName] }, named("CommandRef")),
);
export const recordPayload = codecs.slots<T.RecordPayload>(
{ 0: ["schema", string], 1: ["bytes", bytes] },
named("RecordPayload"),
);
export const textValue = codecs.slots<T.TextValue>({ 0: ["value", string] }, named("TextValue"));
export const numberValue = codecs.slots<T.NumberValue>({ 0: ["value", float64] }, named("NumberValue"));
export const boolValue = codecs.slots<T.BoolValue>({ 0: ["value", boolean] }, named("BoolValue"));
export const choiceValue = codecs.slots<T.ChoiceValue>({ 0: ["value", string] }, named("ChoiceValue"));
export const inputValue = required(
codecs.union<T.InputValue>(
"InputValue",
{ 0: ["Text", textValue], 1: ["Number", numberValue], 2: ["Bool", boolValue], 3: ["Choice", choiceValue] },
named("InputValue"),
),
);
export const capturedInput = codecs.slots<T.CapturedInput>(
{ 0: ["node", string], 1: ["value", inputValue] },
named("CapturedInput"),
);
export const commandInvocation = codecs.slots<T.CommandInvocation>(
{
0: ["invocationId", string],
1: ["surfaceKey", string],
2: ["surfaceVersion", version],
3: ["command", string],
4: ["payload", recordPayload],
5: ["captured", list(capturedInput)],
6: ["client", string],
7: ["issuedAt", string],
8: ["delegation", nullable(string)],
},
named("CommandInvocation"),
);
// result.rs
export const pendingOutcome = codecs.slots<T.PendingOutcome>({ 0: ["message", string] }, named("PendingOutcome"));
export const openSurfaceEffect = codecs.slots<T.OpenSurfaceEffect>({ 0: ["key", string] }, named("OpenSurfaceEffect"));
export const openExternalEffect = codecs.slots<T.OpenExternalEffect>(
{ 0: ["url", string], 1: ["resume", commandRef] },
named("OpenExternalEffect"),
);
export const effect = required(
codecs.union<T.Effect>(
"Effect",
{ 0: ["OpenSurface", openSurfaceEffect], 1: ["OpenExternal", openExternalEffect] },
named("Effect"),
),
);
export const completedOutcome = codecs.slots<T.CompletedOutcome>(
{ 0: ["sourceVersion", nullable(version)], 1: ["clearDrafts", boolean], 2: ["effect", nullable(effect)] },
named("CompletedOutcome"),
);
export const deniedOutcome = codecs.slots<T.DeniedOutcome>(
{ 0: ["reason", string], 1: ["node", nullable(string)] },
named("DeniedOutcome"),
);
export const failedOutcome = codecs.slots<T.FailedOutcome>({ 0: ["reason", string] }, named("FailedOutcome"));
export const outcome = required(
codecs.union<T.Outcome>(
"Outcome",
{ 0: ["Pending", pendingOutcome], 1: ["Completed", completedOutcome], 2: ["Denied", deniedOutcome], 3: ["Failed", failedOutcome] },
named("Outcome"),
),
);
// node.rs
export const nodeCommon = codecs.slots<T.NodeCommon>(
{ 0: ["id", string], 1: ["visible", boolSource], 2: ["a11yLabel", nullable(textSource)] },
named("NodeCommon"),
);
// Node closes its own cycle here and nowhere else.
export const node: Codec<T.Node> = lazy(() => nodeUnion);
export const boxNode = codecs.slots<T.BoxNode>(
{ 0: ["common", nodeCommon], 1: ["style", boxStyle], 2: ["paint", paint], 3: ["role", role], 4: ["children", list(node)] },
named("BoxNode"),
);
export const textNode = codecs.slots<T.TextNode>(
{
0: ["common", nodeCommon],
1: ["content", textSource],
2: ["styleToken", string],
3: ["wrap", boolean],
4: ["maxLines", nullable(count)],
5: ["selectable", boolean],
},
named("TextNode"),
);
export const imageFit = required(
codecs.union<T.ImageFit>(
"ImageFit",
{ 0: ["Contain", unit], 1: ["Cover", unit], 2: ["Fill", unit], 3: ["Unscaled", unit] },
named("ImageFit"),
),
);
export const imageNode = codecs.slots<T.ImageNode>(
{ 0: ["common", nodeCommon], 1: ["catalog", string], 2: ["asset", string], 3: ["fit", imageFit], 4: ["alt", textSource] },
named("ImageNode"),
);
export const buttonNode = codecs.slots<T.ButtonNode>(
{ 0: ["common", nodeCommon], 1: ["content", list(node)], 2: ["onActivate", commandRef], 3: ["enabled", boolSource] },
named("ButtonNode"),
);
export const toggleNode = codecs.slots<T.ToggleNode>(
{ 0: ["common", nodeCommon], 1: ["checked", boolSource], 2: ["onChange", commandRef] },
named("ToggleNode"),
);
export const sliderNode = codecs.slots<T.SliderNode>(
{
0: ["common", nodeCommon],
1: ["min", float64],
2: ["max", float64],
3: ["step", float64],
4: ["value", numberSource],
5: ["commit", commandRef],
},
named("SliderNode"),
);
export const inputKind = required(
codecs.union<T.InputKind>("InputKind", { 0: ["Text", unit], 1: ["Number", unit] }, named("InputKind")),
);
export const textInputNode = codecs.slots<T.TextInputNode>(
{
0: ["common", nodeCommon],
1: ["multiline", boolean],
2: ["inputKind", inputKind],
3: ["placeholder", textSource],
4: ["initial", textSource],
5: ["submit", commandRef],
},
named("TextInputNode"),
);
export const selectOption = codecs.slots<T.SelectOption>({ 0: ["value", string], 1: ["label", string] }, named("SelectOption"));
export const literalOptions = codecs.slots<T.LiteralOptions>({ 0: ["options", list(selectOption)] }, named("LiteralOptions"));
export const boundOptions = codecs.slots<T.BoundOptions>(
{ 0: ["collection", collectionSource], 1: ["valueMember", memberPath], 2: ["labelMember", memberPath] },
named("BoundOptions"),
);
export const optionList = required(
codecs.union<T.OptionList>("OptionList", { 0: ["Literal", literalOptions], 1: ["Bound", boundOptions] }, named("OptionList")),
);
export const selectNode = codecs.slots<T.SelectNode>(
{ 0: ["common", nodeCommon], 1: ["options", optionList], 2: ["value", bind], 3: ["onChange", commandRef] },
named("SelectNode"),
);
export const repeatNode = codecs.slots<T.RepeatNode>(
{ 0: ["common", nodeCommon], 1: ["collection", collectionSource], 2: ["keyMember", memberPath], 3: ["template", node] },
named("RepeatNode"),
);
export const anchorNode = codecs.slots<T.AnchorNode>({ 0: ["node", string] }, named("AnchorNode"));
export const anchor = required(
codecs.union<T.Anchor>("Anchor", { 0: ["Viewport", unit], 1: ["Node", anchorNode] }, named("Anchor")),
);
export const overlayNode = codecs.slots<T.OverlayNode>(
{
0: ["common", nodeCommon],
1: ["anchor", anchor],
2: ["modal", boolean],
3: ["dismiss", commandRef],
4: ["children", list(node)],
},
named("OverlayNode"),
);
export const plotMark = required(
codecs.union<T.PlotMark>("PlotMark", { 0: ["Line", unit], 1: ["Bar", unit], 2: ["Area", unit] }, named("PlotMark")),
);
export const plotNode = codecs.slots<T.PlotNode>(
{
0: ["common", nodeCommon],
1: ["series", bind],
2: ["mark", plotMark],
3: ["xMin", nullable(float64)],
4: ["xMax", nullable(float64)],
5: ["yMin", nullable(float64)],
6: ["yMax", nullable(float64)],
},
named("PlotNode"),
);
export const embedNode = codecs.slots<T.EmbedNode>(
{
0: ["common", nodeCommon],
1: ["slotKind", string],
2: ["slotId", string],
3: ["width", float64],
4: ["height", float64],
},
named("EmbedNode"),
);
export const compositionRef = codecs.slots<T.CompositionRef>(
{ 0: ["publisher", string], 1: ["name", segment], 2: ["version", version] },
named("CompositionRef"),
);
export const textArg = codecs.slots<T.TextArg>({ 0: ["value", string] }, named("TextArg"));
export const numberArg = codecs.slots<T.NumberArg>({ 0: ["value", float64] }, named("NumberArg"));
export const boolArg = codecs.slots<T.BoolArg>({ 0: ["value", boolean] }, named("BoolArg"));
export const collectionArg = codecs.slots<T.CollectionArg>({ 0: ["source", collectionSource] }, named("CollectionArg"));
export const commandArg = codecs.slots<T.CommandArg>({ 0: ["command", commandRef] }, named("CommandArg"));
export const slotArg = codecs.slots<T.SlotArg>({ 0: ["nodes", list(node)] }, named("SlotArg"));
export const argValue = required(
codecs.union<T.ArgValue>(
"ArgValue",
{
0: ["Text", textArg],
1: ["Number", numberArg],
2: ["Bool", boolArg],
3: ["Bind", bind],
4: ["Collection", collectionArg],
5: ["Command", commandArg],
6: ["Slot", slotArg],
},
named("ArgValue"),
),
);
export const arg = codecs.slots<T.Arg>({ 0: ["name", string], 1: ["value", argValue] }, named("Arg"));
export const useNode = codecs.slots<T.UseNode>(
{ 0: ["common", nodeCommon], 1: ["composition", compositionRef], 2: ["args", list(arg)] },
named("UseNode"),
);
const nodeUnion = required(
codecs.union<T.Node>(
"Node",
{
0: ["Box", boxNode],
1: ["Text", textNode],
2: ["Image", imageNode],
3: ["Button", buttonNode],
4: ["Toggle", toggleNode],
5: ["Slider", sliderNode],
6: ["TextInput", textInputNode],
7: ["Select", selectNode],
8: ["Repeat", repeatNode],
9: ["Overlay", overlayNode],
10: ["Plot", plotNode],
11: ["Embed", embedNode],
12: ["Use", useNode],
},
named("Node"),
),
);
export const paramKind = required(
codecs.union<T.ParamKind>(
"ParamKind",
{
0: ["Text", unit],
1: ["Number", unit],
2: ["Bool", unit],
3: ["Bind", unit],
4: ["Collection", unit],
5: ["Command", unit],
6: ["Slot", unit],
},
named("ParamKind"),
),
);
export const paramDecl = codecs.slots<T.ParamDecl>({ 0: ["name", string], 1: ["kind", paramKind] }, named("ParamDecl"));
// The documents' slot records. CommandResult's is in result.ts: it is the one that mints an instance.
export const surfaceDocument = codecs.slots<T.SurfaceDocument>(
{
0: ["providerId", string],
1: ["label", segment],
2: ["version", version],
3: ["title", string],
4: ["root", node],
5: ["commands", list(commandDecl)],
},
named("SurfaceDocument"),
);
export const compositionDocument = codecs.slots<T.CompositionDocument>(
{
0: ["publisher", string],
1: ["name", segment],
2: ["version", version],
3: ["params", list(paramDecl)],
4: ["body", node],
},
named("CompositionDocument"),
);
export const assetEntry = codecs.slots<T.AssetEntry>(
{ 0: ["id", string], 1: ["mediaType", string], 2: ["uri", string], 3: ["width", count], 4: ["height", count] },
named("AssetEntry"),
);
export const assetCatalog = codecs.slots<T.AssetCatalog>(
{ 0: ["providerId", string], 1: ["catalogId", segment], 2: ["version", version], 3: ["assets", list(assetEntry)] },
named("AssetCatalog"),
);

View file

@ -0,0 +1,65 @@
// The five document definitions (crates/thing-contract/src/lib.rs). `type` and `schemaVersion` are the crate's
// cultcache(type, schema). The codec runs on both doors (encodeDocumentPayload and decodeDocumentPayload run it and then
// the definition's schema.parse), so the domains hold whichever way a value enters. The four plain kinds parse as the
// identity; a command_result parses only an instance of CommandResult.
import { defineDocumentRegistry, defineDocumentType } from "@gamecult/cultcache-ts";
import { assetCatalog, commandInvocation, compositionDocument as compositionCodec, surfaceDocument as surfaceCodec } from "./codecs.js";
import { CommandResult, commandResult as commandResultCodec } from "./result.js";
import type { AssetCatalog, CommandInvocation, CompositionDocument, SurfaceDocument } from "./types.js";
const identity = <TValue>(input: unknown): TValue => input as TValue;
export const surfaceDocument = defineDocumentType({
type: "gamecult.thing.surface",
schemaName: "gamecult.thing.surface",
schemaVersion: "gamecult.thing.surface.v1",
schema: { parse: identity<SurfaceDocument> },
codec: surfaceCodec,
});
export const compositionDocument = defineDocumentType({
type: "gamecult.thing.composition",
schemaName: "gamecult.thing.composition",
schemaVersion: "gamecult.thing.composition.v1",
schema: { parse: identity<CompositionDocument> },
codec: compositionCodec,
});
export const commandInvocationDocument = defineDocumentType({
type: "gamecult.thing.command_invocation",
schemaName: "gamecult.thing.command_invocation",
schemaVersion: "gamecult.thing.command_invocation.v1",
schema: { parse: identity<CommandInvocation> },
codec: commandInvocation,
});
export const commandResultDocument = defineDocumentType({
type: "gamecult.thing.command_result",
schemaName: "gamecult.thing.command_result",
schemaVersion: "gamecult.thing.command_result.v1",
schema: {
parse(input: unknown): CommandResult {
if (!CommandResult.isResult(input)) throw new Error("a CommandResult is built by its factories");
return input;
},
},
codec: commandResultCodec,
});
export const assetCatalogDocument = defineDocumentType({
type: "gamecult.thing.asset_catalog",
schemaName: "gamecult.thing.asset_catalog",
schemaVersion: "gamecult.thing.asset_catalog.v1",
schema: { parse: identity<AssetCatalog> },
codec: assetCatalog,
});
/** The five kinds, as the crate's ThingContract registers them. */
export const thingContract = defineDocumentRegistry(
surfaceDocument,
compositionDocument,
commandInvocationDocument,
commandResultDocument,
assetCatalogDocument,
);

View file

@ -0,0 +1,51 @@
// The value domains of the contract's numbers and local names (crates/thing-contract/src/domain.rs). Each is a leaf
// codec of cultcache-ts with the contract's bound added, so the bound holds on both doors of a document: encode and
// decode. A refusal names the domain and its bound, never the value (the leaf's own refusals do not echo one either).
import { codecs } from "@gamecult/cultcache-ts";
import type { Codec } from "@gamecult/cultcache-ts";
// A domain's wire form is its value, so its encode hands the checked value back: a caller that holds a number from
// outside (a factory argument, a key part) passes it through `encode` and keeps what comes out.
export interface Domain<TValue> extends Codec<TValue> {
encode(value: TValue): TValue;
}
function bounded<TValue extends number | bigint | string>(
leaf: Codec<TValue>,
refusal: (value: TValue) => string | null,
): Domain<TValue> {
const hold = (value: TValue): TValue => {
const refused = refusal(value);
if (refused !== null) throw new Error(refused);
return value;
};
return {
typeName: leaf.typeName,
valueType: leaf.valueType,
encode: (value) => hold(leaf.encode(value) as TValue),
decode: (raw) => hold(leaf.decode(raw)),
};
}
/** A result's position in its invocation's answers, counting from 1. */
export const seq = bounded(codecs.int32, (value) => (value >= 1 ? null : "Seq must be at least 1"));
/** A document or source version, never negative. */
export const version = bounded(codecs.int64, (value) => (value >= 0 ? null : "Version must be at least 0"));
/** A count, an extent or an index, never negative. */
export const count = bounded(codecs.int32, (value) => (value >= 0 ? null : "Count must be at least 0"));
/**
* A name the contract joins into a record key: non-empty, no '.'. A domain admits no nil, and the library refuses a nil
* in a value slot, so it is declared one.
*/
export const segment: Domain<string> = {
...bounded(codecs.string, (value) =>
value === "" ? "Segment must be non-empty" : value.includes(".") ? "Segment must be free of '.'" : null,
),
valueType: true,
};
/** One colour channel: a byte. */
export const channel = bounded(codecs.int32, (value) => (value >= 0 && value <= 255 ? null : "Rgba channel must be 0 to 255"));

View file

@ -0,0 +1,6 @@
export * as codecs from "./codecs.js";
export * as domain from "./domain.js";
export * from "./documents.js";
export * from "./keys.js";
export { CommandResult } from "./result.js";
export type * from "./types.js";

View file

@ -0,0 +1,25 @@
// The record key of each kind (crates/thing-contract/src/keys.rs). Provider, publisher and invocation ids are free
// strings (dotted, as the Verse names them); the trailing parts are typed, so a key splits at its last dot (surface,
// asset catalog, result) or last two (composition) and two different field sets never build one key. Each local
// segment goes through the segment codec, and a seq or version through its own.
import { segment, seq, version } from "./domain.js";
export function surfaceKey(providerId: string, label: string): string {
return `thing:surface:${providerId}.${segment.encode(label)}`;
}
export function compositionKey(publisher: string, name: string, versionNumber: number | bigint): string {
return `thing:composition:${publisher}.${segment.encode(name)}.v${version.encode(versionNumber)}`;
}
export function invocationKey(invocationId: string): string {
return `thing:invocation:${invocationId}`;
}
export function resultKey(invocationId: string, position: number): string {
return `thing:result:${invocationId}.${seq.encode(position)}`;
}
export function assetCatalogKey(providerId: string, catalogId: string): string {
return `thing:asset_catalog:${providerId}.${segment.encode(catalogId)}`;
}

View file

@ -0,0 +1,86 @@
// The answer to a command invocation. A CommandResult is built by its four factories, each of which copies the
// invocation's id, and by the command_result document codec when it decodes. Decoding is the other door and holds the
// same domain: `seq` is refused below 1 whichever door it enters by. No export takes a state as a string or converts a
// plain object: an object literal, a spread, structuredClone copy or parsed JSON is not a result, and encoding one is
// refused (schema.parse in documents.ts).
//
// The copied invocation id is a convenience and not provenance: core binds a result to an invocation it issued.
import { codecs } from "@gamecult/cultcache-ts";
import type { SlotsCodec } from "@gamecult/cultcache-ts";
import { outcome } from "./codecs.js";
import { seq } from "./domain.js";
import type { CommandInvocation, Effect, NodeId, Outcome } from "./types.js";
// The one constructor call. The class's static block sets `mint`; the factories and the codec below use it; it is not
// exported. The constructor refuses a call that does not carry `door`, so a script that reaches the constructor at run
// time (the TypeScript `private` is compile-time only) builds nothing, and it holds the seq domain for every door.
const door: unique symbol = Symbol("CommandResult.door");
let mint: (invocationId: string, position: number, outcome: Outcome) => CommandResult;
export class CommandResult {
readonly #brand = true;
readonly invocationId: string;
readonly seq: number;
readonly outcome: Outcome;
private constructor(key: typeof door, invocationId: string, position: number, outcome: Outcome) {
if (key !== door) throw new Error("a CommandResult is built by its factories");
this.invocationId = invocationId;
this.seq = seq.encode(position);
this.outcome = outcome;
Object.freeze(this);
}
static {
mint = (invocationId, position, outcome) => new CommandResult(door, invocationId, position, outcome);
}
static pending(invocation: CommandInvocation, position: number, message: string): CommandResult {
return mint(invocation.invocationId, position, { kind: "Pending", message });
}
static completed(
invocation: CommandInvocation,
position: number,
sourceVersion: number | bigint | null,
clearDrafts: boolean,
effect: Effect | null,
): CommandResult {
return mint(invocation.invocationId, position, { kind: "Completed", sourceVersion, clearDrafts, effect });
}
static denied(invocation: CommandInvocation, position: number, reason: string, node: NodeId | null): CommandResult {
return mint(invocation.invocationId, position, { kind: "Denied", reason, node });
}
static failed(invocation: CommandInvocation, position: number, reason: string): CommandResult {
return mint(invocation.invocationId, position, { kind: "Failed", reason });
}
/** True for an instance a factory or the codec made; false for every other value, whatever its shape or prototype. */
static isResult(value: unknown): value is CommandResult {
return typeof value === "object" && value !== null && #brand in value;
}
}
// The command_result document codec: the slot record, with decode minting an instance. Encode reads the instance's
// fields through the same slot record, so the seq domain is held on both doors.
const slotRecord = codecs.slots<{ invocationId: string; seq: number; outcome: Outcome }>(
{ 0: ["invocationId", codecs.string], 1: ["seq", seq], 2: ["outcome", outcome] },
"GameCult.Thing.Contract.CommandResult",
);
export const commandResult: SlotsCodec<CommandResult> = {
names: slotRecord.names,
typeName: slotRecord.typeName,
get members() {
return slotRecord.members;
},
encode: (value) => slotRecord.encode(value),
decode: (raw) => {
const { invocationId, seq: position, outcome: answer } = slotRecord.decode(raw);
return mint(invocationId, position, answer);
},
};

View file

@ -0,0 +1,369 @@
// One TypeScript type per type of crates/thing-contract. A record is an interface of readonly camelCase fields; a closed
// set is a union discriminated on `kind`, the Rust arm name, and an arm carries its payload's fields beside the tag (a
// unit arm is just the tag). Option<T> is `T | null`, Vec<T> is `readonly T[]`, bytes are a Uint8Array.
//
// The value domains (Seq, Version, Count, Segment, an Rgba channel) are held by the codecs in domain.ts, not by these
// types: a type cannot say "at least 1". Every document is minted and checked by its codec.
type Arm<Kind extends string, Payload = unknown> = { readonly kind: Kind } & Payload;
export type ProviderId = string;
export type SchemaId = string;
export type RecordKey = string;
export type NodeId = string;
export type CommandId = string;
// source.rs
export interface ParamName { readonly name: string }
export interface TextLiteral { readonly value: string }
export interface NumberLiteral { readonly value: number }
export interface BoolLiteral { readonly value: boolean }
export type TextSource = Arm<"Literal", TextLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>;
export type NumberSource = Arm<"Literal", NumberLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>;
export type BoolSource = Arm<"Literal", BoolLiteral> | Arm<"Bound", Bind> | Arm<"Param", ParamName>;
export interface Bind {
readonly source: StateSource;
readonly member: MemberPath;
readonly format: Format | null;
}
export type StateSource = Arm<"Record", StateRef> | Arm<"Item">;
export interface StateRef {
readonly provider: ProviderId | null;
readonly schema: SchemaId;
readonly key: RecordKey;
}
export interface MemberPath { readonly steps: readonly MemberStep[] }
export type MemberStep = Arm<"Name", MemberName> | Arm<"Index", MemberIndex>;
export interface MemberName { readonly name: string }
/** `index` is a Count: at least 0. */
export interface MemberIndex { readonly index: number }
export type Format = Arm<"Number", NumberFormat> | Arm<"Duration"> | Arm<"Timestamp"> | Arm<"Affix", AffixFormat>;
/** `precision` is a Count: at least 0. */
export interface NumberFormat {
readonly precision: number;
readonly percent: boolean;
readonly unit: string | null;
}
export interface AffixFormat { readonly prefix: string; readonly suffix: string }
export type CollectionSource = Arm<"Records", RecordsSource> | Arm<"Member", Bind> | Arm<"Param", ParamName>;
export interface RecordsSource {
readonly provider: ProviderId | null;
readonly schema: SchemaId;
readonly keyPrefix: string;
}
// style.rs
export interface Scalar { readonly value: number }
export type Dimension =
| Arm<"Px", Scalar>
| Arm<"Percent", Scalar>
| Arm<"Fr", Scalar>
| Arm<"Auto">
| Arm<"Content">;
export interface Size2 { readonly width: Dimension; readonly height: Dimension }
export interface Edges {
readonly top: Dimension;
readonly right: Dimension;
readonly bottom: Dimension;
readonly left: Dimension;
}
export type Display = Arm<"Flex"> | Arm<"Grid"> | Arm<"Block">;
export type Direction = Arm<"Row"> | Arm<"Column"> | Arm<"RowReverse"> | Arm<"ColumnReverse">;
export type Wrap = Arm<"NoWrap"> | Arm<"Wrap"> | Arm<"WrapReverse">;
export type Position = Arm<"Relative"> | Arm<"Absolute">;
export type Overflow = Arm<"Visible"> | Arm<"Clip"> | Arm<"Scroll">;
export type ItemAlign = Arm<"Auto"> | Arm<"Start"> | Arm<"End"> | Arm<"Center"> | Arm<"Stretch"> | Arm<"Baseline">;
export type ContentAlign =
| Arm<"Auto">
| Arm<"Start">
| Arm<"End">
| Arm<"Center">
| Arm<"Stretch">
| Arm<"SpaceBetween">
| Arm<"SpaceAround">
| Arm<"SpaceEvenly">;
export interface BoxStyle {
readonly display: Display;
readonly direction: Direction;
readonly wrap: Wrap;
readonly gap: Size2;
readonly padding: Edges;
readonly margin: Edges;
readonly size: Size2;
readonly minSize: Size2;
readonly maxSize: Size2;
readonly position: Position;
readonly inset: Edges;
readonly overflow: Overflow;
readonly alignItems: ItemAlign;
readonly alignSelf: ItemAlign;
readonly alignContent: ContentAlign;
readonly justifyContent: ContentAlign;
readonly grow: number;
readonly shrink: number;
readonly basis: Dimension;
readonly gridColumns: readonly Dimension[];
readonly gridRows: readonly Dimension[];
}
export interface ThemeToken { readonly name: string }
/** Each channel is a byte: 0 to 255. */
export interface Rgba { readonly r: number; readonly g: number; readonly b: number; readonly a: number }
export type ColorRef = Arm<"Token", ThemeToken> | Arm<"Rgba", Rgba>;
export interface Border { readonly color: ColorRef; readonly width: number }
export interface Paint {
readonly background: ColorRef | null;
readonly border: Border | null;
readonly radius: number;
}
export type Role =
| Arm<"Group">
| Arm<"Region">
| Arm<"List">
| Arm<"ListItem">
| Arm<"Heading">
| Arm<"Dialog">
| Arm<"Toolbar">;
// command.rs
export interface CommandDecl {
readonly id: CommandId;
readonly payloadSchema: SchemaId;
readonly captures: readonly NodeId[];
readonly confirm: string | null;
}
export interface CommandIdRef { readonly id: CommandId }
export type CommandRef = Arm<"Id", CommandIdRef> | Arm<"Param", ParamName>;
export interface RecordPayload { readonly schema: SchemaId; readonly bytes: Uint8Array }
export interface TextValue { readonly value: string }
export interface NumberValue { readonly value: number }
export interface BoolValue { readonly value: boolean }
export interface ChoiceValue { readonly value: string }
export type InputValue = Arm<"Text", TextValue> | Arm<"Number", NumberValue> | Arm<"Bool", BoolValue> | Arm<"Choice", ChoiceValue>;
export interface CapturedInput { readonly node: NodeId; readonly value: InputValue }
/**
* A consumer's request that a provider run a declared command. Its fields are plain, so a result's copy of
* `invocationId` is a convenience and not provenance: core binds results to the invocations it issued.
* `surfaceVersion` is a Version: at least 0.
*/
export interface CommandInvocation {
readonly invocationId: string;
readonly surfaceKey: string;
readonly surfaceVersion: number | bigint;
readonly command: CommandId;
readonly payload: RecordPayload;
readonly captured: readonly CapturedInput[];
readonly client: string;
readonly issuedAt: string;
readonly delegation: string | null;
}
// result.rs
export interface PendingOutcome { readonly message: string }
/** `sourceVersion` is a Version: at least 0. */
export interface CompletedOutcome {
readonly sourceVersion: number | bigint | null;
readonly clearDrafts: boolean;
readonly effect: Effect | null;
}
export interface DeniedOutcome { readonly reason: string; readonly node: NodeId | null }
export interface FailedOutcome { readonly reason: string }
export type Outcome =
| Arm<"Pending", PendingOutcome>
| Arm<"Completed", CompletedOutcome>
| Arm<"Denied", DeniedOutcome>
| Arm<"Failed", FailedOutcome>;
export interface OpenSurfaceEffect { readonly key: string }
export interface OpenExternalEffect { readonly url: string; readonly resume: CommandRef }
export type Effect = Arm<"OpenSurface", OpenSurfaceEffect> | Arm<"OpenExternal", OpenExternalEffect>;
/** A provider's handler for one invocation: the outcome it answers with. */
export type Handler = (invocation: CommandInvocation) => Outcome | Promise<Outcome>;
// node.rs
export interface NodeCommon {
readonly id: NodeId;
readonly visible: BoolSource;
readonly a11yLabel: TextSource | null;
}
export type Node =
| Arm<"Box", BoxNode>
| Arm<"Text", TextNode>
| Arm<"Image", ImageNode>
| Arm<"Button", ButtonNode>
| Arm<"Toggle", ToggleNode>
| Arm<"Slider", SliderNode>
| Arm<"TextInput", TextInputNode>
| Arm<"Select", SelectNode>
| Arm<"Repeat", RepeatNode>
| Arm<"Overlay", OverlayNode>
| Arm<"Plot", PlotNode>
| Arm<"Embed", EmbedNode>
| Arm<"Use", UseNode>;
export interface BoxNode {
readonly common: NodeCommon;
readonly style: BoxStyle;
readonly paint: Paint;
readonly role: Role;
readonly children: readonly Node[];
}
/** `maxLines` is a Count: at least 0. */
export interface TextNode {
readonly common: NodeCommon;
readonly content: TextSource;
readonly styleToken: string;
readonly wrap: boolean;
readonly maxLines: number | null;
readonly selectable: boolean;
}
export type ImageFit = Arm<"Contain"> | Arm<"Cover"> | Arm<"Fill"> | Arm<"Unscaled">;
export interface ImageNode {
readonly common: NodeCommon;
readonly catalog: string;
readonly asset: string;
readonly fit: ImageFit;
readonly alt: TextSource;
}
export interface ButtonNode {
readonly common: NodeCommon;
readonly content: readonly Node[];
readonly onActivate: CommandRef;
readonly enabled: BoolSource;
}
export interface ToggleNode {
readonly common: NodeCommon;
readonly checked: BoolSource;
readonly onChange: CommandRef;
}
export interface SliderNode {
readonly common: NodeCommon;
readonly min: number;
readonly max: number;
readonly step: number;
readonly value: NumberSource;
readonly commit: CommandRef;
}
export type InputKind = Arm<"Text"> | Arm<"Number">;
/**
* The Rust field is `kind`; here it is `inputKind`, because a TextInput arm carries its payload beside the union's own
* `kind` tag and the two cannot share a name.
*/
export interface TextInputNode {
readonly common: NodeCommon;
readonly multiline: boolean;
readonly inputKind: InputKind;
readonly placeholder: TextSource;
readonly initial: TextSource;
readonly submit: CommandRef;
}
export interface SelectOption { readonly value: string; readonly label: string }
export interface LiteralOptions { readonly options: readonly SelectOption[] }
export interface BoundOptions {
readonly collection: CollectionSource;
readonly valueMember: MemberPath;
readonly labelMember: MemberPath;
}
export type OptionList = Arm<"Literal", LiteralOptions> | Arm<"Bound", BoundOptions>;
export interface SelectNode {
readonly common: NodeCommon;
readonly options: OptionList;
readonly value: Bind;
readonly onChange: CommandRef;
}
export interface RepeatNode {
readonly common: NodeCommon;
readonly collection: CollectionSource;
readonly keyMember: MemberPath;
readonly template: Node;
}
export interface AnchorNode { readonly node: NodeId }
export type Anchor = Arm<"Viewport"> | Arm<"Node", AnchorNode>;
export interface OverlayNode {
readonly common: NodeCommon;
readonly anchor: Anchor;
readonly modal: boolean;
readonly dismiss: CommandRef;
readonly children: readonly Node[];
}
export type PlotMark = Arm<"Line"> | Arm<"Bar"> | Arm<"Area">;
export interface PlotNode {
readonly common: NodeCommon;
readonly series: Bind;
readonly mark: PlotMark;
readonly xMin: number | null;
readonly xMax: number | null;
readonly yMin: number | null;
readonly yMax: number | null;
}
export interface EmbedNode {
readonly common: NodeCommon;
readonly slotKind: string;
readonly slotId: string;
readonly width: number;
readonly height: number;
}
/** `name` is a Segment (non-empty, no '.'); `version` is a Version (at least 0). */
export interface CompositionRef {
readonly publisher: string;
readonly name: string;
readonly version: number | bigint;
}
export interface UseNode {
readonly common: NodeCommon;
readonly composition: CompositionRef;
readonly args: readonly Arg[];
}
export interface Arg { readonly name: string; readonly value: ArgValue }
export interface TextArg { readonly value: string }
export interface NumberArg { readonly value: number }
export interface BoolArg { readonly value: boolean }
export interface CollectionArg { readonly source: CollectionSource }
export interface CommandArg { readonly command: CommandRef }
export interface SlotArg { readonly nodes: readonly Node[] }
export type ArgValue =
| Arm<"Text", TextArg>
| Arm<"Number", NumberArg>
| Arm<"Bool", BoolArg>
| Arm<"Bind", Bind>
| Arm<"Collection", CollectionArg>
| Arm<"Command", CommandArg>
| Arm<"Slot", SlotArg>;
export type ParamKind =
| Arm<"Text">
| Arm<"Number">
| Arm<"Bool">
| Arm<"Bind">
| Arm<"Collection">
| Arm<"Command">
| Arm<"Slot">;
export interface ParamDecl { readonly name: string; readonly kind: ParamKind }
// The documents. `label`, `name` and `catalogId` are Segments, `version` a Version, an extent a Count. The fifth
// document, CommandResult, is a class (result.ts).
export interface SurfaceDocument {
readonly providerId: ProviderId;
readonly label: string;
readonly version: number | bigint;
readonly title: string;
readonly root: Node;
readonly commands: readonly CommandDecl[];
}
export interface CompositionDocument {
readonly publisher: string;
readonly name: string;
readonly version: number | bigint;
readonly params: readonly ParamDecl[];
readonly body: Node;
}
export interface AssetEntry {
readonly id: string;
readonly mediaType: string;
readonly uri: string;
readonly width: number;
readonly height: number;
}
export interface AssetCatalog {
readonly providerId: ProviderId;
readonly catalogId: string;
readonly version: number | bigint;
readonly assets: readonly AssetEntry[];
}

View file

@ -0,0 +1,478 @@
// The values the goldens are built from, ported from crates/thing-contract/tests/common/mod.rs and goldens.rs. Every
// value is an object literal of the package's exported types; no bytes are spelled here. The golden files are read, never
// written: the Rust crate blesses them.
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { CommandResult } from "@gamecult/thing-contract";
import type {
Arg,
AssetCatalog,
BoxNode,
Bind,
BoolSource,
CollectionSource,
CommandInvocation,
CommandRef,
CompositionDocument,
Dimension,
Edges,
Format,
MemberPath,
Node,
NodeCommon,
NumberSource,
Paint,
Size2,
SurfaceDocument,
TextSource,
BoxStyle,
} from "@gamecult/thing-contract";
export const CANARY = "CANARY-7f3a91c2";
export const PINNED_CLOCK = "2026-01-01T00:00:00Z";
/** The goldens directory, found by walking up from this file (a mutation sandbox sits deeper in the tree). */
export const goldensDir: string = (() => {
let here = dirname(fileURLToPath(import.meta.url));
for (;;) {
const candidate = join(here, "crates", "thing-contract", "tests", "goldens");
if (existsSync(candidate)) return candidate;
const parent = dirname(here);
if (parent === here) throw new Error("the Rust crate's goldens directory is not above this file");
here = parent;
}
})();
export function golden(file: string): Uint8Array {
return new Uint8Array(readFileSync(join(goldensDir, file)));
}
export const lit = (value: string): TextSource => ({ kind: "Literal", value });
export const litBool = (value: boolean): BoolSource => ({ kind: "Literal", value });
export const litNumber = (value: number): NumberSource => ({ kind: "Literal", value });
export const param = (name: string) => ({ kind: "Param", name }) as const;
export const path = (...names: string[]): MemberPath => ({ steps: names.map((name) => ({ kind: "Name", name }) as const) });
export const command = (id: string): CommandRef => ({ kind: "Id", id });
export function recordBind(member: MemberPath, format: Format | null): Bind {
return {
source: { kind: "Record", provider: "provider.a", schema: "gamecult.thing.test.v1", key: "row-1" },
member,
format,
};
}
export const itemBind = (member: MemberPath): Bind => ({ source: { kind: "Item" }, member, format: null });
export const common = (id: string): NodeCommon => ({ id, visible: litBool(true), a11yLabel: null });
export const px = (value: number): Dimension => ({ kind: "Px", value });
export const size2 = (width: Dimension, height: Dimension): Size2 => ({ width, height });
export const edges = (all: Dimension): Edges => ({ top: all, right: all, bottom: all, left: all });
export function style(): BoxStyle {
return {
display: { kind: "Flex" },
direction: { kind: "Column" },
wrap: { kind: "NoWrap" },
gap: size2(px(4), px(8)),
padding: edges(px(2)),
margin: edges({ kind: "Auto" }),
size: size2({ kind: "Percent", value: 100 }, { kind: "Content" }),
minSize: size2(px(0), px(0)),
maxSize: size2({ kind: "Auto" }, { kind: "Auto" }),
position: { kind: "Relative" },
inset: edges({ kind: "Auto" }),
overflow: { kind: "Visible" },
alignItems: { kind: "Stretch" },
alignSelf: { kind: "Auto" },
alignContent: { kind: "Start" },
justifyContent: { kind: "SpaceBetween" },
grow: 1,
shrink: 0,
basis: { kind: "Auto" },
gridColumns: [],
gridRows: [],
};
}
export const plainPaint = (): Paint => ({ background: null, border: null, radius: 0 });
export function boxNode(id: string, children: readonly Node[]): BoxNode {
return { common: common(id), style: style(), paint: plainPaint(), role: { kind: "Group" }, children };
}
export const boxed = (id: string, children: readonly Node[]): Node => ({ kind: "Box", ...boxNode(id, children) });
export const text = (id: string, content: string): Node => ({
kind: "Text",
common: common(id),
content: lit(content),
styleToken: "body",
wrap: true,
maxLines: null,
selectable: false,
});
export function invocation(): CommandInvocation {
return {
invocationId: "3f2b8c1e-5d44-4a6b-9a07-1c2d3e4f5a6b",
surfaceKey: "thing:surface:provider.a.main",
surfaceVersion: 7,
command: "go",
payload: { schema: "gamecult.thing.test.go.v1", bytes: new Uint8Array([0x91, 0x01, 0xff, 0x00]) },
captured: [
{ node: "name-input", value: { kind: "Text", value: "Ada" } },
{ node: "level", value: { kind: "Number", value: 0.75 } },
{ node: "flag", value: { kind: "Bool", value: true } },
{ node: "pick", value: { kind: "Choice", value: "a" } },
],
client: "renderer.web",
issuedAt: PINNED_CLOCK,
delegation: "delegate-1",
};
}
export function bindAllFormats(): Bind[] {
const number: Format = { kind: "Number", precision: 2, percent: true, unit: "ms" };
const affix: Format = { kind: "Affix", prefix: "$", suffix: "/s" };
const indexed: MemberPath = { steps: [{ kind: "Name", name: "items" }, { kind: "Index", index: 3 }] };
return [
recordBind(indexed, number),
recordBind(path("elapsed"), { kind: "Duration" }),
recordBind(path("at"), { kind: "Timestamp" }),
recordBind(path("price"), affix),
recordBind(path("plain"), null),
itemBind(path("name")),
];
}
export function fullSurface(): SurfaceDocument {
const binds = bindAllFormats();
const boundText: TextSource = { kind: "Bound", ...binds[3] };
const boundBool: BoolSource = { kind: "Bound", ...binds[4] };
const boundNumber: NumberSource = { kind: "Bound", ...binds[0] };
const painted: Node = {
kind: "Box",
common: { id: "painted", visible: param("show"), a11yLabel: param("label") },
style: {
...style(),
display: { kind: "Grid" },
gridColumns: [{ kind: "Fr", value: 1 }, px(120)],
gridRows: [{ kind: "Content" }],
position: { kind: "Absolute" },
overflow: { kind: "Scroll" },
direction: { kind: "RowReverse" },
wrap: { kind: "WrapReverse" },
alignItems: { kind: "Baseline" },
alignSelf: { kind: "Center" },
alignContent: { kind: "SpaceAround" },
justifyContent: { kind: "SpaceEvenly" },
},
paint: {
background: { kind: "Token", name: "surface.raised" },
border: { color: { kind: "Rgba", r: 1, g: 2, b: 3, a: 255 }, width: 1.5 },
radius: 4,
},
role: { kind: "Region" },
children: [text("deep-text", "deep")],
};
const repeat: Node = {
kind: "Repeat",
common: common("rows"),
collection: { kind: "Records", provider: null, schema: "gamecult.thing.test.v1", keyPrefix: "row-" },
keyMember: path("id"),
template: {
kind: "Text",
common: common("row-name"),
content: { kind: "Bound", ...itemBind(path("name")) },
styleToken: "body",
wrap: false,
maxLines: 2,
selectable: true,
},
};
const leaves: Node[] = [
{
kind: "Text",
common: common("t-bound"),
content: boundText,
styleToken: "label",
wrap: true,
maxLines: null,
selectable: false,
},
{ kind: "Image", common: common("img"), catalog: "icons", asset: "gear", fit: { kind: "Cover" }, alt: lit("a gear") },
{
kind: "Button",
common: common("go"),
content: [text("go-label", "Go")],
onActivate: command("go"),
enabled: boundBool,
},
{ kind: "Toggle", common: common("flag"), checked: boundBool, onChange: param("toggle") },
{
kind: "Slider",
common: common("level"),
min: 0,
max: 1,
step: 0.25,
value: boundNumber,
commit: command("set-level"),
},
{
kind: "TextInput",
common: common("name-input"),
multiline: true,
inputKind: { kind: "Number" },
placeholder: lit("name"),
initial: param("initial"),
submit: command("rename"),
},
{
kind: "TextInput",
common: common("title-input"),
multiline: false,
inputKind: { kind: "Text" },
placeholder: lit("title"),
initial: param("initial"),
submit: command("retitle"),
},
{
kind: "Select",
common: common("pick"),
options: { kind: "Literal", options: [{ value: "a", label: "Alpha" }] },
value: binds[4],
onChange: command("pick"),
},
{
kind: "Select",
common: common("pick-bound"),
options: {
kind: "Bound",
collection: { kind: "Member", ...binds[0] },
valueMember: path("id"),
labelMember: path("name"),
},
value: binds[5],
onChange: command("pick"),
},
repeat,
{
kind: "Overlay",
common: common("menu"),
anchor: { kind: "Node", node: "go" },
modal: false,
dismiss: command("close"),
children: [text("menu-item", "one")],
},
{
kind: "Overlay",
common: common("dialog"),
anchor: { kind: "Viewport" },
modal: true,
dismiss: command("close"),
children: [],
},
{
kind: "Plot",
common: common("trend"),
series: binds[1],
mark: { kind: "Area" },
xMin: 0,
xMax: null,
yMin: null,
yMax: 100,
},
{ kind: "Embed", common: common("viewport"), slotKind: "world.scene", slotId: "main", width: 640, height: 480 },
{
kind: "Use",
common: common("kv"),
composition: { publisher: "thing.std", name: "key-value", version: 1 },
args: [{ name: "key", value: { kind: "Text", value: "k" } }],
},
];
let deep: Node = boxed("d4", [painted]);
for (const id of ["d3", "d2", "d1"]) deep = boxed(id, [deep]);
const children: Node[] = [deep, ...leaves];
for (const [id, mark] of [
["trend-line", { kind: "Line" }],
["trend-bar", { kind: "Bar" }],
] as const) {
children.push({
kind: "Plot",
common: common(id),
series: binds[2],
mark,
xMin: null,
xMax: null,
yMin: null,
yMax: null,
});
}
(["Contain", "Fill", "Unscaled"] as const).forEach((fit, index) => {
children.push({
kind: "Image",
common: common(`img-fit-${index}`),
catalog: "icons",
asset: "gear",
fit: { kind: fit },
alt: lit(""),
});
});
(["List", "ListItem", "Heading", "Dialog", "Toolbar"] as const).forEach((role, index) => {
const roleStyle: BoxStyle =
index === 0
? { ...style(), direction: { kind: "Row" }, alignContent: { kind: "Auto" }, justifyContent: { kind: "Stretch" } }
: style();
children.push({
kind: "Box",
common: common(`role-${index}`),
style: roleStyle,
paint: plainPaint(),
role: { kind: role },
children: [],
});
});
children.push({
kind: "Box",
common: common("block"),
style: {
...style(),
display: { kind: "Block" },
direction: { kind: "ColumnReverse" },
wrap: { kind: "Wrap" },
overflow: { kind: "Clip" },
alignContent: { kind: "Center" },
justifyContent: { kind: "End" },
alignItems: { kind: "End" },
alignSelf: { kind: "Start" },
basis: { kind: "Content" },
},
paint: plainPaint(),
role: { kind: "Group" },
children: [],
});
children.push({
kind: "Text",
common: common("t-param"),
content: param("title"),
styleToken: "title",
wrap: true,
maxLines: null,
selectable: false,
});
children.push({
kind: "Toggle",
common: { id: "literal-number", visible: param("v"), a11yLabel: null },
checked: litBool(false),
onChange: command("toggle"),
});
children.push({
kind: "Slider",
common: common("slider-literal"),
min: -1,
max: 1,
step: 0.5,
value: litNumber(0.5),
commit: command("set-level"),
});
children.push({
kind: "Slider",
common: common("slider-param"),
min: 0,
max: 10,
step: 1,
value: param("n"),
commit: command("set-level"),
});
const rows: CollectionSource = param("rows");
children.push({
kind: "Repeat",
common: common("rows-param"),
collection: rows,
keyMember: path("id"),
template: text("row-param", "r"),
});
return {
providerId: "provider.a",
label: "main",
version: 7,
title: "All arms",
root: boxed("root", children),
commands: [
{ id: "go", payloadSchema: "gamecult.thing.test.go.v1", captures: ["name-input", "level"], confirm: "Go now?" },
{ id: "close", payloadSchema: "gamecult.thing.test.close.v1", captures: [], confirm: null },
],
};
}
export function fullComposition(): CompositionDocument {
const args: Arg[] = [
{ name: "text", value: { kind: "Text", value: "t" } },
{ name: "number", value: { kind: "Number", value: 2.5 } },
{ name: "bool", value: { kind: "Bool", value: true } },
{ name: "bind", value: { kind: "Bind", ...recordBind(path("x"), null) } },
{ name: "collection", value: { kind: "Collection", source: param("rows") } },
{ name: "command", value: { kind: "Command", command: command("go") } },
{ name: "slot", value: { kind: "Slot", nodes: [text("slot-text", "inside"), boxed("slot-box", [])] } },
];
const innerUse: Node = {
kind: "Use",
common: common("inner"),
composition: { publisher: "thing.std", name: "card", version: 2 },
args,
};
return {
publisher: "provider.a",
name: "panel",
version: 3,
params: [
{ name: "title", kind: { kind: "Text" } },
{ name: "count", kind: { kind: "Number" } },
{ name: "on", kind: { kind: "Bool" } },
{ name: "source", kind: { kind: "Bind" } },
{ name: "rows", kind: { kind: "Collection" } },
{ name: "act", kind: { kind: "Command" } },
{ name: "body", kind: { kind: "Slot" } },
],
body: boxed("panel-root", [text("panel-title", "t"), innerUse]),
};
}
export function results(answered: CommandInvocation): [string, CommandResult][] {
return [
["result-pending", CommandResult.pending(answered, 1, "working")],
["result-completed", CommandResult.completed(answered, 2, 8, true, null)],
[
"result-completed-open-surface",
CommandResult.completed(answered, 3, null, false, { kind: "OpenSurface", key: "thing:surface:provider.a.next" }),
],
[
"result-completed-open-external",
CommandResult.completed(answered, 4, 9, true, {
kind: "OpenExternal",
url: "https://example.test/auth",
resume: command("resume"),
}),
],
["result-denied", CommandResult.denied(answered, 5, "not yours", "go")],
["result-denied-no-node", CommandResult.denied(answered, 6, "not allowed", null)],
["result-failed", CommandResult.failed(answered, 7, "broke")],
];
}
export function assetCatalogValue(): AssetCatalog {
return {
providerId: "provider.a",
catalogId: "icons",
version: 2,
assets: [
{ id: "gear", mediaType: "image/png", uri: "assets/gear.png", width: 32, height: 32 },
{ id: "bolt", mediaType: "image/svg+xml", uri: "assets/bolt.svg", width: 0, height: 0 },
],
};
}

View file

@ -0,0 +1,37 @@
// Every codec of the package is named GameCult.Thing.Contract.<RustName>, the reference's full type name, and every union
// names itself in its refusals. The exports are the lowerCamel of the Rust type names, so the test reads the expected name
// off the export.
import assert from "node:assert/strict";
import test from "node:test";
import { codecs } from "@gamecult/thing-contract";
const NS = "GameCult.Thing.Contract.";
const entries = Object.entries(codecs) as [string, { typeName?: string; decode(raw: unknown): unknown }][];
const rustName = (key: string) => key[0].toUpperCase() + key.slice(1);
test("the package exports ninety-five codecs", () => {
assert.equal(entries.length, 95);
});
test("every codec carries the reference's full type name", () => {
for (const [key, codec] of entries) assert.equal(codec.typeName, `${NS}${rustName(key)}`, key);
});
test("every union refuses an unknown arm by its own name, and the twenty-nine unions are all of them", () => {
const unions: string[] = [];
for (const [key, codec] of entries) {
let message: string;
try {
codec.decode([2 ** 30, []]);
continue;
} catch (error) {
message = (error as Error).message;
}
const named = /^Union (\w+): unknown arm key 1073741824 \(declared keys: [-\d, ]+\)\.$/.exec(message);
if (named === null) continue;
unions.push(key);
assert.equal(named[1], rustName(key), key);
}
assert.equal(unions.length, 29);
});

View file

@ -0,0 +1,76 @@
// The deepest tree the payload bound admits decodes and re-encodes on Node's default stack (no --stack-size), and one
// level deeper is refused with CultCachePayloadDepthError, never an abort. The Rust test of the same name needs a 16 MiB
// thread for this; here the stack holds, and the bound refuses first.
import assert from "node:assert/strict";
import test from "node:test";
import {
CultCachePayloadDepthError,
MAX_PAYLOAD_DEPTH,
decodeDocumentPayload,
documentPayloadBytes,
documentPayloadFromBytes,
encodeDocumentPayload,
} from "@gamecult/cultcache-ts";
import { surfaceDocument } from "@gamecult/thing-contract";
import type { Node, SurfaceDocument } from "@gamecult/thing-contract";
import { boxNode, boxed, text } from "./builders.js";
function chain(levels: number): SurfaceDocument {
let root: Node = text("leaf", "bottom");
for (let depth = levels - 1; depth >= 0; depth--) root = boxed(`deep-${depth}`, [root]);
return { providerId: "provider.a", label: "deep", version: 1, title: "deep", root, commands: [] };
}
const admits = (levels: number): boolean => {
try {
encodeDocumentPayload(surfaceDocument, chain(levels));
return true;
} catch (error) {
assert.ok(error instanceof CultCachePayloadDepthError, "the only refusal of a plain chain is the depth bound");
assert.equal(error.limit, MAX_PAYLOAD_DEPTH);
return false;
}
};
test("the deepest Box chain the bound admits round-trips on the default stack, and one more is refused", () => {
let deepest = 1;
while (admits(deepest + 1)) deepest++;
assert.equal(deepest, 83, "the bound admits 83 Box levels above the text leaf, 84 nodes deep");
assert.ok(!admits(deepest + 1));
const value = chain(deepest);
const bytes = documentPayloadBytes(encodeDocumentPayload(surfaceDocument, value));
const decoded = decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(bytes));
assert.deepStrictEqual(decoded, value);
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(surfaceDocument, decoded)), bytes);
});
test("a payload one container past the bound is refused with the depth error, before any codec reads it", () => {
const nested = (levels: number) => new Uint8Array([...new Array<number>(levels).fill(0x91), 0xc0]);
assert.throws(
() => decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(nested(MAX_PAYLOAD_DEPTH + 1))),
(error: unknown) => error instanceof CultCachePayloadDepthError && error.depth === MAX_PAYLOAD_DEPTH + 1,
);
// At the bound the walk admits it and the codec refuses the shape instead.
assert.throws(
() => decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(nested(MAX_PAYLOAD_DEPTH))),
(error: unknown) => error instanceof Error && !(error instanceof CultCachePayloadDepthError),
);
});
test("a wide tree of a thousand nodes round-trips", () => {
const children: Node[] = Array.from({ length: 998 }, (_, index) => text(`t${index}`, "x"));
children.push(boxed("tail", []));
const wide: SurfaceDocument = {
providerId: "provider.a",
label: "wide",
version: 1,
title: "wide",
root: { kind: "Box", ...boxNode("root", children) },
commands: [],
};
const bytes = documentPayloadBytes(encodeDocumentPayload(surfaceDocument, wide));
assert.deepStrictEqual(decodeDocumentPayload(surfaceDocument, documentPayloadFromBytes(bytes)), wide);
});

View file

@ -0,0 +1,86 @@
// The five definitions carry the crate's cultcache(type, schema) and, per document slot, the reference's member name
// and C# type name that a store's schema catalog is compared on.
import assert from "node:assert/strict";
import test from "node:test";
import {
assetCatalogDocument,
commandInvocationDocument,
commandResultDocument,
compositionDocument,
surfaceDocument,
thingContract,
} from "@gamecult/thing-contract";
const NS = "GameCult.Thing.Contract.";
const STRING = "System.String";
const list = (item: string) => `System.Collections.Generic.List<${NS}${item}>`;
const rustName: Record<string, string> = {
"gamecult.thing.surface": "SurfaceDocument",
"gamecult.thing.composition": "CompositionDocument",
"gamecult.thing.command_invocation": "CommandInvocation",
"gamecult.thing.command_result": "CommandResult",
"gamecult.thing.asset_catalog": "AssetCatalog",
};
const expected = [
[surfaceDocument, "gamecult.thing.surface", [
"0:ProviderId:" + STRING,
"1:Label:" + STRING,
"2:Version:System.Int64",
"3:Title:" + STRING,
`4:Root:${NS}Node`,
`5:Commands:${list("CommandDecl")}`,
]],
[compositionDocument, "gamecult.thing.composition", [
"0:Publisher:" + STRING,
"1:Name:" + STRING,
"2:Version:System.Int64",
`3:Params:${list("ParamDecl")}`,
`4:Body:${NS}Node`,
]],
[commandInvocationDocument, "gamecult.thing.command_invocation", [
"0:InvocationId:" + STRING,
"1:SurfaceKey:" + STRING,
"2:SurfaceVersion:System.Int64",
"3:Command:" + STRING,
`4:Payload:${NS}RecordPayload`,
`5:Captured:${list("CapturedInput")}`,
"6:Client:" + STRING,
"7:IssuedAt:" + STRING,
"8:Delegation:" + STRING,
]],
[commandResultDocument, "gamecult.thing.command_result", [
"0:InvocationId:" + STRING,
"1:Seq:System.Int32",
`2:Outcome:${NS}Outcome`,
]],
[assetCatalogDocument, "gamecult.thing.asset_catalog", [
"0:ProviderId:" + STRING,
"1:CatalogId:" + STRING,
"2:Version:System.Int64",
`3:Assets:${list("AssetEntry")}`,
]],
] as const;
for (const [definition, type, members] of expected) {
test(`${type}: type, schema and catalog members`, () => {
assert.equal(definition.type, type);
assert.equal(definition.schemaName, type);
assert.equal(definition.schemaVersion, `${type}.v1`);
assert.equal(definition.codec?.typeName, `${NS}${rustName[type]}`);
assert.deepEqual(
definition.members?.map((member) => `${member.slot}:${member.memberName}:${member.typeName}`),
members,
);
});
}
test("the registry lists the five definitions, in the crate's order", () => {
assert.deepEqual(
thingContract.definitions.map((definition) => definition.type),
expected.map(([, type]) => type),
);
assert.equal(new Set(thingContract.definitions).size, 5);
});

View file

@ -0,0 +1,141 @@
// A CommandResult exists only by a factory or by the decode door. Construction is attacked the way an outside consumer
// would: an object literal, JSON.parse output, structuredClone, spread, prototype tricks, the constructor, a Proxy. Each is
// refused where a result is encoded, and none is a CommandResult. The type-level half is test/types.ts.
import assert from "node:assert/strict";
import test from "node:test";
import { decodeDocumentPayload, documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload } from "@gamecult/cultcache-ts";
import { CommandResult, commandResultDocument } from "@gamecult/thing-contract";
import { CANARY, golden, invocation, results } from "./builders.js";
const answered = invocation();
const encodeResult = (value: unknown) => () => encodeDocumentPayload(commandResultDocument, value);
const BUILT = /a CommandResult is built by its factories/;
test("a factory copies the invocation id and holds the seq", () => {
const result = CommandResult.failed(answered, 5, "broke");
assert.equal(result.invocationId, answered.invocationId);
assert.equal(result.seq, 5);
assert.deepEqual(result.outcome, { kind: "Failed", reason: "broke" });
assert.ok(result instanceof CommandResult);
assert.ok(CommandResult.isResult(result));
});
test("each factory builds its outcome", () => {
assert.deepEqual(CommandResult.pending(answered, 1, "working").outcome, { kind: "Pending", message: "working" });
assert.deepEqual(CommandResult.completed(answered, 2, 8, true, null).outcome, {
kind: "Completed",
sourceVersion: 8,
clearDrafts: true,
effect: null,
});
assert.deepEqual(CommandResult.denied(answered, 3, "no", "go").outcome, { kind: "Denied", reason: "no", node: "go" });
});
test("a factory refuses seq below 1 naming Seq, and not the value", () => {
for (const bad of [0, -1, -(2 ** 31)]) {
for (const build of [
() => CommandResult.pending(answered, bad, "m"),
() => CommandResult.completed(answered, bad, null, false, null),
() => CommandResult.denied(answered, bad, "r", null),
() => CommandResult.failed(answered, bad, "r"),
]) {
assert.throws(build, (error: Error) => /Seq must be at least 1/.test(error.message) && !error.message.includes(String(bad)));
}
}
assert.throws(() => CommandResult.failed(answered, 1.5, "r"), /int32/);
assert.throws(() => CommandResult.failed(answered, 2 ** 31, "r"), /int32/);
assert.equal(CommandResult.failed(answered, 2 ** 31 - 1, "r").seq, 2 ** 31 - 1);
});
test("the decode door yields a CommandResult instance, equal to the factory's", () => {
for (const [file, value] of results(answered)) {
const decoded = decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden(`${file}.bin`)));
assert.ok(decoded instanceof CommandResult, file);
assert.ok(CommandResult.isResult(decoded), file);
assert.deepStrictEqual(decoded, value, file);
assert.ok(Object.isFrozen(decoded), file);
}
});
test("encoding a result a factory built succeeds, and re-decodes", () => {
const result = CommandResult.pending(answered, 1, "working");
const bytes = documentPayloadBytes(encodeDocumentPayload(commandResultDocument, result));
assert.deepEqual(bytes, golden("result-pending.bin"));
});
test("a copy of a result is not a result: structuredClone, spread and JSON.parse output are refused at encode", () => {
const result = CommandResult.failed(answered, 7, "broke");
for (const [name, copy] of [
["structuredClone", structuredClone(result)],
["spread", { ...result }],
["JSON.parse", JSON.parse(JSON.stringify(result)) as unknown],
["Object.assign", Object.assign({}, result)],
] as const) {
assert.ok(!CommandResult.isResult(copy), name);
assert.ok(!(copy instanceof CommandResult), name);
assert.throws(encodeResult(copy), BUILT, name);
}
});
test("an object literal is not a result", () => {
const literal = { invocationId: answered.invocationId, seq: 1, outcome: { kind: "Failed", reason: "broke" } };
assert.ok(!CommandResult.isResult(literal));
assert.throws(encodeResult(literal), BUILT);
for (const other of [null, undefined, 0, "", "completed", [], {}, () => 0]) {
assert.ok(!CommandResult.isResult(other));
assert.throws(encodeResult(other), BUILT);
}
});
test("prototype tricks do not make a result", () => {
const result = CommandResult.failed(answered, 7, "broke");
const viaCreate = Object.assign(Object.create(CommandResult.prototype) as object, result);
const viaSetPrototype = Object.setPrototypeOf({ ...result }, CommandResult.prototype) as object;
const viaProxy = new Proxy(result, {});
class Impostor extends (CommandResult as unknown as new () => object) {}
for (const [name, fake] of [["Object.create", viaCreate], ["setPrototypeOf", viaSetPrototype], ["Proxy", viaProxy]] as const) {
assert.ok(!CommandResult.isResult(fake), name);
assert.throws(encodeResult(fake), BUILT, name);
}
assert.throws(() => new Impostor(), BUILT);
});
test("the constructor is closed at run time as well as in types", () => {
const construct = CommandResult as unknown as new (...args: unknown[]) => CommandResult;
const outcome = { kind: "Failed", reason: "broke" };
assert.throws(() => new construct(answered.invocationId, 1, outcome), BUILT);
assert.throws(() => new construct(Symbol("door"), answered.invocationId, 1, outcome), BUILT);
assert.throws(() => new construct(undefined, answered.invocationId, 0, outcome), BUILT);
assert.throws(() => Reflect.construct(construct, [answered.invocationId, 0, outcome]), BUILT);
assert.throws(() => Reflect.construct(construct, [], CommandResult), BUILT);
});
test("a result is frozen: a script that ignores types cannot move its seq out of domain", () => {
const result = CommandResult.failed(answered, 7, "broke");
assert.ok(Object.isFrozen(result));
const writable = result as unknown as { seq: number; invocationId: string };
assert.throws(() => {
writable.seq = 0;
}, TypeError);
assert.throws(() => {
writable.invocationId = "other";
}, TypeError);
assert.equal(result.seq, 7);
assert.equal(result.invocationId, answered.invocationId);
});
test("an out-of-domain seq cannot cross the decode door, and no message echoes the payload", () => {
const message = (run: () => unknown) => {
try {
run();
} catch (error) {
return (error as Error).message;
}
return assert.fail("expected a refusal");
};
const refused = message(() => decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden("result-seq-zero.bin"))));
assert.match(refused, /Seq must be at least 1/);
assert.ok(!refused.includes(CANARY));
});

View file

@ -0,0 +1,48 @@
// The value goldens: each file is what the Rust crate writes for the builder's value. Encoding the ported builder gives
// the file byte for byte, decoding the file gives the value back, and re-encoding what was decoded gives the file again.
import assert from "node:assert/strict";
import test from "node:test";
import {
decodeDocumentPayload,
documentPayloadBytes,
documentPayloadFromBytes,
encodeDocumentPayload,
} from "@gamecult/cultcache-ts";
import {
assetCatalogDocument,
commandInvocationDocument,
commandResultDocument,
compositionDocument,
surfaceDocument,
} from "@gamecult/thing-contract";
import { assetCatalogValue, fullComposition, fullSurface, golden, invocation, results } from "./builders.js";
type Case = [file: string, definition: Parameters<typeof encodeDocumentPayload>[0], value: unknown];
const answered = invocation();
const cases: Case[] = [
["surface-all-arms.bin", surfaceDocument, fullSurface()],
["composition-all-params.bin", compositionDocument, fullComposition()],
["invocation-all-inputs.bin", commandInvocationDocument, answered],
...results(answered).map(([name, value]): Case => [`${name}.bin`, commandResultDocument, value]),
["asset-catalog.bin", assetCatalogDocument, assetCatalogValue()],
];
test("the case table names the eleven value goldens", () => {
assert.equal(cases.length, 11);
assert.equal(new Set(cases.map(([file]) => file)).size, 11);
assert.equal(cases.filter(([, definition]) => definition === commandResultDocument).length, 7);
});
for (const [file, definition, value] of cases) {
test(`${file}: the builder encodes to the golden, the golden decodes to the builder, and re-encodes identical`, () => {
const committed = golden(file);
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(definition, value)), committed);
const decoded = decodeDocumentPayload(definition, documentPayloadFromBytes(committed));
assert.deepStrictEqual(decoded, value);
assert.deepEqual(documentPayloadBytes(encodeDocumentPayload(definition, decoded)), committed);
});
}

View file

@ -0,0 +1,67 @@
// The key grammar of crates/thing-contract/src/keys.rs and the cases of tests/result.rs: keys are built from the fields,
// every local segment is a Segment, and two different field sets never build one key.
import assert from "node:assert/strict";
import test from "node:test";
import { assetCatalogKey, compositionKey, invocationKey, resultKey, surfaceKey } from "@gamecult/thing-contract";
import { CANARY } from "./builders.js";
const builders: [string, (segment: string) => string][] = [
["surfaceKey", (segment) => surfaceKey("p", segment)],
["compositionKey name", (segment) => compositionKey("pub", segment, 1)],
["assetCatalogKey", (segment) => assetCatalogKey("p", segment)],
];
for (const [name, build] of builders) {
test(`${name} refuses an empty, a dot and a dotted segment, naming Segment and not the value`, () => {
assert.throws(() => build(""), /Segment must be non-empty/);
assert.throws(() => build("."), /Segment must be free of '\.'/);
assert.throws(() => build("a.b"), /Segment must be free of '\.'/);
assert.throws(
() => build(`${CANARY}.x`),
(error: Error) => /Segment/.test(error.message) && !error.message.includes(CANARY),
);
});
}
test("the keys are spelled from the fields", () => {
assert.equal(invocationKey("an-invocation-no-constructor-can-guess"), "thing:invocation:an-invocation-no-constructor-can-guess");
assert.equal(resultKey("an-invocation-no-constructor-can-guess", 3), "thing:result:an-invocation-no-constructor-can-guess.3");
assert.equal(surfaceKey("p", "main"), "thing:surface:p.main");
assert.equal(compositionKey("pub", "card", 2), "thing:composition:pub.card.v2");
assert.equal(compositionKey("pub", "card", 2n ** 40n), "thing:composition:pub.card.v1099511627776");
assert.equal(assetCatalogKey("p", "icons"), "thing:asset_catalog:p.icons");
});
test("a result key refuses a seq below 1 and a composition key a version below 0", () => {
assert.throws(() => resultKey("x", 0), /Seq must be at least 1/);
assert.throws(() => compositionKey("pub", "card", -1), /Version must be at least 0/);
assert.equal(compositionKey("pub", "card", 0), "thing:composition:pub.card.v0");
assert.throws(() => compositionKey("pub", "card", 1.5), /int64/);
assert.throws(() => resultKey("x", 1.5), /int32/);
assert.throws(() => resultKey("x", 2 ** 31), /int32/);
});
test("keys are injective over their field sets", () => {
// A dotted provider with a plain label, and a plain provider with a dotted label: the second cannot be built.
assert.equal(surfaceKey("a.b", "c"), "thing:surface:a.b.c");
assert.throws(() => surfaceKey("a", "b.c"), /Segment/);
assert.notEqual(surfaceKey("a", "c"), surfaceKey("a.b", "c"));
assert.notEqual(surfaceKey("a.b", "c"), surfaceKey("a", "bc"));
// A composition splits at its last two dots: publisher, name, then the version.
assert.equal(compositionKey("a.b", "c", 1), "thing:composition:a.b.c.v1");
assert.notEqual(compositionKey("a.b", "c", 1), compositionKey("a", "bc", 1));
assert.notEqual(compositionKey("a", "c", 1), compositionKey("a", "c", 11));
assert.notEqual(compositionKey("a", "c", 1), compositionKey("a", "c", 0));
// A catalog likewise, under the asset_catalog prefix.
assert.equal(assetCatalogKey("a.b", "c"), "thing:asset_catalog:a.b.c");
assert.notEqual(assetCatalogKey("a.b", "c"), assetCatalogKey("a", "bc"));
// A result splits at its last dot, and the seq is digits.
assert.equal(resultKey("x.1", 2), "thing:result:x.1.2");
assert.equal(resultKey("x", 12), "thing:result:x.12");
assert.notEqual(resultKey("x.1", 2), resultKey("x", 12));
});

View file

@ -0,0 +1,148 @@
// The refusal goldens and the domain doors. A payload is refused by name (the union and the arm key, or the domain), and
// no message echoes a value: the goldens carry CANARY in the slots a refusal must not repeat.
import assert from "node:assert/strict";
import test from "node:test";
import {
decodeDocumentPayload,
defineDocumentType,
documentPayloadFromBytes,
encodeDocumentPayload,
} from "@gamecult/cultcache-ts";
import {
CommandResult,
assetCatalogDocument,
codecs,
commandResultDocument,
domain,
surfaceDocument,
} from "@gamecult/thing-contract";
import { CANARY, assetCatalogValue, fullSurface, golden, invocation } from "./builders.js";
const rgbaDocument = defineDocumentType({
type: "test.rgba",
schema: { parse: (input: unknown) => input },
codec: codecs.rgba,
});
function refusal(run: () => unknown): string {
try {
run();
} catch (error) {
assert.ok(error instanceof Error);
assert.ok(!error.message.includes(CANARY), `the refusal echoed a value: ${error.message}`);
return error.message;
}
return assert.fail("expected a refusal");
}
const read = (definition: Parameters<typeof decodeDocumentPayload>[0], file: string) => () =>
decodeDocumentPayload(definition, documentPayloadFromBytes(golden(file)));
test("a Thing 1 flat map is not a command result", () => {
refusal(read(commandResultDocument, "result-thing1-flat.bin"));
});
test("an unknown Node arm is refused naming the union and the key", () => {
assert.match(refusal(read(surfaceDocument, "node-unknown-arm.bin")), /Union Node: unknown arm key 13/);
});
test("an unknown Outcome arm is refused naming the union and the key", () => {
assert.match(refusal(read(commandResultDocument, "outcome-unknown-arm.bin")), /Union Outcome: unknown arm key 4/);
});
test("an extra trailing slot is read and never written back", () => {
const decoded = decodeDocumentPayload(commandResultDocument, documentPayloadFromBytes(golden("result-extra-slot.bin")));
const expected = CommandResult.pending({ ...invocation(), invocationId: "inv-1" }, 1, "working");
assert.deepStrictEqual(decoded, expected);
assert.ok(!JSON.stringify(decoded).includes(CANARY));
});
test("each domain golden is refused naming its domain", () => {
assert.match(refusal(read(commandResultDocument, "result-seq-zero.bin")), /Seq must be at least 1/);
assert.match(refusal(read(surfaceDocument, "surface-version-negative.bin")), /Version must be at least 0/);
assert.match(refusal(read(surfaceDocument, "surface-label-dotted.bin")), /Segment must be free of '\.'/);
assert.match(refusal(read(assetCatalogDocument, "asset-extent-negative.bin")), /Count must be at least 0/);
assert.match(refusal(read(rgbaDocument, "rgba-channel-out-of-range.bin")), /Rgba channel must be 0 to 255/);
});
test("the encode door holds the same domains", () => {
const surface = fullSurface();
const catalog = assetCatalogValue();
const encodeSurface = (value: unknown) => () => encodeDocumentPayload(surfaceDocument, value);
assert.match(refusal(encodeSurface({ ...surface, label: `${CANARY}.x` })), /Segment must be free of '\.'/);
assert.match(refusal(encodeSurface({ ...surface, label: "" })), /Segment must be non-empty/);
assert.match(refusal(encodeSurface({ ...surface, label: null })), /label/);
assert.match(refusal(encodeSurface({ ...surface, version: -1 })), /Version must be at least 0/);
assert.match(refusal(encodeSurface({ ...surface, version: -(2n ** 40n) })), /Version must be at least 0/);
assert.match(
refusal(() => encodeDocumentPayload(assetCatalogDocument, { ...catalog, assets: [{ ...catalog.assets[0], width: -5 }] })),
/Count must be at least 0/,
);
assert.match(
refusal(() => encodeDocumentPayload(rgbaDocument, { r: 256, g: 0, b: 0, a: 255 })),
/Rgba channel must be 0 to 255/,
);
assert.match(
refusal(() => encodeDocumentPayload(rgbaDocument, { r: 0, g: -1, b: 0, a: 255 })),
/Rgba channel must be 0 to 255/,
);
});
test("each domain accepts its boundary values and refuses the step beyond, on both doors", () => {
assert.equal(domain.seq.decode(1), 1);
assert.equal(domain.seq.decode(2 ** 31 - 1), 2 ** 31 - 1);
assert.throws(() => domain.seq.decode(0), /Seq must be at least 1/);
assert.throws(() => domain.seq.decode(2 ** 31), /int32/);
assert.throws(() => domain.seq.encode(0), /Seq must be at least 1/);
assert.equal(domain.seq.encode(1), 1);
assert.equal(domain.version.decode(0), 0);
assert.equal(domain.version.decode(2n ** 62n), 2n ** 62n);
assert.throws(() => domain.version.decode(-1), /Version must be at least 0/);
assert.throws(() => domain.version.encode(-1n), /Version must be at least 0/);
assert.equal(domain.version.encode(0), 0);
assert.equal(domain.count.decode(0), 0);
assert.throws(() => domain.count.decode(-1), /Count must be at least 0/);
assert.throws(() => domain.count.encode(-1), /Count must be at least 0/);
assert.equal(domain.count.encode(0), 0);
assert.equal(domain.segment.decode("a"), "a");
assert.throws(() => domain.segment.decode(""), /Segment must be non-empty/);
assert.throws(() => domain.segment.decode("."), /Segment must be free of '\.'/);
assert.throws(() => domain.segment.encode("a.b"), /Segment must be free of '\.'/);
assert.throws(() => domain.segment.encode(""), /Segment must be non-empty/);
assert.equal(domain.segment.encode("a"), "a");
assert.equal(domain.channel.decode(0), 0);
assert.equal(domain.channel.decode(255), 255);
assert.throws(() => domain.channel.decode(256), /Rgba channel must be 0 to 255/);
assert.throws(() => domain.channel.decode(-1), /Rgba channel must be 0 to 255/);
assert.throws(() => domain.channel.encode(256), /Rgba channel must be 0 to 255/);
assert.throws(() => domain.channel.encode(-1), /Rgba channel must be 0 to 255/);
assert.equal(domain.channel.encode(255), 255);
assert.equal(domain.channel.encode(0), 0);
});
test("a domain refusal never echoes the value", () => {
for (const run of [
() => domain.segment.decode(`${CANARY}.x`),
() => domain.seq.decode(-777),
() => domain.version.decode(-777),
() => domain.count.decode(-777),
() => domain.channel.decode(777),
() => domain.seq.encode(-777),
() => domain.channel.encode(777),
]) {
const message = refusal(run);
assert.ok(!message.includes("777"), message);
}
});
test("a domain codec reports the reference's leaf type name", () => {
assert.equal(domain.seq.typeName, "System.Int32");
assert.equal(domain.count.typeName, "System.Int32");
assert.equal(domain.channel.typeName, "System.Int32");
assert.equal(domain.version.typeName, "System.Int64");
assert.equal(domain.segment.typeName, "System.String");
assert.equal(domain.version.valueType, true);
assert.equal(domain.segment.valueType, true);
});

View file

@ -0,0 +1,84 @@
// surface.cc is a single-file store the Rust crate wrote with one record of each kind under a pinned clock. Opened through
// CultCache with the five definitions it yields five records, each re-encodes to its value golden's bytes, and each record
// key equals the key keys.ts builds from the value.
import assert from "node:assert/strict";
import { join } from "node:path";
import test from "node:test";
import { CultCache, SingleFileMessagePackBackingStore, documentPayloadBytes } from "@gamecult/cultcache-ts";
import {
CommandResult,
assetCatalogDocument,
assetCatalogKey,
commandInvocationDocument,
commandResultDocument,
compositionDocument,
compositionKey,
invocationKey,
resultKey,
surfaceDocument,
surfaceKey,
thingContract,
} from "@gamecult/thing-contract";
import { PINNED_CLOCK, assetCatalogValue, fullComposition, fullSurface, golden, goldensDir, invocation } from "./builders.js";
async function open() {
const cache = CultCache.builder()
.withRegistry(thingContract)
.withGenericStore(new SingleFileMessagePackBackingStore(join(goldensDir, "surface.cc")))
.build();
await cache.pullAllBackingStores();
return cache;
}
test("surface.cc opens to one record of each of the five kinds", async () => {
const cache = await open();
const snapshot = cache.snapshot();
assert.equal(snapshot.length, 5);
assert.deepEqual(
snapshot.map((envelope) => envelope.type).sort(),
[
"gamecult.thing.asset_catalog",
"gamecult.thing.command_invocation",
"gamecult.thing.command_result",
"gamecult.thing.composition",
"gamecult.thing.surface",
],
);
for (const envelope of snapshot) assert.equal(envelope.storedAt, PINNED_CLOCK);
});
test("each record equals its value and re-encodes to its value golden", async () => {
const cache = await open();
const surface = fullSurface();
const composition = fullComposition();
const answered = invocation();
const result = CommandResult.pending(answered, 1, "working");
const catalog = assetCatalogValue();
const surfaceRecordKey = surfaceKey(surface.providerId, surface.label);
const compositionRecordKey = compositionKey(composition.publisher, composition.name, composition.version);
const invocationRecordKey = invocationKey(answered.invocationId);
const resultRecordKey = resultKey(result.invocationId, result.seq);
const catalogRecordKey = assetCatalogKey(catalog.providerId, catalog.catalogId);
assert.deepStrictEqual(cache.getRequired(surfaceDocument, surfaceRecordKey), surface);
assert.deepStrictEqual(cache.getRequired(compositionDocument, compositionRecordKey), composition);
assert.deepStrictEqual(cache.getRequired(commandInvocationDocument, invocationRecordKey), answered);
assert.deepStrictEqual(cache.getRequired(commandResultDocument, resultRecordKey), result);
assert.deepStrictEqual(cache.getRequired(assetCatalogDocument, catalogRecordKey), catalog);
const goldenOf: Record<string, string> = {
[surfaceRecordKey]: "surface-all-arms.bin",
[compositionRecordKey]: "composition-all-params.bin",
[invocationRecordKey]: "invocation-all-inputs.bin",
[resultRecordKey]: "result-pending.bin",
[catalogRecordKey]: "asset-catalog.bin",
};
const snapshot = cache.snapshot();
assert.deepEqual(snapshot.map((envelope) => envelope.key).sort(), Object.keys(goldenOf).sort());
for (const envelope of snapshot) {
assert.deepEqual(documentPayloadBytes(envelope.payload), golden(goldenOf[envelope.key]), envelope.key);
}
});

View file

@ -0,0 +1,45 @@
// Compile-time half of the construction rules, compiled by tsconfig.test.json: every @ts-expect-error line must be an
// error, or the compile fails. This file runs nothing.
import { CommandResult } from "@gamecult/thing-contract";
import type { CommandInvocation, Handler, Outcome } from "@gamecult/thing-contract";
declare const invocation: CommandInvocation;
declare const result: CommandResult;
declare const plain: { invocationId: string; seq: number; outcome: Outcome };
// An object literal does not satisfy CommandResult, because the class carries a private brand.
// @ts-expect-error
export const literal: CommandResult = { invocationId: "x", seq: 1, outcome: { kind: "Failed", reason: "r" } };
// Nor does a plain value of the same shape, nor a spread of a result.
// @ts-expect-error
export const shaped: CommandResult = plain;
// @ts-expect-error
export const spread: CommandResult = { ...result };
// The constructor is private: even a call whose arguments all type-check (the door argument is typed `never` here) is
// refused, so the error is the access, not the arity.
// @ts-expect-error
export const constructed = new CommandResult(undefined as never, "x", 1, { kind: "Failed", reason: "r" });
// The fields are read-only.
// @ts-expect-error
result.seq = 2;
// @ts-expect-error
result.invocationId = "y";
// @ts-expect-error
result.outcome = { kind: "Failed", reason: "r" };
// An Outcome is a tagged value, never a state string.
// @ts-expect-error
export const stringOutcome: Outcome = "completed";
// @ts-expect-error
export const stringFactory = CommandResult.failed(invocation, 1, "r", "completed", 3);
// @ts-expect-error
export const noInvocation = CommandResult.failed("inv-1", 1, "r");
// @ts-expect-error
export const stateInHandler: Handler = (): string => "completed";
// The positive cases compile.
export const built: CommandResult = CommandResult.failed(invocation, 1, "r");
export const handler: Handler = async () => ({ kind: "Pending", message: "m" });

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"declaration": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"newLine": "lf",
"noEmitOnError": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"newLine": "lf",
"noEmitOnError": true,
"outDir": "dist-test",
"rootDir": ".",
"strict": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["test/**/*.ts", "src/**/*.ts"]
}