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.
- package/README.md +33 -0
- package/package.json +5 -3
- package/src/authoring/form-authoring.js +16 -26
- package/src/builtins/builtin-definitions.js +22 -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.js +100 -37
- package/src/engine/evaluator.js +13 -5
- package/src/engine/event-source.js +37 -29
- package/src/engine/events.js +6 -2
- package/src/engine/form-engine.js +1 -1
- package/src/engine/managed-events.js +27 -9
- package/src/engine/record-context.js +126 -0
- package/src/engine/record-evaluator.js +657 -0
- package/src/engine/record-freshness.js +19 -0
- package/src/engine/submission-validation.js +125 -0
- package/src/index.js +3 -0
- package/src/schema/schema-validator.js +5 -0
- package/src/utilities/calculation-dependencies.js +2 -1
- package/src/utilities/calculation-editor.js +94 -1
- package/src/utilities/record-data.js +23 -0
- package/src/utilities/repeatable-collections.js +170 -0
- package/src/utilities/schema-revision.js +30 -0
|
@@ -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
|
+
}
|