@cynodia/axiom-runtime 0.3.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/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/dom.d.ts +42 -0
- package/dist/dom.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/memory-host.d.ts +52 -0
- package/dist/memory-host.js +145 -0
- package/dist/mutation/mutation-engine.d.ts +44 -0
- package/dist/mutation/mutation-engine.js +92 -0
- package/dist/mutation/resolve-location.d.ts +37 -0
- package/dist/mutation/resolve-location.js +108 -0
- package/dist/mutation/store.d.ts +14 -0
- package/dist/mutation/store.js +19 -0
- package/dist/mutation/transaction.d.ts +21 -0
- package/dist/mutation/transaction.js +45 -0
- package/dist/mutation/values.d.ts +17 -0
- package/dist/mutation/values.js +73 -0
- package/dist/runtime.d.ts +48 -0
- package/dist/runtime.js +1149 -0
- package/dist/source.d.ts +5 -0
- package/dist/source.js +71 -0
- package/package.json +40 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
import { createMutationEngine } from './mutation/mutation-engine.js';
|
|
2
|
+
import { LocationResolutionError, resolveLocation } from './mutation/resolve-location.js';
|
|
3
|
+
import { createStateStore } from './mutation/store.js';
|
|
4
|
+
import { createTransactionManager } from './mutation/transaction.js';
|
|
5
|
+
import { cloneValue, compareValues, deepFreeze, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
|
|
6
|
+
const MISSING = Symbol('missing');
|
|
7
|
+
function unwrapType(type) {
|
|
8
|
+
return type.kind === 'optional' ? unwrapType(type.valueType) : type;
|
|
9
|
+
}
|
|
10
|
+
function defaultForType(type) {
|
|
11
|
+
const resolved = unwrapType(type);
|
|
12
|
+
if (type.kind === 'optional') {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
switch (resolved.kind) {
|
|
16
|
+
case 'collection':
|
|
17
|
+
return [];
|
|
18
|
+
case 'primitive':
|
|
19
|
+
switch (resolved.primitive) {
|
|
20
|
+
case 'number':
|
|
21
|
+
return 0;
|
|
22
|
+
case 'boolean':
|
|
23
|
+
return false;
|
|
24
|
+
default:
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
case 'enum':
|
|
28
|
+
return resolved.values[0] ?? '';
|
|
29
|
+
default:
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function createAxiomRuntime(options) {
|
|
34
|
+
const { ir, rootElement, host } = options;
|
|
35
|
+
const store = createStateStore();
|
|
36
|
+
const derivedCache = new Map();
|
|
37
|
+
const natives = new Map(Object.entries(options.nativeOperations ?? {}));
|
|
38
|
+
const diagnostics = [];
|
|
39
|
+
const inputElements = new Map();
|
|
40
|
+
let focusedNodeId = null;
|
|
41
|
+
let focusedCaret = null;
|
|
42
|
+
let started = false;
|
|
43
|
+
let transactionCounter = 0;
|
|
44
|
+
const mutationLog = [];
|
|
45
|
+
const inputValidation = options.inputValidation ?? 'immediate';
|
|
46
|
+
const statesById = new Map(ir.states.map((state) => [state.id, state]));
|
|
47
|
+
const entitiesById = new Map(ir.entities.map((entity) => [entity.id, entity]));
|
|
48
|
+
const parameterTypes = new Map();
|
|
49
|
+
for (const action of Object.values(ir.actions)) {
|
|
50
|
+
for (const parameter of action.parameters ?? []) {
|
|
51
|
+
parameterTypes.set(parameter.id, parameter.valueType);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const route of ir.routes) {
|
|
55
|
+
for (const parameter of route.parameters) {
|
|
56
|
+
parameterTypes.set(parameter.id, parameter.valueType);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function report(diagnostic) {
|
|
60
|
+
diagnostics.push(diagnostic);
|
|
61
|
+
if (diagnostic.severity === 'error') {
|
|
62
|
+
host.report?.(`${diagnostic.code}: ${diagnostic.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------- state store
|
|
66
|
+
function storageKey(state) {
|
|
67
|
+
if (state.persistence?.kind !== 'local-storage') {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return state.persistence.key ?? `${ir.id}:${state.id}`;
|
|
71
|
+
}
|
|
72
|
+
function initializeStore() {
|
|
73
|
+
for (const state of ir.states) {
|
|
74
|
+
if (state.derivation) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const key = storageKey(state);
|
|
78
|
+
if (key && host.storage) {
|
|
79
|
+
const persisted = host.storage.read(key);
|
|
80
|
+
if (persisted !== null) {
|
|
81
|
+
try {
|
|
82
|
+
store.write(state.id, JSON.parse(persisted));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
report({
|
|
87
|
+
code: 'PERSISTED_STATE_UNREADABLE',
|
|
88
|
+
message: `Stored value for ${state.id} could not be parsed; falling back to the initial value`,
|
|
89
|
+
severity: 'warning',
|
|
90
|
+
nodeId: state.id,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
store.write(state.id, state.initialValue === undefined ? defaultForType(state.valueType) : cloneValue(state.initialValue));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function persistState(stateId) {
|
|
99
|
+
const state = statesById.get(stateId);
|
|
100
|
+
if (!state || !host.storage) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const key = storageKey(state);
|
|
104
|
+
if (key) {
|
|
105
|
+
host.storage.write(key, JSON.stringify(store.read(stateId) ?? null));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Derived values are recomputed from their derivation and handed out as frozen copies,
|
|
110
|
+
* so nothing can work by sharing an object with the state it was derived from.
|
|
111
|
+
*/
|
|
112
|
+
function readState(stateId) {
|
|
113
|
+
const state = statesById.get(stateId);
|
|
114
|
+
if (state?.derivation) {
|
|
115
|
+
if (derivedCache.has(stateId)) {
|
|
116
|
+
return derivedCache.get(stateId);
|
|
117
|
+
}
|
|
118
|
+
derivedCache.set(stateId, null);
|
|
119
|
+
const value = deepFreeze(cloneValue(evaluate(state.derivation, rootScope())));
|
|
120
|
+
derivedCache.set(stateId, value);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
return store.read(stateId);
|
|
124
|
+
}
|
|
125
|
+
/** The only place the store is written. Values are frozen on the way in. */
|
|
126
|
+
function writeState(stateId, value) {
|
|
127
|
+
if (!statesById.has(stateId)) {
|
|
128
|
+
report({
|
|
129
|
+
code: 'UNKNOWN_STATE',
|
|
130
|
+
message: `Cannot write to unknown state ${stateId}`,
|
|
131
|
+
severity: 'error',
|
|
132
|
+
nodeId: stateId,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (statesById.get(stateId)?.derivation) {
|
|
137
|
+
report({
|
|
138
|
+
code: 'DERIVED_STATE_WRITE',
|
|
139
|
+
message: `${stateId} is derived state and cannot be written to`,
|
|
140
|
+
severity: 'error',
|
|
141
|
+
nodeId: stateId,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
store.write(stateId, value);
|
|
146
|
+
derivedCache.clear();
|
|
147
|
+
persistState(stateId);
|
|
148
|
+
}
|
|
149
|
+
function restoreStore(snapshot) {
|
|
150
|
+
store.restore(snapshot);
|
|
151
|
+
derivedCache.clear();
|
|
152
|
+
}
|
|
153
|
+
// ------------------------------------------------------- mutation subsystem
|
|
154
|
+
const transactions = createTransactionManager({
|
|
155
|
+
capture: () => store.capture(),
|
|
156
|
+
restore: restoreStore,
|
|
157
|
+
}, () => {
|
|
158
|
+
transactionCounter += 1;
|
|
159
|
+
return `tx_${transactionCounter}`;
|
|
160
|
+
});
|
|
161
|
+
const mutations = createMutationEngine({
|
|
162
|
+
runtime: {
|
|
163
|
+
readState: (stateId) => readState(stateId),
|
|
164
|
+
writeState: (stateId, value) => writeState(stateId, value),
|
|
165
|
+
evaluate: (expression, scope) => evaluate(expression, scope),
|
|
166
|
+
},
|
|
167
|
+
recordValues: options.recordMutationValues !== false,
|
|
168
|
+
onLog: (entry) => {
|
|
169
|
+
mutationLog.push(entry);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
/**
|
|
173
|
+
* Settles a transaction and records the outcome against everything it logged. Only the
|
|
174
|
+
* outermost transaction decides: a nested one shares its parent's fate.
|
|
175
|
+
*/
|
|
176
|
+
function settle(transaction, outcome) {
|
|
177
|
+
if (outcome === 'committed') {
|
|
178
|
+
transaction.commit();
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
transaction.rollback();
|
|
182
|
+
}
|
|
183
|
+
if (!transaction.isRoot) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const entry of mutationLog) {
|
|
187
|
+
if (entry.transactionId === transaction.id && entry.outcome === undefined) {
|
|
188
|
+
entry.outcome = outcome;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Applies a mutation inside the current transaction and reports resolution failures. */
|
|
193
|
+
function mutate(apply, context, failures) {
|
|
194
|
+
try {
|
|
195
|
+
return apply();
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const failure = {
|
|
199
|
+
code: error instanceof LocationResolutionError ? 'LOCATION_UNRESOLVED' : 'MUTATION_FAILED',
|
|
200
|
+
message: error instanceof Error ? error.message : String(error),
|
|
201
|
+
severity: 'error',
|
|
202
|
+
...(context.sourceNodeId ? { nodeId: context.sourceNodeId } : {}),
|
|
203
|
+
};
|
|
204
|
+
failures.push(failure);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ------------------------------------------------------------------- scopes
|
|
209
|
+
let activeRoute = null;
|
|
210
|
+
function rootScope() {
|
|
211
|
+
const values = new Map();
|
|
212
|
+
if (activeRoute) {
|
|
213
|
+
for (const [parameterId, value] of Object.entries(activeRoute.parameters)) {
|
|
214
|
+
values.set(parameterId, value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { values };
|
|
218
|
+
}
|
|
219
|
+
function childScope(parent, id, value) {
|
|
220
|
+
return { values: new Map([[id, value]]), parent };
|
|
221
|
+
}
|
|
222
|
+
function lookup(scope, id) {
|
|
223
|
+
let current = scope;
|
|
224
|
+
while (current) {
|
|
225
|
+
if (current.values.has(id)) {
|
|
226
|
+
return current.values.get(id);
|
|
227
|
+
}
|
|
228
|
+
current = current.parent;
|
|
229
|
+
}
|
|
230
|
+
return MISSING;
|
|
231
|
+
}
|
|
232
|
+
// --------------------------------------------------------------- evaluation
|
|
233
|
+
function evaluate(expression, scope) {
|
|
234
|
+
switch (expression.kind) {
|
|
235
|
+
case 'literal':
|
|
236
|
+
return expression.value;
|
|
237
|
+
case 'ref': {
|
|
238
|
+
const scoped = lookup(scope, expression.targetId);
|
|
239
|
+
if (scoped !== MISSING) {
|
|
240
|
+
return scoped;
|
|
241
|
+
}
|
|
242
|
+
if (statesById.has(expression.targetId)) {
|
|
243
|
+
return readState(expression.targetId);
|
|
244
|
+
}
|
|
245
|
+
report({
|
|
246
|
+
code: 'UNRESOLVED_REFERENCE',
|
|
247
|
+
message: `Reference ${expression.targetId} could not be resolved`,
|
|
248
|
+
severity: 'error',
|
|
249
|
+
nodeId: expression.targetId,
|
|
250
|
+
});
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
case 'field': {
|
|
254
|
+
const source = evaluate(expression.source, scope);
|
|
255
|
+
if (!isRecord(source)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const value = source[expression.fieldId];
|
|
259
|
+
return value === undefined ? null : value;
|
|
260
|
+
}
|
|
261
|
+
case 'object': {
|
|
262
|
+
const result = {};
|
|
263
|
+
for (const entry of expression.entries) {
|
|
264
|
+
result[entry.fieldId] = evaluate(entry.value, scope);
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
case 'binary':
|
|
269
|
+
return evaluateBinary(expression.operator, expression.left, expression.right, scope);
|
|
270
|
+
case 'unary': {
|
|
271
|
+
const operand = evaluate(expression.operand, scope);
|
|
272
|
+
return expression.operator === 'not' ? !toBoolean(operand) : -Number(operand ?? 0);
|
|
273
|
+
}
|
|
274
|
+
case 'call':
|
|
275
|
+
return evaluateCall(expression.function, expression.arguments, scope);
|
|
276
|
+
case 'filter': {
|
|
277
|
+
const source = evaluate(expression.source, scope);
|
|
278
|
+
if (!Array.isArray(source)) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
return source.filter((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
282
|
+
}
|
|
283
|
+
case 'find': {
|
|
284
|
+
const source = evaluate(expression.source, scope);
|
|
285
|
+
if (!Array.isArray(source)) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const found = source.find((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
|
|
289
|
+
return found === undefined ? null : found;
|
|
290
|
+
}
|
|
291
|
+
default:
|
|
292
|
+
report({
|
|
293
|
+
code: 'UNKNOWN_EXPRESSION',
|
|
294
|
+
message: `Unknown expression kind "${expression.kind}"`,
|
|
295
|
+
severity: 'error',
|
|
296
|
+
});
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function evaluateBinary(operator, leftExpression, rightExpression, scope) {
|
|
301
|
+
if (operator === 'and') {
|
|
302
|
+
return toBoolean(evaluate(leftExpression, scope)) && toBoolean(evaluate(rightExpression, scope));
|
|
303
|
+
}
|
|
304
|
+
if (operator === 'or') {
|
|
305
|
+
return toBoolean(evaluate(leftExpression, scope)) || toBoolean(evaluate(rightExpression, scope));
|
|
306
|
+
}
|
|
307
|
+
const left = evaluate(leftExpression, scope);
|
|
308
|
+
const right = evaluate(rightExpression, scope);
|
|
309
|
+
switch (operator) {
|
|
310
|
+
case 'eq':
|
|
311
|
+
return valuesEqual(left, right);
|
|
312
|
+
case 'neq':
|
|
313
|
+
return !valuesEqual(left, right);
|
|
314
|
+
case 'gt':
|
|
315
|
+
return compareValues(left, right) > 0;
|
|
316
|
+
case 'gte':
|
|
317
|
+
return compareValues(left, right) >= 0;
|
|
318
|
+
case 'lt':
|
|
319
|
+
return compareValues(left, right) < 0;
|
|
320
|
+
case 'lte':
|
|
321
|
+
return compareValues(left, right) <= 0;
|
|
322
|
+
case 'add':
|
|
323
|
+
return Number(left ?? 0) + Number(right ?? 0);
|
|
324
|
+
case 'subtract':
|
|
325
|
+
return Number(left ?? 0) - Number(right ?? 0);
|
|
326
|
+
case 'multiply':
|
|
327
|
+
return Number(left ?? 0) * Number(right ?? 0);
|
|
328
|
+
case 'divide': {
|
|
329
|
+
const divisor = Number(right ?? 0);
|
|
330
|
+
return divisor === 0 ? null : Number(left ?? 0) / divisor;
|
|
331
|
+
}
|
|
332
|
+
default:
|
|
333
|
+
report({ code: 'UNKNOWN_OPERATOR', message: `Unknown operator "${operator}"`, severity: 'error' });
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function evaluateCall(fn, args, scope) {
|
|
338
|
+
const values = args.map((argument) => evaluate(argument, scope));
|
|
339
|
+
switch (fn) {
|
|
340
|
+
case 'required':
|
|
341
|
+
return isPresent(values[0]);
|
|
342
|
+
case 'is-empty':
|
|
343
|
+
return !isPresent(values[0]);
|
|
344
|
+
case 'length':
|
|
345
|
+
return Array.isArray(values[0]) ? values[0].length : toText(values[0]).length;
|
|
346
|
+
case 'count':
|
|
347
|
+
return Array.isArray(values[0]) ? values[0].length : 0;
|
|
348
|
+
case 'contains': {
|
|
349
|
+
const [haystack, needle] = values;
|
|
350
|
+
if (Array.isArray(haystack)) {
|
|
351
|
+
return haystack.some((item) => valuesEqual(item, needle));
|
|
352
|
+
}
|
|
353
|
+
return toText(haystack).toLowerCase().includes(toText(needle).toLowerCase());
|
|
354
|
+
}
|
|
355
|
+
case 'concat':
|
|
356
|
+
return values.map(toText).join('');
|
|
357
|
+
case 'coalesce':
|
|
358
|
+
return values.find((value) => isPresent(value)) ?? null;
|
|
359
|
+
case 'one-of':
|
|
360
|
+
return values.slice(1).some((option) => valuesEqual(option, values[0]));
|
|
361
|
+
case 'lowercase':
|
|
362
|
+
return toText(values[0]).toLowerCase();
|
|
363
|
+
case 'to-string':
|
|
364
|
+
return toText(values[0]);
|
|
365
|
+
case 'now':
|
|
366
|
+
return host.now();
|
|
367
|
+
case 'uuid':
|
|
368
|
+
return host.uuid();
|
|
369
|
+
default:
|
|
370
|
+
report({ code: 'UNKNOWN_FUNCTION', message: `Unknown function "${fn}"`, severity: 'error' });
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// -------------------------------------------------------------- validation
|
|
375
|
+
function collectionEntityId(state) {
|
|
376
|
+
const resolved = unwrapType(state.valueType);
|
|
377
|
+
if (resolved.kind === 'collection') {
|
|
378
|
+
const item = unwrapType(resolved.itemType);
|
|
379
|
+
return item.kind === 'entity' ? item.entityId : null;
|
|
380
|
+
}
|
|
381
|
+
return resolved.kind === 'entity' ? resolved.entityId : null;
|
|
382
|
+
}
|
|
383
|
+
function instancesOf(entityId) {
|
|
384
|
+
const instances = [];
|
|
385
|
+
for (const state of ir.states) {
|
|
386
|
+
// Drafts are incomplete by definition, and derived states are views of data that
|
|
387
|
+
// is already validated where it is stored.
|
|
388
|
+
if (state.draft || state.derivation) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (collectionEntityId(state) !== entityId) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const value = readState(state.id);
|
|
395
|
+
if (Array.isArray(value)) {
|
|
396
|
+
instances.push(...value);
|
|
397
|
+
}
|
|
398
|
+
else if (isRecord(value)) {
|
|
399
|
+
instances.push(value);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return instances;
|
|
403
|
+
}
|
|
404
|
+
function checkFieldValue(field, value, entityId) {
|
|
405
|
+
if (!isPresent(value)) {
|
|
406
|
+
if (field.required) {
|
|
407
|
+
return {
|
|
408
|
+
code: 'REQUIRED_FIELD_MISSING',
|
|
409
|
+
message: `${field.name ?? field.id} is required`,
|
|
410
|
+
severity: 'error',
|
|
411
|
+
nodeId: entityId,
|
|
412
|
+
fieldId: field.id,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
const resolved = unwrapType(field.valueType);
|
|
418
|
+
if (resolved.kind === 'enum' && !resolved.values.includes(toText(value))) {
|
|
419
|
+
return {
|
|
420
|
+
code: 'ENUM_VALUE_INVALID',
|
|
421
|
+
message: `${field.name ?? field.id} must be one of: ${resolved.values.join(', ')}`,
|
|
422
|
+
severity: 'error',
|
|
423
|
+
nodeId: entityId,
|
|
424
|
+
fieldId: field.id,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (resolved.kind === 'primitive' && resolved.primitive === 'number' && typeof value !== 'number') {
|
|
428
|
+
return {
|
|
429
|
+
code: 'TYPE_MISMATCH',
|
|
430
|
+
message: `${field.name ?? field.id} must be a number`,
|
|
431
|
+
severity: 'error',
|
|
432
|
+
nodeId: entityId,
|
|
433
|
+
fieldId: field.id,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (resolved.kind === 'primitive' && resolved.primitive === 'boolean' && typeof value !== 'boolean') {
|
|
437
|
+
return {
|
|
438
|
+
code: 'TYPE_MISMATCH',
|
|
439
|
+
message: `${field.name ?? field.id} must be a boolean`,
|
|
440
|
+
severity: 'error',
|
|
441
|
+
nodeId: entityId,
|
|
442
|
+
fieldId: field.id,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
/** Schema conformance plus declared constraints, evaluated over live instances. */
|
|
448
|
+
function evaluateInvariants() {
|
|
449
|
+
const failures = [];
|
|
450
|
+
for (const entity of ir.entities) {
|
|
451
|
+
for (const instance of instancesOf(entity.id)) {
|
|
452
|
+
if (!isRecord(instance)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
for (const field of entity.fields) {
|
|
456
|
+
const failure = checkFieldValue(field, instance[field.id], entity.id);
|
|
457
|
+
if (failure) {
|
|
458
|
+
failures.push(failure);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const constraint of ir.constraints) {
|
|
464
|
+
failures.push(...evaluateConstraint(constraint));
|
|
465
|
+
}
|
|
466
|
+
return failures;
|
|
467
|
+
}
|
|
468
|
+
/** Invariant failures that must never be left standing in canonical state. */
|
|
469
|
+
function hardViolations() {
|
|
470
|
+
return evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error');
|
|
471
|
+
}
|
|
472
|
+
function violationKey(diagnostic) {
|
|
473
|
+
return [
|
|
474
|
+
diagnostic.code,
|
|
475
|
+
diagnostic.nodeId ?? '',
|
|
476
|
+
diagnostic.fieldId ?? '',
|
|
477
|
+
diagnostic.message,
|
|
478
|
+
].join('|');
|
|
479
|
+
}
|
|
480
|
+
function countViolations(violations) {
|
|
481
|
+
const counts = new Map();
|
|
482
|
+
for (const violation of violations) {
|
|
483
|
+
const key = violationKey(violation);
|
|
484
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
485
|
+
}
|
|
486
|
+
return counts;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Violations that were not already present. A change is only held responsible for what
|
|
490
|
+
* it broke, so data that was already invalid does not make the rest of the UI unusable.
|
|
491
|
+
*/
|
|
492
|
+
function violationsIntroducedSince(before) {
|
|
493
|
+
const remaining = new Map(before);
|
|
494
|
+
const introduced = [];
|
|
495
|
+
for (const violation of hardViolations()) {
|
|
496
|
+
const key = violationKey(violation);
|
|
497
|
+
const outstanding = remaining.get(key) ?? 0;
|
|
498
|
+
if (outstanding > 0) {
|
|
499
|
+
remaining.set(key, outstanding - 1);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
introduced.push(violation);
|
|
503
|
+
}
|
|
504
|
+
return introduced;
|
|
505
|
+
}
|
|
506
|
+
function evaluateConstraint(constraint) {
|
|
507
|
+
const severity = constraint.severity ?? 'error';
|
|
508
|
+
const failures = [];
|
|
509
|
+
const record = () => {
|
|
510
|
+
failures.push({
|
|
511
|
+
code: 'CONSTRAINT_VIOLATION',
|
|
512
|
+
message: constraint.message ?? `Constraint ${constraint.name ?? constraint.id} failed`,
|
|
513
|
+
severity,
|
|
514
|
+
nodeId: constraint.id,
|
|
515
|
+
});
|
|
516
|
+
};
|
|
517
|
+
if (!constraint.entityId) {
|
|
518
|
+
if (!toBoolean(evaluate(constraint.expression, rootScope()))) {
|
|
519
|
+
record();
|
|
520
|
+
}
|
|
521
|
+
return failures;
|
|
522
|
+
}
|
|
523
|
+
for (const instance of instancesOf(constraint.entityId)) {
|
|
524
|
+
const scope = childScope(rootScope(), constraint.entityId, instance);
|
|
525
|
+
if (!toBoolean(evaluate(constraint.expression, scope))) {
|
|
526
|
+
record();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return failures;
|
|
530
|
+
}
|
|
531
|
+
// --------------------------------------------------------------- behaviour
|
|
532
|
+
function executeOperation(operation, scope, context, result) {
|
|
533
|
+
switch (operation.kind) {
|
|
534
|
+
case 'set':
|
|
535
|
+
case 'insert':
|
|
536
|
+
case 'remove':
|
|
537
|
+
mutate(() => mutations.apply(operation, scope, context), context, result);
|
|
538
|
+
return;
|
|
539
|
+
case 'invoke': {
|
|
540
|
+
const args = {};
|
|
541
|
+
for (const [parameterId, argument] of Object.entries(operation.arguments ?? {})) {
|
|
542
|
+
args[parameterId] = evaluate(argument, scope);
|
|
543
|
+
}
|
|
544
|
+
const nested = runAction(operation.actionId, args);
|
|
545
|
+
result.push(...nested.diagnostics);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
case 'navigate': {
|
|
549
|
+
if (operation.path) {
|
|
550
|
+
navigate(operation.path);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const route = ir.routes.find((candidate) => candidate.id === operation.routeId);
|
|
554
|
+
if (!route) {
|
|
555
|
+
result.push({
|
|
556
|
+
code: 'ROUTE_NOT_FOUND',
|
|
557
|
+
message: `Navigate operation could not resolve route ${String(operation.routeId)}`,
|
|
558
|
+
severity: 'error',
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const values = {};
|
|
563
|
+
for (const [parameterId, argument] of Object.entries(operation.parameters ?? {})) {
|
|
564
|
+
values[parameterId] = toText(evaluate(argument, scope));
|
|
565
|
+
}
|
|
566
|
+
navigate(buildPath(route, values));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
case 'native': {
|
|
570
|
+
const implementation = natives.get(operation.implementationId);
|
|
571
|
+
if (!implementation) {
|
|
572
|
+
result.push({
|
|
573
|
+
code: 'NATIVE_OPERATION_MISSING',
|
|
574
|
+
message: `No implementation registered for "${operation.implementationId}"`,
|
|
575
|
+
severity: 'error',
|
|
576
|
+
});
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const inputs = {};
|
|
580
|
+
for (const [key, argument] of Object.entries(operation.inputs ?? {})) {
|
|
581
|
+
// Native code receives copies: it can never reach into managed state.
|
|
582
|
+
inputs[key] = cloneValue(evaluate(argument, scope));
|
|
583
|
+
}
|
|
584
|
+
const returned = implementation(inputs);
|
|
585
|
+
if (operation.resultTarget) {
|
|
586
|
+
const target = operation.resultTarget;
|
|
587
|
+
mutate(() => mutations.set(target, cloneValue(returned), scope, { ...context, source: 'native' }), context, result);
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
default:
|
|
592
|
+
result.push({
|
|
593
|
+
code: 'UNKNOWN_OPERATION',
|
|
594
|
+
message: `Unknown operation kind "${operation.kind}"`,
|
|
595
|
+
severity: 'error',
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function runAction(actionId, args = {}) {
|
|
600
|
+
const action = ir.actions[actionId];
|
|
601
|
+
if (!action) {
|
|
602
|
+
const failure = {
|
|
603
|
+
code: 'ACTION_NOT_FOUND',
|
|
604
|
+
message: `Action ${actionId} is not defined`,
|
|
605
|
+
severity: 'error',
|
|
606
|
+
};
|
|
607
|
+
report(failure);
|
|
608
|
+
return { ok: false, diagnostics: [failure] };
|
|
609
|
+
}
|
|
610
|
+
const scope = rootScope();
|
|
611
|
+
for (const parameter of action.parameters ?? []) {
|
|
612
|
+
scope.values.set(parameter.id, args[parameter.id] ?? null);
|
|
613
|
+
}
|
|
614
|
+
const failures = [];
|
|
615
|
+
for (const parameter of action.parameters ?? []) {
|
|
616
|
+
if (parameter.required && !isPresent(scope.values.get(parameter.id))) {
|
|
617
|
+
failures.push({
|
|
618
|
+
code: 'PARAMETER_MISSING',
|
|
619
|
+
message: `Action ${action.name ?? action.id} requires ${parameter.name ?? parameter.id}`,
|
|
620
|
+
severity: 'error',
|
|
621
|
+
nodeId: action.id,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (failures.length > 0) {
|
|
626
|
+
failures.forEach(report);
|
|
627
|
+
return { ok: false, diagnostics: failures };
|
|
628
|
+
}
|
|
629
|
+
for (const precondition of action.preconditions ?? []) {
|
|
630
|
+
if (!toBoolean(evaluate(precondition, scope))) {
|
|
631
|
+
const failure = {
|
|
632
|
+
code: 'PRECONDITION_FAILED',
|
|
633
|
+
message: action.failureModes?.[0]?.message ??
|
|
634
|
+
`A precondition of ${action.name ?? action.id} was not satisfied`,
|
|
635
|
+
severity: 'error',
|
|
636
|
+
nodeId: action.id,
|
|
637
|
+
};
|
|
638
|
+
report(failure);
|
|
639
|
+
return { ok: false, diagnostics: [failure] };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (action.requiresConfirmation) {
|
|
643
|
+
const message = action.confirmationMessage ?? `Confirm ${action.name ?? action.id}. This cannot be undone.`;
|
|
644
|
+
if (!host.confirm(message)) {
|
|
645
|
+
return { ok: false, diagnostics: [] };
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const transaction = transactions.begin();
|
|
649
|
+
const context = {
|
|
650
|
+
source: 'action',
|
|
651
|
+
sourceNodeId: action.id,
|
|
652
|
+
transactionId: transaction.id,
|
|
653
|
+
};
|
|
654
|
+
const operationDiagnostics = [];
|
|
655
|
+
for (const operation of action.operations ?? []) {
|
|
656
|
+
executeOperation(operation, scope, context, operationDiagnostics);
|
|
657
|
+
}
|
|
658
|
+
const violations = [
|
|
659
|
+
...operationDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'),
|
|
660
|
+
...evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error'),
|
|
661
|
+
];
|
|
662
|
+
for (const postcondition of action.postconditions ?? []) {
|
|
663
|
+
if (!toBoolean(evaluate(postcondition, scope))) {
|
|
664
|
+
violations.push({
|
|
665
|
+
code: 'POSTCONDITION_FAILED',
|
|
666
|
+
message: `A postcondition of ${action.name ?? action.id} was not satisfied`,
|
|
667
|
+
severity: 'error',
|
|
668
|
+
nodeId: action.id,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (violations.length > 0) {
|
|
673
|
+
settle(transaction, 'rolled-back');
|
|
674
|
+
violations.forEach(report);
|
|
675
|
+
renderApplication();
|
|
676
|
+
return { ok: false, diagnostics: violations };
|
|
677
|
+
}
|
|
678
|
+
settle(transaction, 'committed');
|
|
679
|
+
renderApplication();
|
|
680
|
+
return { ok: true, diagnostics: operationDiagnostics };
|
|
681
|
+
}
|
|
682
|
+
// ------------------------------------------------------------------ routing
|
|
683
|
+
function buildPath(route, values) {
|
|
684
|
+
const segments = route.segments.map((segment) => {
|
|
685
|
+
if (segment.kind === 'static') {
|
|
686
|
+
return segment.value;
|
|
687
|
+
}
|
|
688
|
+
const parameterId = segment.parameterId ?? '';
|
|
689
|
+
const value = values[parameterId] ?? '';
|
|
690
|
+
return encodeURIComponent(value);
|
|
691
|
+
});
|
|
692
|
+
return `/${segments.join('/')}`.replace(/\/+/g, '/');
|
|
693
|
+
}
|
|
694
|
+
function matchRoute(pathname) {
|
|
695
|
+
const parts = pathname.split('?')[0].split('/').filter(Boolean);
|
|
696
|
+
for (const route of ir.routes) {
|
|
697
|
+
if (route.segments.length !== parts.length) {
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const parameters = {};
|
|
701
|
+
let matched = true;
|
|
702
|
+
for (let index = 0; index < route.segments.length; index += 1) {
|
|
703
|
+
const segment = route.segments[index];
|
|
704
|
+
const part = parts[index];
|
|
705
|
+
if (segment.kind === 'static') {
|
|
706
|
+
if (segment.value !== part) {
|
|
707
|
+
matched = false;
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (segment.parameterId) {
|
|
713
|
+
parameters[segment.parameterId] = decodeURIComponent(part);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (matched) {
|
|
717
|
+
return { route, parameters };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
function navigate(path) {
|
|
723
|
+
host.pushPath(path);
|
|
724
|
+
syncRoute();
|
|
725
|
+
}
|
|
726
|
+
function syncRoute() {
|
|
727
|
+
activeRoute = matchRoute(host.getPath());
|
|
728
|
+
derivedCache.clear();
|
|
729
|
+
renderApplication();
|
|
730
|
+
}
|
|
731
|
+
// ----------------------------------------------------------------- renderer
|
|
732
|
+
function element(tagName, className) {
|
|
733
|
+
const created = host.document.createElement(tagName);
|
|
734
|
+
if (className) {
|
|
735
|
+
created.setAttribute('class', className);
|
|
736
|
+
}
|
|
737
|
+
return created;
|
|
738
|
+
}
|
|
739
|
+
function presentationClasses(node) {
|
|
740
|
+
const hints = node.presentation;
|
|
741
|
+
if (!hints) {
|
|
742
|
+
return '';
|
|
743
|
+
}
|
|
744
|
+
return [
|
|
745
|
+
hints.role ? `axiom-role-${hints.role}` : '',
|
|
746
|
+
hints.density ? `axiom-density-${hints.density}` : '',
|
|
747
|
+
hints.emphasis ? `axiom-emphasis-${hints.emphasis}` : '',
|
|
748
|
+
]
|
|
749
|
+
.filter(Boolean)
|
|
750
|
+
.join(' ');
|
|
751
|
+
}
|
|
752
|
+
function renderChildren(ids, scope, parent) {
|
|
753
|
+
for (const id of ids) {
|
|
754
|
+
const child = renderNode(id, scope);
|
|
755
|
+
if (child) {
|
|
756
|
+
parent.appendChild(child);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function fieldOf(id) {
|
|
761
|
+
return ir.fields[id]?.field;
|
|
762
|
+
}
|
|
763
|
+
/** Reads through a location without giving the renderer a way to write. */
|
|
764
|
+
function readLocation(location, scope) {
|
|
765
|
+
try {
|
|
766
|
+
return resolveLocation(location, scope, {
|
|
767
|
+
readState,
|
|
768
|
+
writeState,
|
|
769
|
+
evaluate: (expression, inner) => evaluate(expression, inner),
|
|
770
|
+
}).read();
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function resolveInputTag(node) {
|
|
777
|
+
const located = ir.locationTypes[node.id];
|
|
778
|
+
const resolved = located ? unwrapType(located) : null;
|
|
779
|
+
const hint = node.inputHint;
|
|
780
|
+
if (hint === 'multiline') {
|
|
781
|
+
return { tag: 'textarea' };
|
|
782
|
+
}
|
|
783
|
+
if (node.options) {
|
|
784
|
+
return { tag: 'select' };
|
|
785
|
+
}
|
|
786
|
+
if (hint === 'select' || (!hint && resolved?.kind === 'enum')) {
|
|
787
|
+
return { tag: 'select', options: resolved?.kind === 'enum' ? resolved.values : [] };
|
|
788
|
+
}
|
|
789
|
+
if (hint === 'checkbox' || (!hint && resolved?.kind === 'primitive' && resolved.primitive === 'boolean')) {
|
|
790
|
+
return { tag: 'input', type: 'checkbox' };
|
|
791
|
+
}
|
|
792
|
+
if (hint) {
|
|
793
|
+
return { tag: 'input', type: hint };
|
|
794
|
+
}
|
|
795
|
+
if (resolved?.kind === 'primitive') {
|
|
796
|
+
switch (resolved.primitive) {
|
|
797
|
+
case 'number':
|
|
798
|
+
return { tag: 'input', type: 'number' };
|
|
799
|
+
case 'date':
|
|
800
|
+
case 'datetime':
|
|
801
|
+
return { tag: 'input', type: 'date' };
|
|
802
|
+
default:
|
|
803
|
+
return { tag: 'input', type: 'text' };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { tag: 'input', type: 'text' };
|
|
807
|
+
}
|
|
808
|
+
/** Choices for a select: either enum values or records drawn from application data. */
|
|
809
|
+
function optionChoices(node, scope, enumValues) {
|
|
810
|
+
const source = node.options;
|
|
811
|
+
if (!source) {
|
|
812
|
+
return enumValues.map((value) => ({ value, label: value }));
|
|
813
|
+
}
|
|
814
|
+
const candidates = evaluate(source.source, scope);
|
|
815
|
+
if (!Array.isArray(candidates)) {
|
|
816
|
+
return [];
|
|
817
|
+
}
|
|
818
|
+
return candidates.filter(isRecord).map((candidate) => {
|
|
819
|
+
const value = toText(candidate[source.valueFieldId]);
|
|
820
|
+
const label = source.labelFieldId ? toText(candidate[source.labelFieldId]) : value;
|
|
821
|
+
return { value, label: label || value };
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
function coerceInputValue(inputId, raw, checked) {
|
|
825
|
+
const located = ir.locationTypes[inputId];
|
|
826
|
+
const optional = located?.kind === 'optional';
|
|
827
|
+
const resolved = located ? unwrapType(located) : null;
|
|
828
|
+
if (resolved?.kind === 'primitive' && resolved.primitive === 'boolean') {
|
|
829
|
+
return Boolean(checked);
|
|
830
|
+
}
|
|
831
|
+
if (resolved?.kind === 'primitive' && resolved.primitive === 'number') {
|
|
832
|
+
if (raw.trim() === '') {
|
|
833
|
+
return optional ? null : 0;
|
|
834
|
+
}
|
|
835
|
+
const parsed = Number(raw);
|
|
836
|
+
return Number.isNaN(parsed) ? raw : parsed;
|
|
837
|
+
}
|
|
838
|
+
return raw;
|
|
839
|
+
}
|
|
840
|
+
function renderNode(id, scope) {
|
|
841
|
+
const node = ir.uiNodes[id];
|
|
842
|
+
if (!node) {
|
|
843
|
+
report({
|
|
844
|
+
code: 'UI_NODE_MISSING',
|
|
845
|
+
message: `UI node ${id} is not defined`,
|
|
846
|
+
severity: 'error',
|
|
847
|
+
nodeId: id,
|
|
848
|
+
});
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
if (node.visibleWhen && !toBoolean(evaluate(node.visibleWhen, scope))) {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
switch (node.kind) {
|
|
855
|
+
case 'view': {
|
|
856
|
+
const container = element('div', `axiom-view ${presentationClasses(node)}`.trim());
|
|
857
|
+
container.setAttribute('data-node', node.id);
|
|
858
|
+
renderChildren(node.children, scope, container);
|
|
859
|
+
return container;
|
|
860
|
+
}
|
|
861
|
+
case 'container': {
|
|
862
|
+
const container = element('div', `axiom-container axiom-layout-${node.layout ?? 'vertical'} ${presentationClasses(node)}`.trim());
|
|
863
|
+
container.setAttribute('data-node', node.id);
|
|
864
|
+
renderChildren(node.children, scope, container);
|
|
865
|
+
return container;
|
|
866
|
+
}
|
|
867
|
+
case 'text': {
|
|
868
|
+
const text = element('span', `axiom-text ${presentationClasses(node)}`.trim());
|
|
869
|
+
text.setAttribute('data-node', node.id);
|
|
870
|
+
text.textContent =
|
|
871
|
+
typeof node.value === 'string' ? node.value : toText(evaluate(node.value, scope));
|
|
872
|
+
return text;
|
|
873
|
+
}
|
|
874
|
+
case 'repeat': {
|
|
875
|
+
const container = element('div', 'axiom-repeat');
|
|
876
|
+
container.setAttribute('data-node', node.id);
|
|
877
|
+
const source = evaluate(node.source, scope);
|
|
878
|
+
const items = Array.isArray(source) ? source : [];
|
|
879
|
+
if (items.length === 0 && node.emptyTemplateId) {
|
|
880
|
+
renderChildren([node.emptyTemplateId], scope, container);
|
|
881
|
+
return container;
|
|
882
|
+
}
|
|
883
|
+
for (const item of items) {
|
|
884
|
+
const child = renderNode(node.templateId, childScope(scope, node.id, item));
|
|
885
|
+
if (child) {
|
|
886
|
+
container.appendChild(child);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return container;
|
|
890
|
+
}
|
|
891
|
+
case 'field-display': {
|
|
892
|
+
const container = element('div', 'axiom-field');
|
|
893
|
+
container.setAttribute('data-node', node.id);
|
|
894
|
+
const field = fieldOf(node.fieldId);
|
|
895
|
+
if (node.label ?? field?.name) {
|
|
896
|
+
const label = element('span', 'axiom-field-label');
|
|
897
|
+
label.textContent = node.label ?? field?.name ?? '';
|
|
898
|
+
container.appendChild(label);
|
|
899
|
+
}
|
|
900
|
+
const value = element('span', 'axiom-field-value');
|
|
901
|
+
const source = evaluate(node.source, scope);
|
|
902
|
+
value.textContent = isRecord(source) ? toText(source[node.fieldId]) : '';
|
|
903
|
+
container.appendChild(value);
|
|
904
|
+
return container;
|
|
905
|
+
}
|
|
906
|
+
case 'form': {
|
|
907
|
+
const form = element('form', 'axiom-form');
|
|
908
|
+
form.setAttribute('data-node', node.id);
|
|
909
|
+
renderChildren(node.children, scope, form);
|
|
910
|
+
if (node.submitActionId) {
|
|
911
|
+
const submit = element('button', 'axiom-submit');
|
|
912
|
+
submit.setAttribute('type', 'submit');
|
|
913
|
+
submit.textContent = node.submitLabel ?? 'Submit';
|
|
914
|
+
form.appendChild(submit);
|
|
915
|
+
const actionId = node.submitActionId;
|
|
916
|
+
form.addEventListener('submit', (event) => {
|
|
917
|
+
event.preventDefault?.();
|
|
918
|
+
runAction(actionId);
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return form;
|
|
922
|
+
}
|
|
923
|
+
case 'input': {
|
|
924
|
+
const wrapper = element('label', 'axiom-input');
|
|
925
|
+
wrapper.setAttribute('data-node', node.id);
|
|
926
|
+
if (node.label) {
|
|
927
|
+
const label = element('span', 'axiom-input-label');
|
|
928
|
+
label.textContent = node.label;
|
|
929
|
+
wrapper.appendChild(label);
|
|
930
|
+
}
|
|
931
|
+
const descriptor = resolveInputTag(node);
|
|
932
|
+
const control = element(descriptor.tag, 'axiom-control');
|
|
933
|
+
control.setAttribute('data-node', node.id);
|
|
934
|
+
if (descriptor.type) {
|
|
935
|
+
control.setAttribute('type', descriptor.type);
|
|
936
|
+
}
|
|
937
|
+
if (node.placeholder) {
|
|
938
|
+
control.setAttribute('placeholder', node.placeholder);
|
|
939
|
+
}
|
|
940
|
+
const current = readLocation(node.binding.location, scope);
|
|
941
|
+
if (descriptor.type === 'checkbox') {
|
|
942
|
+
control.checked = Boolean(current);
|
|
943
|
+
}
|
|
944
|
+
else if (descriptor.tag === 'select') {
|
|
945
|
+
for (const choice of optionChoices(node, scope, descriptor.options ?? [])) {
|
|
946
|
+
const option = element('option');
|
|
947
|
+
option.setAttribute('value', choice.value);
|
|
948
|
+
option.textContent = choice.label;
|
|
949
|
+
if (toText(current) === choice.value) {
|
|
950
|
+
option.setAttribute('selected', 'selected');
|
|
951
|
+
}
|
|
952
|
+
control.appendChild(option);
|
|
953
|
+
}
|
|
954
|
+
control.value = toText(current);
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
control.value = toText(current);
|
|
958
|
+
control.setAttribute('value', toText(current));
|
|
959
|
+
}
|
|
960
|
+
// An input mutates through the same engine and transaction machinery as an
|
|
961
|
+
// action. There is no separate write path inside the renderer.
|
|
962
|
+
//
|
|
963
|
+
// A write to canonical state is transactional with respect to hard invariants:
|
|
964
|
+
// if the value would break one, the whole mutation is rolled back. A write to a
|
|
965
|
+
// draft is not, because a draft is incomplete by definition while it is filled in.
|
|
966
|
+
const apply = (event) => {
|
|
967
|
+
const source = (event.target ?? control);
|
|
968
|
+
const next = coerceInputValue(node.id, source.value ?? '', source.checked);
|
|
969
|
+
const rootStateId = ir.locationRoots[node.id];
|
|
970
|
+
const rootState = rootStateId === undefined ? undefined : statesById.get(rootStateId);
|
|
971
|
+
const guarded = inputValidation === 'immediate' && rootState !== undefined && rootState.draft !== true;
|
|
972
|
+
const before = guarded ? countViolations(hardViolations()) : null;
|
|
973
|
+
const transaction = transactions.begin();
|
|
974
|
+
const context = {
|
|
975
|
+
source: 'ui',
|
|
976
|
+
sourceNodeId: node.id,
|
|
977
|
+
transactionId: transaction.id,
|
|
978
|
+
};
|
|
979
|
+
const failures = [];
|
|
980
|
+
mutate(() => mutations.set(node.binding.location, next, scope, context), context, failures);
|
|
981
|
+
if (failures.length > 0) {
|
|
982
|
+
settle(transaction, 'rolled-back');
|
|
983
|
+
failures.forEach(report);
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
const introduced = before ? violationsIntroducedSince(before) : [];
|
|
987
|
+
if (introduced.length > 0) {
|
|
988
|
+
settle(transaction, 'rolled-back');
|
|
989
|
+
introduced.forEach(report);
|
|
990
|
+
report({
|
|
991
|
+
code: 'INPUT_REJECTED',
|
|
992
|
+
message: `${node.label ?? node.id} kept its previous value: ${introduced[0].message}`,
|
|
993
|
+
severity: 'warning',
|
|
994
|
+
nodeId: node.id,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
settle(transaction, 'committed');
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
focusedNodeId = node.id;
|
|
1002
|
+
focusedCaret = typeof source.selectionStart === 'number' ? source.selectionStart : null;
|
|
1003
|
+
renderApplication();
|
|
1004
|
+
};
|
|
1005
|
+
control.addEventListener('input', apply);
|
|
1006
|
+
control.addEventListener('change', apply);
|
|
1007
|
+
control.addEventListener('focus', () => {
|
|
1008
|
+
focusedNodeId = node.id;
|
|
1009
|
+
});
|
|
1010
|
+
inputElements.set(node.id, control);
|
|
1011
|
+
wrapper.appendChild(control);
|
|
1012
|
+
return wrapper;
|
|
1013
|
+
}
|
|
1014
|
+
case 'button': {
|
|
1015
|
+
const button = element('button', `axiom-button ${node.destructive ? 'axiom-destructive' : ''} ${presentationClasses(node)}`.trim());
|
|
1016
|
+
button.setAttribute('data-node', node.id);
|
|
1017
|
+
button.setAttribute('type', 'button');
|
|
1018
|
+
button.textContent =
|
|
1019
|
+
typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
|
|
1020
|
+
button.addEventListener('click', (event) => {
|
|
1021
|
+
event.preventDefault?.();
|
|
1022
|
+
const args = {};
|
|
1023
|
+
for (const [parameterId, argument] of Object.entries(node.arguments ?? {})) {
|
|
1024
|
+
args[parameterId] = evaluate(argument, scope);
|
|
1025
|
+
}
|
|
1026
|
+
runAction(node.actionId, args);
|
|
1027
|
+
});
|
|
1028
|
+
return button;
|
|
1029
|
+
}
|
|
1030
|
+
case 'conditional': {
|
|
1031
|
+
const container = element('div', 'axiom-conditional');
|
|
1032
|
+
container.setAttribute('data-node', node.id);
|
|
1033
|
+
const branch = toBoolean(evaluate(node.condition, scope)) ? node.whenTrue : node.whenFalse ?? [];
|
|
1034
|
+
renderChildren(branch, scope, container);
|
|
1035
|
+
return container;
|
|
1036
|
+
}
|
|
1037
|
+
default:
|
|
1038
|
+
report({
|
|
1039
|
+
code: 'UNKNOWN_UI_NODE',
|
|
1040
|
+
message: `Unknown UI node kind "${node.kind}"`,
|
|
1041
|
+
severity: 'error',
|
|
1042
|
+
});
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function renderApplication() {
|
|
1047
|
+
inputElements.clear();
|
|
1048
|
+
const scope = rootScope();
|
|
1049
|
+
if (!activeRoute) {
|
|
1050
|
+
const missing = element('div', 'axiom-no-route');
|
|
1051
|
+
missing.textContent = `No route matches ${host.getPath()}`;
|
|
1052
|
+
rootElement.replaceChildren(missing);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const view = renderNode(activeRoute.route.viewId, scope);
|
|
1056
|
+
rootElement.replaceChildren(...(view ? [view] : []));
|
|
1057
|
+
restoreFocus();
|
|
1058
|
+
}
|
|
1059
|
+
function restoreFocus() {
|
|
1060
|
+
if (!focusedNodeId) {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const control = inputElements.get(focusedNodeId);
|
|
1064
|
+
if (!control) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
control.focus?.();
|
|
1069
|
+
if (focusedCaret !== null && typeof control.selectionStart === 'number') {
|
|
1070
|
+
control.selectionStart = focusedCaret;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
catch {
|
|
1074
|
+
// Some controls reject caret manipulation; focus alone is enough.
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
// -------------------------------------------------------------- public API
|
|
1078
|
+
initializeStore();
|
|
1079
|
+
return {
|
|
1080
|
+
start() {
|
|
1081
|
+
if (started) {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
started = true;
|
|
1085
|
+
host.onPathChange(() => {
|
|
1086
|
+
activeRoute = matchRoute(host.getPath());
|
|
1087
|
+
derivedCache.clear();
|
|
1088
|
+
renderApplication();
|
|
1089
|
+
});
|
|
1090
|
+
syncRoute();
|
|
1091
|
+
},
|
|
1092
|
+
render: renderApplication,
|
|
1093
|
+
getState(id) {
|
|
1094
|
+
return cloneValue(readState(id));
|
|
1095
|
+
},
|
|
1096
|
+
setState(id, value) {
|
|
1097
|
+
const transaction = transactions.begin();
|
|
1098
|
+
const failures = [];
|
|
1099
|
+
const context = { source: 'system', transactionId: transaction.id };
|
|
1100
|
+
mutate(() => mutations.set({ kind: 'state', stateId: id }, cloneValue(value), rootScope(), context), context, failures);
|
|
1101
|
+
if (failures.length > 0) {
|
|
1102
|
+
settle(transaction, 'rolled-back');
|
|
1103
|
+
failures.forEach(report);
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
settle(transaction, 'committed');
|
|
1107
|
+
}
|
|
1108
|
+
renderApplication();
|
|
1109
|
+
},
|
|
1110
|
+
invokeAction(id, args = {}) {
|
|
1111
|
+
return runAction(id, args);
|
|
1112
|
+
},
|
|
1113
|
+
navigate,
|
|
1114
|
+
currentRoute() {
|
|
1115
|
+
return activeRoute;
|
|
1116
|
+
},
|
|
1117
|
+
diagnostics() {
|
|
1118
|
+
return diagnostics.map((diagnostic) => ({ ...diagnostic }));
|
|
1119
|
+
},
|
|
1120
|
+
getMutationLog() {
|
|
1121
|
+
return mutationLog.map((entry) => ({ ...entry }));
|
|
1122
|
+
},
|
|
1123
|
+
registerNativeOperation(implementationId, implementation) {
|
|
1124
|
+
natives.set(implementationId, implementation);
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/** Builds a host bound to the browser globals. Used by generated pages. */
|
|
1129
|
+
export function createBrowserHost() {
|
|
1130
|
+
const globals = globalThis;
|
|
1131
|
+
return {
|
|
1132
|
+
document: globals.document,
|
|
1133
|
+
getPath: () => globals.location.pathname,
|
|
1134
|
+
pushPath: (path) => globals.history.pushState({}, '', path),
|
|
1135
|
+
onPathChange: (listener) => globals.addEventListener?.('popstate', listener),
|
|
1136
|
+
confirm: (message) => Boolean(globals.confirm(message)),
|
|
1137
|
+
now: () => new Date().toISOString(),
|
|
1138
|
+
uuid: () => typeof globals.crypto?.randomUUID === 'function'
|
|
1139
|
+
? globals.crypto.randomUUID()
|
|
1140
|
+
: `id-${Date.now().toString(16)}-${Math.floor(Math.random() * 1e9).toString(16)}`,
|
|
1141
|
+
storage: globals.localStorage
|
|
1142
|
+
? {
|
|
1143
|
+
read: (key) => globals.localStorage.getItem(key),
|
|
1144
|
+
write: (key, value) => globals.localStorage.setItem(key, value),
|
|
1145
|
+
}
|
|
1146
|
+
: undefined,
|
|
1147
|
+
report: (message) => globals.console?.warn?.(message),
|
|
1148
|
+
};
|
|
1149
|
+
}
|