@cynodia/axiom-runtime 0.3.1-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 +20 -1
- package/dist/mutation/values.js +45 -7
- package/dist/runtime.d.ts +49 -3
- package/dist/runtime.js +381 -92
- 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
|
*
|
|
@@ -5,11 +15,20 @@
|
|
|
5
15
|
* throws in strict mode, which is what keeps "no implicit object mutation" an enforced
|
|
6
16
|
* invariant rather than a convention.
|
|
7
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
|
|
20
|
+
* null, which would silently disguise a failed computation as an absent one.
|
|
21
|
+
*/
|
|
8
22
|
export declare function cloneValue<T>(value: T): T;
|
|
9
23
|
export declare function deepFreeze<T>(value: T): T;
|
|
10
24
|
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
11
|
-
/**
|
|
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
|
+
*/
|
|
12
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;
|
|
13
32
|
export declare function toBoolean(value: unknown): boolean;
|
|
14
33
|
export declare function toText(value: unknown): string;
|
|
15
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
|
*
|
|
@@ -5,8 +19,12 @@
|
|
|
5
19
|
* throws in strict mode, which is what keeps "no implicit object mutation" an enforced
|
|
6
20
|
* invariant rather than a convention.
|
|
7
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
|
|
24
|
+
* null, which would silently disguise a failed computation as an absent one.
|
|
25
|
+
*/
|
|
8
26
|
export function cloneValue(value) {
|
|
9
|
-
return value === undefined ? value :
|
|
27
|
+
return value === undefined ? value : structuredClone(value);
|
|
10
28
|
}
|
|
11
29
|
export function deepFreeze(value) {
|
|
12
30
|
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
|
|
@@ -20,18 +38,25 @@ export function deepFreeze(value) {
|
|
|
20
38
|
export function isRecord(value) {
|
|
21
39
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
40
|
}
|
|
23
|
-
/**
|
|
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
|
+
*/
|
|
24
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) {
|
|
25
50
|
if (value === null || value === undefined) {
|
|
26
|
-
return
|
|
51
|
+
return true;
|
|
27
52
|
}
|
|
28
53
|
if (typeof value === 'string') {
|
|
29
|
-
return value.trim().length
|
|
54
|
+
return value.trim().length === 0;
|
|
30
55
|
}
|
|
31
56
|
if (Array.isArray(value)) {
|
|
32
|
-
return value.length
|
|
57
|
+
return value.length === 0;
|
|
33
58
|
}
|
|
34
|
-
return
|
|
59
|
+
return false;
|
|
35
60
|
}
|
|
36
61
|
export function toBoolean(value) {
|
|
37
62
|
if (Array.isArray(value)) {
|
|
@@ -59,6 +84,19 @@ export function compareValues(left, right) {
|
|
|
59
84
|
const rightText = toText(right);
|
|
60
85
|
return leftText === rightText ? 0 : leftText < rightText ? -1 : 1;
|
|
61
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
|
+
}
|
|
62
100
|
export function valuesEqual(left, right) {
|
|
63
101
|
if (left === right) {
|
|
64
102
|
return true;
|
|
@@ -67,7 +105,7 @@ export function valuesEqual(left, right) {
|
|
|
67
105
|
return (left ?? null) === (right ?? null);
|
|
68
106
|
}
|
|
69
107
|
if (typeof left === 'object' || typeof right === 'object') {
|
|
70
|
-
return
|
|
108
|
+
return canonical(left) === canonical(right);
|
|
71
109
|
}
|
|
72
110
|
return false;
|
|
73
111
|
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
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 TRANSITION_CONSTRAINT_VIOLATION: "TRANSITION_CONSTRAINT_VIOLATION";
|
|
24
|
+
readonly UNSUPPORTED_EXPRESSION: "UNSUPPORTED_EXPRESSION";
|
|
25
|
+
readonly UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION";
|
|
26
|
+
readonly ROUTE_NOT_FOUND: "ROUTE_NOT_FOUND";
|
|
27
|
+
readonly NATIVE_OPERATION_MISSING: "NATIVE_OPERATION_MISSING";
|
|
28
|
+
readonly UI_NODE_MISSING: "UI_NODE_MISSING";
|
|
29
|
+
readonly UNSUPPORTED_UI_NODE: "UNSUPPORTED_UI_NODE";
|
|
30
|
+
readonly INPUT_REJECTED: "INPUT_REJECTED";
|
|
31
|
+
readonly PERSISTED_STATE_UNREADABLE: "PERSISTED_STATE_UNREADABLE";
|
|
32
|
+
};
|
|
33
|
+
export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
|
|
4
34
|
export interface RuntimeDiagnostic {
|
|
5
|
-
code:
|
|
35
|
+
code: RuntimeDiagnosticCode;
|
|
6
36
|
message: string;
|
|
7
37
|
severity: 'error' | 'warning';
|
|
8
38
|
nodeId?: NodeId;
|
|
9
39
|
fieldId?: FieldId;
|
|
40
|
+
actionId?: NodeId;
|
|
41
|
+
constraintId?: NodeId;
|
|
42
|
+
stateId?: NodeId;
|
|
43
|
+
location?: Location;
|
|
44
|
+
transactionId?: string;
|
|
45
|
+
/** Structured context, so an agent never has to read the message. */
|
|
46
|
+
details?: Record<string, unknown>;
|
|
10
47
|
}
|
|
11
48
|
export interface ActionResult {
|
|
12
49
|
ok: boolean;
|
|
@@ -33,11 +70,20 @@ export interface AxiomRuntime {
|
|
|
33
70
|
start(): void;
|
|
34
71
|
render(): void;
|
|
35
72
|
getState(id: NodeId): unknown;
|
|
36
|
-
|
|
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;
|
|
37
81
|
invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
|
|
38
82
|
navigate(path: string): void;
|
|
39
83
|
currentRoute(): RouteMatch | null;
|
|
84
|
+
/** Every diagnostic reported so far. Per-invocation results carry their own. */
|
|
40
85
|
diagnostics(): RuntimeDiagnostic[];
|
|
86
|
+
clearDiagnostics(): void;
|
|
41
87
|
/** Every mutation this runtime has applied, in order, with its semantic location. */
|
|
42
88
|
getMutationLog(): MutationLogEntry[];
|
|
43
89
|
registerNativeOperation(implementationId: string, implementation: NativeImplementation): void;
|
package/dist/runtime.js
CHANGED
|
@@ -2,7 +2,36 @@ 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
|
+
/**
|
|
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
|
+
TRANSITION_CONSTRAINT_VIOLATION: 'TRANSITION_CONSTRAINT_VIOLATION',
|
|
26
|
+
UNSUPPORTED_EXPRESSION: 'UNSUPPORTED_EXPRESSION',
|
|
27
|
+
UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
|
|
28
|
+
ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
|
|
29
|
+
NATIVE_OPERATION_MISSING: 'NATIVE_OPERATION_MISSING',
|
|
30
|
+
UI_NODE_MISSING: 'UI_NODE_MISSING',
|
|
31
|
+
UNSUPPORTED_UI_NODE: 'UNSUPPORTED_UI_NODE',
|
|
32
|
+
INPUT_REJECTED: 'INPUT_REJECTED',
|
|
33
|
+
PERSISTED_STATE_UNREADABLE: 'PERSISTED_STATE_UNREADABLE',
|
|
34
|
+
};
|
|
6
35
|
const MISSING = Symbol('missing');
|
|
7
36
|
function unwrapType(type) {
|
|
8
37
|
return type.kind === 'optional' ? unwrapType(type.valueType) : type;
|
|
@@ -30,6 +59,30 @@ function defaultForType(type) {
|
|
|
30
59
|
return null;
|
|
31
60
|
}
|
|
32
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
|
+
}
|
|
73
|
+
/** A short, structured description of a runtime value, for diagnostics. */
|
|
74
|
+
function describeValue(value) {
|
|
75
|
+
if (value === null || value === undefined) {
|
|
76
|
+
return 'nothing';
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
return `a collection of ${value.length}`;
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === 'object') {
|
|
82
|
+
return 'a record';
|
|
83
|
+
}
|
|
84
|
+
return `${typeof value} ${JSON.stringify(value)}`;
|
|
85
|
+
}
|
|
33
86
|
export function createAxiomRuntime(options) {
|
|
34
87
|
const { ir, rootElement, host } = options;
|
|
35
88
|
const store = createStateStore();
|
|
@@ -56,12 +109,27 @@ export function createAxiomRuntime(options) {
|
|
|
56
109
|
parameterTypes.set(parameter.id, parameter.valueType);
|
|
57
110
|
}
|
|
58
111
|
}
|
|
112
|
+
/** Diagnostics reported while the current invocation runs, if one is collecting. */
|
|
113
|
+
let collector = null;
|
|
59
114
|
function report(diagnostic) {
|
|
60
115
|
diagnostics.push(diagnostic);
|
|
116
|
+
collector?.push(diagnostic);
|
|
61
117
|
if (diagnostic.severity === 'error') {
|
|
62
118
|
host.report?.(`${diagnostic.code}: ${diagnostic.message}`);
|
|
63
119
|
}
|
|
64
120
|
}
|
|
121
|
+
/** Runs `body` while gathering every diagnostic it reports. */
|
|
122
|
+
function collecting(body) {
|
|
123
|
+
const previous = collector;
|
|
124
|
+
const collected = [];
|
|
125
|
+
collector = collected;
|
|
126
|
+
try {
|
|
127
|
+
return body(collected);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
collector = previous;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
65
133
|
// ---------------------------------------------------------------- state store
|
|
66
134
|
function storageKey(state) {
|
|
67
135
|
if (state.persistence?.kind !== 'local-storage') {
|
|
@@ -84,7 +152,7 @@ export function createAxiomRuntime(options) {
|
|
|
84
152
|
}
|
|
85
153
|
catch {
|
|
86
154
|
report({
|
|
87
|
-
code:
|
|
155
|
+
code: RUNTIME_DIAGNOSTIC_CODES.PERSISTED_STATE_UNREADABLE,
|
|
88
156
|
message: `Stored value for ${state.id} could not be parsed; falling back to the initial value`,
|
|
89
157
|
severity: 'warning',
|
|
90
158
|
nodeId: state.id,
|
|
@@ -116,9 +184,16 @@ export function createAxiomRuntime(options) {
|
|
|
116
184
|
return derivedCache.get(stateId);
|
|
117
185
|
}
|
|
118
186
|
derivedCache.set(stateId, null);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
|
122
197
|
}
|
|
123
198
|
return store.read(stateId);
|
|
124
199
|
}
|
|
@@ -126,7 +201,7 @@ export function createAxiomRuntime(options) {
|
|
|
126
201
|
function writeState(stateId, value) {
|
|
127
202
|
if (!statesById.has(stateId)) {
|
|
128
203
|
report({
|
|
129
|
-
code:
|
|
204
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNKNOWN_STATE,
|
|
130
205
|
message: `Cannot write to unknown state ${stateId}`,
|
|
131
206
|
severity: 'error',
|
|
132
207
|
nodeId: stateId,
|
|
@@ -135,7 +210,7 @@ export function createAxiomRuntime(options) {
|
|
|
135
210
|
}
|
|
136
211
|
if (statesById.get(stateId)?.derivation) {
|
|
137
212
|
report({
|
|
138
|
-
code:
|
|
213
|
+
code: RUNTIME_DIAGNOSTIC_CODES.DERIVED_STATE_WRITE,
|
|
139
214
|
message: `${stateId} is derived state and cannot be written to`,
|
|
140
215
|
severity: 'error',
|
|
141
216
|
nodeId: stateId,
|
|
@@ -189,6 +264,28 @@ export function createAxiomRuntime(options) {
|
|
|
189
264
|
}
|
|
190
265
|
}
|
|
191
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
|
+
}
|
|
192
289
|
/** Applies a mutation inside the current transaction and reports resolution failures. */
|
|
193
290
|
function mutate(apply, context, failures) {
|
|
194
291
|
try {
|
|
@@ -196,7 +293,7 @@ export function createAxiomRuntime(options) {
|
|
|
196
293
|
}
|
|
197
294
|
catch (error) {
|
|
198
295
|
const failure = {
|
|
199
|
-
code: error instanceof LocationResolutionError ?
|
|
296
|
+
code: error instanceof LocationResolutionError ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED : 'MUTATION_FAILED',
|
|
200
297
|
message: error instanceof Error ? error.message : String(error),
|
|
201
298
|
severity: 'error',
|
|
202
299
|
...(context.sourceNodeId ? { nodeId: context.sourceNodeId } : {}),
|
|
@@ -242,13 +339,7 @@ export function createAxiomRuntime(options) {
|
|
|
242
339
|
if (statesById.has(expression.targetId)) {
|
|
243
340
|
return readState(expression.targetId);
|
|
244
341
|
}
|
|
245
|
-
|
|
246
|
-
code: 'UNRESOLVED_REFERENCE',
|
|
247
|
-
message: `Reference ${expression.targetId} could not be resolved`,
|
|
248
|
-
severity: 'error',
|
|
249
|
-
nodeId: expression.targetId,
|
|
250
|
-
});
|
|
251
|
-
return null;
|
|
342
|
+
throw new ExpressionEvaluationError(`Reference ${expression.targetId} could not be resolved`, { targetId: expression.targetId });
|
|
252
343
|
}
|
|
253
344
|
case 'field': {
|
|
254
345
|
const source = evaluate(expression.source, scope);
|
|
@@ -274,27 +365,42 @@ export function createAxiomRuntime(options) {
|
|
|
274
365
|
case 'call':
|
|
275
366
|
return evaluateCall(expression.function, expression.arguments, scope);
|
|
276
367
|
case 'filter': {
|
|
277
|
-
const source = evaluate(expression.source, scope);
|
|
278
|
-
if (!Array.isArray(source)) {
|
|
279
|
-
return [];
|
|
280
|
-
}
|
|
368
|
+
const source = requireCollection(evaluate(expression.source, scope), 'filter');
|
|
281
369
|
return source.filter((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
282
370
|
}
|
|
371
|
+
case 'map': {
|
|
372
|
+
const source = requireCollection(evaluate(expression.source, scope), 'map');
|
|
373
|
+
return source.map((item) => evaluate(expression.projection, childScope(scope, expression.scopeId, item)));
|
|
374
|
+
}
|
|
375
|
+
case 'sort': {
|
|
376
|
+
const source = requireCollection(evaluate(expression.source, scope), 'sort');
|
|
377
|
+
const direction = expression.direction === 'desc' ? -1 : 1;
|
|
378
|
+
const key = (item) => evaluate(expression.by, childScope(scope, expression.scopeId, item));
|
|
379
|
+
return [...source].sort((left, right) => direction * compareValues(key(left), key(right)));
|
|
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);
|
|
283
397
|
case 'find': {
|
|
284
|
-
const source = evaluate(expression.source, scope);
|
|
285
|
-
if (!Array.isArray(source)) {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
398
|
+
const source = requireCollection(evaluate(expression.source, scope), 'find');
|
|
288
399
|
const found = source.find((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
289
400
|
return found === undefined ? null : found;
|
|
290
401
|
}
|
|
291
402
|
default:
|
|
292
|
-
|
|
293
|
-
code: 'UNKNOWN_EXPRESSION',
|
|
294
|
-
message: `Unknown expression kind "${expression.kind}"`,
|
|
295
|
-
severity: 'error',
|
|
296
|
-
});
|
|
297
|
-
return null;
|
|
403
|
+
throw new ExpressionEvaluationError(`Unknown expression kind "${expression.kind}"`, { kind: expression.kind });
|
|
298
404
|
}
|
|
299
405
|
}
|
|
300
406
|
function evaluateBinary(operator, leftExpression, rightExpression, scope) {
|
|
@@ -330,21 +436,36 @@ export function createAxiomRuntime(options) {
|
|
|
330
436
|
return divisor === 0 ? null : Number(left ?? 0) / divisor;
|
|
331
437
|
}
|
|
332
438
|
default:
|
|
333
|
-
|
|
334
|
-
return null;
|
|
439
|
+
throw new ExpressionEvaluationError(`Unknown operator "${operator}"`, { operator });
|
|
335
440
|
}
|
|
336
441
|
}
|
|
337
442
|
function evaluateCall(fn, args, scope) {
|
|
338
443
|
const values = args.map((argument) => evaluate(argument, scope));
|
|
339
444
|
switch (fn) {
|
|
340
445
|
case 'required':
|
|
446
|
+
// Presence only: an empty collection or string exists, and so does 0 and false.
|
|
341
447
|
return isPresent(values[0]);
|
|
342
448
|
case 'is-empty':
|
|
343
|
-
return
|
|
449
|
+
return isEmptyValue(values[0]);
|
|
450
|
+
case 'non-empty':
|
|
451
|
+
return !isEmptyValue(values[0]);
|
|
344
452
|
case 'length':
|
|
345
453
|
return Array.isArray(values[0]) ? values[0].length : toText(values[0]).length;
|
|
346
454
|
case 'count':
|
|
347
|
-
return
|
|
455
|
+
return requireCollection(values[0], 'count').length;
|
|
456
|
+
case 'sum': {
|
|
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');
|
|
460
|
+
let total = 0;
|
|
461
|
+
for (const member of source) {
|
|
462
|
+
if (typeof member !== 'number' || !Number.isFinite(member)) {
|
|
463
|
+
throw new ExpressionEvaluationError(`sum encountered ${describeValue(member)} where a number was required`, { member });
|
|
464
|
+
}
|
|
465
|
+
total += member;
|
|
466
|
+
}
|
|
467
|
+
return total;
|
|
468
|
+
}
|
|
348
469
|
case 'contains': {
|
|
349
470
|
const [haystack, needle] = values;
|
|
350
471
|
if (Array.isArray(haystack)) {
|
|
@@ -355,6 +476,7 @@ export function createAxiomRuntime(options) {
|
|
|
355
476
|
case 'concat':
|
|
356
477
|
return values.map(toText).join('');
|
|
357
478
|
case 'coalesce':
|
|
479
|
+
// Nullish, not "non-empty": falling back to an empty collection has to be possible.
|
|
358
480
|
return values.find((value) => isPresent(value)) ?? null;
|
|
359
481
|
case 'one-of':
|
|
360
482
|
return values.slice(1).some((option) => valuesEqual(option, values[0]));
|
|
@@ -367,8 +489,7 @@ export function createAxiomRuntime(options) {
|
|
|
367
489
|
case 'uuid':
|
|
368
490
|
return host.uuid();
|
|
369
491
|
default:
|
|
370
|
-
|
|
371
|
-
return null;
|
|
492
|
+
throw new ExpressionEvaluationError(`Unknown function "${fn}"`, { function: fn });
|
|
372
493
|
}
|
|
373
494
|
}
|
|
374
495
|
// -------------------------------------------------------------- validation
|
|
@@ -380,32 +501,110 @@ export function createAxiomRuntime(options) {
|
|
|
380
501
|
}
|
|
381
502
|
return resolved.kind === 'entity' ? resolved.entityId : null;
|
|
382
503
|
}
|
|
383
|
-
|
|
384
|
-
|
|
504
|
+
/**
|
|
505
|
+
* Every canonical occurrence of every entity, found by walking state values against
|
|
506
|
+
* their declared types. Entities nested inside collections and inside other entities
|
|
507
|
+
* are reached recursively, so their constraints apply wherever they actually live.
|
|
508
|
+
*/
|
|
509
|
+
function collectInstances(read = readState) {
|
|
510
|
+
const found = new Map();
|
|
511
|
+
const visit = (value, type) => {
|
|
512
|
+
const resolved = unwrapType(type);
|
|
513
|
+
if (resolved.kind === 'collection') {
|
|
514
|
+
if (Array.isArray(value)) {
|
|
515
|
+
for (const item of value) {
|
|
516
|
+
visit(item, resolved.itemType);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (resolved.kind !== 'entity' || !isRecord(value)) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const instances = found.get(resolved.entityId) ?? [];
|
|
525
|
+
instances.push(value);
|
|
526
|
+
found.set(resolved.entityId, instances);
|
|
527
|
+
for (const field of entitiesById.get(resolved.entityId)?.fields ?? []) {
|
|
528
|
+
visit(value[field.id], field.valueType);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
385
531
|
for (const state of ir.states) {
|
|
386
532
|
// Drafts are incomplete by definition, and derived states are views of data that
|
|
387
533
|
// is already validated where it is stored.
|
|
388
534
|
if (state.draft || state.derivation) {
|
|
389
535
|
continue;
|
|
390
536
|
}
|
|
391
|
-
|
|
537
|
+
visit(read(state.id), state.valueType);
|
|
538
|
+
}
|
|
539
|
+
return found;
|
|
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) {
|
|
392
558
|
continue;
|
|
393
559
|
}
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
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);
|
|
397
564
|
}
|
|
398
|
-
|
|
399
|
-
|
|
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);
|
|
400
596
|
}
|
|
401
597
|
}
|
|
402
|
-
return
|
|
598
|
+
return failures;
|
|
599
|
+
}
|
|
600
|
+
function instancesOf(entityId) {
|
|
601
|
+
return collectInstances().get(entityId) ?? [];
|
|
403
602
|
}
|
|
404
603
|
function checkFieldValue(field, value, entityId) {
|
|
405
604
|
if (!isPresent(value)) {
|
|
406
605
|
if (field.required) {
|
|
407
606
|
return {
|
|
408
|
-
code:
|
|
607
|
+
code: RUNTIME_DIAGNOSTIC_CODES.REQUIRED_FIELD_MISSING,
|
|
409
608
|
message: `${field.name ?? field.id} is required`,
|
|
410
609
|
severity: 'error',
|
|
411
610
|
nodeId: entityId,
|
|
@@ -417,7 +616,7 @@ export function createAxiomRuntime(options) {
|
|
|
417
616
|
const resolved = unwrapType(field.valueType);
|
|
418
617
|
if (resolved.kind === 'enum' && !resolved.values.includes(toText(value))) {
|
|
419
618
|
return {
|
|
420
|
-
code:
|
|
619
|
+
code: RUNTIME_DIAGNOSTIC_CODES.ENUM_VALUE_INVALID,
|
|
421
620
|
message: `${field.name ?? field.id} must be one of: ${resolved.values.join(', ')}`,
|
|
422
621
|
severity: 'error',
|
|
423
622
|
nodeId: entityId,
|
|
@@ -426,7 +625,7 @@ export function createAxiomRuntime(options) {
|
|
|
426
625
|
}
|
|
427
626
|
if (resolved.kind === 'primitive' && resolved.primitive === 'number' && typeof value !== 'number') {
|
|
428
627
|
return {
|
|
429
|
-
code:
|
|
628
|
+
code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
|
|
430
629
|
message: `${field.name ?? field.id} must be a number`,
|
|
431
630
|
severity: 'error',
|
|
432
631
|
nodeId: entityId,
|
|
@@ -435,7 +634,7 @@ export function createAxiomRuntime(options) {
|
|
|
435
634
|
}
|
|
436
635
|
if (resolved.kind === 'primitive' && resolved.primitive === 'boolean' && typeof value !== 'boolean') {
|
|
437
636
|
return {
|
|
438
|
-
code:
|
|
637
|
+
code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
|
|
439
638
|
message: `${field.name ?? field.id} must be a boolean`,
|
|
440
639
|
severity: 'error',
|
|
441
640
|
nodeId: entityId,
|
|
@@ -444,11 +643,15 @@ export function createAxiomRuntime(options) {
|
|
|
444
643
|
}
|
|
445
644
|
return null;
|
|
446
645
|
}
|
|
447
|
-
/**
|
|
646
|
+
/**
|
|
647
|
+
* Schema conformance plus declared constraints, evaluated over every canonical instance
|
|
648
|
+
* — including instances nested inside collections and inside other entities.
|
|
649
|
+
*/
|
|
448
650
|
function evaluateInvariants() {
|
|
449
651
|
const failures = [];
|
|
652
|
+
const instances = collectInstances();
|
|
450
653
|
for (const entity of ir.entities) {
|
|
451
|
-
for (const instance of
|
|
654
|
+
for (const instance of instances.get(entity.id) ?? []) {
|
|
452
655
|
if (!isRecord(instance)) {
|
|
453
656
|
continue;
|
|
454
657
|
}
|
|
@@ -461,7 +664,7 @@ export function createAxiomRuntime(options) {
|
|
|
461
664
|
}
|
|
462
665
|
}
|
|
463
666
|
for (const constraint of ir.constraints) {
|
|
464
|
-
failures.push(...evaluateConstraint(constraint));
|
|
667
|
+
failures.push(...evaluateConstraint(constraint, instances));
|
|
465
668
|
}
|
|
466
669
|
return failures;
|
|
467
670
|
}
|
|
@@ -503,39 +706,81 @@ export function createAxiomRuntime(options) {
|
|
|
503
706
|
}
|
|
504
707
|
return introduced;
|
|
505
708
|
}
|
|
506
|
-
|
|
709
|
+
/**
|
|
710
|
+
* An entity-scoped constraint is evaluated once per instance, with `ref(entityId)`
|
|
711
|
+
* bound to the instance under validation.
|
|
712
|
+
*/
|
|
713
|
+
function evaluateConstraint(constraint, instances) {
|
|
507
714
|
const severity = constraint.severity ?? 'error';
|
|
508
715
|
const failures = [];
|
|
509
|
-
const record = () => {
|
|
716
|
+
const record = (instance) => {
|
|
510
717
|
failures.push({
|
|
511
|
-
code:
|
|
718
|
+
code: RUNTIME_DIAGNOSTIC_CODES.CONSTRAINT_VIOLATION,
|
|
512
719
|
message: constraint.message ?? `Constraint ${constraint.name ?? constraint.id} failed`,
|
|
513
720
|
severity,
|
|
514
721
|
nodeId: constraint.id,
|
|
722
|
+
constraintId: constraint.id,
|
|
723
|
+
...(constraint.entityId ? { details: { entityId: constraint.entityId, instance } } : {}),
|
|
515
724
|
});
|
|
516
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
|
+
};
|
|
517
738
|
if (!constraint.entityId) {
|
|
518
|
-
if (!
|
|
739
|
+
if (!holds(rootScope())) {
|
|
519
740
|
record();
|
|
520
741
|
}
|
|
521
742
|
return failures;
|
|
522
743
|
}
|
|
523
|
-
for (const instance of
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
record();
|
|
744
|
+
for (const instance of instances.get(constraint.entityId) ?? []) {
|
|
745
|
+
if (!holds(childScope(rootScope(), constraint.entityId, instance))) {
|
|
746
|
+
record(instance);
|
|
527
747
|
}
|
|
528
748
|
}
|
|
529
749
|
return failures;
|
|
530
750
|
}
|
|
531
751
|
// --------------------------------------------------------------- behaviour
|
|
532
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) {
|
|
533
764
|
switch (operation.kind) {
|
|
534
765
|
case 'set':
|
|
535
766
|
case 'insert':
|
|
536
767
|
case 'remove':
|
|
537
768
|
mutate(() => mutations.apply(operation, scope, context), context, result);
|
|
538
769
|
return;
|
|
770
|
+
case 'for-each': {
|
|
771
|
+
// The members are read once, before any of them are mutated, so the iteration
|
|
772
|
+
// walks the collection as it stood when the operation began. Nothing here opens
|
|
773
|
+
// a transaction: these mutations belong to the action's own transaction, and a
|
|
774
|
+
// failure in any iteration rolls back every iteration with it.
|
|
775
|
+
const members = requireCollection(evaluate(operation.collection, scope), 'for-each');
|
|
776
|
+
for (const member of members) {
|
|
777
|
+
const iteration = childScope(scope, operation.scopeId, member);
|
|
778
|
+
for (const nested of operation.operations ?? []) {
|
|
779
|
+
executeOperation(nested, iteration, context, result);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
539
784
|
case 'invoke': {
|
|
540
785
|
const args = {};
|
|
541
786
|
for (const [parameterId, argument] of Object.entries(operation.arguments ?? {})) {
|
|
@@ -553,7 +798,7 @@ export function createAxiomRuntime(options) {
|
|
|
553
798
|
const route = ir.routes.find((candidate) => candidate.id === operation.routeId);
|
|
554
799
|
if (!route) {
|
|
555
800
|
result.push({
|
|
556
|
-
code:
|
|
801
|
+
code: RUNTIME_DIAGNOSTIC_CODES.ROUTE_NOT_FOUND,
|
|
557
802
|
message: `Navigate operation could not resolve route ${String(operation.routeId)}`,
|
|
558
803
|
severity: 'error',
|
|
559
804
|
});
|
|
@@ -570,7 +815,7 @@ export function createAxiomRuntime(options) {
|
|
|
570
815
|
const implementation = natives.get(operation.implementationId);
|
|
571
816
|
if (!implementation) {
|
|
572
817
|
result.push({
|
|
573
|
-
code:
|
|
818
|
+
code: RUNTIME_DIAGNOSTIC_CODES.NATIVE_OPERATION_MISSING,
|
|
574
819
|
message: `No implementation registered for "${operation.implementationId}"`,
|
|
575
820
|
severity: 'error',
|
|
576
821
|
});
|
|
@@ -590,22 +835,25 @@ export function createAxiomRuntime(options) {
|
|
|
590
835
|
}
|
|
591
836
|
default:
|
|
592
837
|
result.push({
|
|
593
|
-
code:
|
|
838
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_OPERATION,
|
|
594
839
|
message: `Unknown operation kind "${operation.kind}"`,
|
|
595
840
|
severity: 'error',
|
|
596
841
|
});
|
|
597
842
|
}
|
|
598
843
|
}
|
|
599
844
|
function runAction(actionId, args = {}) {
|
|
845
|
+
return collecting((collected) => runActionCollecting(actionId, args, collected));
|
|
846
|
+
}
|
|
847
|
+
function runActionCollecting(actionId, args, collected) {
|
|
600
848
|
const action = ir.actions[actionId];
|
|
601
849
|
if (!action) {
|
|
602
850
|
const failure = {
|
|
603
|
-
code:
|
|
851
|
+
code: RUNTIME_DIAGNOSTIC_CODES.ACTION_NOT_FOUND,
|
|
604
852
|
message: `Action ${actionId} is not defined`,
|
|
605
853
|
severity: 'error',
|
|
606
854
|
};
|
|
607
855
|
report(failure);
|
|
608
|
-
return { ok: false, diagnostics: [
|
|
856
|
+
return { ok: false, diagnostics: [...collected] };
|
|
609
857
|
}
|
|
610
858
|
const scope = rootScope();
|
|
611
859
|
for (const parameter of action.parameters ?? []) {
|
|
@@ -615,7 +863,7 @@ export function createAxiomRuntime(options) {
|
|
|
615
863
|
for (const parameter of action.parameters ?? []) {
|
|
616
864
|
if (parameter.required && !isPresent(scope.values.get(parameter.id))) {
|
|
617
865
|
failures.push({
|
|
618
|
-
code:
|
|
866
|
+
code: RUNTIME_DIAGNOSTIC_CODES.PARAMETER_MISSING,
|
|
619
867
|
message: `Action ${action.name ?? action.id} requires ${parameter.name ?? parameter.id}`,
|
|
620
868
|
severity: 'error',
|
|
621
869
|
nodeId: action.id,
|
|
@@ -624,25 +872,34 @@ export function createAxiomRuntime(options) {
|
|
|
624
872
|
}
|
|
625
873
|
if (failures.length > 0) {
|
|
626
874
|
failures.forEach(report);
|
|
627
|
-
return { ok: false, diagnostics:
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return { ok: false, diagnostics: [failure] };
|
|
875
|
+
return { ok: false, diagnostics: [...collected] };
|
|
876
|
+
}
|
|
877
|
+
// Failure modes line up with preconditions by position, so a refusal says which
|
|
878
|
+
// condition was not met rather than always naming the first one.
|
|
879
|
+
for (const [index, precondition] of (action.preconditions ?? []).entries()) {
|
|
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)) {
|
|
886
|
+
continue;
|
|
640
887
|
}
|
|
888
|
+
const mode = action.failureModes?.[index];
|
|
889
|
+
report({
|
|
890
|
+
code: RUNTIME_DIAGNOSTIC_CODES.PRECONDITION_FAILED,
|
|
891
|
+
message: mode?.message ?? `A precondition of ${action.name ?? action.id} was not satisfied`,
|
|
892
|
+
severity: 'error',
|
|
893
|
+
nodeId: action.id,
|
|
894
|
+
actionId: action.id,
|
|
895
|
+
details: { preconditionIndex: index, ...(mode?.code ? { failureMode: mode.code } : {}) },
|
|
896
|
+
});
|
|
897
|
+
return { ok: false, diagnostics: [...collected] };
|
|
641
898
|
}
|
|
642
899
|
if (action.requiresConfirmation) {
|
|
643
900
|
const message = action.confirmationMessage ?? `Confirm ${action.name ?? action.id}. This cannot be undone.`;
|
|
644
901
|
if (!host.confirm(message)) {
|
|
645
|
-
return { ok: false, diagnostics: [] };
|
|
902
|
+
return { ok: false, diagnostics: [...collected] };
|
|
646
903
|
}
|
|
647
904
|
}
|
|
648
905
|
const transaction = transactions.begin();
|
|
@@ -652,32 +909,46 @@ export function createAxiomRuntime(options) {
|
|
|
652
909
|
transactionId: transaction.id,
|
|
653
910
|
};
|
|
654
911
|
const operationDiagnostics = [];
|
|
912
|
+
const reportedBefore = collected.length;
|
|
655
913
|
for (const operation of action.operations ?? []) {
|
|
656
914
|
executeOperation(operation, scope, context, operationDiagnostics);
|
|
657
915
|
}
|
|
916
|
+
// Anything reported while the operations ran — an expression that could not be
|
|
917
|
+
// evaluated, for instance — is a failure of this action, not a passing curiosity.
|
|
658
918
|
const violations = [
|
|
659
919
|
...operationDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'),
|
|
920
|
+
...collected.slice(reportedBefore).filter((diagnostic) => diagnostic.severity === 'error'),
|
|
660
921
|
...evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error'),
|
|
922
|
+
...evaluateTransitions().filter((diagnostic) => diagnostic.severity === 'error'),
|
|
661
923
|
];
|
|
662
924
|
for (const postcondition of action.postconditions ?? []) {
|
|
663
|
-
|
|
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
|
+
}
|
|
664
930
|
violations.push({
|
|
665
|
-
code:
|
|
931
|
+
code: RUNTIME_DIAGNOSTIC_CODES.POSTCONDITION_FAILED,
|
|
666
932
|
message: `A postcondition of ${action.name ?? action.id} was not satisfied`,
|
|
667
933
|
severity: 'error',
|
|
668
934
|
nodeId: action.id,
|
|
935
|
+
actionId: action.id,
|
|
669
936
|
});
|
|
670
937
|
}
|
|
671
938
|
}
|
|
672
939
|
if (violations.length > 0) {
|
|
673
940
|
settle(transaction, 'rolled-back');
|
|
674
|
-
violations
|
|
941
|
+
for (const violation of violations) {
|
|
942
|
+
if (!collected.includes(violation)) {
|
|
943
|
+
report({ ...violation, actionId: action.id, transactionId: transaction.id });
|
|
944
|
+
}
|
|
945
|
+
}
|
|
675
946
|
renderApplication();
|
|
676
|
-
return { ok: false, diagnostics:
|
|
947
|
+
return { ok: false, diagnostics: [...collected] };
|
|
677
948
|
}
|
|
678
949
|
settle(transaction, 'committed');
|
|
679
950
|
renderApplication();
|
|
680
|
-
return { ok: true, diagnostics:
|
|
951
|
+
return { ok: true, diagnostics: [...collected] };
|
|
681
952
|
}
|
|
682
953
|
// ------------------------------------------------------------------ routing
|
|
683
954
|
function buildPath(route, values) {
|
|
@@ -838,10 +1109,19 @@ export function createAxiomRuntime(options) {
|
|
|
838
1109
|
return raw;
|
|
839
1110
|
}
|
|
840
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) {
|
|
841
1121
|
const node = ir.uiNodes[id];
|
|
842
1122
|
if (!node) {
|
|
843
1123
|
report({
|
|
844
|
-
code:
|
|
1124
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UI_NODE_MISSING,
|
|
845
1125
|
message: `UI node ${id} is not defined`,
|
|
846
1126
|
severity: 'error',
|
|
847
1127
|
nodeId: id,
|
|
@@ -983,15 +1263,21 @@ export function createAxiomRuntime(options) {
|
|
|
983
1263
|
failures.forEach(report);
|
|
984
1264
|
}
|
|
985
1265
|
else {
|
|
986
|
-
|
|
987
|
-
|
|
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) {
|
|
988
1273
|
settle(transaction, 'rolled-back');
|
|
989
|
-
|
|
1274
|
+
rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
|
|
990
1275
|
report({
|
|
991
|
-
code:
|
|
992
|
-
message: `${node.label ?? node.id} kept its previous value: ${
|
|
1276
|
+
code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
|
|
1277
|
+
message: `${node.label ?? node.id} kept its previous value: ${rejected[0].message}`,
|
|
993
1278
|
severity: 'warning',
|
|
994
1279
|
nodeId: node.id,
|
|
1280
|
+
details: { source: 'input' },
|
|
995
1281
|
});
|
|
996
1282
|
}
|
|
997
1283
|
else {
|
|
@@ -1036,7 +1322,7 @@ export function createAxiomRuntime(options) {
|
|
|
1036
1322
|
}
|
|
1037
1323
|
default:
|
|
1038
1324
|
report({
|
|
1039
|
-
code:
|
|
1325
|
+
code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_UI_NODE,
|
|
1040
1326
|
message: `Unknown UI node kind "${node.kind}"`,
|
|
1041
1327
|
severity: 'error',
|
|
1042
1328
|
});
|
|
@@ -1093,7 +1379,7 @@ export function createAxiomRuntime(options) {
|
|
|
1093
1379
|
getState(id) {
|
|
1094
1380
|
return cloneValue(readState(id));
|
|
1095
1381
|
},
|
|
1096
|
-
|
|
1382
|
+
hydrateState(id, value) {
|
|
1097
1383
|
const transaction = transactions.begin();
|
|
1098
1384
|
const failures = [];
|
|
1099
1385
|
const context = { source: 'system', transactionId: transaction.id };
|
|
@@ -1117,6 +1403,9 @@ export function createAxiomRuntime(options) {
|
|
|
1117
1403
|
diagnostics() {
|
|
1118
1404
|
return diagnostics.map((diagnostic) => ({ ...diagnostic }));
|
|
1119
1405
|
},
|
|
1406
|
+
clearDiagnostics() {
|
|
1407
|
+
diagnostics.length = 0;
|
|
1408
|
+
},
|
|
1120
1409
|
getMutationLog() {
|
|
1121
1410
|
return mutationLog.map((entry) => ({ ...entry }));
|
|
1122
1411
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cynodia/axiom-runtime",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
34
|
+
"@cynodia/axiom-core": "0.4.1-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|