@cynodia/axiom-core 0.6.3-alpha.1 → 0.7.0-alpha.2

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.
@@ -0,0 +1,53 @@
1
+ import type { AnyNode } from './types.js';
2
+ /**
3
+ * Metadata that describes how a node was **authored**, not how it executes.
4
+ *
5
+ * `metadata` has always been a free-form bag, which means anything put there travels with the
6
+ * node into the IR, into the browser, and across the trust boundary. That is right for
7
+ * metadata a runtime might consult and wrong for metadata only an authoring tool cares about
8
+ * — a generated node's origin, a design-tool reference, a migration marker. Such data is
9
+ * inert by construction, and inert data still costs payload and still crosses boundaries.
10
+ *
11
+ * Everything under the reserved key is therefore **authoring metadata**: stripped from every
12
+ * compiled artifact by default, present in the graph, and available to a tool that asks for
13
+ * it explicitly.
14
+ *
15
+ * ```ts
16
+ * metadata: { [AUTHORING_METADATA_KEY]: { generatedBy: 'entity-list' }, tracked: true }
17
+ * // ↑ stripped from the IR ↑ kept
18
+ * ```
19
+ *
20
+ * The mechanism is deliberately not toolkit-specific: a UI toolkit is the first thing that
21
+ * needs it, not the only one.
22
+ */
23
+ export declare const AUTHORING_METADATA_KEY = "axiomAuthoring";
24
+ export type AuthoringMetadata = Record<string, unknown>;
25
+ /** The authoring metadata on a node, if it carries any. */
26
+ export declare function authoringMetadata(node: {
27
+ metadata?: Record<string, unknown>;
28
+ }): AuthoringMetadata | undefined;
29
+ /** Whether a node carries authoring metadata at all. */
30
+ export declare function hasAuthoringMetadata(node: {
31
+ metadata?: Record<string, unknown>;
32
+ }): boolean;
33
+ /**
34
+ * Attaches authoring metadata, merging with whatever is already there.
35
+ *
36
+ * Returns a new node: nothing here mutates a node an author still holds a reference to.
37
+ */
38
+ export declare function withAuthoringMetadata<T extends {
39
+ metadata?: Record<string, unknown>;
40
+ }>(node: T, authoring: AuthoringMetadata): T;
41
+ /**
42
+ * Removes authoring metadata from one node.
43
+ *
44
+ * `metadata` itself is dropped when nothing else was in it, so a node that carried only
45
+ * authoring metadata comes out byte-identical to one that never had any. That equality is
46
+ * what makes "stripping equals never recording" testable rather than approximate.
47
+ */
48
+ export declare function stripAuthoringMetadata<T extends {
49
+ metadata?: Record<string, unknown>;
50
+ }>(node: T): T;
51
+ /** Removes authoring metadata from every node in a collection. */
52
+ export declare function stripAuthoringMetadataFrom<T extends AnyNode>(nodes: readonly T[]): T[];
53
+ //# sourceMappingURL=authoring-metadata.d.ts.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Metadata that describes how a node was **authored**, not how it executes.
3
+ *
4
+ * `metadata` has always been a free-form bag, which means anything put there travels with the
5
+ * node into the IR, into the browser, and across the trust boundary. That is right for
6
+ * metadata a runtime might consult and wrong for metadata only an authoring tool cares about
7
+ * — a generated node's origin, a design-tool reference, a migration marker. Such data is
8
+ * inert by construction, and inert data still costs payload and still crosses boundaries.
9
+ *
10
+ * Everything under the reserved key is therefore **authoring metadata**: stripped from every
11
+ * compiled artifact by default, present in the graph, and available to a tool that asks for
12
+ * it explicitly.
13
+ *
14
+ * ```ts
15
+ * metadata: { [AUTHORING_METADATA_KEY]: { generatedBy: 'entity-list' }, tracked: true }
16
+ * // ↑ stripped from the IR ↑ kept
17
+ * ```
18
+ *
19
+ * The mechanism is deliberately not toolkit-specific: a UI toolkit is the first thing that
20
+ * needs it, not the only one.
21
+ */
22
+ export const AUTHORING_METADATA_KEY = 'axiomAuthoring';
23
+ /** The authoring metadata on a node, if it carries any. */
24
+ export function authoringMetadata(node) {
25
+ const found = node.metadata?.[AUTHORING_METADATA_KEY];
26
+ return found === undefined ? undefined : found;
27
+ }
28
+ /** Whether a node carries authoring metadata at all. */
29
+ export function hasAuthoringMetadata(node) {
30
+ return authoringMetadata(node) !== undefined;
31
+ }
32
+ /**
33
+ * Attaches authoring metadata, merging with whatever is already there.
34
+ *
35
+ * Returns a new node: nothing here mutates a node an author still holds a reference to.
36
+ */
37
+ export function withAuthoringMetadata(node, authoring) {
38
+ const existing = authoringMetadata(node);
39
+ return {
40
+ ...node,
41
+ metadata: {
42
+ ...node.metadata,
43
+ [AUTHORING_METADATA_KEY]: { ...existing, ...authoring },
44
+ },
45
+ };
46
+ }
47
+ /**
48
+ * Removes authoring metadata from one node.
49
+ *
50
+ * `metadata` itself is dropped when nothing else was in it, so a node that carried only
51
+ * authoring metadata comes out byte-identical to one that never had any. That equality is
52
+ * what makes "stripping equals never recording" testable rather than approximate.
53
+ */
54
+ export function stripAuthoringMetadata(node) {
55
+ if (!node.metadata || !(AUTHORING_METADATA_KEY in node.metadata)) {
56
+ return node;
57
+ }
58
+ const { [AUTHORING_METADATA_KEY]: _authoring, ...rest } = node.metadata;
59
+ const next = { ...node };
60
+ if (Object.keys(rest).length === 0) {
61
+ delete next.metadata;
62
+ }
63
+ else {
64
+ next.metadata = rest;
65
+ }
66
+ return next;
67
+ }
68
+ /** Removes authoring metadata from every node in a collection. */
69
+ export function stripAuthoringMetadataFrom(nodes) {
70
+ return nodes.map((node) => stripAuthoringMetadata(node));
71
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Expression } from './expressions.js';
2
2
  import type { NodeId } from './ids.js';
