@cynodia/axiom-runtime 0.6.0-alpha.1 → 0.6.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/README.md CHANGED
@@ -19,13 +19,13 @@ Main exports: `createAxiomRuntime`, `createBrowserHost`, `createMemoryHost`,
19
19
  ## Installation
20
20
 
21
21
  ```bash
22
- npm install @cynodia/axiom-runtime@alpha
22
+ npm install @cynodia/axiom-runtime
23
23
  ```
24
24
 
25
25
  Most applications should install the facade package instead, which re-exports this one:
26
26
 
27
27
  ```bash
28
- npm install @cynodia/axiom@alpha
28
+ npm install @cynodia/axiom
29
29
  ```
30
30
 
31
31
 
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './mutation/store.js';
4
4
  export * from './mutation/transaction.js';
5
5
  export * from './mutation/resolve-location.js';
6
6
  export * from './mutation/mutation-engine.js';
7
+ export * from './remote.js';
7
8
  export * from './format.js';
8
9
  export * from './presentation-classes.js';
9
10
  export * from './runtime.js';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from './mutation/store.js';
4
4
  export * from './mutation/transaction.js';
5
5
  export * from './mutation/resolve-location.js';
6
6
  export * from './mutation/mutation-engine.js';
7
+ export * from './remote.js';
7
8
  export * from './format.js';
8
9
  export * from './presentation-classes.js';
9
10
  export * from './runtime.js';
@@ -31,6 +31,16 @@ export declare function isPresent(value: unknown): boolean;
31
31
  export declare function isEmptyValue(value: unknown): boolean;
32
32
  export declare function toBoolean(value: unknown): boolean;
33
33
  export declare function toText(value: unknown): string;
34
+ /**
35
+ * Lexicographic order by Unicode code point.
36
+ *
37
+ * Not locale collation, and deliberately not the language's default string comparison:
38
+ * ordering is part of the semantic contract, so two conforming runtimes must agree on it
39
+ * character for character. Code points are the one ordering every language can reproduce
40
+ * exactly — a UTF-16 comparison, which is what `<` does here, disagrees with a UTF-8 one
41
+ * whenever a string mixes astral characters with U+E000..U+FFFF.
42
+ */
43
+ export declare function compareText(left: string, right: string): number;
34
44
  export declare function compareValues(left: unknown, right: unknown): number;
35
45
  export declare function valuesEqual(left: unknown, right: unknown): boolean;
36
46
  //# sourceMappingURL=values.d.ts.map
@@ -76,13 +76,33 @@ export function toText(value) {
76
76
  }
77
77
  return JSON.stringify(value);
78
78
  }
79
+ /**
80
+ * Lexicographic order by Unicode code point.
81
+ *
82
+ * Not locale collation, and deliberately not the language's default string comparison:
83
+ * ordering is part of the semantic contract, so two conforming runtimes must agree on it
84
+ * character for character. Code points are the one ordering every language can reproduce
85
+ * exactly — a UTF-16 comparison, which is what `<` does here, disagrees with a UTF-8 one
86
+ * whenever a string mixes astral characters with U+E000..U+FFFF.
87
+ */
88
+ export function compareText(left, right) {
89
+ const leftPoints = Array.from(left);
90
+ const rightPoints = Array.from(right);
91
+ const shared = Math.min(leftPoints.length, rightPoints.length);
92
+ for (let index = 0; index < shared; index += 1) {
93
+ const leftPoint = leftPoints[index].codePointAt(0);
94
+ const rightPoint = rightPoints[index].codePointAt(0);
95
+ if (leftPoint !== rightPoint) {
96
+ return leftPoint < rightPoint ? -1 : 1;
97
+ }
98
+ }
99
+ return leftPoints.length === rightPoints.length ? 0 : leftPoints.length < rightPoints.length ? -1 : 1;
100
+ }
79
101
  export function compareValues(left, right) {
80
102
  if (typeof left === 'number' && typeof right === 'number') {
81
103
  return left === right ? 0 : left < right ? -1 : 1;
82
104
  }
83
- const leftText = toText(left);
84
- const rightText = toText(right);
85
- return leftText === rightText ? 0 : leftText < rightText ? -1 : 1;
105
+ return compareText(toText(left), toText(right));
86
106
  }
