@saga-a2ui/core 0.5.6-rc.1 → 0.7.7-rc.1
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/README.md +1 -1
- package/package.json +1 -1
- package/src/actions/binding.ts +23 -5
- package/src/actions/category.ts +13 -4
- package/src/actions/definition.ts +250 -77
- package/src/actions/index.ts +14 -2
- package/src/actions/instance-host.ts +73 -21
- package/src/actions/invocation.ts +295 -76
- package/src/actions/limits.ts +23 -6
- package/src/actions/outcome.ts +57 -12
- package/src/actions/prerequisite.ts +33 -10
- package/src/actions/scope.ts +50 -14
- package/src/actions/server.ts +45 -12
- package/src/actions/thenable.ts +3 -1
- package/src/delivered-result.ts +31 -0
- package/src/host-links.ts +49 -0
- package/src/protocols/a2ui/v0_9/decoder.ts +48 -19
- package/src/protocols/a2ui/v0_9/deep-freeze.ts +8 -0
- package/src/protocols/a2ui/v0_9/index.ts +14 -1
- package/src/protocols/a2ui/v0_9/session.ts +26 -9
- package/src/protocols/a2ui/v0_9/surface-layout.ts +256 -0
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
UpdateComponentsMessage,
|
|
13
13
|
UpdateDataModelMessage,
|
|
14
14
|
} from '@a2ui/web_core/v0_9';
|
|
15
|
+
import { deepFreeze } from './deep-freeze.js';
|
|
15
16
|
import type { ValidatedMessage } from './message.js';
|
|
16
17
|
|
|
17
18
|
/** Why input was rejected. `message` is plain text that is safe to display. */
|
|
@@ -39,15 +40,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
39
40
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
const malformed = (message: string): DecodeFailure => ({
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Object.freeze(value);
|
|
47
|
-
for (const child of Object.values(value)) deepFreeze(child);
|
|
48
|
-
}
|
|
49
|
-
return value;
|
|
50
|
-
}
|
|
43
|
+
const malformed = (message: string): DecodeFailure => ({
|
|
44
|
+
kind: 'malformed',
|
|
45
|
+
message,
|
|
46
|
+
});
|
|
51
47
|
|
|
52
48
|
/**
|
|
53
49
|
* Wrap a message that `decodeCase` has just validated against its upstream schema. Private
|
|
@@ -82,12 +78,16 @@ function decodeCase<Schema extends z.ZodTypeAny>(
|
|
|
82
78
|
): ValidatedMessage | DecodeFailure {
|
|
83
79
|
const parsed = schema.safeParse(raw);
|
|
84
80
|
if (!parsed.success) {
|
|
85
|
-
return malformed(
|
|
81
|
+
return malformed(
|
|
82
|
+
`Invalid ${tag} message: ${parsed.error.errors.map(formatZodIssue).join(', ')}`,
|
|
83
|
+
);
|
|
86
84
|
}
|
|
87
85
|
try {
|
|
88
86
|
return build(parsed.data);
|
|
89
87
|
} catch {
|
|
90
|
-
return malformed(
|
|
88
|
+
return malformed(
|
|
89
|
+
`Invalid ${tag} message: it contains a value that cannot be copied.`,
|
|
90
|
+
);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -103,21 +103,44 @@ function describeVersion(version: unknown): string {
|
|
|
103
103
|
function decodeMessage(raw: unknown): ValidatedMessage | DecodeFailure {
|
|
104
104
|
if (!isRecord(raw)) return malformed('Each message must be an object.');
|
|
105
105
|
if (!SUPPORTED_VERSIONS.includes(raw.version)) {
|
|
106
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
kind: 'unsupported-version',
|
|
108
|
+
message: `Unsupported A2UI version: ${describeVersion(raw.version)}. Supported: v0.9.`,
|
|
109
|
+
};
|
|
107
110
|
}
|
|
108
111
|
// The only place wire tags are inspected. Upstream schemas are strict, so a message with
|
|
109
112
|
// extra operations or fields fails validation of its case.
|
|
110
113
|
if ('createSurface' in raw) {
|
|
111
|
-
return decodeCase(
|
|
114
|
+
return decodeCase(
|
|
115
|
+
'createSurface',
|
|
116
|
+
CreateSurfaceMessageSchema,
|
|
117
|
+
raw,
|
|
118
|
+
validatedMessage.createSurface,
|
|
119
|
+
);
|
|
112
120
|
}
|
|
113
121
|
if ('updateComponents' in raw) {
|
|
114
|
-
return decodeCase(
|
|
122
|
+
return decodeCase(
|
|
123
|
+
'updateComponents',
|
|
124
|
+
UpdateComponentsMessageSchema,
|
|
125
|
+
raw,
|
|
126
|
+
validatedMessage.updateComponents,
|
|
127
|
+
);
|
|
115
128
|
}
|
|
116
129
|
if ('updateDataModel' in raw) {
|
|
117
|
-
return decodeCase(
|
|
130
|
+
return decodeCase(
|
|
131
|
+
'updateDataModel',
|
|
132
|
+
UpdateDataModelMessageSchema,
|
|
133
|
+
raw,
|
|
134
|
+
validatedMessage.updateDataModel,
|
|
135
|
+
);
|
|
118
136
|
}
|
|
119
137
|
if ('deleteSurface' in raw) {
|
|
120
|
-
return decodeCase(
|
|
138
|
+
return decodeCase(
|
|
139
|
+
'deleteSurface',
|
|
140
|
+
DeleteSurfaceMessageSchema,
|
|
141
|
+
raw,
|
|
142
|
+
validatedMessage.deleteSurface,
|
|
143
|
+
);
|
|
121
144
|
}
|
|
122
145
|
return malformed('Message has no supported operation.');
|
|
123
146
|
}
|
|
@@ -126,7 +149,9 @@ function rawMessages(input: unknown): readonly unknown[] | DecodeFailure {
|
|
|
126
149
|
if (Array.isArray(input)) return input;
|
|
127
150
|
if (isRecord(input) && 'messages' in input) {
|
|
128
151
|
if (!Array.isArray(input.messages) || Object.keys(input).length !== 1) {
|
|
129
|
-
return malformed(
|
|
152
|
+
return malformed(
|
|
153
|
+
'A message list must be an object with only a "messages" array.',
|
|
154
|
+
);
|
|
130
155
|
}
|
|
131
156
|
return input.messages;
|
|
132
157
|
}
|
|
@@ -135,7 +160,8 @@ function rawMessages(input: unknown): readonly unknown[] | DecodeFailure {
|
|
|
135
160
|
|
|
136
161
|
function decodeInput(input: unknown): DecodeResult {
|
|
137
162
|
const raws = rawMessages(input);
|
|
138
|
-
if (!Array.isArray(raws))
|
|
163
|
+
if (!Array.isArray(raws))
|
|
164
|
+
return { ok: false, failure: raws as DecodeFailure };
|
|
139
165
|
const messages: ValidatedMessage[] = [];
|
|
140
166
|
for (const raw of raws) {
|
|
141
167
|
const decoded = decodeMessage(raw);
|
|
@@ -155,7 +181,10 @@ export const a2uiV0_9Decoder: MessageDecoder = {
|
|
|
155
181
|
try {
|
|
156
182
|
return decodeInput(input);
|
|
157
183
|
} catch {
|
|
158
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
failure: malformed('Input could not be inspected or parsed.'),
|
|
187
|
+
};
|
|
159
188
|
}
|
|
160
189
|
},
|
|
161
190
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Freezes a value and everything reachable from it, and returns it. */
|
|
2
|
+
export function deepFreeze<T>(value: T): T {
|
|
3
|
+
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
|
|
4
|
+
Object.freeze(value);
|
|
5
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
// Public interface of the A2UI v0.9 protocol module. Decoding, validated message cases and the
|
|
2
2
|
// message session are one coherent interface; nothing here imports a DOM, MCP or renderer.
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
a2uiV0_9Decoder,
|
|
5
|
+
type DecodeFailure,
|
|
6
|
+
type DecodeResult,
|
|
7
|
+
type MessageDecoder,
|
|
8
|
+
} from './decoder.js';
|
|
4
9
|
export type { A2uiMessageVisitor, ValidatedMessage } from './message.js';
|
|
10
|
+
export {
|
|
11
|
+
defineSurface,
|
|
12
|
+
type ComponentDefinition,
|
|
13
|
+
type SurfaceLayout,
|
|
14
|
+
type SurfaceSpec,
|
|
15
|
+
type SurfaceUpdateFailure,
|
|
16
|
+
type UpdateInput,
|
|
17
|
+
} from './surface-layout.js';
|
|
5
18
|
export {
|
|
6
19
|
createMessageSession,
|
|
7
20
|
type IngestFailure,
|
|
@@ -16,7 +16,9 @@ export interface IngestFailure {
|
|
|
16
16
|
readonly message: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export type IngestResult =
|
|
19
|
+
export type IngestResult =
|
|
20
|
+
| { readonly ok: true }
|
|
21
|
+
| { readonly ok: false; readonly failure: IngestFailure };
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Owns one upstream `MessageProcessor` and the mutable surface state inside it.
|
|
@@ -44,7 +46,9 @@ export interface MessageSessionOptions<T extends ComponentApi> {
|
|
|
44
46
|
readonly decoder: MessageDecoder;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
export function createMessageSession<T extends ComponentApi>(
|
|
49
|
+
export function createMessageSession<T extends ComponentApi>(
|
|
50
|
+
options: MessageSessionOptions<T>,
|
|
51
|
+
): MessageSession<T> {
|
|
48
52
|
const processor = new MessageProcessor<T>([...options.catalogs], undefined, {
|
|
49
53
|
validationConfig: STRICT_VALIDATION,
|
|
50
54
|
});
|
|
@@ -56,10 +60,14 @@ export function createMessageSession<T extends ComponentApi>(options: MessageSes
|
|
|
56
60
|
// Upstream keeps supplied objects (e.g. the data-model value) and mutates them in place on
|
|
57
61
|
// later updates, so it gets its own copy; the validated, frozen message stays unchanged.
|
|
58
62
|
const apply: A2uiMessageVisitor<void> = {
|
|
59
|
-
createSurface: (message) =>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
createSurface: (message) =>
|
|
64
|
+
processor.processMessages(structuredClone(message)),
|
|
65
|
+
updateComponents: (message) =>
|
|
66
|
+
processor.processMessages(structuredClone(message)),
|
|
67
|
+
updateDataModel: (message) =>
|
|
68
|
+
processor.processMessages(structuredClone(message)),
|
|
69
|
+
deleteSurface: (message) =>
|
|
70
|
+
processor.processMessages(structuredClone(message)),
|
|
63
71
|
};
|
|
64
72
|
|
|
65
73
|
const applyAll = (messages: readonly ValidatedMessage[]): IngestResult => {
|
|
@@ -69,16 +77,25 @@ export function createMessageSession<T extends ComponentApi>(options: MessageSes
|
|
|
69
77
|
} catch (error) {
|
|
70
78
|
return {
|
|
71
79
|
ok: false,
|
|
72
|
-
failure: {
|
|
80
|
+
failure: {
|
|
81
|
+
kind: 'rejected',
|
|
82
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
83
|
+
},
|
|
73
84
|
};
|
|
74
85
|
}
|
|
75
86
|
};
|
|
76
87
|
|
|
77
88
|
return {
|
|
78
89
|
ingest(input) {
|
|
79
|
-
if (disposed)
|
|
90
|
+
if (disposed)
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
failure: { kind: 'rejected', message: 'The session was disposed.' },
|
|
94
|
+
};
|
|
80
95
|
const decoded = options.decoder.decode(input);
|
|
81
|
-
return decoded.ok
|
|
96
|
+
return decoded.ok
|
|
97
|
+
? applyAll(decoded.messages)
|
|
98
|
+
: { ok: false, failure: decoded.failure };
|
|
82
99
|
},
|
|
83
100
|
onSurfaceCreated: (handler) => processor.onSurfaceCreated(handler),
|
|
84
101
|
onSurfaceDeleted: (handler) => processor.onSurfaceDeleted(handler),
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { z } from '../zod3.js';
|
|
2
|
+
import { deepFreeze } from './deep-freeze.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Surface authoring for A2UI v0.9: an application states a surface's data and its component
|
|
6
|
+
* layout, and this module is the one place that writes the protocol's messages for it. Wire
|
|
7
|
+
* names and the version literal stay here (see "Protocol input becomes a value object" in the
|
|
8
|
+
* coding standards).
|
|
9
|
+
*
|
|
10
|
+
* Bounded on purpose: one root component over a flat data model, plus an explicit list of
|
|
11
|
+
* further components passed through unchanged. It is not a binding engine, and it does not
|
|
12
|
+
* validate component names or properties itself. Every generated message is meant to go
|
|
13
|
+
* through `a2uiV0_9Decoder` and the upstream processor like any other input, which is where
|
|
14
|
+
* catalog support is decided.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The fixed id of a layout's root component, as the upstream renderer expects. */
|
|
18
|
+
const ROOT_ID = 'root';
|
|
19
|
+
const VERSION = 'v0.9';
|
|
20
|
+
|
|
21
|
+
/** One component of a layout as the wire format spells it; the catalog decides what it means. */
|
|
22
|
+
export interface ComponentDefinition {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly component: string;
|
|
25
|
+
readonly [property: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Why `update` refused data. `message` is plain text that is safe to display. */
|
|
29
|
+
export interface SurfaceUpdateFailure {
|
|
30
|
+
readonly kind: 'invalid-data';
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Decoder input for one data update, or the reason no message was produced. */
|
|
35
|
+
export type UpdateInput =
|
|
36
|
+
| { readonly ok: true; readonly messages: readonly unknown[] }
|
|
37
|
+
| { readonly ok: false; readonly failure: SurfaceUpdateFailure };
|
|
38
|
+
|
|
39
|
+
/** A surface as an application defines it: identity, typed data and the messages that open it. */
|
|
40
|
+
export interface SurfaceLayout<TData> {
|
|
41
|
+
readonly surfaceId: string;
|
|
42
|
+
readonly catalogId: string;
|
|
43
|
+
/** The data the surface opens with. */
|
|
44
|
+
readonly initial: TData;
|
|
45
|
+
/**
|
|
46
|
+
* `createSurface`, `updateComponents` and the initial `updateDataModel`, as decoder input.
|
|
47
|
+
* Deeply frozen, so one layout can open any number of sessions.
|
|
48
|
+
*/
|
|
49
|
+
readonly opening: readonly unknown[];
|
|
50
|
+
/**
|
|
51
|
+
* The `updateDataModel` message that replaces the whole data model with `data`, as decoder
|
|
52
|
+
* input. The data must satisfy the layout's schema exactly: an unknown field (at any depth) is
|
|
53
|
+
* refused, never dropped, and no default is filled in and nothing is coerced.
|
|
54
|
+
*/
|
|
55
|
+
update(data: TData): UpdateInput;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SurfaceSpec<Schema extends z.ZodObject<z.ZodRawShape>> {
|
|
59
|
+
readonly surfaceId: string;
|
|
60
|
+
readonly catalogId: string;
|
|
61
|
+
/** The flat data model: a Zod 3 object whose fields are the surface's data. */
|
|
62
|
+
readonly data: Schema;
|
|
63
|
+
readonly root: {
|
|
64
|
+
readonly component: string;
|
|
65
|
+
/** Root property name -> the data field it shows, as a path binding. */
|
|
66
|
+
readonly bind: Readonly<
|
|
67
|
+
Record<string, Extract<keyof z.infer<Schema>, string>>
|
|
68
|
+
>;
|
|
69
|
+
/** Root properties that are plain values rather than bindings. */
|
|
70
|
+
readonly props?: Readonly<Record<string, unknown>>;
|
|
71
|
+
};
|
|
72
|
+
/** The other components, passed through exactly as written. Their ids must be distinct. */
|
|
73
|
+
readonly components?: readonly ComponentDefinition[];
|
|
74
|
+
readonly initial: z.infer<Schema>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The text of every issue, each led by the field it is about (empty path means the whole value). */
|
|
78
|
+
function describeIssues(label: string, error: z.ZodError): string {
|
|
79
|
+
return error.issues
|
|
80
|
+
.map((issue) =>
|
|
81
|
+
issue.path.length > 0
|
|
82
|
+
? `${label}.${issue.path.join('.')}: ${issue.message}`
|
|
83
|
+
: `${label}: ${issue.message}`,
|
|
84
|
+
)
|
|
85
|
+
.join('; ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The path of the first place where `parsed` differs from `given`, or undefined when equal. */
|
|
89
|
+
function firstChange(
|
|
90
|
+
given: unknown,
|
|
91
|
+
parsed: unknown,
|
|
92
|
+
path: readonly (string | number)[],
|
|
93
|
+
): string | undefined {
|
|
94
|
+
if (Object.is(given, parsed)) return undefined;
|
|
95
|
+
const here = path.join('.');
|
|
96
|
+
const bothArrays = Array.isArray(given) && Array.isArray(parsed);
|
|
97
|
+
const bothRecords =
|
|
98
|
+
!bothArrays &&
|
|
99
|
+
typeof given === 'object' &&
|
|
100
|
+
given !== null &&
|
|
101
|
+
typeof parsed === 'object' &&
|
|
102
|
+
parsed !== null &&
|
|
103
|
+
!Array.isArray(given) &&
|
|
104
|
+
!Array.isArray(parsed);
|
|
105
|
+
if (!bothArrays && !bothRecords) return here;
|
|
106
|
+
const keys = new Set([
|
|
107
|
+
...Object.keys(given as object),
|
|
108
|
+
...Object.keys(parsed as object),
|
|
109
|
+
]);
|
|
110
|
+
for (const key of keys) {
|
|
111
|
+
const before = (given as Record<string, unknown>)[key];
|
|
112
|
+
const after = (parsed as Record<string, unknown>)[key];
|
|
113
|
+
// An optional field given as `undefined` and one that is absent mean the same thing.
|
|
114
|
+
if (before === undefined && after === undefined) continue;
|
|
115
|
+
const changed = firstChange(before, after, [...path, key]);
|
|
116
|
+
if (changed !== undefined) return changed;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Checks `value` against the schema and requires that parsing leaves it exactly as given. Zod
|
|
123
|
+
* strips unknown keys of nested objects, applies defaults and runs coercions and transforms; any
|
|
124
|
+
* of those would change what the surface shows from what the caller passed, so each is refused,
|
|
125
|
+
* naming the first changed field.
|
|
126
|
+
*/
|
|
127
|
+
function checkExact<Schema extends z.ZodObject<z.ZodRawShape>>(
|
|
128
|
+
schema: Schema,
|
|
129
|
+
label: string,
|
|
130
|
+
value: unknown,
|
|
131
|
+
): { ok: true; data: z.infer<Schema> } | { ok: false; message: string } {
|
|
132
|
+
const parsed = schema.safeParse(value);
|
|
133
|
+
if (!parsed.success)
|
|
134
|
+
return { ok: false, message: describeIssues(label, parsed.error) };
|
|
135
|
+
const changed = firstChange(value, parsed.data, [label]);
|
|
136
|
+
if (changed !== undefined)
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
message: `${changed}: the schema would change this value (an unknown field dropped, a default applied or a conversion made); give exactly the data the schema describes.`,
|
|
140
|
+
};
|
|
141
|
+
return { ok: true, data: parsed.data };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A JSON Pointer to a top-level field (RFC 6901 escapes). */
|
|
145
|
+
const pointerTo = (field: string): string =>
|
|
146
|
+
`/${field.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
|
147
|
+
|
|
148
|
+
function requireText(name: string, value: string, surfaceId: string): void {
|
|
149
|
+
if (value.length === 0)
|
|
150
|
+
throw new Error(`defineSurface(${surfaceId}): ${name} must not be empty.`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Defines a surface. Throws, naming the field, when the definition is inconsistent: an
|
|
155
|
+
* `initial` that fails the schema, a binding to a field the schema does not have, a property
|
|
156
|
+
* given both as a binding and as a value, or component ids that repeat or reuse the root's.
|
|
157
|
+
* Whether the catalog supports the components is not decided here; opening the surface in a
|
|
158
|
+
* session reports that.
|
|
159
|
+
*/
|
|
160
|
+
export function defineSurface<Schema extends z.ZodObject<z.ZodRawShape>>(
|
|
161
|
+
spec: SurfaceSpec<Schema>,
|
|
162
|
+
): SurfaceLayout<z.infer<Schema>> {
|
|
163
|
+
const { surfaceId, catalogId, root } = spec;
|
|
164
|
+
requireText('surfaceId', surfaceId, surfaceId);
|
|
165
|
+
requireText('catalogId', catalogId, surfaceId);
|
|
166
|
+
requireText('root.component', root.component, surfaceId);
|
|
167
|
+
|
|
168
|
+
// Strict: data with a field the schema lacks is refused, not silently dropped.
|
|
169
|
+
const schema = spec.data.strict();
|
|
170
|
+
const label = `defineSurface(${surfaceId})`;
|
|
171
|
+
|
|
172
|
+
const fields = Object.keys(spec.data.shape);
|
|
173
|
+
const props = root.props ?? {};
|
|
174
|
+
for (const [property, field] of Object.entries(root.bind)) {
|
|
175
|
+
if (!fields.includes(field))
|
|
176
|
+
throw new Error(
|
|
177
|
+
`${label}: root.bind.${property} names the field "${field}", which the data schema does not define.`,
|
|
178
|
+
);
|
|
179
|
+
if (property in props)
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${label}: root property "${property}" is both bound and given a value.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const reserved = ['id', 'component'];
|
|
185
|
+
for (const property of [...Object.keys(root.bind), ...Object.keys(props)]) {
|
|
186
|
+
if (reserved.includes(property))
|
|
187
|
+
throw new Error(
|
|
188
|
+
`${label}: root property "${property}" is set by the layout and cannot be supplied.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const ids = new Set<string>([ROOT_ID]);
|
|
192
|
+
for (const entry of spec.components ?? []) {
|
|
193
|
+
if (ids.has(entry.id))
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${label}: component id "${entry.id}" is repeated or reuses the root's id "${ROOT_ID}".`,
|
|
196
|
+
);
|
|
197
|
+
ids.add(entry.id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const initial = checkExact(schema, 'initial', spec.initial);
|
|
201
|
+
if (!initial.ok) throw new Error(`${label}: ${initial.message}`);
|
|
202
|
+
const initialData: z.infer<Schema> = deepFreeze(
|
|
203
|
+
structuredClone(initial.data),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const bindings = Object.fromEntries(
|
|
207
|
+
Object.entries(root.bind).map(([property, field]) => [
|
|
208
|
+
property,
|
|
209
|
+
{ path: pointerTo(field) },
|
|
210
|
+
]),
|
|
211
|
+
);
|
|
212
|
+
const rootComponent = {
|
|
213
|
+
id: ROOT_ID,
|
|
214
|
+
component: root.component,
|
|
215
|
+
...props,
|
|
216
|
+
...bindings,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const replaceData = (value: unknown): unknown => ({
|
|
220
|
+
version: VERSION,
|
|
221
|
+
updateDataModel: { surfaceId, value },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const opening = deepFreeze(
|
|
225
|
+
structuredClone([
|
|
226
|
+
{ version: VERSION, createSurface: { surfaceId, catalogId } },
|
|
227
|
+
{
|
|
228
|
+
version: VERSION,
|
|
229
|
+
updateComponents: {
|
|
230
|
+
surfaceId,
|
|
231
|
+
components: [rootComponent, ...(spec.components ?? [])],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
replaceData(initialData),
|
|
235
|
+
]),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
surfaceId,
|
|
240
|
+
catalogId,
|
|
241
|
+
initial: initialData,
|
|
242
|
+
opening,
|
|
243
|
+
update(data) {
|
|
244
|
+
const checked = checkExact(schema, 'data', data);
|
|
245
|
+
if (!checked.ok)
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
failure: { kind: 'invalid-data', message: checked.message },
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
messages: deepFreeze(structuredClone([replaceData(checked.data)])),
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|