@cynodia/axiom-runtime 0.3.1-alpha.1 → 0.4.0-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/values.d.ts +4 -0
- package/dist/mutation/values.js +5 -1
- package/dist/runtime.d.ts +40 -2
- package/dist/runtime.js +241 -60
- package/package.json +2 -2
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* throws in strict mode, which is what keeps "no implicit object mutation" an enforced
|
|
6
6
|
* invariant rather than a convention.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
|
|
10
|
+
* null, which would silently disguise a failed computation as an absent one.
|
|
11
|
+
*/
|
|
8
12
|
export declare function cloneValue<T>(value: T): T;
|
|
9
13
|
export declare function deepFreeze<T>(value: T): T;
|
|
10
14
|
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
package/dist/mutation/values.js
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* throws in strict mode, which is what keeps "no implicit object mutation" an enforced
|
|
6
6
|
* invariant rather than a convention.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
|
|
10
|
+
* null, which would silently disguise a failed computation as an absent one.
|
|
11
|
+
*/
|
|
8
12
|
export function cloneValue(value) {
|
|
9
|
-
return value === undefined ? value :
|
|
13
|
+
return value === undefined ? value : structuredClone(value);
|
|
10
14
|
}
|
|
11
15
|
export function deepFreeze(value) {
|
|
12
16
|
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,12 +1,48 @@
|
|
|
1
|
-
import type { ApplicationIR, CompiledRoute, FieldId, NodeId } from '@cynodia/axiom-core';
|
|
1
|
+
import type { ApplicationIR, CompiledRoute, FieldId, Location, NodeId } from '@cynodia/axiom-core';
|
|
2
2
|
import type { DomElement, HostEnvironment } from './dom.js';
|
|
3
3
|
import type { MutationLogEntry } from './mutation/mutation-engine.js';
|
|
4
|
+
/**
|
|
5
|
+
* The diagnostics a runtime can report. Agents match on the code rather than parsing the
|
|
6
|
+
* message, so this vocabulary is part of the public contract.
|
|
7
|
+
*/
|
|
8
|
+
export declare const RUNTIME_DIAGNOSTIC_CODES: {
|
|
9
|
+
readonly ACTION_NOT_FOUND: "ACTION_NOT_FOUND";
|
|
10
|
+
readonly PARAMETER_MISSING: "PARAMETER_MISSING";
|
|
11
|
+
readonly PRECONDITION_FAILED: "PRECONDITION_FAILED";
|
|
12
|
+
readonly POSTCONDITION_FAILED: "POSTCONDITION_FAILED";
|
|
13
|
+
readonly CONSTRAINT_VIOLATION: "CONSTRAINT_VIOLATION";
|
|
14
|
+
readonly REQUIRED_FIELD_MISSING: "REQUIRED_FIELD_MISSING";
|
|
15
|
+
readonly ENUM_VALUE_INVALID: "ENUM_VALUE_INVALID";
|
|
16
|
+
readonly TYPE_MISMATCH: "TYPE_MISMATCH";
|
|
17
|
+
readonly EXPRESSION_EVALUATION_FAILED: "EXPRESSION_EVALUATION_FAILED";
|
|
18
|
+
readonly UNRESOLVED_REFERENCE: "UNRESOLVED_REFERENCE";
|
|
19
|
+
readonly LOCATION_RESOLUTION_FAILED: "LOCATION_RESOLUTION_FAILED";
|
|
20
|
+
readonly MUTATION_FAILED: "MUTATION_FAILED";
|
|
21
|
+
readonly DERIVED_STATE_WRITE: "DERIVED_STATE_WRITE";
|
|
22
|
+
readonly UNKNOWN_STATE: "UNKNOWN_STATE";
|
|
23
|
+
readonly UNSUPPORTED_EXPRESSION: "UNSUPPORTED_EXPRESSION";
|
|
24
|
+
readonly UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION";
|
|
25
|
+
readonly ROUTE_NOT_FOUND: "ROUTE_NOT_FOUND";
|
|
26
|
+
readonly NATIVE_OPERATION_MISSING: "NATIVE_OPERATION_MISSING";
|
|
27
|
+
readonly UI_NODE_MISSING: "UI_NODE_MISSING";
|
|
28
|
+
readonly UNSUPPORTED_UI_NODE: "UNSUPPORTED_UI_NODE";
|
|
29
|
+
readonly INPUT_REJECTED: "INPUT_REJECTED";
|
|
30
|
+
readonly PERSISTED_STATE_UNREADABLE: "PERSISTED_STATE_UNREADABLE";
|
|
31
|
+
};
|
|
32
|
+
export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
|
|
4
33
|
export interface RuntimeDiagnostic {
|
|
5
|
-
code:
|
|
34
|
+
code: RuntimeDiagnosticCode;
|
|
6
35
|
message: string;
|
|
7
36
|
severity: 'error' | 'warning';
|
|
8
37
|
nodeId?: NodeId;
|
|
9
38
|
fieldId?: FieldId;
|
|
39
|
+
actionId?: NodeId;
|
|
40
|
+
constraintId?: NodeId;
|
|
41
|
+
stateId?: NodeId;
|
|
42
|
+
location?: Location;
|
|
43
|
+
transactionId?: string;
|
|
44
|
+
/** Structured context, so an agent never has to read the message. */
|
|
45
|
+
details?: Record<string, unknown>;
|
|
10
46
|
}
|
|
11
47
|
export interface ActionResult {
|
|
12
48
|
ok: boolean;
|
|
@@ -37,7 +73,9 @@ export interface AxiomRuntime {
|
|
|
37
73
|
invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
|
|
38
74
|
navigate(path: string): void;
|
|
39
75
|
currentRoute(): RouteMatch | null;
|
|
76
|
+
/** Every diagnostic reported so far. Per-invocation results carry their own. */
|
|
40
77
|
diagnostics(): RuntimeDiagnostic[];
|
|
78
|
+
clearDiagnostics(): void;
|
|
41
79
|
/** Every mutation this runtime has applied, in order, with its semantic location. */
|
|
42
80
|
getMutationLog(): MutationLogEntry[];
|
|
43
81
|
registerNativeOperation(implementationId: string, implementation: NativeImplementation): void;
|
package/dist/runtime.js
CHANGED
|
@@ -3,6 +3,34 @@ import { LocationResolutionError, resolveLocation } from './mutation/resolve-loc
|
|
|
3
3
|
import { createStateStore } from './mutation/store.js';
|
|
4
4
|
import { createTransactionManager } from './mutation/transaction.js';
|
|
5
5
|
import { cloneValue, compareValues, deepFreeze, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
|
|
6
|
+
/**
|
|
7
|
+
* The diagnostics a runtime can report. Agents match on the code rather than parsing the
|
|
8
|
+
* message, so this vocabulary is part of the public contract.
|
|
9
|
+
*/
|
|
10
|
+
export const RUNTIME_DIAGNOSTIC_CODES = {
|
|
11
|
+
ACTION_NOT_FOUND: 'ACTION_NOT_FOUND',
|
|
12
|
+
PARAMETER_MISSING: 'PARAMETER_MISSING',
|
|
13
|
+
PRECONDITION_FAILED: 'PRECONDITION_FAILED',
|
|
14
|
+
POSTCONDITION_FAILED: 'POSTCONDITION_FAILED',
|
|
15
|
+
CONSTRAINT_VIOLATION: 'CONSTRAINT_VIOLATION',
|
|
16
|
+
REQUIRED_FIELD_MISSING: 'REQUIRED_FIELD_MISSING',
|
|
17
|
+
ENUM_VALUE_INVALID: 'ENUM_VALUE_INVALID',
|
|
18
|
+
TYPE_MISMATCH: 'TYPE_MISMATCH',
|
|
19
|
+
EXPRESSION_EVALUATION_FAILED: 'EXPRESSION_EVALUATION_FAILED',
|
|
20
|
+
UNRESOLVED_REFERENCE: 'UNRESOLVED_REFERENCE',
|
|
21
|
+
LOCATION_RESOLUTION_FAILED: 'LOCATION_RESOLUTION_FAILED',
|
|
22
|
+
MUTATION_FAILED: 'MUTATION_FAILED',
|
|
23
|
+
DERIVED_STATE_WRITE: 'DERIVED_STATE_WRITE',
|
|
24
|
+
UNKNOWN_STATE: 'UNKNOWN_STATE',
|
|
25
|
+
UNSUPPORTED_EXPRESSION: 'UNSUPPORTED_EXPRESSION',
|
|
26
|
+
UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
|
|
27
|
+
ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
|
|
28
|
+
NATIVE_OPERATION_MISSING: 'NATIVE_OPERATION_MISSING',
|
|
29
|
+
UI_NODE_MISSING: 'UI_NODE_MISSING',
|
|
30
|
+
UNSUPPORTED_UI_NODE: 'UNSUPPORTED_UI_NODE',
|
|
31
|
+
INPUT_REJECTED: 'INPUT_REJECTED',
|
|
32
|
+
PERSISTED_STATE_UNREADABLE: 'PERSISTED_STATE_UNREADABLE',
|
|
33
|
+
};
|
|
6
34
|
const MISSING = Symbol('missing');
|
|
7
35
|
function unwrapType(type) {
|
|
8
36
|
return type.kind === 'optional' ? unwrapType(type.valueType) : type;
|
|
@@ -30,6 +58,19 @@ function defaultForType(type) {
|
|
|
30
58
|
return null;
|
|
31
59
|
}
|
|
32
60
|
}
|
|
61
|
+
/** A short, structured description of a runtime value, for diagnostics. */
|
|
62
|
+
function describeValue(value) {
|
|
63
|
+
if (value === null || value === undefined) {
|
|
64
|
+
return 'nothing';
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return `a collection of ${value.length}`;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value === 'object') {
|
|
70
|
+
return 'a record';
|
|
71
|
+
}
|
|
72
|
+
return `${typeof value} ${JSON.stringify(value)}`;
|
|
73
|
+
}
|
|
33
74
|
export function createAxiomRuntime(options) {
|
|
34
75
|
const { ir, rootElement, host } = options;
|
|
35
76
|
const store = createStateStore();
|
|
@@ -56,12 +97,27 @@ export function createAxiomRuntime(options) {
|
|
|
56
97
|
parameterTypes.set(parameter.id, parameter.valueType);
|
|
57
98
|
}
|
|
58
99
|
}
|
|
100
|
+
/** Diagnostics reported while the current invocation runs, if one is collecting. */
|
|
101
|
+
let collector = null;
|
|
59
102
|
function report(diagnostic) {
|
|
60
103
|
diagnostics.push(diagnostic);
|
|
104
|
+
collector?.push(diagnostic);
|
|
61
105
|
if (diagnostic.severity === 'error') {
|
|
62
106
|
host.report?.(`${diagnostic.code}: ${diagnostic.message}`);
|
|
63
107
|
}
|
|
64
108
|
}
|
|
109
|
+
/** Runs `body` while gathering every diagnostic it reports. */
|
|
110
|
+
function collecting(body) {
|
|
111
|
+
const previous = collector;
|
|
112
|
+
const collected = [];
|
|
113
|
+
collector = collected;
|
|
114
|
+
try {
|
|
115
|
+
return body(collected);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
collector = previous;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
65
121
|
// ---------------------------------------------------------------- state store
|
|
66
122
|
function storageKey(state) {
|
|
67
123
|
if (state.persistence?.kind !== 'local-storage') {
|
|
@@ -84,7 +140,7 @@ export function createAxiomRuntime(options) {
|
|
|
84
140
|
}
|
|
85
141
|
catch {
|
|
86
142
|
report({
|
|
87
|
-
code:
|
|
143
|
+
code: RUNTIME_DIAGNOSTIC_CODES.PERSISTED_STATE_UNREADABLE,
|
|
88
144
|
message: `Stored value for ${state.id} could not be parsed; falling back to the initial value`,
|
|
89
145
|
severity: 'warning',
|
|
90
146
|
nodeId: state.id,
|
|
@@ -126,7 +182,7 @@ export function createAxiomRuntime(options) {
|
|
|
126
182
|
function writeState(stateId, value) {
|
|
127
183
|
if (!statesById.has(stateId)) {
|
|
128
184
|
report({
|
|
129
|
-
code:
|
|
185
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNKNOWN_STATE,
|
|
130
186
|
message: `Cannot write to unknown state ${stateId}`,
|
|
131
187
|
severity: 'error',
|
|
132
188
|
nodeId: stateId,
|
|
@@ -135,7 +191,7 @@ export function createAxiomRuntime(options) {
|
|
|
135
191
|
}
|
|
136
192
|
if (statesById.get(stateId)?.derivation) {
|
|
137
193
|
report({
|
|
138
|
-
code:
|
|
194
|
+
code: RUNTIME_DIAGNOSTIC_CODES.DERIVED_STATE_WRITE,
|
|
139
195
|
message: `${stateId} is derived state and cannot be written to`,
|
|
140
196
|
severity: 'error',
|
|
141
197
|
nodeId: stateId,
|
|
@@ -196,7 +252,7 @@ export function createAxiomRuntime(options) {
|
|
|
196
252
|
}
|
|
197
253
|
catch (error) {
|
|
198
254
|
const failure = {
|
|
199
|
-
code: error instanceof LocationResolutionError ?
|
|
255
|
+
code: error instanceof LocationResolutionError ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED : 'MUTATION_FAILED',
|
|
200
256
|
message: error instanceof Error ? error.message : String(error),
|
|
201
257
|
severity: 'error',
|
|
202
258
|
...(context.sourceNodeId ? { nodeId: context.sourceNodeId } : {}),
|
|
@@ -243,7 +299,7 @@ export function createAxiomRuntime(options) {
|
|
|
243
299
|
return readState(expression.targetId);
|
|
244
300
|
}
|
|
245
301
|
report({
|
|
246
|
-
code:
|
|
302
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNRESOLVED_REFERENCE,
|
|
247
303
|
message: `Reference ${expression.targetId} could not be resolved`,
|
|
248
304
|
severity: 'error',
|
|
249
305
|
nodeId: expression.targetId,
|
|
@@ -280,6 +336,32 @@ export function createAxiomRuntime(options) {
|
|
|
280
336
|
}
|
|
281
337
|
return source.filter((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
282
338
|
}
|
|
339
|
+
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
|
+
}
|
|
349
|
+
return source.map((item) => evaluate(expression.projection, childScope(scope, expression.scopeId, item)));
|
|
350
|
+
}
|
|
351
|
+
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
|
+
}
|
|
361
|
+
const direction = expression.direction === 'desc' ? -1 : 1;
|
|
362
|
+
const key = (item) => evaluate(expression.by, childScope(scope, expression.scopeId, item));
|
|
363
|
+
return [...source].sort((left, right) => direction * compareValues(key(left), key(right)));
|
|
364
|
+
}
|
|
283
365
|
case 'find': {
|
|
284
366
|
const source = evaluate(expression.source, scope);
|
|
285
367
|
if (!Array.isArray(source)) {
|
|
@@ -290,7 +372,7 @@ export function createAxiomRuntime(options) {
|
|
|
290
372
|
}
|
|
291
373
|
default:
|
|
292
374
|
report({
|
|
293
|
-
code:
|
|
375
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION,
|
|
294
376
|
message: `Unknown expression kind "${expression.kind}"`,
|
|
295
377
|
severity: 'error',
|
|
296
378
|
});
|
|
@@ -330,7 +412,7 @@ export function createAxiomRuntime(options) {
|
|
|
330
412
|
return divisor === 0 ? null : Number(left ?? 0) / divisor;
|
|
331
413
|
}
|
|
332
414
|
default:
|
|
333
|
-
report({ code:
|
|
415
|
+
report({ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION, message: `Unknown operator "${operator}"`, severity: 'error' });
|
|
334
416
|
return null;
|
|
335
417
|
}
|
|
336
418
|
}
|
|
@@ -345,6 +427,34 @@ export function createAxiomRuntime(options) {
|
|
|
345
427
|
return Array.isArray(values[0]) ? values[0].length : toText(values[0]).length;
|
|
346
428
|
case 'count':
|
|
347
429
|
return Array.isArray(values[0]) ? values[0].length : 0;
|
|
430
|
+
case 'sum': {
|
|
431
|
+
// An aggregation that cannot be computed must not quietly produce a value that
|
|
432
|
+
// makes a guard pass. It reports, and yields a number no comparison accepts.
|
|
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
|
+
}
|
|
443
|
+
let total = 0;
|
|
444
|
+
for (const member of source) {
|
|
445
|
+
if (typeof member !== 'number' || !Number.isFinite(member)) {
|
|
446
|
+
report({
|
|
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;
|
|
453
|
+
}
|
|
454
|
+
total += member;
|
|
455
|
+
}
|
|
456
|
+
return total;
|
|
457
|
+
}
|
|
348
458
|
case 'contains': {
|
|
349
459
|
const [haystack, needle] = values;
|
|
350
460
|
if (Array.isArray(haystack)) {
|
|
@@ -367,7 +477,7 @@ export function createAxiomRuntime(options) {
|
|
|
367
477
|
case 'uuid':
|
|
368
478
|
return host.uuid();
|
|
369
479
|
default:
|
|
370
|
-
report({ code:
|
|
480
|
+
report({ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION, message: `Unknown function "${fn}"`, severity: 'error' });
|
|
371
481
|
return null;
|
|
372
482
|
}
|
|
373
483
|
}
|
|
@@ -380,32 +490,51 @@ export function createAxiomRuntime(options) {
|
|
|
380
490
|
}
|
|
381
491
|
return resolved.kind === 'entity' ? resolved.entityId : null;
|
|
382
492
|
}
|
|
383
|
-
|
|
384
|
-
|
|
493
|
+
/**
|
|
494
|
+
* Every canonical occurrence of every entity, found by walking state values against
|
|
495
|
+
* their declared types. Entities nested inside collections and inside other entities
|
|
496
|
+
* are reached recursively, so their constraints apply wherever they actually live.
|
|
497
|
+
*/
|
|
498
|
+
function collectInstances() {
|
|
499
|
+
const found = new Map();
|
|
500
|
+
const visit = (value, type) => {
|
|
501
|
+
const resolved = unwrapType(type);
|
|
502
|
+
if (resolved.kind === 'collection') {
|
|
503
|
+
if (Array.isArray(value)) {
|
|
504
|
+
for (const item of value) {
|
|
505
|
+
visit(item, resolved.itemType);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (resolved.kind !== 'entity' || !isRecord(value)) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const instances = found.get(resolved.entityId) ?? [];
|
|
514
|
+
instances.push(value);
|
|
515
|
+
found.set(resolved.entityId, instances);
|
|
516
|
+
for (const field of entitiesById.get(resolved.entityId)?.fields ?? []) {
|
|
517
|
+
visit(value[field.id], field.valueType);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
385
520
|
for (const state of ir.states) {
|
|
386
521
|
// Drafts are incomplete by definition, and derived states are views of data that
|
|
387
522
|
// is already validated where it is stored.
|
|
388
523
|
if (state.draft || state.derivation) {
|
|
389
524
|
continue;
|
|
390
525
|
}
|
|
391
|
-
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
const value = readState(state.id);
|
|
395
|
-
if (Array.isArray(value)) {
|
|
396
|
-
instances.push(...value);
|
|
397
|
-
}
|
|
398
|
-
else if (isRecord(value)) {
|
|
399
|
-
instances.push(value);
|
|
400
|
-
}
|
|
526
|
+
visit(readState(state.id), state.valueType);
|
|
401
527
|
}
|
|
402
|
-
return
|
|
528
|
+
return found;
|
|
529
|
+
}
|
|
530
|
+
function instancesOf(entityId) {
|
|
531
|
+
return collectInstances().get(entityId) ?? [];
|
|
403
532
|
}
|
|
404
533
|
function checkFieldValue(field, value, entityId) {
|
|
405
534
|
if (!isPresent(value)) {
|
|
406
535
|
if (field.required) {
|
|
407
536
|
return {
|
|
408
|
-
code:
|
|
537
|
+
code: RUNTIME_DIAGNOSTIC_CODES.REQUIRED_FIELD_MISSING,
|
|
409
538
|
message: `${field.name ?? field.id} is required`,
|
|
410
539
|
severity: 'error',
|
|
411
540
|
nodeId: entityId,
|
|
@@ -417,7 +546,7 @@ export function createAxiomRuntime(options) {
|
|
|
417
546
|
const resolved = unwrapType(field.valueType);
|
|
418
547
|
if (resolved.kind === 'enum' && !resolved.values.includes(toText(value))) {
|
|
419
548
|
return {
|
|
420
|
-
code:
|
|
549
|
+
code: RUNTIME_DIAGNOSTIC_CODES.ENUM_VALUE_INVALID,
|
|
421
550
|
message: `${field.name ?? field.id} must be one of: ${resolved.values.join(', ')}`,
|
|
422
551
|
severity: 'error',
|
|
423
552
|
nodeId: entityId,
|
|
@@ -426,7 +555,7 @@ export function createAxiomRuntime(options) {
|
|
|
426
555
|
}
|
|
427
556
|
if (resolved.kind === 'primitive' && resolved.primitive === 'number' && typeof value !== 'number') {
|
|
428
557
|
return {
|
|
429
|
-
code:
|
|
558
|
+
code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
|
|
430
559
|
message: `${field.name ?? field.id} must be a number`,
|
|
431
560
|
severity: 'error',
|
|
432
561
|
nodeId: entityId,
|
|
@@ -435,7 +564,7 @@ export function createAxiomRuntime(options) {
|
|
|
435
564
|
}
|
|
436
565
|
if (resolved.kind === 'primitive' && resolved.primitive === 'boolean' && typeof value !== 'boolean') {
|
|
437
566
|
return {
|
|
438
|
-
code:
|
|
567
|
+
code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
|
|
439
568
|
message: `${field.name ?? field.id} must be a boolean`,
|
|
440
569
|
severity: 'error',
|
|
441
570
|
nodeId: entityId,
|
|
@@ -444,11 +573,15 @@ export function createAxiomRuntime(options) {
|
|
|
444
573
|
}
|
|
445
574
|
return null;
|
|
446
575
|
}
|
|
447
|
-
/**
|
|
576
|
+
/**
|
|
577
|
+
* Schema conformance plus declared constraints, evaluated over every canonical instance
|
|
578
|
+
* — including instances nested inside collections and inside other entities.
|
|
579
|
+
*/
|
|
448
580
|
function evaluateInvariants() {
|
|
449
581
|
const failures = [];
|
|
582
|
+
const instances = collectInstances();
|
|
450
583
|
for (const entity of ir.entities) {
|
|
451
|
-
for (const instance of
|
|
584
|
+
for (const instance of instances.get(entity.id) ?? []) {
|
|
452
585
|
if (!isRecord(instance)) {
|
|
453
586
|
continue;
|
|
454
587
|
}
|
|
@@ -461,7 +594,7 @@ export function createAxiomRuntime(options) {
|
|
|
461
594
|
}
|
|
462
595
|
}
|
|
463
596
|
for (const constraint of ir.constraints) {
|
|
464
|
-
failures.push(...evaluateConstraint(constraint));
|
|
597
|
+
failures.push(...evaluateConstraint(constraint, instances));
|
|
465
598
|
}
|
|
466
599
|
return failures;
|
|
467
600
|
}
|
|
@@ -503,15 +636,21 @@ export function createAxiomRuntime(options) {
|
|
|
503
636
|
}
|
|
504
637
|
return introduced;
|
|
505
638
|
}
|
|
506
|
-
|
|
639
|
+
/**
|
|
640
|
+
* An entity-scoped constraint is evaluated once per instance, with `ref(entityId)`
|
|
641
|
+
* bound to the instance under validation.
|
|
642
|
+
*/
|
|
643
|
+
function evaluateConstraint(constraint, instances) {
|
|
507
644
|
const severity = constraint.severity ?? 'error';
|
|
508
645
|
const failures = [];
|
|
509
|
-
const record = () => {
|
|
646
|
+
const record = (instance) => {
|
|
510
647
|
failures.push({
|
|
511
|
-
code:
|
|
648
|
+
code: RUNTIME_DIAGNOSTIC_CODES.CONSTRAINT_VIOLATION,
|
|
512
649
|
message: constraint.message ?? `Constraint ${constraint.name ?? constraint.id} failed`,
|
|
513
650
|
severity,
|
|
514
651
|
nodeId: constraint.id,
|
|
652
|
+
constraintId: constraint.id,
|
|
653
|
+
...(constraint.entityId ? { details: { entityId: constraint.entityId, instance } } : {}),
|
|
515
654
|
});
|
|
516
655
|
};
|
|
517
656
|
if (!constraint.entityId) {
|
|
@@ -520,10 +659,10 @@ export function createAxiomRuntime(options) {
|
|
|
520
659
|
}
|
|
521
660
|
return failures;
|
|
522
661
|
}
|
|
523
|
-
for (const instance of
|
|
662
|
+
for (const instance of instances.get(constraint.entityId) ?? []) {
|
|
524
663
|
const scope = childScope(rootScope(), constraint.entityId, instance);
|
|
525
664
|
if (!toBoolean(evaluate(constraint.expression, scope))) {
|
|
526
|
-
record();
|
|
665
|
+
record(instance);
|
|
527
666
|
}
|
|
528
667
|
}
|
|
529
668
|
return failures;
|
|
@@ -536,6 +675,29 @@ export function createAxiomRuntime(options) {
|
|
|
536
675
|
case 'remove':
|
|
537
676
|
mutate(() => mutations.apply(operation, scope, context), context, result);
|
|
538
677
|
return;
|
|
678
|
+
case 'for-each': {
|
|
679
|
+
// The members are read once, before any of them are mutated, so the iteration
|
|
680
|
+
// walks the collection as it stood when the operation began. Nothing here opens
|
|
681
|
+
// a transaction: these mutations belong to the action's own transaction, and a
|
|
682
|
+
// 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
|
+
}
|
|
693
|
+
for (const member of members) {
|
|
694
|
+
const iteration = childScope(scope, operation.scopeId, member);
|
|
695
|
+
for (const nested of operation.operations ?? []) {
|
|
696
|
+
executeOperation(nested, iteration, context, result);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
539
701
|
case 'invoke': {
|
|
540
702
|
const args = {};
|
|
541
703
|
for (const [parameterId, argument] of Object.entries(operation.arguments ?? {})) {
|
|
@@ -553,7 +715,7 @@ export function createAxiomRuntime(options) {
|
|
|
553
715
|
const route = ir.routes.find((candidate) => candidate.id === operation.routeId);
|
|
554
716
|
if (!route) {
|
|
555
717
|
result.push({
|
|
556
|
-
code:
|
|
718
|
+
code: RUNTIME_DIAGNOSTIC_CODES.ROUTE_NOT_FOUND,
|
|
557
719
|
message: `Navigate operation could not resolve route ${String(operation.routeId)}`,
|
|
558
720
|
severity: 'error',
|
|
559
721
|
});
|
|
@@ -570,7 +732,7 @@ export function createAxiomRuntime(options) {
|
|
|
570
732
|
const implementation = natives.get(operation.implementationId);
|
|
571
733
|
if (!implementation) {
|
|
572
734
|
result.push({
|
|
573
|
-
code:
|
|
735
|
+
code: RUNTIME_DIAGNOSTIC_CODES.NATIVE_OPERATION_MISSING,
|
|
574
736
|
message: `No implementation registered for "${operation.implementationId}"`,
|
|
575
737
|
severity: 'error',
|
|
576
738
|
});
|
|
@@ -590,22 +752,25 @@ export function createAxiomRuntime(options) {
|
|
|
590
752
|
}
|
|
591
753
|
default:
|
|
592
754
|
result.push({
|
|
593
|
-
code:
|
|
755
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_OPERATION,
|
|
594
756
|
message: `Unknown operation kind "${operation.kind}"`,
|
|
595
757
|
severity: 'error',
|
|
596
758
|
});
|
|
597
759
|
}
|
|
598
760
|
}
|
|
599
761
|
function runAction(actionId, args = {}) {
|
|
762
|
+
return collecting((collected) => runActionCollecting(actionId, args, collected));
|
|
763
|
+
}
|
|
764
|
+
function runActionCollecting(actionId, args, collected) {
|
|
600
765
|
const action = ir.actions[actionId];
|
|
601
766
|
if (!action) {
|
|
602
767
|
const failure = {
|
|
603
|
-
code:
|
|
768
|
+
code: RUNTIME_DIAGNOSTIC_CODES.ACTION_NOT_FOUND,
|
|
604
769
|
message: `Action ${actionId} is not defined`,
|
|
605
770
|
severity: 'error',
|
|
606
771
|
};
|
|
607
772
|
report(failure);
|
|
608
|
-
return { ok: false, diagnostics: [
|
|
773
|
+
return { ok: false, diagnostics: [...collected] };
|
|
609
774
|
}
|
|
610
775
|
const scope = rootScope();
|
|
611
776
|
for (const parameter of action.parameters ?? []) {
|
|
@@ -615,7 +780,7 @@ export function createAxiomRuntime(options) {
|
|
|
615
780
|
for (const parameter of action.parameters ?? []) {
|
|
616
781
|
if (parameter.required && !isPresent(scope.values.get(parameter.id))) {
|
|
617
782
|
failures.push({
|
|
618
|
-
code:
|
|
783
|
+
code: RUNTIME_DIAGNOSTIC_CODES.PARAMETER_MISSING,
|
|
619
784
|
message: `Action ${action.name ?? action.id} requires ${parameter.name ?? parameter.id}`,
|
|
620
785
|
severity: 'error',
|
|
621
786
|
nodeId: action.id,
|
|
@@ -624,25 +789,29 @@ export function createAxiomRuntime(options) {
|
|
|
624
789
|
}
|
|
625
790
|
if (failures.length > 0) {
|
|
626
791
|
failures.forEach(report);
|
|
627
|
-
return { ok: false, diagnostics:
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
`A precondition of ${action.name ?? action.id} was not satisfied`,
|
|
635
|
-
severity: 'error',
|
|
636
|
-
nodeId: action.id,
|
|
637
|
-
};
|
|
638
|
-
report(failure);
|
|
639
|
-
return { ok: false, diagnostics: [failure] };
|
|
792
|
+
return { ok: false, diagnostics: [...collected] };
|
|
793
|
+
}
|
|
794
|
+
// Failure modes line up with preconditions by position, so a refusal says which
|
|
795
|
+
// condition was not met rather than always naming the first one.
|
|
796
|
+
for (const [index, precondition] of (action.preconditions ?? []).entries()) {
|
|
797
|
+
if (toBoolean(evaluate(precondition, scope))) {
|
|
798
|
+
continue;
|
|
640
799
|
}
|
|
800
|
+
const mode = action.failureModes?.[index];
|
|
801
|
+
report({
|
|
802
|
+
code: RUNTIME_DIAGNOSTIC_CODES.PRECONDITION_FAILED,
|
|
803
|
+
message: mode?.message ?? `A precondition of ${action.name ?? action.id} was not satisfied`,
|
|
804
|
+
severity: 'error',
|
|
805
|
+
nodeId: action.id,
|
|
806
|
+
actionId: action.id,
|
|
807
|
+
details: { preconditionIndex: index, ...(mode?.code ? { failureMode: mode.code } : {}) },
|
|
808
|
+
});
|
|
809
|
+
return { ok: false, diagnostics: [...collected] };
|
|
641
810
|
}
|
|
642
811
|
if (action.requiresConfirmation) {
|
|
643
812
|
const message = action.confirmationMessage ?? `Confirm ${action.name ?? action.id}. This cannot be undone.`;
|
|
644
813
|
if (!host.confirm(message)) {
|
|
645
|
-
return { ok: false, diagnostics: [] };
|
|
814
|
+
return { ok: false, diagnostics: [...collected] };
|
|
646
815
|
}
|
|
647
816
|
}
|
|
648
817
|
const transaction = transactions.begin();
|
|
@@ -652,32 +821,41 @@ export function createAxiomRuntime(options) {
|
|
|
652
821
|
transactionId: transaction.id,
|
|
653
822
|
};
|
|
654
823
|
const operationDiagnostics = [];
|
|
824
|
+
const reportedBefore = collected.length;
|
|
655
825
|
for (const operation of action.operations ?? []) {
|
|
656
826
|
executeOperation(operation, scope, context, operationDiagnostics);
|
|
657
827
|
}
|
|
828
|
+
// Anything reported while the operations ran — an expression that could not be
|
|
829
|
+
// evaluated, for instance — is a failure of this action, not a passing curiosity.
|
|
658
830
|
const violations = [
|
|
659
831
|
...operationDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'),
|
|
832
|
+
...collected.slice(reportedBefore).filter((diagnostic) => diagnostic.severity === 'error'),
|
|
660
833
|
...evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error'),
|
|
661
834
|
];
|
|
662
835
|
for (const postcondition of action.postconditions ?? []) {
|
|
663
836
|
if (!toBoolean(evaluate(postcondition, scope))) {
|
|
664
837
|
violations.push({
|
|
665
|
-
code:
|
|
838
|
+
code: RUNTIME_DIAGNOSTIC_CODES.POSTCONDITION_FAILED,
|
|
666
839
|
message: `A postcondition of ${action.name ?? action.id} was not satisfied`,
|
|
667
840
|
severity: 'error',
|
|
668
841
|
nodeId: action.id,
|
|
842
|
+
actionId: action.id,
|
|
669
843
|
});
|
|
670
844
|
}
|
|
671
845
|
}
|
|
672
846
|
if (violations.length > 0) {
|
|
673
847
|
settle(transaction, 'rolled-back');
|
|
674
|
-
violations
|
|
848
|
+
for (const violation of violations) {
|
|
849
|
+
if (!collected.includes(violation)) {
|
|
850
|
+
report({ ...violation, actionId: action.id, transactionId: transaction.id });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
675
853
|
renderApplication();
|
|
676
|
-
return { ok: false, diagnostics:
|
|
854
|
+
return { ok: false, diagnostics: [...collected] };
|
|
677
855
|
}
|
|
678
856
|
settle(transaction, 'committed');
|
|
679
857
|
renderApplication();
|
|
680
|
-
return { ok: true, diagnostics:
|
|
858
|
+
return { ok: true, diagnostics: [...collected] };
|
|
681
859
|
}
|
|
682
860
|
// ------------------------------------------------------------------ routing
|
|
683
861
|
function buildPath(route, values) {
|
|
@@ -841,7 +1019,7 @@ export function createAxiomRuntime(options) {
|
|
|
841
1019
|
const node = ir.uiNodes[id];
|
|
842
1020
|
if (!node) {
|
|
843
1021
|
report({
|
|
844
|
-
code:
|
|
1022
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UI_NODE_MISSING,
|
|
845
1023
|
message: `UI node ${id} is not defined`,
|
|
846
1024
|
severity: 'error',
|
|
847
1025
|
nodeId: id,
|
|
@@ -988,7 +1166,7 @@ export function createAxiomRuntime(options) {
|
|
|
988
1166
|
settle(transaction, 'rolled-back');
|
|
989
1167
|
introduced.forEach(report);
|
|
990
1168
|
report({
|
|
991
|
-
code:
|
|
1169
|
+
code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
|
|
992
1170
|
message: `${node.label ?? node.id} kept its previous value: ${introduced[0].message}`,
|
|
993
1171
|
severity: 'warning',
|
|
994
1172
|
nodeId: node.id,
|
|
@@ -1036,7 +1214,7 @@ export function createAxiomRuntime(options) {
|
|
|
1036
1214
|
}
|
|
1037
1215
|
default:
|
|
1038
1216
|
report({
|
|
1039
|
-
code:
|
|
1217
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_UI_NODE,
|
|
1040
1218
|
message: `Unknown UI node kind "${node.kind}"`,
|
|
1041
1219
|
severity: 'error',
|
|
1042
1220
|
});
|
|
@@ -1117,6 +1295,9 @@ export function createAxiomRuntime(options) {
|
|
|
1117
1295
|
diagnostics() {
|
|
1118
1296
|
return diagnostics.map((diagnostic) => ({ ...diagnostic }));
|
|
1119
1297
|
},
|
|
1298
|
+
clearDiagnostics() {
|
|
1299
|
+
diagnostics.length = 0;
|
|
1300
|
+
},
|
|
1120
1301
|
getMutationLog() {
|
|
1121
1302
|
return mutationLog.map((entry) => ({ ...entry }));
|
|
1122
1303
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cynodia/axiom-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-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.
|
|
34
|
+
"@cynodia/axiom-core": "0.4.0-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|