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.
Files changed (34) hide show
  1. package/README.md +50 -0
  2. package/package.json +6 -3
  3. package/src/authoring/form-authoring.js +16 -26
  4. package/src/builtins/builtin-definitions.js +27 -0
  5. package/src/builtins/builtin-metadata.js +12 -0
  6. package/src/builtins/control/eval.js +11 -2
  7. package/src/builtins/registry.js +4 -0
  8. package/src/builtins/repeatable/repeatablesum.js +12 -0
  9. package/src/builtins/repeatable/repeatablevalues.js +12 -0
  10. package/src/engine/calculation-bindings.js +76 -0
  11. package/src/engine/calculation.js +114 -38
  12. package/src/engine/evaluator.js +11 -5
  13. package/src/engine/event-operation-scope.js +7 -3
  14. package/src/engine/event-policy.js +61 -0
  15. package/src/engine/event-registration.js +38 -0
  16. package/src/engine/event-source.js +41 -30
  17. package/src/engine/events.js +42 -14
  18. package/src/engine/expression-compilation.js +39 -0
  19. package/src/engine/form-engine.js +57 -22
  20. package/src/engine/managed-events.js +53 -27
  21. package/src/engine/record-context.js +133 -0
  22. package/src/engine/record-evaluator.js +883 -0
  23. package/src/engine/record-freshness.js +19 -0
  24. package/src/engine/submission-validation.js +130 -0
  25. package/src/index.js +7 -1
  26. package/src/schema/schema-validator.js +5 -0
  27. package/src/security/validation.js +4 -4
  28. package/src/utilities/calculation-dependencies.js +2 -1
  29. package/src/utilities/calculation-editor.js +94 -1
  30. package/src/utilities/form-event-editor.js +4 -0
  31. package/src/utilities/record-data.js +69 -0
  32. package/src/utilities/record-transformer.js +110 -39
  33. package/src/utilities/repeatable-collections.js +170 -0
  34. 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.6.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: deepClone(CALCULATION_GUIDANCE),
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 = formContext?.[expression];
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 = formContext?.[result];
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:',
@@ -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
- const ctx = { ...helpers };
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
- for (const [fieldName, value] of Object.entries(values)) {
119
- const accessLevel = contextResolver.resolveFieldAccess(executionContext, fieldName);
120
-
121
- if (accessLevel === 'accessible') {
122
- ctx[`$${fieldName}`] = value;
123
- } else if (accessLevel === 'restricted') {
124
- if (referencedFields.has(fieldName)) {
125
- ctx[`$${fieldName}`] = undefined;
126
- restrictedAccessedFields.push(fieldName);
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 = contextResolver.resolveFieldAccess(executionContext, fieldName);
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
- const executionContext = buildCalculationExecutionContext(schema, field.data_name, resolver);
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 nextValue = runExpression(field.calculate, context, securityConfig, false, schema, {
286
- requireResult: true,
287
- fieldName: field.data_name,
288
- suppressConsoleWarning:
289
- diagnostics?.consoleOverride !== undefined || Array.isArray(runtimeDiagnostics),
290
- onDiagnostic: (diagnostic) => diagnostics?.record(field.data_name, diagnostic),
291
- evalReporting: {
292
- suppressConsole: diagnostics?.consoleOverride !== undefined,
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
- onError: (error) => {
296
- pushRuntimeDiagnostic(
297
- runtimeDiagnostics,
298
- field.data_name,
299
- error instanceof Error && error.message
300
- ? error.message
301
- : 'Unknown calculation runtime error.'
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),