@cynodia/axiom-runtime 0.5.2-alpha.1 → 0.6.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/runtime.d.ts +63 -2
- package/dist/runtime.js +138 -4
- package/package.json +2 -2
package/dist/runtime.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApplicationIR, CompiledRoute, FieldId, Location, NodeId } from '@cynodia/axiom-core';
|
|
1
|
+
import type { ApplicationIR, CompiledRoute, Expression, 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
4
|
/**
|
|
@@ -29,6 +29,10 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
|
|
|
29
29
|
readonly UNSUPPORTED_UI_NODE: "UNSUPPORTED_UI_NODE";
|
|
30
30
|
readonly INPUT_REJECTED: "INPUT_REJECTED";
|
|
31
31
|
readonly PERSISTED_STATE_UNREADABLE: "PERSISTED_STATE_UNREADABLE";
|
|
32
|
+
/** A local write was attempted against state whose authority is the server. */
|
|
33
|
+
readonly SERVER_STATE_WRITE: "SERVER_STATE_WRITE";
|
|
34
|
+
/** An action belongs to the authority, but no gateway to it was configured. */
|
|
35
|
+
readonly REMOTE_ACTION_UNAVAILABLE: "REMOTE_ACTION_UNAVAILABLE";
|
|
32
36
|
};
|
|
33
37
|
export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
|
|
34
38
|
/**
|
|
@@ -52,6 +56,34 @@ export interface RuntimeDiagnostic {
|
|
|
52
56
|
export interface ActionResult {
|
|
53
57
|
ok: boolean;
|
|
54
58
|
diagnostics: RuntimeDiagnostic[];
|
|
59
|
+
/**
|
|
60
|
+
* Set when the invocation was dispatched to the authority. `ok` is not yet meaningful:
|
|
61
|
+
* the outcome arrives later, and reaches the interface through the action's recorded
|
|
62
|
+
* outcome and any `diagnostic` node presenting it.
|
|
63
|
+
*/
|
|
64
|
+
pending?: true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* How a client reaches the authority.
|
|
68
|
+
*
|
|
69
|
+
* The client requests **semantic actions**; it never sends operations. A remote invocation
|
|
70
|
+
* is dispatched and answered later, so `invokeAction` returns `pending` and the outcome
|
|
71
|
+
* arrives through the same diagnostic lifecycle a local refusal uses.
|
|
72
|
+
*/
|
|
73
|
+
export interface RemoteGateway {
|
|
74
|
+
invoke(request: {
|
|
75
|
+
actionId: NodeId;
|
|
76
|
+
arguments: Record<string, unknown>;
|
|
77
|
+
requestId: string;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
ok: boolean;
|
|
80
|
+
diagnostics: RuntimeDiagnostic[];
|
|
81
|
+
changes: Record<NodeId, unknown>;
|
|
82
|
+
}>;
|
|
83
|
+
/** The authoritative values of every observable state. */
|
|
84
|
+
snapshot?(): Promise<{
|
|
85
|
+
states: Record<NodeId, unknown>;
|
|
86
|
+
}>;
|
|
55
87
|
}
|
|
56
88
|
/**
|
|
57
89
|
* The outcome of an action's most recent invocation.
|
|
@@ -63,7 +95,8 @@ export interface ActionResult {
|
|
|
63
95
|
*/
|
|
64
96
|
export interface ActionOutcome {
|
|
65
97
|
actionId: NodeId;
|
|
66
|
-
outcome
|
|
98
|
+
/** `pending` means the request is with the authority and the outcome is not yet known. */
|
|
99
|
+
outcome: 'ok' | 'failed' | 'cancelled' | 'pending';
|
|
67
100
|
diagnostics: RuntimeDiagnostic[];
|
|
68
101
|
}
|
|
69
102
|
export interface RouteMatch {
|
|
@@ -83,6 +116,11 @@ export interface AxiomRuntimeOptions {
|
|
|
83
116
|
rootElement: DomElement;
|
|
84
117
|
host: HostEnvironment;
|
|
85
118
|
nativeOperations?: Record<string, NativeImplementation>;
|
|
119
|
+
/**
|
|
120
|
+
* How to reach the authority. Required if the application has server-authoritative
|
|
121
|
+
* state; without it a remote invocation reports `REMOTE_ACTION_UNAVAILABLE`.
|
|
122
|
+
*/
|
|
123
|
+
remote?: RemoteGateway;
|
|
86
124
|
inputValidation?: InputValidationMode;
|
|
87
125
|
/** Records previous and next values in the mutation log. */
|
|
88
126
|
recordMutationValues?: boolean;
|
|
@@ -121,6 +159,29 @@ export interface AxiomRuntime {
|
|
|
121
159
|
/** Every mutation this runtime has applied, in order, with its semantic location. */
|
|
122
160
|
getMutationLog(): MutationLogEntry[];
|
|
123
161
|
registerNativeOperation(implementationId: string, implementation: NativeImplementation): void;
|
|
162
|
+
/**
|
|
163
|
+
* Invokes an action and waits for its outcome. For a remote action this awaits the
|
|
164
|
+
* authority's answer; for a local one it is `invokeAction` in promise form.
|
|
165
|
+
*/
|
|
166
|
+
invokeActionAsync(id: NodeId, args?: Record<string, unknown>): Promise<ActionResult>;
|
|
167
|
+
/**
|
|
168
|
+
* Loads the authoritative snapshot and applies it. Called by `start()` when a gateway
|
|
169
|
+
* provides one.
|
|
170
|
+
*/
|
|
171
|
+
syncAuthoritativeState(): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Evaluates an expression in the root scope, reporting rather than throwing. It is a
|
|
174
|
+
* pure read: an expression cannot change state.
|
|
175
|
+
*
|
|
176
|
+
* An authority uses it to evaluate an authorization rule before opening a transaction.
|
|
177
|
+
*/
|
|
178
|
+
evaluate(expression: Expression): {
|
|
179
|
+
ok: true;
|
|
180
|
+
value: unknown;
|
|
181
|
+
} | {
|
|
182
|
+
ok: false;
|
|
183
|
+
diagnostic: RuntimeDiagnostic;
|
|
184
|
+
};
|
|
124
185
|
}
|
|
125
186
|
export declare function createAxiomRuntime(options: AxiomRuntimeOptions): AxiomRuntime;
|
|
126
187
|
/** Builds a host bound to the browser globals. Used by generated pages. */
|
package/dist/runtime.js
CHANGED
|
@@ -33,6 +33,10 @@ export const RUNTIME_DIAGNOSTIC_CODES = {
|
|
|
33
33
|
UNSUPPORTED_UI_NODE: 'UNSUPPORTED_UI_NODE',
|
|
34
34
|
INPUT_REJECTED: 'INPUT_REJECTED',
|
|
35
35
|
PERSISTED_STATE_UNREADABLE: 'PERSISTED_STATE_UNREADABLE',
|
|
36
|
+
/** A local write was attempted against state whose authority is the server. */
|
|
37
|
+
SERVER_STATE_WRITE: 'SERVER_STATE_WRITE',
|
|
38
|
+
/** An action belongs to the authority, but no gateway to it was configured. */
|
|
39
|
+
REMOTE_ACTION_UNAVAILABLE: 'REMOTE_ACTION_UNAVAILABLE',
|
|
36
40
|
};
|
|
37
41
|
const MISSING = Symbol('missing');
|
|
38
42
|
function unwrapType(type) {
|
|
@@ -99,6 +103,15 @@ export function createAxiomRuntime(options) {
|
|
|
99
103
|
let transactionCounter = 0;
|
|
100
104
|
const mutationLog = [];
|
|
101
105
|
const inputValidation = options.inputValidation ?? 'immediate';
|
|
106
|
+
const remote = options.remote;
|
|
107
|
+
const remoteActionIds = new Set(ir.remoteActionIds ?? []);
|
|
108
|
+
/**
|
|
109
|
+
* Set only while an authoritative answer is being applied. The authority owns the value;
|
|
110
|
+
* every other path is refused, which is what makes the boundary structural rather than a
|
|
111
|
+
* convention about where inputs are bound.
|
|
112
|
+
*/
|
|
113
|
+
let applyingAuthoritative = false;
|
|
114
|
+
let remoteRequests = 0;
|
|
102
115
|
const theme = ir.theme;
|
|
103
116
|
const locale = theme?.locale ?? 'en-US';
|
|
104
117
|
/**
|
|
@@ -272,6 +285,18 @@ export function createAxiomRuntime(options) {
|
|
|
272
285
|
}
|
|
273
286
|
/** The only place the store is written. Values are frozen on the way in. */
|
|
274
287
|
function writeState(stateId, value) {
|
|
288
|
+
if (!applyingAuthoritative && ir.authority?.[stateId] === 'server') {
|
|
289
|
+
// Whatever the path — an action, an input, an administrative hydrate — a client does
|
|
290
|
+
// not commit state the authority owns.
|
|
291
|
+
report({
|
|
292
|
+
code: RUNTIME_DIAGNOSTIC_CODES.SERVER_STATE_WRITE,
|
|
293
|
+
message: `${stateId} is server-authoritative and cannot be written by this client`,
|
|
294
|
+
severity: 'error',
|
|
295
|
+
nodeId: stateId,
|
|
296
|
+
stateId: stateId,
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
275
300
|
if (!statesById.has(stateId)) {
|
|
276
301
|
report({
|
|
277
302
|
code: RUNTIME_DIAGNOSTIC_CODES.UNKNOWN_STATE,
|
|
@@ -501,19 +526,26 @@ export function createAxiomRuntime(options) {
|
|
|
501
526
|
}
|
|
502
527
|
const left = evaluate(leftExpression, scope);
|
|
503
528
|
const right = evaluate(rightExpression, scope);
|
|
529
|
+
/**
|
|
530
|
+
* A number that is not finite has no place in an ordering. Every ordered comparison
|
|
531
|
+
* against one is false, so a guard fails closed rather than passing on a value that
|
|
532
|
+
* could not be computed — or one a hostile caller supplied.
|
|
533
|
+
*/
|
|
534
|
+
const unordered = (typeof left === 'number' && !Number.isFinite(left)) ||
|
|
535
|
+
(typeof right === 'number' && !Number.isFinite(right));
|
|
504
536
|
switch (operator) {
|
|
505
537
|
case 'eq':
|
|
506
538
|
return valuesEqual(left, right);
|
|
507
539
|
case 'neq':
|
|
508
540
|
return !valuesEqual(left, right);
|
|
509
541
|
case 'gt':
|
|
510
|
-
return compareValues(left, right) > 0;
|
|
542
|
+
return !unordered && compareValues(left, right) > 0;
|
|
511
543
|
case 'gte':
|
|
512
|
-
return compareValues(left, right) >= 0;
|
|
544
|
+
return !unordered && compareValues(left, right) >= 0;
|
|
513
545
|
case 'lt':
|
|
514
|
-
return compareValues(left, right) < 0;
|
|
546
|
+
return !unordered && compareValues(left, right) < 0;
|
|
515
547
|
case 'lte':
|
|
516
|
-
return compareValues(left, right) <= 0;
|
|
548
|
+
return !unordered && compareValues(left, right) <= 0;
|
|
517
549
|
case 'add':
|
|
518
550
|
return Number(left ?? 0) + Number(right ?? 0);
|
|
519
551
|
case 'subtract':
|
|
@@ -930,7 +962,90 @@ export function createAxiomRuntime(options) {
|
|
|
930
962
|
});
|
|
931
963
|
}
|
|
932
964
|
}
|
|
965
|
+
/**
|
|
966
|
+
* Applies an authoritative answer. The only path permitted to write server-owned state —
|
|
967
|
+
* and it still goes through `writeState`, so the store keeps exactly one writer.
|
|
968
|
+
*/
|
|
969
|
+
function applyAuthoritative(changes) {
|
|
970
|
+
applyingAuthoritative = true;
|
|
971
|
+
try {
|
|
972
|
+
for (const [stateId, value] of Object.entries(changes)) {
|
|
973
|
+
if (statesById.has(stateId)) {
|
|
974
|
+
writeState(stateId, cloneValue(value));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
finally {
|
|
979
|
+
applyingAuthoritative = false;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Dispatches a semantic action to the authority.
|
|
984
|
+
*
|
|
985
|
+
* The client sends an action id and typed arguments — never operations. The answer is
|
|
986
|
+
* applied when it arrives, and recorded through the same outcome lifecycle a local
|
|
987
|
+
* refusal uses, so a `diagnostic` node presents a server refusal exactly as it presents
|
|
988
|
+
* a local one.
|
|
989
|
+
*/
|
|
990
|
+
function runRemoteAction(action, args) {
|
|
991
|
+
if (!remote) {
|
|
992
|
+
const failure = {
|
|
993
|
+
code: RUNTIME_DIAGNOSTIC_CODES.REMOTE_ACTION_UNAVAILABLE,
|
|
994
|
+
message: `${action.name ?? action.id} executes on the authority, but no gateway to it is configured`,
|
|
995
|
+
severity: 'error',
|
|
996
|
+
nodeId: action.id,
|
|
997
|
+
actionId: action.id,
|
|
998
|
+
};
|
|
999
|
+
report(failure);
|
|
1000
|
+
recordOutcome(action.id, 'failed', [failure]);
|
|
1001
|
+
renderApplication();
|
|
1002
|
+
return { ok: false, diagnostics: [failure] };
|
|
1003
|
+
}
|
|
1004
|
+
if (action.requiresConfirmation && !askForConfirmation(action)) {
|
|
1005
|
+
// Confirmation is interaction, and it happens here. The authority never treats it as
|
|
1006
|
+
// an authorization mechanism.
|
|
1007
|
+
recordOutcome(action.id, 'cancelled', []);
|
|
1008
|
+
renderApplication();
|
|
1009
|
+
return { ok: false, diagnostics: [] };
|
|
1010
|
+
}
|
|
1011
|
+
remoteRequests += 1;
|
|
1012
|
+
// A stable key, so a retry after a lost answer cannot execute the action twice.
|
|
1013
|
+
const requestId = `${ir.id}:${action.id}:${remoteRequests}:${host.uuid()}`;
|
|
1014
|
+
recordOutcome(action.id, 'pending', []);
|
|
1015
|
+
renderApplication();
|
|
1016
|
+
const settle = remote
|
|
1017
|
+
.invoke({ actionId: action.id, arguments: args, requestId })
|
|
1018
|
+
.then((answer) => {
|
|
1019
|
+
applyAuthoritative(answer.changes ?? {});
|
|
1020
|
+
answer.diagnostics.forEach(report);
|
|
1021
|
+
recordOutcome(action.id, answer.ok ? 'ok' : 'failed', answer.ok ? [] : answer.diagnostics);
|
|
1022
|
+
renderApplication();
|
|
1023
|
+
return { ok: answer.ok, diagnostics: answer.diagnostics };
|
|
1024
|
+
})
|
|
1025
|
+
.catch((error) => {
|
|
1026
|
+
// A transport failure becomes a structured diagnostic, not an escaping exception.
|
|
1027
|
+
const failure = {
|
|
1028
|
+
code: RUNTIME_DIAGNOSTIC_CODES.REMOTE_ACTION_UNAVAILABLE,
|
|
1029
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1030
|
+
severity: 'error',
|
|
1031
|
+
nodeId: action.id,
|
|
1032
|
+
actionId: action.id,
|
|
1033
|
+
};
|
|
1034
|
+
report(failure);
|
|
1035
|
+
recordOutcome(action.id, 'failed', [failure]);
|
|
1036
|
+
renderApplication();
|
|
1037
|
+
return { ok: false, diagnostics: [failure] };
|
|
1038
|
+
});
|
|
1039
|
+
pending.set(action.id, settle);
|
|
1040
|
+
return { ok: false, pending: true, diagnostics: [] };
|
|
1041
|
+
}
|
|
1042
|
+
/** In-flight remote invocations, so `invokeActionAsync` can await one. */
|
|
1043
|
+
const pending = new Map();
|
|
933
1044
|
function runAction(actionId, args = {}) {
|
|
1045
|
+
const remoteAction = remoteActionIds.has(actionId) ? ir.actions[actionId] : undefined;
|
|
1046
|
+
if (remoteAction) {
|
|
1047
|
+
return runRemoteAction(remoteAction, args);
|
|
1048
|
+
}
|
|
934
1049
|
return collecting((collected) => {
|
|
935
1050
|
const started = collected.length;
|
|
936
1051
|
const result = runActionCollecting(actionId, args, collected);
|
|
@@ -1804,6 +1919,25 @@ export function createAxiomRuntime(options) {
|
|
|
1804
1919
|
registerNativeOperation(implementationId, implementation) {
|
|
1805
1920
|
natives.set(implementationId, implementation);
|
|
1806
1921
|
},
|
|
1922
|
+
async invokeActionAsync(id, args = {}) {
|
|
1923
|
+
const result = runAction(id, args);
|
|
1924
|
+
if (!result.pending) {
|
|
1925
|
+
return result;
|
|
1926
|
+
}
|
|
1927
|
+
return (await pending.get(id)) ?? result;
|
|
1928
|
+
},
|
|
1929
|
+
async syncAuthoritativeState() {
|
|
1930
|
+
if (!remote?.snapshot) {
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const snapshot = await remote.snapshot();
|
|
1934
|
+
applyAuthoritative(snapshot.states ?? {});
|
|
1935
|
+
renderApplication();
|
|
1936
|
+
},
|
|
1937
|
+
evaluate(expression) {
|
|
1938
|
+
const outcome = tryEvaluate(expression, rootScope(), {});
|
|
1939
|
+
return outcome.ok ? { ok: true, value: outcome.value } : { ok: false, diagnostic: outcome.diagnostic };
|
|
1940
|
+
},
|
|
1807
1941
|
};
|
|
1808
1942
|
}
|
|
1809
1943
|
/** Builds a host bound to the browser globals. Used by generated pages. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cynodia/axiom-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.6.0-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|