@cynodia/axiom-runtime 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/mutation/transaction.d.ts +6 -0
- package/dist/mutation/transaction.js +1 -0
- package/dist/mutation/values.d.ts +16 -1
- package/dist/mutation/values.js +40 -6
- package/dist/runtime.d.ts +9 -1
- package/dist/runtime.js +198 -90
- package/package.json +2 -2
|
@@ -16,6 +16,12 @@ export interface RuntimeTransaction {
|
|
|
16
16
|
export interface TransactionManager {
|
|
17
17
|
begin(): RuntimeTransaction;
|
|
18
18
|
currentId(): string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Committed state as it was immediately before the outermost open transaction began —
|
|
21
|
+
* what a transition rule means by "previous". Not the previous operation, not the
|
|
22
|
+
* previous iteration.
|
|
23
|
+
*/
|
|
24
|
+
entrySnapshot(): unknown;
|
|
19
25
|
}
|
|
20
26
|
export declare function createTransactionManager(store: StoreSnapshot, nextId: () => string): TransactionManager;
|
|
21
27
|
//# sourceMappingURL=transaction.d.ts.map
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised when an expression cannot be evaluated — a collection operator applied to
|
|
3
|
+
* something that is not a collection, an aggregation over non-numeric data, a reference
|
|
4
|
+
* that does not resolve. Failing loudly is deliberate: an expression must never return a
|
|
5
|
+
* plausible-looking value and report a failure at the same time.
|
|
6
|
+
*/
|
|
7
|
+
export declare class ExpressionEvaluationError extends Error {
|
|
8
|
+
readonly details: Record<string, unknown>;
|
|
9
|
+
constructor(message: string, details?: Record<string, unknown>);
|
|
10
|
+
}
|
|
1
11
|
/**
|
|
2
12
|
* Value helpers shared by the runtime and the mutation subsystem.
|
|
3
13
|
*
|
|
@@ -12,8 +22,13 @@
|
|
|
12
22
|
export declare function cloneValue<T>(value: T): T;
|
|
13
23
|
export declare function deepFreeze<T>(value: T): T;
|
|
14
24
|
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
15
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Presence answers one question: does a value exist? It says nothing about whether the
|
|
27
|
+
* value is empty. An empty collection, an empty string, zero and false are all present.
|
|
28
|
+
*/
|
|
16
29
|
export declare function isPresent(value: unknown): boolean;
|
|
30
|
+
/** Emptiness of a collection or a string. Anything else is never empty. */
|
|
31
|
+
export declare function isEmptyValue(value: unknown): boolean;
|
|
17
32
|
export declare function toBoolean(value: unknown): boolean;
|
|
18
33
|
export declare function toText(value: unknown): string;
|
|
19
34
|
export declare function compareValues(left: unknown, right: unknown): number;
|
package/dist/mutation/values.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised when an expression cannot be evaluated — a collection operator applied to
|
|
3
|
+
* something that is not a collection, an aggregation over non-numeric data, a reference
|
|
4
|
+
* that does not resolve. Failing loudly is deliberate: an expression must never return a
|
|
5
|
+
* plausible-looking value and report a failure at the same time.
|
|
6
|
+
*/
|
|
7
|
+
export class ExpressionEvaluationError extends Error {
|
|
8
|
+
details;
|
|
9
|
+
constructor(message, details = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ExpressionEvaluationError';
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* Value helpers shared by the runtime and the mutation subsystem.
|
|
3
17
|
*
|
|
@@ -24,18 +38,25 @@ export function deepFreeze(value) {
|
|
|
24
38
|
export function isRecord(value) {
|
|
25
39
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
40
|
}
|
|
27
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Presence answers one question: does a value exist? It says nothing about whether the
|
|
43
|
+
* value is empty. An empty collection, an empty string, zero and false are all present.
|
|
44
|
+
*/
|
|
28
45
|
export function isPresent(value) {
|
|
46
|
+
return value !== null && value !== undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Emptiness of a collection or a string. Anything else is never empty. */
|
|
49
|
+
export function isEmptyValue(value) {
|
|
29
50
|
if (value === null || value === undefined) {
|
|
30
|
-
return
|
|
51
|
+
return true;
|
|
31
52
|
}
|
|
32
53
|
if (typeof value === 'string') {
|
|
33
|
-
return value.trim().length
|
|
54
|
+
return value.trim().length === 0;
|
|
34
55
|
}
|
|
35
56
|
if (Array.isArray(value)) {
|
|
36
|
-
return value.length
|
|
57
|
+
return value.length === 0;
|
|
37
58
|
}
|
|
38
|
-
return
|
|
59
|
+
return false;
|
|
39
60
|
}
|
|
40
61
|
export function toBoolean(value) {
|
|
41
62
|
if (Array.isArray(value)) {
|
|
@@ -63,6 +84,19 @@ export function compareValues(left, right) {
|
|
|
63
84
|
const rightText = toText(right);
|
|
64
85
|
return leftText === rightText ? 0 : leftText < rightText ? -1 : 1;
|
|
65
86
|
}
|
|
87
|
+
/** A stable serialization, so record comparison does not depend on key order. */
|
|
88
|
+
function canonical(value) {
|
|
89
|
+
if (value === null || typeof value !== 'object') {
|
|
90
|
+
return JSON.stringify(value) ?? 'null';
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
94
|
+
}
|
|
95
|
+
const entries = Object.entries(value)
|
|
96
|
+
.filter(([, entry]) => entry !== undefined)
|
|
97
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
98
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(',')}}`;
|
|
99
|
+
}
|
|
66
100
|
export function valuesEqual(left, right) {
|
|
67
101
|
if (left === right) {
|
|
68
102
|
return true;
|
|
@@ -71,7 +105,7 @@ export function valuesEqual(left, right) {
|
|
|
71
105
|
return (left ?? null) === (right ?? null);
|
|
72
106
|
}
|
|
73
107
|
if (typeof left === 'object' || typeof right === 'object') {
|
|
74
|
-
return
|
|
108
|
+
return canonical(left) === canonical(right);
|
|
75
109
|
}
|
|
76
110
|
return false;
|
|
77
111
|
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
|
|
|
20
20
|
readonly MUTATION_FAILED: "MUTATION_FAILED";
|
|
21
21
|
readonly DERIVED_STATE_WRITE: "DERIVED_STATE_WRITE";
|
|
22
22
|
readonly UNKNOWN_STATE: "UNKNOWN_STATE";
|
|
23
|
+
readonly TRANSITION_CONSTRAINT_VIOLATION: "TRANSITION_CONSTRAINT_VIOLATION";
|
|
23
24
|
readonly UNSUPPORTED_EXPRESSION: "UNSUPPORTED_EXPRESSION";
|
|
24
25
|
readonly UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION";
|
|
25
26
|
readonly ROUTE_NOT_FOUND: "ROUTE_NOT_FOUND";
|
|
@@ -69,7 +70,14 @@ export interface AxiomRuntime {
|
|
|
69
70
|
start(): void;
|
|
70
71
|
render(): void;
|
|
71
72
|
getState(id: NodeId): unknown;
|
|
72
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Replaces a state value outright, for hosts, tests and seeding.
|
|
75
|
+
*
|
|
76
|
+
* This is an administrative facility, not a semantic write: it does not evaluate
|
|
77
|
+
* preconditions, entity constraints or transition constraints. Application behaviour
|
|
78
|
+
* belongs in actions and input bindings, which are governed.
|
|
79
|
+
*/
|
|
80
|
+
hydrateState(id: NodeId, value: unknown): void;
|
|
73
81
|
invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
|
|
74
82
|
navigate(path: string): void;
|
|
75
83
|
currentRoute(): RouteMatch | null;
|
package/dist/runtime.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createMutationEngine } from './mutation/mutation-engine.js';
|
|
|
2
2
|
import { LocationResolutionError, resolveLocation } from './mutation/resolve-location.js';
|
|
3
3
|
import { createStateStore } from './mutation/store.js';
|
|
4
4
|
import { createTransactionManager } from './mutation/transaction.js';
|
|
5
|
-
import { cloneValue, compareValues, deepFreeze, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
|
|
5
|
+
import { ExpressionEvaluationError, cloneValue, compareValues, deepFreeze, isEmptyValue, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
|
|
6
6
|
/**
|
|
7
7
|
* The diagnostics a runtime can report. Agents match on the code rather than parsing the
|
|
8
8
|
* message, so this vocabulary is part of the public contract.
|
|
@@ -22,6 +22,7 @@ export const RUNTIME_DIAGNOSTIC_CODES = {
|
|
|
22
22
|
MUTATION_FAILED: 'MUTATION_FAILED',
|
|
23
23
|
DERIVED_STATE_WRITE: 'DERIVED_STATE_WRITE',
|
|
24
24
|
UNKNOWN_STATE: 'UNKNOWN_STATE',
|
|
25
|
+
TRANSITION_CONSTRAINT_VIOLATION: 'TRANSITION_CONSTRAINT_VIOLATION',
|
|
25
26
|
UNSUPPORTED_EXPRESSION: 'UNSUPPORTED_EXPRESSION',
|
|
26
27
|
UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
|
|
27
28
|
ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
|
|
@@ -58,6 +59,17 @@ function defaultForType(type) {
|
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Collection operators are strict about their source: `null` means a missing or invalid
|
|
64
|
+
* collection, and an empty collection means an empty collection. Conflating the two is
|
|
65
|
+
* what made 0.4 hard to reason about.
|
|
66
|
+
*/
|
|
67
|
+
function requireCollection(value, operator) {
|
|
68
|
+
if (!Array.isArray(value)) {
|
|
69
|
+
throw new ExpressionEvaluationError(`${operator} expects a collection but received ${describeValue(value)}`, { operator, received: value });
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
61
73
|
/** A short, structured description of a runtime value, for diagnostics. */
|
|
62
74
|
function describeValue(value) {
|
|
63
75
|
if (value === null || value === undefined) {
|
|
@@ -172,9 +184,16 @@ export function createAxiomRuntime(options) {
|
|
|
172
184
|
return derivedCache.get(stateId);
|
|
173
185
|
}
|
|
174
186
|
derivedCache.set(stateId, null);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
187
|
+
try {
|
|
188
|
+
const value = deepFreeze(cloneValue(evaluate(state.derivation, rootScope())));
|
|
189
|
+
derivedCache.set(stateId, value);
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
report(evaluationFailure(error, { nodeId: state.id, stateId: state.id }));
|
|
194
|
+
derivedCache.set(stateId, null);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
178
197
|
}
|
|
179
198
|
return store.read(stateId);
|
|
180
199
|
}
|
|
@@ -245,6 +264,28 @@ export function createAxiomRuntime(options) {
|
|
|
245
264
|
}
|
|
246
265
|
}
|
|
247
266
|
}
|
|
267
|
+
/** Turns an evaluation failure into a diagnostic instead of letting it escape. */
|
|
268
|
+
function evaluationFailure(error, context = {}) {
|
|
269
|
+
if (error instanceof ExpressionEvaluationError) {
|
|
270
|
+
return {
|
|
271
|
+
code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
|
|
272
|
+
message: error.message,
|
|
273
|
+
severity: 'error',
|
|
274
|
+
...context,
|
|
275
|
+
details: { ...error.details, ...(context.details ?? {}) },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
/** Evaluates an expression, reporting rather than throwing if it cannot be evaluated. */
|
|
281
|
+
function tryEvaluate(expression, scope, context = {}) {
|
|
282
|
+
try {
|
|
283
|
+
return { ok: true, value: evaluate(expression, scope) };
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
return { ok: false, diagnostic: evaluationFailure(error, context) };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
248
289
|
/** Applies a mutation inside the current transaction and reports resolution failures. */
|
|
249
290
|
function mutate(apply, context, failures) {
|
|
250
291
|
try {
|
|
@@ -298,13 +339,7 @@ export function createAxiomRuntime(options) {
|
|
|
298
339
|
if (statesById.has(expression.targetId)) {
|
|
299
340
|
return readState(expression.targetId);
|
|
300
341
|
}
|
|
301
|
-
|
|
302
|
-
code: RUNTIME_DIAGNOSTIC_CODES.UNRESOLVED_REFERENCE,
|
|
303
|
-
message: `Reference ${expression.targetId} could not be resolved`,
|
|
304
|
-
severity: 'error',
|
|
305
|
-
nodeId: expression.targetId,
|
|
306
|
-
});
|
|
307
|
-
return null;
|
|
342
|
+
throw new ExpressionEvaluationError(`Reference ${expression.targetId} could not be resolved`, { targetId: expression.targetId });
|
|
308
343
|
}
|
|
309
344
|
case 'field': {
|
|
310
345
|
const source = evaluate(expression.source, scope);
|
|
@@ -330,53 +365,42 @@ export function createAxiomRuntime(options) {
|
|
|
330
365
|
case 'call':
|
|
331
366
|
return evaluateCall(expression.function, expression.arguments, scope);
|
|
332
367
|
case 'filter': {
|
|
333
|
-
const source = evaluate(expression.source, scope);
|
|
334
|
-
if (!Array.isArray(source)) {
|
|
335
|
-
return [];
|
|
336
|
-
}
|
|
368
|
+
const source = requireCollection(evaluate(expression.source, scope), 'filter');
|
|
337
369
|
return source.filter((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
338
370
|
}
|
|
339
371
|
case 'map': {
|
|
340
|
-
const source = evaluate(expression.source, scope);
|
|
341
|
-
if (!Array.isArray(source)) {
|
|
342
|
-
report({
|
|
343
|
-
code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
|
|
344
|
-
message: `map expects a collection but received ${describeValue(source)}`,
|
|
345
|
-
severity: 'error',
|
|
346
|
-
});
|
|
347
|
-
return [];
|
|
348
|
-
}
|
|
372
|
+
const source = requireCollection(evaluate(expression.source, scope), 'map');
|
|
349
373
|
return source.map((item) => evaluate(expression.projection, childScope(scope, expression.scopeId, item)));
|
|
350
374
|
}
|
|
351
375
|
case 'sort': {
|
|
352
|
-
const source = evaluate(expression.source, scope);
|
|
353
|
-
if (!Array.isArray(source)) {
|
|
354
|
-
report({
|
|
355
|
-
code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
|
|
356
|
-
message: `sort expects a collection but received ${describeValue(source)}`,
|
|
357
|
-
severity: 'error',
|
|
358
|
-
});
|
|
359
|
-
return [];
|
|
360
|
-
}
|
|
376
|
+
const source = requireCollection(evaluate(expression.source, scope), 'sort');
|
|
361
377
|
const direction = expression.direction === 'desc' ? -1 : 1;
|
|
362
378
|
const key = (item) => evaluate(expression.by, childScope(scope, expression.scopeId, item));
|
|
363
379
|
return [...source].sort((left, right) => direction * compareValues(key(left), key(right)));
|
|
364
380
|
}
|
|
381
|
+
case 'every': {
|
|
382
|
+
const source = requireCollection(evaluate(expression.source, scope), 'every');
|
|
383
|
+
return source.every((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
384
|
+
}
|
|
385
|
+
case 'some': {
|
|
386
|
+
const source = requireCollection(evaluate(expression.source, scope), 'some');
|
|
387
|
+
return source.some((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
388
|
+
}
|
|
389
|
+
case 'flatten': {
|
|
390
|
+
const source = requireCollection(evaluate(expression.source, scope), 'flatten');
|
|
391
|
+
return source.flatMap((member) => requireCollection(member, 'flatten'));
|
|
392
|
+
}
|
|
393
|
+
case 'conditional':
|
|
394
|
+
return toBoolean(evaluate(expression.condition, scope))
|
|
395
|
+
? evaluate(expression.whenTrue, scope)
|
|
396
|
+
: evaluate(expression.whenFalse, scope);
|
|
365
397
|
case 'find': {
|
|
366
|
-
const source = evaluate(expression.source, scope);
|
|
367
|
-
if (!Array.isArray(source)) {
|
|
368
|
-
return null;
|
|
369
|
-
}
|
|
398
|
+
const source = requireCollection(evaluate(expression.source, scope), 'find');
|
|
370
399
|
const found = source.find((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
371
400
|
return found === undefined ? null : found;
|
|
372
401
|
}
|
|
373
402
|
default:
|
|
374
|
-
|
|
375
|
-
code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION,
|
|
376
|
-
message: `Unknown expression kind "${expression.kind}"`,
|
|
377
|
-
severity: 'error',
|
|
378
|
-
});
|
|
379
|
-
return null;
|
|
403
|
+
throw new ExpressionEvaluationError(`Unknown expression kind "${expression.kind}"`, { kind: expression.kind });
|
|
380
404
|
}
|
|
381
405
|
}
|
|
382
406
|
function evaluateBinary(operator, leftExpression, rightExpression, scope) {
|
|
@@ -412,44 +436,31 @@ export function createAxiomRuntime(options) {
|
|
|
412
436
|
return divisor === 0 ? null : Number(left ?? 0) / divisor;
|
|
413
437
|
}
|
|
414
438
|
default:
|
|
415
|
-
|
|
416
|
-
return null;
|
|
439
|
+
throw new ExpressionEvaluationError(`Unknown operator "${operator}"`, { operator });
|
|
417
440
|
}
|
|
418
441
|
}
|
|
419
442
|
function evaluateCall(fn, args, scope) {
|
|
420
443
|
const values = args.map((argument) => evaluate(argument, scope));
|
|
421
444
|
switch (fn) {
|
|
422
445
|
case 'required':
|
|
446
|
+
// Presence only: an empty collection or string exists, and so does 0 and false.
|
|
423
447
|
return isPresent(values[0]);
|
|
424
448
|
case 'is-empty':
|
|
425
|
-
return
|
|
449
|
+
return isEmptyValue(values[0]);
|
|
450
|
+
case 'non-empty':
|
|
451
|
+
return !isEmptyValue(values[0]);
|
|
426
452
|
case 'length':
|
|
427
453
|
return Array.isArray(values[0]) ? values[0].length : toText(values[0]).length;
|
|
428
454
|
case 'count':
|
|
429
|
-
return
|
|
455
|
+
return requireCollection(values[0], 'count').length;
|
|
430
456
|
case 'sum': {
|
|
431
|
-
// An aggregation that cannot be computed
|
|
432
|
-
//
|
|
433
|
-
const source = values[0];
|
|
434
|
-
if (!Array.isArray(source)) {
|
|
435
|
-
report({
|
|
436
|
-
code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
|
|
437
|
-
message: `sum expects a collection of numbers but received ${describeValue(source)}`,
|
|
438
|
-
severity: 'error',
|
|
439
|
-
details: { received: source },
|
|
440
|
-
});
|
|
441
|
-
return Number.NaN;
|
|
442
|
-
}
|
|
457
|
+
// An aggregation that cannot be computed fails; it never returns a number that
|
|
458
|
+
// would quietly satisfy a guard.
|
|
459
|
+
const source = requireCollection(values[0], 'sum');
|
|
443
460
|
let total = 0;
|
|
444
461
|
for (const member of source) {
|
|
445
462
|
if (typeof member !== 'number' || !Number.isFinite(member)) {
|
|
446
|
-
|
|
447
|
-
code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
|
|
448
|
-
message: `sum encountered ${describeValue(member)} where a number was required`,
|
|
449
|
-
severity: 'error',
|
|
450
|
-
details: { member },
|
|
451
|
-
});
|
|
452
|
-
return Number.NaN;
|
|
463
|
+
throw new ExpressionEvaluationError(`sum encountered ${describeValue(member)} where a number was required`, { member });
|
|
453
464
|
}
|
|
454
465
|
total += member;
|
|
455
466
|
}
|
|
@@ -465,6 +476,7 @@ export function createAxiomRuntime(options) {
|
|
|
465
476
|
case 'concat':
|
|
466
477
|
return values.map(toText).join('');
|
|
467
478
|
case 'coalesce':
|
|
479
|
+
// Nullish, not "non-empty": falling back to an empty collection has to be possible.
|
|
468
480
|
return values.find((value) => isPresent(value)) ?? null;
|
|
469
481
|
case 'one-of':
|
|
470
482
|
return values.slice(1).some((option) => valuesEqual(option, values[0]));
|
|
@@ -477,8 +489,7 @@ export function createAxiomRuntime(options) {
|
|
|
477
489
|
case 'uuid':
|
|
478
490
|
return host.uuid();
|
|
479
491
|
default:
|
|
480
|
-
|
|
481
|
-
return null;
|
|
492
|
+
throw new ExpressionEvaluationError(`Unknown function "${fn}"`, { function: fn });
|
|
482
493
|
}
|
|
483
494
|
}
|
|
484
495
|
// -------------------------------------------------------------- validation
|
|
@@ -495,7 +506,7 @@ export function createAxiomRuntime(options) {
|
|
|
495
506
|
* their declared types. Entities nested inside collections and inside other entities
|
|
496
507
|
* are reached recursively, so their constraints apply wherever they actually live.
|
|
497
508
|
*/
|
|
498
|
-
function collectInstances() {
|
|
509
|
+
function collectInstances(read = readState) {
|
|
499
510
|
const found = new Map();
|
|
500
511
|
const visit = (value, type) => {
|
|
501
512
|
const resolved = unwrapType(type);
|
|
@@ -523,10 +534,69 @@ export function createAxiomRuntime(options) {
|
|
|
523
534
|
if (state.draft || state.derivation) {
|
|
524
535
|
continue;
|
|
525
536
|
}
|
|
526
|
-
visit(
|
|
537
|
+
visit(read(state.id), state.valueType);
|
|
527
538
|
}
|
|
528
539
|
return found;
|
|
529
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Transition rules, evaluated against the state the transaction started from and the
|
|
543
|
+
* state it now proposes. Every governed mutation path runs this before committing, so a
|
|
544
|
+
* rule holds regardless of which path attempted the write.
|
|
545
|
+
*/
|
|
546
|
+
function evaluateTransitions() {
|
|
547
|
+
const snapshot = transactions.entrySnapshot();
|
|
548
|
+
if (!snapshot || ir.transitionConstraints.length === 0) {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
const previousInstances = collectInstances((stateId) => snapshot.get(stateId));
|
|
552
|
+
const proposedInstances = collectInstances();
|
|
553
|
+
const failures = [];
|
|
554
|
+
for (const rule of ir.transitionConstraints) {
|
|
555
|
+
const entity = entitiesById.get(rule.entityId);
|
|
556
|
+
const identity = entity?.identityFieldId;
|
|
557
|
+
if (!identity) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const identify = (instance) => (isRecord(instance) ? instance[identity] : undefined);
|
|
561
|
+
const proposedByIdentity = new Map();
|
|
562
|
+
for (const instance of proposedInstances.get(rule.entityId) ?? []) {
|
|
563
|
+
proposedByIdentity.set(toText(identify(instance)), instance);
|
|
564
|
+
}
|
|
565
|
+
for (const previous of previousInstances.get(rule.entityId) ?? []) {
|
|
566
|
+
const key = toText(identify(previous));
|
|
567
|
+
// A removed instance is a transition too: its proposed form is nothing.
|
|
568
|
+
const proposed = proposedByIdentity.get(key) ?? null;
|
|
569
|
+
if (proposed !== null && valuesEqual(previous, proposed)) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const scope = childScope(childScope(rootScope(), rule.previousScopeId, previous), rule.proposedScopeId, proposed);
|
|
573
|
+
const outcome = tryEvaluate(rule.expression, scope, {
|
|
574
|
+
nodeId: rule.id,
|
|
575
|
+
constraintId: rule.id,
|
|
576
|
+
});
|
|
577
|
+
if (outcome.ok && toBoolean(outcome.value)) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
failures.push(outcome.ok
|
|
581
|
+
? {
|
|
582
|
+
code: RUNTIME_DIAGNOSTIC_CODES.TRANSITION_CONSTRAINT_VIOLATION,
|
|
583
|
+
message: rule.message ?? `Transition ${rule.name ?? rule.id} is not allowed`,
|
|
584
|
+
severity: rule.severity ?? 'error',
|
|
585
|
+
nodeId: rule.id,
|
|
586
|
+
constraintId: rule.id,
|
|
587
|
+
details: {
|
|
588
|
+
transitionConstraintId: rule.id,
|
|
589
|
+
entityId: rule.entityId,
|
|
590
|
+
identity: identify(previous),
|
|
591
|
+
previousValue: previous,
|
|
592
|
+
proposedValue: proposed,
|
|
593
|
+
},
|
|
594
|
+
}
|
|
595
|
+
: outcome.diagnostic);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return failures;
|
|
599
|
+
}
|
|
530
600
|
function instancesOf(entityId) {
|
|
531
601
|
return collectInstances().get(entityId) ?? [];
|
|
532
602
|
}
|
|
@@ -653,15 +723,26 @@ export function createAxiomRuntime(options) {
|
|
|
653
723
|
...(constraint.entityId ? { details: { entityId: constraint.entityId, instance } } : {}),
|
|
654
724
|
});
|
|
655
725
|
};
|
|
726
|
+
/** A constraint that cannot be evaluated counts as violated, never as satisfied. */
|
|
727
|
+
const holds = (scope) => {
|
|
728
|
+
const outcome = tryEvaluate(constraint.expression, scope, {
|
|
729
|
+
nodeId: constraint.id,
|
|
730
|
+
constraintId: constraint.id,
|
|
731
|
+
});
|
|
732
|
+
if (!outcome.ok) {
|
|
733
|
+
failures.push(outcome.diagnostic);
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
return toBoolean(outcome.value);
|
|
737
|
+
};
|
|
656
738
|
if (!constraint.entityId) {
|
|
657
|
-
if (!
|
|
739
|
+
if (!holds(rootScope())) {
|
|
658
740
|
record();
|
|
659
741
|
}
|
|
660
742
|
return failures;
|
|
661
743
|
}
|
|
662
744
|
for (const instance of instances.get(constraint.entityId) ?? []) {
|
|
663
|
-
|
|
664
|
-
if (!toBoolean(evaluate(constraint.expression, scope))) {
|
|
745
|
+
if (!holds(childScope(rootScope(), constraint.entityId, instance))) {
|
|
665
746
|
record(instance);
|
|
666
747
|
}
|
|
667
748
|
}
|
|
@@ -669,6 +750,17 @@ export function createAxiomRuntime(options) {
|
|
|
669
750
|
}
|
|
670
751
|
// --------------------------------------------------------------- behaviour
|
|
671
752
|
function executeOperation(operation, scope, context, result) {
|
|
753
|
+
try {
|
|
754
|
+
executeOperationUnguarded(operation, scope, context, result);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
result.push(evaluationFailure(error, {
|
|
758
|
+
...(context.sourceNodeId ? { nodeId: context.sourceNodeId, actionId: context.sourceNodeId } : {}),
|
|
759
|
+
...(context.transactionId ? { transactionId: context.transactionId } : {}),
|
|
760
|
+
}));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function executeOperationUnguarded(operation, scope, context, result) {
|
|
672
764
|
switch (operation.kind) {
|
|
673
765
|
case 'set':
|
|
674
766
|
case 'insert':
|
|
@@ -680,16 +772,7 @@ export function createAxiomRuntime(options) {
|
|
|
680
772
|
// walks the collection as it stood when the operation began. Nothing here opens
|
|
681
773
|
// a transaction: these mutations belong to the action's own transaction, and a
|
|
682
774
|
// failure in any iteration rolls back every iteration with it.
|
|
683
|
-
const members = evaluate(operation.collection, scope);
|
|
684
|
-
if (!Array.isArray(members)) {
|
|
685
|
-
result.push({
|
|
686
|
-
code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
|
|
687
|
-
message: `for-each expects a collection but received ${describeValue(members)}`,
|
|
688
|
-
severity: 'error',
|
|
689
|
-
...(context.sourceNodeId ? { actionId: context.sourceNodeId } : {}),
|
|
690
|
-
});
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
775
|
+
const members = requireCollection(evaluate(operation.collection, scope), 'for-each');
|
|
693
776
|
for (const member of members) {
|
|
694
777
|
const iteration = childScope(scope, operation.scopeId, member);
|
|
695
778
|
for (const nested of operation.operations ?? []) {
|
|
@@ -794,7 +877,12 @@ export function createAxiomRuntime(options) {
|
|
|
794
877
|
// Failure modes line up with preconditions by position, so a refusal says which
|
|
795
878
|
// condition was not met rather than always naming the first one.
|
|
796
879
|
for (const [index, precondition] of (action.preconditions ?? []).entries()) {
|
|
797
|
-
|
|
880
|
+
const outcome = tryEvaluate(precondition, scope, { nodeId: action.id, actionId: action.id });
|
|
881
|
+
if (!outcome.ok) {
|
|
882
|
+
report(outcome.diagnostic);
|
|
883
|
+
return { ok: false, diagnostics: [...collected] };
|
|
884
|
+
}
|
|
885
|
+
if (toBoolean(outcome.value)) {
|
|
798
886
|
continue;
|
|
799
887
|
}
|
|
800
888
|
const mode = action.failureModes?.[index];
|
|
@@ -831,9 +919,14 @@ export function createAxiomRuntime(options) {
|
|
|
831
919
|
...operationDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'),
|
|
832
920
|
...collected.slice(reportedBefore).filter((diagnostic) => diagnostic.severity === 'error'),
|
|
833
921
|
...evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error'),
|
|
922
|
+
...evaluateTransitions().filter((diagnostic) => diagnostic.severity === 'error'),
|
|
834
923
|
];
|
|
835
924
|
for (const postcondition of action.postconditions ?? []) {
|
|
836
|
-
|
|
925
|
+
const outcome = tryEvaluate(postcondition, scope, { nodeId: action.id, actionId: action.id });
|
|
926
|
+
if (!outcome.ok || !toBoolean(outcome.value)) {
|
|
927
|
+
if (!outcome.ok) {
|
|
928
|
+
violations.push(outcome.diagnostic);
|
|
929
|
+
}
|
|
837
930
|
violations.push({
|
|
838
931
|
code: RUNTIME_DIAGNOSTIC_CODES.POSTCONDITION_FAILED,
|
|
839
932
|
message: `A postcondition of ${action.name ?? action.id} was not satisfied`,
|
|
@@ -1016,6 +1109,15 @@ export function createAxiomRuntime(options) {
|
|
|
1016
1109
|
return raw;
|
|
1017
1110
|
}
|
|
1018
1111
|
function renderNode(id, scope) {
|
|
1112
|
+
try {
|
|
1113
|
+
return renderNodeUnguarded(id, scope);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
report(evaluationFailure(error, { nodeId: id }));
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
function renderNodeUnguarded(id, scope) {
|
|
1019
1121
|
const node = ir.uiNodes[id];
|
|
1020
1122
|
if (!node) {
|
|
1021
1123
|
report({
|
|
@@ -1161,15 +1263,21 @@ export function createAxiomRuntime(options) {
|
|
|
1161
1263
|
failures.forEach(report);
|
|
1162
1264
|
}
|
|
1163
1265
|
else {
|
|
1164
|
-
|
|
1165
|
-
|
|
1266
|
+
// A transition rule always applies: it compares against the state this
|
|
1267
|
+
// mutation started from, so it can never be a pre-existing violation.
|
|
1268
|
+
const rejected = [
|
|
1269
|
+
...(before ? violationsIntroducedSince(before) : []),
|
|
1270
|
+
...evaluateTransitions().filter((diagnostic) => diagnostic.severity === 'error'),
|
|
1271
|
+
];
|
|
1272
|
+
if (rejected.length > 0) {
|
|
1166
1273
|
settle(transaction, 'rolled-back');
|
|
1167
|
-
|
|
1274
|
+
rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
|
|
1168
1275
|
report({
|
|
1169
1276
|
code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
|
|
1170
|
-
message: `${node.label ?? node.id} kept its previous value: ${
|
|
1277
|
+
message: `${node.label ?? node.id} kept its previous value: ${rejected[0].message}`,
|
|
1171
1278
|
severity: 'warning',
|
|
1172
1279
|
nodeId: node.id,
|
|
1280
|
+
details: { source: 'input' },
|
|
1173
1281
|
});
|
|
1174
1282
|
}
|
|
1175
1283
|
else {
|
|
@@ -1271,7 +1379,7 @@ export function createAxiomRuntime(options) {
|
|
|
1271
1379
|
getState(id) {
|
|
1272
1380
|
return cloneValue(readState(id));
|
|
1273
1381
|
},
|
|
1274
|
-
|
|
1382
|
+
hydrateState(id, value) {
|
|
1275
1383
|
const transaction = transactions.begin();
|
|
1276
1384
|
const failures = [];
|
|
1277
1385
|
const context = { source: 'system', transactionId: transaction.id };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cynodia/axiom-runtime",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1-alpha.1",
|
|
4
4
|
"description": "Domain-independent runtime that executes an Axiom application graph.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AskTech AS",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@cynodia/axiom-core": "0.4.
|
|
34
|
+
"@cynodia/axiom-core": "0.4.1-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|