@cynodia/axiom-server 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/LICENSE +21 -0
- package/README.md +46 -0
- package/conformance/argument-validation.json +783 -0
- package/conformance/authorization.json +772 -0
- package/conformance/concurrent-invocations.json +759 -0
- package/conformance/constraint-rolls-back.json +759 -0
- package/conformance/for-each-provisional.json +754 -0
- package/conformance/guard-refuses.json +768 -0
- package/conformance/idempotent-retry.json +761 -0
- package/conformance/mutation-commits.json +754 -0
- package/conformance/restart.json +759 -0
- package/conformance/transition-constraint.json +759 -0
- package/dist/deps.d.ts +11 -0
- package/dist/deps.js +1 -0
- package/dist/host.d.ts +57 -0
- package/dist/host.js +28 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +8 -0
- package/dist/node-host.d.ts +22 -0
- package/dist/node-host.js +63 -0
- package/dist/persistence.d.ts +58 -0
- package/dist/persistence.js +38 -0
- package/dist/protocol.d.ts +72 -0
- package/dist/protocol.js +16 -0
- package/dist/runtime-deps.d.ts +3 -0
- package/dist/runtime-deps.js +2 -0
- package/dist/server.d.ts +58 -0
- package/dist/server.js +419 -0
- package/dist/sqlite-persistence.d.ts +21 -0
- package/dist/sqlite-persistence.js +73 -0
- package/dist/transport.d.ts +38 -0
- package/dist/transport.js +106 -0
- package/package.json +42 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { DEFAULT_THEME, PRINCIPAL, SERVER_IR_CONTRACT, optionalType, entityType, validateValueAgainstType, } from '@cynodia/axiom-core';
|
|
2
|
+
import { MemoryElement, createAxiomRuntime, createMemoryHost } from '@cynodia/axiom-runtime';
|
|
3
|
+
import { createMemoryPersistence } from './persistence.js';
|
|
4
|
+
import { createServerHost } from './host.js';
|
|
5
|
+
import { PROTOCOL_VERSION } from './protocol.js';
|
|
6
|
+
/**
|
|
7
|
+
* Diagnostic codes the authority adds to the runtime vocabulary. They describe failures of
|
|
8
|
+
* the boundary, not of an application rule.
|
|
9
|
+
*/
|
|
10
|
+
export const SERVER_DIAGNOSTIC_CODES = {
|
|
11
|
+
/** The request named an action this authority does not execute. */
|
|
12
|
+
UNKNOWN_SERVER_ACTION: 'UNKNOWN_SERVER_ACTION',
|
|
13
|
+
/** An argument did not conform to its declared parameter type. */
|
|
14
|
+
ARGUMENT_TYPE_MISMATCH: 'ARGUMENT_TYPE_MISMATCH',
|
|
15
|
+
/** The caller may not invoke this action. */
|
|
16
|
+
AUTHORIZATION_DENIED: 'AUTHORIZATION_DENIED',
|
|
17
|
+
/** Another transaction committed the same state first; nothing was applied. */
|
|
18
|
+
CONCURRENCY_CONFLICT: 'CONCURRENCY_CONFLICT',
|
|
19
|
+
/** The request itself was malformed, or spoke an unknown protocol. */
|
|
20
|
+
MALFORMED_REQUEST: 'MALFORMED_REQUEST',
|
|
21
|
+
/** The authority could not be reached, or did not answer. */
|
|
22
|
+
AUTHORITY_UNREACHABLE: 'AUTHORITY_UNREACHABLE',
|
|
23
|
+
};
|
|
24
|
+
const IDEMPOTENCY_WINDOW = 256;
|
|
25
|
+
function diagnostic(code, message, details) {
|
|
26
|
+
// Server codes join the same structured vocabulary, so a client matches on `code` exactly
|
|
27
|
+
// as it does for a local failure.
|
|
28
|
+
return {
|
|
29
|
+
code: code,
|
|
30
|
+
message,
|
|
31
|
+
severity: 'error',
|
|
32
|
+
...(details ? { details } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The authoritative runtime.
|
|
37
|
+
*
|
|
38
|
+
* It executes the **same semantic engine** the client runs, given an IR that contains no UI
|
|
39
|
+
* and no routes. That is deliberate rather than convenient: transaction boundaries,
|
|
40
|
+
* provisional writes, `for-each` ordering, constraint and transition evaluation, rollback
|
|
41
|
+
* and the mutation log are not reimplemented here, so a graph cannot behave differently
|
|
42
|
+
* merely because execution moved to the authority.
|
|
43
|
+
*
|
|
44
|
+
* Requests are serialized. One action runs at a time, and its persistence commit completes
|
|
45
|
+
* before the next begins, so two callers cannot both commit from the same snapshot.
|
|
46
|
+
*/
|
|
47
|
+
export function createAxiomServer(options) {
|
|
48
|
+
if (options.ir.contract !== SERVER_IR_CONTRACT) {
|
|
49
|
+
throw new Error(`Unsupported Server IR contract "${String(options.ir.contract)}"; this runtime executes ${SERVER_IR_CONTRACT}`);
|
|
50
|
+
}
|
|
51
|
+
const ir = options.ir;
|
|
52
|
+
const persistence = options.persistence ?? createMemoryPersistence();
|
|
53
|
+
const host = options.host ?? createServerHost();
|
|
54
|
+
const window = options.idempotencyWindow ?? IDEMPOTENCY_WINDOW;
|
|
55
|
+
const entities = new Map(ir.entities.map((entity) => [entity.id, entity]));
|
|
56
|
+
const statesById = new Map(ir.states.map((state) => [state.id, state]));
|
|
57
|
+
/** Persistable state: derived values are recomputed, never stored. */
|
|
58
|
+
const durableStateIds = ir.states.filter((state) => !state.derivation).map((state) => state.id);
|
|
59
|
+
const runtime = buildRuntime(ir, host);
|
|
60
|
+
let storeRevision = 0;
|
|
61
|
+
const revisions = new Map();
|
|
62
|
+
const replies = new Map();
|
|
63
|
+
let queue = Promise.resolve();
|
|
64
|
+
let started = false;
|
|
65
|
+
/** Runs `body` after every earlier request has finished, and before any later one. */
|
|
66
|
+
function serialize(body) {
|
|
67
|
+
const next = queue.then(body, body);
|
|
68
|
+
queue = next.then(() => undefined, () => undefined);
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
function report(event) {
|
|
72
|
+
host.report?.(event);
|
|
73
|
+
}
|
|
74
|
+
/** The caller's identity field only. A whole principal record is never reported. */
|
|
75
|
+
function principalIdentity(principal) {
|
|
76
|
+
if (!principal || !ir.principalEntityId) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const identity = entities.get(ir.principalEntityId)?.identityFieldId;
|
|
80
|
+
return identity ? principal[identity] : undefined;
|
|
81
|
+
}
|
|
82
|
+
async function resolvePrincipal(request) {
|
|
83
|
+
if (!host.authenticate) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return (await host.authenticate(request.credential ?? null)) ?? null;
|
|
87
|
+
}
|
|
88
|
+
/** Untrusted input, checked against the declared parameter types. */
|
|
89
|
+
function checkArguments(action, args) {
|
|
90
|
+
const problems = [];
|
|
91
|
+
const declared = new Map((action.parameters ?? []).map((parameter) => [String(parameter.id), parameter]));
|
|
92
|
+
for (const key of Object.keys(args)) {
|
|
93
|
+
if (!declared.has(key)) {
|
|
94
|
+
problems.push(diagnostic(SERVER_DIAGNOSTIC_CODES.ARGUMENT_TYPE_MISMATCH, `${action.name ?? action.id} has no parameter ${key}`, { parameterId: key, expected: [...declared.keys()] }));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const parameter of action.parameters ?? []) {
|
|
98
|
+
const present = Object.prototype.hasOwnProperty.call(args, String(parameter.id));
|
|
99
|
+
const value = args[String(parameter.id)];
|
|
100
|
+
if (!present || value === undefined || value === null) {
|
|
101
|
+
if (parameter.required) {
|
|
102
|
+
problems.push(diagnostic(SERVER_DIAGNOSTIC_CODES.ARGUMENT_TYPE_MISMATCH, `${action.name ?? action.id} requires ${parameter.name ?? parameter.id}`, { parameterId: parameter.id }));
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!parameter.valueType) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// The same walk that checks seed data checks hostile input; there is no second
|
|
110
|
+
// validation system to drift from the type model.
|
|
111
|
+
const issues = validateValueAgainstType(value, parameter.valueType, {
|
|
112
|
+
path: String(parameter.id),
|
|
113
|
+
getEntity: (id) => entities.get(id),
|
|
114
|
+
});
|
|
115
|
+
for (const problem of issues) {
|
|
116
|
+
problems.push(diagnostic(SERVER_DIAGNOSTIC_CODES.ARGUMENT_TYPE_MISMATCH, problem.message, {
|
|
117
|
+
parameterId: parameter.id,
|
|
118
|
+
...problem.details,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return problems;
|
|
123
|
+
}
|
|
124
|
+
/** Authorization, evaluated here and nowhere else. */
|
|
125
|
+
function authorize(action, context) {
|
|
126
|
+
if (!action.authorization) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
runtime.hydrateState(PRINCIPAL, context.principal);
|
|
130
|
+
const outcome = runtime.evaluate(action.authorization);
|
|
131
|
+
if (!outcome.ok) {
|
|
132
|
+
// A rule that cannot be evaluated denies, exactly as an unevaluable constraint is
|
|
133
|
+
// counted as violated.
|
|
134
|
+
return diagnostic(SERVER_DIAGNOSTIC_CODES.AUTHORIZATION_DENIED, `Authorization for ${action.name ?? action.id} could not be evaluated`, { actionId: action.id, cause: outcome.diagnostic.code });
|
|
135
|
+
}
|
|
136
|
+
const permitted = Array.isArray(outcome.value) ? outcome.value.length > 0 : Boolean(outcome.value);
|
|
137
|
+
return permitted
|
|
138
|
+
? null
|
|
139
|
+
: diagnostic(SERVER_DIAGNOSTIC_CODES.AUTHORIZATION_DENIED, `The caller may not invoke ${action.name ?? action.id}`, { actionId: action.id, principal: principalIdentity(context.principal) });
|
|
140
|
+
}
|
|
141
|
+
function snapshotOf() {
|
|
142
|
+
const states = {};
|
|
143
|
+
for (const stateId of ir.observableStateIds) {
|
|
144
|
+
states[stateId] = runtime.getState(stateId);
|
|
145
|
+
}
|
|
146
|
+
return { revision: storeRevision, states };
|
|
147
|
+
}
|
|
148
|
+
function remember(requestId, response) {
|
|
149
|
+
replies.set(requestId, response);
|
|
150
|
+
if (replies.size > window) {
|
|
151
|
+
const oldest = replies.keys().next().value;
|
|
152
|
+
if (oldest !== undefined) {
|
|
153
|
+
replies.delete(oldest);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function invoke(request) {
|
|
158
|
+
const startedAt = Date.now();
|
|
159
|
+
if (request.requestId) {
|
|
160
|
+
const previous = replies.get(request.requestId);
|
|
161
|
+
if (previous) {
|
|
162
|
+
// A retry after a lost response must not execute the action a second time.
|
|
163
|
+
report({ kind: 'replay', actionId: request.actionId, requestId: request.requestId });
|
|
164
|
+
return { ...previous, replayed: true };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Resolved from this authority's own IR. A client's idea of what an action does is
|
|
168
|
+
// never consulted.
|
|
169
|
+
const action = ir.actions[request.actionId];
|
|
170
|
+
if (!action) {
|
|
171
|
+
const diagnostics = [
|
|
172
|
+
diagnostic(SERVER_DIAGNOSTIC_CODES.UNKNOWN_SERVER_ACTION, `This authority does not execute ${String(request.actionId)}`, { actionId: request.actionId }),
|
|
173
|
+
];
|
|
174
|
+
report({ kind: 'reject', actionId: request.actionId, ok: false, diagnostics });
|
|
175
|
+
return refusal(diagnostics, request.requestId);
|
|
176
|
+
}
|
|
177
|
+
const context = {
|
|
178
|
+
principal: await resolvePrincipal(request),
|
|
179
|
+
...(request.credential !== undefined ? { credential: request.credential } : {}),
|
|
180
|
+
...(request.requestId ? { requestId: request.requestId } : {}),
|
|
181
|
+
};
|
|
182
|
+
const argumentProblems = checkArguments(action, request.arguments ?? {});
|
|
183
|
+
if (argumentProblems.length > 0) {
|
|
184
|
+
report({ kind: 'reject', actionId: action.id, ok: false, diagnostics: argumentProblems });
|
|
185
|
+
return refusal(argumentProblems, request.requestId);
|
|
186
|
+
}
|
|
187
|
+
const denial = authorize(action, context);
|
|
188
|
+
if (denial) {
|
|
189
|
+
report({
|
|
190
|
+
kind: 'reject',
|
|
191
|
+
actionId: action.id,
|
|
192
|
+
ok: false,
|
|
193
|
+
principal: principalIdentity(context.principal),
|
|
194
|
+
diagnostics: [denial],
|
|
195
|
+
});
|
|
196
|
+
return refusal([denial], request.requestId);
|
|
197
|
+
}
|
|
198
|
+
// Everything the transaction might touch, as it stands now, so a refused commit can be
|
|
199
|
+
// undone exactly.
|
|
200
|
+
const before = new Map();
|
|
201
|
+
for (const stateId of durableStateIds) {
|
|
202
|
+
before.set(stateId, runtime.getState(stateId));
|
|
203
|
+
}
|
|
204
|
+
const mark = runtime.getMutationLog().length;
|
|
205
|
+
runtime.clearDiagnostics();
|
|
206
|
+
const result = runtime.invokeAction(action.id, request.arguments ?? {});
|
|
207
|
+
const written = new Set();
|
|
208
|
+
for (const entry of runtime.getMutationLog().slice(mark)) {
|
|
209
|
+
if (entry.outcome === 'committed') {
|
|
210
|
+
written.add(entry.path.rootStateId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const writes = [...written].filter((stateId) => durableStateIds.includes(stateId));
|
|
214
|
+
if (!result.ok || writes.length === 0) {
|
|
215
|
+
const response = respond(result.ok, result.diagnostics, {}, request.requestId);
|
|
216
|
+
report({
|
|
217
|
+
kind: 'invoke',
|
|
218
|
+
actionId: action.id,
|
|
219
|
+
ok: result.ok,
|
|
220
|
+
principal: principalIdentity(context.principal),
|
|
221
|
+
requestId: request.requestId,
|
|
222
|
+
durationMs: Date.now() - startedAt,
|
|
223
|
+
revision: storeRevision,
|
|
224
|
+
diagnostics: result.diagnostics,
|
|
225
|
+
committed: [],
|
|
226
|
+
});
|
|
227
|
+
if (request.requestId) {
|
|
228
|
+
remember(request.requestId, response);
|
|
229
|
+
}
|
|
230
|
+
return response;
|
|
231
|
+
}
|
|
232
|
+
const expected = {};
|
|
233
|
+
for (const stateId of writes) {
|
|
234
|
+
expected[stateId] = revisions.get(stateId) ?? 0;
|
|
235
|
+
}
|
|
236
|
+
const outcome = await persistence.commit({
|
|
237
|
+
writes: writes.map((stateId) => ({ stateId, value: runtime.getState(stateId) })),
|
|
238
|
+
expected,
|
|
239
|
+
});
|
|
240
|
+
if (!outcome.committed) {
|
|
241
|
+
// Nothing durable was written, so nothing in memory may survive either.
|
|
242
|
+
for (const stateId of writes) {
|
|
243
|
+
runtime.hydrateState(stateId, before.get(stateId));
|
|
244
|
+
}
|
|
245
|
+
const diagnostics = [
|
|
246
|
+
diagnostic(SERVER_DIAGNOSTIC_CODES.CONCURRENCY_CONFLICT, `${action.name ?? action.id} was not committed: ${outcome.conflicts.join(', ')} changed while it ran`, { actionId: action.id, conflicts: outcome.conflicts }),
|
|
247
|
+
];
|
|
248
|
+
report({ kind: 'conflict', actionId: action.id, ok: false, diagnostics, revision: outcome.revision });
|
|
249
|
+
const response = refusal(diagnostics, request.requestId);
|
|
250
|
+
if (request.requestId) {
|
|
251
|
+
remember(request.requestId, response);
|
|
252
|
+
}
|
|
253
|
+
return response;
|
|
254
|
+
}
|
|
255
|
+
storeRevision = outcome.revision;
|
|
256
|
+
for (const stateId of writes) {
|
|
257
|
+
revisions.set(stateId, outcome.revision);
|
|
258
|
+
}
|
|
259
|
+
const changes = {};
|
|
260
|
+
for (const stateId of ir.observableStateIds) {
|
|
261
|
+
if (written.has(stateId) || ir.states.find((state) => state.id === stateId)?.derivation) {
|
|
262
|
+
changes[stateId] = runtime.getState(stateId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const response = respond(true, result.diagnostics, changes, request.requestId);
|
|
266
|
+
report({
|
|
267
|
+
kind: 'invoke',
|
|
268
|
+
actionId: action.id,
|
|
269
|
+
ok: true,
|
|
270
|
+
principal: principalIdentity(context.principal),
|
|
271
|
+
requestId: request.requestId,
|
|
272
|
+
durationMs: Date.now() - startedAt,
|
|
273
|
+
revision: storeRevision,
|
|
274
|
+
committed: writes,
|
|
275
|
+
});
|
|
276
|
+
if (request.requestId) {
|
|
277
|
+
remember(request.requestId, response);
|
|
278
|
+
}
|
|
279
|
+
return response;
|
|
280
|
+
}
|
|
281
|
+
function respond(ok, diagnostics, changes, requestId) {
|
|
282
|
+
return {
|
|
283
|
+
kind: 'result',
|
|
284
|
+
protocol: PROTOCOL_VERSION,
|
|
285
|
+
ok,
|
|
286
|
+
diagnostics,
|
|
287
|
+
changes,
|
|
288
|
+
revision: storeRevision,
|
|
289
|
+
...(requestId ? { requestId } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function refusal(diagnostics, requestId) {
|
|
293
|
+
return respond(false, diagnostics, {}, requestId);
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
async start() {
|
|
297
|
+
if (started) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
started = true;
|
|
301
|
+
// Committed state is restored administratively: it is already authoritative, so it
|
|
302
|
+
// is not re-validated as though it were being proposed.
|
|
303
|
+
for (const entry of await persistence.load()) {
|
|
304
|
+
if (statesById.has(entry.stateId)) {
|
|
305
|
+
runtime.hydrateState(entry.stateId, entry.value);
|
|
306
|
+
revisions.set(entry.stateId, entry.revision);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
storeRevision = await persistence.revision();
|
|
310
|
+
},
|
|
311
|
+
handle(request) {
|
|
312
|
+
return serialize(async () => {
|
|
313
|
+
if (request?.protocol !== PROTOCOL_VERSION) {
|
|
314
|
+
return {
|
|
315
|
+
kind: 'error',
|
|
316
|
+
protocol: PROTOCOL_VERSION,
|
|
317
|
+
diagnostics: [
|
|
318
|
+
diagnostic(SERVER_DIAGNOSTIC_CODES.MALFORMED_REQUEST, `Unsupported protocol ${String(request?.protocol)}`),
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (request.kind === 'snapshot') {
|
|
323
|
+
report({ kind: 'snapshot', revision: storeRevision });
|
|
324
|
+
const response = {
|
|
325
|
+
kind: 'snapshot',
|
|
326
|
+
protocol: PROTOCOL_VERSION,
|
|
327
|
+
snapshot: snapshotOf(),
|
|
328
|
+
};
|
|
329
|
+
return response;
|
|
330
|
+
}
|
|
331
|
+
if (request.kind === 'invoke') {
|
|
332
|
+
return invoke(request);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
kind: 'error',
|
|
336
|
+
protocol: PROTOCOL_VERSION,
|
|
337
|
+
diagnostics: [
|
|
338
|
+
diagnostic(SERVER_DIAGNOSTIC_CODES.MALFORMED_REQUEST, `Unknown request kind ${String(request.kind)}`),
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
snapshot: snapshotOf,
|
|
344
|
+
getState: (id) => runtime.getState(id),
|
|
345
|
+
revision: () => storeRevision,
|
|
346
|
+
mutationLog: () => runtime.getMutationLog(),
|
|
347
|
+
async stop() {
|
|
348
|
+
await queue.catch(() => undefined);
|
|
349
|
+
await persistence.close?.();
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Wraps the Server IR as an `ApplicationIR` with no UI, no routes and no presentation, and
|
|
355
|
+
* runs the ordinary semantic engine over it. Nothing about transactions, constraints or
|
|
356
|
+
* iteration is reimplemented, which is the only way to guarantee the semantics match.
|
|
357
|
+
*/
|
|
358
|
+
function buildRuntime(ir, host) {
|
|
359
|
+
const nodes = {};
|
|
360
|
+
for (const entity of ir.entities) {
|
|
361
|
+
nodes[entity.id] = entity;
|
|
362
|
+
}
|
|
363
|
+
for (const state of ir.states) {
|
|
364
|
+
nodes[state.id] = state;
|
|
365
|
+
}
|
|
366
|
+
for (const action of Object.values(ir.actions)) {
|
|
367
|
+
nodes[action.id] = action;
|
|
368
|
+
}
|
|
369
|
+
for (const constraint of ir.constraints) {
|
|
370
|
+
nodes[constraint.id] = constraint;
|
|
371
|
+
}
|
|
372
|
+
for (const constraint of ir.transitionConstraints) {
|
|
373
|
+
nodes[constraint.id] = constraint;
|
|
374
|
+
}
|
|
375
|
+
const states = [...ir.states];
|
|
376
|
+
if (ir.principalEntityId) {
|
|
377
|
+
// The caller is bound through an ordinary state so that `ref(PRINCIPAL)` resolves with
|
|
378
|
+
// the existing scope rules. It is never persisted and never observable.
|
|
379
|
+
const principalState = {
|
|
380
|
+
id: PRINCIPAL,
|
|
381
|
+
kind: 'state',
|
|
382
|
+
name: 'principal',
|
|
383
|
+
valueType: optionalType(entityType(ir.principalEntityId)),
|
|
384
|
+
ephemeral: true,
|
|
385
|
+
initialValue: null,
|
|
386
|
+
};
|
|
387
|
+
states.push(principalState);
|
|
388
|
+
nodes[PRINCIPAL] = principalState;
|
|
389
|
+
}
|
|
390
|
+
const applicationIR = {
|
|
391
|
+
id: ir.id,
|
|
392
|
+
name: ir.name,
|
|
393
|
+
version: ir.version,
|
|
394
|
+
nodes,
|
|
395
|
+
fields: ir.fields,
|
|
396
|
+
entities: ir.entities,
|
|
397
|
+
states,
|
|
398
|
+
actions: ir.actions,
|
|
399
|
+
uiNodes: {},
|
|
400
|
+
constraints: ir.constraints,
|
|
401
|
+
transitionConstraints: ir.transitionConstraints,
|
|
402
|
+
routes: [],
|
|
403
|
+
edges: [],
|
|
404
|
+
locationTypes: {},
|
|
405
|
+
locationRoots: {},
|
|
406
|
+
locationRequired: {},
|
|
407
|
+
repeatIdentityFields: {},
|
|
408
|
+
authority: {},
|
|
409
|
+
remoteActionIds: [],
|
|
410
|
+
theme: DEFAULT_THEME,
|
|
411
|
+
presentation: {},
|
|
412
|
+
};
|
|
413
|
+
const dom = createMemoryHost();
|
|
414
|
+
return createAxiomRuntime({
|
|
415
|
+
ir: applicationIR,
|
|
416
|
+
rootElement: new MemoryElement('div'),
|
|
417
|
+
host: { ...dom, now: () => host.now(), uuid: () => host.uuid() },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PersistenceAdapter } from './persistence.js';
|
|
2
|
+
/**
|
|
3
|
+
* Durable persistence on SQLite, through Node's built-in `node:sqlite`.
|
|
4
|
+
*
|
|
5
|
+
* State is stored in document form — one row per state, holding its serialized semantic
|
|
6
|
+
* value and its revision. That is deliberate: 0.6 is about persistence *semantics*, and a
|
|
7
|
+
* relational projection of the semantic model is separate design work. What matters here is
|
|
8
|
+
* durability, atomicity, identity preservation and transaction correctness.
|
|
9
|
+
*
|
|
10
|
+
* The whole semantic transaction is written inside one SQL transaction, so a crash cannot
|
|
11
|
+
* leave half of an action committed.
|
|
12
|
+
*/
|
|
13
|
+
export interface SqlitePersistenceOptions {
|
|
14
|
+
/** A file path, or `':memory:'`. */
|
|
15
|
+
location: string;
|
|
16
|
+
table?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Whether this Node build offers `node:sqlite` at all. */
|
|
19
|
+
export declare function isSqliteAvailable(): Promise<boolean>;
|
|
20
|
+
export declare function createSqlitePersistence(options: SqlitePersistenceOptions): Promise<PersistenceAdapter>;
|
|
21
|
+
//# sourceMappingURL=sqlite-persistence.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Whether this Node build offers `node:sqlite` at all. */
|
|
2
|
+
export async function isSqliteAvailable() {
|
|
3
|
+
try {
|
|
4
|
+
const module = (await import('node:sqlite'));
|
|
5
|
+
return typeof module.DatabaseSync === 'function';
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function createSqlitePersistence(options) {
|
|
12
|
+
const module = (await import('node:sqlite'));
|
|
13
|
+
const table = options.table ?? 'axiom_state';
|
|
14
|
+
const database = new module.DatabaseSync(options.location);
|
|
15
|
+
database.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
17
|
+
state_id TEXT PRIMARY KEY,
|
|
18
|
+
revision INTEGER NOT NULL,
|
|
19
|
+
value TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS ${table}_meta (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
value INTEGER NOT NULL
|
|
24
|
+
);
|
|
25
|
+
`);
|
|
26
|
+
const readAll = database.prepare(`SELECT state_id, revision, value FROM ${table}`);
|
|
27
|
+
const readRevision = database.prepare(`SELECT value FROM ${table}_meta WHERE key = 'revision'`);
|
|
28
|
+
const readOne = database.prepare(`SELECT revision FROM ${table} WHERE state_id = ?`);
|
|
29
|
+
const upsert = database.prepare(`INSERT INTO ${table} (state_id, revision, value) VALUES (?, ?, ?)
|
|
30
|
+
ON CONFLICT(state_id) DO UPDATE SET revision = excluded.revision, value = excluded.value`);
|
|
31
|
+
const setRevision = database.prepare(`INSERT INTO ${table}_meta (key, value) VALUES ('revision', ?)
|
|
32
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`);
|
|
33
|
+
const currentRevision = () => Number(readRevision.get()?.value ?? 0);
|
|
34
|
+
return {
|
|
35
|
+
async load() {
|
|
36
|
+
return readAll.all().map((row) => ({
|
|
37
|
+
stateId: String(row.state_id),
|
|
38
|
+
revision: Number(row.revision),
|
|
39
|
+
value: JSON.parse(String(row.value)),
|
|
40
|
+
}));
|
|
41
|
+
},
|
|
42
|
+
async commit(commit) {
|
|
43
|
+
const conflicts = commit.writes
|
|
44
|
+
.map((write) => write.stateId)
|
|
45
|
+
.filter((stateId) => Number(readOne.get(stateId)?.revision ?? 0) !== (commit.expected[stateId] ?? 0));
|
|
46
|
+
if (conflicts.length > 0) {
|
|
47
|
+
return { committed: false, revision: currentRevision(), conflicts };
|
|
48
|
+
}
|
|
49
|
+
const revision = currentRevision() + 1;
|
|
50
|
+
// One SQL transaction for one semantic transaction. A failure part-way leaves the
|
|
51
|
+
// store exactly as it was.
|
|
52
|
+
database.exec('BEGIN IMMEDIATE');
|
|
53
|
+
try {
|
|
54
|
+
for (const write of commit.writes) {
|
|
55
|
+
upsert.run(write.stateId, revision, JSON.stringify(write.value ?? null));
|
|
56
|
+
}
|
|
57
|
+
setRevision.run(revision);
|
|
58
|
+
database.exec('COMMIT');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
database.exec('ROLLBACK');
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
return { committed: true, revision, conflicts: [] };
|
|
65
|
+
},
|
|
66
|
+
async revision() {
|
|
67
|
+
return currentRevision();
|
|
68
|
+
},
|
|
69
|
+
async close() {
|
|
70
|
+
database.close();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ServerResponse, TransportAdapter } from './protocol.js';
|
|
2
|
+
import type { AxiomServer } from './server.js';
|
|
3
|
+
import type { RemoteGateway } from '@cynodia/axiom-runtime';
|
|
4
|
+
/**
|
|
5
|
+
* In-process transport: the client runtime talks to an authority in the same process,
|
|
6
|
+
* without opening a port.
|
|
7
|
+
*
|
|
8
|
+
* This is what makes an end-to-end semantic test deterministic — the whole client/server
|
|
9
|
+
* round trip, with the real authority, and nothing asynchronous but the call itself.
|
|
10
|
+
*/
|
|
11
|
+
export interface DirectTransportOptions {
|
|
12
|
+
/** Read per request, so a client is configured the same way on every transport. */
|
|
13
|
+
credential?: () => string | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function createDirectTransport(server: AxiomServer, options?: DirectTransportOptions): TransportAdapter;
|
|
16
|
+
export interface HttpTransportOptions {
|
|
17
|
+
url: string;
|
|
18
|
+
/** Supplied per request, so a host can refresh a credential without rebuilding the client. */
|
|
19
|
+
credential?: () => string | null;
|
|
20
|
+
fetch?: typeof globalThis.fetch;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The reference network transport.
|
|
25
|
+
*
|
|
26
|
+
* HTTP is an implementation detail of this adapter: one endpoint carrying semantic
|
|
27
|
+
* requests, never a route per entity. No ApplicationGraph mentions a URL or a verb, and
|
|
28
|
+
* replacing this with a WebSocket or a worker channel changes nothing in a graph.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createHttpTransport(options: HttpTransportOptions): TransportAdapter;
|
|
31
|
+
/**
|
|
32
|
+
* Adapts a transport into the gateway a client runtime expects, so a client is configured
|
|
33
|
+
* with one object and knows nothing about how the authority is reached.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createRemoteGateway(transport: TransportAdapter): RemoteGateway;
|
|
36
|
+
/** Reads and dispatches one semantic request. Shared by every server-side transport. */
|
|
37
|
+
export declare function dispatch(server: AxiomServer, body: unknown): Promise<ServerResponse>;
|
|
38
|
+
//# sourceMappingURL=transport.d.ts.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION, isServerRequest } from './protocol.js';
|
|
2
|
+
import { SERVER_DIAGNOSTIC_CODES } from './server.js';
|
|
3
|
+
function boundaryFailure(code, message) {
|
|
4
|
+
return {
|
|
5
|
+
kind: 'error',
|
|
6
|
+
protocol: PROTOCOL_VERSION,
|
|
7
|
+
diagnostics: [
|
|
8
|
+
{
|
|
9
|
+
code: code,
|
|
10
|
+
message,
|
|
11
|
+
severity: 'error',
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function createDirectTransport(server, options = {}) {
|
|
17
|
+
return {
|
|
18
|
+
async send(request) {
|
|
19
|
+
const credential = options.credential?.() ?? request.credential ?? null;
|
|
20
|
+
// The request crosses a real boundary even here: it is serialized, so a test cannot
|
|
21
|
+
// accidentally hand the authority a live object reference.
|
|
22
|
+
const copy = JSON.parse(JSON.stringify({ ...request, credential }));
|
|
23
|
+
return server.handle(copy);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The reference network transport.
|
|
29
|
+
*
|
|
30
|
+
* HTTP is an implementation detail of this adapter: one endpoint carrying semantic
|
|
31
|
+
* requests, never a route per entity. No ApplicationGraph mentions a URL or a verb, and
|
|
32
|
+
* replacing this with a WebSocket or a worker channel changes nothing in a graph.
|
|
33
|
+
*/
|
|
34
|
+
export function createHttpTransport(options) {
|
|
35
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
36
|
+
return {
|
|
37
|
+
async send(request) {
|
|
38
|
+
if (!fetchImpl) {
|
|
39
|
+
return boundaryFailure(SERVER_DIAGNOSTIC_CODES.AUTHORITY_UNREACHABLE, 'No fetch implementation is available');
|
|
40
|
+
}
|
|
41
|
+
const credential = options.credential?.() ?? null;
|
|
42
|
+
const controller = options.timeoutMs ? new AbortController() : undefined;
|
|
43
|
+
const timer = controller
|
|
44
|
+
? setTimeout(() => controller.abort(), options.timeoutMs)
|
|
45
|
+
: undefined;
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetchImpl(options.url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ ...request, credential }),
|
|
51
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
return boundaryFailure(SERVER_DIAGNOSTIC_CODES.AUTHORITY_UNREACHABLE, `The authority answered ${response.status}`);
|
|
55
|
+
}
|
|
56
|
+
return (await response.json());
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// A network failure becomes a structured diagnostic, not an exception escaping into
|
|
60
|
+
// application code.
|
|
61
|
+
return boundaryFailure(SERVER_DIAGNOSTIC_CODES.AUTHORITY_UNREACHABLE, error instanceof Error ? error.message : String(error));
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (timer !== undefined) {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Adapts a transport into the gateway a client runtime expects, so a client is configured
|
|
73
|
+
* with one object and knows nothing about how the authority is reached.
|
|
74
|
+
*/
|
|
75
|
+
export function createRemoteGateway(transport) {
|
|
76
|
+
return {
|
|
77
|
+
async invoke(request) {
|
|
78
|
+
const answer = await transport.send({
|
|
79
|
+
kind: 'invoke',
|
|
80
|
+
protocol: PROTOCOL_VERSION,
|
|
81
|
+
actionId: request.actionId,
|
|
82
|
+
arguments: request.arguments,
|
|
83
|
+
requestId: request.requestId,
|
|
84
|
+
});
|
|
85
|
+
if (answer.kind === 'result') {
|
|
86
|
+
return { ok: answer.ok, diagnostics: answer.diagnostics, changes: answer.changes };
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
diagnostics: answer.kind === 'error' ? answer.diagnostics : [],
|
|
91
|
+
changes: {},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
async snapshot() {
|
|
95
|
+
const answer = await transport.send({ kind: 'snapshot', protocol: PROTOCOL_VERSION });
|
|
96
|
+
return { states: answer.kind === 'snapshot' ? answer.snapshot.states : {} };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Reads and dispatches one semantic request. Shared by every server-side transport. */
|
|
101
|
+
export async function dispatch(server, body) {
|
|
102
|
+
if (!isServerRequest(body)) {
|
|
103
|
+
return boundaryFailure(SERVER_DIAGNOSTIC_CODES.MALFORMED_REQUEST, 'The request is not an Axiom semantic request');
|
|
104
|
+
}
|
|
105
|
+
return server.handle(body);
|
|
106
|
+
}
|