form0-core 0.6.0 → 0.8.0

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.
Files changed (34) hide show
  1. package/README.md +50 -0
  2. package/package.json +6 -3
  3. package/src/authoring/form-authoring.js +16 -26
  4. package/src/builtins/builtin-definitions.js +27 -0
  5. package/src/builtins/builtin-metadata.js +12 -0
  6. package/src/builtins/control/eval.js +11 -2
  7. package/src/builtins/registry.js +4 -0
  8. package/src/builtins/repeatable/repeatablesum.js +12 -0
  9. package/src/builtins/repeatable/repeatablevalues.js +12 -0
  10. package/src/engine/calculation-bindings.js +76 -0
  11. package/src/engine/calculation.js +114 -38
  12. package/src/engine/evaluator.js +11 -5
  13. package/src/engine/event-operation-scope.js +7 -3
  14. package/src/engine/event-policy.js +61 -0
  15. package/src/engine/event-registration.js +38 -0
  16. package/src/engine/event-source.js +41 -30
  17. package/src/engine/events.js +42 -14
  18. package/src/engine/expression-compilation.js +39 -0
  19. package/src/engine/form-engine.js +57 -22
  20. package/src/engine/managed-events.js +53 -27
  21. package/src/engine/record-context.js +133 -0
  22. package/src/engine/record-evaluator.js +883 -0
  23. package/src/engine/record-freshness.js +19 -0
  24. package/src/engine/submission-validation.js +130 -0
  25. package/src/index.js +7 -1
  26. package/src/schema/schema-validator.js +5 -0
  27. package/src/security/validation.js +4 -4
  28. package/src/utilities/calculation-dependencies.js +2 -1
  29. package/src/utilities/calculation-editor.js +94 -1
  30. package/src/utilities/form-event-editor.js +4 -0
  31. package/src/utilities/record-data.js +69 -0
  32. package/src/utilities/record-transformer.js +110 -39
  33. package/src/utilities/repeatable-collections.js +170 -0
  34. package/src/utilities/schema-revision.js +30 -0
