form0-core 0.5.1 → 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/condition-context.js +90 -0
- package/src/engine/conditions.js +59 -50
- package/src/engine/context-resolver.js +26 -10
- 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 +9 -7
- 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/engine/warning-system.js +4 -0
- package/src/index.js +3 -0
- package/src/schema/operators.js +2 -24
- package/src/schema/schema-validator.js +25 -20
- 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
package/README.md
CHANGED
|
@@ -256,3 +256,36 @@ See [SUPPORT.md](https://github.com/paqu-io/form0-core/blob/main/SUPPORT.md) for
|
|
|
256
256
|
## 📄 License
|
|
257
257
|
|
|
258
258
|
[MIT](./LICENSE)
|
|
259
|
+
|
|
260
|
+
### Record-tree evaluation and submission preparation (unreleased)
|
|
261
|
+
|
|
262
|
+
`createRecordEvaluator()` coordinates root and nested accepted records without changing the caller's
|
|
263
|
+
snapshot. It shares the existing calculation executor and scope/security rules; `engine.eval()` keeps
|
|
264
|
+
its intrinsic evaluation contract.
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
import { createRecordEvaluator, validateSubmission } from 'form0-core';
|
|
268
|
+
|
|
269
|
+
const evaluator = createRecordEvaluator({ schema });
|
|
270
|
+
const accepted = await evaluator.evaluate(snapshot); // retained child calculations
|
|
271
|
+
const refreshed = await evaluator.evaluate(snapshot, { mode: 'refresh' });
|
|
272
|
+
const prepared = await evaluator.prepareSubmission(snapshot);
|
|
273
|
+
// Persist only when prepared.eligible; persistence and save hooks belong to the host.
|
|
274
|
+
const summary = validateSubmission({ schema, snapshot: refreshed.snapshot });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The runtime snapshot is `{ values, repeatable }`, using existing row objects with `id`, `values`,
|
|
278
|
+
nested `repeatable` and metadata. `REPEATABLEVALUES($tasks, 'cost')` and
|
|
279
|
+
`REPEATABLESUM($tasks, 'cost')` require this record-tree context and are calculation-only.
|
|
280
|
+
|
|
281
|
+
`form.repeatable_recalculation` accepts `automatic` (default) or `confirm`. A changed child proposal
|
|
282
|
+
in confirm mode returns `status: 'requires_decision'`, `reviewId`, `proposal`, `changes` and no selected
|
|
283
|
+
snapshot. Resume with the current snapshot and
|
|
284
|
+
`{ decision: { reviewId, action: 'refresh' } }`; `retain` additionally requires `allowRetain: true` on
|
|
285
|
+
both preparation calls. Decisions are single-use and reject obsolete inputs/configuration.
|
|
286
|
+
|
|
287
|
+
Draft preparation uses `{ intent: 'draft' }`, retains child values without confirmation, and permits
|
|
288
|
+
ordinary validation failures. Persist the returned `freshness` companion separately from the record
|
|
289
|
+
and supply it to the host on reopening; do not clear stored pending status before persistence succeeds.
|
|
290
|
+
`managedEvents: true` enables root and participating closed-row custom validation only. No save or
|
|
291
|
+
closed-row lifecycle hooks are replayed. Renderer/worker/Native/CLI UI adoption is separate work.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
"SECURITY.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/builtin-import-isolation.test.js && node tests/builtin-catalog-examples.test.js && node tests/date-builtins.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/form-authoring.test.js && node tests/calculation-dependency-engine.test.js && node tests/calculation-diagnostics.characterization.test.js && node tests/calculation-diagnostics.test.js && node tests/calculation-missing-result.test.js && node tests/evaluator-reentry.test.js && node tests/event-compatibility.characterization.test.js && node tests/event-hermes.test.js && node tests/event-async-isolation.test.js && node tests/managed-events.test.js && node tests/security-test.js",
|
|
15
|
+
"test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/condition-scope.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/builtin-import-isolation.test.js && node tests/builtin-catalog-examples.test.js && node tests/date-builtins.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/form-authoring.test.js && node tests/calculation-dependency-engine.test.js && node tests/calculation-diagnostics.characterization.test.js && node tests/calculation-diagnostics.test.js && node tests/calculation-missing-result.test.js && node tests/evaluator-reentry.test.js && node tests/event-compatibility.characterization.test.js && node tests/event-hermes.test.js && node tests/event-async-isolation.test.js && node tests/managed-events.test.js && node tests/security-test.js && node tests/record-evaluator.test.js && node tests/complex-record-evaluator.test.js",
|
|
16
16
|
"format": "prettier --write \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md EVENT_CONTRACT.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
|
|
17
17
|
"format:check": "prettier --check \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md EVENT_CONTRACT.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
|
|
18
18
|
"lint:check": "eslint src tests",
|
|
19
19
|
"package:check": "node scripts/check-package-contents.mjs",
|
|
20
20
|
"security:audit:prod": "npm audit --omit=dev --omit=peer --audit-level=high",
|
|
21
21
|
"check": "npm run format:check && npm run lint:check && npm test",
|
|
22
|
-
"release:check": "npm run check && npm run security:audit:prod && npm run package:check"
|
|
22
|
+
"release:check": "npm run check && npm run security:audit:prod && npm run package:check",
|
|
23
|
+
"benchmark:records": "node scripts/benchmark-record-evaluator.mjs",
|
|
24
|
+
"benchmark:records:complex": "node scripts/benchmark-complex-records.mjs"
|
|
23
25
|
},
|
|
24
26
|
"exports": {
|
|
25
27
|
".": {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getFormSchemaRevision } from '../utilities/schema-revision.js';
|
|
2
|
+
export { getFormSchemaRevision } from '../utilities/schema-revision.js';
|
|
1
3
|
import { FIELD_SPECS } from '../schema/field-specs.js';
|
|
2
4
|
import { getValidOperators } from '../schema/operators.js';
|
|
3
5
|
import { validateSchema } from '../schema/schema-validator.js';
|
|
@@ -162,25 +164,6 @@ function normalizeRootSchema(schema) {
|
|
|
162
164
|
throw new Error('Expected a root schema containing form.elements or a form schema');
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
function stableValue(value) {
|
|
166
|
-
if (Array.isArray(value)) return value.map(stableValue);
|
|
167
|
-
if (!value || typeof value !== 'object') return value;
|
|
168
|
-
return Object.fromEntries(
|
|
169
|
-
Object.keys(value)
|
|
170
|
-
.sort()
|
|
171
|
-
.map((key) => [key, stableValue(value[key])])
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function fnv1a64(input) {
|
|
176
|
-
let hash = 0xcbf29ce484222325n;
|
|
177
|
-
for (let index = 0; index < input.length; index += 1) {
|
|
178
|
-
hash ^= BigInt(input.charCodeAt(index));
|
|
179
|
-
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
180
|
-
}
|
|
181
|
-
return hash.toString(16).padStart(16, '0');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
167
|
function toSerializable(value) {
|
|
185
168
|
if (Array.isArray(value)) return value.map(toSerializable);
|
|
186
169
|
if (!value || typeof value !== 'object') return typeof value === 'function' ? undefined : value;
|
|
@@ -551,12 +534,6 @@ function validateDraft(root, security) {
|
|
|
551
534
|
return diagnostics;
|
|
552
535
|
}
|
|
553
536
|
|
|
554
|
-
/** Return a deterministic revision for optimistic authoring concurrency. */
|
|
555
|
-
export function getFormSchemaRevision(schema) {
|
|
556
|
-
const root = normalizeRootSchema(schema);
|
|
557
|
-
return `form0-${fnv1a64(JSON.stringify(stableValue(root)))}`;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
537
|
/** Return the installed-version authoring capabilities and complete schema context. */
|
|
561
538
|
export function getFormAuthoringContext({ schema, coreVersion = null }) {
|
|
562
539
|
const root = normalizeRootSchema(schema);
|
|
@@ -575,6 +552,7 @@ export function getFormAuthoringContext({ schema, coreVersion = null }) {
|
|
|
575
552
|
schema: root,
|
|
576
553
|
fieldDataName: field.data_name,
|
|
577
554
|
includeRestricted: true,
|
|
555
|
+
includeCollections: true,
|
|
578
556
|
}),
|
|
579
557
|
}));
|
|
580
558
|
return {
|
|
@@ -589,7 +567,19 @@ export function getFormAuthoringContext({ schema, coreVersion = null }) {
|
|
|
589
567
|
eventTypes: getAllEventTypes(),
|
|
590
568
|
eventGuidance: deepClone(EVENT_GUIDANCE),
|
|
591
569
|
calculationBuiltins: getCalculationBuiltinCatalog(),
|
|
592
|
-
calculationGuidance:
|
|
570
|
+
calculationGuidance: {
|
|
571
|
+
...deepClone(CALCULATION_GUIDANCE),
|
|
572
|
+
repeatableCollections: {
|
|
573
|
+
context: 'calculation-only',
|
|
574
|
+
requiresRecordEvaluator: true,
|
|
575
|
+
directSectionReference: true,
|
|
576
|
+
literalTargetDataName: true,
|
|
577
|
+
examples: ["REPEATABLEVALUES($tasks, 'cost')", "REPEATABLESUM($tasks, 'cost')"],
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
formSettings: {
|
|
581
|
+
repeatable_recalculation: { values: ['automatic', 'confirm'], default: 'automatic' },
|
|
582
|
+
},
|
|
593
583
|
eventBuiltins: getFormEventBuiltinCatalog(),
|
|
594
584
|
eventReferences: getFormEventReferenceCatalog({ schema: root }),
|
|
595
585
|
calculations,
|
|
@@ -284,6 +284,26 @@ export const INVALID_METADATA = defineBuiltinMetadata({
|
|
|
284
284
|
contexts: [BUILTIN_CONTEXTS.EVENT],
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
export const REPEATABLEVALUES_METADATA = defineBuiltinMetadata({
|
|
288
|
+
name: 'REPEATABLEVALUES',
|
|
289
|
+
category: 'repeatable',
|
|
290
|
+
signature: 'REPEATABLEVALUES($repeatable_data_name, target_data_name)',
|
|
291
|
+
description:
|
|
292
|
+
'Collect accepted or refreshed row values from the named RepeatableSection in record-tree context. Preserves row order and grouping; requires a literal target data_name.',
|
|
293
|
+
examples: ["REPEATABLEVALUES($tasks, 'cost')"],
|
|
294
|
+
contexts: [BUILTIN_CONTEXTS.CALCULATION],
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
export const REPEATABLESUM_METADATA = defineBuiltinMetadata({
|
|
298
|
+
name: 'REPEATABLESUM',
|
|
299
|
+
category: 'repeatable',
|
|
300
|
+
signature: 'REPEATABLESUM($repeatable_data_name, target_data_name)',
|
|
301
|
+
description:
|
|
302
|
+
'Sum finite NumericField or CalculatedField row values in record-tree context. Skips missing values, returns zero for no rows, and never coerces strings.',
|
|
303
|
+
examples: ["REPEATABLESUM($tasks, 'cost')"],
|
|
304
|
+
contexts: [BUILTIN_CONTEXTS.CALCULATION],
|
|
305
|
+
});
|
|
306
|
+
|
|
287
307
|
export const BUILTIN_DEFINITIONS = Object.freeze([
|
|
288
308
|
IF_METADATA,
|
|
289
309
|
AND_METADATA,
|
|
@@ -315,6 +335,8 @@ export const BUILTIN_DEFINITIONS = Object.freeze([
|
|
|
315
335
|
ON_METADATA,
|
|
316
336
|
OFF_METADATA,
|
|
317
337
|
INVALID_METADATA,
|
|
338
|
+
REPEATABLEVALUES_METADATA,
|
|
339
|
+
REPEATABLESUM_METADATA,
|
|
318
340
|
]);
|
|
319
341
|
|
|
320
342
|
function filterDefinitionsByContext(context) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractStaticFieldReferences } from '../../utilities/calculation-dependencies.js';
|
|
1
2
|
import { validateExpression } from '../../security/validation.js';
|
|
2
3
|
import { SAFE_SECURITY_CONFIG } from '../../security/config.js';
|
|
3
4
|
export { EVAL_METADATA } from '../builtin-definitions.js';
|
|
@@ -182,7 +183,9 @@ function evaluateHybridExpression(expression, formContext) {
|
|
|
182
183
|
|
|
183
184
|
if (isFieldReference(expression)) {
|
|
184
185
|
// Field reference - resolve from context
|
|
185
|
-
const fieldValue =
|
|
186
|
+
const fieldValue = _evalReporting?.resolveReference
|
|
187
|
+
? _evalReporting.resolveReference(expression)
|
|
188
|
+
: formContext?.[expression];
|
|
186
189
|
if (fieldValue !== undefined) {
|
|
187
190
|
logDebug('[form0] EVAL() resolved field reference:', expression, '→', fieldValue);
|
|
188
191
|
return fieldValue;
|
|
@@ -208,6 +211,10 @@ function evaluateHybridExpression(expression, formContext) {
|
|
|
208
211
|
|
|
209
212
|
// Step 3c: Add form context for variable access
|
|
210
213
|
Object.assign(basicContext, formContext || {});
|
|
214
|
+
if (_evalReporting?.resolveReference) {
|
|
215
|
+
for (const name of extractStaticFieldReferences(expression))
|
|
216
|
+
basicContext[`$${name}`] = _evalReporting.resolveReference(`$${name}`);
|
|
217
|
+
}
|
|
211
218
|
|
|
212
219
|
// Step 3d: Evaluate expression for string building
|
|
213
220
|
const keys = Object.keys(basicContext);
|
|
@@ -221,7 +228,9 @@ function evaluateHybridExpression(expression, formContext) {
|
|
|
221
228
|
// Step 3e: Check if result should be resolved as field reference
|
|
222
229
|
if (typeof result === 'string' && result.startsWith('$')) {
|
|
223
230
|
// This is a field reference - resolve it from context
|
|
224
|
-
const fieldValue =
|
|
231
|
+
const fieldValue = _evalReporting?.resolveReference
|
|
232
|
+
? _evalReporting.resolveReference(result)
|
|
233
|
+
: formContext?.[result];
|
|
225
234
|
if (fieldValue !== undefined) {
|
|
226
235
|
logDebug(
|
|
227
236
|
'[form0] EVAL() resolved field reference:',
|
package/src/builtins/registry.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { REPEATABLEVALUES, REPEATABLEVALUES_METADATA } from './repeatable/repeatablevalues.js';
|
|
2
|
+
import { REPEATABLESUM, REPEATABLESUM_METADATA } from './repeatable/repeatablesum.js';
|
|
1
3
|
import { cloneBuiltinMetadata, BUILTIN_CONTEXTS } from './builtin-metadata.js';
|
|
2
4
|
import { BUILTIN_DEFINITIONS } from './builtin-definitions.js';
|
|
3
5
|
|
|
@@ -115,6 +117,8 @@ const RUNTIME_ENTRIES = Object.freeze([
|
|
|
115
117
|
{ implementation: DAYS, definition: DAYS_METADATA },
|
|
116
118
|
{ implementation: UPPER, definition: UPPER_METADATA },
|
|
117
119
|
{ implementation: SETRESULT, definition: SETRESULT_METADATA },
|
|
120
|
+
{ implementation: REPEATABLEVALUES, definition: REPEATABLEVALUES_METADATA },
|
|
121
|
+
{ implementation: REPEATABLESUM, definition: REPEATABLESUM_METADATA },
|
|
118
122
|
{ implementation: ALERT, definition: ALERT_METADATA },
|
|
119
123
|
{ implementation: SETVALUE, definition: SETVALUE_METADATA },
|
|
120
124
|
{ implementation: ON, definition: ON_METADATA },
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { REPEATABLESUM_METADATA } from '../builtin-definitions.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @builtin REPEATABLESUM
|
|
5
|
+
* Metadata: builtin-definitions.js.
|
|
6
|
+
* @param {*} source
|
|
7
|
+
* @param {string} target
|
|
8
|
+
* @returns {number}
|
|
9
|
+
*/
|
|
10
|
+
export function REPEATABLESUM() {
|
|
11
|
+
throw new Error('REPEATABLESUM requires record-tree context from createRecordEvaluator().');
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { REPEATABLEVALUES_METADATA } from '../builtin-definitions.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @builtin REPEATABLEVALUES
|
|
5
|
+
* Metadata: builtin-definitions.js.
|
|
6
|
+
* @param {*} source
|
|
7
|
+
* @param {string} target
|
|
8
|
+
* @returns {Array}
|
|
9
|
+
*/
|
|
10
|
+
export function REPEATABLEVALUES() {
|
|
11
|
+
throw new Error('REPEATABLEVALUES requires record-tree context from createRecordEvaluator().');
|
|
12
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { analyzeRepeatableCollections } from '../utilities/repeatable-collections.js';
|
|
1
2
|
import { runExpression } from './evaluator.js';
|
|
2
3
|
import { ContextResolver } from './context-resolver.js';
|
|
3
4
|
import { WarningSystem } from './warning-system.js';
|
|
@@ -107,32 +108,59 @@ function buildScopedContext(
|
|
|
107
108
|
contextResolver,
|
|
108
109
|
warningSystem,
|
|
109
110
|
expressionCode,
|
|
110
|
-
diagnostics
|
|
111
|
+
diagnostics,
|
|
112
|
+
preparation = null
|
|
111
113
|
) {
|
|
112
114
|
const ctx = { ...helpers };
|
|
113
115
|
|
|
114
|
-
const referencedFields = extractStaticFieldReferences(expressionCode);
|
|
116
|
+
const referencedFields = preparation?.references ?? extractStaticFieldReferences(expressionCode);
|
|
117
|
+
const access = (fieldName) => {
|
|
118
|
+
if (
|
|
119
|
+
!preparation ||
|
|
120
|
+
(!contextResolver.elementOwnership.has(fieldName) && !referencedFields.has(fieldName))
|
|
121
|
+
)
|
|
122
|
+
return contextResolver.resolveFieldAccess(executionContext, fieldName);
|
|
123
|
+
if (!preparation.access.has(fieldName))
|
|
124
|
+
preparation.access.set(
|
|
125
|
+
fieldName,
|
|
126
|
+
contextResolver.resolveFieldAccess(executionContext, fieldName)
|
|
127
|
+
);
|
|
128
|
+
return preparation.access.get(fieldName);
|
|
129
|
+
};
|
|
115
130
|
const restrictedAccessedFields = [];
|
|
116
131
|
const notFoundAccessedFields = [];
|
|
117
132
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
if (preparation) {
|
|
134
|
+
// The record evaluator has one immutable schema. Enumerate permitted bindings once,
|
|
135
|
+
// then read their CURRENT values on every execution (including all EVAL-accessible fields).
|
|
136
|
+
preparation.bindings ??= [...contextResolver.fieldOwnership.keys()]
|
|
137
|
+
.filter((name) => access(name) === 'accessible')
|
|
138
|
+
.map((name) => [name, `$${name}`]);
|
|
139
|
+
for (const [name, identifier] of preparation.bindings)
|
|
140
|
+
if (Object.hasOwn(values, name)) ctx[identifier] = values[name];
|
|
141
|
+
} else {
|
|
142
|
+
for (const [fieldName, value] of Object.entries(values)) {
|
|
143
|
+
const accessLevel = access(fieldName);
|
|
144
|
+
|
|
145
|
+
if (accessLevel === 'accessible') {
|
|
146
|
+
ctx[`$${fieldName}`] = value;
|
|
147
|
+
} else if (accessLevel === 'restricted') {
|
|
148
|
+
if (referencedFields.has(fieldName)) {
|
|
149
|
+
ctx[`$${fieldName}`] = undefined;
|
|
150
|
+
restrictedAccessedFields.push(fieldName);
|
|
151
|
+
}
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
for (const fieldName of referencedFields) {
|
|
132
|
-
if (!(fieldName in values)) {
|
|
133
|
-
const accessLevel =
|
|
134
|
-
if (accessLevel === 'not_found') {
|
|
157
|
+
if (preparation || !(fieldName in values)) {
|
|
158
|
+
const accessLevel = access(fieldName);
|
|
159
|
+
if (accessLevel === 'not_found' && !(fieldName in values)) {
|
|
135
160
|
notFoundAccessedFields.push(fieldName);
|
|
161
|
+
} else if (accessLevel === 'restricted') {
|
|
162
|
+
ctx[`$${fieldName}`] = undefined;
|
|
163
|
+
restrictedAccessedFields.push(fieldName);
|
|
136
164
|
}
|
|
137
165
|
}
|
|
138
166
|
}
|
|
@@ -255,7 +283,7 @@ function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics, dia
|
|
|
255
283
|
});
|
|
256
284
|
}
|
|
257
285
|
|
|
258
|
-
function evaluateCalculatedField(
|
|
286
|
+
export function evaluateCalculatedField(
|
|
259
287
|
field,
|
|
260
288
|
schema,
|
|
261
289
|
values,
|
|
@@ -264,14 +292,27 @@ function evaluateCalculatedField(
|
|
|
264
292
|
resolver,
|
|
265
293
|
warnings,
|
|
266
294
|
runtimeDiagnostics,
|
|
267
|
-
diagnostics
|
|
295
|
+
diagnostics,
|
|
296
|
+
execution = {}
|
|
268
297
|
) {
|
|
269
298
|
if (!field?.data_name || !field.calculate) {
|
|
270
299
|
return { changed: false, hasError: false };
|
|
271
300
|
}
|
|
272
301
|
|
|
273
302
|
try {
|
|
274
|
-
|
|
303
|
+
// Factory-owned metadata only: never retain instance values, diagnostics or permissions
|
|
304
|
+
// from a different schema. The ordinary engine path continues without this private cache.
|
|
305
|
+
let preparation = execution.preparationCache?.get(field.data_name);
|
|
306
|
+
if (!preparation && execution.preparationCache) {
|
|
307
|
+
preparation = {
|
|
308
|
+
context: buildCalculationExecutionContext(schema, field.data_name, resolver),
|
|
309
|
+
references: extractStaticFieldReferences(field.calculate),
|
|
310
|
+
access: new Map(),
|
|
311
|
+
};
|
|
312
|
+
execution.preparationCache.set(field.data_name, preparation);
|
|
313
|
+
}
|
|
314
|
+
const executionContext =
|
|
315
|
+
preparation?.context ?? buildCalculationExecutionContext(schema, field.data_name, resolver);
|
|
275
316
|
const context = buildScopedContext(
|
|
276
317
|
values,
|
|
277
318
|
helpers,
|
|
@@ -279,30 +320,52 @@ function evaluateCalculatedField(
|
|
|
279
320
|
resolver,
|
|
280
321
|
warnings,
|
|
281
322
|
field.calculate,
|
|
282
|
-
diagnostics
|
|
323
|
+
diagnostics,
|
|
324
|
+
preparation
|
|
283
325
|
);
|
|
284
326
|
const previousValue = values[field.data_name];
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
327
|
+
const collection =
|
|
328
|
+
execution.expression === undefined
|
|
329
|
+
? analyzeRepeatableCollections(field.calculate, resolver, field.data_name)
|
|
330
|
+
: null;
|
|
331
|
+
if (collection?.issues.length) {
|
|
332
|
+
values[field.data_name] = null;
|
|
333
|
+
for (const issue of collection.issues)
|
|
334
|
+
diagnostics?.record(field.data_name, { ...issue, phase: 'validation' });
|
|
335
|
+
return { changed: previousValue !== null, hasError: true };
|
|
336
|
+
}
|
|
337
|
+
const nextValue = runExpression(
|
|
338
|
+
execution.expression ?? collection?.expression ?? field.calculate,
|
|
339
|
+
context,
|
|
340
|
+
securityConfig,
|
|
341
|
+
false,
|
|
342
|
+
schema,
|
|
343
|
+
{
|
|
344
|
+
sourceExpression: field.calculate,
|
|
345
|
+
compileCache: execution.compileCache,
|
|
346
|
+
requireResult: true,
|
|
347
|
+
fieldName: field.data_name,
|
|
348
|
+
suppressConsoleWarning:
|
|
349
|
+
diagnostics?.consoleOverride !== undefined || Array.isArray(runtimeDiagnostics),
|
|
293
350
|
onDiagnostic: (diagnostic) => diagnostics?.record(field.data_name, diagnostic),
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
351
|
+
evalReporting: {
|
|
352
|
+
resolveReference: execution.resolveReference,
|
|
353
|
+
suppressConsole: diagnostics?.consoleOverride !== undefined,
|
|
354
|
+
onDiagnostic: (diagnostic) => diagnostics?.record(field.data_name, diagnostic),
|
|
355
|
+
},
|
|
356
|
+
onError: (error) => {
|
|
357
|
+
pushRuntimeDiagnostic(
|
|
358
|
+
runtimeDiagnostics,
|
|
359
|
+
field.data_name,
|
|
360
|
+
error instanceof Error && error.message
|
|
361
|
+
? error.message
|
|
362
|
+
: 'Unknown calculation runtime error.'
|
|
363
|
+
);
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
);
|
|
305
367
|
values[field.data_name] = nextValue;
|
|
368
|
+
execution.writeValue?.(nextValue);
|
|
306
369
|
|
|
307
370
|
return {
|
|
308
371
|
changed: !areStructuredValuesEqual(previousValue, nextValue),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { flattenFields } from '../utilities/field-helpers.js';
|
|
2
|
+
import { ContextResolver } from './context-resolver.js';
|
|
3
|
+
|
|
4
|
+
export const CONDITION_PROPERTIES = [
|
|
5
|
+
'visible_conditions',
|
|
6
|
+
'required_conditions',
|
|
7
|
+
'read_only_conditions',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/** Visit every reference, independently of boolean short-circuiting. */
|
|
11
|
+
export function forEachConditionReference(conditions, visit) {
|
|
12
|
+
if (Array.isArray(conditions)) {
|
|
13
|
+
conditions.forEach((condition) => forEachConditionReference(condition, visit));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!conditions || typeof conditions !== 'object') return;
|
|
17
|
+
if (conditions.field_id) visit(conditions);
|
|
18
|
+
if (conditions.and) forEachConditionReference(conditions.and, visit);
|
|
19
|
+
if (conditions.or) forEachConditionReference(conditions.or, visit);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Engine-private index, built once and shared by all three condition maps. */
|
|
23
|
+
export function createConditionContext(schema, contextResolver = null, warningSystem = null) {
|
|
24
|
+
const fields = flattenFields(schema.elements);
|
|
25
|
+
const allFields = Object.create(null);
|
|
26
|
+
for (const field of fields) {
|
|
27
|
+
if (field.key) allFields[field.key] = field;
|
|
28
|
+
if (field.data_name) allFields[field.data_name] = field;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
fields,
|
|
32
|
+
allFields,
|
|
33
|
+
contextResolver: contextResolver || new ContextResolver(schema),
|
|
34
|
+
warningSystem,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getConditionReferenceIssues(owner, property, conditions, allFields, resolver) {
|
|
39
|
+
const issues = [];
|
|
40
|
+
const executionContext = {
|
|
41
|
+
type: 'condition',
|
|
42
|
+
fieldName: owner.data_name,
|
|
43
|
+
conditionProperty: property,
|
|
44
|
+
parentPath: [...(resolver.elementOwnership.get(owner.data_name)?.parentPath || [])],
|
|
45
|
+
};
|
|
46
|
+
forEachConditionReference(conditions, (condition) => {
|
|
47
|
+
const reference = condition.field_id;
|
|
48
|
+
const field = allFields[reference];
|
|
49
|
+
if (!field) {
|
|
50
|
+
issues.push({
|
|
51
|
+
reason: 'not_found',
|
|
52
|
+
reference,
|
|
53
|
+
executionContext,
|
|
54
|
+
message: `Condition references unknown field "${reference}" in field "${owner.data_name}"`,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const access = resolver.resolveFieldAccessInfo(executionContext, field.data_name);
|
|
59
|
+
if (access.level === 'accessible') return;
|
|
60
|
+
const ownerPath = resolver.formatRepeatableSectionPath(executionContext.parentPath);
|
|
61
|
+
const referencePath = resolver.formatRepeatableSectionPath(access.fieldInfo?.parentPath || []);
|
|
62
|
+
issues.push({
|
|
63
|
+
reason: access.level === 'not_found' ? 'not_found' : 'restricted',
|
|
64
|
+
reference: field.data_name,
|
|
65
|
+
executionContext,
|
|
66
|
+
message: `Field "${owner.data_name}" ${property} references "${reference}" outside its record scope (owner: ${ownerPath}; reference: ${referencePath})`,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
return issues;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function assertConditionReferences(conditions, owner, property, context) {
|
|
73
|
+
const issues = getConditionReferenceIssues(
|
|
74
|
+
owner,
|
|
75
|
+
property,
|
|
76
|
+
conditions,
|
|
77
|
+
context.allFields,
|
|
78
|
+
context.contextResolver
|
|
79
|
+
);
|
|
80
|
+
if (!issues.length) return;
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
const warning = context.contextResolver.generateAccessWarning(
|
|
83
|
+
issue.executionContext,
|
|
84
|
+
issue.reference,
|
|
85
|
+
issue.reason
|
|
86
|
+
);
|
|
87
|
+
context.warningSystem?.emitWarning({ ...warning, message: issue.message });
|
|
88
|
+
}
|
|
89
|
+
throw new Error(issues.map((issue) => issue.message).join('\n'));
|
|
90
|
+
}
|