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
@@ -8,6 +8,9 @@ import { recordVersion } from './version-utils.js';
8
8
  import { buildRepeatableMetadata } from './repeatable-helpers.js';
9
9
  import { buildDatasetDescriptors, resolveDatasetRowTitle } from './dataset-descriptors.js';
10
10
 
11
+ const resolveRecordId = (primary, fallback) =>
12
+ primary === 0 ? 0 : primary || (fallback === 0 ? 0 : fallback || null);
13
+
11
14
  const hasMeaningfulValue = (value) => {
12
15
  if (value === null || value === undefined) {
13
16
  return false;
@@ -45,40 +48,8 @@ const hasMeaningfulRecord = (record) => {
45
48
  return false;
46
49
  };
47
50
 
48
- /**
49
- * Create structured record from form engine state with support for unlimited RepeatableSection nesting
50
- * @param {Object} state - Form engine state {values, errors, visible, required, read_only}
51
- * @param {Array} [fields] - Optional flattened fields array from schema (for key mapping)
52
- * @param {Object} options - Additional options for record creation
53
- * - mainRecordId: UUID for the main record
54
- * - childRecordIds: Nested object structure for RepeatableSection child record UUIDs
55
- * - originalElements: Original nested form elements (for proper RepeatableSection nesting)
56
- * @param {string} [id] - DEPRECATED: Use options.mainRecordId instead
57
- * @returns {Object} Structured record ready for submission
58
- *
59
- * @example
60
- * // Simple RepeatableSection:
61
- * options = {
62
- * mainRecordId: "main-uuid",
63
- * childRecordIds: {
64
- * "repA": ["child-1", "child-2", "child-3"]
65
- * }
66
- * }
67
- *
68
- * // Nested RepeatableSections:
69
- * options = {
70
- * mainRecordId: "main-uuid",
71
- * childRecordIds: {
72
- * "repA": {
73
- * _records: ["repA-record-1", "repA-record-2"],
74
- * "repA-record-1": {
75
- * "repB": ["repB-child-1", "repB-child-2"]
76
- * }
77
- * }
78
- * }
79
- * }
80
- */
81
- export function createStructuredRecord(state, fields = null, options = {}, id = null) {
51
+ /** Shared transformation steps; yielding does not change formatting or record metadata. */
52
+ function* structuredRecordSteps(state, fields = null, options = {}, id = null) {
82
53
  // Compute final version values
83
54
  const finalVersion = options.version || 1;
84
55
  const finalChildVersion = options.childVersion || 1;
@@ -276,7 +247,11 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
276
247
  }
277
248
 
278
249
  // Enhanced recursive function to process nested RepeatableSections
279
- const processRepeatableSection = (repInfo, currentPath = [], structuredInstances = null) => {
250
+ const processRepeatableSection = function* (
251
+ repInfo,
252
+ currentPath = [],
253
+ structuredInstances = null
254
+ ) {
280
255
  const pathKey = repInfo.preferredKey;
281
256
 
282
257
  // Get child record IDs from options using the full path
@@ -389,7 +364,7 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
389
364
  ? instanceRepeatable[childRepInfo.preferredKey]
390
365
  : [];
391
366
  const nestedInstances = Array.isArray(rawNestedInstances) ? rawNestedInstances : [];
392
- const childRepeatableArray = processRepeatableSection(
367
+ const childRepeatableArray = yield* processRepeatableSection(
393
368
  childRepInfo,
394
369
  [...currentPath, pathKey],
395
370
  nestedInstances
@@ -405,6 +380,7 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
405
380
  if (hasMeaningfulRecord(childRecord)) {
406
381
  childRecords.push(childRecord);
407
382
  }
383
+ yield;
408
384
  }
409
385
 
410
386
  return childRecords;
@@ -499,7 +475,7 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
499
475
 
500
476
  // Recursively process child RepeatableSections
501
477
  for (const [childDataName, childRepInfo] of repInfo.children) {
502
- const childRepeatableArray = processRepeatableSection(childRepInfo, [
478
+ const childRepeatableArray = yield* processRepeatableSection(childRepInfo, [
503
479
  ...currentPath,
504
480
  pathKey,
505
481
  ]);
@@ -526,6 +502,7 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
526
502
  if (hasMeaningfulRecord(childRecord)) {
527
503
  childRecords.push(childRecord);
528
504
  }
505
+ yield;
529
506
  }
530
507
 
531
508
  return childRecords;
@@ -549,7 +526,7 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
549
526
  : []
550
527
  : null;
551
528
 
552
- const childRecords = processRepeatableSection(repInfo, [], structuredInstances);
529
+ const childRecords = yield* processRepeatableSection(repInfo, [], structuredInstances);
553
530
  const repeatableOutputKey = resolveRepeatableOutputKey(repInfo);
554
531
  if (hasStructuredRepeatableState) {
555
532
  // In structured mode keep explicit repeatable keys, even when empty.
@@ -614,7 +591,7 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
614
591
  ),
615
592
 
616
593
  // Ensure our specific handling isn't overridden (always set last)
617
- id: options.mainRecordId || id || null,
594
+ id: resolveRecordId(options.mainRecordId, id),
618
595
  changeset_id: options.changeset_id || null, // Ensure changeset_id isn't overridden
619
596
  created_at: clientCreatedAt, // Ensure canonical created_at isn't overridden
620
597
  updated_at: serverUpdatedAt, // Ensure canonical updated_at isn't overridden
@@ -648,3 +625,97 @@ export function createStructuredRecord(state, fields = null, options = {}, id =
648
625
 
649
626
  return record;
650
627
  }
628
+
629
+ /**
630
+ * Create structured record from form engine state with support for unlimited RepeatableSection nesting
631
+ * @param {Object} state - Form engine state {values, errors, visible, required, read_only}
632
+ * @param {Array} [fields] - Optional flattened fields array from schema (for key mapping)
633
+ * @param {Object} options - Additional options for record creation
634
+ * - mainRecordId: UUID for the main record
635
+ * - childRecordIds: Nested object structure for RepeatableSection child record UUIDs
636
+ * - originalElements: Original nested form elements (for proper RepeatableSection nesting)
637
+ * @param {string} [id] - DEPRECATED: Use options.mainRecordId instead
638
+ * @returns {Object} Structured record ready for submission
639
+ *
640
+ * @example
641
+ * // Simple RepeatableSection:
642
+ * options = {
643
+ * mainRecordId: "main-uuid",
644
+ * childRecordIds: {
645
+ * "repA": ["child-1", "child-2", "child-3"]
646
+ * }
647
+ * }
648
+ *
649
+ * // Nested RepeatableSections:
650
+ * options = {
651
+ * mainRecordId: "main-uuid",
652
+ * childRecordIds: {
653
+ * "repA": {
654
+ * _records: ["repA-record-1", "repA-record-2"],
655
+ * "repA-record-1": {
656
+ * "repB": ["repB-child-1", "repB-child-2"]
657
+ * }
658
+ * }
659
+ * }
660
+ * }
661
+ */
662
+ export function createStructuredRecord(state, fields = null, options = {}, id = null) {
663
+ const work = structuredRecordSteps(state, fields, options, id);
664
+ let next;
665
+ do {
666
+ next = work.next();
667
+ } while (!next.done);
668
+ return next.value;
669
+ }
670
+
671
+ /**
672
+ * Create the same structured record with host scheduling between rows.
673
+ * Supply a stable evaluated snapshot; this function never evaluates or mutates it.
674
+ * @param {Object} state
675
+ * @param {Object[]} [fields]
676
+ * @param {Object} [options] The synchronous serializer's options; use mainRecordId for identity.
677
+ * @param {{signal?: AbortSignal, budgetMs?: number, yieldToHost?: Function}} [scheduling]
678
+ * @returns {Promise<Object>} Cancellation rejects with an AbortError; no partial record is returned.
679
+ */
680
+ export async function createStructuredRecordAsync(
681
+ state,
682
+ fields = null,
683
+ options = {},
684
+ scheduling = {}
685
+ ) {
686
+ const clock = () => globalThis.performance?.now?.() ?? Date.now();
687
+ const check = () => {
688
+ if (scheduling.signal?.aborted)
689
+ throw Object.assign(new Error('Record serialization was cancelled.'), {
690
+ name: 'AbortError',
691
+ code: 'cancelled',
692
+ });
693
+ };
694
+ const yieldToHost =
695
+ scheduling.yieldToHost || (() => new Promise((resolve) => setTimeout(resolve, 0)));
696
+ const pause = async () => {
697
+ try {
698
+ await yieldToHost();
699
+ } catch (error) {
700
+ throw Object.assign(new Error(error?.message || 'Record serialization scheduling failed.'), {
701
+ code: 'record_scheduling_failed',
702
+ });
703
+ }
704
+ };
705
+ check();
706
+ // Finish prior editor/signature work before beginning this separate phase.
707
+ await pause();
708
+ check();
709
+ let started = clock();
710
+ const work = structuredRecordSteps(state, fields, options);
711
+ for (;;) {
712
+ const next = work.next();
713
+ check();
714
+ if (next.done) return next.value;
715
+ if (clock() - started >= (scheduling.budgetMs ?? 8)) {
716
+ await pause();
717
+ check();
718
+ started = clock();
719
+ }
720
+ }
721
+ }
@@ -0,0 +1,170 @@
1
+ import { parse } from 'acorn';
2
+ import { ContextResolver } from '../engine/context-resolver.js';
3
+
4
+ export const COLLECTION_NAMES = new Set(['REPEATABLEVALUES', 'REPEATABLESUM']);
5
+ const VALUE_TYPES = new Set([
6
+ 'TextField',
7
+ 'NumericField',
8
+ 'SingleChoiceField',
9
+ 'MultiChoiceField',
10
+ 'BooleanField',
11
+ 'DateField',
12
+ 'TimeField',
13
+ 'CalculatedField',
14
+ 'FormLinkField',
15
+ ]);
16
+
17
+ /** Parse schema references without interpreting collection sources as scalar values. */
18
+ export function analyzeRepeatableCollections(source, resolver = null, owner = null) {
19
+ const calls = [];
20
+ const issues = [];
21
+ if (!/\bREPEATABLE(?:VALUES|SUM)\b/.test(source || ''))
22
+ return { calls, issues, expression: source, scalarSource: source };
23
+ let tree;
24
+ try {
25
+ tree = parse(source, { ecmaVersion: 'latest', sourceType: 'commonjs' });
26
+ } catch {
27
+ return { calls, issues, expression: source, scalarSource: source };
28
+ }
29
+ const edits = [];
30
+ function visit(node, parent) {
31
+ if (!node || typeof node !== 'object') return;
32
+ if (
33
+ node.type === 'CallExpression' &&
34
+ node.callee?.name === 'EVAL' &&
35
+ node.arguments.some(
36
+ (argument) =>
37
+ argument.type === 'Literal' &&
38
+ typeof argument.value === 'string' &&
39
+ /\bREPEATABLE(?:VALUES|SUM)\s*\(/.test(argument.value)
40
+ )
41
+ )
42
+ issues.push({
43
+ code: 'dynamic_collection_unsupported',
44
+ message: 'Repeatable collection calls inside EVAL are unsupported; use direct calls.',
45
+ index: node.start,
46
+ length: node.end - node.start,
47
+ });
48
+ if (node.type === 'Identifier' && COLLECTION_NAMES.has(node.name)) {
49
+ if (parent?.type !== 'CallExpression' || parent.callee !== node) {
50
+ issues.push({
51
+ code: 'collection_alias_unsupported',
52
+ message: 'Repeatable collection builtins require direct calls.',
53
+ index: node.start,
54
+ length: node.end - node.start,
55
+ });
56
+ } else {
57
+ const [section, target] = parent.arguments;
58
+ const valid =
59
+ parent.arguments.length === 2 &&
60
+ section?.type === 'Identifier' &&
61
+ /^\$[a-zA-Z_][a-zA-Z0-9_]*$/.test(section.name) &&
62
+ target?.type === 'Literal' &&
63
+ typeof target.value === 'string';
64
+ if (!valid) {
65
+ issues.push({
66
+ code: 'invalid_collection_arguments',
67
+ message: `${node.name} requires a direct $repeatable_data_name and a literal target data_name.`,
68
+ index: parent.start,
69
+ length: parent.end - parent.start,
70
+ });
71
+ } else {
72
+ const call = {
73
+ name: node.name,
74
+ section: section.name.slice(1),
75
+ target: target.value,
76
+ index: section.start,
77
+ length: section.end - section.start,
78
+ };
79
+ calls.push(call);
80
+ edits.push({
81
+ start: section.start,
82
+ end: section.end,
83
+ value: JSON.stringify(call.section),
84
+ });
85
+ if (resolver) {
86
+ const info = resolver.repeatableSectionTree.get(call.section);
87
+ const fieldInfo = resolver.fieldOwnership.get(call.target);
88
+ const ownerPath = resolver.getFieldInfo(owner)?.parentPath || [];
89
+ let message = null;
90
+ if (!info) message = `Unknown RepeatableSection '${call.section}'.`;
91
+ else if (
92
+ !fieldInfo ||
93
+ !ContextResolver.pathsEqual(fieldInfo.parentPath, info.currentPath)
94
+ )
95
+ message = `Collection target '${call.target}' must belong to '${call.section}' rows.`;
96
+ else if (!VALUE_TYPES.has(fieldInfo.field.type))
97
+ message = `Collection target type ${fieldInfo.field.type} is unsupported.`;
98
+ else if (
99
+ call.name === 'REPEATABLESUM' &&
100
+ !['NumericField', 'CalculatedField'].includes(fieldInfo.field.type)
101
+ )
102
+ message = 'REPEATABLESUM requires a NumericField or CalculatedField target.';
103
+ else if (
104
+ !ContextResolver.isPrefix(ownerPath, info.parentPath) &&
105
+ !ContextResolver.isPrefix(info.parentPath, ownerPath)
106
+ )
107
+ message = `Collection source '${call.section}' is outside the calculation's record ancestry.`;
108
+ if (message)
109
+ issues.push({
110
+ code: 'invalid_collection_reference',
111
+ message,
112
+ index: parent.start,
113
+ length: parent.end - parent.start,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ }
119
+ for (const [key, value] of Object.entries(node)) {
120
+ if (key === 'start' || key === 'end') continue;
121
+ if (Array.isArray(value)) value.forEach((child) => visit(child, node));
122
+ else if (value && typeof value === 'object') visit(value, node);
123
+ }
124
+ }
125
+ visit(tree, null);
126
+ let expression = source;
127
+ let scalarSource = source;
128
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
129
+ expression = expression.slice(0, edit.start) + edit.value + expression.slice(edit.end);
130
+ scalarSource =
131
+ scalarSource.slice(0, edit.start) +
132
+ ' '.repeat(edit.end - edit.start) +
133
+ scalarSource.slice(edit.end);
134
+ }
135
+ return { calls, issues, expression, scalarSource };
136
+ }
137
+
138
+ export function projectCollectionValue(field, value) {
139
+ if (value == null) return ['MultiChoiceField', 'FormLinkField'].includes(field.type) ? [] : null;
140
+ const fail = () => {
141
+ throw new Error(`Malformed collection value for '${field.data_name}'.`);
142
+ };
143
+ if (['SingleChoiceField', 'MultiChoiceField', 'BooleanField'].includes(field.type)) {
144
+ if (typeof value !== 'object' || Array.isArray(value)) return fail();
145
+ const multiple = field.type === 'MultiChoiceField';
146
+ const selections = value[multiple ? 'choices' : 'choice'];
147
+ const others = value.other ?? (field.type === 'BooleanField' ? [] : undefined);
148
+ if (!Array.isArray(selections) || !Array.isArray(others)) return fail();
149
+ if ((field.type === 'BooleanField' || !field.allow_other) && others.length) return fail();
150
+ const allowed = new Set(field.choices.map((choice) => choice.value));
151
+ const projected = selections.map((entry) => {
152
+ if (!entry || !allowed.has(entry.value)) return fail();
153
+ return entry.value;
154
+ });
155
+ for (const entry of others) {
156
+ if (!entry || typeof entry.label !== 'string' || !entry.label.trim()) return fail();
157
+ projected.push(entry.label);
158
+ }
159
+ if (!multiple && projected.length > 1) return fail();
160
+ return multiple ? projected : (projected[0] ?? null);
161
+ }
162
+ if (field.type === 'FormLinkField') {
163
+ if (!Array.isArray(value) || (!field.allow_multiple_records && value.length > 1)) return fail();
164
+ return value.map((entry) => {
165
+ if (!entry || typeof entry.record_id !== 'string' || !entry.record_id.trim()) return fail();
166
+ return { record_id: entry.record_id };
167
+ });
168
+ }
169
+ return value;
170
+ }
@@ -0,0 +1,30 @@
1
+ function stableValue(value) {
2
+ if (Array.isArray(value)) return value.map(stableValue);
3
+ if (!value || typeof value !== 'object') return value;
4
+ return Object.fromEntries(
5
+ Object.keys(value)
6
+ .sort()
7
+ .map((key) => [key, stableValue(value[key])])
8
+ );
9
+ }
10
+
11
+ function fnv1a64(input) {
12
+ let hash = 0xcbf29ce484222325n;
13
+ for (let index = 0; index < input.length; index += 1) {
14
+ hash ^= BigInt(input.charCodeAt(index));
15
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
16
+ }
17
+ return hash.toString(16).padStart(16, '0');
18
+ }
19
+
20
+ /** Return a deterministic schema revision without importing authoring/editor modules. */
21
+ export function getFormSchemaRevision(schema) {
22
+ const root =
23
+ schema?.form && Array.isArray(schema.form.elements)
24
+ ? schema
25
+ : schema && Array.isArray(schema.elements)
26
+ ? { form: schema }
27
+ : null;
28
+ if (!root) throw new Error('Expected a root schema containing form.elements or a form schema');
29
+ return `form0-${fnv1a64(JSON.stringify(stableValue(root)))}`;
30
+ }