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
@@ -0,0 +1,19 @@
1
+ import { copyRecordData } from '../utilities/record-data.js';
2
+
3
+ /** Group pending field identities by complete row path; retain no values or proposal history. */
4
+ export function createRecordFreshness(schemaRevision, changes = []) {
5
+ const affected = new Map();
6
+ for (const { instancePath, fieldName, fieldKey } of changes) {
7
+ if (!instancePath.length) continue;
8
+ const key = JSON.stringify(instancePath);
9
+ if (!affected.has(key))
10
+ affected.set(key, { instancePath: copyRecordData(instancePath), fields: [] });
11
+ affected.get(key).fields.push({ fieldName, fieldKey });
12
+ }
13
+ return {
14
+ version: 1,
15
+ status: affected.size ? 'pending' : 'current',
16
+ schemaRevision,
17
+ affected: [...affected.values()],
18
+ };
19
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @typedef {Object} SubmissionFinding
3
+ * @property {'required'|'error'|'custom'} kind
4
+ * @property {string} message
5
+ * @property {Object[]} instancePath Complete row ancestry; [] for root.
6
+ * @property {string} [fieldName] Absent for scope-level custom findings.
7
+ * @property {string} [fieldKey]
8
+ *
9
+ * @typedef {Object} SubmissionValidationSummary
10
+ * @property {boolean} hasErrors
11
+ * @property {SubmissionFinding[]} requiredFieldErrors
12
+ * @property {SubmissionFinding[]} generalErrors
13
+ * @property {SubmissionFinding[]} scopeIssues
14
+ * @property {SubmissionFinding[]} orderedIssues
15
+ */
16
+
17
+ import { prepareRecordContext, visitRecordScopes } from './record-context.js';
18
+
19
+ const hasText = (value) => typeof value === 'string' && value.trim().length > 0;
20
+ const hasChoice = (entries) =>
21
+ Array.isArray(entries) &&
22
+ entries.some(
23
+ (entry) => entry && ((entry.value != null && entry.value !== '') || hasText(entry.label))
24
+ );
25
+ const hasOther = (entries) =>
26
+ Array.isArray(entries) &&
27
+ entries.some((entry) =>
28
+ typeof entry === 'string'
29
+ ? hasText(entry)
30
+ : entry && (hasText(entry.label) || (entry.value != null && entry.value !== ''))
31
+ );
32
+
33
+ /** Type-aware input emptiness shared with submission participation; zero/false are values. */
34
+ export function isFieldValueEmpty(field, value) {
35
+ if (!field || value == null) return true;
36
+ switch (field.type) {
37
+ case 'TextField':
38
+ case 'CalculatedField':
39
+ case 'DateField':
40
+ case 'TimeField':
41
+ case 'TitleField':
42
+ case 'StatusField':
43
+ return typeof value === 'string' && !value.trim();
44
+ case 'NumericField':
45
+ return typeof value === 'number' ? false : typeof value === 'string' ? !value.trim() : true;
46
+ case 'SingleChoiceField':
47
+ case 'BooleanField':
48
+ return !hasChoice(value.choice) && !hasOther(value.other);
49
+ case 'MultiChoiceField':
50
+ return !hasChoice(value.choices) && !hasOther(value.other);
51
+ case 'PhotoField':
52
+ case 'VideoField':
53
+ return !Array.isArray(value) || value.length === 0;
54
+ case 'SignatureField':
55
+ return typeof value === 'string'
56
+ ? !value.trim()
57
+ : !(hasText(value?.data) || hasText(value?.url));
58
+ default:
59
+ return Array.isArray(value)
60
+ ? value.length === 0
61
+ : typeof value === 'object'
62
+ ? Object.keys(value).length === 0
63
+ : false;
64
+ }
65
+ }
66
+
67
+ export function validateScope(scope) {
68
+ const findings = [];
69
+ if (!scope.participating) return findings;
70
+ const { record, template, instancePath } = scope;
71
+ for (const field of template.fields) {
72
+ if (['Section', 'BuildingPlanSection', 'RepeatableSection', 'LabelField'].includes(field.type))
73
+ continue;
74
+ if (
75
+ record.visible?.[field.data_name] === false ||
76
+ !(template.chains.get(field.data_name) || []).every(
77
+ (name) => record.visible?.[name] !== false
78
+ )
79
+ )
80
+ continue;
81
+ const message = record.errors?.[field.data_name];
82
+ const required = record.required?.[field.data_name] ?? field.required;
83
+ const base = {
84
+ fieldName: field.data_name,
85
+ fieldKey: field.key || field.data_name,
86
+ instancePath,
87
+ };
88
+ if (message) findings.push({ ...base, kind: 'error', message });
89
+ else if (required && isFieldValueEmpty(field, record.values?.[field.data_name]))
90
+ findings.push({ ...base, kind: 'required', message: 'This field is required' });
91
+ }
92
+ return findings;
93
+ }
94
+
95
+ export function validateSubmissionWithContext(snapshot, context, customIssues = []) {
96
+ const orderedIssues = [];
97
+ const issuesByScope = new Map();
98
+ for (const custom of customIssues) {
99
+ const key = JSON.stringify(custom.instancePath || []);
100
+ if (!issuesByScope.has(key)) issuesByScope.set(key, []);
101
+ issuesByScope.get(key).push(custom);
102
+ }
103
+ visitRecordScopes(snapshot, context, (scope) => {
104
+ orderedIssues.push(...validateScope(scope));
105
+ if (scope.participating)
106
+ for (const custom of issuesByScope.get(JSON.stringify(scope.instancePath)) || [])
107
+ orderedIssues.push({ ...custom, instancePath: scope.instancePath, kind: 'custom' });
108
+ });
109
+ return summarizeSubmissionFindings(orderedIssues);
110
+ }
111
+
112
+ /** Shared ordering/classification for a complete tree or a selected editor scope. */
113
+ export function summarizeSubmissionFindings(orderedIssues) {
114
+ return {
115
+ hasErrors: orderedIssues.length > 0,
116
+ requiredFieldErrors: orderedIssues.filter((issue) => issue.kind === 'required'),
117
+ generalErrors: orderedIssues.filter((issue) => issue.kind === 'error'),
118
+ scopeIssues: orderedIssues.filter((issue) => issue.kind === 'custom'),
119
+ orderedIssues,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Validate an already evaluated runtime record tree; never evaluates or mutates intrinsic errors.
125
+ * @param {{schema: Object, snapshot: Object, validationIssues?: Object[]}} options
126
+ * @returns {SubmissionValidationSummary} Ordered field and scope findings; no freshness assertion.
127
+ */
128
+ export function validateSubmission({ schema, snapshot, validationIssues = [] }) {
129
+ return validateSubmissionWithContext(snapshot, prepareRecordContext(schema), validationIssues);
130
+ }
package/src/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  export { createFormEngine } from './engine/form-engine.js';
2
2
  export { WarningSystem } from './engine/warning-system.js';
3
- export { createStructuredRecord } from './utilities/record-transformer.js';
3
+ export {
4
+ createStructuredRecord,
5
+ createStructuredRecordAsync,
6
+ } from './utilities/record-transformer.js';
4
7
  export {
5
8
  normalizeStructuredRecord,
6
9
  buildFormRecordSnapshot,
@@ -86,3 +89,6 @@ export {
86
89
  applyFormMutationBatch,
87
90
  } from './authoring/form-authoring.js';
88
91
  export { INVALID } from './builtins/event/control/invalid.js';
92
+
93
+ export { createRecordEvaluator } from './engine/record-evaluator.js';
94
+ export { validateSubmission, isFieldValueEmpty } from './engine/submission-validation.js';
@@ -85,6 +85,11 @@ export function validateSchemaWithContext(form, contextResolver) {
85
85
  const duplicateDataNames = new Set();
86
86
  const duplicateKeys = new Set();
87
87
  const errors = [];
88
+ if (
89
+ form.repeatable_recalculation !== undefined &&
90
+ !['automatic', 'confirm'].includes(form.repeatable_recalculation)
91
+ )
92
+ errors.push('form.repeatable_recalculation must be automatic or confirm');
88
93
 
89
94
  const formAIValidation = validateFormAIMetadata(form);
90
95
  if (!formAIValidation.isValid) {
@@ -4,14 +4,14 @@ import {
4
4
  } from '../builtins/builtin-definitions.js';
5
5
  import { DEFAULT_SECURITY_CONFIG, SAFE_SECURITY_CONFIG, SECURITY_MODES } from './config.js';
6
6
 
7
+ const calculationBuiltinNames = new Set(CALCULATION_BUILTIN_DEFINITIONS.map(({ name }) => name));
8
+ const eventBuiltinNames = new Set(EVENT_BUILTIN_DEFINITIONS.map(({ name }) => name));
9
+
7
10
  // Helper function to validate builtin function names in expressions
8
11
  function validateBuiltinNames(expr, includeEventBuiltins = false) {
9
12
  // Read the same immutable catalog used to build the runtime helpers, without
10
13
  // importing their implementations (EVAL itself depends on this validator).
11
- const definitions = includeEventBuiltins
12
- ? EVENT_BUILTIN_DEFINITIONS
13
- : CALCULATION_BUILTIN_DEFINITIONS;
14
- const allowedBuiltins = new Set(definitions.map(({ name }) => name));
14
+ const allowedBuiltins = includeEventBuiltins ? eventBuiltinNames : calculationBuiltinNames;
15
15
 
16
16
  // Extract function calls from the expression
17
17
  // This regex matches function calls like FUNCTIONNAME( allowing for whitespace
@@ -1,3 +1,4 @@
1
+ import { analyzeRepeatableCollections } from './repeatable-collections.js';
1
2
  import { ContextResolver } from '../engine/context-resolver.js';
2
3
  import { flattenFields } from './field-helpers.js';
3
4
 
@@ -378,7 +379,7 @@ export function removeEvalContents(code) {
378
379
  }
379
380
 
380
381
  export function extractStaticFieldReferenceMatches(code) {
381
- const cleanedCode = removeEvalContents(code);
382
+ const cleanedCode = removeEvalContents(analyzeRepeatableCollections(code).scalarSource);
382
383
  const fieldRegex = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
383
384
  const matches = [];
384
385
  let match;
@@ -1,3 +1,5 @@
1
+ import { analyzeRepeatableCollections } from './repeatable-collections.js';
2
+ import { createRecordEvaluator } from '../engine/record-evaluator.js';
1
3
  import { parse } from 'acorn';
2
4
  import { BUILTIN_CONTEXTS } from '../builtins/builtin-metadata.js';
3
5
  import {
@@ -363,6 +365,7 @@ function createSimulationErrorResult(runtimeError, warnings = []) {
363
365
  * @property {string | null} description
364
366
  * @property {string | null} displayStyle
365
367
  * @property {string[]} parentPath
368
+ * @property {boolean} [collectionOnly] Section references are valid only in collection calls.
366
369
  * @property {{
367
370
  * level: 'accessible' | 'restricted',
368
371
  * code: 'main_form' | 'same_repeatable_section' | 'ancestor_repeatable_context' | 'different_repeatable_section',
@@ -396,18 +399,20 @@ export function getCalculationBuiltinCatalog() {
396
399
  * @param {Object} options.schema
397
400
  * @param {string} options.fieldDataName
398
401
  * @param {boolean} [options.includeRestricted=false]
402
+ * @param {boolean} [options.includeCollections=false] Include collection-only section references.
399
403
  * @returns {CalculationReferenceDefinition[]}
400
404
  */
401
405
  export function getCalculationReferenceCatalog({
402
406
  schema,
403
407
  fieldDataName,
404
408
  includeRestricted = false,
409
+ includeCollections = false,
405
410
  }) {
406
411
  const form = normalizeCalculationFormSchema(schema);
407
412
  const resolver = new ContextResolver(form);
408
413
  const executionContext = buildCalculationExecutionContext(form, fieldDataName, resolver);
409
414
 
410
- return flattenFields(form.elements)
415
+ const scalarReferences = flattenFields(form.elements)
411
416
  .filter((field) => field && typeof field === 'object')
412
417
  .filter((field) => field.data_name && field.data_name !== fieldDataName)
413
418
  .filter(
@@ -419,6 +424,33 @@ export function getCalculationReferenceCatalog({
419
424
  .map((field) => toCalculationReferenceDefinition(field, resolver, executionContext))
420
425
  .filter((reference) => reference !== null)
421
426
  .filter((reference) => includeRestricted || reference.access.level === 'accessible');
427
+ if (!includeCollections) return scalarReferences;
428
+ const collections = [...resolver.repeatableSectionTree.values()]
429
+ .map((info) => {
430
+ const accessible =
431
+ ContextResolver.isPrefix(executionContext.parentPath, info.parentPath) ||
432
+ ContextResolver.isPrefix(info.parentPath, executionContext.parentPath);
433
+ return {
434
+ key: info.preferredKey,
435
+ reference: `$${info.field.data_name}`,
436
+ dataName: info.field.data_name,
437
+ label: info.field.label,
438
+ type: 'RepeatableSection',
439
+ description: info.field.description,
440
+ displayStyle: null,
441
+ parentPath: [...info.parentPath],
442
+ collectionOnly: true,
443
+ access: {
444
+ level: accessible ? 'accessible' : 'restricted',
445
+ code: accessible ? 'same_repeatable_section' : 'different_repeatable_section',
446
+ message: accessible ? null : 'Collection is outside the record ancestry.',
447
+ suggestion:
448
+ 'Use this reference only as the first argument of REPEATABLEVALUES or REPEATABLESUM.',
449
+ },
450
+ };
451
+ })
452
+ .filter((reference) => includeRestricted || reference.access.level === 'accessible');
453
+ return [...scalarReferences, ...collections];
422
454
  }
423
455
 
424
456
  /**
@@ -434,6 +466,7 @@ export function getCalculationReferenceCatalog({
434
466
  * issues: CalculationExpressionIssue[],
435
467
  * usedBuiltins: string[],
436
468
  * referencedFields: string[],
469
+ * collectionReferences: Array<{builtinName: string, sectionDataName: string, targetDataName: string, startIndex: number, endIndex: number}>,
437
470
  * }}
438
471
  */
439
472
  export function analyzeCalculationExpression({
@@ -651,6 +684,13 @@ export function analyzeCalculationExpression({
651
684
  );
652
685
  }
653
686
 
687
+ const collectionAnalysis = analyzeRepeatableCollections(
688
+ normalizedExpression,
689
+ resolver,
690
+ fieldDataName
691
+ );
692
+ for (const issue of collectionAnalysis.issues)
693
+ addIssue(issues, issueKeys, createIssue({ ...issue, severity: 'error' }));
654
694
  for (const reference of extractStaticFieldReferenceMatches(normalizedExpression)) {
655
695
  if (!referencedFieldNames.has(reference.fieldName)) {
656
696
  referencedFieldNames.add(reference.fieldName);
@@ -732,6 +772,13 @@ export function analyzeCalculationExpression({
732
772
  issues,
733
773
  usedBuiltins,
734
774
  referencedFields,
775
+ collectionReferences: collectionAnalysis.calls.map((call) => ({
776
+ builtinName: call.name,
777
+ sectionDataName: call.section,
778
+ targetDataName: call.target,
779
+ startIndex: call.index,
780
+ endIndex: call.index + call.length,
781
+ })),
735
782
  };
736
783
  }
737
784
 
@@ -757,6 +804,7 @@ export function analyzeCalculationExpression({
757
804
  * fieldContext: Object | null,
758
805
  * }>,
759
806
  * },
807
+ * runRecord: (options: {snapshot: Object, instancePath?: Object[], expression?: string}) => Promise<{result: unknown, runtimeError: string|null, warnings: Object[], evaluation?: Object}>,
760
808
  * dispose: () => void,
761
809
  * }}
762
810
  */
@@ -774,6 +822,10 @@ export function createCalculationPreviewSession({
774
822
  return {
775
823
  run: () =>
776
824
  createSimulationErrorResult(`Field '${fieldDataName}' does not exist in the form schema.`),
825
+ runRecord: async () =>
826
+ createSimulationErrorResult(
827
+ `Field '${fieldDataName}' does not exist or is not a CalculatedField.`
828
+ ),
777
829
  dispose: () => {},
778
830
  };
779
831
  }
@@ -781,6 +833,10 @@ export function createCalculationPreviewSession({
781
833
  if (targetField.type !== 'CalculatedField') {
782
834
  return {
783
835
  run: () => createSimulationErrorResult(`Field '${fieldDataName}' is not a CalculatedField.`),
836
+ runRecord: async () =>
837
+ createSimulationErrorResult(
838
+ `Field '${fieldDataName}' does not exist or is not a CalculatedField.`
839
+ ),
784
840
  dispose: () => {},
785
841
  };
786
842
  }
@@ -852,6 +908,43 @@ export function createCalculationPreviewSession({
852
908
  );
853
909
  }
854
910
  },
911
+ runRecord: async ({ snapshot, instancePath = [], expression: nextExpression, ...options }) => {
912
+ if (disposed)
913
+ return createSimulationErrorResult('Calculation preview session has been disposed.');
914
+ if (typeof nextExpression === 'string') targetField.calculate = nextExpression;
915
+ const evaluator = createRecordEvaluator({
916
+ schema: simulationSchema,
917
+ security,
918
+ warningSystem,
919
+ diagnostics: { console: false },
920
+ });
921
+ const evaluation = await evaluator.evaluate(snapshot, { ...options, mode: 'refresh' });
922
+ if (evaluation.status !== 'evaluated')
923
+ return {
924
+ ...createSimulationErrorResult(evaluation.issues[0]?.message || 'Record preview failed.'),
925
+ evaluation,
926
+ };
927
+ let record = evaluation.snapshot;
928
+ for (const segment of instancePath)
929
+ record = record?.repeatable?.[segment.repeatableKey]?.[segment.index];
930
+ if (!record?.values || !Object.hasOwn(record.values, fieldDataName))
931
+ return {
932
+ ...createSimulationErrorResult(
933
+ 'Preview instancePath must identify the record owning the calculated field.'
934
+ ),
935
+ evaluation,
936
+ };
937
+ const runtimeError =
938
+ evaluation.diagnostics.find(
939
+ (item) => item.fieldName === fieldDataName && item.severity === 'error'
940
+ )?.message || null;
941
+ return {
942
+ result: record?.values?.[fieldDataName] ?? null,
943
+ runtimeError,
944
+ warnings: sanitizeCollectedWarnings(warningSystem.getCollectedWarnings()),
945
+ evaluation,
946
+ };
947
+ },
855
948
  dispose: () => {
856
949
  disposed = true;
857
950
  warningSystem.clearCollectedWarnings();
@@ -39,6 +39,7 @@ function toFormEventBuiltinDefinition(definition) {
39
39
  signature: definition.signature,
40
40
  description: definition.description,
41
41
  examples: Object.freeze([...definition.examples]),
42
+ ...(definition.operation ? { operation: definition.operation } : {}),
42
43
  status: getFormEventBuiltinStatus(definition.name),
43
44
  });
44
45
  }
@@ -247,6 +248,9 @@ function cloneCatalogDefinition(definition) {
247
248
  return {
248
249
  ...definition,
249
250
  examples: [...definition.examples],
251
+ ...(definition.operation
252
+ ? { operation: { ...definition.operation, effects: [...definition.operation.effects] } }
253
+ : {}),
250
254
  };
251
255
  }
252
256
 
@@ -0,0 +1,69 @@
1
+ function createContainer(value, seen) {
2
+ if (seen.has(value)) throw new Error('Record data must not contain cyclic references.');
3
+ if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
4
+ throw new Error('Record data must use plain objects, arrays, dates or native media handles.');
5
+ return Array.isArray(value) ? [] : {};
6
+ }
7
+
8
+ /** Clone portable record data without copying immutable binary media into JS memory. */
9
+ export function copyRecordData(value, seen = new Map()) {
10
+ if (typeof value === 'function' || typeof value === 'symbol')
11
+ throw new Error('Record data must not contain functions or symbols.');
12
+ if (value === null || typeof value !== 'object') return value;
13
+ if (value instanceof Date) return new Date(value.getTime());
14
+ if (typeof Blob === 'function' && value instanceof Blob) return value;
15
+ const result = createContainer(value, seen);
16
+ seen.set(value, result);
17
+ for (const key of Object.keys(value)) {
18
+ Object.defineProperty(result, key, {
19
+ value: copyRecordData(value[key], seen),
20
+ enumerable: true,
21
+ writable: true,
22
+ configurable: true,
23
+ });
24
+ }
25
+ seen.delete(value);
26
+ return result;
27
+ }
28
+
29
+ /** Copy privately captured results in bounded batches; never use this to capture live inputs. */
30
+ export async function copyRecordDataCooperatively(value, checkpoint) {
31
+ const seen = new Map();
32
+ const frames = [];
33
+ function begin(source) {
34
+ if (
35
+ source === null ||
36
+ typeof source !== 'object' ||
37
+ source instanceof Date ||
38
+ (typeof Blob === 'function' && source instanceof Blob)
39
+ )
40
+ return copyRecordData(source, seen);
41
+ const target = createContainer(source, seen);
42
+ seen.set(source, target);
43
+ frames.push({ source, target, keys: Object.keys(source), index: 0 });
44
+ return target;
45
+ }
46
+ const result = begin(value);
47
+ let work = 0;
48
+ while (frames.length) {
49
+ const frame = frames[frames.length - 1];
50
+ if (frame.index === frame.keys.length) {
51
+ seen.delete(frame.source);
52
+ frames.pop();
53
+ continue;
54
+ }
55
+ const key = frame.keys[frame.index++];
56
+ Object.defineProperty(frame.target, key, {
57
+ value: begin(frame.source[key]),
58
+ enumerable: true,
59
+ writable: true,
60
+ configurable: true,
61
+ });
62
+ if (++work === 128) {
63
+ work = 0;
64
+ await checkpoint();
65
+ }
66
+ }
67
+ await checkpoint();
68
+ return result;
69
+ }