form0-core 0.1.7 → 0.1.9
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/package.json +2 -2
- package/src/builtins/control/setresult.js +1 -1
- package/src/builtins/event/control/off.js +11 -0
- package/src/builtins/event/control/on.js +16 -0
- package/src/builtins/registry.js +19 -5
- package/src/engine/calculation.js +276 -152
- package/src/engine/form-engine.js +7 -4
- package/src/index.js +5 -0
- package/src/security/validation.js +6 -7
- package/src/utilities/calculation-dependencies.js +311 -0
- package/src/utilities/calculation-editor.js +60 -97
- package/src/utilities/form-event-editor.js +488 -0
- package/src/utilities/value-equality.js +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"main": "src/index.js",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node tests/form0-core.test.js && node tests/builtin-metadata.test.js && node tests/calculation-editor.test.js && node tests/security-test.js",
|
|
11
|
+
"test": "node tests/form0-core.test.js && node tests/builtin-metadata.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/security-test.js",
|
|
12
12
|
"postinstall": "node scripts/postinstall.cjs",
|
|
13
13
|
"format": "prettier --write \"**/*.{js,jsx,json,ts,tsx,md}\"",
|
|
14
14
|
"format:check": "prettier --check \"**/*.{js,jsx,json,ts,tsx,md}\""
|
|
@@ -22,7 +22,7 @@ export const SETRESULT_METADATA = defineBuiltinMetadata({
|
|
|
22
22
|
signature: 'SETRESULT(value)',
|
|
23
23
|
description: 'Set the final return value for multiline calculations.',
|
|
24
24
|
examples: ['SETRESULT($price * $quantity)'],
|
|
25
|
-
contexts: [BUILTIN_CONTEXTS.CALCULATION
|
|
25
|
+
contexts: [BUILTIN_CONTEXTS.CALCULATION],
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
export const SETRESULT = (value) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { __collectEventOperation } from '../event-operations-collector.js';
|
|
2
2
|
import { BUILTIN_CONTEXTS, defineBuiltinMetadata } from '../../builtin-metadata.js';
|
|
3
|
+
import { isValidEventType } from '../../../engine/event-registry.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* OFF builtin for form events
|
|
@@ -37,6 +38,16 @@ export const OFF_METADATA = defineBuiltinMetadata({
|
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
export function OFF(eventType, fieldKeyOrCallback, callback) {
|
|
41
|
+
if (typeof eventType !== 'string' || eventType.trim().length === 0) {
|
|
42
|
+
console.warn('[form0] OFF() requires a non-empty event type string');
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!isValidEventType(eventType)) {
|
|
47
|
+
console.warn(`[form0] OFF() received an unknown event type: ${eventType}`);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
let fieldKey = '*';
|
|
41
52
|
let actualCallback = undefined;
|
|
42
53
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { __collectEventOperation } from '../event-operations-collector.js';
|
|
2
2
|
import { BUILTIN_CONTEXTS, defineBuiltinMetadata } from '../../builtin-metadata.js';
|
|
3
|
+
import { isValidEventType } from '../../../engine/event-registry.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* ON builtin for form events
|
|
@@ -29,6 +30,16 @@ export const ON_METADATA = defineBuiltinMetadata({
|
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
export function ON(eventType, fieldKeyOrCallback, callback) {
|
|
33
|
+
if (typeof eventType !== 'string' || eventType.trim().length === 0) {
|
|
34
|
+
console.warn('[form0] ON() requires a non-empty event type string');
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!isValidEventType(eventType)) {
|
|
39
|
+
console.warn(`[form0] ON() received an unknown event type: ${eventType}`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
let fieldKey = '*';
|
|
33
44
|
let actualCallback = fieldKeyOrCallback;
|
|
34
45
|
|
|
@@ -38,6 +49,11 @@ export function ON(eventType, fieldKeyOrCallback, callback) {
|
|
|
38
49
|
actualCallback = callback;
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
if (typeof actualCallback !== 'function') {
|
|
53
|
+
console.warn('[form0] ON() requires a callback function');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
// Create operation descriptor
|
|
42
58
|
const operation = {
|
|
43
59
|
type: 'EVENT_OPERATION',
|
package/src/builtins/registry.js
CHANGED
|
@@ -65,7 +65,7 @@ export { ON, ON_METADATA } from './event/control/on.js';
|
|
|
65
65
|
export { OFF, OFF_METADATA } from './event/control/off.js';
|
|
66
66
|
export { __consumeEventOperations } from './event/event-operations-collector.js';
|
|
67
67
|
|
|
68
|
-
const
|
|
68
|
+
const COMMON_RUNTIME_ENTRIES = Object.freeze([
|
|
69
69
|
{ implementation: IF, definition: IF_METADATA },
|
|
70
70
|
{ implementation: AND, definition: AND_METADATA },
|
|
71
71
|
{ implementation: OR, definition: OR_METADATA },
|
|
@@ -73,7 +73,6 @@ const CALCULATION_AND_EVENT_ENTRIES = Object.freeze([
|
|
|
73
73
|
{ implementation: COUNTA, definition: COUNTA_METADATA },
|
|
74
74
|
{ implementation: COUNTBLANK, definition: COUNTBLANK_METADATA },
|
|
75
75
|
{ implementation: ARRAY, definition: ARRAY_METADATA },
|
|
76
|
-
{ implementation: SETRESULT, definition: SETRESULT_METADATA },
|
|
77
76
|
{ implementation: EVAL, definition: EVAL_METADATA },
|
|
78
77
|
{ implementation: CHOICEVALUE, definition: CHOICEVALUE_METADATA },
|
|
79
78
|
{ implementation: CHOICELABEL, definition: CHOICELABEL_METADATA },
|
|
@@ -91,6 +90,10 @@ const CALCULATION_AND_EVENT_ENTRIES = Object.freeze([
|
|
|
91
90
|
{ implementation: UPPER, definition: UPPER_METADATA },
|
|
92
91
|
]);
|
|
93
92
|
|
|
93
|
+
const CALCULATION_ONLY_ENTRIES = Object.freeze([
|
|
94
|
+
{ implementation: SETRESULT, definition: SETRESULT_METADATA },
|
|
95
|
+
]);
|
|
96
|
+
|
|
94
97
|
const EVENT_ONLY_ENTRIES = Object.freeze([
|
|
95
98
|
{ implementation: ALERT, definition: ALERT_METADATA },
|
|
96
99
|
{ implementation: SETVALUE, definition: SETVALUE_METADATA },
|
|
@@ -110,12 +113,23 @@ function filterDefinitionsByContext(definitions, context) {
|
|
|
110
113
|
return Object.freeze(definitions.filter((definition) => definition.contexts.includes(context)));
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
export const
|
|
116
|
+
export const calculationBuiltins = buildBuiltinObject([
|
|
117
|
+
...COMMON_RUNTIME_ENTRIES,
|
|
118
|
+
...CALCULATION_ONLY_ENTRIES,
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
export const eventBuiltins = buildBuiltinObject([
|
|
122
|
+
...COMMON_RUNTIME_ENTRIES,
|
|
123
|
+
...EVENT_ONLY_ENTRIES,
|
|
124
|
+
]);
|
|
114
125
|
|
|
115
|
-
|
|
126
|
+
// Backward-compatible alias for calculation runtime helpers.
|
|
127
|
+
export const builtins = calculationBuiltins;
|
|
116
128
|
|
|
117
129
|
export const BUILTIN_DEFINITIONS = Object.freeze(
|
|
118
|
-
[...
|
|
130
|
+
[...COMMON_RUNTIME_ENTRIES, ...CALCULATION_ONLY_ENTRIES, ...EVENT_ONLY_ENTRIES].map(
|
|
131
|
+
({ definition }) => definition
|
|
132
|
+
)
|
|
119
133
|
);
|
|
120
134
|
|
|
121
135
|
export const CALCULATION_BUILTIN_DEFINITIONS = filterDefinitionsByContext(
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { runExpression } from './evaluator.js';
|
|
2
|
-
import { flattenFields } from '../utilities/field-helpers.js';
|
|
3
2
|
import { ContextResolver } from './context-resolver.js';
|
|
4
3
|
import { WarningSystem } from './warning-system.js';
|
|
4
|
+
import {
|
|
5
|
+
buildCalculationDependencyPlan,
|
|
6
|
+
buildCalculationExecutionContext,
|
|
7
|
+
extractStaticFieldReferences,
|
|
8
|
+
} from '../utilities/calculation-dependencies.js';
|
|
9
|
+
import { areStructuredValuesEqual } from '../utilities/value-equality.js';
|
|
5
10
|
|
|
6
11
|
export function evaluateCalculatedFields(
|
|
7
12
|
schema,
|
|
@@ -10,69 +15,68 @@ export function evaluateCalculatedFields(
|
|
|
10
15
|
securityConfig,
|
|
11
16
|
contextResolver = null,
|
|
12
17
|
warningSystem = null,
|
|
13
|
-
runtimeDiagnostics = null
|
|
18
|
+
runtimeDiagnostics = null,
|
|
19
|
+
calculationPlan = null
|
|
14
20
|
) {
|
|
15
|
-
const fields = flattenFields(schema.elements);
|
|
16
|
-
|
|
17
|
-
// Initialize context resolver and warning system if not provided (backward compatibility)
|
|
18
21
|
const resolver = contextResolver || new ContextResolver(schema);
|
|
19
22
|
const warnings = warningSystem || new WarningSystem();
|
|
23
|
+
const plan = calculationPlan || buildCalculationDependencyPlan(schema, resolver);
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
25
|
+
emitDependencyPlanWarnings(plan, warnings, runtimeDiagnostics);
|
|
26
|
+
|
|
27
|
+
if (plan.totalCalculatedFieldCount === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (plan.hasDynamicDependencies) {
|
|
32
|
+
evaluateFieldSequenceUntilStable(
|
|
33
|
+
plan.orderedFieldNames,
|
|
34
|
+
plan,
|
|
35
|
+
schema,
|
|
36
|
+
values,
|
|
37
|
+
helpers,
|
|
38
|
+
securityConfig,
|
|
39
|
+
resolver,
|
|
40
|
+
warnings,
|
|
41
|
+
runtimeDiagnostics
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
plan.orderedComponentIds.forEach((componentId) => {
|
|
47
|
+
const component = plan.componentsById.get(componentId);
|
|
48
|
+
if (!component || component.fieldNames.length === 0) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (component.isCyclic) {
|
|
53
|
+
evaluateFieldSequenceUntilStable(
|
|
54
|
+
component.fieldNames,
|
|
55
|
+
plan,
|
|
56
|
+
schema,
|
|
57
|
+
values,
|
|
58
|
+
helpers,
|
|
59
|
+
securityConfig,
|
|
60
|
+
resolver,
|
|
61
|
+
warnings,
|
|
62
|
+
runtimeDiagnostics
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fieldName = component.fieldNames[0];
|
|
68
|
+
const node = plan.nodesByDataName.get(fieldName);
|
|
69
|
+
if (node?.field) {
|
|
70
|
+
evaluateCalculatedField(
|
|
71
|
+
node.field,
|
|
72
|
+
schema,
|
|
73
|
+
values,
|
|
74
|
+
helpers,
|
|
75
|
+
securityConfig,
|
|
76
|
+
resolver,
|
|
77
|
+
warnings,
|
|
78
|
+
runtimeDiagnostics
|
|
79
|
+
);
|
|
76
80
|
}
|
|
77
81
|
});
|
|
78
82
|
}
|
|
@@ -87,43 +91,32 @@ function buildScopedContext(
|
|
|
87
91
|
) {
|
|
88
92
|
const ctx = { ...helpers };
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
const referencedFields = extractFieldReferences(expressionCode);
|
|
92
|
-
|
|
93
|
-
// Track problematic fields that are actually accessed
|
|
94
|
+
const referencedFields = extractStaticFieldReferences(expressionCode);
|
|
94
95
|
const restrictedAccessedFields = [];
|
|
95
96
|
const notFoundAccessedFields = [];
|
|
96
97
|
|
|
97
|
-
// Add scoped field access
|
|
98
98
|
for (const [fieldName, value] of Object.entries(values)) {
|
|
99
99
|
const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
|
|
100
100
|
|
|
101
101
|
if (accessLevel === 'accessible') {
|
|
102
102
|
ctx[`$${fieldName}`] = value;
|
|
103
103
|
} else if (accessLevel === 'restricted') {
|
|
104
|
-
// Only add restricted fields to context if they're actually referenced
|
|
105
104
|
if (referencedFields.has(fieldName)) {
|
|
106
105
|
ctx[`$${fieldName}`] = undefined;
|
|
107
106
|
restrictedAccessedFields.push(fieldName);
|
|
108
107
|
}
|
|
109
|
-
// If not referenced, don't add to context at all
|
|
110
108
|
}
|
|
111
|
-
// 'not_found' fields are not added to context at all
|
|
112
109
|
}
|
|
113
110
|
|
|
114
|
-
// Check for not_found fields that are actually referenced
|
|
115
111
|
for (const fieldName of referencedFields) {
|
|
116
|
-
// Check if this field was processed above (exists in values)
|
|
117
112
|
if (!(fieldName in values)) {
|
|
118
113
|
const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
|
|
119
114
|
if (accessLevel === 'not_found') {
|
|
120
115
|
notFoundAccessedFields.push(fieldName);
|
|
121
|
-
// Don't add to context - it will be undefined when accessed
|
|
122
116
|
}
|
|
123
117
|
}
|
|
124
118
|
}
|
|
125
119
|
|
|
126
|
-
// Emit warnings for restricted fields that are actually accessed
|
|
127
120
|
restrictedAccessedFields.forEach((fieldName) => {
|
|
128
121
|
const warning = contextResolver.generateAccessWarning(
|
|
129
122
|
executionContext,
|
|
@@ -133,7 +126,6 @@ function buildScopedContext(
|
|
|
133
126
|
warningSystem.emitWarning(warning);
|
|
134
127
|
});
|
|
135
128
|
|
|
136
|
-
// Emit warnings for not_found fields that are actually accessed
|
|
137
129
|
notFoundAccessedFields.forEach((fieldName) => {
|
|
138
130
|
const warning = contextResolver.generateAccessWarning(executionContext, fieldName, 'not_found');
|
|
139
131
|
warningSystem.emitWarning(warning);
|
|
@@ -142,93 +134,225 @@ function buildScopedContext(
|
|
|
142
134
|
return ctx;
|
|
143
135
|
}
|
|
144
136
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// Remove content inside EVAL() calls to avoid false positives
|
|
155
|
-
// This handles complex patterns like EVAL('string' + variable + 'more')
|
|
156
|
-
const cleanedCode = removeEvalContents(code);
|
|
157
|
-
|
|
158
|
-
// Simple regex to find $fieldname patterns in the cleaned code
|
|
159
|
-
// This matches $ followed by valid JavaScript identifier characters
|
|
160
|
-
const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
161
|
-
let match;
|
|
162
|
-
|
|
163
|
-
while ((match = fieldRegex.exec(cleanedCode)) !== null) {
|
|
164
|
-
fieldReferences.add(match[1]); // Add the field name (without $)
|
|
137
|
+
function pushRuntimeDiagnostic(
|
|
138
|
+
runtimeDiagnostics,
|
|
139
|
+
fieldName,
|
|
140
|
+
message,
|
|
141
|
+
severity = 'error',
|
|
142
|
+
dedupeKey = null
|
|
143
|
+
) {
|
|
144
|
+
if (!Array.isArray(runtimeDiagnostics)) {
|
|
145
|
+
return;
|
|
165
146
|
}
|
|
166
147
|
|
|
167
|
-
|
|
148
|
+
if (
|
|
149
|
+
dedupeKey &&
|
|
150
|
+
runtimeDiagnostics.some(
|
|
151
|
+
(diagnostic) =>
|
|
152
|
+
diagnostic?.fieldName === fieldName &&
|
|
153
|
+
diagnostic?.message === message &&
|
|
154
|
+
diagnostic?.dedupeKey === dedupeKey
|
|
155
|
+
)
|
|
156
|
+
) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
runtimeDiagnostics.push({
|
|
161
|
+
fieldName,
|
|
162
|
+
message,
|
|
163
|
+
severity,
|
|
164
|
+
dedupeKey,
|
|
165
|
+
});
|
|
168
166
|
}
|
|
169
167
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
} else if (char === '"' && !inSingleQuote && !inBacktick) {
|
|
201
|
-
inDoubleQuote = !inDoubleQuote;
|
|
202
|
-
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
|
|
203
|
-
inBacktick = !inBacktick;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Only count parentheses when not inside strings
|
|
207
|
-
if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
|
|
208
|
-
if (char === '(') {
|
|
209
|
-
parenCount++;
|
|
210
|
-
} else if (char === ')') {
|
|
211
|
-
parenCount--;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
i++;
|
|
216
|
-
}
|
|
217
|
-
} else {
|
|
218
|
-
// Not an EVAL call, add character to result
|
|
219
|
-
result += code[i];
|
|
220
|
-
i++;
|
|
168
|
+
function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics) {
|
|
169
|
+
plan.dynamicFieldNames.forEach((fieldName) => {
|
|
170
|
+
const message = `CalculatedField '${fieldName}' uses dynamic field access. Runtime evaluation falls back to bounded stabilization.`;
|
|
171
|
+
warningSystem.emitWarning({
|
|
172
|
+
type: 'calculation_dependency',
|
|
173
|
+
reason: 'dynamic_dependencies',
|
|
174
|
+
message,
|
|
175
|
+
suggestion: 'Prefer direct $field references when possible to enable dependency ordering.',
|
|
176
|
+
executionContext: {
|
|
177
|
+
type: 'calculation',
|
|
178
|
+
fieldName,
|
|
179
|
+
},
|
|
180
|
+
fieldContext: {
|
|
181
|
+
fieldName,
|
|
182
|
+
dynamic: true,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
pushRuntimeDiagnostic(
|
|
186
|
+
runtimeDiagnostics,
|
|
187
|
+
fieldName,
|
|
188
|
+
message,
|
|
189
|
+
'warning',
|
|
190
|
+
`dynamic:${fieldName}`
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
plan.cyclicComponentIds.forEach((componentId) => {
|
|
195
|
+
const component = plan.componentsById.get(componentId);
|
|
196
|
+
if (!component?.isCyclic) {
|
|
197
|
+
return;
|
|
221
198
|
}
|
|
199
|
+
|
|
200
|
+
const fieldNames = component.fieldNames;
|
|
201
|
+
const message = `CalculatedField dependency cycle detected: ${fieldNames.join(' -> ')}. Runtime evaluation will use bounded stabilization.`;
|
|
202
|
+
warningSystem.emitWarning({
|
|
203
|
+
type: 'calculation_dependency',
|
|
204
|
+
reason: 'cyclic_dependencies',
|
|
205
|
+
message,
|
|
206
|
+
suggestion:
|
|
207
|
+
'Remove the cycle or break it with a source field so calculated values can be evaluated deterministically.',
|
|
208
|
+
executionContext: {
|
|
209
|
+
type: 'calculation',
|
|
210
|
+
fieldName: fieldNames[0] || null,
|
|
211
|
+
},
|
|
212
|
+
fieldContext: {
|
|
213
|
+
fieldNames: [...fieldNames],
|
|
214
|
+
cyclic: true,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
fieldNames.forEach((fieldName) => {
|
|
219
|
+
pushRuntimeDiagnostic(
|
|
220
|
+
runtimeDiagnostics,
|
|
221
|
+
fieldName,
|
|
222
|
+
message,
|
|
223
|
+
'warning',
|
|
224
|
+
`cycle:${componentId}`
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function evaluateCalculatedField(
|
|
231
|
+
field,
|
|
232
|
+
schema,
|
|
233
|
+
values,
|
|
234
|
+
helpers,
|
|
235
|
+
securityConfig,
|
|
236
|
+
resolver,
|
|
237
|
+
warnings,
|
|
238
|
+
runtimeDiagnostics
|
|
239
|
+
) {
|
|
240
|
+
if (!field?.data_name || !field.calculate) {
|
|
241
|
+
return { changed: false, hasError: false };
|
|
222
242
|
}
|
|
223
243
|
|
|
224
|
-
|
|
244
|
+
try {
|
|
245
|
+
const executionContext = buildCalculationExecutionContext(schema, field.data_name, resolver);
|
|
246
|
+
const context = buildScopedContext(
|
|
247
|
+
values,
|
|
248
|
+
helpers,
|
|
249
|
+
executionContext,
|
|
250
|
+
resolver,
|
|
251
|
+
warnings,
|
|
252
|
+
field.calculate
|
|
253
|
+
);
|
|
254
|
+
const previousValue = values[field.data_name];
|
|
255
|
+
const nextValue = runExpression(field.calculate, context, securityConfig, false, schema, {
|
|
256
|
+
suppressConsoleWarning: Array.isArray(runtimeDiagnostics),
|
|
257
|
+
onError: (error) => {
|
|
258
|
+
pushRuntimeDiagnostic(
|
|
259
|
+
runtimeDiagnostics,
|
|
260
|
+
field.data_name,
|
|
261
|
+
error instanceof Error && error.message
|
|
262
|
+
? error.message
|
|
263
|
+
: 'Unknown calculation runtime error.'
|
|
264
|
+
);
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
values[field.data_name] = nextValue;
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
changed: !areStructuredValuesEqual(previousValue, nextValue),
|
|
271
|
+
hasError: false,
|
|
272
|
+
};
|
|
273
|
+
} catch (error) {
|
|
274
|
+
pushRuntimeDiagnostic(
|
|
275
|
+
runtimeDiagnostics,
|
|
276
|
+
field.data_name,
|
|
277
|
+
error instanceof Error && error.message ? error.message : 'Unknown calculation runtime error.'
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (!Array.isArray(runtimeDiagnostics)) {
|
|
281
|
+
console.warn(`Calculation failed for ${field.data_name}:`, error?.message);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { changed: false, hasError: true };
|
|
285
|
+
}
|
|
225
286
|
}
|
|
226
287
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
288
|
+
function evaluateFieldSequenceUntilStable(
|
|
289
|
+
fieldNames,
|
|
290
|
+
plan,
|
|
291
|
+
schema,
|
|
292
|
+
values,
|
|
293
|
+
helpers,
|
|
294
|
+
securityConfig,
|
|
295
|
+
resolver,
|
|
296
|
+
warnings,
|
|
297
|
+
runtimeDiagnostics
|
|
298
|
+
) {
|
|
299
|
+
const maxPasses = Math.max(2, plan.totalCalculatedFieldCount);
|
|
300
|
+
let stillChangingFieldNames = [];
|
|
301
|
+
|
|
302
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
303
|
+
stillChangingFieldNames = [];
|
|
304
|
+
|
|
305
|
+
fieldNames.forEach((fieldName) => {
|
|
306
|
+
const node = plan.nodesByDataName.get(fieldName);
|
|
307
|
+
if (!node?.field) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const result = evaluateCalculatedField(
|
|
312
|
+
node.field,
|
|
313
|
+
schema,
|
|
314
|
+
values,
|
|
315
|
+
helpers,
|
|
316
|
+
securityConfig,
|
|
317
|
+
resolver,
|
|
318
|
+
warnings,
|
|
319
|
+
runtimeDiagnostics
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
if (result.changed) {
|
|
323
|
+
stillChangingFieldNames.push(fieldName);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
if (stillChangingFieldNames.length === 0) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
232
330
|
}
|
|
233
|
-
|
|
331
|
+
|
|
332
|
+
const message = `Calculated fields did not stabilize within ${maxPasses} evaluation passes: ${stillChangingFieldNames.join(', ')}.`;
|
|
333
|
+
warnings.emitWarning({
|
|
334
|
+
type: 'calculation_dependency',
|
|
335
|
+
reason: 'non_converging_runtime',
|
|
336
|
+
message,
|
|
337
|
+
suggestion:
|
|
338
|
+
'Review the involved CalculatedField expressions for dependency cycles or dynamic references that never settle.',
|
|
339
|
+
executionContext: {
|
|
340
|
+
type: 'calculation',
|
|
341
|
+
fieldName: stillChangingFieldNames[0] || null,
|
|
342
|
+
},
|
|
343
|
+
fieldContext: {
|
|
344
|
+
fieldNames: [...stillChangingFieldNames],
|
|
345
|
+
maxPasses,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
stillChangingFieldNames.forEach((fieldName) => {
|
|
350
|
+
pushRuntimeDiagnostic(
|
|
351
|
+
runtimeDiagnostics,
|
|
352
|
+
fieldName,
|
|
353
|
+
message,
|
|
354
|
+
'warning',
|
|
355
|
+
`non-converging:${fieldNames.join(',')}`
|
|
356
|
+
);
|
|
357
|
+
});
|
|
234
358
|
}
|