@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 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: 'ok' | 'failed' | 'cancelled';
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.5.2-alpha.1",
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.5.2-alpha.1"
34
+ "@cynodia/axiom-core": "0.6.0-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",