87
107
  /** A stable serialization, so record comparison does not depend on key order. */
88
108
  function canonical(value) {
@@ -0,0 +1,51 @@
1
+ import type { NodeId, RuntimeDiagnostic } from './runtime-types.js';
2
+ /**
3
+ * A browser-safe gateway to an Axiom authority.
4
+ *
5
+ * It lives here, in the runtime, rather than in the server package, because a browser
6
+ * client needs it and that package imports Node built-ins a browser has no use for.
7
+ * Everything below uses `fetch` and nothing else, so a generated page can reach an
8
+ * authority with no application code and no Node dependency at all.
9
+ *
10
+ * The protocol is the semantic one: the client asks for actions, never for URLs. The
11
+ * endpoint is a single path, the same for every application.
12
+ */
13
+ /** Must match `PROTOCOL_VERSION` in `@cynodia/axiom-server`. */
14
+ export declare const AXIOM_PROTOCOL_VERSION = "axiom.protocol.v1";
15
+ /** The endpoint an Axiom authority answers on, unless a host says otherwise. */
16
+ export declare const AXIOM_DEFAULT_ENDPOINT = "/axiom";
17
+ export interface HttpRemoteGatewayOptions {
18
+ /** Defaults to `/axiom` on the current origin. */
19
+ endpoint?: string;
20
+ /** Read per request, so a host can refresh a credential without rebuilding the client. */
21
+ credential?: () => string | null;
22
+ /** Defaults to the global `fetch`. */
23
+ fetch?: typeof globalThis.fetch;
24
+ timeoutMs?: number;
25
+ }
26
+ /**
27
+ * Builds the gateway a client runtime is configured with.
28
+ *
29
+ * ```ts
30
+ * createAxiomRuntime({ ir, rootElement, host, remote: createHttpRemoteGateway() });
31
+ * ```
32
+ *
33
+ * A transport failure becomes a structured diagnostic rather than an exception escaping
34
+ * into application code, so a refused or unreachable authority reaches the interface the
35
+ * same way a refused local action does.
36
+ */
37
+ export declare function createHttpRemoteGateway(options?: HttpRemoteGatewayOptions): {
38
+ invoke(request: {
39
+ actionId: NodeId;
40
+ arguments: Record<string, unknown>;
41
+ requestId: string;
42
+ }): Promise<{
43
+ ok: boolean;
44
+ diagnostics: RuntimeDiagnostic[];
45
+ changes: Record<NodeId, unknown>;
46
+ }>;
47
+ snapshot(): Promise<{
48
+ states: Record<NodeId, unknown>;
49
+ }>;
50
+ };
51
+ //# sourceMappingURL=remote.d.ts.map
package/dist/remote.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A browser-safe gateway to an Axiom authority.
3
+ *
4
+ * It lives here, in the runtime, rather than in the server package, because a browser
5
+ * client needs it and that package imports Node built-ins a browser has no use for.
6
+ * Everything below uses `fetch` and nothing else, so a generated page can reach an
7
+ * authority with no application code and no Node dependency at all.
8
+ *
9
+ * The protocol is the semantic one: the client asks for actions, never for URLs. The
10
+ * endpoint is a single path, the same for every application.
11
+ */
12
+ /** Must match `PROTOCOL_VERSION` in `@cynodia/axiom-server`. */
13
+ export const AXIOM_PROTOCOL_VERSION = 'axiom.protocol.v1';
14
+ /** The endpoint an Axiom authority answers on, unless a host says otherwise. */
15
+ export const AXIOM_DEFAULT_ENDPOINT = '/axiom';
16
+ function unreachable(message) {
17
+ return {
18
+ code: 'AUTHORITY_UNREACHABLE',
19
+ message,
20
+ severity: 'error',
21
+ };
22
+ }
23
+ /**
24
+ * Builds the gateway a client runtime is configured with.
25
+ *
26
+ * ```ts
27
+ * createAxiomRuntime({ ir, rootElement, host, remote: createHttpRemoteGateway() });
28
+ * ```
29
+ *
30
+ * A transport failure becomes a structured diagnostic rather than an exception escaping
31
+ * into application code, so a refused or unreachable authority reaches the interface the
32
+ * same way a refused local action does.
33
+ */
34
+ export function createHttpRemoteGateway(options = {}) {
35
+ const endpoint = options.endpoint ?? AXIOM_DEFAULT_ENDPOINT;
36
+ const fetchImpl = options.fetch ?? globalThis.fetch;
37
+ async function send(body) {
38
+ if (!fetchImpl) {
39
+ throw new Error('No fetch implementation is available');
40
+ }
41
+ const controller = options.timeoutMs && typeof AbortController === 'function' ? new AbortController() : undefined;
42
+ const timer = controller ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
43
+ try {
44
+ const response = await fetchImpl(endpoint, {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({
48
+ ...body,
49
+ protocol: AXIOM_PROTOCOL_VERSION,
50
+ credential: options.credential?.() ?? null,
51
+ }),
52
+ ...(controller ? { signal: controller.signal } : {}),
53
+ });
54
+ if (!response.ok) {
55
+ throw new Error(`The authority answered ${response.status}`);
56
+ }
57
+ return (await response.json());
58
+ }
59
+ finally {
60
+ if (timer !== undefined) {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+ }
65
+ return {
66
+ async invoke(request) {
67
+ try {
68
+ const answer = await send({
69
+ kind: 'invoke',
70
+ actionId: request.actionId,
71
+ arguments: request.arguments,
72
+ requestId: request.requestId,
73
+ });
74
+ if (answer.kind === 'result') {
75
+ return {
76
+ ok: answer.ok === true,
77
+ diagnostics: answer.diagnostics ?? [],
78
+ changes: answer.changes ?? {},
79
+ };
80
+ }
81
+ return { ok: false, diagnostics: answer.diagnostics ?? [], changes: {} };
82
+ }
83
+ catch (error) {
84
+ return {
85
+ ok: false,
86
+ diagnostics: [unreachable(error instanceof Error ? error.message : String(error))],
87
+ changes: {},
88
+ };
89
+ }
90
+ },
91
+ async snapshot() {
92
+ // A failed snapshot must reject: `start()` distinguishes "not loaded" from "empty",
93
+ // and swallowing the failure here would erase that difference.
94
+ const answer = await send({ kind: 'snapshot' });
95
+ if (answer.kind !== 'snapshot' || !answer.snapshot) {
96
+ throw new Error('The authority did not return a snapshot');
97
+ }
98
+ return { states: answer.snapshot.states ?? {} };
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The handful of types the browser-safe gateway needs, declared without importing the
3
+ * runtime module.
4
+ *
5
+ * `remote.ts` is inlined into generated pages, and a value import from core would be
6
+ * stripped from that bundle. Keeping these local also means the gateway pulls in nothing
7
+ * else at all.
8
+ */
9
+ export type NodeId = string & {
10
+ readonly __brand?: 'NodeId';
11
+ };
12
+ export interface RuntimeDiagnostic {
13
+ code: string;
14
+ message: string;
15
+ severity: 'error' | 'warning';
16
+ details?: Record<string, unknown>;
17
+ [key: string]: unknown;
18
+ }
19
+ //# sourceMappingURL=runtime-types.d.ts.map
@@ -0,0 +1 @@
1
+ export {};
package/dist/runtime.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
33
33
  readonly SERVER_STATE_WRITE: "SERVER_STATE_WRITE";
34
34
  /** An action belongs to the authority, but no gateway to it was configured. */
35
35
  readonly REMOTE_ACTION_UNAVAILABLE: "REMOTE_ACTION_UNAVAILABLE";
36
+ /** The authority could not be reached. Authoritative state was not loaded. */
37
+ readonly AUTHORITY_UNREACHABLE: "AUTHORITY_UNREACHABLE";
36
38
  };
37
39
  export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
38
40
  /**
@@ -126,7 +128,35 @@ export interface AxiomRuntimeOptions {
126
128
  recordMutationValues?: boolean;
127
129
  }
128
130
  export interface AxiomRuntime {
129
- start(): void;
131
+ /**
132
+ * Brings the runtime to its initial usable state.
133
+ *
134
+ * The lifecycle is fixed and does not depend on whether a gateway is configured:
135
+ *
136
+ * 1. local state is initialized from `initialValue` and persistence, before anything else;
137
+ * 2. route matching is resolved and the application renders once, so a slow authority
138
+ * never leaves a blank page;
139
+ * 3. if a `remote` gateway with a snapshot is configured, authoritative state is loaded
140
+ * and applied, and the application renders again.
141
+ *
142
+ * `start()` returns a promise that settles when step 3 has completed. Awaiting it is the
143
+ * whole startup sequence: **there is no second call to remember.** Ignoring the promise
144
+ * is safe — steps 1 and 2 have already run synchronously — but authoritative state may
145
+ * not have arrived yet.
146
+ *
147
+ * If synchronization fails, the failure is reported as an `AUTHORITY_UNREACHABLE`
148
+ * diagnostic and `authoritativeStateLoaded()` stays false, so an empty authoritative
149
+ * collection is never mistaken for a loaded one.
150
+ */
151
+ start(): Promise<void>;
152
+ /**
153
+ * Whether authoritative state has been loaded successfully.
154
+ *
155
+ * `false` with a configured gateway means the authority has not answered — which is not
156
+ * the same as an authoritative collection that is genuinely empty. Applications with no
157
+ * remote gateway are always `true`: all their state is local.
158
+ */
159
+ authoritativeStateLoaded(): boolean;
130
160
  render(): void;
131
161
  /** A deep clone of the value. Derived state is recomputed. */
132
162
  getState(id: NodeId): unknown;
@@ -169,6 +199,14 @@ export interface AxiomRuntime {
169
199
  * provides one.
170
200
  */
171
201
  syncAuthoritativeState(): Promise<void>;
202
+ /**
203
+ * Resolves when no remote invocation is outstanding.
204
+ *
205
+ * An action started from the interface has no promise the caller can hold; this is how a
206
+ * test, a script or a host waits for the authority to have answered without guessing a
207
+ * delay. Resolves immediately when nothing is in flight.
208
+ */
209
+ settled(): Promise<void>;
172
210
  /**
173
211
  * Evaluates an expression in the root scope, reporting rather than throwing. It is a
174
212
  * pure read: an expression cannot change state.
package/dist/runtime.js CHANGED
@@ -37,6 +37,8 @@ export const RUNTIME_DIAGNOSTIC_CODES = {
37
37
  SERVER_STATE_WRITE: 'SERVER_STATE_WRITE',
38
38
  /** An action belongs to the authority, but no gateway to it was configured. */
39
39
  REMOTE_ACTION_UNAVAILABLE: 'REMOTE_ACTION_UNAVAILABLE',
40
+ /** The authority could not be reached. Authoritative state was not loaded. */
41
+ AUTHORITY_UNREACHABLE: 'AUTHORITY_UNREACHABLE',
40
42
  };
41
43
  const MISSING = Symbol('missing');
42
44
  function unwrapType(type) {
@@ -89,6 +91,17 @@ function describeValue(value) {
89
91
  }
90
92
  return `${typeof value} ${JSON.stringify(value)}`;
91
93
  }
94
+ /**
95
+ * Distinguishes runtimes that share a process.
96
+ *
97
+ * `host.uuid()` is the only entropy a runtime has, and a deterministic host — the memory
98
+ * host, a conformance host, a test double — hands every runtime it constructs the same
99
+ * sequence. Two clients would then generate the same request id for their first remote
100
+ * invocation, and the authority would answer the second from the first one's idempotency
101
+ * record. A counter that lives above the host closes that: within a process it is what
102
+ * separates two runtimes, and across processes a real host's uuid is.
103
+ */
104
+ let runtimeSessions = 0;
92
105
  export function createAxiomRuntime(options) {
93
106
  const { ir, rootElement, host } = options;
94
107
  const store = createStateStore();
@@ -97,6 +110,15 @@ export function createAxiomRuntime(options) {
97
110
  const diagnostics = [];
98
111
  /** Rendered controls, keyed by render instance — not by node id. */
99
112
  const inputElements = new Map();
113
+ /**
114
+ * How each rendered form submits, keyed by the form's render instance.
115
+ *
116
+ * A form with a declared submit control must invoke the action **exactly** as that button
117
+ * would on its own — same arguments, evaluated in the button's own scope. Registering the
118
+ * button's invocation here is what makes the two paths literally the same code, rather
119
+ * than two that have to be kept in step.
120
+ */
121
+ const submitInvokers = new Map();
100
122
  let focusedInstance = null;
101
123
  let focusedCaret = null;
102
124
  let started = false;
@@ -112,6 +134,11 @@ export function createAxiomRuntime(options) {
112
134
  */
113
135
  let applyingAuthoritative = false;
114
136
  let remoteRequests = 0;
137
+ /** Generated once, and only when this runtime can actually talk to an authority. */
138
+ const sessionId = remote ? `s${(runtimeSessions += 1)}-${host.uuid()}` : '';
139
+ /** False until the authority has answered, when one is configured. */
140
+ let authoritativeLoaded = options.remote?.snapshot === undefined;
141
+ let startup = Promise.resolve();
115
142
  const theme = ir.theme;
116
143
  const locale = theme?.locale ?? 'en-US';
117
144
  /**
@@ -1009,8 +1036,9 @@ export function createAxiomRuntime(options) {
1009
1036
  return { ok: false, diagnostics: [] };
1010
1037
  }
1011
1038
  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()}`;
1039
+ // A stable key, so a retry after a lost answer cannot execute the action twice — and one
1040
+ // carrying this runtime's own session identity, so two clients never claim the same key.
1041
+ const requestId = `${ir.id}:${sessionId}:${action.id}:${remoteRequests}:${host.uuid()}`;
1014
1042
  recordOutcome(action.id, 'pending', []);
1015
1043
  renderApplication();
1016
1044
  const settle = remote
@@ -1037,10 +1065,54 @@ export function createAxiomRuntime(options) {
1037
1065
  return { ok: false, diagnostics: [failure] };
1038
1066
  });
1039
1067
  pending.set(action.id, settle);
1068
+ void settle.finally(() => {
1069
+ if (pending.get(action.id) === settle) {
1070
+ pending.delete(action.id);
1071
+ }
1072
+ });
1040
1073
  return { ok: false, pending: true, diagnostics: [] };
1041
1074
  }
1042
1075
  /** In-flight remote invocations, so `invokeActionAsync` can await one. */
1043
1076
  const pending = new Map();
1077
+ /**
1078
+ * Waits until nothing is outstanding with an authority.
1079
+ *
1080
+ * A remote action started from the interface — a click, a form submit — returns to the
1081
+ * event handler immediately, so there is no promise for the caller to hold. Without this,
1082
+ * anything driving the UI has to guess a delay. It loops because settling one invocation
1083
+ * may start another.
1084
+ */
1085
+ async function allSettled() {
1086
+ while (pending.size > 0) {
1087
+ await Promise.allSettled([...pending.values()]);
1088
+ }
1089
+ }
1090
+ /**
1091
+ * Loads authoritative state and applies it.
1092
+ *
1093
+ * A failure is a diagnostic, not an exception, and leaves `authoritativeLoaded` false —
1094
+ * so an application can tell "the authority has not answered" from "the collection is
1095
+ * empty", which are very different things to show a person.
1096
+ */
1097
+ async function syncAuthoritative() {
1098
+ if (!remote?.snapshot) {
1099
+ return;
1100
+ }
1101
+ try {
1102
+ const snapshot = await remote.snapshot();
1103
+ applyAuthoritative(snapshot.states ?? {});
1104
+ authoritativeLoaded = true;
1105
+ }
1106
+ catch (error) {
1107
+ authoritativeLoaded = false;
1108
+ report({
1109
+ code: RUNTIME_DIAGNOSTIC_CODES.AUTHORITY_UNREACHABLE,
1110
+ message: `Authoritative state could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
1111
+ severity: 'error',
1112
+ });
1113
+ }
1114
+ renderApplication();
1115
+ }
1044
1116
  function runAction(actionId, args = {}) {
1045
1117
  const remoteAction = remoteActionIds.has(actionId) ? ir.actions[actionId] : undefined;
1046
1118
  if (remoteAction) {
@@ -1446,6 +1518,18 @@ export function createAxiomRuntime(options) {
1446
1518
  }
1447
1519
  return target;
1448
1520
  };
1521
+ /**
1522
+ * `data-control` names the one element a person actually operates.
1523
+ *
1524
+ * A single semantic node can render as more than one element — an input is a label
1525
+ * wrapping a control, and both carry `data-node`, because both are that node. Anything
1526
+ * that wants to type into it, click it or read its value needs the inner one, and
1527
+ * `data-node` cannot say which that is. This can.
1528
+ */
1529
+ const asControl = (target) => {
1530
+ target.setAttribute('data-control', node.id);
1531
+ return target;
1532
+ };
1449
1533
  switch (node.kind) {
1450
1534
  case 'view': {
1451
1535
  const container = identify(element('div', nodeClasses(node, 'axiom-view')));
@@ -1544,8 +1628,15 @@ export function createAxiomRuntime(options) {
1544
1628
  actions.appendChild(submit);
1545
1629
  form.appendChild(actions);
1546
1630
  }
1631
+ const formInstance = instanceKey(node.id, path);
1547
1632
  form.addEventListener('submit', (event) => {
1548
1633
  event.preventDefault?.();
1634
+ // A declared control carries the arguments; a generated one has none to carry.
1635
+ const declared = submitInvokers.get(formInstance);
1636
+ if (declared) {
1637
+ declared();
1638
+ return;
1639
+ }
1549
1640
  runAction(submitActionId);
1550
1641
  });
1551
1642
  }
@@ -1581,7 +1672,10 @@ export function createAxiomRuntime(options) {
1581
1672
  const describedBy = [];
1582
1673
  const control = identify(element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control'));
1583
1674
  if (descriptor.variant) {
1584
- control.setAttribute('data-control', descriptor.variant);
1675
+ control.setAttribute('data-variant', descriptor.variant);
1676
+ }
1677
+ if (!grouped) {
1678
+ asControl(control);
1585
1679
  }
1586
1680
  if (!grouped) {
1587
1681
  control.setAttribute('id', controlId);
@@ -1599,7 +1693,9 @@ export function createAxiomRuntime(options) {
1599
1693
  }
1600
1694
  }
1601
1695
  if (grouped) {
1602
- wrapper.setAttribute('data-control', descriptor.variant ?? 'radio-group');
1696
+ // A radio group has no single element to operate, so the group itself is it.
1697
+ wrapper.setAttribute('data-variant', descriptor.variant ?? 'radio-group');
1698
+ asControl(wrapper);
1603
1699
  }
1604
1700
  if (descriptor.variant === 'switch') {
1605
1701
  control.setAttribute('role', 'switch');
@@ -1743,7 +1839,7 @@ export function createAxiomRuntime(options) {
1743
1839
  }
1744
1840
  case 'button': {
1745
1841
  const submits = submitControls.get(node.id);
1746
- const button = identify(element('button', nodeClasses(node, 'axiom-button', submits ? 'axiom-submit' : '')));
1842
+ const button = asControl(identify(element('button', nodeClasses(node, 'axiom-button', submits ? 'axiom-submit' : ''))));
1747
1843
  button.setAttribute('type', 'button');
1748
1844
  const label = typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
1749
1845
  if (presentation?.icon) {
@@ -1769,18 +1865,40 @@ export function createAxiomRuntime(options) {
1769
1865
  if (reporting) {
1770
1866
  button.setAttribute('aria-describedby', reporting);
1771
1867
  }
1868
+ // A remote action is in flight until the authority answers, and a person watching a
1869
+ // button that does nothing will press it again. `pending` is already a semantic
1870
+ // runtime outcome; this is the whole of its presentation — the control says it is
1871
+ // working, and refuses a second invocation until it is not. No async model, no
1872
+ // spinner vocabulary, nothing an author writes.
1873
+ const pending = actionOutcomes.get(node.actionId)?.outcome === 'pending';
1874
+ if (pending) {
1875
+ button.setAttribute('data-pending', 'true');
1876
+ button.setAttribute('aria-busy', 'true');
1877
+ button.setAttribute('disabled', 'true');
1878
+ }
1879
+ /** What this button does, wherever the interaction came from. */
1880
+ const invoke = () => {
1881
+ if (pending) {
1882
+ // The authority has not answered the last one. Pressing again would be a second
1883
+ // transaction, not a retry of the first.
1884
+ return;
1885
+ }
1886
+ const args = {};
1887
+ for (const [parameterId, argument] of Object.entries(node.arguments ?? {})) {
1888
+ args[parameterId] = evaluate(argument, scope);
1889
+ }
1890
+ runAction(node.actionId, args);
1891
+ };
1772
1892
  if (submits) {
1773
1893
  // Native form submission runs the action; a click handler here would run it twice.
1894
+ // The form invokes exactly this, so the button's arguments are not lost.
1774
1895
  button.setAttribute('type', 'submit');
1896
+ submitInvokers.set(instanceKey(submits.formId, path), invoke);
1775
1897
  return button;
1776
1898
  }
1777
1899
  button.addEventListener('click', (event) => {
1778
1900
  event.preventDefault?.();
1779
- const args = {};
1780
- for (const [parameterId, argument] of Object.entries(node.arguments ?? {})) {
1781
- args[parameterId] = evaluate(argument, scope);
1782
- }
1783
- runAction(node.actionId, args);
1901
+ invoke();
1784
1902
  });
1785
1903
  return button;
1786
1904
  }
@@ -1828,6 +1946,7 @@ export function createAxiomRuntime(options) {
1828
1946
  }
1829
1947
  function renderApplication() {
1830
1948
  inputElements.clear();
1949
+ submitInvokers.clear();
1831
1950
  const scope = rootScope();
1832
1951
  if (!activeRoute) {
1833
1952
  const missing = element('div', 'axiom-no-route');
@@ -1862,7 +1981,7 @@ export function createAxiomRuntime(options) {
1862
1981
  return {
1863
1982
  start() {
1864
1983
  if (started) {
1865
- return;
1984
+ return startup;
1866
1985
  }
1867
1986
  started = true;
1868
1987
  host.onPathChange(() => {
@@ -1873,7 +1992,14 @@ export function createAxiomRuntime(options) {
1873
1992
  inputErrors.clear();
1874
1993
  renderApplication();
1875
1994
  });
1995
+ // Local state and a first render happen synchronously, so an application is on screen
1996
+ // before the authority is consulted.
1876
1997
  syncRoute();
1998
+ startup = remote?.snapshot ? syncAuthoritative() : Promise.resolve();
1999
+ return startup;
2000
+ },
2001
+ authoritativeStateLoaded() {
2002
+ return authoritativeLoaded;
1877
2003
  },
1878
2004
  render: renderApplication,
1879
2005
  getState(id) {
@@ -1926,13 +2052,11 @@ export function createAxiomRuntime(options) {
1926
2052
  }
1927
2053
  return (await pending.get(id)) ?? result;
1928
2054
  },
1929
- async syncAuthoritativeState() {
1930
- if (!remote?.snapshot) {
1931
- return;
1932
- }
1933
- const snapshot = await remote.snapshot();
1934
- applyAuthoritative(snapshot.states ?? {});
1935
- renderApplication();
2055
+ syncAuthoritativeState() {
2056
+ return syncAuthoritative();
2057
+ },
2058
+ settled() {
2059
+ return allSettled();
1936
2060
  },
1937
2061
  evaluate(expression) {
1938
2062
  const outcome = tryEvaluate(expression, rootScope(), {});
package/dist/source.js CHANGED
@@ -12,6 +12,7 @@ const RUNTIME_MODULES = [
12
12
  './mutation/resolve-location.js',
13
13
  './mutation/mutation-engine.js',
14
14
  './format.js',
15
+ './remote.js',
15
16
  './presentation-classes.js',
16
17
  './runtime.js',
17
18
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.6.0-alpha.1",
3
+ "version": "0.6.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.6.0-alpha.1"
34
+ "@cynodia/axiom-core": "0.6.1-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",