form0-core 0.6.0 → 0.7.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.
@@ -0,0 +1,657 @@
1
+ import { prepareRecordContext, visitRecordScopes } from './record-context.js';
2
+ import { validateSubmissionWithContext, validateScope } from './submission-validation.js';
3
+ import { getDefaultValue } from './form-engine.js';
4
+ import { evaluateCalculatedField } from './calculation.js';
5
+ import { evaluateVisibility, evaluateRequirement, evaluateReadOnly } from './conditions.js';
6
+ import { createCalculationDiagnostics } from './calculation-diagnostics.js';
7
+ import { createManagedEventDispatcher } from './managed-events.js';
8
+ import { EventManager } from './events.js';
9
+ import { WarningSystem } from './warning-system.js';
10
+ import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
11
+ import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
12
+ import { validateFieldValue } from '../schema/field-value-registry.js';
13
+ import { copyRecordData } from '../utilities/record-data.js';
14
+ import { areStructuredValuesEqual } from '../utilities/value-equality.js';
15
+ import { projectCollectionValue } from '../utilities/repeatable-collections.js';
16
+ import { getFormSchemaRevision } from '../utilities/schema-revision.js';
17
+ import { createRecordFreshness } from './record-freshness.js';
18
+
19
+ const clock = () => globalThis.performance?.now?.() ?? Date.now();
20
+ const issue = (code, message, instancePath = []) => ({ code, message, instancePath });
21
+ const failure = (error) => ({
22
+ status: error.code === 'cancelled' ? 'cancelled' : 'failed',
23
+ eligible: false,
24
+ snapshot: null,
25
+ issues: [
26
+ {
27
+ ...issue(error.code || 'record_evaluation_failed', error.message, error.instancePath || []),
28
+ ...(error.context ? { context: error.context } : {}),
29
+ },
30
+ ],
31
+ diagnostics: [],
32
+ });
33
+ function problem(code, message) {
34
+ return Object.assign(new Error(message), { code });
35
+ }
36
+
37
+ function scheduler(options) {
38
+ let started = clock();
39
+ const check = () => {
40
+ if (options.signal?.aborted) throw problem('cancelled', 'Record evaluation was cancelled.');
41
+ };
42
+ return async () => {
43
+ check();
44
+ if (clock() - started >= (options.budgetMs ?? 8)) {
45
+ await (options.yieldToHost
46
+ ? options.yieldToHost()
47
+ : new Promise((resolve) => setTimeout(resolve, 0)));
48
+ started = clock();
49
+ check();
50
+ }
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @typedef {Object} RecordInstanceSegment
56
+ * @property {string} repeatableKey Preferred schema key.
57
+ * @property {string|number|null} instanceId Preserves zero; null for positional targeting.
58
+ * @property {number} index Captured row position.
59
+ * @property {'id'|'position'} identity
60
+ *
61
+ * @typedef {Object<string, *> & {values?: Object<string, *>, repeatable?: Object<string, RecordSnapshot[]>, id?: string|number, record_id?: string|number}} RecordSnapshot
62
+ * Live values use data_names; collections use preferred schema keys. Unknown metadata is retained.
63
+ *
64
+ * @typedef {Object} RecordFieldChange
65
+ * @property {RecordInstanceSegment[]} instancePath
66
+ * @property {string} fieldName data_name.
67
+ * @property {string} fieldKey
68
+ * @property {*} before
69
+ * @property {*} after
70
+ *
71
+ * @typedef {Object} RecordFreshness
72
+ * @property {1} version
73
+ * @property {'current'|'pending'} status
74
+ * @property {string} schemaRevision
75
+ * @property {Array<{instancePath: RecordInstanceSegment[], fields: Array<{fieldName: string, fieldKey: string}>}>} affected One entry per affected row, in record order.
76
+ *
77
+ * @typedef {Object} RecordEvaluationOptions
78
+ * @property {'accepted'|'refresh'} [mode='accepted'] Closed-child calculation policy.
79
+ * @property {AbortSignal} [signal]
80
+ * @property {number} [budgetMs=8] Cooperative scheduling target, not an expression timeout.
81
+ * @property {() => (void|Promise<void>)} [yieldToHost]
82
+ *
83
+ * @typedef {Object} SubmissionPreparationOptions
84
+ * @property {'submit'|'draft'} [intent='submit']
85
+ * @property {'automatic'|'confirm'} [mode='automatic'] Cannot weaken schema confirmation.
86
+ * @property {boolean} [allowRetain=false] Explicit host permission on both review calls.
87
+ * @property {boolean} [managedEvents=false] Synchronous custom validation only.
88
+ * @property {{reviewId: string, action: 'refresh'|'retain'|'cancel'}} [decision]
89
+ * @property {AbortSignal} [signal]
90
+ * @property {number} [budgetMs=8]
91
+ * @property {() => (void|Promise<void>)} [yieldToHost]
92
+ *
93
+ * @typedef {Object} RecordEvaluationResult
94
+ * @property {'evaluated'|'ready'|'blocked'|'requires_decision'|'failed'|'cancelled'} status
95
+ * @property {RecordSnapshot|null} snapshot Null when no selection can be delivered.
96
+ * @property {boolean} [eligible] Submission eligibility, distinct from diagnostics.
97
+ * @property {RecordSnapshot} [proposal] Review only, not a deliverable selection.
98
+ * @property {RecordFieldChange[]} [changes]
99
+ * @property {Object[]} diagnostics Scoped calculation findings, separate from validation.
100
+ * @property {Object[]} issues Execution/configuration failures.
101
+ * @property {import('./submission-validation.js').SubmissionValidationSummary} [validationSummary]
102
+ * @property {RecordFreshness} [freshness] Host-persisted companion, outside the record.
103
+ * @property {string} [reviewId] Single-use factory-owned review handle.
104
+ * @property {Array<'refresh'|'retain'|'cancel'>} [choices]
105
+ *
106
+ * @typedef {Object} RecordEvaluator
107
+ * @property {(snapshot: RecordSnapshot, options?: RecordEvaluationOptions) => Promise<RecordEvaluationResult>} evaluate
108
+ * @property {(snapshot: RecordSnapshot, options?: SubmissionPreparationOptions) => Promise<RecordEvaluationResult>} prepareSubmission
109
+ */
110
+
111
+ /**
112
+ * Coordinate an immutable runtime record tree using the existing expression/condition engine.
113
+ * Factory configuration is captured; recreate the evaluator when schema/helpers/security change.
114
+ * @param {{schema: Object, helpers?: Object, security?: Object, warningSystem?: Object,
115
+ * diagnostics?: {console?: boolean}, onDiagnostics?: Function}} config
116
+ * @returns {RecordEvaluator}
117
+ */
118
+ export function createRecordEvaluator({
119
+ schema,
120
+ helpers = {},
121
+ security = DEFAULT_SECURITY_CONFIG,
122
+ warningSystem = new WarningSystem(),
123
+ diagnostics: diagnosticOptions = {},
124
+ onDiagnostics = null,
125
+ }) {
126
+ const context = prepareRecordContext(schema, warningSystem);
127
+ const fixedHelpers = { ...helpers };
128
+ const fixedSecurity = {
129
+ ...security,
130
+ ...(Array.isArray(security.allowedGlobals)
131
+ ? { allowedGlobals: [...security.allowedGlobals] }
132
+ : {}),
133
+ ...(Array.isArray(security.blockedPatterns)
134
+ ? {
135
+ blockedPatterns: security.blockedPatterns.map((pattern) =>
136
+ pattern instanceof RegExp ? new RegExp(pattern.source, pattern.flags) : pattern
137
+ ),
138
+ }
139
+ : {}),
140
+ };
141
+ const schemaRevision = getFormSchemaRevision(context.sourceSchema);
142
+ const diagnostics = createCalculationDiagnostics(
143
+ diagnosticOptions,
144
+ null,
145
+ context.resolver,
146
+ warningSystem
147
+ );
148
+ const compileCache = new Map();
149
+ const calculationPreparationCache = new Map();
150
+ const eventSourceCache = new Map();
151
+ const eventReferenceCache = new Map();
152
+ const evaluatorId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
153
+ let generation = 0;
154
+ let review = null;
155
+
156
+ async function run(input, options = {}) {
157
+ const mode = options.mode ?? 'accepted';
158
+ if (!['accepted', 'refresh'].includes(mode))
159
+ throw problem('invalid_request', 'Evaluation mode must be accepted or refresh.');
160
+ input = copyRecordData(input);
161
+ const snapshot = copyRecordData(input);
162
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
163
+ throw problem('invalid_record_tree', 'A runtime record snapshot is required.');
164
+ const pause = scheduler(options);
165
+ await pause();
166
+ const scopes = [];
167
+ const byDefinition = new Map();
168
+ visitRecordScopes(snapshot, context, (scope) => {
169
+ const { record, template } = scope;
170
+ if (
171
+ record.values !== undefined &&
172
+ (!record.values || typeof record.values !== 'object' || Array.isArray(record.values))
173
+ )
174
+ throw problem('invalid_record_tree', 'Record values must be an object.');
175
+ if (
176
+ record.repeatable !== undefined &&
177
+ (!record.repeatable ||
178
+ typeof record.repeatable !== 'object' ||
179
+ Array.isArray(record.repeatable))
180
+ )
181
+ throw problem('invalid_record_tree', 'Record repeatable collections must be an object.');
182
+ record.values ??= {};
183
+ record.errors = {};
184
+ record.visible = {};
185
+ record.required = {};
186
+ record.read_only = {};
187
+ for (const field of template.fields)
188
+ if (!Object.hasOwn(record.values, field.data_name))
189
+ record.values[field.data_name] = copyRecordData(getDefaultValue(field));
190
+ scope.uid = scopes.length;
191
+ scope.lineage = [...scope.ancestors, scope];
192
+ scope.nodes = new Map();
193
+ scopes.push(scope);
194
+ const key = JSON.stringify(template.path);
195
+ if (!byDefinition.has(key)) byDefinition.set(key, []);
196
+ byDefinition.get(key).push(scope);
197
+ });
198
+ const nodes = [];
199
+ for (const scope of scopes) {
200
+ if (scope.instancePath.length && mode === 'accepted') continue;
201
+ for (const field of scope.template.fields)
202
+ if (field.type === 'CalculatedField' && field.calculate) {
203
+ const definition = context.calculations.get(field.data_name);
204
+ const node = {
205
+ scope,
206
+ field,
207
+ definition,
208
+ dependencies: new Set(),
209
+ state: 0,
210
+ blocked: false,
211
+ };
212
+ scope.nodes.set(field.data_name, node);
213
+ nodes.push(node);
214
+ }
215
+ }
216
+ const ownerScope = (scope, name) => {
217
+ const info = context.resolver.fieldOwnership.get(name);
218
+ return info &&
219
+ info.parentPath.length <= scope.template.path.length &&
220
+ info.parentPath.every((part, index) => scope.template.path[index] === part)
221
+ ? scope.lineage[info.parentPath.length]
222
+ : null;
223
+ };
224
+ const queryCache = new Map();
225
+ function rows(scope, section) {
226
+ const info = context.resolver.repeatableSectionTree.get(section);
227
+ const depth = Math.min(scope.template.path.length, info.parentPath.length);
228
+ const anchor = scope.lineage[depth];
229
+ const key = `${anchor.uid}:${section}`;
230
+ if (!queryCache.has(key))
231
+ queryCache.set(
232
+ key,
233
+ (byDefinition.get(JSON.stringify(info.currentPath)) || []).filter(
234
+ (candidate) => candidate.lineage[depth] === anchor
235
+ )
236
+ );
237
+ return queryCache.get(key);
238
+ }
239
+ for (const node of nodes) {
240
+ for (const name of node.definition.directReferences) {
241
+ const dependency = ownerScope(node.scope, name)?.nodes.get(name);
242
+ if (dependency) node.dependencies.add(dependency);
243
+ }
244
+ if (!node.definition.collection.issues.length)
245
+ for (const call of node.definition.collection.calls)
246
+ for (const row of rows(node.scope, call.section)) {
247
+ const dependency = row.nodes.get(call.target);
248
+ if (dependency) node.dependencies.add(dependency);
249
+ }
250
+ }
251
+ const ordered = [];
252
+ const visiting = [];
253
+ const reported = [];
254
+ let fatal = null;
255
+ function cycle(node) {
256
+ const group = visiting.slice(visiting.indexOf(node));
257
+ if (
258
+ new Set(group.map((entry) => entry.scope.uid)).size > 1 ||
259
+ group.some((entry) => entry.definition.collection.calls.length)
260
+ ) {
261
+ fatal = problem(
262
+ 'record_dependency_cycle',
263
+ `Record-spanning calculation dependency cycle detected: ${group.map((entry) => entry.field.data_name).join(' -> ')}.`
264
+ );
265
+ fatal.instancePath = node.scope.instancePath;
266
+ fatal.context = {
267
+ cycle: group.map((entry) => ({
268
+ fieldName: entry.field.data_name,
269
+ instancePath: entry.scope.instancePath,
270
+ })),
271
+ };
272
+ throw fatal;
273
+ }
274
+ for (const entry of group) {
275
+ entry.blocked = true;
276
+ entry.scope.record.values[entry.field.data_name] = null;
277
+ reported.push({
278
+ code: 'cyclic_dependencies',
279
+ severity: 'error',
280
+ phase: 'dependency',
281
+ fieldName: entry.field.data_name,
282
+ message: 'CalculatedField dependency cycle detected; involved fields evaluate to null.',
283
+ suggestion: 'Remove the calculation cycle.',
284
+ context: {},
285
+ instancePath: entry.scope.instancePath,
286
+ });
287
+ }
288
+ }
289
+ function order(node) {
290
+ if (node.state === 2) return;
291
+ if (node.state === 1) {
292
+ cycle(node);
293
+ return;
294
+ }
295
+ node.state = 1;
296
+ visiting.push(node);
297
+ for (const dependency of node.dependencies) order(dependency);
298
+ visiting.pop();
299
+ node.state = 2;
300
+ ordered.push(node);
301
+ }
302
+ for (const node of nodes) {
303
+ order(node);
304
+ await pause();
305
+ }
306
+ for (const node of nodes) node.state = 0;
307
+ const projectionCache = new Map();
308
+ const readable = (scope) =>
309
+ Object.assign({}, ...scope.lineage.map((entry) => entry.record.values));
310
+ function execute(node) {
311
+ if (node.blocked || node.state === 2) return;
312
+ if (node.state === 1) {
313
+ cycle(node);
314
+ return;
315
+ }
316
+ node.state = 1;
317
+ visiting.push(node);
318
+ for (const dependency of node.dependencies) execute(dependency);
319
+ const frame = diagnostics.begin();
320
+ try {
321
+ const analysis = node.definition.collection;
322
+ if (analysis.issues.length) {
323
+ node.scope.record.values[node.field.data_name] = null;
324
+ for (const item of analysis.issues)
325
+ diagnostics.record(node.field.data_name, { ...item, phase: 'validation' });
326
+ } else {
327
+ function collect(section, target) {
328
+ const info = context.resolver.repeatableSectionTree.get(section);
329
+ const anchor =
330
+ node.scope.lineage[Math.min(node.scope.template.path.length, info.parentPath.length)];
331
+ const key = `${anchor.uid}:${section}:${target}`;
332
+ if (!projectionCache.has(key)) {
333
+ const field = context.resolver.fieldOwnership.get(target).field;
334
+ projectionCache.set(
335
+ key,
336
+ rows(node.scope, section).map((row) => {
337
+ const dependency = row.nodes.get(target);
338
+ if (dependency) execute(dependency);
339
+ return copyRecordData(projectCollectionValue(field, row.record.values[target]));
340
+ })
341
+ );
342
+ }
343
+ return copyRecordData(projectionCache.get(key));
344
+ }
345
+ const runtimeHelpers = {
346
+ ...calculationBuiltins,
347
+ ...fixedHelpers,
348
+ REPEATABLEVALUES: collect,
349
+ REPEATABLESUM: (section, target) =>
350
+ collect(section, target).reduce((sum, value) => {
351
+ if (value == null) return sum;
352
+ if (typeof value !== 'number' || !Number.isFinite(value))
353
+ throw new Error(
354
+ 'REPEATABLESUM requires finite numbers; numeric strings are not converted.'
355
+ );
356
+ const next = sum + value;
357
+ if (!Number.isFinite(next)) throw new Error('REPEATABLESUM result is not finite.');
358
+ return next;
359
+ }, 0),
360
+ };
361
+ const calculationValues = readable(node.scope);
362
+ for (const name of node.definition.directReferences) {
363
+ const owner = ownerScope(node.scope, name);
364
+ if (
365
+ owner &&
366
+ owner !== node.scope &&
367
+ calculationValues[name] &&
368
+ typeof calculationValues[name] === 'object'
369
+ )
370
+ calculationValues[name] = copyRecordData(calculationValues[name]);
371
+ }
372
+ evaluateCalculatedField(
373
+ node.field,
374
+ context.schema.form,
375
+ calculationValues,
376
+ runtimeHelpers,
377
+ fixedSecurity,
378
+ context.resolver,
379
+ warningSystem,
380
+ null,
381
+ diagnostics,
382
+ {
383
+ expression: analysis.expression,
384
+ compileCache,
385
+ preparationCache: calculationPreparationCache,
386
+ writeValue: (value) => {
387
+ node.scope.record.values[node.field.data_name] = value;
388
+ },
389
+ resolveReference: (reference) => {
390
+ const name = reference.slice(1);
391
+ const owner = ownerScope(node.scope, name);
392
+ if (!owner) return undefined;
393
+ const dependency = owner.nodes.get(name);
394
+ if (dependency) execute(dependency);
395
+ return copyRecordData(owner.record.values[name]);
396
+ },
397
+ }
398
+ );
399
+ }
400
+ } finally {
401
+ diagnostics.complete(frame);
402
+ reported.push(
403
+ ...diagnostics
404
+ .getSnapshot()
405
+ .map((item) => ({ ...item, instancePath: node.scope.instancePath }))
406
+ );
407
+ visiting.pop();
408
+ node.state = 2;
409
+ }
410
+ if (node.blocked) node.scope.record.values[node.field.data_name] = null;
411
+ if (fatal) throw fatal;
412
+ }
413
+ for (const node of ordered) {
414
+ execute(node);
415
+ await pause();
416
+ }
417
+ for (const scope of scopes) {
418
+ const values = readable(scope);
419
+ evaluateRequirement(
420
+ scope.template.form,
421
+ values,
422
+ scope.record.required,
423
+ scope.template.conditions
424
+ );
425
+ evaluateVisibility(
426
+ scope.template.form,
427
+ values,
428
+ scope.record.visible,
429
+ scope.template.conditions
430
+ );
431
+ evaluateReadOnly(
432
+ scope.template.form,
433
+ values,
434
+ scope.record.read_only,
435
+ scope.template.conditions
436
+ );
437
+ for (const field of scope.template.fields) {
438
+ const error = validateFieldValue(field, scope.record.values[field.data_name]);
439
+ if (error) scope.record.errors[field.data_name] = error;
440
+ }
441
+ await pause();
442
+ }
443
+ const changes = [];
444
+ visitRecordScopes(snapshot, context, (scope) => {
445
+ // Locate the original row by captured indices, retaining ID-zero identities in reports.
446
+ let original = input;
447
+ for (const segment of scope.instancePath)
448
+ original = original.repeatable?.[segment.repeatableKey]?.[segment.index];
449
+ for (const field of scope.template.fields)
450
+ if (field.type === 'CalculatedField') {
451
+ const before = original?.values?.[field.data_name] ?? null;
452
+ const after = scope.record.values[field.data_name] ?? null;
453
+ if (!areStructuredValuesEqual(before, after))
454
+ changes.push({
455
+ instancePath: scope.instancePath,
456
+ fieldName: field.data_name,
457
+ fieldKey: field.key || field.data_name,
458
+ before: copyRecordData(before),
459
+ after: copyRecordData(after),
460
+ });
461
+ }
462
+ });
463
+ return { status: 'evaluated', snapshot, changes, diagnostics: reported, issues: [] };
464
+ }
465
+
466
+ async function validate(result, options) {
467
+ const custom = [];
468
+ const executionIssues = [];
469
+ const pause = scheduler(options);
470
+ await pause();
471
+ if (options.managedEvents === true) {
472
+ const scopes = [];
473
+ visitRecordScopes(result.snapshot, context, (scope) => scopes.push(scope));
474
+ for (const scope of scopes) {
475
+ if (!scope.participating) continue;
476
+ const values = Object.assign(
477
+ {},
478
+ ...scope.ancestors.map((entry) => entry.record.values),
479
+ scope.record.values
480
+ );
481
+ const eventManager = new EventManager(
482
+ context.schema.form,
483
+ context.resolver,
484
+ warningSystem,
485
+ {
486
+ sourceCache: eventSourceCache,
487
+ compilationCache: compileCache,
488
+ referenceSourceCache: eventReferenceCache,
489
+ }
490
+ );
491
+ eventManager.securityConfig = fixedSecurity;
492
+ const initialContext = { ...eventBuiltins, ...fixedHelpers };
493
+ for (const [name, info] of context.resolver.fieldOwnership)
494
+ initialContext[`$${name}`] = copyRecordData(getDefaultValue(info.field));
495
+ for (const [name, value] of Object.entries(values))
496
+ initialContext[`$${name}`] = copyRecordData(value);
497
+ if (context.schema.form.events?.code)
498
+ eventManager.initializeEventCode(context.schema.form.events.code, initialContext);
499
+ eventManager.eventContext = initialContext;
500
+ const dispatch = createManagedEventDispatcher(eventManager, {
501
+ instancePath: scope.instancePath,
502
+ });
503
+ const row = scope.instancePath.length > 0;
504
+ const definition = row
505
+ ? context.repeatableByPath.get(JSON.stringify(scope.template.path))
506
+ : null;
507
+ const detail = dispatch(
508
+ row ? 'validate-repeatable' : 'validate-record',
509
+ definition?.field.data_name,
510
+ { isDraft: options.intent === 'draft', isValid: validateScope(scope).length === 0 }
511
+ );
512
+ custom.push(...detail.validationIssues);
513
+ executionIssues.push(...detail.issues);
514
+ await pause();
515
+ }
516
+ }
517
+ const validationSummary = validateSubmissionWithContext(result.snapshot, context, custom);
518
+ await pause();
519
+ return {
520
+ ...result,
521
+ status: executionIssues.length
522
+ ? 'failed'
523
+ : options.intent !== 'draft' && validationSummary.hasErrors
524
+ ? 'blocked'
525
+ : 'ready',
526
+ eligible:
527
+ !executionIssues.length && (options.intent === 'draft' || !validationSummary.hasErrors),
528
+ validationSummary,
529
+ issues: executionIssues,
530
+ };
531
+ }
532
+
533
+ async function evaluate(snapshot, options = {}) {
534
+ try {
535
+ const result = await run(snapshot, options);
536
+ if (typeof onDiagnostics === 'function') {
537
+ try {
538
+ Promise.resolve(onDiagnostics(copyRecordData(result.diagnostics))).catch(() => {});
539
+ } catch {
540
+ /* Observational. */
541
+ }
542
+ }
543
+ return result;
544
+ } catch (error) {
545
+ return failure(error);
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Prepare only: no save hooks, persistence, editor acceptance or timestamp generation.
551
+ * Resume a required decision with {decision:{reviewId, action:'refresh'|'retain'|'cancel'}}.
552
+ */
553
+ async function prepareSubmission(snapshot, options = {}) {
554
+ const prior = review;
555
+ const attempt = ++generation;
556
+ review = null;
557
+ try {
558
+ const intent = options.intent ?? 'submit';
559
+ if (!['submit', 'draft'].includes(intent))
560
+ throw problem('invalid_request', 'Submission intent must be submit or draft.');
561
+ if (options.mode !== undefined && !['automatic', 'confirm'].includes(options.mode))
562
+ throw problem('invalid_request', 'Submission mode must be automatic or confirm.');
563
+ const mode =
564
+ context.schema.form.repeatable_recalculation === 'confirm' || options.mode === 'confirm'
565
+ ? 'confirm'
566
+ : 'automatic';
567
+ const settings = {
568
+ intent,
569
+ mode,
570
+ allowRetain: options.allowRetain === true,
571
+ managedEvents: options.managedEvents === true,
572
+ };
573
+ let fresh, retained, selected;
574
+ if (options.decision) {
575
+ const decision = options.decision;
576
+ if (
577
+ !prior ||
578
+ prior.id !== decision.reviewId ||
579
+ !areStructuredValuesEqual(snapshot, prior.source) ||
580
+ !areStructuredValuesEqual(settings, prior.settings)
581
+ )
582
+ throw problem(
583
+ 'stale_review',
584
+ 'Submission review is obsolete; prepare the current record again.'
585
+ );
586
+ if (decision.action === 'cancel')
587
+ return {
588
+ status: 'cancelled',
589
+ eligible: false,
590
+ snapshot: null,
591
+ issues: [],
592
+ diagnostics: [],
593
+ };
594
+ if (!['refresh', 'retain'].includes(decision.action))
595
+ throw problem('invalid_decision', 'Decision must be refresh, retain or cancel.');
596
+ if (decision.action === 'retain' && !settings.allowRetain)
597
+ throw problem(
598
+ 'retention_not_allowed',
599
+ 'The host has not permitted retaining child calculations.'
600
+ );
601
+ fresh = prior.fresh;
602
+ retained = prior.retained;
603
+ selected = decision.action === 'retain' ? retained : fresh;
604
+ } else {
605
+ const source = copyRecordData(snapshot);
606
+ fresh = await run(source, { ...options, mode: 'refresh' });
607
+ if (attempt !== generation)
608
+ throw problem(
609
+ 'stale_preparation',
610
+ 'A newer submission preparation superseded this attempt.'
611
+ );
612
+ const childChanges = fresh.changes.filter((change) => change.instancePath.length);
613
+ if (intent === 'draft' || (mode === 'confirm' && childChanges.length))
614
+ retained = await run(source, { ...options, mode: 'accepted' });
615
+ if (attempt !== generation)
616
+ throw problem(
617
+ 'stale_preparation',
618
+ 'A newer submission preparation superseded this attempt.'
619
+ );
620
+ if (intent === 'submit' && mode === 'confirm' && childChanges.length) {
621
+ const id = `${evaluatorId}:${attempt}`;
622
+ review = { id, source, settings, fresh, retained };
623
+ return {
624
+ status: 'requires_decision',
625
+ eligible: false,
626
+ snapshot: null,
627
+ proposal: copyRecordData(fresh.snapshot),
628
+ reviewId: id,
629
+ changes: copyRecordData(fresh.changes),
630
+ validationSummary: validateSubmissionWithContext(fresh.snapshot, context),
631
+ diagnostics: copyRecordData(fresh.diagnostics),
632
+ issues: [],
633
+ freshness: createRecordFreshness(schemaRevision, childChanges),
634
+ choices: settings.allowRetain ? ['refresh', 'retain', 'cancel'] : ['refresh', 'cancel'],
635
+ };
636
+ }
637
+ selected = intent === 'draft' ? retained : fresh;
638
+ }
639
+ const childChanges = fresh.changes.filter((change) => change.instancePath.length);
640
+ const pending = selected === retained && childChanges.length > 0;
641
+ const prepared = await validate(selected, { ...options, intent });
642
+ if (attempt !== generation)
643
+ throw problem(
644
+ 'stale_preparation',
645
+ 'A newer submission preparation superseded this attempt.'
646
+ );
647
+ return copyRecordData({
648
+ ...prepared,
649
+ changes: fresh.changes,
650
+ freshness: createRecordFreshness(schemaRevision, pending ? childChanges : []),
651
+ });
652
+ } catch (error) {
653
+ return failure(error);
654
+ }
655
+ }
656
+ return { evaluate, prepareSubmission };
657
+ }
@@ -0,0 +1,19 @@
1
+ import { copyRecordData } from '../utilities/record-data.js';
2
+
3
+ /** Group pending field identities by complete row path; retain no values or proposal history. */
4
+ export function createRecordFreshness(schemaRevision, changes = []) {
5
+ const affected = new Map();
6
+ for (const { instancePath, fieldName, fieldKey } of changes) {
7
+ if (!instancePath.length) continue;
8
+ const key = JSON.stringify(instancePath);
9
+ if (!affected.has(key))
10
+ affected.set(key, { instancePath: copyRecordData(instancePath), fields: [] });
11
+ affected.get(key).fields.push({ fieldName, fieldKey });
12
+ }
13
+ return {
14
+ version: 1,
15
+ status: affected.size ? 'pending' : 'current',
16
+ schemaRevision,
17
+ affected: [...affected.values()],
18
+ };
19
+ }