@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.
- package/dist/authoring-metadata.d.ts +53 -0
- package/dist/authoring-metadata.js +71 -0
- package/dist/authority.d.ts +3 -1
- package/dist/authority.js +8 -1
- package/dist/context.js +10 -0
- package/dist/derive-edges.d.ts +10 -3
- package/dist/derive-edges.js +126 -32
- package/dist/diagnostics.d.ts +16 -0
- package/dist/diagnostics.js +17 -0
- package/dist/expressions.d.ts +56 -1
- package/dist/expressions.js +47 -1
- package/dist/graph.js +1 -1
- package/dist/group.d.ts +23 -0
- package/dist/group.js +24 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/infer.d.ts +7 -3
- package/dist/infer.js +63 -11
- package/dist/ir.d.ts +6 -1
- package/dist/nodes.d.ts +39 -0
- package/dist/renderer-capabilities.d.ts +28 -0
- package/dist/renderer-capabilities.js +21 -0
- package/dist/server-ir.d.ts +43 -5
- package/dist/server-ir.js +92 -2
- package/dist/type-ref.d.ts +18 -1
- package/dist/type-ref.js +12 -0
- package/dist/types.d.ts +5 -3
- package/dist/types.js +10 -1
- package/dist/ui.d.ts +50 -2
- package/dist/ui.js +2 -0
- package/dist/validate-value.js +22 -0
- package/dist/validate.d.ts +10 -1
- package/dist/validate.js +301 -9
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/authority.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/derive-edges.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
7
|
-
|
|
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
|
package/dist/derive-edges.js
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -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";
|
package/dist/diagnostics.js
CHANGED
|
@@ -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',
|
package/dist/expressions.d.ts
CHANGED
|
@@ -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
|