@cynodia/axiom-core 0.4.0-alpha.1 → 0.4.1-alpha.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/dist/derive-edges.js +67 -6
- package/dist/diagnostics.d.ts +2 -0
- package/dist/diagnostics.js +2 -0
- package/dist/expressions.d.ts +44 -2
- package/dist/expressions.js +52 -0
- package/dist/graph.js +1 -1
- package/dist/infer.js +10 -0
- package/dist/ir.d.ts +3 -1
- package/dist/nodes.d.ts +35 -0
- package/dist/nodes.js +13 -0
- package/dist/types.d.ts +3 -3
- package/dist/validate.js +92 -9
- package/package.json +1 -1
package/dist/derive-edges.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expressionFieldIds, walkExpression } from './expressions.js';
|
|
1
|
+
import { constructedFieldIds, expressionFieldIds, walkExpression } from './expressions.js';
|
|
2
2
|
import { locationExpressions, locationFieldIds, locationRootStateId, locationSelectorFieldIds, } from './location.js';
|
|
3
3
|
import { isUINode } from './ui.js';
|
|
4
4
|
/** Ids a `ref` expression mentions anywhere in the tree. */
|
|
@@ -42,6 +42,19 @@ export function deriveEdges(nodes) {
|
|
|
42
42
|
rootScope.set(node.id, statesOf(node.source, new Map(), states));
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
// Which states can hold instances of an entity, including nested ones. An entity-scoped
|
|
46
|
+
// constraint reads its fields wherever those instances actually live.
|
|
47
|
+
const entities = new Map(nodes.filter((node) => node.kind === 'entity').map((node) => [node.id, node]));
|
|
48
|
+
const statesByEntity = new Map();
|
|
49
|
+
for (const node of nodes) {
|
|
50
|
+
if (node.kind !== 'state' || node.draft || node.derivation) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const entityId of reachableEntities(node.valueType, entities)) {
|
|
54
|
+
statesByEntity.set(entityId, [...(statesByEntity.get(entityId) ?? []), node.id]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const entityScope = (entityId) => new Map([...rootScope, [entityId, statesByEntity.get(entityId) ?? []]]);
|
|
45
58
|
const link = (from, to, kind, fieldIds = []) => {
|
|
46
59
|
if (from === to || !known.has(from) || !known.has(to)) {
|
|
47
60
|
return;
|
|
@@ -92,8 +105,14 @@ export function deriveEdges(nodes) {
|
|
|
92
105
|
if (node.entityId) {
|
|
93
106
|
link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
|
|
94
107
|
}
|
|
95
|
-
reads(node.id, node.expression, rootScope);
|
|
108
|
+
reads(node.id, node.expression, node.entityId ? entityScope(node.entityId) : rootScope);
|
|
109
|
+
break;
|
|
110
|
+
case 'transition-constraint': {
|
|
111
|
+
link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
|
|
112
|
+
const holders = statesByEntity.get(node.entityId) ?? [];
|
|
113
|
+
reads(node.id, node.expression, new Map([...rootScope, [node.previousScopeId, holders], [node.proposedScopeId, holders]]));
|
|
96
114
|
break;
|
|
115
|
+
}
|
|
97
116
|
case 'route':
|
|
98
117
|
link(node.id, node.viewId, 'routes-to');
|
|
99
118
|
break;
|
|
@@ -111,7 +130,11 @@ export function deriveEdges(nodes) {
|
|
|
111
130
|
},
|
|
112
131
|
}));
|
|
113
132
|
}
|
|
114
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* The states an expression ultimately draws its members from. Following `field` and calls
|
|
135
|
+
* matters: a collection reached as `coalesce(field(ref(state), lines), [])` still comes
|
|
136
|
+
* from that state, and its members' fields are still reads of it.
|
|
137
|
+
*/
|
|
115
138
|
function statesOf(expression, scope, states) {
|
|
116
139
|
switch (expression.kind) {
|
|
117
140
|
case 'ref': {
|
|
@@ -125,11 +148,38 @@ function statesOf(expression, scope, states) {
|
|
|
125
148
|
case 'find':
|
|
126
149
|
case 'sort':
|
|
127
150
|
case 'map':
|
|
151
|
+
case 'flatten':
|
|
152
|
+
return statesOf(expression.source, scope, states);
|
|
153
|
+
case 'conditional':
|
|
154
|
+
return [
|
|
155
|
+
...new Set([
|
|
156
|
+
...statesOf(expression.whenTrue, scope, states),
|
|
157
|
+
...statesOf(expression.whenFalse, scope, states),
|
|
158
|
+
]),
|
|
159
|
+
];
|
|
160
|
+
case 'field':
|
|
128
161
|
return statesOf(expression.source, scope, states);
|
|
162
|
+
case 'call':
|
|
163
|
+
return [...new Set(expression.arguments.flatMap((argument) => statesOf(argument, scope, states)))];
|
|
129
164
|
default:
|
|
130
165
|
return [];
|
|
131
166
|
}
|
|
132
167
|
}
|
|
168
|
+
/** Entities reachable from a type, following entity fields as well as collections. */
|
|
169
|
+
function reachableEntities(type, entities, seen = new Set()) {
|
|
170
|
+
const found = [];
|
|
171
|
+
for (const entityId of entityIdsIn(type)) {
|
|
172
|
+
if (seen.has(entityId)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
seen.add(entityId);
|
|
176
|
+
found.push(entityId);
|
|
177
|
+
for (const field of entities.get(entityId)?.fields ?? []) {
|
|
178
|
+
found.push(...reachableEntities(field.valueType, entities, seen));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
133
183
|
function bind(scope, id, targets) {
|
|
134
184
|
const next = new Map(scope);
|
|
135
185
|
next.set(id, targets);
|
|
@@ -180,7 +230,9 @@ function collectReads(expression, scope, states, found = new Map()) {
|
|
|
180
230
|
case 'filter':
|
|
181
231
|
case 'find':
|
|
182
232
|
case 'map':
|
|
183
|
-
case 'sort':
|
|
233
|
+
case 'sort':
|
|
234
|
+
case 'every':
|
|
235
|
+
case 'some': {
|
|
184
236
|
collectReads(expression.source, scope, states, found);
|
|
185
237
|
const inner = bind(scope, expression.scopeId, statesOf(expression.source, scope, states));
|
|
186
238
|
const body = expression.kind === 'map'
|
|
@@ -191,6 +243,14 @@ function collectReads(expression, scope, states, found = new Map()) {
|
|
|
191
243
|
collectReads(body, inner, states, found);
|
|
192
244
|
return found;
|
|
193
245
|
}
|
|
246
|
+
case 'flatten':
|
|
247
|
+
collectReads(expression.source, scope, states, found);
|
|
248
|
+
return found;
|
|
249
|
+
case 'conditional':
|
|
250
|
+
collectReads(expression.condition, scope, states, found);
|
|
251
|
+
collectReads(expression.whenTrue, scope, states, found);
|
|
252
|
+
collectReads(expression.whenFalse, scope, states, found);
|
|
253
|
+
return found;
|
|
194
254
|
default:
|
|
195
255
|
return found;
|
|
196
256
|
}
|
|
@@ -217,8 +277,9 @@ function linkOperations(actionId, operations, linker, scope) {
|
|
|
217
277
|
linker.reads(actionId, operation.value, scope);
|
|
218
278
|
break;
|
|
219
279
|
case 'insert':
|
|
220
|
-
//
|
|
221
|
-
|
|
280
|
+
// A constructed record writes the fields it declares. Fields consulted while
|
|
281
|
+
// computing those values are reads, and must not be reported as writes.
|
|
282
|
+
linker.writes(actionId, operation.target, scope, 'writes', constructedFieldIds(operation.value));
|
|
222
283
|
linker.reads(actionId, operation.value, scope);
|
|
223
284
|
break;
|
|
224
285
|
case 'remove':
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -48,5 +48,7 @@ export declare const VALIDATION_CODES: {
|
|
|
48
48
|
readonly initialValueUnknownField: "INITIAL_VALUE_UNKNOWN_FIELD";
|
|
49
49
|
readonly initialValueMissingRequiredField: "INITIAL_VALUE_MISSING_REQUIRED_FIELD";
|
|
50
50
|
readonly initialValueInvalidEntity: "INITIAL_VALUE_INVALID_ENTITY";
|
|
51
|
+
readonly scopeShadowing: "SCOPE_SHADOWING";
|
|
52
|
+
readonly scopeCollidesWithNode: "SCOPE_COLLIDES_WITH_NODE";
|
|
51
53
|
};
|
|
52
54
|
//# sourceMappingURL=diagnostics.d.ts.map
|
package/dist/diagnostics.js
CHANGED
|
@@ -31,4 +31,6 @@ export const VALIDATION_CODES = {
|
|
|
31
31
|
initialValueUnknownField: 'INITIAL_VALUE_UNKNOWN_FIELD',
|
|
32
32
|
initialValueMissingRequiredField: 'INITIAL_VALUE_MISSING_REQUIRED_FIELD',
|
|
33
33
|
initialValueInvalidEntity: 'INITIAL_VALUE_INVALID_ENTITY',
|
|
34
|
+
scopeShadowing: 'SCOPE_SHADOWING',
|
|
35
|
+
scopeCollidesWithNode: 'SCOPE_COLLIDES_WITH_NODE',
|
|
34
36
|
};
|
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;
|
|
8
|
+
export type Expression = LiteralExpression | RefExpression | FieldExpression | ObjectExpression | BinaryExpression | UnaryExpression | CallExpression | FilterExpression | FindExpression | MapExpression | SortExpression | EveryExpression | SomeExpression | FlattenExpression | ConditionalExpression;
|
|
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[];
|
|
@@ -57,7 +57,7 @@ export interface UnaryExpression {
|
|
|
57
57
|
* must be implemented by the runtime: a function that is declared here but unevaluated
|
|
58
58
|
* would be a construct that typechecks, validates and then does nothing.
|
|
59
59
|
*/
|
|
60
|
-
export type BuiltinFunction = 'required' | 'is-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'now' | 'uuid';
|
|
60
|
+
export type BuiltinFunction = 'required' | 'is-empty' | 'non-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'now' | 'uuid';
|
|
61
61
|
export declare const BUILTIN_FUNCTIONS: readonly BuiltinFunction[];
|
|
62
62
|
/** Functions that reduce a collection of numbers to a number. */
|
|
63
63
|
export declare const AGGREGATE_FUNCTIONS: readonly BuiltinFunction[];
|
|
@@ -99,6 +99,32 @@ export interface SortExpression {
|
|
|
99
99
|
by: Expression;
|
|
100
100
|
direction?: 'asc' | 'desc';
|
|
101
101
|
}
|
|
102
|
+
/** True when every member satisfies the predicate. An empty collection satisfies it. */
|
|
103
|
+
export interface EveryExpression {
|
|
104
|
+
kind: 'every';
|
|
105
|
+
source: Expression;
|
|
106
|
+
scopeId: NodeId;
|
|
107
|
+
predicate: Expression;
|
|
108
|
+
}
|
|
109
|
+
/** True when at least one member satisfies the predicate. An empty collection does not. */
|
|
110
|
+
export interface SomeExpression {
|
|
111
|
+
kind: 'some';
|
|
112
|
+
source: Expression;
|
|
113
|
+
scopeId: NodeId;
|
|
114
|
+
predicate: Expression;
|
|
115
|
+
}
|
|
116
|
+
/** Collapses one level of nesting: Collection<Collection<T>> becomes Collection<T>. */
|
|
117
|
+
export interface FlattenExpression {
|
|
118
|
+
kind: 'flatten';
|
|
119
|
+
source: Expression;
|
|
120
|
+
}
|
|
121
|
+
/** Chooses between two values. Both branches are expressions, never callbacks. */
|
|
122
|
+
export interface ConditionalExpression {
|
|
123
|
+
kind: 'conditional';
|
|
124
|
+
condition: Expression;
|
|
125
|
+
whenTrue: Expression;
|
|
126
|
+
whenFalse: Expression;
|
|
127
|
+
}
|
|
102
128
|
export declare function literal(value: LiteralValue): LiteralExpression;
|
|
103
129
|
export declare function ref(targetId: NodeId): RefExpression;
|
|
104
130
|
export declare function field(source: Expression, id: FieldId): FieldExpression;
|
|
@@ -113,8 +139,24 @@ export declare function sort(source: Expression, scopeId: NodeId, by: Expression
|
|
|
113
139
|
/** Sums a collection of numbers. An empty collection sums to zero. */
|
|
114
140
|
export declare function sum(source: Expression): CallExpression;
|
|
115
141
|
export declare function count(source: Expression): CallExpression;
|
|
142
|
+
/** True when a value exists at all — an empty collection or string still exists. */
|
|
143
|
+
export declare function required(value: Expression): CallExpression;
|
|
144
|
+
/** True when a collection or string has no members. */
|
|
145
|
+
export declare function isEmpty(value: Expression): CallExpression;
|
|
146
|
+
export declare function nonEmpty(value: Expression): CallExpression;
|
|
147
|
+
/** The first value that exists. Only null and undefined are skipped. */
|
|
148
|
+
export declare function coalesce(...values: Expression[]): CallExpression;
|
|
149
|
+
export declare function every(source: Expression, scopeId: NodeId, predicate: Expression): EveryExpression;
|
|
150
|
+
export declare function some(source: Expression, scopeId: NodeId, predicate: Expression): SomeExpression;
|
|
151
|
+
export declare function flatten(source: Expression): FlattenExpression;
|
|
152
|
+
export declare function conditional(condition: Expression, whenTrue: Expression, whenFalse: Expression): ConditionalExpression;
|
|
116
153
|
/** Visits every sub-expression, parents before children. */
|
|
117
154
|
export declare function walkExpression(expression: Expression, visit: (node: Expression) => void): void;
|
|
155
|
+
/**
|
|
156
|
+
* Field ids a constructed record assigns. Only the record's own entries count: the
|
|
157
|
+
* expressions that compute those values are reads, not writes.
|
|
158
|
+
*/
|
|
159
|
+
export declare function constructedFieldIds(expression: Expression): FieldId[];
|
|
118
160
|
/** Field ids an expression reads, including nested sources and constructed records. */
|
|
119
161
|
export declare function expressionFieldIds(expression: Expression): FieldId[];
|
|
120
162
|
//# sourceMappingURL=expressions.d.ts.map
|
package/dist/expressions.js
CHANGED
|
@@ -11,10 +11,15 @@ export const EXPRESSION_KINDS = [
|
|
|
11
11
|
'find',
|
|
12
12
|
'map',
|
|
13
13
|
'sort',
|
|
14
|
+
'every',
|
|
15
|
+
'some',
|
|
16
|
+
'flatten',
|
|
17
|
+
'conditional',
|
|
14
18
|
];
|
|
15
19
|
export const BUILTIN_FUNCTIONS = [
|
|
16
20
|
'required',
|
|
17
21
|
'is-empty',
|
|
22
|
+
'non-empty',
|
|
18
23
|
'length',
|
|
19
24
|
'contains',
|
|
20
25
|
'concat',
|
|
@@ -69,6 +74,33 @@ export function sum(source) {
|
|
|
69
74
|
export function count(source) {
|
|
70
75
|
return call('count', source);
|
|
71
76
|
}
|
|
77
|
+
/** True when a value exists at all — an empty collection or string still exists. */
|
|
78
|
+
export function required(value) {
|
|
79
|
+
return call('required', value);
|
|
80
|
+
}
|
|
81
|
+
/** True when a collection or string has no members. */
|
|
82
|
+
export function isEmpty(value) {
|
|
83
|
+
return call('is-empty', value);
|
|
84
|
+
}
|
|
85
|
+
export function nonEmpty(value) {
|
|
86
|
+
return call('non-empty', value);
|
|
87
|
+
}
|
|
88
|
+
/** The first value that exists. Only null and undefined are skipped. */
|
|
89
|
+
export function coalesce(...values) {
|
|
90
|
+
return call('coalesce', ...values);
|
|
91
|
+
}
|
|
92
|
+
export function every(source, scopeId, predicate) {
|
|
93
|
+
return { kind: 'every', source, scopeId, predicate };
|
|
94
|
+
}
|
|
95
|
+
export function some(source, scopeId, predicate) {
|
|
96
|
+
return { kind: 'some', source, scopeId, predicate };
|
|
97
|
+
}
|
|
98
|
+
export function flatten(source) {
|
|
99
|
+
return { kind: 'flatten', source };
|
|
100
|
+
}
|
|
101
|
+
export function conditional(condition, whenTrue, whenFalse) {
|
|
102
|
+
return { kind: 'conditional', condition, whenTrue, whenFalse };
|
|
103
|
+
}
|
|
72
104
|
/** Visits every sub-expression, parents before children. */
|
|
73
105
|
export function walkExpression(expression, visit) {
|
|
74
106
|
visit(expression);
|
|
@@ -106,9 +138,29 @@ export function walkExpression(expression, visit) {
|
|
|
106
138
|
walkExpression(expression.source, visit);
|
|
107
139
|
walkExpression(expression.by, visit);
|
|
108
140
|
return;
|
|
141
|
+
case 'every':
|
|
142
|
+
case 'some':
|
|
143
|
+
walkExpression(expression.source, visit);
|
|
144
|
+
walkExpression(expression.predicate, visit);
|
|
145
|
+
return;
|
|
146
|
+
case 'flatten':
|
|
147
|
+
walkExpression(expression.source, visit);
|
|
148
|
+
return;
|
|
149
|
+
case 'conditional':
|
|
150
|
+
walkExpression(expression.condition, visit);
|
|
151
|
+
walkExpression(expression.whenTrue, visit);
|
|
152
|
+
walkExpression(expression.whenFalse, visit);
|
|
153
|
+
return;
|
|
109
154
|
default:
|
|
110
155
|
}
|
|
111
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Field ids a constructed record assigns. Only the record's own entries count: the
|
|
159
|
+
* expressions that compute those values are reads, not writes.
|
|
160
|
+
*/
|
|
161
|
+
export function constructedFieldIds(expression) {
|
|
162
|
+
return expression.kind === 'object' ? expression.entries.map((entry) => entry.fieldId) : [];
|
|
163
|
+
}
|
|
112
164
|
/** Field ids an expression reads, including nested sources and constructed records. */
|
|
113
165
|
export function expressionFieldIds(expression) {
|
|
114
166
|
const found = [];
|
package/dist/graph.js
CHANGED
|
@@ -16,7 +16,7 @@ export class ApplicationGraph {
|
|
|
16
16
|
/** Bumped by every change, so the derived edge index can never serve stale data. */
|
|
17
17
|
revision = 0;
|
|
18
18
|
semanticIndex;
|
|
19
|
-
constructor(id, name, version = '0.4.
|
|
19
|
+
constructor(id, name, version = '0.4.1') {
|
|
20
20
|
this.data = { id, name, version, nodes: {}, edges: {} };
|
|
21
21
|
}
|
|
22
22
|
get id() {
|
package/dist/infer.js
CHANGED
|
@@ -141,6 +141,14 @@ export function inferExpressionType(expression, context, scope) {
|
|
|
141
141
|
const item = itemTypeOf(inferExpressionType(expression.source, context, scope));
|
|
142
142
|
return item ? optionalType(item) : undefined;
|
|
143
143
|
}
|
|
144
|
+
case 'every':
|
|
145
|
+
case 'some':
|
|
146
|
+
return primitiveType('boolean');
|
|
147
|
+
case 'flatten':
|
|
148
|
+
return itemTypeOf(inferExpressionType(expression.source, context, scope));
|
|
149
|
+
case 'conditional':
|
|
150
|
+
return (inferExpressionType(expression.whenTrue, context, scope) ??
|
|
151
|
+
inferExpressionType(expression.whenFalse, context, scope));
|
|
144
152
|
case 'map': {
|
|
145
153
|
const item = itemTypeOf(inferExpressionType(expression.source, context, scope));
|
|
146
154
|
const projected = inferExpressionType(expression.projection, context, withScope(scope, expression.scopeId, item));
|
|
@@ -157,6 +165,8 @@ export function scopeForExpression(expression, context, scope) {
|
|
|
157
165
|
case 'find':
|
|
158
166
|
case 'map':
|
|
159
167
|
case 'sort':
|
|
168
|
+
case 'every':
|
|
169
|
+
case 'some':
|
|
160
170
|
return withScope(scope, expression.scopeId, itemTypeOf(inferExpressionType(expression.source, context, scope)));
|
|
161
171
|
default:
|
|
162
172
|
return scope ?? new Map();
|
package/dist/ir.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FieldId, NodeId } from './ids.js';
|
|
2
|
-
import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteParameter, StateDef } from './nodes.js';
|
|
2
|
+
import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteParameter, StateDef, TransitionConstraintDef } from './nodes.js';
|
|
3
3
|
import type { FieldIndexEntry } from './graph.js';
|
|
4
4
|
import type { UINode } from './ui.js';
|
|
5
5
|
import type { AnyNode } from './types.js';
|
|
@@ -34,6 +34,8 @@ export interface ApplicationIR {
|
|
|
34
34
|
actions: Record<NodeId, ActionDef>;
|
|
35
35
|
uiNodes: Record<NodeId, UINode>;
|
|
36
36
|
constraints: ConstraintDef[];
|
|
37
|
+
/** Rules about how state may change, enforced on every governed mutation path. */
|
|
38
|
+
transitionConstraints: TransitionConstraintDef[];
|
|
37
39
|
routes: CompiledRoute[];
|
|
38
40
|
edges: GraphEdge[];
|
|
39
41
|
/**
|
package/dist/nodes.d.ts
CHANGED
|
@@ -62,9 +62,16 @@ export interface FailureMode {
|
|
|
62
62
|
code: string;
|
|
63
63
|
message?: string;
|
|
64
64
|
}
|
|
65
|
+
/** A condition together with the failure it reports, so the two cannot drift apart. */
|
|
66
|
+
export interface ActionGuard {
|
|
67
|
+
condition: Expression;
|
|
68
|
+
failureMode?: FailureMode;
|
|
69
|
+
}
|
|
65
70
|
export interface ActionDef extends NodeBase {
|
|
66
71
|
kind: 'action';
|
|
67
72
|
parameters?: ActionParameter[];
|
|
73
|
+
/** Preferred over the parallel `preconditions` and `failureModes` arrays. */
|
|
74
|
+
guards?: ActionGuard[];
|
|
68
75
|
preconditions?: Expression[];
|
|
69
76
|
operations: Operation[];
|
|
70
77
|
postconditions?: Expression[];
|
|
@@ -74,6 +81,11 @@ export interface ActionDef extends NodeBase {
|
|
|
74
81
|
requiresConfirmation?: boolean;
|
|
75
82
|
confirmationMessage?: string;
|
|
76
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The conditions an action checks, however they were written. `guards` pairs each
|
|
86
|
+
* condition with its failure; the older parallel arrays are matched by position.
|
|
87
|
+
*/
|
|
88
|
+
export declare function actionGuards(action: ActionDef): ActionGuard[];
|
|
77
89
|
export type Operation = SetOperation | InsertOperation | RemoveOperation | ForEachOperation | InvokeOperation | NavigateOperation | NativeOperation;
|
|
78
90
|
export type OperationKind = Operation['kind'];
|
|
79
91
|
/** Every operation kind the runtime is required to execute. */
|
|
@@ -159,6 +171,29 @@ export interface ConstraintDef extends NodeBase {
|
|
|
159
171
|
severity?: 'error' | 'warning';
|
|
160
172
|
message?: string;
|
|
161
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* A rule about how state may change, rather than about what state may be.
|
|
176
|
+
*
|
|
177
|
+
* An ordinary constraint judges the proposed state on its own; a transition constraint
|
|
178
|
+
* sees the instance as it was when the transaction began *and* as the transaction
|
|
179
|
+
* proposes it. That is what lets a rule like "once confirmed, an order may not change"
|
|
180
|
+
* hold no matter which path attempts the write — an action, an input binding, an
|
|
181
|
+
* iteration, or something added later.
|
|
182
|
+
*
|
|
183
|
+
* `previousScopeId` and `proposedScopeId` bind those two instances for the expression.
|
|
184
|
+
* When the instance is being removed, the proposed scope is bound to nothing.
|
|
185
|
+
*/
|
|
186
|
+
export interface TransitionConstraintDef extends NodeBase {
|
|
187
|
+
kind: 'transition-constraint';
|
|
188
|
+
/** The entity whose transitions are governed. It must have an identity field. */
|
|
189
|
+
entityId: NodeId;
|
|
190
|
+
previousScopeId: NodeId;
|
|
191
|
+
proposedScopeId: NodeId;
|
|
192
|
+
/** Must hold for every governed transition. */
|
|
193
|
+
expression: Expression;
|
|
194
|
+
severity?: 'error' | 'warning';
|
|
195
|
+
message?: string;
|
|
196
|
+
}
|
|
162
197
|
export interface RouteParameter {
|
|
163
198
|
id: NodeId;
|
|
164
199
|
/** Matches the `:name` placeholder in the route path. */
|
package/dist/nodes.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conditions an action checks, however they were written. `guards` pairs each
|
|
3
|
+
* condition with its failure; the older parallel arrays are matched by position.
|
|
4
|
+
*/
|
|
5
|
+
export function actionGuards(action) {
|
|
6
|
+
if (action.guards && action.guards.length > 0) {
|
|
7
|
+
return action.guards;
|
|
8
|
+
}
|
|
9
|
+
return (action.preconditions ?? []).map((condition, index) => ({
|
|
10
|
+
condition,
|
|
11
|
+
...(action.failureModes?.[index] ? { failureMode: action.failureModes[index] } : {}),
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
1
14
|
/** Every operation kind the runtime is required to execute. */
|
|
2
15
|
export const OPERATION_KINDS = [
|
|
3
16
|
'set',
|
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteDef, StateDef } from './nodes.js';
|
|
1
|
+
import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteDef, StateDef, TransitionConstraintDef } from './nodes.js';
|
|
2
2
|
import type { NodeId } from './ids.js';
|
|
3
3
|
import type { UINode, UINodeKind } from './ui.js';
|
|
4
|
-
export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'route';
|
|
4
|
+
export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route';
|
|
5
5
|
export type NodeKind = SemanticNodeKind | UINodeKind;
|
|
6
|
-
export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | RouteDef | UINode;
|
|
6
|
+
export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | UINode;
|
|
7
7
|
export type NodeOfKind<K extends NodeKind> = Extract<AnyNode, {
|
|
8
8
|
kind: K;
|
|
9
9
|
}>;
|
package/dist/validate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AGGREGATE_FUNCTIONS, BUILTIN_FUNCTIONS } from './expressions.js';
|
|
2
2
|
import { VALIDATION_CODES } from './diagnostics.js';
|
|
3
|
-
import { EDGE_KINDS, isMutationOperation } from './nodes.js';
|
|
3
|
+
import { EDGE_KINDS, actionGuards, isMutationOperation } from './nodes.js';
|
|
4
|
+
import { entityType } from './type-ref.js';
|
|
4
5
|
import { isUINode, uiChildIds } from './ui.js';
|
|
5
6
|
import { semanticContextFromGraph } from './context.js';
|
|
6
7
|
import { inferExpressionType, inferLocationType, isNonNumericCollection, isObviouslyIncompatible, itemTypeOf, } from './infer.js';
|
|
@@ -94,6 +95,9 @@ function validateNode(node, context) {
|
|
|
94
95
|
case 'constraint':
|
|
95
96
|
validateConstraint(node, context);
|
|
96
97
|
return;
|
|
98
|
+
case 'transition-constraint':
|
|
99
|
+
validateTransitionConstraint(node, context);
|
|
100
|
+
return;
|
|
97
101
|
case 'route':
|
|
98
102
|
validateRoute(node, context);
|
|
99
103
|
return;
|
|
@@ -219,8 +223,15 @@ function validateAction(action, context) {
|
|
|
219
223
|
local.types.set(parameter.id, parameter.valueType);
|
|
220
224
|
}
|
|
221
225
|
}
|
|
222
|
-
|
|
223
|
-
|
|
226
|
+
if (action.guards?.length && action.preconditions?.length) {
|
|
227
|
+
context.errors.push({
|
|
228
|
+
code: VALIDATION_CODES.unsupportedOperation,
|
|
229
|
+
message: `Action ${action.id} declares both guards and preconditions; use guards alone`,
|
|
230
|
+
nodeId: action.id,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
for (const guard of actionGuards(action)) {
|
|
234
|
+
validateExpression(guard.condition, action.id, context, local);
|
|
224
235
|
}
|
|
225
236
|
for (const postcondition of action.postconditions ?? []) {
|
|
226
237
|
validateExpression(postcondition, action.id, context, local);
|
|
@@ -234,7 +245,7 @@ function validateOperation(operation, action, context, local) {
|
|
|
234
245
|
case 'for-each': {
|
|
235
246
|
validateExpression(operation.collection, action.id, context, local);
|
|
236
247
|
requireCollection(operation.collection, action.id, context, local, 'for-each');
|
|
237
|
-
const scoped = iterationScope(local, operation.scopeId, operation.collection, context);
|
|
248
|
+
const scoped = iterationScope(local, operation.scopeId, operation.collection, context, action.id);
|
|
238
249
|
if ((operation.operations ?? []).length === 0) {
|
|
239
250
|
context.warnings.push({
|
|
240
251
|
code: VALIDATION_CODES.unsupportedOperation,
|
|
@@ -381,6 +392,32 @@ function validateConstraint(constraint, context) {
|
|
|
381
392
|
}
|
|
382
393
|
validateExpression(constraint.expression, constraint.id, context, local);
|
|
383
394
|
}
|
|
395
|
+
function validateTransitionConstraint(constraint, context) {
|
|
396
|
+
requireKind(constraint.entityId, 'entity', constraint.id, context, VALIDATION_CODES.danglingNodeRef);
|
|
397
|
+
const entity = context.nodes.get(constraint.entityId);
|
|
398
|
+
// Without an identity field there is no way to say which previous instance a proposed
|
|
399
|
+
// instance corresponds to, so the rule could not be evaluated at all.
|
|
400
|
+
if (entity?.kind === 'entity' && !entity.identityFieldId) {
|
|
401
|
+
context.errors.push({
|
|
402
|
+
code: VALIDATION_CODES.unsupportedConstraintScope,
|
|
403
|
+
message: `Transition constraint ${constraint.id} governs ${entity.id}, which has no identity field to match instances by`,
|
|
404
|
+
nodeId: constraint.id,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
if (constraint.previousScopeId === constraint.proposedScopeId) {
|
|
408
|
+
context.errors.push({
|
|
409
|
+
code: VALIDATION_CODES.unsupportedConstraintScope,
|
|
410
|
+
message: `Transition constraint ${constraint.id} uses one scope for both the previous and the proposed instance`,
|
|
411
|
+
nodeId: constraint.id,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const scope = emptyScope(new Set([constraint.previousScopeId, constraint.proposedScopeId]));
|
|
415
|
+
if (entity?.kind === 'entity') {
|
|
416
|
+
scope.types.set(constraint.previousScopeId, entityType(entity.id));
|
|
417
|
+
scope.types.set(constraint.proposedScopeId, entityType(entity.id));
|
|
418
|
+
}
|
|
419
|
+
validateExpression(constraint.expression, constraint.id, context, scope);
|
|
420
|
+
}
|
|
384
421
|
function validateRoute(route, context) {
|
|
385
422
|
const view = context.nodes.get(route.viewId);
|
|
386
423
|
if (!view || view.kind !== 'view') {
|
|
@@ -607,8 +644,27 @@ function validateTypeRef(type, ownerId, context, field, inCollection = false) {
|
|
|
607
644
|
function emptyScope(ids = new Set()) {
|
|
608
645
|
return { ids, types: new Map() };
|
|
609
646
|
}
|
|
610
|
-
/**
|
|
611
|
-
|
|
647
|
+
/**
|
|
648
|
+
* Extends a scope with the member type of the collection an iteration walks.
|
|
649
|
+
*
|
|
650
|
+
* An iteration binder is not a graph node, and reusing an id that is already bound — or
|
|
651
|
+
* one that names a node — makes a reference ambiguous to read and to analyze.
|
|
652
|
+
*/
|
|
653
|
+
function iterationScope(scope, scopeId, source, context, ownerId) {
|
|
654
|
+
if (scope.ids.has(scopeId)) {
|
|
655
|
+
context.errors.push({
|
|
656
|
+
code: VALIDATION_CODES.scopeShadowing,
|
|
657
|
+
message: `Scope ${scopeId} in ${ownerId ?? 'an expression'} is already bound by an enclosing iteration`,
|
|
658
|
+
...(ownerId ? { nodeId: ownerId } : {}),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
if (context.nodes.has(scopeId)) {
|
|
662
|
+
context.errors.push({
|
|
663
|
+
code: VALIDATION_CODES.scopeCollidesWithNode,
|
|
664
|
+
message: `Scope ${scopeId} in ${ownerId ?? 'an expression'} has the same id as a graph node`,
|
|
665
|
+
...(ownerId ? { nodeId: ownerId } : {}),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
612
668
|
const item = itemTypeOf(inferExpressionType(source, context.semantics, scope.types));
|
|
613
669
|
const types = new Map(scope.types);
|
|
614
670
|
if (item) {
|
|
@@ -691,19 +747,46 @@ function validateExpression(expression, ownerId, context, local) {
|
|
|
691
747
|
case 'find': {
|
|
692
748
|
validateExpression(expression.source, ownerId, context, scope);
|
|
693
749
|
requireCollection(expression.source, ownerId, context, scope, expression.kind);
|
|
694
|
-
validateExpression(expression.predicate, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context));
|
|
750
|
+
validateExpression(expression.predicate, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
|
|
695
751
|
return;
|
|
696
752
|
}
|
|
697
753
|
case 'map': {
|
|
698
754
|
validateExpression(expression.source, ownerId, context, scope);
|
|
699
755
|
requireCollection(expression.source, ownerId, context, scope, 'map');
|
|
700
|
-
validateExpression(expression.projection, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context));
|
|
756
|
+
validateExpression(expression.projection, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
case 'every':
|
|
760
|
+
case 'some': {
|
|
761
|
+
validateExpression(expression.source, ownerId, context, scope);
|
|
762
|
+
requireCollection(expression.source, ownerId, context, scope, expression.kind);
|
|
763
|
+
validateExpression(expression.predicate, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
case 'flatten': {
|
|
767
|
+
validateExpression(expression.source, ownerId, context, scope);
|
|
768
|
+
requireCollection(expression.source, ownerId, context, scope, 'flatten');
|
|
769
|
+
const inner = itemTypeOf(inferExpressionType(expression.source, context.semantics, scope.types));
|
|
770
|
+
if (inner && resolveKnownType(inner)?.kind !== 'collection') {
|
|
771
|
+
context.errors.push({
|
|
772
|
+
code: VALIDATION_CODES.notACollection,
|
|
773
|
+
message: `flatten in ${ownerId} expects a collection of collections but its members are ${describeType(inner)}`,
|
|
774
|
+
nodeId: ownerId,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
case 'conditional': {
|
|
780
|
+
validateExpression(expression.condition, ownerId, context, scope);
|
|
781
|
+
validateExpression(expression.whenTrue, ownerId, context, scope);
|
|
782
|
+
validateExpression(expression.whenFalse, ownerId, context, scope);
|
|
783
|
+
reportIncompatible(inferExpressionType(expression.whenTrue, context.semantics, scope.types), inferExpressionType(expression.whenFalse, context.semantics, scope.types), ownerId, context);
|
|
701
784
|
return;
|
|
702
785
|
}
|
|
703
786
|
case 'sort': {
|
|
704
787
|
validateExpression(expression.source, ownerId, context, scope);
|
|
705
788
|
requireCollection(expression.source, ownerId, context, scope, 'sort');
|
|
706
|
-
validateExpression(expression.by, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context));
|
|
789
|
+
validateExpression(expression.by, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
|
|
707
790
|
return;
|
|
708
791
|
}
|
|
709
792
|
default:
|