form0-core 0.7.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.
- package/README.md +17 -0
- package/package.json +3 -2
- package/src/builtins/builtin-definitions.js +5 -0
- package/src/builtins/builtin-metadata.js +12 -0
- package/src/engine/calculation-bindings.js +76 -0
- package/src/engine/calculation.js +15 -2
- package/src/engine/evaluator.js +10 -12
- package/src/engine/event-operation-scope.js +7 -3
- package/src/engine/event-policy.js +61 -0
- package/src/engine/event-registration.js +38 -0
- package/src/engine/event-source.js +15 -12
- package/src/engine/events.js +36 -12
- package/src/engine/expression-compilation.js +39 -0
- package/src/engine/form-engine.js +56 -21
- package/src/engine/managed-events.js +26 -18
- package/src/engine/record-context.js +13 -6
- package/src/engine/record-evaluator.js +282 -56
- package/src/engine/submission-validation.js +5 -0
- package/src/index.js +4 -1
- package/src/security/validation.js +4 -4
- package/src/utilities/form-event-editor.js +4 -0
- package/src/utilities/record-data.js +50 -4
- package/src/utilities/record-transformer.js +110 -39
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { prepareRecordContext, visitRecordScopes } from './record-context.js';
|
|
2
|
-
import {
|
|
1
|
+
import { prepareRecordContext, visitRecordScopes, iterateRecordScopes } from './record-context.js';
|
|
2
|
+
import {
|
|
3
|
+
validateSubmissionWithContext,
|
|
4
|
+
validateScope,
|
|
5
|
+
summarizeSubmissionFindings,
|
|
6
|
+
} from './submission-validation.js';
|
|
3
7
|
import { getDefaultValue } from './form-engine.js';
|
|
4
8
|
import { evaluateCalculatedField } from './calculation.js';
|
|
5
9
|
import { evaluateVisibility, evaluateRequirement, evaluateReadOnly } from './conditions.js';
|
|
6
10
|
import { createCalculationDiagnostics } from './calculation-diagnostics.js';
|
|
7
11
|
import { createManagedEventDispatcher } from './managed-events.js';
|
|
12
|
+
import { isLiteralEventRegistration } from './event-registration.js';
|
|
8
13
|
import { EventManager } from './events.js';
|
|
9
14
|
import { WarningSystem } from './warning-system.js';
|
|
10
15
|
import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
|
|
11
16
|
import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
|
|
12
17
|
import { validateFieldValue } from '../schema/field-value-registry.js';
|
|
13
|
-
import { copyRecordData } from '../utilities/record-data.js';
|
|
18
|
+
import { copyRecordData, copyRecordDataCooperatively } from '../utilities/record-data.js';
|
|
14
19
|
import { areStructuredValuesEqual } from '../utilities/value-equality.js';
|
|
15
20
|
import { projectCollectionValue } from '../utilities/repeatable-collections.js';
|
|
16
21
|
import { getFormSchemaRevision } from '../utilities/schema-revision.js';
|
|
@@ -34,6 +39,42 @@ function problem(code, message) {
|
|
|
34
39
|
return Object.assign(new Error(message), { code });
|
|
35
40
|
}
|
|
36
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
|
+
|
|
37
78
|
function scheduler(options) {
|
|
38
79
|
let started = clock();
|
|
39
80
|
const check = () => {
|
|
@@ -103,8 +144,25 @@ function scheduler(options) {
|
|
|
103
144
|
* @property {string} [reviewId] Single-use factory-owned review handle.
|
|
104
145
|
* @property {Array<'refresh'|'retain'|'cancel'>} [choices]
|
|
105
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
|
+
*
|
|
106
163
|
* @typedef {Object} RecordEvaluator
|
|
107
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.
|
|
108
166
|
* @property {(snapshot: RecordSnapshot, options?: SubmissionPreparationOptions) => Promise<RecordEvaluationResult>} prepareSubmission
|
|
109
167
|
*/
|
|
110
168
|
|
|
@@ -149,24 +207,52 @@ export function createRecordEvaluator({
|
|
|
149
207
|
const calculationPreparationCache = new Map();
|
|
150
208
|
const eventSourceCache = new Map();
|
|
151
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
|
+
);
|
|
152
214
|
const evaluatorId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
|
153
215
|
let generation = 0;
|
|
154
216
|
let review = null;
|
|
155
217
|
|
|
156
|
-
async function run(input, options = {}) {
|
|
218
|
+
async function run(input, options = {}, scopePath = null) {
|
|
157
219
|
const mode = options.mode ?? 'accepted';
|
|
158
220
|
if (!['accepted', 'refresh'].includes(mode))
|
|
159
221
|
throw problem('invalid_request', 'Evaluation mode must be accepted or refresh.');
|
|
160
|
-
|
|
161
|
-
const
|
|
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);
|
|
162
232
|
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
|
|
163
233
|
throw problem('invalid_record_tree', 'A runtime record snapshot is required.');
|
|
164
234
|
const pause = scheduler(options);
|
|
165
|
-
await pause();
|
|
235
|
+
if (!scoped) await pause();
|
|
236
|
+
else if (options.signal?.aborted)
|
|
237
|
+
throw problem('cancelled', 'Record evaluation was cancelled.');
|
|
166
238
|
const scopes = [];
|
|
167
239
|
const byDefinition = new Map();
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
);
|
|
170
256
|
if (
|
|
171
257
|
record.values !== undefined &&
|
|
172
258
|
(!record.values || typeof record.values !== 'object' || Array.isArray(record.values))
|
|
@@ -179,14 +265,23 @@ export function createRecordEvaluator({
|
|
|
179
265
|
Array.isArray(record.repeatable))
|
|
180
266
|
)
|
|
181
267
|
throw problem('invalid_record_tree', 'Record repeatable collections must be an object.');
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
|
190
285
|
scope.uid = scopes.length;
|
|
191
286
|
scope.lineage = [...scope.ancestors, scope];
|
|
192
287
|
scope.nodes = new Map();
|
|
@@ -194,10 +289,24 @@ export function createRecordEvaluator({
|
|
|
194
289
|
const key = JSON.stringify(template.path);
|
|
195
290
|
if (!byDefinition.has(key)) byDefinition.set(key, []);
|
|
196
291
|
byDefinition.get(key).push(scope);
|
|
197
|
-
}
|
|
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.');
|
|
198
303
|
const nodes = [];
|
|
199
304
|
for (const scope of scopes) {
|
|
200
|
-
if (
|
|
305
|
+
if (
|
|
306
|
+
viewing ||
|
|
307
|
+
(scoped ? scope !== selectedScope : scope.instancePath.length && mode === 'accepted')
|
|
308
|
+
)
|
|
309
|
+
continue;
|
|
201
310
|
for (const field of scope.template.fields)
|
|
202
311
|
if (field.type === 'CalculatedField' && field.calculate) {
|
|
203
312
|
const definition = context.calculations.get(field.data_name);
|
|
@@ -212,6 +321,7 @@ export function createRecordEvaluator({
|
|
|
212
321
|
scope.nodes.set(field.data_name, node);
|
|
213
322
|
nodes.push(node);
|
|
214
323
|
}
|
|
324
|
+
if (!scoped) await pause();
|
|
215
325
|
}
|
|
216
326
|
const ownerScope = (scope, name) => {
|
|
217
327
|
const info = context.resolver.fieldOwnership.get(name);
|
|
@@ -236,6 +346,28 @@ export function createRecordEvaluator({
|
|
|
236
346
|
);
|
|
237
347
|
return queryCache.get(key);
|
|
238
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
|
+
}
|
|
239
371
|
for (const node of nodes) {
|
|
240
372
|
for (const name of node.definition.directReferences) {
|
|
241
373
|
const dependency = ownerScope(node.scope, name)?.nodes.get(name);
|
|
@@ -247,6 +379,7 @@ export function createRecordEvaluator({
|
|
|
247
379
|
const dependency = row.nodes.get(call.target);
|
|
248
380
|
if (dependency) node.dependencies.add(dependency);
|
|
249
381
|
}
|
|
382
|
+
await pause();
|
|
250
383
|
}
|
|
251
384
|
const ordered = [];
|
|
252
385
|
const visiting = [];
|
|
@@ -336,7 +469,7 @@ export function createRecordEvaluator({
|
|
|
336
469
|
rows(node.scope, section).map((row) => {
|
|
337
470
|
const dependency = row.nodes.get(target);
|
|
338
471
|
if (dependency) execute(dependency);
|
|
339
|
-
return copyRecordData(projectCollectionValue(field, row.record.values[target]));
|
|
472
|
+
return copyRecordData(projectCollectionValue(field, row.record.values?.[target]));
|
|
340
473
|
})
|
|
341
474
|
);
|
|
342
475
|
}
|
|
@@ -415,6 +548,7 @@ export function createRecordEvaluator({
|
|
|
415
548
|
await pause();
|
|
416
549
|
}
|
|
417
550
|
for (const scope of scopes) {
|
|
551
|
+
if (scoped && scope !== selectedScope) continue;
|
|
418
552
|
const values = readable(scope);
|
|
419
553
|
evaluateRequirement(
|
|
420
554
|
scope.template.form,
|
|
@@ -441,14 +575,13 @@ export function createRecordEvaluator({
|
|
|
441
575
|
await pause();
|
|
442
576
|
}
|
|
443
577
|
const changes = [];
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
let original = input;
|
|
447
|
-
for (const segment of scope.instancePath)
|
|
448
|
-
original = original.repeatable?.[segment.repeatableKey]?.[segment.index];
|
|
578
|
+
for (const scope of scopes) {
|
|
579
|
+
if (scoped && scope !== selectedScope) continue;
|
|
449
580
|
for (const field of scope.template.fields)
|
|
450
581
|
if (field.type === 'CalculatedField') {
|
|
451
|
-
const before =
|
|
582
|
+
const before = scoped
|
|
583
|
+
? (scope.beforeValues[field.data_name] ?? null)
|
|
584
|
+
: scope.beforeCalculations.get(field.data_name);
|
|
452
585
|
const after = scope.record.values[field.data_name] ?? null;
|
|
453
586
|
if (!areStructuredValuesEqual(before, after))
|
|
454
587
|
changes.push({
|
|
@@ -459,7 +592,45 @@ export function createRecordEvaluator({
|
|
|
459
592
|
after: copyRecordData(after),
|
|
460
593
|
});
|
|
461
594
|
}
|
|
462
|
-
|
|
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
|
+
}
|
|
463
634
|
return { status: 'evaluated', snapshot, changes, diagnostics: reported, issues: [] };
|
|
464
635
|
}
|
|
465
636
|
|
|
@@ -490,16 +661,27 @@ export function createRecordEvaluator({
|
|
|
490
661
|
);
|
|
491
662
|
eventManager.securityConfig = fixedSecurity;
|
|
492
663
|
const initialContext = { ...eventBuiltins, ...fixedHelpers };
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
for (const [name, value] of Object.entries(values))
|
|
496
|
-
initialContext[`$${name}`] = copyRecordData(value);
|
|
497
|
-
if (context.schema.form.events?.code)
|
|
664
|
+
if (literalEventRegistration) {
|
|
665
|
+
// These calls only register functions. Handlers still execute with fresh scoped values.
|
|
498
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
|
+
}
|
|
499
677
|
eventManager.eventContext = initialContext;
|
|
500
|
-
const dispatch = createManagedEventDispatcher(
|
|
501
|
-
|
|
502
|
-
|
|
678
|
+
const dispatch = createManagedEventDispatcher(
|
|
679
|
+
eventManager,
|
|
680
|
+
{
|
|
681
|
+
instancePath: scope.instancePath,
|
|
682
|
+
},
|
|
683
|
+
() => 'readonly'
|
|
684
|
+
);
|
|
503
685
|
const row = scope.instancePath.length > 0;
|
|
504
686
|
const definition = row
|
|
505
687
|
? context.repeatableByPath.get(JSON.stringify(scope.template.path))
|
|
@@ -546,6 +728,25 @@ export function createRecordEvaluator({
|
|
|
546
728
|
}
|
|
547
729
|
}
|
|
548
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
|
+
|
|
549
750
|
/**
|
|
550
751
|
* Prepare only: no save hooks, persistence, editor acceptance or timestamp generation.
|
|
551
752
|
* Resume a required decision with {decision:{reviewId, action:'refresh'|'retain'|'cancel'}}.
|
|
@@ -570,6 +771,7 @@ export function createRecordEvaluator({
|
|
|
570
771
|
allowRetain: options.allowRetain === true,
|
|
571
772
|
managedEvents: options.managedEvents === true,
|
|
572
773
|
};
|
|
774
|
+
if (options.signal?.aborted) throw problem('cancelled', 'Record evaluation was cancelled.');
|
|
573
775
|
let fresh, retained, selected;
|
|
574
776
|
if (options.decision) {
|
|
575
777
|
const decision = options.decision;
|
|
@@ -602,7 +804,10 @@ export function createRecordEvaluator({
|
|
|
602
804
|
retained = prior.retained;
|
|
603
805
|
selected = decision.action === 'retain' ? retained : fresh;
|
|
604
806
|
} else {
|
|
605
|
-
|
|
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;
|
|
606
811
|
fresh = await run(source, { ...options, mode: 'refresh' });
|
|
607
812
|
if (attempt !== generation)
|
|
608
813
|
throw problem(
|
|
@@ -619,20 +824,32 @@ export function createRecordEvaluator({
|
|
|
619
824
|
);
|
|
620
825
|
if (intent === 'submit' && mode === 'confirm' && childChanges.length) {
|
|
621
826
|
const id = `${evaluatorId}:${attempt}`;
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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;
|
|
636
853
|
}
|
|
637
854
|
selected = intent === 'draft' ? retained : fresh;
|
|
638
855
|
}
|
|
@@ -644,14 +861,23 @@ export function createRecordEvaluator({
|
|
|
644
861
|
'stale_preparation',
|
|
645
862
|
'A newer submission preparation superseded this attempt.'
|
|
646
863
|
);
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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;
|
|
652
878
|
} catch (error) {
|
|
653
879
|
return failure(error);
|
|
654
880
|
}
|
|
655
881
|
}
|
|
656
|
-
return { evaluate, prepareSubmission };
|
|
882
|
+
return { evaluate, evaluateScope, prepareSubmission };
|
|
657
883
|
}
|
|
@@ -106,6 +106,11 @@ export function validateSubmissionWithContext(snapshot, context, customIssues =
|
|
|
106
106
|
for (const custom of issuesByScope.get(JSON.stringify(scope.instancePath)) || [])
|
|
107
107
|
orderedIssues.push({ ...custom, instancePath: scope.instancePath, kind: 'custom' });
|
|
108
108
|
});
|
|
109
|
+
return summarizeSubmissionFindings(orderedIssues);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Shared ordering/classification for a complete tree or a selected editor scope. */
|
|
113
|
+
export function summarizeSubmissionFindings(orderedIssues) {
|
|
109
114
|
return {
|
|
110
115
|
hasErrors: orderedIssues.length > 0,
|
|
111
116
|
requiredFieldErrors: orderedIssues.filter((issue) => issue.kind === 'required'),
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { createFormEngine } from './engine/form-engine.js';
|
|
2
2
|
export { WarningSystem } from './engine/warning-system.js';
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
createStructuredRecord,
|
|
5
|
+
createStructuredRecordAsync,
|
|
6
|
+
} from './utilities/record-transformer.js';
|
|
4
7
|
export {
|
|
5
8
|
normalizeStructuredRecord,
|
|
6
9
|
buildFormRecordSnapshot,
|
|
@@ -4,14 +4,14 @@ import {
|
|
|
4
4
|
} from '../builtins/builtin-definitions.js';
|
|
5
5
|
import { DEFAULT_SECURITY_CONFIG, SAFE_SECURITY_CONFIG, SECURITY_MODES } from './config.js';
|
|
6
6
|
|
|
7
|
+
const calculationBuiltinNames = new Set(CALCULATION_BUILTIN_DEFINITIONS.map(({ name }) => name));
|
|
8
|
+
const eventBuiltinNames = new Set(EVENT_BUILTIN_DEFINITIONS.map(({ name }) => name));
|
|
9
|
+
|
|
7
10
|
// Helper function to validate builtin function names in expressions
|
|
8
11
|
function validateBuiltinNames(expr, includeEventBuiltins = false) {
|
|
9
12
|
// Read the same immutable catalog used to build the runtime helpers, without
|
|
10
13
|
// importing their implementations (EVAL itself depends on this validator).
|
|
11
|
-
const
|
|
12
|
-
? EVENT_BUILTIN_DEFINITIONS
|
|
13
|
-
: CALCULATION_BUILTIN_DEFINITIONS;
|
|
14
|
-
const allowedBuiltins = new Set(definitions.map(({ name }) => name));
|
|
14
|
+
const allowedBuiltins = includeEventBuiltins ? eventBuiltinNames : calculationBuiltinNames;
|
|
15
15
|
|
|
16
16
|
// Extract function calls from the expression
|
|
17
17
|
// This regex matches function calls like FUNCTIONNAME( allowing for whitespace
|
|
@@ -39,6 +39,7 @@ function toFormEventBuiltinDefinition(definition) {
|
|
|
39
39
|
signature: definition.signature,
|
|
40
40
|
description: definition.description,
|
|
41
41
|
examples: Object.freeze([...definition.examples]),
|
|
42
|
+
...(definition.operation ? { operation: definition.operation } : {}),
|
|
42
43
|
status: getFormEventBuiltinStatus(definition.name),
|
|
43
44
|
});
|
|
44
45
|
}
|
|
@@ -247,6 +248,9 @@ function cloneCatalogDefinition(definition) {
|
|
|
247
248
|
return {
|
|
248
249
|
...definition,
|
|
249
250
|
examples: [...definition.examples],
|
|
251
|
+
...(definition.operation
|
|
252
|
+
? { operation: { ...definition.operation, effects: [...definition.operation.effects] } }
|
|
253
|
+
: {}),
|
|
250
254
|
};
|
|
251
255
|
}
|
|
252
256
|
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
function createContainer(value, seen) {
|
|
2
|
+
if (seen.has(value)) throw new Error('Record data must not contain cyclic references.');
|
|
3
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
4
|
+
throw new Error('Record data must use plain objects, arrays, dates or native media handles.');
|
|
5
|
+
return Array.isArray(value) ? [] : {};
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
/** Clone portable record data without copying immutable binary media into JS memory. */
|
|
2
9
|
export function copyRecordData(value, seen = new Map()) {
|
|
3
10
|
if (typeof value === 'function' || typeof value === 'symbol')
|
|
4
11
|
throw new Error('Record data must not contain functions or symbols.');
|
|
5
12
|
if (value === null || typeof value !== 'object') return value;
|
|
6
|
-
if (seen.has(value)) throw new Error('Record data must not contain cyclic references.');
|
|
7
13
|
if (value instanceof Date) return new Date(value.getTime());
|
|
8
14
|
if (typeof Blob === 'function' && value instanceof Blob) return value;
|
|
9
|
-
|
|
10
|
-
throw new Error('Record data must use plain objects, arrays, dates or native media handles.');
|
|
11
|
-
const result = Array.isArray(value) ? [] : {};
|
|
15
|
+
const result = createContainer(value, seen);
|
|
12
16
|
seen.set(value, result);
|
|
13
17
|
for (const key of Object.keys(value)) {
|
|
14
18
|
Object.defineProperty(result, key, {
|
|
@@ -21,3 +25,45 @@ export function copyRecordData(value, seen = new Map()) {
|
|
|
21
25
|
seen.delete(value);
|
|
22
26
|
return result;
|
|
23
27
|
}
|
|
28
|
+
|
|
29
|
+
/** Copy privately captured results in bounded batches; never use this to capture live inputs. */
|
|
30
|
+
export async function copyRecordDataCooperatively(value, checkpoint) {
|
|
31
|
+
const seen = new Map();
|
|
32
|
+
const frames = [];
|
|
33
|
+
function begin(source) {
|
|
34
|
+
if (
|
|
35
|
+
source === null ||
|
|
36
|
+
typeof source !== 'object' ||
|
|
37
|
+
source instanceof Date ||
|
|
38
|
+
(typeof Blob === 'function' && source instanceof Blob)
|
|
39
|
+
)
|
|
40
|
+
return copyRecordData(source, seen);
|
|
41
|
+
const target = createContainer(source, seen);
|
|
42
|
+
seen.set(source, target);
|
|
43
|
+
frames.push({ source, target, keys: Object.keys(source), index: 0 });
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
const result = begin(value);
|
|
47
|
+
let work = 0;
|
|
48
|
+
while (frames.length) {
|
|
49
|
+
const frame = frames[frames.length - 1];
|
|
50
|
+
if (frame.index === frame.keys.length) {
|
|
51
|
+
seen.delete(frame.source);
|
|
52
|
+
frames.pop();
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const key = frame.keys[frame.index++];
|
|
56
|
+
Object.defineProperty(frame.target, key, {
|
|
57
|
+
value: begin(frame.source[key]),
|
|
58
|
+
enumerable: true,
|
|
59
|
+
writable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
});
|
|
62
|
+
if (++work === 128) {
|
|
63
|
+
work = 0;
|
|
64
|
+
await checkpoint();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await checkpoint();
|
|
68
|
+
return result;
|
|
69
|
+
}
|