3
- import type { ActionDef, ConstraintDef, StateDef, TransitionConstraintDef } from './nodes.js';
3
+ import type { ActionDef, ConstraintDef, ExpressionDef, StateDef, TransitionConstraintDef } from './nodes.js';
4
4
  import type { AnyNode } from './types.js';
5
5
  /**
6
6
  * Who may commit a canonical mutation.
@@ -36,6 +36,8 @@ export interface AuthorityContext {
36
36
  actions: Map<NodeId, ActionDef>;
37
37
  constraints: ConstraintDef[];
38
38
  transitionConstraints: TransitionConstraintDef[];
39
+ /** Named expressions, so a rule that reuses one still declares what it reads. */
40
+ expressions: Map<NodeId, ExpressionDef>;
39
41
  principalEntityId?: NodeId;
40
42
  }
41
43
  export declare function authorityContext(nodes: readonly AnyNode[], principalEntityId?: NodeId): AuthorityContext;
package/dist/authority.js CHANGED
@@ -31,6 +31,7 @@ export function authorityContext(nodes, principalEntityId) {
31
31
  const actions = new Map();
32
32
  const constraints = [];
33
33
  const transitionConstraints = [];
34
+ const expressions = new Map();
34
35
  for (const node of nodes) {
35
36
  switch (node.kind) {
36
37
  case 'state':
@@ -45,6 +46,9 @@ export function authorityContext(nodes, principalEntityId) {
45
46
  case 'transition-constraint':
46
47
  transitionConstraints.push(node);
47
48
  break;
49
+ case 'expression':
50
+ expressions.set(node.id, node);
51
+ break;
48
52
  default:
49
53
  }
50
54
  }
@@ -53,6 +57,7 @@ export function authorityContext(nodes, principalEntityId) {
53
57
  actions,
54
58
  constraints,
55
59
  transitionConstraints,
60
+ expressions,
56
61
  ...(principalEntityId ? { principalEntityId } : {}),
57
62
  };
58
63
  }
@@ -61,7 +66,9 @@ export function statesReadBy(expressions, context) {
61
66
  const found = new Set();
62
67
  const seen = new Set();
63
68
  const walk = (expression) => {
64
- for (const id of referencedIds(expression)) {
69
+ // Following named expressions matters here more than anywhere: a rule that reads
70
+ // server state through a reused calculation reads server state.
71
+ for (const id of referencedIds(expression, (target) => context.expressions.get(target))) {
65
72
  const state = context.states.get(id);
66
73
  if (!state || seen.has(id)) {
67
74
  continue;
package/dist/context.js CHANGED
@@ -14,6 +14,12 @@ export function semanticContextFromGraph(graph) {
14
14
  parameterNames.set(parameter.id, parameter.name);
15
15
  }
16
16
  }
17
+ for (const definition of graph.getNodesByKind('expression')) {
18
+ for (const parameter of definition.parameters ?? []) {
19
+ parameterTypes.set(parameter.id, parameter.valueType);
20
+ parameterNames.set(parameter.id, parameter.name);
21
+ }
22
+ }
17
23
  return {
18
24
  getState: (id) => {
19
25
  const node = graph.getNode(id);
@@ -24,6 +30,10 @@ export function semanticContextFromGraph(graph) {
24
30
  return node?.kind === 'entity' ? node : undefined;
25
31
  },
26
32
  getField: (id) => graph.getField(id),
33
+ getExpressionDef: (id) => {
34
+ const node = graph.getNode(id);
35
+ return node?.kind === 'expression' ? node : undefined;
36
+ },
27
37
  getParameterType: (id) => parameterTypes.get(id),
28
38
  getName: (id) => graph.getNode(id)?.name ?? parameterNames.get(id),
29
39
  };
@@ -1,10 +1,17 @@
1
1
  import type { Expression } from './expressions.js';
2
2
  import type { NodeId } from './ids.js';
3
- import type { GraphEdge } from './nodes.js';
3
+ import type { ExpressionDef, GraphEdge } from './nodes.js';
4
4
  import type { AnyNode } from './types.js';
5
5
  import type { ApplicationGraph } from './graph.js';
6
- /** Ids a `ref` expression mentions anywhere in the tree. */
7
- export declare function referencedIds(expression: Expression): NodeId[];
6
+ /**
7
+ * Ids a `ref` expression mentions anywhere in the tree.
8
+ *
9
+ * A `resolve` function makes the walk follow `expression-ref` into the definition's body, so
10
+ * a caller that asks "what does this expression read" gets the same answer whether the
11
+ * calculation was written inline or named. Without one, only the arguments are seen —
12
+ * which is right for a caller that is asking about this tree alone.
13
+ */
14
+ export declare function referencedIds(expression: Expression, resolve?: (id: NodeId) => ExpressionDef | undefined, seen?: Set<NodeId>): NodeId[];
8
15
  /**
9
16
  * Recomputes the structural edges implied by node definitions. Edges index semantics that
10
17
  * already exist in the nodes, so they are derived rather than hand maintained — see
@@ -1,13 +1,28 @@
1
- import { constructedFieldIds, expressionFieldIds, walkExpression } from './expressions.js';
1
+ import { constructedFieldIds, expressionDefsIn, expressionFieldIds, walkExpression } from './expressions.js';
2
2
  import { locationExpressions, locationFieldIds, locationRootStateId, locationSelectorFieldIds, } from './location.js';
3
+ import { isGroupFieldId } from './group.js';
3
4
  import { isUINode } from './ui.js';
4
- /** Ids a `ref` expression mentions anywhere in the tree. */
5
- export function referencedIds(expression) {
5
+ /**
6
+ * Ids a `ref` expression mentions anywhere in the tree.
7
+ *
8
+ * A `resolve` function makes the walk follow `expression-ref` into the definition's body, so
9
+ * a caller that asks "what does this expression read" gets the same answer whether the
10
+ * calculation was written inline or named. Without one, only the arguments are seen —
11
+ * which is right for a caller that is asking about this tree alone.
12
+ */
13
+ export function referencedIds(expression, resolve, seen = new Set()) {
6
14
  const found = [];
7
15
  walkExpression(expression, (node) => {
8
16
  if (node.kind === 'ref') {
9
17
  found.push(node.targetId);
10
18
  }
19
+ if (node.kind === 'expression-ref' && resolve && !seen.has(node.expressionId)) {
20
+ seen.add(node.expressionId);
21
+ const definition = resolve(node.expressionId);
22
+ if (definition) {
23
+ found.push(...referencedIds(definition.expression, resolve, seen));
24
+ }
25
+ }
11
26
  });
12
27
  return found;
13
28
  }
@@ -35,11 +50,13 @@ export function deriveEdges(nodes) {
35
50
  const known = new Set(nodes.map((node) => node.id));
36
51
  const states = new Set(nodes.filter((node) => node.kind === 'state').map((node) => node.id));
37
52
  const pending = new Map();
53
+ // Named expressions, so a consumer's reads include what the definition reads.
54
+ const defs = new Map(nodes.filter((node) => node.kind === 'expression').map((node) => [node.id, node]));
38
55
  // A repeat's template refers to the current item by the repeat node's own id.
39
56
  const rootScope = new Map();
40
57
  for (const node of nodes) {
41
58
  if (node.kind === 'repeat') {
42
- rootScope.set(node.id, statesOf(node.source, new Map(), states));
59
+ rootScope.set(node.id, statesOf(node.source, new Map(), states, defs));
43
60
  }
44
61
  }
45
62
  // Which states can hold instances of an entity, including nested ones. An entity-scoped
@@ -67,9 +84,14 @@ export function deriveEdges(nodes) {
67
84
  pending.set(key, entry);
68
85
  };
69
86
  const reads = (from, expression, scope, kind = 'reads') => {
70
- for (const [stateId, fieldIds] of collectReads(expression, scope, states)) {
87
+ for (const [stateId, fieldIds] of collectReads(expression, scope, states, new Map(), defs)) {
71
88
  link(from, stateId, kind, [...fieldIds]);
72
89
  }
90
+ // Using a named expression is a relationship in its own right: it is how "what depends
91
+ // on this calculation" is answerable without re-walking every expression in the graph.
92
+ for (const expressionId of expressionDefsIn(expression)) {
93
+ link(from, expressionId, 'references');
94
+ }
73
95
  };
74
96
  const writes = (from, location, scope, kind = 'writes', extraFields = []) => {
75
97
  link(from, locationRootStateId(location), kind, [...locationFieldIds(location), ...extraFields]);
@@ -103,12 +125,12 @@ export function deriveEdges(nodes) {
103
125
  break;
104
126
  case 'constraint':
105
127
  if (node.entityId) {
106
- link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
128
+ link(node.id, node.entityId, 'constrains', fieldsRead(node.expression, defs));
107
129
  }
108
130
  reads(node.id, node.expression, node.entityId ? entityScope(node.entityId) : rootScope);
109
131
  break;
110
132
  case 'transition-constraint': {
111
- link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
133
+ link(node.id, node.entityId, 'constrains', fieldsRead(node.expression, defs));
112
134
  const holders = statesByEntity.get(node.entityId) ?? [];
113
135
  reads(node.id, node.expression, new Map([...rootScope, [node.previousScopeId, holders], [node.proposedScopeId, holders]]));
114
136
  break;
@@ -116,6 +138,11 @@ export function deriveEdges(nodes) {
116
138
  case 'route':
117
139
  link(node.id, node.viewId, 'routes-to');
118
140
  break;
141
+ case 'expression':
142
+ // The body is evaluated in isolation, so it is analyzed in isolation: no repeat
143
+ // bindings, no caller scope. Its parameters resolve to nothing here by design.
144
+ reads(node.id, node.expression, new Map());
145
+ break;
119
146
  default:
120
147
  }
121
148
  }
@@ -135,7 +162,7 @@ export function deriveEdges(nodes) {
135
162
  * matters: a collection reached as `coalesce(field(ref(state), lines), [])` still comes
136
163
  * from that state, and its members' fields are still reads of it.
137
164
  */
138
- function statesOf(expression, scope, states) {
165
+ function statesOf(expression, scope, states, defs = new Map()) {
139
166
  switch (expression.kind) {
140
167
  case 'ref': {
141
168
  const bound = scope.get(expression.targetId);
@@ -149,22 +176,75 @@ function statesOf(expression, scope, states) {
149
176
  case 'sort':
150
177
  case 'map':
151
178
  case 'flatten':
152
- return statesOf(expression.source, scope, states);
179
+ case 'group':
180
+ return statesOf(expression.source, scope, states, defs);
153
181
  case 'conditional':
154
182
  return [
155
183
  ...new Set([
156
- ...statesOf(expression.whenTrue, scope, states),
157
- ...statesOf(expression.whenFalse, scope, states),
184
+ ...statesOf(expression.whenTrue, scope, states, defs),
185
+ ...statesOf(expression.whenFalse, scope, states, defs),
158
186
  ]),
159
187
  ];
160
188
  case 'field':
161
- return statesOf(expression.source, scope, states);
189
+ return statesOf(expression.source, scope, states, defs);
162
190
  case 'call':
163
- return [...new Set(expression.arguments.flatMap((argument) => statesOf(argument, scope, states)))];
191
+ return [
192
+ ...new Set(expression.arguments.flatMap((argument) => statesOf(argument, scope, states, defs))),
193
+ ];
194
+ case 'expression-ref': {
195
+ // A named calculation's members come from wherever its body draws them, which may be
196
+ // an argument the caller supplied. Following it is what keeps a repeat over a reused
197
+ // expression attributable to the state its rows actually live in.
198
+ const definition = defs.get(expression.expressionId);
199
+ if (!definition) {
200
+ return [];
201
+ }
202
+ return statesOf(definition.expression, definitionScope(expression, definition, scope, states, defs), states, withoutDefinition(defs, expression.expressionId));
203
+ }
164
204
  default:
165
205
  return [];
166
206
  }
167
207
  }
208
+ /**
209
+ * Fields an expression reads, including those a named expression reads on its behalf.
210
+ *
211
+ * A rule that reuses a calculation constrains the same fields as one that inlined it, so
212
+ * "which fields does this rule watch" must not depend on how it was written.
213
+ */
214
+ function fieldsRead(expression, defs, seen = new Set()) {
215
+ const found = new Set(expressionFieldIds(expression));
216
+ for (const id of expressionDefsIn(expression)) {
217
+ if (seen.has(id)) {
218
+ continue;
219
+ }
220
+ seen.add(id);
221
+ const definition = defs.get(id);
222
+ if (definition) {
223
+ for (const fieldId of fieldsRead(definition.expression, defs, seen)) {
224
+ found.add(fieldId);
225
+ }
226
+ }
227
+ }
228
+ return [...found];
229
+ }
230
+ /** Stops a cyclic definition — which validation rejects — from recursing here. */
231
+ function withoutDefinition(defs, id) {
232
+ const next = new Map(defs);
233
+ next.delete(id);
234
+ return next;
235
+ }
236
+ /**
237
+ * The bindings a definition's body sees: its parameters bound to what the caller passed,
238
+ * and nothing of the caller's own scope. The same isolation validation enforces.
239
+ */
240
+ function definitionScope(reference, definition, callerScope, states, defs) {
241
+ const bindings = new Map();
242
+ for (const parameter of definition.parameters ?? []) {
243
+ const argument = reference.arguments?.[String(parameter.id)];
244
+ bindings.set(parameter.id, argument ? statesOf(argument, callerScope, states, defs) : []);
245
+ }
246
+ return bindings;
247
+ }
168
248
  /** Entities reachable from a type, following entity fields as well as collections. */
169
249
  function reachableEntities(type, entities, seen = new Set()) {
170
250
  const found = [];
@@ -190,7 +270,7 @@ function bind(scope, id, targets) {
190
270
  * projecting a field of each member is recorded as a read of that field of the state the
191
271
  * members came from.
192
272
  */
193
- function collectReads(expression, scope, states, found = new Map()) {
273
+ function collectReads(expression, scope, states, found = new Map(), defs = new Map()) {
194
274
  const record = (stateId, fieldId) => {
195
275
  const entry = found.get(stateId) ?? new Set();
196
276
  if (fieldId) {
@@ -200,31 +280,33 @@ function collectReads(expression, scope, states, found = new Map()) {
200
280
  };
201
281
  switch (expression.kind) {
202
282
  case 'ref':
203
- for (const stateId of statesOf(expression, scope, states)) {
283
+ for (const stateId of statesOf(expression, scope, states, defs)) {
204
284
  record(stateId);
205
285
  }
206
286
  return found;
207
287
  case 'field':
208
- for (const stateId of statesOf(expression.source, scope, states)) {
209
- record(stateId, expression.fieldId);
288
+ for (const stateId of statesOf(expression.source, scope, states, defs)) {
289
+ // A group's own positions belong to no entity, so they are not recorded as field
290
+ // reads; reading them is still a read of the state the group was built from.
291
+ record(stateId, isGroupFieldId(expression.fieldId) ? undefined : expression.fieldId);
210
292
  }
211
- collectReads(expression.source, scope, states, found);
293
+ collectReads(expression.source, scope, states, found, defs);
212
294
  return found;
213
295
  case 'object':
214
296
  for (const entry of expression.entries) {
215
- collectReads(entry.value, scope, states, found);
297
+ collectReads(entry.value, scope, states, found, defs);
216
298
  }
217
299
  return found;
218
300
  case 'binary':
219
- collectReads(expression.left, scope, states, found);
220
- collectReads(expression.right, scope, states, found);
301
+ collectReads(expression.left, scope, states, found, defs);
302
+ collectReads(expression.right, scope, states, found, defs);
221
303
  return found;
222
304
  case 'unary':
223
- collectReads(expression.operand, scope, states, found);
305
+ collectReads(expression.operand, scope, states, found, defs);
224
306
  return found;
225
307
  case 'call':
226
308
  for (const argument of expression.arguments) {
227
- collectReads(argument, scope, states, found);
309
+ collectReads(argument, scope, states, found, defs);
228
310
  }
229
311
  return found;
230
312
  case 'filter':
@@ -232,25 +314,37 @@ function collectReads(expression, scope, states, found = new Map()) {
232
314
  case 'map':
233
315
  case 'sort':
234
316
  case 'every':
235
- case 'some': {
236
- collectReads(expression.source, scope, states, found);
237
- const inner = bind(scope, expression.scopeId, statesOf(expression.source, scope, states));
317
+ case 'some':
318
+ case 'group': {
319
+ collectReads(expression.source, scope, states, found, defs);
320
+ const inner = bind(scope, expression.scopeId, statesOf(expression.source, scope, states, defs));
238
321
  const body = expression.kind === 'map'
239
322
  ? expression.projection
240
- : expression.kind === 'sort'
323
+ : expression.kind === 'sort' || expression.kind === 'group'
241
324
  ? expression.by
242
325
  : expression.predicate;
243
- collectReads(body, inner, states, found);
326
+ collectReads(body, inner, states, found, defs);
244
327
  return found;
245
328
  }
246
329
  case 'flatten':
247
- collectReads(expression.source, scope, states, found);
330
+ collectReads(expression.source, scope, states, found, defs);
248
331
  return found;
249
332
  case 'conditional':
250
- collectReads(expression.condition, scope, states, found);
251
- collectReads(expression.whenTrue, scope, states, found);
252
- collectReads(expression.whenFalse, scope, states, found);
333
+ collectReads(expression.condition, scope, states, found, defs);
334
+ collectReads(expression.whenTrue, scope, states, found, defs);
335
+ collectReads(expression.whenFalse, scope, states, found, defs);
253
336
  return found;
337
+ case 'expression-ref': {
338
+ // The arguments are read in the caller's scope; the body in the definition's own.
339
+ for (const argument of Object.values(expression.arguments ?? {})) {
340
+ collectReads(argument, scope, states, found, defs);
341
+ }
342
+ const definition = defs.get(expression.expressionId);
343
+ if (definition) {
344
+ collectReads(definition.expression, definitionScope(expression, definition, scope, states, defs), states, found, withoutDefinition(defs, expression.expressionId));
345
+ }
346
+ return found;
347
+ }
254
348
  default:
255
349
  return found;
256
350
  }
@@ -51,6 +51,18 @@ export declare const VALIDATION_CODES: {
51
51
  readonly scopeShadowing: "SCOPE_SHADOWING";
52
52
  readonly scopeCollidesWithNode: "SCOPE_COLLIDES_WITH_NODE";
53
53
  readonly ephemeralStatePersisted: "EPHEMERAL_STATE_PERSISTED";
54
+ /** A `field` read of a group position where the source is not a group, or the reverse. */
55
+ readonly invalidGroupField: "INVALID_GROUP_FIELD";
56
+ /** An entity declaring one of the reserved group field ids. */
57
+ readonly reservedFieldId: "RESERVED_FIELD_ID";
58
+ /** An `expression-ref` naming something that is not an expression definition. */
59
+ readonly unknownExpressionDef: "UNKNOWN_EXPRESSION_DEF";
60
+ /** A definition that reaches itself, directly or through another definition. */
61
+ readonly expressionDefCycle: "EXPRESSION_DEF_CYCLE";
62
+ /** A parameter the reference does not supply — the body would resolve nothing. */
63
+ readonly missingExpressionArgument: "MISSING_EXPRESSION_ARGUMENT";
64
+ /** An argument the definition declares no parameter for. */
65
+ readonly unknownExpressionArgument: "UNKNOWN_EXPRESSION_ARGUMENT";
54
66
  readonly unknownPresentationToken: "UNKNOWN_PRESENTATION_TOKEN";
55
67
  readonly presentationSemanticConflict: "PRESENTATION_SEMANTIC_CONFLICT";
56
68
  readonly multiplePrimaryActions: "MULTIPLE_PRIMARY_ACTIONS";
@@ -59,6 +71,10 @@ export declare const VALIDATION_CODES: {
59
71
  readonly destructiveActionUnmarked: "DESTRUCTIVE_ACTION_UNMARKED";
60
72
  readonly excessiveHorizontalActions: "EXCESSIVE_HORIZONTAL_ACTIONS";
61
73
  readonly emptyStateWithoutRecoveryAction: "EMPTY_STATE_WITHOUT_RECOVERY_ACTION";
74
+ /** A UI node kind the intended renderer cannot draw. */
75
+ readonly unsupportedUiNodeKind: "UNSUPPORTED_UI_NODE_KIND";
76
+ /** A dialog whose declaration cannot produce a usable dialog. */
77
+ readonly invalidDialog: "INVALID_DIALOG";
62
78
  readonly rigidHorizontalLayout: "RIGID_HORIZONTAL_LAYOUT";
63
79
  readonly conflictingSizing: "CONFLICTING_SIZING";
64
80
  readonly interactiveElementMissingLabel: "INTERACTIVE_ELEMENT_MISSING_LABEL";
@@ -34,6 +34,19 @@ export const VALIDATION_CODES = {
34
34
  scopeShadowing: 'SCOPE_SHADOWING',
35
35
  scopeCollidesWithNode: 'SCOPE_COLLIDES_WITH_NODE',
36
36
  ephemeralStatePersisted: 'EPHEMERAL_STATE_PERSISTED',
37
+ // Reusable expressions and grouping.
38
+ /** A `field` read of a group position where the source is not a group, or the reverse. */
39
+ invalidGroupField: 'INVALID_GROUP_FIELD',
40
+ /** An entity declaring one of the reserved group field ids. */
41
+ reservedFieldId: 'RESERVED_FIELD_ID',
42
+ /** An `expression-ref` naming something that is not an expression definition. */
43
+ unknownExpressionDef: 'UNKNOWN_EXPRESSION_DEF',
44
+ /** A definition that reaches itself, directly or through another definition. */
45
+ expressionDefCycle: 'EXPRESSION_DEF_CYCLE',
46
+ /** A parameter the reference does not supply — the body would resolve nothing. */
47
+ missingExpressionArgument: 'MISSING_EXPRESSION_ARGUMENT',
48
+ /** An argument the definition declares no parameter for. */
49
+ unknownExpressionArgument: 'UNKNOWN_EXPRESSION_ARGUMENT',
37
50
  // Presentation and UX. Everything here is a warning except an unknown token, which the
38
51
  // renderer genuinely cannot act on.
39
52
  unknownPresentationToken: 'UNKNOWN_PRESENTATION_TOKEN',
@@ -44,6 +57,10 @@ export const VALIDATION_CODES = {
44
57
  destructiveActionUnmarked: 'DESTRUCTIVE_ACTION_UNMARKED',
45
58
  excessiveHorizontalActions: 'EXCESSIVE_HORIZONTAL_ACTIONS',
46
59
  emptyStateWithoutRecoveryAction: 'EMPTY_STATE_WITHOUT_RECOVERY_ACTION',
60
+ /** A UI node kind the intended renderer cannot draw. */
61
+ unsupportedUiNodeKind: 'UNSUPPORTED_UI_NODE_KIND',
62
+ /** A dialog whose declaration cannot produce a usable dialog. */
63
+ invalidDialog: 'INVALID_DIALOG',
47
64
  rigidHorizontalLayout: 'RIGID_HORIZONTAL_LAYOUT',
48
65
  conflictingSizing: 'CONFLICTING_SIZING',
49
66
  interactiveElementMissingLabel: 'INTERACTIVE_ELEMENT_MISSING_LABEL',
@@ -5,7 +5,7 @@ import type { LiteralValue } from './nodes.js';
5
5
  * identifier: `ref` resolves an id against the evaluation scope chain (route parameters,
6
6
  * action parameters, iteration scopes, then state), and `field` reads a field by id.
7
7
  */
8
- export type Expression = LiteralExpression | RefExpression | FieldExpression | ObjectExpression | BinaryExpression | UnaryExpression | CallExpression | FilterExpression | FindExpression | MapExpression | SortExpression | EveryExpression | SomeExpression | FlattenExpression | ConditionalExpression;
8
+ export type Expression = LiteralExpression | RefExpression | FieldExpression | ObjectExpression | BinaryExpression | UnaryExpression | CallExpression | FilterExpression | FindExpression | MapExpression | SortExpression | EveryExpression | SomeExpression | FlattenExpression | ConditionalExpression | GroupExpression | ExpressionRefExpression;
9
9
  export type ExpressionKind = Expression['kind'];
10
10
  /** Every expression kind the runtime is required to evaluate. */
11
11
  export declare const EXPRESSION_KINDS: readonly ExpressionKind[];
@@ -118,6 +118,46 @@ export interface FlattenExpression {
118
118
  kind: 'flatten';
119
119
  source: Expression;
120
120
  }
121
+ /**
122
+ * Partitions a collection by a key.
123
+ *
124
+ * `Collection<A>` becomes `Collection<Group<K, A>>`, where the key is `by` evaluated with
125
+ * each member bound to `scopeId` — the same iteration scope every other collection operator
126
+ * introduces. A group is read with `groupKey` and `groupItems`.
127
+ *
128
+ * The **ordering contract** is part of the semantics, not an accident of implementation:
129
+ *
130
+ * - groups appear in the order their key was **first seen** in the source collection;
131
+ * - members within a group keep their source order;
132
+ * - two keys are the same key when they are structurally equal, so a key may be a nested
133
+ * record and not only a primitive;
134
+ * - an empty collection produces no groups, and a source that is `null` fails the
135
+ * evaluation like every other collection operator.
136
+ *
137
+ * Nothing is sorted. A caller that wants groups in key order says so with `sort`, which is
138
+ * the operator whose job that is.
139
+ */
140
+ export interface GroupExpression {
141
+ kind: 'group';
142
+ source: Expression;
143
+ scopeId: NodeId;
144
+ by: Expression;
145
+ }
146
+ /**
147
+ * Evaluates a named expression definition — the reuse mechanism (`ExpressionDef`).
148
+ *
149
+ * `arguments` are keyed by the definition's parameter ids and are evaluated in **this**
150
+ * scope; the body is then evaluated in an **isolated** scope that sees the parameters and
151
+ * application state and nothing else. That isolation is the whole point: a definition
152
+ * reused in three places cannot pick up an iteration scope from one of them, and its own
153
+ * internal scope ids can never collide with a caller's.
154
+ */
155
+ export interface ExpressionRefExpression {
156
+ kind: 'expression-ref';
157
+ expressionId: NodeId;
158
+ /** Keyed by parameter id. */
159
+ arguments?: Record<string, Expression>;
160
+ }
121
161
  /** Chooses between two values. Both branches are expressions, never callbacks. */
122
162
  export interface ConditionalExpression {
123
163
  kind: 'conditional';
@@ -149,6 +189,19 @@ export declare function coalesce(...values: Expression[]): CallExpression;
149
189
  export declare function every(source: Expression, scopeId: NodeId, predicate: Expression): EveryExpression;
150
190
  export declare function some(source: Expression, scopeId: NodeId, predicate: Expression): SomeExpression;
151
191
  export declare function flatten(source: Expression): FlattenExpression;
192
+ export declare function group(source: Expression, scopeId: NodeId, by: Expression): GroupExpression;
193
+ /** The key every member of a group shares. */
194
+ export declare function groupKey(source: Expression): FieldExpression;
195
+ /** The members of a group, in the order they appeared in the source collection. */
196
+ export declare function groupItems(source: Expression): FieldExpression;
197
+ /**
198
+ * References a named expression definition, optionally supplying its parameters.
199
+ *
200
+ * Deliberately not `ref`: `ref` resolves a value in the scope chain, and a definition is not
201
+ * a value in scope. A separate kind means a reader can see that an expression reaches into a
202
+ * definition without resolving anything first.
203
+ */
204
+ export declare function expressionRef(expressionId: NodeId, args?: Record<string, Expression>): ExpressionRefExpression;
152
205
  export declare function conditional(condition: Expression, whenTrue: Expression, whenFalse: Expression): ConditionalExpression;
153
206
  /** Visits every sub-expression, parents before children. */
154
207
  export declare function walkExpression(expression: Expression, visit: (node: Expression) => void): void;
@@ -157,6 +210,8 @@ export declare function walkExpression(expression: Expression, visit: (node: Exp
157
210
  * expressions that compute those values are reads, not writes.
158
211
  */
159
212
  export declare function constructedFieldIds(expression: Expression): FieldId[];
213
+ /** Expression definitions an expression reaches directly, in tree order. */
214
+ export declare function expressionDefsIn(expression: Expression): NodeId[];
160
215
  /** Field ids an expression reads, including nested sources and constructed records. */
161
216
  export declare function expressionFieldIds(expression: Expression): FieldId[];
162
217
  //# sourceMappingURL=expressions.d.ts.map