@@ -0,0 +1,883 @@
1
+ import { prepareRecordContext, visitRecordScopes, iterateRecordScopes } from './record-context.js';
2
+ import {
3
+ validateSubmissionWithContext,
4
+ validateScope,
5
+ summarizeSubmissionFindings,
6
+ } from './submission-validation.js';
7
+ import { getDefaultValue } from './form-engine.js';
8
+ import { evaluateCalculatedField } from './calculation.js';
9
+ import { evaluateVisibility, evaluateRequirement, evaluateReadOnly } from './conditions.js';
10
+ import { createCalculationDiagnostics } from './calculation-diagnostics.js';
11
+ import { createManagedEventDispatcher } from './managed-events.js';
12
+ import { isLiteralEventRegistration } from './event-registration.js';
13
+ import { EventManager } from './events.js';
14
+ import { WarningSystem } from './warning-system.js';
15
+ import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
16
+ import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
17
+ import { validateFieldValue } from '../schema/field-value-registry.js';
18
+ import { copyRecordData, copyRecordDataCooperatively } from '../utilities/record-data.js';
19
+ import { areStructuredValuesEqual } from '../utilities/value-equality.js';
20
+ import { projectCollectionValue } from '../utilities/repeatable-collections.js';
21
+ import { getFormSchemaRevision } from '../utilities/schema-revision.js';
22
+ import { createRecordFreshness } from './record-freshness.js';
23
+
24
+ const clock = () => globalThis.performance?.now?.() ?? Date.now();
25
+ const issue = (code, message, instancePath = []) => ({ code, message, instancePath });
26
+ const failure = (error) => ({
27
+ status: error.code === 'cancelled' ? 'cancelled' : 'failed',
28
+ eligible: false,
29
+ snapshot: null,
30
+ issues: [
31
+ {
32
+ ...issue(error.code || 'record_evaluation_failed', error.message, error.instancePath || []),
33
+ ...(error.context ? { context: error.context } : {}),
34
+ },
35
+ ],
36
+ diagnostics: [],
37
+ });
38
+ function problem(code, message) {
39
+ return Object.assign(new Error(message), { code });
40
+ }
41
+
42
+ function sameInstancePath(left, right) {
43
+ return (
44
+ left.length === right.length &&
45
+ left.every((part, index) => {
46
+ const other = right[index];
47
+ return (
48
+ part.repeatableKey === other.repeatableKey &&
49
+ part.identity === other.identity &&
50
+ (part.identity === 'id' ? part.instanceId === other.instanceId : part.index === other.index)
51
+ );
52
+ })
53
+ );
54
+ }
55
+
56
+ function validateInstancePath(path) {
57
+ if (
58
+ !Array.isArray(path) ||
59
+ path.some(
60
+ (part) =>
61
+ !part ||
62
+ typeof part.repeatableKey !== 'string' ||
63
+ !part.repeatableKey ||
64
+ !Number.isSafeInteger(part.index) ||
65
+ part.index < 0 ||
66
+ !['id', 'position'].includes(part.identity) ||
67
+ (part.identity === 'id' &&
68
+ !(
69
+ (typeof part.instanceId === 'string' && part.instanceId.trim()) ||
70
+ (typeof part.instanceId === 'number' && Number.isFinite(part.instanceId))
71
+ )) ||
72
+ (part.identity === 'position' && part.instanceId != null)
73
+ )
74
+ )
75
+ throw problem('invalid_instance_path', 'A complete record instancePath is required.');
76
+ }
77
+
78
+ function scheduler(options) {
79
+ let started = clock();
80
+ const check = () => {
81
+ if (options.signal?.aborted) throw problem('cancelled', 'Record evaluation was cancelled.');
82
+ };
83
+ return async () => {
84
+ check();
85
+ if (clock() - started >= (options.budgetMs ?? 8)) {
86
+ await (options.yieldToHost
87
+ ? options.yieldToHost()
88
+ : new Promise((resolve) => setTimeout(resolve, 0)));
89
+ started = clock();
90
+ check();
91
+ }
92
+ };
93
+ }
94
+
95
+ /**
96
+ * @typedef {Object} RecordInstanceSegment
97
+ * @property {string} repeatableKey Preferred schema key.
98
+ * @property {string|number|null} instanceId Preserves zero; null for positional targeting.
99
+ * @property {number} index Captured row position.
100
+ * @property {'id'|'position'} identity
101
+ *
102
+ * @typedef {Object<string, *> & {values?: Object<string, *>, repeatable?: Object<string, RecordSnapshot[]>, id?: string|number, record_id?: string|number}} RecordSnapshot
103
+ * Live values use data_names; collections use preferred schema keys. Unknown metadata is retained.
104
+ *
105
+ * @typedef {Object} RecordFieldChange
106
+ * @property {RecordInstanceSegment[]} instancePath
107
+ * @property {string} fieldName data_name.
108
+ * @property {string} fieldKey
109
+ * @property {*} before
110
+ * @property {*} after
111
+ *
112
+ * @typedef {Object} RecordFreshness
113
+ * @property {1} version
114
+ * @property {'current'|'pending'} status
115
+ * @property {string} schemaRevision
116
+ * @property {Array<{instancePath: RecordInstanceSegment[], fields: Array<{fieldName: string, fieldKey: string}>}>} affected One entry per affected row, in record order.
117
+ *
118
+ * @typedef {Object} RecordEvaluationOptions
119
+ * @property {'accepted'|'refresh'} [mode='accepted'] Closed-child calculation policy.
120
+ * @property {AbortSignal} [signal]
121
+ * @property {number} [budgetMs=8] Cooperative scheduling target, not an expression timeout.
122
+ * @property {() => (void|Promise<void>)} [yieldToHost]
123
+ *
124
+ * @typedef {Object} SubmissionPreparationOptions
125
+ * @property {'submit'|'draft'} [intent='submit']
126
+ * @property {'automatic'|'confirm'} [mode='automatic'] Cannot weaken schema confirmation.
127
+ * @property {boolean} [allowRetain=false] Explicit host permission on both review calls.
128
+ * @property {boolean} [managedEvents=false] Synchronous custom validation only.
129
+ * @property {{reviewId: string, action: 'refresh'|'retain'|'cancel'}} [decision]
130
+ * @property {AbortSignal} [signal]
131
+ * @property {number} [budgetMs=8]
132
+ * @property {() => (void|Promise<void>)} [yieldToHost]
133
+ *
134
+ * @typedef {Object} RecordEvaluationResult
135
+ * @property {'evaluated'|'ready'|'blocked'|'requires_decision'|'failed'|'cancelled'} status
136
+ * @property {RecordSnapshot|null} snapshot Null when no selection can be delivered.
137
+ * @property {boolean} [eligible] Submission eligibility, distinct from diagnostics.
138
+ * @property {RecordSnapshot} [proposal] Review only, not a deliverable selection.
139
+ * @property {RecordFieldChange[]} [changes]
140
+ * @property {Object[]} diagnostics Scoped calculation findings, separate from validation.
141
+ * @property {Object[]} issues Execution/configuration failures.
142
+ * @property {import('./submission-validation.js').SubmissionValidationSummary} [validationSummary]
143
+ * @property {RecordFreshness} [freshness] Host-persisted companion, outside the record.
144
+ * @property {string} [reviewId] Single-use factory-owned review handle.
145
+ * @property {Array<'refresh'|'retain'|'cancel'>} [choices]
146
+ *
147
+ * @typedef {Object} RecordScopeEvaluationOptions
148
+ * @property {'edit'|'readonly'} [interactionMode='edit'] Readonly computes field state from supplied values without calculations or defaults.
149
+ * @property {RecordInstanceSegment[]} [instancePath=[]] Complete record ancestry; an empty path selects root.
150
+ * @property {AbortSignal} [signal]
151
+ * @property {number} [budgetMs=8]
152
+ * @property {() => (void|Promise<void>)} [yieldToHost]
153
+ *
154
+ * @typedef {Object} RecordScopeEvaluationResult
155
+ * @property {'evaluated'|'failed'|'cancelled'} status
156
+ * @property {RecordInstanceSegment[]} [instancePath] Resolved identity and current ordering on success.
157
+ * @property {{values: Object, errors: Object, visible: Object, required: Object, read_only: Object}|null} state
158
+ * @property {import('./submission-validation.js').SubmissionValidationSummary} [validationSummary]
159
+ * @property {RecordFieldChange[]} [changes]
160
+ * @property {Object[]} diagnostics
161
+ * @property {Object[]} issues
162
+ *
163
+ * @typedef {Object} RecordEvaluator
164
+ * @property {(snapshot: RecordSnapshot, options?: RecordEvaluationOptions) => Promise<RecordEvaluationResult>} evaluate
165
+ * @property {(snapshot: RecordSnapshot, options?: RecordScopeEvaluationOptions) => Promise<RecordScopeEvaluationResult>} evaluateScope Evaluates only the selected editor record, without a complete tree response.
166
+ * @property {(snapshot: RecordSnapshot, options?: SubmissionPreparationOptions) => Promise<RecordEvaluationResult>} prepareSubmission
167
+ */
168
+
169
+ /**
170
+ * Coordinate an immutable runtime record tree using the existing expression/condition engine.
171
+ * Factory configuration is captured; recreate the evaluator when schema/helpers/security change.
172
+ * @param {{schema: Object, helpers?: Object, security?: Object, warningSystem?: Object,
173
+ * diagnostics?: {console?: boolean}, onDiagnostics?: Function}} config
174
+ * @returns {RecordEvaluator}
175
+ */
176
+ export function createRecordEvaluator({
177
+ schema,
178
+ helpers = {},
179
+ security = DEFAULT_SECURITY_CONFIG,
180
+ warningSystem = new WarningSystem(),
181
+ diagnostics: diagnosticOptions = {},
182
+ onDiagnostics = null,
183
+ }) {
184
+ const context = prepareRecordContext(schema, warningSystem);
185
+ const fixedHelpers = { ...helpers };
186
+ const fixedSecurity = {
187
+ ...security,
188
+ ...(Array.isArray(security.allowedGlobals)
189
+ ? { allowedGlobals: [...security.allowedGlobals] }
190
+ : {}),
191
+ ...(Array.isArray(security.blockedPatterns)
192
+ ? {
193
+ blockedPatterns: security.blockedPatterns.map((pattern) =>
194
+ pattern instanceof RegExp ? new RegExp(pattern.source, pattern.flags) : pattern
195
+ ),
196
+ }
197
+ : {}),
198
+ };
199
+ const schemaRevision = getFormSchemaRevision(context.sourceSchema);
200
+ const diagnostics = createCalculationDiagnostics(
201
+ diagnosticOptions,
202
+ null,
203
+ context.resolver,
204
+ warningSystem
205
+ );
206
+ const compileCache = new Map();
207
+ const calculationPreparationCache = new Map();
208
+ const eventSourceCache = new Map();
209
+ const eventReferenceCache = new Map();
210
+ const literalEventRegistration = isLiteralEventRegistration(
211
+ context.schema.form.events?.code,
212
+ Object.hasOwn(fixedHelpers, 'ON') ? fixedHelpers.ON : eventBuiltins.ON
213
+ );
214
+ const evaluatorId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
215
+ let generation = 0;
216
+ let review = null;
217
+
218
+ async function run(input, options = {}, scopePath = null) {
219
+ const mode = options.mode ?? 'accepted';
220
+ if (!['accepted', 'refresh'].includes(mode))
221
+ throw problem('invalid_request', 'Evaluation mode must be accepted or refresh.');
222
+ const scoped = scopePath !== null;
223
+ const viewing = scoped && options.interactionMode === 'readonly';
224
+ if (
225
+ scoped &&
226
+ options.interactionMode !== undefined &&
227
+ !['edit', 'readonly'].includes(options.interactionMode)
228
+ )
229
+ throw problem('invalid_request', 'interactionMode must be edit or readonly.');
230
+ if (options.signal?.aborted) throw problem('cancelled', 'Record evaluation was cancelled.');
231
+ const snapshot = scoped ? input : copyRecordData(input);
232
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
233
+ throw problem('invalid_record_tree', 'A runtime record snapshot is required.');
234
+ const pause = scheduler(options);
235
+ if (!scoped) await pause();
236
+ else if (options.signal?.aborted)
237
+ throw problem('cancelled', 'Record evaluation was cancelled.');
238
+ const scopes = [];
239
+ const byDefinition = new Map();
240
+ let selectedScope = null;
241
+ const prepareScope = (scope) => {
242
+ let { record } = scope;
243
+ const { template } = scope;
244
+ scope.sourceRecord = record;
245
+ // Capture only the calculation values needed by the change report. A second complete
246
+ // record-tree copy is unnecessary; all execution happens against our private snapshot.
247
+ if (!scoped)
248
+ scope.beforeCalculations = new Map(
249
+ template.fields
250
+ .filter((field) => field.type === 'CalculatedField')
251
+ .map((field) => [
252
+ field.data_name,
253
+ copyRecordData(record.values?.[field.data_name] ?? null),
254
+ ])
255
+ );
256
+ if (
257
+ record.values !== undefined &&
258
+ (!record.values || typeof record.values !== 'object' || Array.isArray(record.values))
259
+ )
260
+ throw problem('invalid_record_tree', 'Record values must be an object.');
261
+ if (
262
+ record.repeatable !== undefined &&
263
+ (!record.repeatable ||
264
+ typeof record.repeatable !== 'object' ||
265
+ Array.isArray(record.repeatable))
266
+ )
267
+ throw problem('invalid_record_tree', 'Record repeatable collections must be an object.');
268
+ if (scoped && sameInstancePath(scope.instancePath, scopePath)) {
269
+ selectedScope = scope;
270
+ scope.beforeValues = copyRecordData(record.values || {});
271
+ record = scope.record = { ...record, values: copyRecordData(record.values || {}) };
272
+ }
273
+ if (!scoped || selectedScope === scope) {
274
+ record.values ??= {};
275
+ record.errors = {};
276
+ record.visible = {};
277
+ record.required = {};
278
+ record.read_only = {};
279
+ for (const field of template.fields)
280
+ if (!Object.hasOwn(record.values, field.data_name))
281
+ record.values[field.data_name] = copyRecordData(
282
+ getDefaultValue(viewing ? { ...field, default_value: null } : field)
283
+ );
284
+ }
285
+ scope.uid = scopes.length;
286
+ scope.lineage = [...scope.ancestors, scope];
287
+ scope.nodes = new Map();
288
+ scopes.push(scope);
289
+ const key = JSON.stringify(template.path);
290
+ if (!byDefinition.has(key)) byDefinition.set(key, []);
291
+ byDefinition.get(key).push(scope);
292
+ };
293
+ // Live scoped reads must all be captured before the first yield. Full runs already own
294
+ // their captured input, so scope preparation can give the host control between rows.
295
+ if (scoped) visitRecordScopes(snapshot, context, prepareScope);
296
+ else
297
+ for (const scope of iterateRecordScopes(snapshot, context)) {
298
+ prepareScope(scope);
299
+ await pause();
300
+ }
301
+ if (scoped && !selectedScope)
302
+ throw problem('invalid_instance_path', 'No record matches this instancePath.');
303
+ const nodes = [];
304
+ for (const scope of scopes) {
305
+ if (
306
+ viewing ||
307
+ (scoped ? scope !== selectedScope : scope.instancePath.length && mode === 'accepted')
308
+ )
309
+ continue;
310
+ for (const field of scope.template.fields)
311
+ if (field.type === 'CalculatedField' && field.calculate) {
312
+ const definition = context.calculations.get(field.data_name);
313
+ const node = {
314
+ scope,
315
+ field,
316
+ definition,
317
+ dependencies: new Set(),
318
+ state: 0,
319
+ blocked: false,
320
+ };
321
+ scope.nodes.set(field.data_name, node);
322
+ nodes.push(node);
323
+ }
324
+ if (!scoped) await pause();
325
+ }
326
+ const ownerScope = (scope, name) => {
327
+ const info = context.resolver.fieldOwnership.get(name);
328
+ return info &&
329
+ info.parentPath.length <= scope.template.path.length &&
330
+ info.parentPath.every((part, index) => scope.template.path[index] === part)
331
+ ? scope.lineage[info.parentPath.length]
332
+ : null;
333
+ };
334
+ const queryCache = new Map();
335
+ function rows(scope, section) {
336
+ const info = context.resolver.repeatableSectionTree.get(section);
337
+ const depth = Math.min(scope.template.path.length, info.parentPath.length);
338
+ const anchor = scope.lineage[depth];
339
+ const key = `${anchor.uid}:${section}`;
340
+ if (!queryCache.has(key))
341
+ queryCache.set(
342
+ key,
343
+ (byDefinition.get(JSON.stringify(info.currentPath)) || []).filter(
344
+ (candidate) => candidate.lineage[depth] === anchor
345
+ )
346
+ );
347
+ return queryCache.get(key);
348
+ }
349
+ if (scoped) {
350
+ const captured = new Set([selectedScope]);
351
+ for (const ancestor of selectedScope.ancestors) {
352
+ ancestor.record = { values: copyRecordData(ancestor.sourceRecord.values || {}) };
353
+ captured.add(ancestor);
354
+ }
355
+ // Capture only the fields actually read from closed collections, before yielding.
356
+ for (const node of nodes)
357
+ for (const call of node.definition.collection.calls)
358
+ if (!node.definition.collection.issues.length)
359
+ for (const row of rows(selectedScope, call.section)) {
360
+ if (row === selectedScope) continue;
361
+ if (!captured.has(row)) {
362
+ row.record = { values: {} };
363
+ captured.add(row);
364
+ }
365
+ row.record.values[call.target] = copyRecordData(
366
+ row.sourceRecord.values?.[call.target]
367
+ );
368
+ }
369
+ await pause();
370
+ }
371
+ for (const node of nodes) {
372
+ for (const name of node.definition.directReferences) {
373
+ const dependency = ownerScope(node.scope, name)?.nodes.get(name);
374
+ if (dependency) node.dependencies.add(dependency);
375
+ }
376
+ if (!node.definition.collection.issues.length)
377
+ for (const call of node.definition.collection.calls)
378
+ for (const row of rows(node.scope, call.section)) {
379
+ const dependency = row.nodes.get(call.target);
380
+ if (dependency) node.dependencies.add(dependency);
381
+ }
382
+ await pause();
383
+ }
384
+ const ordered = [];
385
+ const visiting = [];
386
+ const reported = [];
387
+ let fatal = null;
388
+ function cycle(node) {
389
+ const group = visiting.slice(visiting.indexOf(node));
390
+ if (
391
+ new Set(group.map((entry) => entry.scope.uid)).size > 1 ||
392
+ group.some((entry) => entry.definition.collection.calls.length)
393
+ ) {
394
+ fatal = problem(
395
+ 'record_dependency_cycle',
396
+ `Record-spanning calculation dependency cycle detected: ${group.map((entry) => entry.field.data_name).join(' -> ')}.`
397
+ );
398
+ fatal.instancePath = node.scope.instancePath;
399
+ fatal.context = {
400
+ cycle: group.map((entry) => ({
401
+ fieldName: entry.field.data_name,
402
+ instancePath: entry.scope.instancePath,
403
+ })),
404
+ };
405
+ throw fatal;
406
+ }
407
+ for (const entry of group) {
408
+ entry.blocked = true;
409
+ entry.scope.record.values[entry.field.data_name] = null;
410
+ reported.push({
411
+ code: 'cyclic_dependencies',
412
+ severity: 'error',
413
+ phase: 'dependency',
414
+ fieldName: entry.field.data_name,
415
+ message: 'CalculatedField dependency cycle detected; involved fields evaluate to null.',
416
+ suggestion: 'Remove the calculation cycle.',
417
+ context: {},
418
+ instancePath: entry.scope.instancePath,
419
+ });
420
+ }
421
+ }
422
+ function order(node) {
423
+ if (node.state === 2) return;
424
+ if (node.state === 1) {
425
+ cycle(node);
426
+ return;
427
+ }
428
+ node.state = 1;
429
+ visiting.push(node);
430
+ for (const dependency of node.dependencies) order(dependency);
431
+ visiting.pop();
432
+ node.state = 2;
433
+ ordered.push(node);
434
+ }
435
+ for (const node of nodes) {
436
+ order(node);
437
+ await pause();
438
+ }
439
+ for (const node of nodes) node.state = 0;
440
+ const projectionCache = new Map();
441
+ const readable = (scope) =>
442
+ Object.assign({}, ...scope.lineage.map((entry) => entry.record.values));
443
+ function execute(node) {
444
+ if (node.blocked || node.state === 2) return;
445
+ if (node.state === 1) {
446
+ cycle(node);
447
+ return;
448
+ }
449
+ node.state = 1;
450
+ visiting.push(node);
451
+ for (const dependency of node.dependencies) execute(dependency);
452
+ const frame = diagnostics.begin();
453
+ try {
454
+ const analysis = node.definition.collection;
455
+ if (analysis.issues.length) {
456
+ node.scope.record.values[node.field.data_name] = null;
457
+ for (const item of analysis.issues)
458
+ diagnostics.record(node.field.data_name, { ...item, phase: 'validation' });
459
+ } else {
460
+ function collect(section, target) {
461
+ const info = context.resolver.repeatableSectionTree.get(section);
462
+ const anchor =
463
+ node.scope.lineage[Math.min(node.scope.template.path.length, info.parentPath.length)];
464
+ const key = `${anchor.uid}:${section}:${target}`;
465
+ if (!projectionCache.has(key)) {
466
+ const field = context.resolver.fieldOwnership.get(target).field;
467
+ projectionCache.set(
468
+ key,
469
+ rows(node.scope, section).map((row) => {
470
+ const dependency = row.nodes.get(target);
471
+ if (dependency) execute(dependency);
472
+ return copyRecordData(projectCollectionValue(field, row.record.values?.[target]));
473
+ })
474
+ );
475
+ }
476
+ return copyRecordData(projectionCache.get(key));
477
+ }
478
+ const runtimeHelpers = {
479
+ ...calculationBuiltins,
480
+ ...fixedHelpers,
481
+ REPEATABLEVALUES: collect,
482
+ REPEATABLESUM: (section, target) =>
483
+ collect(section, target).reduce((sum, value) => {
484
+ if (value == null) return sum;
485
+ if (typeof value !== 'number' || !Number.isFinite(value))
486
+ throw new Error(
487
+ 'REPEATABLESUM requires finite numbers; numeric strings are not converted.'
488
+ );
489
+ const next = sum + value;
490
+ if (!Number.isFinite(next)) throw new Error('REPEATABLESUM result is not finite.');
491
+ return next;
492
+ }, 0),
493
+ };
494
+ const calculationValues = readable(node.scope);
495
+ for (const name of node.definition.directReferences) {
496
+ const owner = ownerScope(node.scope, name);
497
+ if (
498
+ owner &&
499
+ owner !== node.scope &&
500
+ calculationValues[name] &&
501
+ typeof calculationValues[name] === 'object'
502
+ )
503
+ calculationValues[name] = copyRecordData(calculationValues[name]);
504
+ }
505
+ evaluateCalculatedField(
506
+ node.field,
507
+ context.schema.form,
508
+ calculationValues,
509
+ runtimeHelpers,
510
+ fixedSecurity,
511
+ context.resolver,
512
+ warningSystem,
513
+ null,
514
+ diagnostics,
515
+ {
516
+ expression: analysis.expression,
517
+ compileCache,
518
+ preparationCache: calculationPreparationCache,
519
+ writeValue: (value) => {
520
+ node.scope.record.values[node.field.data_name] = value;
521
+ },
522
+ resolveReference: (reference) => {
523
+ const name = reference.slice(1);
524
+ const owner = ownerScope(node.scope, name);
525
+ if (!owner) return undefined;
526
+ const dependency = owner.nodes.get(name);
527
+ if (dependency) execute(dependency);
528
+ return copyRecordData(owner.record.values[name]);
529
+ },
530
+ }
531
+ );
532
+ }
533
+ } finally {
534
+ diagnostics.complete(frame);
535
+ reported.push(
536
+ ...diagnostics
537
+ .getSnapshot()
538
+ .map((item) => ({ ...item, instancePath: node.scope.instancePath }))
539
+ );
540
+ visiting.pop();
541
+ node.state = 2;
542
+ }
543
+ if (node.blocked) node.scope.record.values[node.field.data_name] = null;
544
+ if (fatal) throw fatal;
545
+ }
546
+ for (const node of ordered) {
547
+ execute(node);
548
+ await pause();
549
+ }
550
+ for (const scope of scopes) {
551
+ if (scoped && scope !== selectedScope) continue;
552
+ const values = readable(scope);
553
+ evaluateRequirement(
554
+ scope.template.form,
555
+ values,
556
+ scope.record.required,
557
+ scope.template.conditions
558
+ );
559
+ evaluateVisibility(
560
+ scope.template.form,
561
+ values,
562
+ scope.record.visible,
563
+ scope.template.conditions
564
+ );
565
+ evaluateReadOnly(
566
+ scope.template.form,
567
+ values,
568
+ scope.record.read_only,
569
+ scope.template.conditions
570
+ );
571
+ for (const field of scope.template.fields) {
572
+ const error = validateFieldValue(field, scope.record.values[field.data_name]);
573
+ if (error) scope.record.errors[field.data_name] = error;
574
+ }
575
+ await pause();
576
+ }
577
+ const changes = [];
578
+ for (const scope of scopes) {
579
+ if (scoped && scope !== selectedScope) continue;
580
+ for (const field of scope.template.fields)
581
+ if (field.type === 'CalculatedField') {
582
+ const before = scoped
583
+ ? (scope.beforeValues[field.data_name] ?? null)
584
+ : scope.beforeCalculations.get(field.data_name);
585
+ const after = scope.record.values[field.data_name] ?? null;
586
+ if (!areStructuredValuesEqual(before, after))
587
+ changes.push({
588
+ instancePath: scope.instancePath,
589
+ fieldName: field.data_name,
590
+ fieldKey: field.key || field.data_name,
591
+ before: copyRecordData(before),
592
+ after: copyRecordData(after),
593
+ });
594
+ }
595
+ await pause();
596
+ }
597
+ if (scoped) {
598
+ // A scoped caller need not carry cached ancestor visibility maps. Resolve participation
599
+ // from the current supplied ancestors without refreshing any ancestor calculations.
600
+ selectedScope.participating = true;
601
+ for (const [index, ancestor] of selectedScope.ancestors.entries()) {
602
+ const visible = {};
603
+ evaluateVisibility(
604
+ ancestor.template.form,
605
+ readable(ancestor),
606
+ visible,
607
+ ancestor.template.conditions
608
+ );
609
+ const key = selectedScope.instancePath[index].repeatableKey;
610
+ const section = ancestor.template.fields.find(
611
+ (field) =>
612
+ field.type === 'RepeatableSection' &&
613
+ context.resolver.repeatableSectionTree.get(field.data_name).preferredKey === key
614
+ );
615
+ if (
616
+ visible[section.data_name] === false ||
617
+ !(ancestor.template.chains.get(section.data_name) || []).every(
618
+ (name) => visible[name] !== false
619
+ )
620
+ )
621
+ selectedScope.participating = false;
622
+ }
623
+ const { values, errors, visible, required, read_only } = selectedScope.record;
624
+ return {
625
+ status: 'evaluated',
626
+ instancePath: copyRecordData(selectedScope.instancePath),
627
+ state: { values, errors, visible, required, read_only },
628
+ validationSummary: summarizeSubmissionFindings(validateScope(selectedScope)),
629
+ changes,
630
+ diagnostics: reported,
631
+ issues: [],
632
+ };
633
+ }
634
+ return { status: 'evaluated', snapshot, changes, diagnostics: reported, issues: [] };
635
+ }
636
+
637
+ async function validate(result, options) {
638
+ const custom = [];
639
+ const executionIssues = [];
640
+ const pause = scheduler(options);
641
+ await pause();
642
+ if (options.managedEvents === true) {
643
+ const scopes = [];
644
+ visitRecordScopes(result.snapshot, context, (scope) => scopes.push(scope));
645
+ for (const scope of scopes) {
646
+ if (!scope.participating) continue;
647
+ const values = Object.assign(
648
+ {},
649
+ ...scope.ancestors.map((entry) => entry.record.values),
650
+ scope.record.values
651
+ );
652
+ const eventManager = new EventManager(
653
+ context.schema.form,
654
+ context.resolver,
655
+ warningSystem,
656
+ {
657
+ sourceCache: eventSourceCache,
658
+ compilationCache: compileCache,
659
+ referenceSourceCache: eventReferenceCache,
660
+ }
661
+ );
662
+ eventManager.securityConfig = fixedSecurity;
663
+ const initialContext = { ...eventBuiltins, ...fixedHelpers };
664
+ if (literalEventRegistration) {
665
+ // These calls only register functions. Handlers still execute with fresh scoped values.
666
+ eventManager.initializeEventCode(context.schema.form.events.code, initialContext);
667
+ for (const [name, value] of Object.entries(values))
668
+ initialContext[`$${name}`] = copyRecordData(value);
669
+ } else {
670
+ for (const [name, info] of context.resolver.fieldOwnership)
671
+ initialContext[`$${name}`] = copyRecordData(getDefaultValue(info.field));
672
+ for (const [name, value] of Object.entries(values))
673
+ initialContext[`$${name}`] = copyRecordData(value);
674
+ if (context.schema.form.events?.code)
675
+ eventManager.initializeEventCode(context.schema.form.events.code, initialContext);
676
+ }
677
+ eventManager.eventContext = initialContext;
678
+ const dispatch = createManagedEventDispatcher(
679
+ eventManager,
680
+ {
681
+ instancePath: scope.instancePath,
682
+ },
683
+ () => 'readonly'
684
+ );
685
+ const row = scope.instancePath.length > 0;
686
+ const definition = row
687
+ ? context.repeatableByPath.get(JSON.stringify(scope.template.path))
688
+ : null;
689
+ const detail = dispatch(
690
+ row ? 'validate-repeatable' : 'validate-record',
691
+ definition?.field.data_name,
692
+ { isDraft: options.intent === 'draft', isValid: validateScope(scope).length === 0 }
693
+ );
694
+ custom.push(...detail.validationIssues);
695
+ executionIssues.push(...detail.issues);
696
+ await pause();
697
+ }
698
+ }
699
+ const validationSummary = validateSubmissionWithContext(result.snapshot, context, custom);
700
+ await pause();
701
+ return {
702
+ ...result,
703
+ status: executionIssues.length
704
+ ? 'failed'
705
+ : options.intent !== 'draft' && validationSummary.hasErrors
706
+ ? 'blocked'
707
+ : 'ready',
708
+ eligible:
709
+ !executionIssues.length && (options.intent === 'draft' || !validationSummary.hasErrors),
710
+ validationSummary,
711
+ issues: executionIssues,
712
+ };
713
+ }
714
+
715
+ async function evaluate(snapshot, options = {}) {
716
+ try {
717
+ const result = await run(snapshot, options);
718
+ if (typeof onDiagnostics === 'function') {
719
+ try {
720
+ Promise.resolve(onDiagnostics(copyRecordData(result.diagnostics))).catch(() => {});
721
+ } catch {
722
+ /* Observational. */
723
+ }
724
+ }
725
+ return result;
726
+ } catch (error) {
727
+ return failure(error);
728
+ }
729
+ }
730
+
731
+ /** Evaluate one editor without refreshing or copying its accepted descendants. */
732
+ async function evaluateScope(snapshot, options = {}) {
733
+ try {
734
+ const path = options.instancePath ?? [];
735
+ validateInstancePath(path);
736
+ const result = await run(snapshot, { ...options, mode: 'accepted' }, path);
737
+ if (typeof onDiagnostics === 'function') {
738
+ try {
739
+ Promise.resolve(onDiagnostics(copyRecordData(result.diagnostics))).catch(() => {});
740
+ } catch {
741
+ /* Observational. */
742
+ }
743
+ }
744
+ return result;
745
+ } catch (error) {
746
+ return { ...failure(error), state: null };
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Prepare only: no save hooks, persistence, editor acceptance or timestamp generation.
752
+ * Resume a required decision with {decision:{reviewId, action:'refresh'|'retain'|'cancel'}}.
753
+ */
754
+ async function prepareSubmission(snapshot, options = {}) {
755
+ const prior = review;
756
+ const attempt = ++generation;
757
+ review = null;
758
+ try {
759
+ const intent = options.intent ?? 'submit';
760
+ if (!['submit', 'draft'].includes(intent))
761
+ throw problem('invalid_request', 'Submission intent must be submit or draft.');
762
+ if (options.mode !== undefined && !['automatic', 'confirm'].includes(options.mode))
763
+ throw problem('invalid_request', 'Submission mode must be automatic or confirm.');
764
+ const mode =
765
+ context.schema.form.repeatable_recalculation === 'confirm' || options.mode === 'confirm'
766
+ ? 'confirm'
767
+ : 'automatic';
768
+ const settings = {
769
+ intent,
770
+ mode,
771
+ allowRetain: options.allowRetain === true,
772
+ managedEvents: options.managedEvents === true,
773
+ };
774
+ if (options.signal?.aborted) throw problem('cancelled', 'Record evaluation was cancelled.');
775
+ let fresh, retained, selected;
776
+ if (options.decision) {
777
+ const decision = options.decision;
778
+ if (
779
+ !prior ||
780
+ prior.id !== decision.reviewId ||
781
+ !areStructuredValuesEqual(snapshot, prior.source) ||
782
+ !areStructuredValuesEqual(settings, prior.settings)
783
+ )
784
+ throw problem(
785
+ 'stale_review',
786
+ 'Submission review is obsolete; prepare the current record again.'
787
+ );
788
+ if (decision.action === 'cancel')
789
+ return {
790
+ status: 'cancelled',
791
+ eligible: false,
792
+ snapshot: null,
793
+ issues: [],
794
+ diagnostics: [],
795
+ };
796
+ if (!['refresh', 'retain'].includes(decision.action))
797
+ throw problem('invalid_decision', 'Decision must be refresh, retain or cancel.');
798
+ if (decision.action === 'retain' && !settings.allowRetain)
799
+ throw problem(
800
+ 'retention_not_allowed',
801
+ 'The host has not permitted retaining child calculations.'
802
+ );
803
+ fresh = prior.fresh;
804
+ retained = prior.retained;
805
+ selected = decision.action === 'retain' ? retained : fresh;
806
+ } else {
807
+ // run() captures its input before the first yield. Automatic submission has no
808
+ // retained branch, so only confirmation/drafts need another accepted-source copy.
809
+ const source =
810
+ intent === 'draft' || mode === 'confirm' ? copyRecordData(snapshot) : snapshot;
811
+ fresh = await run(source, { ...options, mode: 'refresh' });
812
+ if (attempt !== generation)
813
+ throw problem(
814
+ 'stale_preparation',
815
+ 'A newer submission preparation superseded this attempt.'
816
+ );
817
+ const childChanges = fresh.changes.filter((change) => change.instancePath.length);
818
+ if (intent === 'draft' || (mode === 'confirm' && childChanges.length))
819
+ retained = await run(source, { ...options, mode: 'accepted' });
820
+ if (attempt !== generation)
821
+ throw problem(
822
+ 'stale_preparation',
823
+ 'A newer submission preparation superseded this attempt.'
824
+ );
825
+ if (intent === 'submit' && mode === 'confirm' && childChanges.length) {
826
+ const id = `${evaluatorId}:${attempt}`;
827
+ const pendingReview = { id, source, settings, fresh, retained };
828
+ const result = await copyRecordDataCooperatively(
829
+ {
830
+ status: 'requires_decision',
831
+ eligible: false,
832
+ snapshot: null,
833
+ proposal: fresh.snapshot,
834
+ reviewId: id,
835
+ changes: fresh.changes,
836
+ validationSummary: validateSubmissionWithContext(fresh.snapshot, context),
837
+ diagnostics: fresh.diagnostics,
838
+ issues: [],
839
+ freshness: createRecordFreshness(schemaRevision, childChanges),
840
+ choices: settings.allowRetain
841
+ ? ['refresh', 'retain', 'cancel']
842
+ : ['refresh', 'cancel'],
843
+ },
844
+ scheduler(options)
845
+ );
846
+ if (attempt !== generation)
847
+ throw problem(
848
+ 'stale_preparation',
849
+ 'A newer submission preparation superseded this attempt.'
850
+ );
851
+ review = pendingReview;
852
+ return result;
853
+ }
854
+ selected = intent === 'draft' ? retained : fresh;
855
+ }
856
+ const childChanges = fresh.changes.filter((change) => change.instancePath.length);
857
+ const pending = selected === retained && childChanges.length > 0;
858
+ const prepared = await validate(selected, { ...options, intent });
859
+ if (attempt !== generation)
860
+ throw problem(
861
+ 'stale_preparation',
862
+ 'A newer submission preparation superseded this attempt.'
863
+ );
864
+ const result = await copyRecordDataCooperatively(
865
+ {
866
+ ...prepared,
867
+ changes: fresh.changes,
868
+ freshness: createRecordFreshness(schemaRevision, pending ? childChanges : []),
869
+ },
870
+ scheduler(options)
871
+ );
872
+ if (attempt !== generation)
873
+ throw problem(
874
+ 'stale_preparation',
875
+ 'A newer submission preparation superseded this attempt.'
876
+ );
877
+ return result;
878
+ } catch (error) {
879
+ return failure(error);
880
+ }
881
+ }
882
+ return { evaluate, evaluateScope, prepareSubmission };
883
+ }