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,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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} SubmissionFinding
|
|
3
|
+
* @property {'required'|'error'|'custom'} kind
|
|
4
|
+
* @property {string} message
|
|
5
|
+
* @property {Object[]} instancePath Complete row ancestry; [] for root.
|
|
6
|
+
* @property {string} [fieldName] Absent for scope-level custom findings.
|
|
7
|
+
* @property {string} [fieldKey]
|
|
8
|
+
*
|
|
9
|
+
* @typedef {Object} SubmissionValidationSummary
|
|
10
|
+
* @property {boolean} hasErrors
|
|
11
|
+
* @property {SubmissionFinding[]} requiredFieldErrors
|
|
12
|
+
* @property {SubmissionFinding[]} generalErrors
|
|
13
|
+
* @property {SubmissionFinding[]} scopeIssues
|
|
14
|
+
* @property {SubmissionFinding[]} orderedIssues
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { prepareRecordContext, visitRecordScopes } from './record-context.js';
|
|
18
|
+
|
|
19
|
+
const hasText = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
20
|
+
const hasChoice = (entries) =>
|
|
21
|
+
Array.isArray(entries) &&
|
|
22
|
+
entries.some(
|
|
23
|
+
(entry) => entry && ((entry.value != null && entry.value !== '') || hasText(entry.label))
|
|
24
|
+
);
|
|
25
|
+
const hasOther = (entries) =>
|
|
26
|
+
Array.isArray(entries) &&
|
|
27
|
+
entries.some((entry) =>
|
|
28
|
+
typeof entry === 'string'
|
|
29
|
+
? hasText(entry)
|
|
30
|
+
: entry && (hasText(entry.label) || (entry.value != null && entry.value !== ''))
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/** Type-aware input emptiness shared with submission participation; zero/false are values. */
|
|
34
|
+
export function isFieldValueEmpty(field, value) {
|
|
35
|
+
if (!field || value == null) return true;
|
|
36
|
+
switch (field.type) {
|
|
37
|
+
case 'TextField':
|
|
38
|
+
case 'CalculatedField':
|
|
39
|
+
case 'DateField':
|
|
40
|
+
case 'TimeField':
|
|
41
|
+
case 'TitleField':
|
|
42
|
+
case 'StatusField':
|
|
43
|
+
return typeof value === 'string' && !value.trim();
|
|
44
|
+
case 'NumericField':
|
|
45
|
+
return typeof value === 'number' ? false : typeof value === 'string' ? !value.trim() : true;
|
|
46
|
+
case 'SingleChoiceField':
|
|
47
|
+
case 'BooleanField':
|
|
48
|
+
return !hasChoice(value.choice) && !hasOther(value.other);
|
|
49
|
+
case 'MultiChoiceField':
|
|
50
|
+
return !hasChoice(value.choices) && !hasOther(value.other);
|
|
51
|
+
case 'PhotoField':
|
|
52
|
+
case 'VideoField':
|
|
53
|
+
return !Array.isArray(value) || value.length === 0;
|
|
54
|
+
case 'SignatureField':
|
|
55
|
+
return typeof value === 'string'
|
|
56
|
+
? !value.trim()
|
|
57
|
+
: !(hasText(value?.data) || hasText(value?.url));
|
|
58
|
+
default:
|
|
59
|
+
return Array.isArray(value)
|
|
60
|
+
? value.length === 0
|
|
61
|
+
: typeof value === 'object'
|
|
62
|
+
? Object.keys(value).length === 0
|
|
63
|
+
: false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateScope(scope) {
|
|
68
|
+
const findings = [];
|
|
69
|
+
if (!scope.participating) return findings;
|
|
70
|
+
const { record, template, instancePath } = scope;
|
|
71
|
+
for (const field of template.fields) {
|
|
72
|
+
if (['Section', 'BuildingPlanSection', 'RepeatableSection', 'LabelField'].includes(field.type))
|
|
73
|
+
continue;
|
|
74
|
+
if (
|
|
75
|
+
record.visible?.[field.data_name] === false ||
|
|
76
|
+
!(template.chains.get(field.data_name) || []).every(
|
|
77
|
+
(name) => record.visible?.[name] !== false
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
continue;
|
|
81
|
+
const message = record.errors?.[field.data_name];
|
|
82
|
+
const required = record.required?.[field.data_name] ?? field.required;
|
|
83
|
+
const base = {
|
|
84
|
+
fieldName: field.data_name,
|
|
85
|
+
fieldKey: field.key || field.data_name,
|
|
86
|
+
instancePath,
|
|
87
|
+
};
|
|
88
|
+
if (message) findings.push({ ...base, kind: 'error', message });
|
|
89
|
+
else if (required && isFieldValueEmpty(field, record.values?.[field.data_name]))
|
|
90
|
+
findings.push({ ...base, kind: 'required', message: 'This field is required' });
|
|
91
|
+
}
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateSubmissionWithContext(snapshot, context, customIssues = []) {
|
|
96
|
+
const orderedIssues = [];
|
|
97
|
+
const issuesByScope = new Map();
|
|
98
|
+
for (const custom of customIssues) {
|
|
99
|
+
const key = JSON.stringify(custom.instancePath || []);
|
|
100
|
+
if (!issuesByScope.has(key)) issuesByScope.set(key, []);
|
|
101
|
+
issuesByScope.get(key).push(custom);
|
|
102
|
+
}
|
|
103
|
+
visitRecordScopes(snapshot, context, (scope) => {
|
|
104
|
+
orderedIssues.push(...validateScope(scope));
|
|
105
|
+
if (scope.participating)
|
|
106
|
+
for (const custom of issuesByScope.get(JSON.stringify(scope.instancePath)) || [])
|
|
107
|
+
orderedIssues.push({ ...custom, instancePath: scope.instancePath, kind: 'custom' });
|
|
108
|
+
});
|
|
109
|
+
return summarizeSubmissionFindings(orderedIssues);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Shared ordering/classification for a complete tree or a selected editor scope. */
|
|
113
|
+
export function summarizeSubmissionFindings(orderedIssues) {
|
|
114
|
+
return {
|
|
115
|
+
hasErrors: orderedIssues.length > 0,
|
|
116
|
+
requiredFieldErrors: orderedIssues.filter((issue) => issue.kind === 'required'),
|
|
117
|
+
generalErrors: orderedIssues.filter((issue) => issue.kind === 'error'),
|
|
118
|
+
scopeIssues: orderedIssues.filter((issue) => issue.kind === 'custom'),
|
|
119
|
+
orderedIssues,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate an already evaluated runtime record tree; never evaluates or mutates intrinsic errors.
|
|
125
|
+
* @param {{schema: Object, snapshot: Object, validationIssues?: Object[]}} options
|
|
126
|
+
* @returns {SubmissionValidationSummary} Ordered field and scope findings; no freshness assertion.
|
|
127
|
+
*/
|
|
128
|
+
export function validateSubmission({ schema, snapshot, validationIssues = [] }) {
|
|
129
|
+
return validateSubmissionWithContext(snapshot, prepareRecordContext(schema), validationIssues);
|
|
130
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { createFormEngine } from './engine/form-engine.js';
|
|
2
2
|
export { WarningSystem } from './engine/warning-system.js';
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
createStructuredRecord,
|
|
5
|
+
createStructuredRecordAsync,
|
|
6
|
+
} from './utilities/record-transformer.js';
|
|
4
7
|
export {
|
|
5
8
|
normalizeStructuredRecord,
|
|
6
9
|
buildFormRecordSnapshot,
|
|
@@ -86,3 +89,6 @@ export {
|
|
|
86
89
|
applyFormMutationBatch,
|
|
87
90
|
} from './authoring/form-authoring.js';
|
|
88
91
|
export { INVALID } from './builtins/event/control/invalid.js';
|
|
92
|
+
|
|
93
|
+
export { createRecordEvaluator } from './engine/record-evaluator.js';
|
|
94
|
+
export { validateSubmission, isFieldValueEmpty } from './engine/submission-validation.js';
|
|
@@ -85,6 +85,11 @@ export function validateSchemaWithContext(form, contextResolver) {
|
|
|
85
85
|
const duplicateDataNames = new Set();
|
|
86
86
|
const duplicateKeys = new Set();
|
|
87
87
|
const errors = [];
|
|
88
|
+
if (
|
|
89
|
+
form.repeatable_recalculation !== undefined &&
|
|
90
|
+
!['automatic', 'confirm'].includes(form.repeatable_recalculation)
|
|
91
|
+
)
|
|
92
|
+
errors.push('form.repeatable_recalculation must be automatic or confirm');
|
|
88
93
|
|
|
89
94
|
const formAIValidation = validateFormAIMetadata(form);
|
|
90
95
|
if (!formAIValidation.isValid) {
|
|
@@ -4,14 +4,14 @@ import {
|
|
|
4
4
|
} from '../builtins/builtin-definitions.js';
|
|
5
5
|
import { DEFAULT_SECURITY_CONFIG, SAFE_SECURITY_CONFIG, SECURITY_MODES } from './config.js';
|
|
6
6
|
|
|
7
|
+
const calculationBuiltinNames = new Set(CALCULATION_BUILTIN_DEFINITIONS.map(({ name }) => name));
|
|
8
|
+
const eventBuiltinNames = new Set(EVENT_BUILTIN_DEFINITIONS.map(({ name }) => name));
|
|
9
|
+
|
|
7
10
|
// Helper function to validate builtin function names in expressions
|
|
8
11
|
function validateBuiltinNames(expr, includeEventBuiltins = false) {
|
|
9
12
|
// Read the same immutable catalog used to build the runtime helpers, without
|
|
10
13
|
// importing their implementations (EVAL itself depends on this validator).
|
|
11
|
-
const
|
|
12
|
-
? EVENT_BUILTIN_DEFINITIONS
|
|
13
|
-
: CALCULATION_BUILTIN_DEFINITIONS;
|
|
14
|
-
const allowedBuiltins = new Set(definitions.map(({ name }) => name));
|
|
14
|
+
const allowedBuiltins = includeEventBuiltins ? eventBuiltinNames : calculationBuiltinNames;
|
|
15
15
|
|
|
16
16
|
// Extract function calls from the expression
|
|
17
17
|
// This regex matches function calls like FUNCTIONNAME( allowing for whitespace
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { analyzeRepeatableCollections } from './repeatable-collections.js';
|
|
1
2
|
import { ContextResolver } from '../engine/context-resolver.js';
|
|
2
3
|
import { flattenFields } from './field-helpers.js';
|
|
3
4
|
|
|
@@ -378,7 +379,7 @@ export function removeEvalContents(code) {
|
|
|
378
379
|
}
|
|
379
380
|
|
|
380
381
|
export function extractStaticFieldReferenceMatches(code) {
|
|
381
|
-
const cleanedCode = removeEvalContents(code);
|
|
382
|
+
const cleanedCode = removeEvalContents(analyzeRepeatableCollections(code).scalarSource);
|
|
382
383
|
const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
383
384
|
const matches = [];
|
|
384
385
|
let match;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { analyzeRepeatableCollections } from './repeatable-collections.js';
|
|
2
|
+
import { createRecordEvaluator } from '../engine/record-evaluator.js';
|
|
1
3
|
import { parse } from 'acorn';
|
|
2
4
|
import { BUILTIN_CONTEXTS } from '../builtins/builtin-metadata.js';
|
|
3
5
|
import {
|
|
@@ -363,6 +365,7 @@ function createSimulationErrorResult(runtimeError, warnings = []) {
|
|
|
363
365
|
* @property {string | null} description
|
|
364
366
|
* @property {string | null} displayStyle
|
|
365
367
|
* @property {string[]} parentPath
|
|
368
|
+
* @property {boolean} [collectionOnly] Section references are valid only in collection calls.
|
|
366
369
|
* @property {{
|
|
367
370
|
* level: 'accessible' | 'restricted',
|
|
368
371
|
* code: 'main_form' | 'same_repeatable_section' | 'ancestor_repeatable_context' | 'different_repeatable_section',
|
|
@@ -396,18 +399,20 @@ export function getCalculationBuiltinCatalog() {
|
|
|
396
399
|
* @param {Object} options.schema
|
|
397
400
|
* @param {string} options.fieldDataName
|
|
398
401
|
* @param {boolean} [options.includeRestricted=false]
|
|
402
|
+
* @param {boolean} [options.includeCollections=false] Include collection-only section references.
|
|
399
403
|
* @returns {CalculationReferenceDefinition[]}
|
|
400
404
|
*/
|
|
401
405
|
export function getCalculationReferenceCatalog({
|
|
402
406
|
schema,
|
|
403
407
|
fieldDataName,
|
|
404
408
|
includeRestricted = false,
|
|
409
|
+
includeCollections = false,
|
|
405
410
|
}) {
|
|
406
411
|
const form = normalizeCalculationFormSchema(schema);
|
|
407
412
|
const resolver = new ContextResolver(form);
|
|
408
413
|
const executionContext = buildCalculationExecutionContext(form, fieldDataName, resolver);
|
|
409
414
|
|
|
410
|
-
|
|
415
|
+
const scalarReferences = flattenFields(form.elements)
|
|
411
416
|
.filter((field) => field && typeof field === 'object')
|
|
412
417
|
.filter((field) => field.data_name && field.data_name !== fieldDataName)
|
|
413
418
|
.filter(
|
|
@@ -419,6 +424,33 @@ export function getCalculationReferenceCatalog({
|
|
|
419
424
|
.map((field) => toCalculationReferenceDefinition(field, resolver, executionContext))
|
|
420
425
|
.filter((reference) => reference !== null)
|
|
421
426
|
.filter((reference) => includeRestricted || reference.access.level === 'accessible');
|
|
427
|
+
if (!includeCollections) return scalarReferences;
|
|
428
|
+
const collections = [...resolver.repeatableSectionTree.values()]
|
|
429
|
+
.map((info) => {
|
|
430
|
+
const accessible =
|
|
431
|
+
ContextResolver.isPrefix(executionContext.parentPath, info.parentPath) ||
|
|
432
|
+
ContextResolver.isPrefix(info.parentPath, executionContext.parentPath);
|
|
433
|
+
return {
|
|
434
|
+
key: info.preferredKey,
|
|
435
|
+
reference: `$${info.field.data_name}`,
|
|
436
|
+
dataName: info.field.data_name,
|
|
437
|
+
label: info.field.label,
|
|
438
|
+
type: 'RepeatableSection',
|
|
439
|
+
description: info.field.description,
|
|
440
|
+
displayStyle: null,
|
|
441
|
+
parentPath: [...info.parentPath],
|
|
442
|
+
collectionOnly: true,
|
|
443
|
+
access: {
|
|
444
|
+
level: accessible ? 'accessible' : 'restricted',
|
|
445
|
+
code: accessible ? 'same_repeatable_section' : 'different_repeatable_section',
|
|
446
|
+
message: accessible ? null : 'Collection is outside the record ancestry.',
|
|
447
|
+
suggestion:
|
|
448
|
+
'Use this reference only as the first argument of REPEATABLEVALUES or REPEATABLESUM.',
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
})
|
|
452
|
+
.filter((reference) => includeRestricted || reference.access.level === 'accessible');
|
|
453
|
+
return [...scalarReferences, ...collections];
|
|
422
454
|
}
|
|
423
455
|
|
|
424
456
|
/**
|
|
@@ -434,6 +466,7 @@ export function getCalculationReferenceCatalog({
|
|
|
434
466
|
* issues: CalculationExpressionIssue[],
|
|
435
467
|
* usedBuiltins: string[],
|
|
436
468
|
* referencedFields: string[],
|
|
469
|
+
* collectionReferences: Array<{builtinName: string, sectionDataName: string, targetDataName: string, startIndex: number, endIndex: number}>,
|
|
437
470
|
* }}
|
|
438
471
|
*/
|
|
439
472
|
export function analyzeCalculationExpression({
|
|
@@ -651,6 +684,13 @@ export function analyzeCalculationExpression({
|
|
|
651
684
|
);
|
|
652
685
|
}
|
|
653
686
|
|
|
687
|
+
const collectionAnalysis = analyzeRepeatableCollections(
|
|
688
|
+
normalizedExpression,
|
|
689
|
+
resolver,
|
|
690
|
+
fieldDataName
|
|
691
|
+
);
|
|
692
|
+
for (const issue of collectionAnalysis.issues)
|
|
693
|
+
addIssue(issues, issueKeys, createIssue({ ...issue, severity: 'error' }));
|
|
654
694
|
for (const reference of extractStaticFieldReferenceMatches(normalizedExpression)) {
|
|
655
695
|
if (!referencedFieldNames.has(reference.fieldName)) {
|
|
656
696
|
referencedFieldNames.add(reference.fieldName);
|
|
@@ -732,6 +772,13 @@ export function analyzeCalculationExpression({
|
|
|
732
772
|
issues,
|
|
733
773
|
usedBuiltins,
|
|
734
774
|
referencedFields,
|
|
775
|
+
collectionReferences: collectionAnalysis.calls.map((call) => ({
|
|
776
|
+
builtinName: call.name,
|
|
777
|
+
sectionDataName: call.section,
|
|
778
|
+
targetDataName: call.target,
|
|
779
|
+
startIndex: call.index,
|
|
780
|
+
endIndex: call.index + call.length,
|
|
781
|
+
})),
|
|
735
782
|
};
|
|
736
783
|
}
|
|
737
784
|
|
|
@@ -757,6 +804,7 @@ export function analyzeCalculationExpression({
|
|
|
757
804
|
* fieldContext: Object | null,
|
|
758
805
|
* }>,
|
|
759
806
|
* },
|
|
807
|
+
* runRecord: (options: {snapshot: Object, instancePath?: Object[], expression?: string}) => Promise<{result: unknown, runtimeError: string|null, warnings: Object[], evaluation?: Object}>,
|
|
760
808
|
* dispose: () => void,
|
|
761
809
|
* }}
|
|
762
810
|
*/
|
|
@@ -774,6 +822,10 @@ export function createCalculationPreviewSession({
|
|
|
774
822
|
return {
|
|
775
823
|
run: () =>
|
|
776
824
|
createSimulationErrorResult(`Field '${fieldDataName}' does not exist in the form schema.`),
|
|
825
|
+
runRecord: async () =>
|
|
826
|
+
createSimulationErrorResult(
|
|
827
|
+
`Field '${fieldDataName}' does not exist or is not a CalculatedField.`
|
|
828
|
+
),
|
|
777
829
|
dispose: () => {},
|
|
778
830
|
};
|
|
779
831
|
}
|
|
@@ -781,6 +833,10 @@ export function createCalculationPreviewSession({
|
|
|
781
833
|
if (targetField.type !== 'CalculatedField') {
|
|
782
834
|
return {
|
|
783
835
|
run: () => createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
|
|
836
|
+
runRecord: async () =>
|
|
837
|
+
createSimulationErrorResult(
|
|
838
|
+
`Field '${fieldDataName}' does not exist or is not a CalculatedField.`
|
|
839
|
+
),
|
|
784
840
|
dispose: () => {},
|
|
785
841
|
};
|
|
786
842
|
}
|
|
@@ -852,6 +908,43 @@ export function createCalculationPreviewSession({
|
|
|
852
908
|
);
|
|
853
909
|
}
|
|
854
910
|
},
|
|
911
|
+
runRecord: async ({ snapshot, instancePath = [], expression: nextExpression, ...options }) => {
|
|
912
|
+
if (disposed)
|
|
913
|
+
return createSimulationErrorResult('Calculation preview session has been disposed.');
|
|
914
|
+
if (typeof nextExpression === 'string') targetField.calculate = nextExpression;
|
|
915
|
+
const evaluator = createRecordEvaluator({
|
|
916
|
+
schema: simulationSchema,
|
|
917
|
+
security,
|
|
918
|
+
warningSystem,
|
|
919
|
+
diagnostics: { console: false },
|
|
920
|
+
});
|
|
921
|
+
const evaluation = await evaluator.evaluate(snapshot, { ...options, mode: 'refresh' });
|
|
922
|
+
if (evaluation.status !== 'evaluated')
|
|
923
|
+
return {
|
|
924
|
+
...createSimulationErrorResult(evaluation.issues[0]?.message || 'Record preview failed.'),
|
|
925
|
+
evaluation,
|
|
926
|
+
};
|
|
927
|
+
let record = evaluation.snapshot;
|
|
928
|
+
for (const segment of instancePath)
|
|
929
|
+
record = record?.repeatable?.[segment.repeatableKey]?.[segment.index];
|
|
930
|
+
if (!record?.values || !Object.hasOwn(record.values, fieldDataName))
|
|
931
|
+
return {
|
|
932
|
+
...createSimulationErrorResult(
|
|
933
|
+
'Preview instancePath must identify the record owning the calculated field.'
|
|
934
|
+
),
|
|
935
|
+
evaluation,
|
|
936
|
+
};
|
|
937
|
+
const runtimeError =
|
|
938
|
+
evaluation.diagnostics.find(
|
|
939
|
+
(item) => item.fieldName === fieldDataName && item.severity === 'error'
|
|
940
|
+
)?.message || null;
|
|
941
|
+
return {
|
|
942
|
+
result: record?.values?.[fieldDataName] ?? null,
|
|
943
|
+
runtimeError,
|
|
944
|
+
warnings: sanitizeCollectedWarnings(warningSystem.getCollectedWarnings()),
|
|
945
|
+
evaluation,
|
|
946
|
+
};
|
|
947
|
+
},
|
|
855
948
|
dispose: () => {
|
|
856
949
|
disposed = true;
|
|
857
950
|
warningSystem.clearCollectedWarnings();
|
|
@@ -39,6 +39,7 @@ function toFormEventBuiltinDefinition(definition) {
|
|
|
39
39
|
signature: definition.signature,
|
|
40
40
|
description: definition.description,
|
|
41
41
|
examples: Object.freeze([...definition.examples]),
|
|
42
|
+
...(definition.operation ? { operation: definition.operation } : {}),
|
|
42
43
|
status: getFormEventBuiltinStatus(definition.name),
|
|
43
44
|
});
|
|
44
45
|
}
|
|
@@ -247,6 +248,9 @@ function cloneCatalogDefinition(definition) {
|
|
|
247
248
|
return {
|
|
248
249
|
...definition,
|
|
249
250
|
examples: [...definition.examples],
|
|
251
|
+
...(definition.operation
|
|
252
|
+
? { operation: { ...definition.operation, effects: [...definition.operation.effects] } }
|
|
253
|
+
: {}),
|
|
250
254
|
};
|
|
251
255
|
}
|
|
252
256
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function createContainer(value, seen) {
|
|
2
|
+
if (seen.has(value)) throw new Error('Record data must not contain cyclic references.');
|
|
3
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
4
|
+
throw new Error('Record data must use plain objects, arrays, dates or native media handles.');
|
|
5
|
+
return Array.isArray(value) ? [] : {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Clone portable record data without copying immutable binary media into JS memory. */
|
|
9
|
+
export function copyRecordData(value, seen = new Map()) {
|
|
10
|
+
if (typeof value === 'function' || typeof value === 'symbol')
|
|
11
|
+
throw new Error('Record data must not contain functions or symbols.');
|
|
12
|
+
if (value === null || typeof value !== 'object') return value;
|
|
13
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
14
|
+
if (typeof Blob === 'function' && value instanceof Blob) return value;
|
|
15
|
+
const result = createContainer(value, seen);
|
|
16
|
+
seen.set(value, result);
|
|
17
|
+
for (const key of Object.keys(value)) {
|
|
18
|
+
Object.defineProperty(result, key, {
|
|
19
|
+
value: copyRecordData(value[key], seen),
|
|
20
|
+
enumerable: true,
|
|
21
|
+
writable: true,
|
|
22
|
+
configurable: true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
seen.delete(value);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Copy privately captured results in bounded batches; never use this to capture live inputs. */
|
|
30
|
+
export async function copyRecordDataCooperatively(value, checkpoint) {
|
|
31
|
+
const seen = new Map();
|
|
32
|
+
const frames = [];
|
|
33
|
+
function begin(source) {
|
|
34
|
+
if (
|
|
35
|
+
source === null ||
|
|
36
|
+
typeof source !== 'object' ||
|
|
37
|
+
source instanceof Date ||
|
|
38
|
+
(typeof Blob === 'function' && source instanceof Blob)
|
|
39
|
+
)
|
|
40
|
+
return copyRecordData(source, seen);
|
|
41
|
+
const target = createContainer(source, seen);
|
|
42
|
+
seen.set(source, target);
|
|
43
|
+
frames.push({ source, target, keys: Object.keys(source), index: 0 });
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
const result = begin(value);
|
|
47
|
+
let work = 0;
|
|
48
|
+
while (frames.length) {
|
|
49
|
+
const frame = frames[frames.length - 1];
|
|
50
|
+
if (frame.index === frame.keys.length) {
|
|
51
|
+
seen.delete(frame.source);
|
|
52
|
+
frames.pop();
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const key = frame.keys[frame.index++];
|
|
56
|
+
Object.defineProperty(frame.target, key, {
|
|
57
|
+
value: begin(frame.source[key]),
|
|
58
|
+
enumerable: true,
|
|
59
|
+
writable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
});
|
|
62
|
+
if (++work === 128) {
|
|
63
|
+
work = 0;
|
|
64
|
+
await checkpoint();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await checkpoint();
|
|
68
|
+
return result;
|
|
69
|
+
}
|