@veil-runtime/ui 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +100 -2
- package/SECURITY.md +11 -0
- package/dist/application.d.ts +56 -0
- package/dist/application.js +9 -0
- package/dist/build-manifest.json +95 -0
- package/dist/capability-view-derivation.d.ts +40 -0
- package/dist/capability-view-derivation.js +185 -0
- package/dist/capability-view.d.ts +170 -0
- package/dist/capability-view.js +1 -0
- package/dist/composed-surface.d.ts +67 -0
- package/dist/composed-surface.js +1 -0
- package/dist/composition-inspection.d.ts +51 -0
- package/dist/composition-inspection.js +102 -0
- package/dist/composition.d.ts +43 -0
- package/dist/composition.js +227 -0
- package/dist/derive-input-fields.d.ts +4 -0
- package/dist/derive-input-fields.js +63 -0
- package/dist/derive-output-view.d.ts +26 -0
- package/dist/derive-output-view.js +97 -0
- package/dist/dom.d.ts +31 -0
- package/dist/dom.js +554 -0
- package/dist/execution-request.d.ts +13 -0
- package/dist/execution-request.js +58 -0
- package/dist/experience-orchestration.d.ts +13 -0
- package/dist/experience-orchestration.js +31 -0
- package/dist/format-semantic-value.d.ts +4 -0
- package/dist/format-semantic-value.js +15 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/input-schema.d.ts +25 -0
- package/dist/input-schema.js +131 -0
- package/dist/interaction-resolution.d.ts +27 -0
- package/dist/interaction-resolution.js +179 -0
- package/dist/interface-transaction.d.ts +9 -0
- package/dist/interface-transaction.js +14 -0
- package/dist/interface.d.ts +196 -0
- package/dist/interface.js +375 -0
- package/dist/output-presentation-suggestion-schema.d.ts +12 -0
- package/dist/output-presentation-suggestion-schema.js +8 -0
- package/dist/output-schema.d.ts +26 -0
- package/dist/output-schema.js +213 -0
- package/dist/presentation-composition.d.ts +29 -0
- package/dist/presentation-composition.js +25 -0
- package/dist/presentation-context.d.ts +6 -0
- package/dist/presentation-context.js +1 -0
- package/dist/presentation-suggestion-schema.d.ts +15 -0
- package/dist/presentation-suggestion-schema.js +10 -0
- package/dist/registry.d.ts +21 -0
- package/dist/registry.js +71 -0
- package/dist/relationship-interpretation.d.ts +9 -0
- package/dist/relationship-interpretation.js +16 -0
- package/dist/validate-application.d.ts +24 -0
- package/dist/validate-application.js +253 -0
- package/dist/validation-safety.d.ts +6 -0
- package/dist/validation-safety.js +90 -0
- package/dist/workflow.d.ts +18 -0
- package/dist/workflow.js +3 -0
- package/docs/API.md +19 -0
- package/docs/COMPOSITION.md +67 -0
- package/docs/DECISIONS.md +16 -0
- package/docs/RELEASE-0.3.0-rc.1.md +25 -0
- package/docs/RELEASE-0.3.0-rc.2.md +9 -0
- package/docs/RELEASE-0.3.0.md +7 -0
- package/docs/THIRD_PARTY.md +16 -0
- package/docs/UNIVERSAL-INTERFACE.md +105 -0
- package/docs/third-party/ZOD-LICENSE.txt +21 -0
- package/examples/composed/app.mjs +44 -0
- package/examples/composed/build.mjs +7 -0
- package/examples/composed/index.html +34 -0
- package/examples/standalone/index.mjs +78 -0
- package/examples/standalone/package.json +8 -0
- package/examples/universal/agent.mjs +52 -0
- package/examples/universal/application.mjs +16 -0
- package/examples/universal/build.mjs +6 -0
- package/examples/universal/human.mjs +16 -0
- package/examples/universal/index.html +4 -0
- package/examples/universal/main.mjs +7 -0
- package/package.json +90 -4
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { assertBoundedData, assertSafeKey, copyData } from "./validation-safety.js";
|
|
2
|
+
import { attachInterfaceRenderers, detachInterfaceRenderers } from "./interface-transaction.js";
|
|
3
|
+
const identifier = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
|
|
4
|
+
const commonProperties = ["key", "component", "visible", "ariaLabel"];
|
|
5
|
+
const vocabulary = {
|
|
6
|
+
Stack: { properties: [...commonProperties, "children"], required: [], children: true, event: null },
|
|
7
|
+
Row: { properties: [...commonProperties, "children"], required: [], children: true, event: null },
|
|
8
|
+
Text: { properties: [...commonProperties, "text"], required: [], children: false, event: null },
|
|
9
|
+
Heading: { properties: [...commonProperties, "text"], required: ["text"], children: false, event: null },
|
|
10
|
+
Status: { properties: [...commonProperties, "text"], required: [], children: false, event: null },
|
|
11
|
+
Input: { properties: [...commonProperties, "label", "binding", "inputType"], required: ["label", "binding"], children: false, event: "state change" },
|
|
12
|
+
Button: { properties: [...commonProperties, "text", "action"], required: ["text", "action"], children: false, event: "action" },
|
|
13
|
+
Table: { properties: [...commonProperties, "binding", "columns"], required: ["binding", "columns"], children: false, event: null },
|
|
14
|
+
List: { properties: [...commonProperties, "binding"], required: ["binding"], children: false, event: null },
|
|
15
|
+
};
|
|
16
|
+
const builtins = new Set(Object.keys(vocabulary));
|
|
17
|
+
const nodeKeys = new Set(["key", "component", "children", "text", "binding", "visible", "action", "label", "inputType", "columns", "ariaLabel"]);
|
|
18
|
+
/** JSON-safe description of the built-in vocabulary. Host extensions are trusted code and are never advertised here. */
|
|
19
|
+
export function getInterfaceCapabilities() {
|
|
20
|
+
return copyData({ version: 1, components: vocabulary, bindings: { sources: ["data", "state"], path: "dot-separated own-property path", missing: "empty display", visibility: "strict scalar equality" },
|
|
21
|
+
propertyTypes: { key: "identifier", component: "component name", children: "node array", text: "string or binding", binding: "binding", visible: "{binding,equals:scalar}", action: "declared action identifier", label: "string", inputType: ["text", "number", "checkbox"], columns: "array of {key:identifier,label:string}", ariaLabel: "string" },
|
|
22
|
+
events: { action: { fields: ["action", "source", "state", "revision"], declaration: "definition.actions and host allowedActions" }, state: { type: "scalar", source: "state binding" } },
|
|
23
|
+
limits: { treeDepth: 16, nodes: 512, textLength: 4096, tableRows: 100, tableColumns: 20, listItems: 100, transportBytes: 1048576 } });
|
|
24
|
+
}
|
|
25
|
+
const plain = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
26
|
+
const exact = (value, allowed) => {
|
|
27
|
+
for (const key of Object.keys(value))
|
|
28
|
+
if (!allowed.has(key))
|
|
29
|
+
throw new Error(`Unknown interface property: ${key}`);
|
|
30
|
+
};
|
|
31
|
+
const name = (value) => typeof value === "string" && identifier.test(value);
|
|
32
|
+
const scalar = (value) => value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value));
|
|
33
|
+
function binding(value) {
|
|
34
|
+
if (!plain(value))
|
|
35
|
+
throw new Error("Invalid binding");
|
|
36
|
+
exact(value, new Set(["source", "path"]));
|
|
37
|
+
if ((value.source !== "data" && value.source !== "state") || !name(value.path))
|
|
38
|
+
throw new Error("Invalid binding");
|
|
39
|
+
for (const part of value.path.split("."))
|
|
40
|
+
assertSafeKey(part);
|
|
41
|
+
}
|
|
42
|
+
function valueAt(source, path) {
|
|
43
|
+
let value = source;
|
|
44
|
+
for (const part of path.split("."))
|
|
45
|
+
value = plain(value) && Object.hasOwn(value, part) ? value[part] : undefined;
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function visitNodes(node, callback) {
|
|
49
|
+
callback(node);
|
|
50
|
+
for (const child of node.children ?? [])
|
|
51
|
+
visitNodes(child, callback);
|
|
52
|
+
}
|
|
53
|
+
function validateBindings(definition, data, state) {
|
|
54
|
+
visitNodes(definition.screen, (node) => {
|
|
55
|
+
if (node.component === "Table") {
|
|
56
|
+
const rows = valueAt(node.binding.source === "data" ? data : state, node.binding.path);
|
|
57
|
+
if (rows === undefined)
|
|
58
|
+
return;
|
|
59
|
+
if (!Array.isArray(rows) || rows.length > 100 || rows.some((row) => !plain(row)))
|
|
60
|
+
throw new Error("Invalid or oversized table rows");
|
|
61
|
+
for (const row of rows)
|
|
62
|
+
for (const col of node.columns ?? [])
|
|
63
|
+
if (Object.hasOwn(row, col.key) && !scalar(row[col.key]))
|
|
64
|
+
throw new Error("Invalid table cell");
|
|
65
|
+
}
|
|
66
|
+
if (node.component === "List") {
|
|
67
|
+
const items = valueAt(node.binding.source === "data" ? data : state, node.binding.path);
|
|
68
|
+
if (items !== undefined && (!Array.isArray(items) || items.length > 100 || items.some((item) => !scalar(item))))
|
|
69
|
+
throw new Error("Invalid or oversized list items");
|
|
70
|
+
}
|
|
71
|
+
if (node.component === "Input") {
|
|
72
|
+
const current = valueAt(state, node.binding.path);
|
|
73
|
+
if (current !== undefined && !scalar(current))
|
|
74
|
+
throw new Error("Invalid input state");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** A definition owns its state shape. Retain values only for unchanged input identities. */
|
|
79
|
+
function retainedState(previous, next, oldState) {
|
|
80
|
+
const state = copyData(next.state ?? {});
|
|
81
|
+
if (previous.id !== next.id)
|
|
82
|
+
return state;
|
|
83
|
+
const inputs = new Map();
|
|
84
|
+
visitNodes(previous.screen, (node) => { if (node.component === "Input")
|
|
85
|
+
inputs.set(node.key, node); });
|
|
86
|
+
visitNodes(next.screen, (node) => {
|
|
87
|
+
if (node.component !== "Input")
|
|
88
|
+
return;
|
|
89
|
+
const old = inputs.get(node.key);
|
|
90
|
+
if (!old || old.inputType !== node.inputType || old.binding?.path !== node.binding?.path)
|
|
91
|
+
return;
|
|
92
|
+
const path = node.binding.path;
|
|
93
|
+
const oldValue = valueAt(oldState, path);
|
|
94
|
+
const defaultValue = valueAt(state, path);
|
|
95
|
+
if (!scalar(oldValue) || defaultValue === undefined || typeof oldValue !== typeof defaultValue)
|
|
96
|
+
return;
|
|
97
|
+
const parts = path.split(".");
|
|
98
|
+
let target = state;
|
|
99
|
+
for (const part of parts.slice(0, -1)) {
|
|
100
|
+
if (!plain(target[part]))
|
|
101
|
+
return;
|
|
102
|
+
target = target[part];
|
|
103
|
+
}
|
|
104
|
+
target[parts.at(-1)] = oldValue;
|
|
105
|
+
});
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
export function defineInterface(definition) { return definition; }
|
|
109
|
+
/** Validate untrusted structured data before creating a controller or mounting it. */
|
|
110
|
+
export function validateInterfaceDefinition(value, components = []) {
|
|
111
|
+
assertBoundedData(value);
|
|
112
|
+
if (!plain(value))
|
|
113
|
+
throw new Error("Invalid interface definition");
|
|
114
|
+
exact(value, new Set(["id", "screen", "actions", "data", "state"]));
|
|
115
|
+
if (!name(value.id) || !plain(value.screen))
|
|
116
|
+
throw new Error("Invalid interface identity or screen");
|
|
117
|
+
for (const key of ["data", "state"])
|
|
118
|
+
if (value[key] !== undefined && !plain(value[key]))
|
|
119
|
+
throw new Error(`Invalid ${key}`);
|
|
120
|
+
if (value.actions !== undefined && (!Array.isArray(value.actions) || value.actions.some((action) => !name(action)) || new Set(value.actions).size !== value.actions.length))
|
|
121
|
+
throw new Error("Invalid actions");
|
|
122
|
+
const actions = (value.actions ?? []);
|
|
123
|
+
if (components.some((component) => !name(component) || builtins.has(component)) || new Set(components).size !== components.length)
|
|
124
|
+
throw new Error("Invalid component registry");
|
|
125
|
+
const known = new Set([...builtins, ...components]);
|
|
126
|
+
const keys = new Set();
|
|
127
|
+
let count = 0;
|
|
128
|
+
function visit(node, depth) {
|
|
129
|
+
if (!plain(node) || depth > 16 || ++count > 512)
|
|
130
|
+
throw new Error("Interface tree limit exceeded");
|
|
131
|
+
exact(node, nodeKeys);
|
|
132
|
+
if (!name(node.key) || keys.has(node.key))
|
|
133
|
+
throw new Error("Invalid or duplicate node key");
|
|
134
|
+
keys.add(node.key);
|
|
135
|
+
if (typeof node.component !== "string" || !known.has(node.component))
|
|
136
|
+
throw new Error(`Unknown component: ${String(node.component)}`);
|
|
137
|
+
if (builtins.has(node.component)) {
|
|
138
|
+
const spec = vocabulary[node.component];
|
|
139
|
+
for (const property of Object.keys(node))
|
|
140
|
+
if (!spec.properties.includes(property))
|
|
141
|
+
throw new Error(`Unsupported ${node.component} property: ${property}`);
|
|
142
|
+
for (const property of spec.required)
|
|
143
|
+
if (node[property] === undefined)
|
|
144
|
+
throw new Error(`${node.component} needs ${property}`);
|
|
145
|
+
}
|
|
146
|
+
if (node.text !== undefined) {
|
|
147
|
+
if (typeof node.text === "string") {
|
|
148
|
+
if (node.text.length > 4096)
|
|
149
|
+
throw new Error("Text limit exceeded");
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
binding(node.text);
|
|
153
|
+
}
|
|
154
|
+
if (node.binding !== undefined)
|
|
155
|
+
binding(node.binding);
|
|
156
|
+
if (node.visible !== undefined) {
|
|
157
|
+
if (!plain(node.visible))
|
|
158
|
+
throw new Error("Invalid visibility condition");
|
|
159
|
+
exact(node.visible, new Set(["binding", "equals"]));
|
|
160
|
+
binding(node.visible.binding);
|
|
161
|
+
if (!scalar(node.visible.equals))
|
|
162
|
+
throw new Error("Invalid visibility condition");
|
|
163
|
+
}
|
|
164
|
+
for (const field of ["label", "ariaLabel"])
|
|
165
|
+
if (node[field] !== undefined && (typeof node[field] !== "string" || node[field].length > 256))
|
|
166
|
+
throw new Error(`Invalid ${field}`);
|
|
167
|
+
if (node.action !== undefined && (!name(node.action) || !actions.includes(node.action)))
|
|
168
|
+
throw new Error("Action is not declared by the host");
|
|
169
|
+
if (node.inputType !== undefined && !["text", "number", "checkbox"].includes(node.inputType))
|
|
170
|
+
throw new Error("Invalid input type");
|
|
171
|
+
if (node.columns !== undefined) {
|
|
172
|
+
if (!Array.isArray(node.columns) || node.columns.length < 1 || node.columns.length > 20 || node.columns.some((col) => !plain(col) || Object.keys(col).some((key) => key !== "key" && key !== "label") || !name(col.key) || typeof col.label !== "string" || col.label.length > 256))
|
|
173
|
+
throw new Error("Invalid table columns");
|
|
174
|
+
}
|
|
175
|
+
if (node.component === "Input" && (!node.binding || node.binding.source !== "state" || !node.label))
|
|
176
|
+
throw new Error("Input needs a state binding and label");
|
|
177
|
+
if (node.component === "Button" && (!node.action || !node.text))
|
|
178
|
+
throw new Error("Button needs an action and text");
|
|
179
|
+
if (node.component === "Table" && (!node.binding || !node.columns))
|
|
180
|
+
throw new Error("Table needs rows and columns");
|
|
181
|
+
if (node.component !== "Button" && node.action !== undefined)
|
|
182
|
+
throw new Error("Only Button accepts an action");
|
|
183
|
+
if (node.component !== "Input" && node.inputType !== undefined)
|
|
184
|
+
throw new Error("Only Input accepts inputType");
|
|
185
|
+
if (node.component !== "Table" && node.columns !== undefined)
|
|
186
|
+
throw new Error("Only Table accepts columns");
|
|
187
|
+
if (node.children !== undefined && (!Array.isArray(node.children) || (!(["Stack", "Row"].includes(node.component)) && !components.includes(node.component))))
|
|
188
|
+
throw new Error("Invalid children");
|
|
189
|
+
for (const child of node.children ?? [])
|
|
190
|
+
visit(child, depth + 1);
|
|
191
|
+
}
|
|
192
|
+
visit(value.screen, 0);
|
|
193
|
+
validateBindings(value, (value.data ?? {}), (value.state ?? {}));
|
|
194
|
+
}
|
|
195
|
+
export function createInterfaceController(definition, options = {}) {
|
|
196
|
+
const components = options.components ?? [];
|
|
197
|
+
validateInterfaceDefinition(definition, components);
|
|
198
|
+
if (options.allowedActions && definition.actions?.some((action) => !options.allowedActions.includes(action)))
|
|
199
|
+
throw new Error("Action is not allowed by host");
|
|
200
|
+
let current = copyData(definition);
|
|
201
|
+
let data = copyData(definition.data ?? {});
|
|
202
|
+
let state = copyData(definition.state ?? {});
|
|
203
|
+
validateBindings(current, data, state);
|
|
204
|
+
let revision = 0;
|
|
205
|
+
let disposed = false;
|
|
206
|
+
let dispatching = false;
|
|
207
|
+
let emitting = false;
|
|
208
|
+
let pending = false;
|
|
209
|
+
let updating = false;
|
|
210
|
+
const listeners = new Set();
|
|
211
|
+
const participants = new Set();
|
|
212
|
+
const copySnapshot = (value) => ({
|
|
213
|
+
definition: copyData(value.definition), data: copyData(value.data), state: copyData(value.state), revision: value.revision,
|
|
214
|
+
});
|
|
215
|
+
const snapshot = () => copySnapshot({ definition: current, data, state, revision });
|
|
216
|
+
const report = (error) => { try {
|
|
217
|
+
options.onError?.(error);
|
|
218
|
+
}
|
|
219
|
+
catch { /* Error reporting must not enter the interface runtime. */ } };
|
|
220
|
+
const emit = () => {
|
|
221
|
+
if (emitting) {
|
|
222
|
+
pending = true;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
emitting = true;
|
|
226
|
+
try {
|
|
227
|
+
let passes = 0;
|
|
228
|
+
do {
|
|
229
|
+
pending = false;
|
|
230
|
+
const accepted = snapshot();
|
|
231
|
+
for (const listener of listeners) {
|
|
232
|
+
try {
|
|
233
|
+
listener(copySnapshot(accepted));
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
report(error);
|
|
237
|
+
}
|
|
238
|
+
if (revision !== accepted.revision) {
|
|
239
|
+
pending = true;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (++passes >= 32 && pending) {
|
|
244
|
+
report(new Error("Interface update loop limit exceeded"));
|
|
245
|
+
pending = false;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
} while (pending && !disposed);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
emitting = false;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const accept = (nextDefinition, nextData, nextState) => {
|
|
255
|
+
if (updating)
|
|
256
|
+
throw new Error("Interface update already in progress");
|
|
257
|
+
updating = true;
|
|
258
|
+
const prepared = [];
|
|
259
|
+
try {
|
|
260
|
+
const candidate = { definition: nextDefinition, data: nextData, state: nextState, revision: revision + 1 };
|
|
261
|
+
for (const renderer of participants)
|
|
262
|
+
prepared.push(renderer(copySnapshot(candidate)));
|
|
263
|
+
if (disposed)
|
|
264
|
+
throw new Error("Interface controller was disposed during update");
|
|
265
|
+
for (const renderer of prepared)
|
|
266
|
+
renderer.commit();
|
|
267
|
+
current = nextDefinition;
|
|
268
|
+
data = nextData;
|
|
269
|
+
state = nextState;
|
|
270
|
+
revision++;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
for (const renderer of prepared.reverse()) {
|
|
274
|
+
try {
|
|
275
|
+
renderer.discard();
|
|
276
|
+
}
|
|
277
|
+
catch (cleanupError) {
|
|
278
|
+
report(cleanupError);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
updating = false;
|
|
285
|
+
}
|
|
286
|
+
emit();
|
|
287
|
+
};
|
|
288
|
+
const controller = {
|
|
289
|
+
snapshot,
|
|
290
|
+
updateData(next, expectedRevision) { if (disposed || (expectedRevision !== undefined && expectedRevision !== revision))
|
|
291
|
+
return false; if (updating)
|
|
292
|
+
throw new Error("Interface update already in progress"); assertBoundedData(next); if (!plain(next))
|
|
293
|
+
throw new Error("Invalid data"); const copy = copyData(next); validateBindings(current, copy, state); accept(current, copy, state); return true; },
|
|
294
|
+
updateDefinition(next, expectedRevision) {
|
|
295
|
+
if (disposed || (expectedRevision !== undefined && expectedRevision !== revision))
|
|
296
|
+
return false;
|
|
297
|
+
if (updating)
|
|
298
|
+
throw new Error("Interface update already in progress");
|
|
299
|
+
validateInterfaceDefinition(next, components);
|
|
300
|
+
if (options.allowedActions && next.actions?.some((action) => !options.allowedActions.includes(action)))
|
|
301
|
+
throw new Error("Action is not allowed by host");
|
|
302
|
+
const copy = copyData(next);
|
|
303
|
+
const nextData = copyData(copy.data ?? {});
|
|
304
|
+
const nextState = retainedState(current, copy, state);
|
|
305
|
+
assertBoundedData(nextState);
|
|
306
|
+
validateBindings(copy, nextData, nextState);
|
|
307
|
+
accept(copy, nextData, nextState);
|
|
308
|
+
return true;
|
|
309
|
+
},
|
|
310
|
+
setState(path, value) {
|
|
311
|
+
if (disposed)
|
|
312
|
+
return;
|
|
313
|
+
if (updating)
|
|
314
|
+
throw new Error("Interface update already in progress");
|
|
315
|
+
if (!name(path) || !scalar(value))
|
|
316
|
+
throw new Error("Invalid state update");
|
|
317
|
+
const parts = path.split(".");
|
|
318
|
+
for (const part of parts)
|
|
319
|
+
assertSafeKey(part);
|
|
320
|
+
const next = copyData(state);
|
|
321
|
+
let target = next;
|
|
322
|
+
for (const part of parts.slice(0, -1)) {
|
|
323
|
+
const child = target[part];
|
|
324
|
+
if (child !== undefined && !plain(child))
|
|
325
|
+
throw new Error("State path is not an object");
|
|
326
|
+
if (child === undefined)
|
|
327
|
+
target[part] = {};
|
|
328
|
+
target = target[part];
|
|
329
|
+
}
|
|
330
|
+
target[parts.at(-1)] = value;
|
|
331
|
+
assertBoundedData(next);
|
|
332
|
+
validateBindings(current, data, next);
|
|
333
|
+
accept(current, data, next);
|
|
334
|
+
},
|
|
335
|
+
dispatch(action, source) {
|
|
336
|
+
if (disposed || dispatching)
|
|
337
|
+
return;
|
|
338
|
+
if (updating)
|
|
339
|
+
throw new Error("Interface update already in progress");
|
|
340
|
+
if (!current.actions?.includes(action) || !findAction(current.screen, source, action, data, state))
|
|
341
|
+
throw new Error("Action is not available");
|
|
342
|
+
dispatching = true;
|
|
343
|
+
try {
|
|
344
|
+
const result = options.onEvent?.({ action, source, state: copyData(state), revision });
|
|
345
|
+
if (result && typeof result === "object" && "then" in result)
|
|
346
|
+
void Promise.resolve(result).catch(report);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
report(error);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
dispatching = false;
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
subscribe(listener) { if (disposed)
|
|
356
|
+
throw new Error("Interface controller is disposed"); listeners.add(listener); try {
|
|
357
|
+
listener(snapshot());
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
listeners.delete(listener);
|
|
361
|
+
throw error;
|
|
362
|
+
} return () => { listeners.delete(listener); }; },
|
|
363
|
+
dispose() { disposed = true; listeners.clear(); participants.clear(); detachInterfaceRenderers(controller); },
|
|
364
|
+
};
|
|
365
|
+
attachInterfaceRenderers(controller, participants);
|
|
366
|
+
return controller;
|
|
367
|
+
}
|
|
368
|
+
function findAction(node, key, action, data, state) {
|
|
369
|
+
if (node.visible && valueAt(node.visible.binding.source === "data" ? data : state, node.visible.binding.path) !== node.visible.equals)
|
|
370
|
+
return false;
|
|
371
|
+
return (node.key === key && node.action === action) || (node.children?.some((child) => findAction(child, key, action, data, state)) ?? false);
|
|
372
|
+
}
|
|
373
|
+
export function resolveInterfaceBinding(snapshot, bindingValue) {
|
|
374
|
+
return valueAt(bindingValue.source === "data" ? snapshot.data : snapshot.state, bindingValue.path);
|
|
375
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const generatedOutputFieldSchema: z.ZodObject<{
|
|
3
|
+
key: z.ZodString;
|
|
4
|
+
label: z.ZodString;
|
|
5
|
+
}, z.core.$strict>;
|
|
6
|
+
export declare const generatedOutputPresentationSchema: z.ZodObject<{
|
|
7
|
+
fields: z.ZodArray<z.ZodObject<{
|
|
8
|
+
key: z.ZodString;
|
|
9
|
+
label: z.ZodString;
|
|
10
|
+
}, z.core.$strict>>;
|
|
11
|
+
}, z.core.$strict>;
|
|
12
|
+
export type GeneratedOutputPresentation = z.infer<typeof generatedOutputPresentationSchema>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const generatedOutputFieldSchema = z.object({
|
|
3
|
+
key: z.string().min(1).max(160),
|
|
4
|
+
label: z.string().min(1).max(160),
|
|
5
|
+
}).strict();
|
|
6
|
+
export const generatedOutputPresentationSchema = z.object({
|
|
7
|
+
fields: z.array(generatedOutputFieldSchema).max(256),
|
|
8
|
+
}).strict();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface OutputFieldDefinition {
|
|
2
|
+
type: "string" | "number" | "integer" | "boolean" | "object" | "array";
|
|
3
|
+
description: string;
|
|
4
|
+
required?: boolean;
|
|
5
|
+
format?: "date" | "date-time";
|
|
6
|
+
semantic?: "currency" | "count" | "ratio" | "time-series" | "category-series";
|
|
7
|
+
role?: "dimension" | "measure";
|
|
8
|
+
enum?: string[];
|
|
9
|
+
currency?: string;
|
|
10
|
+
minimum?: number;
|
|
11
|
+
maximum?: number;
|
|
12
|
+
fields?: Record<string, OutputFieldDefinition>;
|
|
13
|
+
items?: OutputFieldDefinition;
|
|
14
|
+
}
|
|
15
|
+
export interface AggregateCapabilityOutputSchema {
|
|
16
|
+
type: "object";
|
|
17
|
+
semantic: "aggregate";
|
|
18
|
+
description: string;
|
|
19
|
+
fields: Record<string, OutputFieldDefinition>;
|
|
20
|
+
}
|
|
21
|
+
export type CapabilityOutputSchema = Record<string, OutputFieldDefinition> | AggregateCapabilityOutputSchema;
|
|
22
|
+
export declare function defineOutputSchema<T extends CapabilityOutputSchema>(schema: T): T;
|
|
23
|
+
export declare function isAggregateOutputSchema(schema: CapabilityOutputSchema): schema is AggregateCapabilityOutputSchema;
|
|
24
|
+
export declare function getOutputSchemaFields(schema: CapabilityOutputSchema): Record<string, OutputFieldDefinition>;
|
|
25
|
+
export declare function validateOutputSchema(schema: CapabilityOutputSchema): void;
|
|
26
|
+
export declare function validateCapabilityOutput(schema: CapabilityOutputSchema, value: unknown): void;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { assertBoundedData, isCalendarDate, isUTCInstant } from "./validation-safety.js";
|
|
2
|
+
export function defineOutputSchema(schema) {
|
|
3
|
+
return schema;
|
|
4
|
+
}
|
|
5
|
+
export function isAggregateOutputSchema(schema) {
|
|
6
|
+
return schema.type === "object" && schema.semantic === "aggregate";
|
|
7
|
+
}
|
|
8
|
+
export function getOutputSchemaFields(schema) {
|
|
9
|
+
return isAggregateOutputSchema(schema) ? schema.fields : schema;
|
|
10
|
+
}
|
|
11
|
+
function validateTimeSeriesField(name, field) {
|
|
12
|
+
if (field.type !== "array")
|
|
13
|
+
throw new Error("Time-series semantics require an array output: " + name);
|
|
14
|
+
if (field.items?.type !== "object" || !field.items.fields)
|
|
15
|
+
throw new Error("Time-series items must be objects: " + name);
|
|
16
|
+
const entries = Object.entries(field.items.fields);
|
|
17
|
+
if (entries.length === 0)
|
|
18
|
+
throw new Error("Time-series item schema cannot be empty: " + name);
|
|
19
|
+
const dimensions = entries.filter(([, definition]) => definition.role === "dimension");
|
|
20
|
+
const measures = entries.filter(([, definition]) => definition.role === "measure");
|
|
21
|
+
if (dimensions.length !== 1)
|
|
22
|
+
throw new Error("Time-series requires exactly one dimension: " + name);
|
|
23
|
+
if (measures.length !== 1)
|
|
24
|
+
throw new Error("Time-series requires exactly one measure in this slice: " + name);
|
|
25
|
+
const [dimensionName, dimension] = dimensions[0];
|
|
26
|
+
if (dimension.type !== "string" || (dimension.format !== "date" && dimension.format !== "date-time"))
|
|
27
|
+
throw new Error("Time-series dimension must be a temporal string: " + dimensionName);
|
|
28
|
+
const [measureName, measure] = measures[0];
|
|
29
|
+
if (measure.type !== "number" && measure.type !== "integer")
|
|
30
|
+
throw new Error("Time-series measure must be numeric: " + measureName);
|
|
31
|
+
if (!measure.semantic || !["count", "currency", "ratio"].includes(measure.semantic))
|
|
32
|
+
throw new Error("Time-series measure requires supported numeric semantics: " + measureName);
|
|
33
|
+
if (measure.semantic === "currency" && !measure.currency)
|
|
34
|
+
throw new Error("Currency measure requires a currency code: " + measureName);
|
|
35
|
+
if (measure.semantic === "ratio" && ((measure.minimum !== undefined && measure.minimum < 0) || (measure.maximum !== undefined && measure.maximum > 1)))
|
|
36
|
+
throw new Error("Ratio measure constraints must remain within 0..1: " + measureName);
|
|
37
|
+
for (const [fieldName, definition] of entries)
|
|
38
|
+
if ((definition.type === "object" || definition.type === "array") && definition.role)
|
|
39
|
+
throw new Error("Nested time-series roles are unsupported: " + fieldName);
|
|
40
|
+
}
|
|
41
|
+
function validateCategorySeriesField(name, field) {
|
|
42
|
+
if (field.type !== "array")
|
|
43
|
+
throw new Error("Category-series semantics require an array output: " + name);
|
|
44
|
+
if (field.items?.type !== "object" || !field.items.fields)
|
|
45
|
+
throw new Error("Category-series items must be objects: " + name);
|
|
46
|
+
const entries = Object.entries(field.items.fields);
|
|
47
|
+
if (entries.length === 0)
|
|
48
|
+
throw new Error("Category-series item schema cannot be empty: " + name);
|
|
49
|
+
const dimensions = entries.filter(([, definition]) => definition.role === "dimension"), measures = entries.filter(([, definition]) => definition.role === "measure");
|
|
50
|
+
if (dimensions.length !== 1)
|
|
51
|
+
throw new Error("Category-series requires exactly one dimension: " + name);
|
|
52
|
+
if (measures.length !== 1)
|
|
53
|
+
throw new Error("Category-series requires exactly one measure in this slice: " + name);
|
|
54
|
+
const [dimensionName, dimension] = dimensions[0];
|
|
55
|
+
if (dimension.type !== "string")
|
|
56
|
+
throw new Error("Category dimension must be a string: " + dimensionName);
|
|
57
|
+
if (dimension.enum && (dimension.enum.length === 0 || new Set(dimension.enum).size !== dimension.enum.length))
|
|
58
|
+
throw new Error("Category enum must contain unique values: " + dimensionName);
|
|
59
|
+
const [measureName, measure] = measures[0];
|
|
60
|
+
if (measure.type !== "number" && measure.type !== "integer")
|
|
61
|
+
throw new Error("Category-series measure must be numeric: " + measureName);
|
|
62
|
+
if (!measure.semantic || !["count", "currency", "ratio"].includes(measure.semantic))
|
|
63
|
+
throw new Error("Category-series measure requires supported numeric semantics: " + measureName);
|
|
64
|
+
if (measure.semantic === "currency" && !measure.currency)
|
|
65
|
+
throw new Error("Currency measure requires a currency code: " + measureName);
|
|
66
|
+
if (measure.semantic === "ratio" && ((measure.minimum !== undefined && measure.minimum < 0) || (measure.maximum !== undefined && measure.maximum > 1)))
|
|
67
|
+
throw new Error("Ratio measure constraints must remain within 0..1: " + measureName);
|
|
68
|
+
for (const [fieldName, definition] of entries)
|
|
69
|
+
if ((definition.type === "object" || definition.type === "array") && definition.role)
|
|
70
|
+
throw new Error("Nested category-series roles are unsupported: " + fieldName);
|
|
71
|
+
}
|
|
72
|
+
export function validateOutputSchema(schema) {
|
|
73
|
+
assertBoundedData(schema);
|
|
74
|
+
const candidate = schema;
|
|
75
|
+
if (candidate.semantic === "aggregate" && candidate.type !== "object")
|
|
76
|
+
throw new Error("Aggregate semantics require an object output");
|
|
77
|
+
const aggregate = isAggregateOutputSchema(schema);
|
|
78
|
+
const entries = Object.entries(getOutputSchemaFields(schema));
|
|
79
|
+
if (aggregate && entries.length === 0)
|
|
80
|
+
throw new Error("Aggregate output must contain at least one renderable metric");
|
|
81
|
+
for (const [name, field] of entries) {
|
|
82
|
+
if (field.semantic === "time-series")
|
|
83
|
+
validateTimeSeriesField(name, field);
|
|
84
|
+
if (field.semantic === "category-series")
|
|
85
|
+
validateCategorySeriesField(name, field);
|
|
86
|
+
if (field.role)
|
|
87
|
+
throw new Error("Dimension/measure roles are only valid inside time-series items: " + name);
|
|
88
|
+
if (aggregate && (field.type === "object" || field.type === "array"))
|
|
89
|
+
throw new Error("Nested aggregate metric is unsupported: " + name);
|
|
90
|
+
if (aggregate && (!field.semantic || !["count", "currency", "ratio"].includes(field.semantic)))
|
|
91
|
+
throw new Error("Unsupported aggregate field semantic: " + name);
|
|
92
|
+
if (field.semantic === "count" && field.type !== "integer" && field.type !== "number")
|
|
93
|
+
throw new Error("Count semantics require a numeric field: " + name);
|
|
94
|
+
if (field.semantic === "currency" && ((field.type !== "integer" && field.type !== "number") || !field.currency))
|
|
95
|
+
throw new Error("Currency semantics require a numeric field and currency code: " + name);
|
|
96
|
+
if (field.semantic === "ratio") {
|
|
97
|
+
if (field.type !== "number")
|
|
98
|
+
throw new Error("Ratio semantics require a number field: " + name);
|
|
99
|
+
if ((field.minimum !== undefined && field.minimum < 0) || (field.maximum !== undefined && field.maximum > 1) || (field.minimum !== undefined && field.maximum !== undefined && field.minimum > field.maximum))
|
|
100
|
+
throw new Error("Ratio constraints must remain within 0..1: " + name);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function missingOutput(value) {
|
|
105
|
+
return value === undefined || value === null;
|
|
106
|
+
}
|
|
107
|
+
function validateOutputValue(field, value, path) {
|
|
108
|
+
if (missingOutput(value)) {
|
|
109
|
+
if (field.required !== false) {
|
|
110
|
+
throw new Error(`Required output omitted: ${path}`);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (field.type === "string") {
|
|
115
|
+
if (typeof value !== "string")
|
|
116
|
+
throw new Error(`Output "${path}" must be a string`);
|
|
117
|
+
if (field.enum && !field.enum.includes(value))
|
|
118
|
+
throw new Error(`Output "${path}" is not an allowed enum value`);
|
|
119
|
+
if (field.format === "date" && !isCalendarDate(value))
|
|
120
|
+
throw new Error(`Output "${path}" must use YYYY-MM-DD`);
|
|
121
|
+
if (field.format === "date-time" && !isUTCInstant(value))
|
|
122
|
+
throw new Error(`Output "${path}" must be an ISO-8601 UTC instant`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (field.type === "number" || field.type === "integer") {
|
|
126
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
127
|
+
throw new Error(`Output "${path}" must be numeric`);
|
|
128
|
+
if (field.type === "integer" && !Number.isInteger(value))
|
|
129
|
+
throw new Error(`Output "${path}" must be an integer`);
|
|
130
|
+
if (field.minimum !== undefined && value < field.minimum)
|
|
131
|
+
throw new Error(`Output "${path}" is below minimum`);
|
|
132
|
+
if (field.maximum !== undefined && value > field.maximum)
|
|
133
|
+
throw new Error(`Output "${path}" exceeds maximum`);
|
|
134
|
+
if (field.semantic === "ratio" && (value < 0 || value > 1))
|
|
135
|
+
throw new Error(`Output ratio "${path}" must remain within 0..1`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (field.type === "boolean") {
|
|
139
|
+
if (typeof value !== "boolean")
|
|
140
|
+
throw new Error(`Output "${path}" must be boolean`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (field.type === "object") {
|
|
144
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
145
|
+
throw new Error(`Output "${path}" must be an object`);
|
|
146
|
+
const record = value;
|
|
147
|
+
for (const key of Object.keys(record)) {
|
|
148
|
+
if (!Object.hasOwn(field.fields ?? {}, key))
|
|
149
|
+
throw new Error(`Unexpected nested output: ${path}.${key}`);
|
|
150
|
+
}
|
|
151
|
+
for (const [name, child] of Object.entries(field.fields ?? {})) {
|
|
152
|
+
validateOutputValue(child, Object.hasOwn(record, name) ? record[name] : undefined, `${path}.${name}`);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!Array.isArray(value))
|
|
157
|
+
throw new Error(`Output "${path}" must be an array`);
|
|
158
|
+
if (!field.items)
|
|
159
|
+
throw new Error(`Output array "${path}" lacks an item schema`);
|
|
160
|
+
value.forEach((item, index) => validateOutputValue(field.items, item, `${path}[${index}]`));
|
|
161
|
+
}
|
|
162
|
+
export function validateCapabilityOutput(schema, value) {
|
|
163
|
+
assertBoundedData(schema);
|
|
164
|
+
assertBoundedData(value);
|
|
165
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
166
|
+
throw new Error("Capability output must be an object");
|
|
167
|
+
}
|
|
168
|
+
const result = value;
|
|
169
|
+
const fields = getOutputSchemaFields(schema);
|
|
170
|
+
for (const key of Object.keys(result)) {
|
|
171
|
+
if (!Object.hasOwn(fields, key))
|
|
172
|
+
throw new Error(`Unexpected supplied output: ${key}`);
|
|
173
|
+
}
|
|
174
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
175
|
+
validateOutputValue(field, Object.hasOwn(result, name) ? result[name] : undefined, name);
|
|
176
|
+
}
|
|
177
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
178
|
+
if (missingOutput(result[name]) && field.required === false)
|
|
179
|
+
continue;
|
|
180
|
+
if (field.semantic === "category-series") {
|
|
181
|
+
const points = result[name];
|
|
182
|
+
const entries = Object.entries(field.items.fields);
|
|
183
|
+
const [dimensionName] = entries.find(([, definition]) => definition.role === "dimension");
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
for (const point of points) {
|
|
186
|
+
const category = point[dimensionName];
|
|
187
|
+
if (seen.has(category))
|
|
188
|
+
throw new Error("Duplicate category is not allowed: " + category);
|
|
189
|
+
seen.add(category);
|
|
190
|
+
}
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (field.semantic === "time-series") {
|
|
194
|
+
const points = result[name];
|
|
195
|
+
const entries = Object.entries(field.items.fields);
|
|
196
|
+
const [dimensionName, dimension] = entries.find(([, definition]) => definition.role === "dimension");
|
|
197
|
+
let previous;
|
|
198
|
+
for (const point of points) {
|
|
199
|
+
const current = point[dimensionName];
|
|
200
|
+
const previousInstant = previous === undefined
|
|
201
|
+
? undefined
|
|
202
|
+
: Date.parse(previous);
|
|
203
|
+
const currentInstant = dimension.format === "date-time"
|
|
204
|
+
? Date.parse(current)
|
|
205
|
+
: Date.parse(`${current}T00:00:00.000Z`);
|
|
206
|
+
if (previousInstant !== undefined && currentInstant < previousInstant) {
|
|
207
|
+
throw new Error("Time-series points must preserve chronological order");
|
|
208
|
+
}
|
|
209
|
+
previous = current;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PresentationEnvelope, PresentationPlan, PresentationProvenance, SurfaceExecution, RelatedSurfaceExecution } from "./composed-surface.js";
|
|
3
|
+
export declare const presentationPlanSchema: z.ZodObject<{
|
|
4
|
+
title: z.ZodOptional<z.ZodString>;
|
|
5
|
+
description: z.ZodOptional<z.ZodString>;
|
|
6
|
+
groups: z.ZodArray<z.ZodObject<{
|
|
7
|
+
id: z.ZodString;
|
|
8
|
+
title: z.ZodOptional<z.ZodString>;
|
|
9
|
+
description: z.ZodOptional<z.ZodString>;
|
|
10
|
+
emphasis: z.ZodEnum<{
|
|
11
|
+
normal: "normal";
|
|
12
|
+
prominent: "prominent";
|
|
13
|
+
subtle: "subtle";
|
|
14
|
+
}>;
|
|
15
|
+
density: z.ZodEnum<{
|
|
16
|
+
comfortable: "comfortable";
|
|
17
|
+
compact: "compact";
|
|
18
|
+
}>;
|
|
19
|
+
sections: z.ZodArray<z.ZodString>;
|
|
20
|
+
}, z.core.$strict>>;
|
|
21
|
+
}, z.core.$strict>;
|
|
22
|
+
export declare function sectionId(kind: "primary" | "related", capability: string): string;
|
|
23
|
+
export declare function buildPresentationEnvelope(primary: SurfaceExecution, related: RelatedSurfaceExecution[]): PresentationEnvelope;
|
|
24
|
+
export declare function deterministicPresentation(envelope: PresentationEnvelope): PresentationPlan;
|
|
25
|
+
export declare function validatePresentationPlan(envelope: PresentationEnvelope, value: unknown): PresentationPlan;
|
|
26
|
+
export declare function composePresentation(envelope: PresentationEnvelope, strategy: "deterministic" | "generative", generate?: (envelope: PresentationEnvelope) => Promise<unknown>): Promise<{
|
|
27
|
+
plan: PresentationPlan;
|
|
28
|
+
provenance: PresentationProvenance;
|
|
29
|
+
}>;
|