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.
- package/README.md +50 -0
- package/package.json +6 -3
- package/src/authoring/form-authoring.js +16 -26
- package/src/builtins/builtin-definitions.js +27 -0
- package/src/builtins/builtin-metadata.js +12 -0
- package/src/builtins/control/eval.js +11 -2
- package/src/builtins/registry.js +4 -0
- package/src/builtins/repeatable/repeatablesum.js +12 -0
- package/src/builtins/repeatable/repeatablevalues.js +12 -0
- package/src/engine/calculation-bindings.js +76 -0
- package/src/engine/calculation.js +114 -38
- package/src/engine/evaluator.js +11 -5
- 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 +41 -30
- package/src/engine/events.js +42 -14
- package/src/engine/expression-compilation.js +39 -0
- package/src/engine/form-engine.js +57 -22
- package/src/engine/managed-events.js +53 -27
- package/src/engine/record-context.js +133 -0
- package/src/engine/record-evaluator.js +883 -0
- package/src/engine/record-freshness.js +19 -0
- package/src/engine/submission-validation.js +130 -0
- package/src/index.js +7 -1
- package/src/schema/schema-validator.js +5 -0
- package/src/security/validation.js +4 -4
- package/src/utilities/calculation-dependencies.js +2 -1
- package/src/utilities/calculation-editor.js +94 -1
- package/src/utilities/form-event-editor.js +4 -0
- package/src/utilities/record-data.js +69 -0
- package/src/utilities/record-transformer.js +110 -39
- package/src/utilities/repeatable-collections.js +170 -0
- 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
|
+
}
|