@cynodia/axiom-runtime 0.6.0-alpha.1 → 0.6.2-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 +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mutation/values.d.ts +10 -0
- package/dist/mutation/values.js +23 -3
- package/dist/remote.d.ts +51 -0
- package/dist/remote.js +101 -0
- package/dist/runtime-types.d.ts +57 -0
- package/dist/runtime-types.js +1 -0
- package/dist/runtime.d.ts +41 -16
- package/dist/runtime.js +149 -21
- package/dist/source.js +1 -0
- package/package.json +2 -2
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
|
|
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
|
|
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
|
package/dist/mutation/values.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/remote.d.ts
ADDED
|
@@ -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,57 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Structurally identical to `NodeId` in `@cynodia/axiom-core`, and it must stay that way:
|
|
11
|
+
* the brand exists only at compile time, so declaring the same shape here makes the two the
|
|
12
|
+
* same type without an import the browser bundle cannot carry.
|
|
13
|
+
* `packages/runtime/test/host.test.ts` fails if the two declarations drift apart.
|
|
14
|
+
*/
|
|
15
|
+
export type NodeId = string & {
|
|
16
|
+
readonly __brand: 'NodeId';
|
|
17
|
+
};
|
|
18
|
+
export interface RuntimeDiagnostic {
|
|
19
|
+
/**
|
|
20
|
+
* A code from `RUNTIME_DIAGNOSTIC_CODES` or `SERVER_DIAGNOSTIC_CODES`, but typed as a
|
|
21
|
+
* string here on purpose: a diagnostic arriving over the wire is untrusted input, and a
|
|
22
|
+
* gateway cannot promise it is one of ours. Match on it; do not switch exhaustively.
|
|
23
|
+
*/
|
|
24
|
+
code: string;
|
|
25
|
+
message: string;
|
|
26
|
+
severity: 'error' | 'warning';
|
|
27
|
+
details?: Record<string, unknown>;
|
|
28
|
+
nodeId?: NodeId;
|
|
29
|
+
actionId?: NodeId;
|
|
30
|
+
constraintId?: NodeId;
|
|
31
|
+
stateId?: NodeId;
|
|
32
|
+
transactionId?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* How a client runtime reaches an authority.
|
|
36
|
+
*
|
|
37
|
+
* It lives here, beside the browser-safe gateway, rather than in `runtime.ts`, so that the
|
|
38
|
+
* gateway a page is given and the gateway the runtime accepts are the **same type**. When
|
|
39
|
+
* they were declared separately, `createHttpRemoteGateway()` did not typecheck as one and
|
|
40
|
+
* every consumer needed a cast — for a value the framework hands them itself.
|
|
41
|
+
*/
|
|
42
|
+
export interface RemoteGateway {
|
|
43
|
+
invoke(request: {
|
|
44
|
+
actionId: NodeId;
|
|
45
|
+
arguments: Record<string, unknown>;
|
|
46
|
+
requestId: string;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
ok: boolean;
|
|
49
|
+
diagnostics: RuntimeDiagnostic[];
|
|
50
|
+
changes: Record<NodeId, unknown>;
|
|
51
|
+
}>;
|
|
52
|
+
/** The authoritative values of every observable state. */
|
|
53
|
+
snapshot?(): Promise<{
|
|
54
|
+
states: Record<NodeId, unknown>;
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=runtime-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RemoteGateway } from './runtime-types.js';
|
|
1
2
|
import type { ApplicationIR, CompiledRoute, Expression, FieldId, Location, NodeId } from '@cynodia/axiom-core';
|
|
2
3
|
import type { DomElement, HostEnvironment } from './dom.js';
|
|
3
4
|
import type { MutationLogEntry } from './mutation/mutation-engine.js';
|
|
@@ -33,6 +34,8 @@ export declare const RUNTIME_DIAGNOSTIC_CODES: {
|
|
|
33
34
|
readonly SERVER_STATE_WRITE: "SERVER_STATE_WRITE";
|
|
34
35
|
/** An action belongs to the authority, but no gateway to it was configured. */
|
|
35
36
|
readonly REMOTE_ACTION_UNAVAILABLE: "REMOTE_ACTION_UNAVAILABLE";
|
|
37
|
+
/** The authority could not be reached. Authoritative state was not loaded. */
|
|
38
|
+
readonly AUTHORITY_UNREACHABLE: "AUTHORITY_UNREACHABLE";
|
|
36
39
|
};
|
|
37
40
|
export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
|
|
38
41
|
/**
|
|
@@ -70,21 +73,7 @@ export interface ActionResult {
|
|
|
70
73
|
* is dispatched and answered later, so `invokeAction` returns `pending` and the outcome
|
|
71
74
|
* arrives through the same diagnostic lifecycle a local refusal uses.
|
|
72
75
|
*/
|
|
73
|
-
export
|
|
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
|
-
}>;
|
|
87
|
-
}
|
|
76
|
+
export type { RemoteGateway } from './runtime-types.js';
|
|
88
77
|
/**
|
|
89
78
|
* The outcome of an action's most recent invocation.
|
|
90
79
|
*
|
|
@@ -126,7 +115,35 @@ export interface AxiomRuntimeOptions {
|
|
|
126
115
|
recordMutationValues?: boolean;
|
|
127
116
|
}
|
|
128
117
|
export interface AxiomRuntime {
|
|
129
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Brings the runtime to its initial usable state.
|
|
120
|
+
*
|
|
121
|
+
* The lifecycle is fixed and does not depend on whether a gateway is configured:
|
|
122
|
+
*
|
|
123
|
+
* 1. local state is initialized from `initialValue` and persistence, before anything else;
|
|
124
|
+
* 2. route matching is resolved and the application renders once, so a slow authority
|
|
125
|
+
* never leaves a blank page;
|
|
126
|
+
* 3. if a `remote` gateway with a snapshot is configured, authoritative state is loaded
|
|
127
|
+
* and applied, and the application renders again.
|
|
128
|
+
*
|
|
129
|
+
* `start()` returns a promise that settles when step 3 has completed. Awaiting it is the
|
|
130
|
+
* whole startup sequence: **there is no second call to remember.** Ignoring the promise
|
|
131
|
+
* is safe — steps 1 and 2 have already run synchronously — but authoritative state may
|
|
132
|
+
* not have arrived yet.
|
|
133
|
+
*
|
|
134
|
+
* If synchronization fails, the failure is reported as an `AUTHORITY_UNREACHABLE`
|
|
135
|
+
* diagnostic and `authoritativeStateLoaded()` stays false, so an empty authoritative
|
|
136
|
+
* collection is never mistaken for a loaded one.
|
|
137
|
+
*/
|
|
138
|
+
start(): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Whether authoritative state has been loaded successfully.
|
|
141
|
+
*
|
|
142
|
+
* `false` with a configured gateway means the authority has not answered — which is not
|
|
143
|
+
* the same as an authoritative collection that is genuinely empty. Applications with no
|
|
144
|
+
* remote gateway are always `true`: all their state is local.
|
|
145
|
+
*/
|
|
146
|
+
authoritativeStateLoaded(): boolean;
|
|
130
147
|
render(): void;
|
|
131
148
|
/** A deep clone of the value. Derived state is recomputed. */
|
|
132
149
|
getState(id: NodeId): unknown;
|
|
@@ -169,6 +186,14 @@ export interface AxiomRuntime {
|
|
|
169
186
|
* provides one.
|
|
170
187
|
*/
|
|
171
188
|
syncAuthoritativeState(): Promise<void>;
|
|
189
|
+
/**
|
|
190
|
+
* Resolves when no remote invocation is outstanding.
|
|
191
|
+
*
|
|
192
|
+
* An action started from the interface has no promise the caller can hold; this is how a
|
|
193
|
+
* test, a script or a host waits for the authority to have answered without guessing a
|
|
194
|
+
* delay. Resolves immediately when nothing is in flight.
|
|
195
|
+
*/
|
|
196
|
+
settled(): Promise<void>;
|
|
172
197
|
/**
|
|
173
198
|
* Evaluates an expression in the root scope, reporting rather than throwing. It is a
|
|
174
199
|
* 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,18 +1036,23 @@ 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
|
-
|
|
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
|
|
1017
1045
|
.invoke({ actionId: action.id, arguments: args, requestId })
|
|
1018
1046
|
.then((answer) => {
|
|
1019
1047
|
applyAuthoritative(answer.changes ?? {});
|
|
1020
|
-
|
|
1021
|
-
|
|
1048
|
+
// A diagnostic from an authority is untrusted input: its `code` is a string until it
|
|
1049
|
+
// matches something we know. It is carried through unchanged, because a client that
|
|
1050
|
+
// rewrote a refusal would be inventing one.
|
|
1051
|
+
const reported = answer.diagnostics;
|
|
1052
|
+
reported.forEach(report);
|
|
1053
|
+
recordOutcome(action.id, answer.ok ? 'ok' : 'failed', answer.ok ? [] : reported);
|
|
1022
1054
|
renderApplication();
|
|
1023
|
-
return { ok: answer.ok, diagnostics:
|
|
1055
|
+
return { ok: answer.ok, diagnostics: reported };
|
|
1024
1056
|
})
|
|
1025
1057
|
.catch((error) => {
|
|
1026
1058
|
// A transport failure becomes a structured diagnostic, not an escaping exception.
|
|
@@ -1037,10 +1069,54 @@ export function createAxiomRuntime(options) {
|
|
|
1037
1069
|
return { ok: false, diagnostics: [failure] };
|
|
1038
1070
|
});
|
|
1039
1071
|
pending.set(action.id, settle);
|
|
1072
|
+
void settle.finally(() => {
|
|
1073
|
+
if (pending.get(action.id) === settle) {
|
|
1074
|
+
pending.delete(action.id);
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1040
1077
|
return { ok: false, pending: true, diagnostics: [] };
|
|
1041
1078
|
}
|
|
1042
1079
|
/** In-flight remote invocations, so `invokeActionAsync` can await one. */
|
|
1043
1080
|
const pending = new Map();
|
|
1081
|
+
/**
|
|
1082
|
+
* Waits until nothing is outstanding with an authority.
|
|
1083
|
+
*
|
|
1084
|
+
* A remote action started from the interface — a click, a form submit — returns to the
|
|
1085
|
+
* event handler immediately, so there is no promise for the caller to hold. Without this,
|
|
1086
|
+
* anything driving the UI has to guess a delay. It loops because settling one invocation
|
|
1087
|
+
* may start another.
|
|
1088
|
+
*/
|
|
1089
|
+
async function allSettled() {
|
|
1090
|
+
while (pending.size > 0) {
|
|
1091
|
+
await Promise.allSettled([...pending.values()]);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Loads authoritative state and applies it.
|
|
1096
|
+
*
|
|
1097
|
+
* A failure is a diagnostic, not an exception, and leaves `authoritativeLoaded` false —
|
|
1098
|
+
* so an application can tell "the authority has not answered" from "the collection is
|
|
1099
|
+
* empty", which are very different things to show a person.
|
|
1100
|
+
*/
|
|
1101
|
+
async function syncAuthoritative() {
|
|
1102
|
+
if (!remote?.snapshot) {
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
try {
|
|
1106
|
+
const snapshot = await remote.snapshot();
|
|
1107
|
+
applyAuthoritative(snapshot.states ?? {});
|
|
1108
|
+
authoritativeLoaded = true;
|
|
1109
|
+
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
authoritativeLoaded = false;
|
|
1112
|
+
report({
|
|
1113
|
+
code: RUNTIME_DIAGNOSTIC_CODES.AUTHORITY_UNREACHABLE,
|
|
1114
|
+
message: `Authoritative state could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
|
|
1115
|
+
severity: 'error',
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
renderApplication();
|
|
1119
|
+
}
|
|
1044
1120
|
function runAction(actionId, args = {}) {
|
|
1045
1121
|
const remoteAction = remoteActionIds.has(actionId) ? ir.actions[actionId] : undefined;
|
|
1046
1122
|
if (remoteAction) {
|
|
@@ -1446,6 +1522,18 @@ export function createAxiomRuntime(options) {
|
|
|
1446
1522
|
}
|
|
1447
1523
|
return target;
|
|
1448
1524
|
};
|
|
1525
|
+
/**
|
|
1526
|
+
* `data-control` names the one element a person actually operates.
|
|
1527
|
+
*
|
|
1528
|
+
* A single semantic node can render as more than one element — an input is a label
|
|
1529
|
+
* wrapping a control, and both carry `data-node`, because both are that node. Anything
|
|
1530
|
+
* that wants to type into it, click it or read its value needs the inner one, and
|
|
1531
|
+
* `data-node` cannot say which that is. This can.
|
|
1532
|
+
*/
|
|
1533
|
+
const asControl = (target) => {
|
|
1534
|
+
target.setAttribute('data-control', node.id);
|
|
1535
|
+
return target;
|
|
1536
|
+
};
|
|
1449
1537
|
switch (node.kind) {
|
|
1450
1538
|
case 'view': {
|
|
1451
1539
|
const container = identify(element('div', nodeClasses(node, 'axiom-view')));
|
|
@@ -1544,8 +1632,15 @@ export function createAxiomRuntime(options) {
|
|
|
1544
1632
|
actions.appendChild(submit);
|
|
1545
1633
|
form.appendChild(actions);
|
|
1546
1634
|
}
|
|
1635
|
+
const formInstance = instanceKey(node.id, path);
|
|
1547
1636
|
form.addEventListener('submit', (event) => {
|
|
1548
1637
|
event.preventDefault?.();
|
|
1638
|
+
// A declared control carries the arguments; a generated one has none to carry.
|
|
1639
|
+
const declared = submitInvokers.get(formInstance);
|
|
1640
|
+
if (declared) {
|
|
1641
|
+
declared();
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1549
1644
|
runAction(submitActionId);
|
|
1550
1645
|
});
|
|
1551
1646
|
}
|
|
@@ -1581,7 +1676,10 @@ export function createAxiomRuntime(options) {
|
|
|
1581
1676
|
const describedBy = [];
|
|
1582
1677
|
const control = identify(element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control'));
|
|
1583
1678
|
if (descriptor.variant) {
|
|
1584
|
-
control.setAttribute('data-
|
|
1679
|
+
control.setAttribute('data-variant', descriptor.variant);
|
|
1680
|
+
}
|
|
1681
|
+
if (!grouped) {
|
|
1682
|
+
asControl(control);
|
|
1585
1683
|
}
|
|
1586
1684
|
if (!grouped) {
|
|
1587
1685
|
control.setAttribute('id', controlId);
|
|
@@ -1599,7 +1697,9 @@ export function createAxiomRuntime(options) {
|
|
|
1599
1697
|
}
|
|
1600
1698
|
}
|
|
1601
1699
|
if (grouped) {
|
|
1602
|
-
|
|
1700
|
+
// A radio group has no single element to operate, so the group itself is it.
|
|
1701
|
+
wrapper.setAttribute('data-variant', descriptor.variant ?? 'radio-group');
|
|
1702
|
+
asControl(wrapper);
|
|
1603
1703
|
}
|
|
1604
1704
|
if (descriptor.variant === 'switch') {
|
|
1605
1705
|
control.setAttribute('role', 'switch');
|
|
@@ -1743,7 +1843,7 @@ export function createAxiomRuntime(options) {
|
|
|
1743
1843
|
}
|
|
1744
1844
|
case 'button': {
|
|
1745
1845
|
const submits = submitControls.get(node.id);
|
|
1746
|
-
const button = identify(element('button', nodeClasses(node, 'axiom-button', submits ? 'axiom-submit' : '')));
|
|
1846
|
+
const button = asControl(identify(element('button', nodeClasses(node, 'axiom-button', submits ? 'axiom-submit' : ''))));
|
|
1747
1847
|
button.setAttribute('type', 'button');
|
|
1748
1848
|
const label = typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
|
|
1749
1849
|
if (presentation?.icon) {
|
|
@@ -1769,18 +1869,40 @@ export function createAxiomRuntime(options) {
|
|
|
1769
1869
|
if (reporting) {
|
|
1770
1870
|
button.setAttribute('aria-describedby', reporting);
|
|
1771
1871
|
}
|
|
1872
|
+
// A remote action is in flight until the authority answers, and a person watching a
|
|
1873
|
+
// button that does nothing will press it again. `pending` is already a semantic
|
|
1874
|
+
// runtime outcome; this is the whole of its presentation — the control says it is
|
|
1875
|
+
// working, and refuses a second invocation until it is not. No async model, no
|
|
1876
|
+
// spinner vocabulary, nothing an author writes.
|
|
1877
|
+
const pending = actionOutcomes.get(node.actionId)?.outcome === 'pending';
|
|
1878
|
+
if (pending) {
|
|
1879
|
+
button.setAttribute('data-pending', 'true');
|
|
1880
|
+
button.setAttribute('aria-busy', 'true');
|
|
1881
|
+
button.setAttribute('disabled', 'true');
|
|
1882
|
+
}
|
|
1883
|
+
/** What this button does, wherever the interaction came from. */
|
|
1884
|
+
const invoke = () => {
|
|
1885
|
+
if (pending) {
|
|
1886
|
+
// The authority has not answered the last one. Pressing again would be a second
|
|
1887
|
+
// transaction, not a retry of the first.
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
const args = {};
|
|
1891
|
+
for (const [parameterId, argument] of Object.entries(node.arguments ?? {})) {
|
|
1892
|
+
args[parameterId] = evaluate(argument, scope);
|
|
1893
|
+
}
|
|
1894
|
+
runAction(node.actionId, args);
|
|
1895
|
+
};
|
|
1772
1896
|
if (submits) {
|
|
1773
1897
|
// Native form submission runs the action; a click handler here would run it twice.
|
|
1898
|
+
// The form invokes exactly this, so the button's arguments are not lost.
|
|
1774
1899
|
button.setAttribute('type', 'submit');
|
|
1900
|
+
submitInvokers.set(instanceKey(submits.formId, path), invoke);
|
|
1775
1901
|
return button;
|
|
1776
1902
|
}
|
|
1777
1903
|
button.addEventListener('click', (event) => {
|
|
1778
1904
|
event.preventDefault?.();
|
|
1779
|
-
|
|
1780
|
-
for (const [parameterId, argument] of Object.entries(node.arguments ?? {})) {
|
|
1781
|
-
args[parameterId] = evaluate(argument, scope);
|
|
1782
|
-
}
|
|
1783
|
-
runAction(node.actionId, args);
|
|
1905
|
+
invoke();
|
|
1784
1906
|
});
|
|
1785
1907
|
return button;
|
|
1786
1908
|
}
|
|
@@ -1828,6 +1950,7 @@ export function createAxiomRuntime(options) {
|
|
|
1828
1950
|
}
|
|
1829
1951
|
function renderApplication() {
|
|
1830
1952
|
inputElements.clear();
|
|
1953
|
+
submitInvokers.clear();
|
|
1831
1954
|
const scope = rootScope();
|
|
1832
1955
|
if (!activeRoute) {
|
|
1833
1956
|
const missing = element('div', 'axiom-no-route');
|
|
@@ -1862,7 +1985,7 @@ export function createAxiomRuntime(options) {
|
|
|
1862
1985
|
return {
|
|
1863
1986
|
start() {
|
|
1864
1987
|
if (started) {
|
|
1865
|
-
return;
|
|
1988
|
+
return startup;
|
|
1866
1989
|
}
|
|
1867
1990
|
started = true;
|
|
1868
1991
|
host.onPathChange(() => {
|
|
@@ -1873,7 +1996,14 @@ export function createAxiomRuntime(options) {
|
|
|
1873
1996
|
inputErrors.clear();
|
|
1874
1997
|
renderApplication();
|
|
1875
1998
|
});
|
|
1999
|
+
// Local state and a first render happen synchronously, so an application is on screen
|
|
2000
|
+
// before the authority is consulted.
|
|
1876
2001
|
syncRoute();
|
|
2002
|
+
startup = remote?.snapshot ? syncAuthoritative() : Promise.resolve();
|
|
2003
|
+
return startup;
|
|
2004
|
+
},
|
|
2005
|
+
authoritativeStateLoaded() {
|
|
2006
|
+
return authoritativeLoaded;
|
|
1877
2007
|
},
|
|
1878
2008
|
render: renderApplication,
|
|
1879
2009
|
getState(id) {
|
|
@@ -1926,13 +2056,11 @@ export function createAxiomRuntime(options) {
|
|
|
1926
2056
|
}
|
|
1927
2057
|
return (await pending.get(id)) ?? result;
|
|
1928
2058
|
},
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
applyAuthoritative(snapshot.states ?? {});
|
|
1935
|
-
renderApplication();
|
|
2059
|
+
syncAuthoritativeState() {
|
|
2060
|
+
return syncAuthoritative();
|
|
2061
|
+
},
|
|
2062
|
+
settled() {
|
|
2063
|
+
return allSettled();
|
|
1936
2064
|
},
|
|
1937
2065
|
evaluate(expression) {
|
|
1938
2066
|
const outcome = tryEvaluate(expression, rootScope(), {});
|
package/dist/source.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cynodia/axiom-runtime",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2-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.
|
|
34
|
+
"@cynodia/axiom-core": "0.6.2-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|