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
package/README.md
CHANGED
|
@@ -256,3 +256,53 @@ 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.
|
|
292
|
+
|
|
293
|
+
For an open editor, `await evaluator.evaluateScope(snapshot, { instancePath: [] })` evaluates only
|
|
294
|
+
that record. Use complete ancestry to target a child. It reads current supplied ancestors and accepted
|
|
295
|
+
other rows, returning the selected `state`, built-in validation summary, changes and separate
|
|
296
|
+
execution issues/diagnostics without another complete tree. It leaves caller inputs and closed-row
|
|
297
|
+
calculations unchanged. See [the record evaluation contract](analyses/RECORD_EVALUATION_CONTRACT.md).
|
|
298
|
+
|
|
299
|
+
For accepted-snapshot viewing, supply `{ interactionMode: 'readonly' }` to `evaluateScope()`. It
|
|
300
|
+
computes field state and intrinsic/submission findings from supplied values without running
|
|
301
|
+
calculations or applying missing schema defaults. Editing is the default. Whole-tree evaluation and
|
|
302
|
+
submission preparation retain their accepted/refresh policies.
|
|
303
|
+
|
|
304
|
+
Scalar engines accept `mode: 'readonly' | 'edit'` (default `edit`) and expose `getMode()`/`setMode()`.
|
|
305
|
+
Call `eval()` after a host mode change. Events receive authoritative `event.mode`; view-time data
|
|
306
|
+
writes fail before host delegation. Load remains available for reads, alerts and scoped ON/OFF
|
|
307
|
+
registration. Move writable initialization to new/edit handlers when it must run after View → Edit.
|
|
308
|
+
See [the event contract](EVENT_CONTRACT.md) for permissions, lifecycle order and migration guidance.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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/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",
|
|
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/expression-compilation.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 && node tests/record-scope.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
|
".": {
|
|
@@ -50,6 +52,7 @@
|
|
|
50
52
|
}
|
|
51
53
|
],
|
|
52
54
|
"license": "MIT",
|
|
55
|
+
"funding": "https://opencollective.com/form0",
|
|
53
56
|
"bugs": {
|
|
54
57
|
"url": "https://github.com/paqu-io/form0-core/issues"
|
|
55
58
|
},
|
|
@@ -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,
|
|
@@ -238,6 +238,7 @@ export const SETRESULT_METADATA = defineBuiltinMetadata({
|
|
|
238
238
|
|
|
239
239
|
export const ALERT_METADATA = defineBuiltinMetadata({
|
|
240
240
|
name: 'ALERT',
|
|
241
|
+
operation: { type: 'UI_OPERATION', effects: ['presentation'] },
|
|
241
242
|
category: 'event',
|
|
242
243
|
signature: "ALERT(title, message = '')",
|
|
243
244
|
description: 'Display an alert from a form event handler.',
|
|
@@ -247,6 +248,7 @@ export const ALERT_METADATA = defineBuiltinMetadata({
|
|
|
247
248
|
|
|
248
249
|
export const SETVALUE_METADATA = defineBuiltinMetadata({
|
|
249
250
|
name: 'SETVALUE',
|
|
251
|
+
operation: { type: 'FIELD_OPERATION', effects: ['record-write'] },
|
|
250
252
|
category: 'event',
|
|
251
253
|
signature: 'SETVALUE(fieldDataName, valueToSet)',
|
|
252
254
|
description: 'Set a field value from a form event handler.',
|
|
@@ -256,6 +258,7 @@ export const SETVALUE_METADATA = defineBuiltinMetadata({
|
|
|
256
258
|
|
|
257
259
|
export const ON_METADATA = defineBuiltinMetadata({
|
|
258
260
|
name: 'ON',
|
|
261
|
+
operation: { type: 'EVENT_OPERATION', effects: ['listeners'] },
|
|
259
262
|
category: 'event',
|
|
260
263
|
signature: 'ON(eventType, fieldKeyOrCallback, callback)',
|
|
261
264
|
description: 'Register an event handler within form event code.',
|
|
@@ -265,6 +268,7 @@ export const ON_METADATA = defineBuiltinMetadata({
|
|
|
265
268
|
|
|
266
269
|
export const OFF_METADATA = defineBuiltinMetadata({
|
|
267
270
|
name: 'OFF',
|
|
271
|
+
operation: { type: 'EVENT_OPERATION', effects: ['listeners'] },
|
|
268
272
|
category: 'event',
|
|
269
273
|
signature: 'OFF(eventType, fieldKeyOrCallback, callback)',
|
|
270
274
|
description: 'Remove event handlers within form event code.',
|
|
@@ -274,6 +278,7 @@ export const OFF_METADATA = defineBuiltinMetadata({
|
|
|
274
278
|
|
|
275
279
|
export const INVALID_METADATA = defineBuiltinMetadata({
|
|
276
280
|
name: 'INVALID',
|
|
281
|
+
operation: { type: 'VALIDATION_OPERATION', effects: ['validation'] },
|
|
277
282
|
category: 'event',
|
|
278
283
|
signature: 'INVALID(message)',
|
|
279
284
|
description:
|
|
@@ -284,6 +289,26 @@ export const INVALID_METADATA = defineBuiltinMetadata({
|
|
|
284
289
|
contexts: [BUILTIN_CONTEXTS.EVENT],
|
|
285
290
|
});
|
|
286
291
|
|
|
292
|
+
export const REPEATABLEVALUES_METADATA = defineBuiltinMetadata({
|
|
293
|
+
name: 'REPEATABLEVALUES',
|
|
294
|
+
category: 'repeatable',
|
|
295
|
+
signature: 'REPEATABLEVALUES($repeatable_data_name, target_data_name)',
|
|
296
|
+
description:
|
|
297
|
+
'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.',
|
|
298
|
+
examples: ["REPEATABLEVALUES($tasks, 'cost')"],
|
|
299
|
+
contexts: [BUILTIN_CONTEXTS.CALCULATION],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
export const REPEATABLESUM_METADATA = defineBuiltinMetadata({
|
|
303
|
+
name: 'REPEATABLESUM',
|
|
304
|
+
category: 'repeatable',
|
|
305
|
+
signature: 'REPEATABLESUM($repeatable_data_name, target_data_name)',
|
|
306
|
+
description:
|
|
307
|
+
'Sum finite NumericField or CalculatedField row values in record-tree context. Skips missing values, returns zero for no rows, and never coerces strings.',
|
|
308
|
+
examples: ["REPEATABLESUM($tasks, 'cost')"],
|
|
309
|
+
contexts: [BUILTIN_CONTEXTS.CALCULATION],
|
|
310
|
+
});
|
|
311
|
+
|
|
287
312
|
export const BUILTIN_DEFINITIONS = Object.freeze([
|
|
288
313
|
IF_METADATA,
|
|
289
314
|
AND_METADATA,
|
|
@@ -315,6 +340,8 @@ export const BUILTIN_DEFINITIONS = Object.freeze([
|
|
|
315
340
|
ON_METADATA,
|
|
316
341
|
OFF_METADATA,
|
|
317
342
|
INVALID_METADATA,
|
|
343
|
+
REPEATABLEVALUES_METADATA,
|
|
344
|
+
REPEATABLESUM_METADATA,
|
|
318
345
|
]);
|
|
319
346
|
|
|
320
347
|
function filterDefinitionsByContext(context) {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @property {string} description
|
|
9
9
|
* @property {string[]} examples
|
|
10
10
|
* @property {BuiltinContext[]} contexts
|
|
11
|
+
* @property {{type: string, effects: string[]}} [operation] Declared descriptor effects.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
export const BUILTIN_CONTEXTS = Object.freeze({
|
|
@@ -26,6 +27,14 @@ export function defineBuiltinMetadata(definition) {
|
|
|
26
27
|
...definition,
|
|
27
28
|
examples: Object.freeze([...(definition.examples ?? [])]),
|
|
28
29
|
contexts: Object.freeze([...(definition.contexts ?? [])]),
|
|
30
|
+
...(definition.operation
|
|
31
|
+
? {
|
|
32
|
+
operation: Object.freeze({
|
|
33
|
+
...definition.operation,
|
|
34
|
+
effects: Object.freeze([...definition.operation.effects]),
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
37
|
+
: {}),
|
|
29
38
|
});
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -40,5 +49,8 @@ export function cloneBuiltinMetadata(definition) {
|
|
|
40
49
|
...definition,
|
|
41
50
|
examples: [...definition.examples],
|
|
42
51
|
contexts: [...definition.contexts],
|
|
52
|
+
...(definition.operation
|
|
53
|
+
? { operation: { ...definition.operation, effects: [...definition.operation.effects] } }
|
|
54
|
+
: {}),
|
|
43
55
|
};
|
|
44
56
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Private conservative fast path for scalar expressions in a schema-bound record evaluator.
|
|
2
|
+
// Unknown calls, EVAL, arguments, member access and complex JavaScript retain full bindings.
|
|
3
|
+
import { parse } from 'acorn';
|
|
4
|
+
import { calculationBuiltins } from '../builtins/registry.js';
|
|
5
|
+
// These two canonical operations never consult ambient field context or invoke author callbacks.
|
|
6
|
+
// Other builtins keep full bindings, including math functions that can be overridden by a host.
|
|
7
|
+
const pureCalls = new Set(['SETRESULT', 'IF']);
|
|
8
|
+
export function prepareScalarBindings(code) {
|
|
9
|
+
const names = new Set(),
|
|
10
|
+
calls = new Set();
|
|
11
|
+
function visit(node) {
|
|
12
|
+
if (!node) return true;
|
|
13
|
+
switch (node.type) {
|
|
14
|
+
case 'Program':
|
|
15
|
+
return node.body.length === 1 && visit(node.body[0]);
|
|
16
|
+
case 'ExpressionStatement':
|
|
17
|
+
return visit(node.expression);
|
|
18
|
+
case 'Literal':
|
|
19
|
+
return !node.regex;
|
|
20
|
+
case 'Identifier':
|
|
21
|
+
if (!node.name.startsWith('$') && !['undefined', 'NaN', 'Infinity'].includes(node.name))
|
|
22
|
+
return false;
|
|
23
|
+
names.add(node.name);
|
|
24
|
+
return true;
|
|
25
|
+
case 'UnaryExpression':
|
|
26
|
+
return node.operator !== 'delete' && visit(node.argument);
|
|
27
|
+
case 'BinaryExpression':
|
|
28
|
+
case 'LogicalExpression':
|
|
29
|
+
return visit(node.left) && visit(node.right);
|
|
30
|
+
case 'ConditionalExpression':
|
|
31
|
+
return visit(node.test) && visit(node.consequent) && visit(node.alternate);
|
|
32
|
+
case 'CallExpression':
|
|
33
|
+
if (node.optional || node.callee.type !== 'Identifier' || !pureCalls.has(node.callee.name))
|
|
34
|
+
return false;
|
|
35
|
+
names.add(node.callee.name);
|
|
36
|
+
calls.add(node.callee.name);
|
|
37
|
+
return node.arguments.every(visit);
|
|
38
|
+
default:
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return visit(parse(code, { ecmaVersion: 'latest' }))
|
|
44
|
+
? { names, calls, fieldCount: [...names].filter((name) => name.startsWith('$')).length }
|
|
45
|
+
: null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function selectScalarBindings(preparation, values, helpers) {
|
|
51
|
+
const plan = preparation.scalar;
|
|
52
|
+
if (!plan) return null;
|
|
53
|
+
for (const name of plan.calls) if (helpers[name] !== calculationBuiltins[name]) return null;
|
|
54
|
+
const bindings = (preparation.scalarBindings ??= preparation.bindings.filter(([, identifier]) =>
|
|
55
|
+
plan.names.has(identifier)
|
|
56
|
+
));
|
|
57
|
+
// Missing/restricted names can fall through to host globals in legacy JavaScript.
|
|
58
|
+
// Specialize only when every field identifier resolves to a captured primitive value.
|
|
59
|
+
if (bindings.length !== plan.fieldCount) return null;
|
|
60
|
+
for (const [name] of bindings)
|
|
61
|
+
if (
|
|
62
|
+
!Object.hasOwn(values, name) ||
|
|
63
|
+
(values[name] !== null &&
|
|
64
|
+
!['undefined', 'string', 'number', 'boolean', 'bigint'].includes(typeof values[name]))
|
|
65
|
+
)
|
|
66
|
+
return null;
|
|
67
|
+
for (const name of plan.names)
|
|
68
|
+
if (
|
|
69
|
+
Object.hasOwn(helpers, name) &&
|
|
70
|
+
helpers[name] !== calculationBuiltins[name] &&
|
|
71
|
+
helpers[name] !== null &&
|
|
72
|
+
!['undefined', 'string', 'number', 'boolean', 'bigint'].includes(typeof helpers[name])
|
|
73
|
+
)
|
|
74
|
+
return null;
|
|
75
|
+
return { names: plan.names, bindings };
|
|
76
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { prepareScalarBindings, selectScalarBindings } from './calculation-bindings.js';
|
|
2
|
+
import { analyzeRepeatableCollections } from '../utilities/repeatable-collections.js';
|
|
1
3
|
import { runExpression } from './evaluator.js';
|
|
2
4
|
import { ContextResolver } from './context-resolver.js';
|
|
3
5
|
import { WarningSystem } from './warning-system.js';
|
|
@@ -107,32 +109,70 @@ function buildScopedContext(
|
|
|
107
109
|
contextResolver,
|
|
108
110
|
warningSystem,
|
|
109
111
|
expressionCode,
|
|
110
|
-
diagnostics
|
|
112
|
+
diagnostics,
|
|
113
|
+
preparation = null
|
|
111
114
|
) {
|
|
112
|
-
|
|
115
|
+
let ctx;
|
|
113
116
|
|
|
114
|
-
const referencedFields = extractStaticFieldReferences(expressionCode);
|
|
117
|
+
const referencedFields = preparation?.references ?? extractStaticFieldReferences(expressionCode);
|
|
118
|
+
const access = (fieldName) => {
|
|
119
|
+
if (
|
|
120
|
+
!preparation ||
|
|
121
|
+
(!contextResolver.elementOwnership.has(fieldName) && !referencedFields.has(fieldName))
|
|
122
|
+
)
|
|
123
|
+
return contextResolver.resolveFieldAccess(executionContext, fieldName);
|
|
124
|
+
if (!preparation.access.has(fieldName))
|
|
125
|
+
preparation.access.set(
|
|
126
|
+
fieldName,
|
|
127
|
+
contextResolver.resolveFieldAccess(executionContext, fieldName)
|
|
128
|
+
);
|
|
129
|
+
return preparation.access.get(fieldName);
|
|
130
|
+
};
|
|
115
131
|
const restrictedAccessedFields = [];
|
|
116
132
|
const notFoundAccessedFields = [];
|
|
117
133
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
if (preparation) {
|
|
135
|
+
// The record evaluator has one immutable schema. Enumerate permitted bindings once,
|
|
136
|
+
// then read their CURRENT values on every execution (including all EVAL-accessible fields).
|
|
137
|
+
preparation.bindings ??= [...contextResolver.fieldOwnership.keys()]
|
|
138
|
+
.filter((name) => access(name) === 'accessible')
|
|
139
|
+
.map((name) => [name, `$${name}`]);
|
|
140
|
+
// Use fewer parameters only when syntax and current primitive bindings prove it safe.
|
|
141
|
+
// Keep the full context for dynamic expressions, helper overrides and object inputs.
|
|
142
|
+
const scalar = selectScalarBindings(preparation, values, helpers);
|
|
143
|
+
ctx = scalar
|
|
144
|
+
? Object.fromEntries(
|
|
145
|
+
[...scalar.names]
|
|
146
|
+
.filter((name) => Object.hasOwn(helpers, name))
|
|
147
|
+
.map((name) => [name, helpers[name]])
|
|
148
|
+
)
|
|
149
|
+
: { ...helpers };
|
|
150
|
+
for (const [name, identifier] of scalar?.bindings ?? preparation.bindings)
|
|
151
|
+
if (Object.hasOwn(values, name)) ctx[identifier] = values[name];
|
|
152
|
+
} else {
|
|
153
|
+
ctx = { ...helpers };
|
|
154
|
+
for (const [fieldName, value] of Object.entries(values)) {
|
|
155
|
+
const accessLevel = access(fieldName);
|
|
156
|
+
|
|
157
|
+
if (accessLevel === 'accessible') {
|
|
158
|
+
ctx[`$${fieldName}`] = value;
|
|
159
|
+
} else if (accessLevel === 'restricted') {
|
|
160
|
+
if (referencedFields.has(fieldName)) {
|
|
161
|
+
ctx[`$${fieldName}`] = undefined;
|
|
162
|
+
restrictedAccessedFields.push(fieldName);
|
|
163
|
+
}
|
|
127
164
|
}
|
|
128
165
|
}
|
|
129
166
|
}
|
|
130
167
|
|
|
131
168
|
for (const fieldName of referencedFields) {
|
|
132
|
-
if (!(fieldName in values)) {
|
|
133
|
-
const accessLevel =
|
|
134
|
-
if (accessLevel === 'not_found') {
|
|
169
|
+
if (preparation || !(fieldName in values)) {
|
|
170
|
+
const accessLevel = access(fieldName);
|
|
171
|
+
if (accessLevel === 'not_found' && !(fieldName in values)) {
|
|
135
172
|
notFoundAccessedFields.push(fieldName);
|
|
173
|
+
} else if (accessLevel === 'restricted') {
|
|
174
|
+
ctx[`$${fieldName}`] = undefined;
|
|
175
|
+
restrictedAccessedFields.push(fieldName);
|
|
136
176
|
}
|
|
137
177
|
}
|
|
138
178
|
}
|
|
@@ -255,7 +295,7 @@ function emitDependencyPlanWarnings(plan, warningSystem, runtimeDiagnostics, dia
|
|
|
255
295
|
});
|
|
256
296
|
}
|
|
257
297
|
|
|
258
|
-
function evaluateCalculatedField(
|
|
298
|
+
export function evaluateCalculatedField(
|
|
259
299
|
field,
|
|
260
300
|
schema,
|
|
261
301
|
values,
|
|
@@ -264,14 +304,28 @@ function evaluateCalculatedField(
|
|
|
264
304
|
resolver,
|
|
265
305
|
warnings,
|
|
266
306
|
runtimeDiagnostics,
|
|
267
|
-
diagnostics
|
|
307
|
+
diagnostics,
|
|
308
|
+
execution = {}
|
|
268
309
|
) {
|
|
269
310
|
if (!field?.data_name || !field.calculate) {
|
|
270
311
|
return { changed: false, hasError: false };
|
|
271
312
|
}
|
|
272
313
|
|
|
273
314
|
try {
|
|
274
|
-
|
|
315
|
+
// Factory-owned metadata only: never retain instance values, diagnostics or permissions
|
|
316
|
+
// from a different schema. The ordinary engine path continues without this private cache.
|
|
317
|
+
let preparation = execution.preparationCache?.get(field.data_name);
|
|
318
|
+
if (!preparation && execution.preparationCache) {
|
|
319
|
+
preparation = {
|
|
320
|
+
context: buildCalculationExecutionContext(schema, field.data_name, resolver),
|
|
321
|
+
references: extractStaticFieldReferences(field.calculate),
|
|
322
|
+
access: new Map(),
|
|
323
|
+
scalar: prepareScalarBindings(field.calculate),
|
|
324
|
+
};
|
|
325
|
+
execution.preparationCache.set(field.data_name, preparation);
|
|
326
|
+
}
|
|
327
|
+
const executionContext =
|
|
328
|
+
preparation?.context ?? buildCalculationExecutionContext(schema, field.data_name, resolver);
|
|
275
329
|
const context = buildScopedContext(
|
|
276
330
|
values,
|
|
277
331
|
helpers,
|
|
@@ -279,30 +333,52 @@ function evaluateCalculatedField(
|
|
|
279
333
|
resolver,
|
|
280
334
|
warnings,
|
|
281
335
|
field.calculate,
|
|
282
|
-
diagnostics
|
|
336
|
+
diagnostics,
|
|
337
|
+
preparation
|
|
283
338
|
);
|
|
284
339
|
const previousValue = values[field.data_name];
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
340
|
+
const collection =
|
|
341
|
+
execution.expression === undefined
|
|
342
|
+
? analyzeRepeatableCollections(field.calculate, resolver, field.data_name)
|
|
343
|
+
: null;
|
|
344
|
+
if (collection?.issues.length) {
|
|
345
|
+
values[field.data_name] = null;
|
|
346
|
+
for (const issue of collection.issues)
|
|
347
|
+
diagnostics?.record(field.data_name, { ...issue, phase: 'validation' });
|
|
348
|
+
return { changed: previousValue !== null, hasError: true };
|
|
349
|
+
}
|
|
350
|
+
const nextValue = runExpression(
|
|
351
|
+
execution.expression ?? collection?.expression ?? field.calculate,
|
|
352
|
+
context,
|
|
353
|
+
securityConfig,
|
|
354
|
+
false,
|
|
355
|
+
schema,
|
|
356
|
+
{
|
|
357
|
+
sourceExpression: field.calculate,
|
|
358
|
+
compileCache: execution.compileCache,
|
|
359
|
+
requireResult: true,
|
|
360
|
+
fieldName: field.data_name,
|
|
361
|
+
suppressConsoleWarning:
|
|
362
|
+
diagnostics?.consoleOverride !== undefined || Array.isArray(runtimeDiagnostics),
|
|
293
363
|
onDiagnostic: (diagnostic) => diagnostics?.record(field.data_name, diagnostic),
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
364
|
+
evalReporting: {
|
|
365
|
+
resolveReference: execution.resolveReference,
|
|
366
|
+
suppressConsole: diagnostics?.consoleOverride !== undefined,
|
|
367
|
+
onDiagnostic: (diagnostic) => diagnostics?.record(field.data_name, diagnostic),
|
|
368
|
+
},
|
|
369
|
+
onError: (error) => {
|
|
370
|
+
pushRuntimeDiagnostic(
|
|
371
|
+
runtimeDiagnostics,
|
|
372
|
+
field.data_name,
|
|
373
|
+
error instanceof Error && error.message
|
|
374
|
+
? error.message
|
|
375
|
+
: 'Unknown calculation runtime error.'
|
|
376
|
+
);
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
);
|
|
305
380
|
values[field.data_name] = nextValue;
|
|
381
|
+
execution.writeValue?.(nextValue);
|
|
306
382
|
|
|
307
383
|
return {
|
|
308
384
|
changed: !areStructuredValuesEqual(previousValue, nextValue),
|