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
@@ -1,6 +1,7 @@
1
1
  import { getEventInfo, isValidEventType } from './event-registry.js';
2
2
  import { closeEventOperationScope, createEventOperationScope } from './event-operation-scope.js';
3
3
  import { getEventFieldReferences } from './event-field-references.js';
4
+ import { eventOperationIssue, reportEventOperationIssue } from './event-policy.js';
4
5
 
5
6
  // Portable defensive copies also work in runtimes without structuredClone (including Hermes).
6
7
  function copy(value, seen = new Map()) {
@@ -30,25 +31,38 @@ function copy(value, seen = new Map()) {
30
31
  return result;
31
32
  }
32
33
 
34
+ const managedReferenceCaches = new WeakMap();
35
+ const managedFieldCaches = new WeakMap();
36
+ const managedRepeatableCaches = new WeakMap();
37
+
33
38
  /** Internal adapter; the public synchronous API is engine.triggerDetailed(). */
34
- export function createManagedEventDispatcher(manager, constructionScope) {
39
+ export function createManagedEventDispatcher(manager, constructionScope, getMode = () => 'edit') {
35
40
  const instancePath = copy(constructionScope?.instancePath);
36
41
  const scopeId = constructionScope?.scopeId;
37
42
  if (scopeId !== undefined && (typeof scopeId !== 'string' || !scopeId.trim()))
38
43
  throw new Error('eventScope.scopeId must be a nonempty string when supplied.');
39
44
  const resolver = manager.contextResolver;
40
- const fields = new Map(resolver.fieldOwnership);
45
+ if (!managedFieldCaches.has(resolver))
46
+ managedFieldCaches.set(resolver, new Map(resolver.fieldOwnership));
47
+ const fields = managedFieldCaches.get(resolver);
48
+ if (!managedRepeatableCaches.has(resolver))
49
+ managedRepeatableCaches.set(
50
+ resolver,
51
+ new Map(
52
+ [...resolver.repeatableSectionTree.values()].map((entry) => [
53
+ JSON.stringify([entry.parentPath, entry.preferredKey]),
54
+ entry,
55
+ ])
56
+ )
57
+ );
58
+ const repeatableIndex = managedRepeatableCaches.get(resolver);
41
59
  const statusField = manager.schema?.status_field;
42
60
  if (statusField?.enabled && typeof statusField.data_name === 'string')
43
61
  fields.set(statusField.data_name, { field: statusField, parentPath: [] });
44
62
  if (!Array.isArray(instancePath)) throw new Error('eventScope.instancePath must be an array.');
45
63
  const ownershipPath = [];
46
64
  for (const segment of instancePath) {
47
- const repeatable = [...resolver.repeatableSectionTree.values()].find(
48
- (entry) =>
49
- entry.preferredKey === segment?.repeatableKey &&
50
- JSON.stringify(entry.parentPath) === JSON.stringify(ownershipPath)
51
- );
65
+ const repeatable = repeatableIndex.get(JSON.stringify([ownershipPath, segment?.repeatableKey]));
52
66
  if (
53
67
  !repeatable ||
54
68
  JSON.stringify(repeatable.parentPath) !== JSON.stringify(ownershipPath) ||
@@ -70,8 +84,10 @@ export function createManagedEventDispatcher(manager, constructionScope) {
70
84
  const readable = (path) =>
71
85
  path.length <= ownershipPath.length && path.every((key, index) => ownershipPath[index] === key);
72
86
  const ownedField = (name) => fields.get(name) || resolver.repeatableSectionTree.get(name);
73
- const referenceCache = new WeakMap();
87
+ if (!managedReferenceCaches.has(manager)) managedReferenceCaches.set(manager, new WeakMap());
88
+ const referenceCache = managedReferenceCaches.get(manager);
74
89
  return function dispatch(eventType, fieldKey, metadata = {}) {
90
+ const mode = getMode();
75
91
  const result = { operations: [], validationIssues: [], issues: [] };
76
92
  const issue = (code, message) =>
77
93
  result.issues.push({
@@ -122,13 +138,13 @@ export function createManagedEventDispatcher(manager, constructionScope) {
122
138
  timestamp: Date.now(),
123
139
  instancePath: copy(instancePath),
124
140
  scopeId,
141
+ mode,
125
142
  };
126
143
  } catch {
127
144
  issue('invalid_request', 'Event metadata must contain portable data.');
128
145
  return result;
129
146
  }
130
- const validation = eventType === 'validate-record' || eventType === 'validate-repeatable';
131
- const closing = /^(cancel|unload)-/.test(eventType);
147
+ const registrations = [];
132
148
  const eventMap = manager.listeners.get(eventType);
133
149
  const listeners = [...(eventMap?.get(fieldKey) || []), ...(eventMap?.get('*') || [])];
134
150
  for (const callback of listeners) {
@@ -149,8 +165,12 @@ export function createManagedEventDispatcher(manager, constructionScope) {
149
165
  let collected;
150
166
  try {
151
167
  const source = manager.callbackSources.getSource(callback);
152
- if (referenceCache.get(callback)?.source !== source)
153
- referenceCache.set(callback, { source, references: getEventFieldReferences(source) });
168
+ if (referenceCache.get(callback)?.source !== source) {
169
+ const references =
170
+ manager.referenceSourceCache?.get(source) ?? getEventFieldReferences(source);
171
+ manager.referenceSourceCache?.set(source, references);
172
+ referenceCache.set(callback, { source, references });
173
+ }
154
174
  let rejectedReference = false;
155
175
  for (const name of referenceCache.get(callback).references) {
156
176
  const field = fields.get(name);
@@ -194,26 +214,31 @@ export function createManagedEventDispatcher(manager, constructionScope) {
194
214
  collected = closeEventOperationScope(scope);
195
215
  }
196
216
  try {
197
- if (returned?.type === 'UI_OPERATION' && !collected.includes(returned))
217
+ if (
218
+ typeof returned?.type === 'string' &&
219
+ returned.type.endsWith('_OPERATION') &&
220
+ !collected.includes(returned)
221
+ )
198
222
  collected.push(copy(returned));
199
223
  } catch {
200
224
  issue('invalid_operation', 'The returned event operation must contain portable data.');
201
225
  }
202
226
  for (const operation of collected) {
203
- if (operation?.type === 'EVENT_OPERATION') continue;
204
- if (operation?.operation === 'INVALID') {
205
- if (!validation)
206
- issue('invalid_context', 'INVALID() is only available during managed validation.');
207
- else
208
- result.validationIssues.push({
209
- message: operation.params.message,
210
- instancePath: copy(instancePath),
211
- });
212
- } else if (validation || closing) {
213
- issue(
214
- 'operation_not_allowed',
215
- 'Validation and close handlers cannot perform value or UI operations.'
216
- );
227
+ const rejected = eventOperationIssue(operation, mode, eventType);
228
+ if (rejected) {
229
+ issue(rejected.code, rejected.message);
230
+ if (
231
+ rejected.code === 'readonly_operation_not_allowed' ||
232
+ rejected.code === 'invalid_operation'
233
+ )
234
+ reportEventOperationIssue(manager.warningSystem, rejected, eventType);
235
+ } else if (operation.type === 'EVENT_OPERATION') {
236
+ registrations.push(operation);
237
+ } else if (operation.operation === 'INVALID') {
238
+ result.validationIssues.push({
239
+ message: operation.params.message,
240
+ instancePath: copy(instancePath),
241
+ });
217
242
  } else if (operation?.type === 'FIELD_OPERATION' && operation.operation === 'SETVALUE') {
218
243
  const field = fields.get(operation.params?.fieldDataName);
219
244
  if (!field || !same(field.parentPath))
@@ -239,6 +264,7 @@ export function createManagedEventDispatcher(manager, constructionScope) {
239
264
  }
240
265
  // A failed managed dispatch is not a partially applicable operation batch.
241
266
  if (result.issues.length) result.operations = [];
267
+ else manager.processEventOperations(registrations);
242
268
  return copy(result);
243
269
  };
244
270
  }
@@ -0,0 +1,133 @@
1
+ import { ContextResolver } from './context-resolver.js';
2
+ import { expandBuildingPlanSchema } from '../schema/building-plan-expander.js';
3
+ import { validateSchemaWithContext } from '../schema/schema-validator.js';
4
+ import { buildCalculationDependencyPlan } from '../utilities/calculation-dependencies.js';
5
+ import { analyzeRepeatableCollections } from '../utilities/repeatable-collections.js';
6
+ import { createConditionContext } from './condition-context.js';
7
+ import { copyRecordData } from '../utilities/record-data.js';
8
+
9
+ /** Shared immutable schema preparation; never owns live row engines. */
10
+ export function prepareRecordContext(inputSchema, warningSystem = null) {
11
+ const sourceSchema = copyRecordData(inputSchema);
12
+ const { schema } = expandBuildingPlanSchema(sourceSchema);
13
+ const resolver = new ContextResolver(schema.form);
14
+ validateSchemaWithContext(schema.form, resolver);
15
+ const calculationPlan = buildCalculationDependencyPlan(schema.form, resolver);
16
+ const conditionContext = createConditionContext(schema.form, resolver, warningSystem);
17
+ const templates = new Map();
18
+ const repeatableByPath = new Map(
19
+ [...resolver.repeatableSectionTree.values()].map((entry) => [
20
+ JSON.stringify(entry.currentPath),
21
+ entry,
22
+ ])
23
+ );
24
+ const calculations = new Map();
25
+ for (const [name, node] of calculationPlan.nodesByDataName) {
26
+ calculations.set(name, {
27
+ ...node,
28
+ collection: analyzeRepeatableCollections(node.field.calculate, resolver, name),
29
+ });
30
+ }
31
+ function template(elements, path) {
32
+ const fields = [];
33
+ const chains = new Map();
34
+ function project(items, ancestors = []) {
35
+ return items.map((field) => {
36
+ fields.push(field);
37
+ chains.set(field.data_name, ancestors);
38
+ if (field.type === 'RepeatableSection') {
39
+ const key = resolver.repeatableSectionTree.get(field.data_name).preferredKey;
40
+ template(field.elements || [], [...path, key]);
41
+ return { ...field, elements: [] };
42
+ }
43
+ if (field.type === 'Section' || field.type === 'BuildingPlanSection')
44
+ return {
45
+ ...field,
46
+ elements: project(field.elements || [], [...ancestors, field.data_name]),
47
+ };
48
+ return field;
49
+ });
50
+ }
51
+ const localForm = { elements: project(elements) };
52
+ const status = path.length === 0 ? schema.form.status_field : null;
53
+ if (status?.enabled) {
54
+ fields.push(status);
55
+ chains.set(status.data_name, []);
56
+ }
57
+ const result = {
58
+ path,
59
+ fields,
60
+ chains,
61
+ form: localForm,
62
+ conditions: { ...conditionContext, fields },
63
+ };
64
+ templates.set(JSON.stringify(path), result);
65
+ return result;
66
+ }
67
+ template(schema.form.elements, []);
68
+ return {
69
+ schema,
70
+ sourceSchema,
71
+ resolver,
72
+ calculationPlan,
73
+ templates,
74
+ calculations,
75
+ repeatableByPath,
76
+ };
77
+ }
78
+
79
+ export function visitRecordScopes(snapshot, context, visit) {
80
+ for (const scope of iterateRecordScopes(snapshot, context)) visit(scope);
81
+ }
82
+
83
+ /** Same traversal for synchronous validation and budgeted private-tree preparation. */
84
+ export function* iterateRecordScopes(snapshot, context) {
85
+ function* walk(record, path, instancePath, ancestors, participating) {
86
+ const template = context.templates.get(JSON.stringify(path));
87
+ const scope = { record, template, instancePath, ancestors, participating };
88
+ yield scope;
89
+ for (const field of template.fields) {
90
+ if (field.type !== 'RepeatableSection') continue;
91
+ const info = context.resolver.repeatableSectionTree.get(field.data_name);
92
+ const rows = record.repeatable?.[info.preferredKey] ?? [];
93
+ if (!Array.isArray(rows))
94
+ throw new Error(`RepeatableSection '${field.data_name}' must contain an array of rows.`);
95
+ const identities = new Set();
96
+ for (let index = 0; index < rows.length; index++) {
97
+ if (!(index in rows)) continue;
98
+ const row = rows[index];
99
+ if (!row || typeof row !== 'object' || Array.isArray(row))
100
+ throw new Error('Repeatable rows must be record objects.');
101
+ const id = row.id ?? row.record_id;
102
+ const identified =
103
+ (typeof id === 'string' && id.trim() !== '') ||
104
+ (typeof id === 'number' && Number.isFinite(id));
105
+ if (identified) {
106
+ const identity = `${typeof id}:${id}`;
107
+ if (identities.has(identity))
108
+ throw new Error(`Duplicate row identity in '${field.data_name}'.`);
109
+ identities.add(identity);
110
+ }
111
+ const segment = {
112
+ repeatableKey: info.preferredKey,
113
+ instanceId: identified ? id : null,
114
+ index,
115
+ identity: identified ? 'id' : 'position',
116
+ };
117
+ const visible =
118
+ record.visible?.[field.data_name] !== false &&
119
+ (template.chains.get(field.data_name) || []).every(
120
+ (name) => record.visible?.[name] !== false
121
+ );
122
+ yield* walk(
123
+ row,
124
+ info.currentPath,
125
+ [...instancePath, segment],
126
+ [...ancestors, scope],
127
+ participating && visible
128
+ );
129
+ }
130
+ }
131
+ }
132
+ yield* walk(snapshot, [], [], [], true);
133
+ }