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 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.5.1",
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: 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,
@@ -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 = 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
+ }
@@ -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
- 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);
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 = contextResolver.resolveFieldAccess(executionContext, fieldName);
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
- const executionContext = buildCalculationExecutionContext(schema, field.data_name, resolver);
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 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,
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
- 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
- });
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
+ }