form0-core 0.4.0 → 0.5.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
@@ -79,13 +79,51 @@ console.log(engine.getState());
79
79
  ```
80
80
 
81
81
  The engine API exposes `eval()` for calculations, conditions, and validation; `trigger()` for form
82
- events; and `getState()` for current values and evaluated field state.
82
+ events; `triggerDetailed()` for managed events and custom validation; and `getState()` for current
83
+ values and evaluated field state.
83
84
 
84
85
  Schema event callbacks retain their authored source, so ordinary functions and arrows defined in
85
86
  `form.events.code` also work on Hermes. Each dispatch uses current scoped field values; initialization
86
87
  runs once. Local initialization variables are not captured. Callbacks supplied by host helpers still
87
88
  require executable `toString()` output; unavailable source produces an event warning.
88
89
 
90
+ ## Managed events and custom validation
91
+
92
+ Use synchronous `engine.triggerDetailed(eventType, fieldDataName, metadata)` to receive defensive
93
+ `operations`, `validationIssues` and execution `issues`. It does not evaluate, apply operations,
94
+ persist or close an editor. Inspect issues before applying a batch; execution failures return no
95
+ applicable operations. The existing `trigger()` operation-array interface retains its legacy behavior.
96
+
97
+ ```js
98
+ // In schema.form.events.code:
99
+ ON('validate-record', function (event) {
100
+ if ($amount < 4) INVALID('Amount must be at least four.');
101
+ });
102
+
103
+ // In the host, after evaluating the intended snapshot:
104
+ const result = engine.triggerDetailed('validate-record', undefined, {
105
+ isDraft: false,
106
+ isValid: true,
107
+ });
108
+ console.log(result.validationIssues);
109
+ ```
110
+
111
+ `INVALID()` requires a nonempty string and is valid only during managed validation. Findings remain
112
+ separate from intrinsic field errors and calculation diagnostics. Validation handlers cannot return
113
+ value or UI operations; cancel/unload handlers are read-only notifications. Promise-returning schema
114
+ handlers are rejected, and late operations cannot enter a later dispatch.
115
+
116
+ Root scope defaults to `[]`. Headless row engines must provide `eventScope.instancePath` at creation,
117
+ using complete repeatable keys and existing IDs (or explicit positional identity). Managed row
118
+ handlers can read current, ancestor and main fields supplied by the host, but `SETVALUE()` writes
119
+ only current-row fields. Dispatch metadata cannot replace engine scope or event identity.
120
+
121
+ See [the maintained event contract](https://github.com/paqu-io/form0-core/blob/main/EVENT_CONTRACT.md)
122
+ for routing, supported events, metadata,
123
+ failure behavior and the subsequent binding adoption. Core does not introduce automatic renderer
124
+ lifecycle or save preparation by itself. Applications retain persistence and closing ownership;
125
+ `save-*` events are pre-save, with no `saved-*` schema events.
126
+
89
127
  ## Calculation diagnostics
90
128
 
91
129
  Diagnostics are collected automatically, including when console reporting is disabled:
@@ -172,6 +210,18 @@ Record utilities follow this contract:
172
210
 
173
211
  Record status remains top-level as `@status`; it is not stored inside `form_values`.
174
212
 
213
+ ## Authoring mutation validation
214
+
215
+ `getFormAuthoringContext({ schema })` includes `mutationOperationCatalog`: the installed operation
216
+ formats, required parameters, and examples. `mutationOperations` remains the list of supported names.
217
+ Use these formats with `applyFormMutationBatch`; for example, a rename uses
218
+ `{ op: 'updateForm', changes: { name: 'Renamed form' } }`, not a `patch` parameter.
219
+
220
+ Malformed operations, unknown operation parameters, and empty change objects are rejected atomically
221
+ with diagnostics, rather than silently ignored. Clearing a title/status field, calculation, or event
222
+ code requires its explicit parameter (for example, `field: null`), not an omitted value. Valid
223
+ idempotent batches remain valid in core; consumers should check actual changes before offering to save.
224
+
175
225
  ## ✅ Requirements
176
226
 
177
227
  - Node.js 22 or newer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -12,9 +12,9 @@
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/security-test.js",
16
- "format": "prettier --write \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
17
- "format:check": "prettier --check \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
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",
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
+ "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",
@@ -16,17 +16,77 @@ import {
16
16
  } from '../utilities/form-event-editor.js';
17
17
 
18
18
  const CONTAINER_TYPES = new Set(['Section', 'RepeatableSection', 'BuildingPlanSection']);
19
- const MUTATION_OPERATIONS = Object.freeze([
20
- 'updateForm',
21
- 'addField',
22
- 'updateField',
23
- 'moveField',
24
- 'removeField',
25
- 'setTitleField',
26
- 'setStatusField',
27
- 'setCalculation',
28
- 'setFormEventCode',
19
+ const FIELD_KEY_PARAMETER = Object.freeze({ type: 'string', minLength: 1 });
20
+ const CHANGES_PARAMETER = Object.freeze({ type: 'object', minProperties: 1 });
21
+ const PLACEMENT_PARAMETERS = Object.freeze({
22
+ parentKey: { type: ['string', 'null'], minLength: 1 },
23
+ position: {
24
+ type: ['object', 'null'],
25
+ properties: { beforeKey: FIELD_KEY_PARAMETER, afterKey: FIELD_KEY_PARAMETER },
26
+ maxProperties: 1,
27
+ description: 'Use one sibling key, or omit position to append in the target container.',
28
+ },
29
+ });
30
+ const MUTATION_OPERATION_CATALOG = Object.freeze([
31
+ {
32
+ op: 'updateForm',
33
+ description:
34
+ 'Update form attributes; elements, events, title_field and status_field use dedicated operations.',
35
+ required: ['changes'],
36
+ parameters: { changes: CHANGES_PARAMETER },
37
+ example: { op: 'updateForm', changes: { name: 'Renamed form' } },
38
+ },
39
+ {
40
+ op: 'addField',
41
+ description: 'Add a complete field matching fieldSpecs; missing keys are generated.',
42
+ required: ['field'],
43
+ parameters: { field: { type: 'object', minProperties: 1 }, ...PLACEMENT_PARAMETERS },
44
+ },
45
+ {
46
+ op: 'updateField',
47
+ description: 'Update an existing field by key; type changes require remove/add.',
48
+ required: ['fieldKey', 'changes'],
49
+ parameters: { fieldKey: FIELD_KEY_PARAMETER, changes: CHANGES_PARAMETER },
50
+ example: { op: 'updateField', fieldKey: 'existing-field-key', changes: { label: 'New label' } },
51
+ },
52
+ {
53
+ op: 'moveField',
54
+ description: 'Move an existing field by key to a container/sibling position.',
55
+ required: ['fieldKey'],
56
+ parameters: { fieldKey: FIELD_KEY_PARAMETER, ...PLACEMENT_PARAMETERS },
57
+ },
58
+ {
59
+ op: 'removeField',
60
+ description: 'Remove an existing field by key only if it is unreferenced.',
61
+ required: ['fieldKey'],
62
+ parameters: { fieldKey: FIELD_KEY_PARAMETER },
63
+ },
64
+ {
65
+ op: 'setTitleField',
66
+ description: 'Set a complete title field, or explicitly pass field: null to clear it.',
67
+ required: ['field'],
68
+ parameters: { field: { type: ['object', 'null'] } },
69
+ },
70
+ {
71
+ op: 'setStatusField',
72
+ description: 'Set a complete status field, or explicitly pass field: null to clear it.',
73
+ required: ['field'],
74
+ parameters: { field: { type: ['object', 'null'] } },
75
+ },
76
+ {
77
+ op: 'setCalculation',
78
+ description: 'Set a CalculatedField expression by key; null explicitly clears it.',
79
+ required: ['fieldKey', 'expression'],
80
+ parameters: { fieldKey: FIELD_KEY_PARAMETER, expression: { type: ['string', 'null'] } },
81
+ },
82
+ {
83
+ op: 'setFormEventCode',
84
+ description: 'Set form event code; an empty string or null explicitly clears it.',
85
+ required: ['code'],
86
+ parameters: { code: { type: ['string', 'null'] } },
87
+ },
29
88
  ]);
89
+ const MUTATION_OPERATIONS = Object.freeze(MUTATION_OPERATION_CATALOG.map(({ op }) => op));
30
90
 
31
91
  const CALCULATION_GUIDANCE = Object.freeze({
32
92
  version: 1,
@@ -306,16 +366,66 @@ function mutationError(index, operation, error) {
306
366
  };
307
367
  }
308
368
 
369
+ function validateMutationParameter(value, spec, path) {
370
+ const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
371
+ const types = Array.isArray(spec.type) ? spec.type : [spec.type];
372
+ if (!types.includes(type)) throw new Error(`${path} must be ${types.join(' or ')}`);
373
+ if (value === null) return;
374
+ if (spec.minLength && value.length < spec.minLength) {
375
+ throw new Error(`${path} must not be empty`);
376
+ }
377
+ if (spec.minProperties && Object.keys(value).length < spec.minProperties) {
378
+ throw new Error(`${path} must be a non-empty object`);
379
+ }
380
+ if (spec.maxProperties && Object.keys(value).length > spec.maxProperties) {
381
+ throw new Error(`${path} must specify at most one property`);
382
+ }
383
+ if (spec.properties) {
384
+ for (const key of Object.keys(value)) {
385
+ if (!Object.hasOwn(spec.properties, key)) {
386
+ throw new Error(`Unexpected mutation parameter '${path}.${key}'`);
387
+ }
388
+ validateMutationParameter(value[key], spec.properties[key], `${path}.${key}`);
389
+ }
390
+ }
391
+ }
392
+
393
+ function validateMutationOperation(operation) {
394
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) {
395
+ throw new Error('A mutation operation must be an object with an op property');
396
+ }
397
+ const definition = MUTATION_OPERATION_CATALOG.find(({ op }) => op === operation.op);
398
+ if (!definition) throw new Error(`Unsupported mutation operation '${operation.op}'`);
399
+ for (const name of definition.required) {
400
+ if (!Object.hasOwn(operation, name) || operation[name] === undefined) {
401
+ const example = definition.example ? ` Example: ${JSON.stringify(definition.example)}` : '';
402
+ throw new Error(`${operation.op} requires '${name}'.${example}`);
403
+ }
404
+ }
405
+ for (const name of Object.keys(operation)) {
406
+ if (name === 'op') continue;
407
+ if (!Object.hasOwn(definition.parameters, name)) {
408
+ throw new Error(`Unexpected ${operation.op} parameter '${name}'`);
409
+ }
410
+ validateMutationParameter(
411
+ operation[name],
412
+ definition.parameters[name],
413
+ `${operation.op}.${name}`
414
+ );
415
+ }
416
+ }
417
+
309
418
  function applyMutation(root, operation) {
419
+ validateMutationOperation(operation);
310
420
  const form = root.form;
311
421
  const fields = indexFields(form.elements);
312
422
  switch (operation.op) {
313
423
  case 'updateForm': {
314
424
  const forbidden = new Set(['elements', 'events', 'title_field', 'status_field']);
315
- for (const key of Object.keys(operation.changes || {})) {
425
+ for (const key of Object.keys(operation.changes)) {
316
426
  if (forbidden.has(key)) throw new Error(`updateForm cannot change '${key}'`);
317
427
  }
318
- Object.assign(form, deepClone(operation.changes || {}));
428
+ Object.assign(form, deepClone(operation.changes));
319
429
  return `Updated form attributes`;
320
430
  }
321
431
  case 'addField': {
@@ -340,7 +450,7 @@ function applyMutation(root, operation) {
340
450
  }
341
451
  const original = deepClone(target.field);
342
452
  const children = target.field.elements;
343
- Object.assign(target.field, deepClone(operation.changes || {}));
453
+ Object.assign(target.field, deepClone(operation.changes));
344
454
  if (
345
455
  target.field.data_name !== original.data_name &&
346
456
  [...fields.values()].some(
@@ -473,6 +583,7 @@ export function getFormAuthoringContext({ schema, coreVersion = null }) {
473
583
  revision: getFormSchemaRevision(root),
474
584
  schema: deepClone(root),
475
585
  mutationOperations: [...MUTATION_OPERATIONS],
586
+ mutationOperationCatalog: deepClone(MUTATION_OPERATION_CATALOG),
476
587
  fieldSpecs,
477
588
  operators,
478
589
  eventTypes: getAllEventTypes(),
@@ -272,6 +272,18 @@ export const OFF_METADATA = defineBuiltinMetadata({
272
272
  contexts: [BUILTIN_CONTEXTS.EVENT],
273
273
  });
274
274
 
275
+ export const INVALID_METADATA = defineBuiltinMetadata({
276
+ name: 'INVALID',
277
+ category: 'event',
278
+ signature: 'INVALID(message)',
279
+ description:
280
+ 'Report a scope-level custom validation message during managed validate-record or validate-repeatable execution. Requires a nonempty string; does not modify field errors.',
281
+ examples: [
282
+ "ON('validate-record', function(event) { if ($amount < 4) INVALID('Amount must be at least four.'); })",
283
+ ],
284
+ contexts: [BUILTIN_CONTEXTS.EVENT],
285
+ });
286
+
275
287
  export const BUILTIN_DEFINITIONS = Object.freeze([
276
288
  IF_METADATA,
277
289
  AND_METADATA,
@@ -302,6 +314,7 @@ export const BUILTIN_DEFINITIONS = Object.freeze([
302
314
  SETVALUE_METADATA,
303
315
  ON_METADATA,
304
316
  OFF_METADATA,
317
+ INVALID_METADATA,
305
318
  ]);
306
319
 
307
320
  function filterDefinitionsByContext(context) {
@@ -0,0 +1,17 @@
1
+ import { __collectEventOperation } from '../event-operations-collector.js';
2
+ export { INVALID_METADATA } from '../../builtin-definitions.js';
3
+
4
+ /**
5
+ * @builtin INVALID
6
+ * See builtin-definitions.js for the authoritative catalog entry.
7
+ * @param {string} message
8
+ * @returns {Object} Validation descriptor consumed by managed validation execution.
9
+ */
10
+ export function INVALID(message) {
11
+ if (typeof message !== 'string' || !message.trim()) {
12
+ throw new Error('INVALID() requires a nonempty string.');
13
+ }
14
+ const operation = { type: 'VALIDATION_OPERATION', operation: 'INVALID', params: { message } };
15
+ if (!__collectEventOperation(operation)) throw new Error('INVALID() requires an event scope.');
16
+ return operation;
17
+ }
@@ -8,6 +8,7 @@ let boundScope = null;
8
8
  export function __collectEventOperation(operation) {
9
9
  const scope = boundScope || activeScope;
10
10
  scope?.collect(operation);
11
+ return Boolean(scope);
11
12
  }
12
13
 
13
14
  /**
@@ -44,6 +44,7 @@ import { ALERT, ALERT_METADATA } from './event/ui/alert.js';
44
44
  import { SETVALUE, SETVALUE_METADATA } from './event/field/setvalue.js';
45
45
  import { ON, ON_METADATA } from './event/control/on.js';
46
46
  import { OFF, OFF_METADATA } from './event/control/off.js';
47
+ import { INVALID, INVALID_METADATA } from './event/control/invalid.js';
47
48
 
48
49
  export { IF, IF_METADATA } from './logical/if.js';
49
50
  export { AND, AND_METADATA } from './logical/and.js';
@@ -86,6 +87,7 @@ export { ALERT, ALERT_METADATA } from './event/ui/alert.js';
86
87
  export { SETVALUE, SETVALUE_METADATA } from './event/field/setvalue.js';
87
88
  export { ON, ON_METADATA } from './event/control/on.js';
88
89
  export { OFF, OFF_METADATA } from './event/control/off.js';
90
+ export { INVALID, INVALID_METADATA } from './event/control/invalid.js';
89
91
 
90
92
  const RUNTIME_ENTRIES = Object.freeze([
91
93
  { implementation: IF, definition: IF_METADATA },
@@ -117,6 +119,7 @@ const RUNTIME_ENTRIES = Object.freeze([
117
119
  { implementation: SETVALUE, definition: SETVALUE_METADATA },
118
120
  { implementation: ON, definition: ON_METADATA },
119
121
  { implementation: OFF, definition: OFF_METADATA },
122
+ { implementation: INVALID, definition: INVALID_METADATA },
120
123
  ]);
121
124
 
122
125
  function buildBuiltinObject(context) {
@@ -0,0 +1,127 @@
1
+ import { parse } from 'acorn';
2
+
3
+ const isFunction = (node) =>
4
+ ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node?.type);
5
+
6
+ function bindPattern(node, names) {
7
+ if (!node) return;
8
+ if (node.type === 'Identifier') names.add(node.name);
9
+ else if (node.type === 'RestElement') bindPattern(node.argument, names);
10
+ else if (node.type === 'AssignmentPattern') bindPattern(node.left, names);
11
+ else if (node.type === 'ArrayPattern')
12
+ node.elements.forEach((element) => bindPattern(element, names));
13
+ else if (node.type === 'ObjectPattern')
14
+ node.properties.forEach((property) =>
15
+ bindPattern(property.type === 'RestElement' ? property.argument : property.value, names)
16
+ );
17
+ }
18
+
19
+ function children(node, visit) {
20
+ for (const value of Object.values(node)) {
21
+ if (Array.isArray(value)) value.forEach((child) => child?.type && visit(child));
22
+ else if (value?.type) visit(value);
23
+ }
24
+ }
25
+
26
+ function bindVars(node, names) {
27
+ if (
28
+ !node ||
29
+ isFunction(node) ||
30
+ ['ClassDeclaration', 'ClassExpression', 'StaticBlock'].includes(node.type)
31
+ )
32
+ return;
33
+ if (node.type === 'VariableDeclaration' && node.kind === 'var')
34
+ node.declarations.forEach((declaration) => bindPattern(declaration.id, names));
35
+ children(node, (child) => bindVars(child, names));
36
+ }
37
+
38
+ function bindDeclarations(statements, names) {
39
+ for (const statement of statements) {
40
+ if (statement.type === 'VariableDeclaration' && statement.kind !== 'var')
41
+ statement.declarations.forEach((declaration) => bindPattern(declaration.id, names));
42
+ else if (['ClassDeclaration', 'FunctionDeclaration'].includes(statement.type))
43
+ bindPattern(statement.id, names);
44
+ }
45
+ }
46
+
47
+ /** Free $field identifiers only: strings, comments, property names and local bindings are excluded. */
48
+ export function getEventFieldReferences(source) {
49
+ const tree = parse(`(${source})`, { ecmaVersion: 'latest' });
50
+ const references = new Set();
51
+ const newScope = (parent) => ({ names: new Set(), parent });
52
+ function bound(scope, name) {
53
+ return scope && (scope.names.has(name) || bound(scope.parent, name));
54
+ }
55
+ function patternExpressions(node, scope) {
56
+ if (!node || node.type === 'Identifier') return;
57
+ if (node.type === 'AssignmentPattern') {
58
+ patternExpressions(node.left, scope);
59
+ walk(node.right, scope);
60
+ } else if (node.type === 'RestElement') patternExpressions(node.argument, scope);
61
+ else if (node.type === 'ArrayPattern')
62
+ node.elements.forEach((element) => patternExpressions(element, scope));
63
+ else if (node.type === 'ObjectPattern')
64
+ node.properties.forEach((property) => {
65
+ if (property.computed) walk(property.key, scope);
66
+ patternExpressions(
67
+ property.type === 'RestElement' ? property.argument : property.value,
68
+ scope
69
+ );
70
+ });
71
+ }
72
+ function walk(node, scope) {
73
+ if (!node) return;
74
+ if (isFunction(node)) {
75
+ const local = newScope(scope);
76
+ bindPattern(node.id, local.names);
77
+ node.params.forEach((param) => bindPattern(param, local.names));
78
+ node.params.forEach((param) => patternExpressions(param, local));
79
+ bindVars(node.body, local.names);
80
+ walk(node.body, local);
81
+ } else if (node.type === 'BlockStatement' || node.type === 'StaticBlock') {
82
+ const local = newScope(scope);
83
+ if (node.type === 'StaticBlock') node.body.forEach((part) => bindVars(part, local.names));
84
+ bindDeclarations(node.body, local.names);
85
+ node.body.forEach((statement) => walk(statement, local));
86
+ } else if (['ForStatement', 'ForOfStatement', 'ForInStatement'].includes(node.type)) {
87
+ const local = newScope(scope);
88
+ const declaration = node.init || node.left;
89
+ if (declaration?.type === 'VariableDeclaration')
90
+ declaration.declarations.forEach((part) => bindPattern(part.id, local.names));
91
+ children(node, (child) => walk(child, local));
92
+ } else if (node.type === 'SwitchStatement') {
93
+ walk(node.discriminant, scope);
94
+ const local = newScope(scope);
95
+ bindDeclarations(
96
+ node.cases.flatMap((part) => part.consequent),
97
+ local.names
98
+ );
99
+ node.cases.forEach((part) => walk(part, local));
100
+ } else if (node.type === 'CatchClause') {
101
+ const local = newScope(scope);
102
+ bindPattern(node.param, local.names);
103
+ walk(node.body, local);
104
+ } else if (node.type === 'VariableDeclarator') {
105
+ patternExpressions(node.id, scope);
106
+ walk(node.init, scope);
107
+ } else if (node.type === 'LabeledStatement') {
108
+ walk(node.body, scope);
109
+ } else if (node.type === 'BreakStatement' || node.type === 'ContinueStatement') {
110
+ return;
111
+ } else if (
112
+ ['MemberExpression', 'Property', 'PropertyDefinition', 'MethodDefinition'].includes(node.type)
113
+ ) {
114
+ if (node.computed) walk(node.property || node.key, scope);
115
+ walk(node.object || node.value, scope);
116
+ } else if (node.type === 'ClassExpression' || node.type === 'ClassDeclaration') {
117
+ const local = newScope(scope);
118
+ bindPattern(node.id, local.names);
119
+ walk(node.superClass, scope);
120
+ walk(node.body, local);
121
+ } else if (node.type === 'Identifier') {
122
+ if (node.name.startsWith('$') && !bound(scope, node.name)) references.add(node.name.slice(1));
123
+ } else children(node, (child) => walk(child, scope));
124
+ }
125
+ walk(tree, newScope(null));
126
+ return references;
127
+ }
@@ -3,7 +3,7 @@ import {
3
3
  __runWithEventOperationScope,
4
4
  } from '../builtins/event/event-operations-collector.js';
5
5
 
6
- const EVENT_OPERATION_BUILTINS = ['ALERT', 'SETVALUE', 'ON', 'OFF'];
6
+ const EVENT_OPERATION_BUILTINS = ['ALERT', 'SETVALUE', 'ON', 'OFF', 'INVALID'];
7
7
 
8
8
  /**
9
9
  * Create an operation scope owned by one synchronous event execution.
@@ -23,6 +23,9 @@ export const EVENT_REGISTRY = {
23
23
  'new-record': { category: 'record', scope: 'main' },
24
24
  'save-record': { category: 'record', scope: 'main' },
25
25
  'validate-record': { category: 'record', scope: 'main' },
26
+ 'unload-record': { category: 'record', scope: 'main' },
27
+ 'cancel-record': { category: 'record', scope: 'main' },
28
+ 'change-status': { category: 'record', scope: 'main' },
26
29
 
27
30
  // Field-level events (contextual scope)
28
31
  change: { category: 'field', scope: 'contextual' },
@@ -36,6 +39,8 @@ export const EVENT_REGISTRY = {
36
39
  'edit-repeatable': { category: 'repeatable', scope: 'repeatable' },
37
40
  'save-repeatable': { category: 'repeatable', scope: 'repeatable' },
38
41
  'validate-repeatable': { category: 'repeatable', scope: 'repeatable' },
42
+ 'unload-repeatable': { category: 'repeatable', scope: 'repeatable' },
43
+ 'cancel-repeatable': { category: 'repeatable', scope: 'repeatable' },
39
44
  };
40
45
 
41
46
  /**
@@ -44,7 +49,9 @@ export const EVENT_REGISTRY = {
44
49
  * @returns {Object} Event info with category and scope, or default for unknown events
45
50
  */
46
51
  export function getEventInfo(eventType) {
47
- return EVENT_REGISTRY[eventType] || { category: 'unknown', scope: 'main' };
52
+ return Object.hasOwn(EVENT_REGISTRY, eventType)
53
+ ? EVENT_REGISTRY[eventType]
54
+ : { category: 'unknown', scope: 'main' };
48
55
  }
49
56
 
50
57
  /**
@@ -53,7 +60,7 @@ export function getEventInfo(eventType) {
53
60
  * @returns {boolean} True if the event type is registered
54
61
  */
55
62
  export function isValidEventType(eventType) {
56
- return eventType in EVENT_REGISTRY;
63
+ return Object.hasOwn(EVENT_REGISTRY, eventType);
57
64
  }
58
65
 
59
66
  /**
@@ -27,6 +27,7 @@ export class EventManager {
27
27
  this.warningSystem = warningSystem || new WarningSystem();
28
28
  this.schema = schema;
29
29
  this.callbackSources = createEventSourceRegistry();
30
+ this.initializationIssues = [];
30
31
  }
31
32
 
32
33
  /**
@@ -64,11 +65,28 @@ export class EventManager {
64
65
  const result = runInEventOperationScope(scope, () =>
65
66
  runExpression(prepared.code, prepared.context, this.securityConfig, true, this.schema, {
66
67
  sourceExpression: code,
68
+ onDiagnostic: (diagnostic) =>
69
+ this.initializationIssues.push({
70
+ code: 'initialization_failed',
71
+ message: diagnostic.message,
72
+ }),
67
73
  })
68
74
  );
69
- this.reportAsyncHandlerResult(result, executionContext, 'form.events.code');
75
+ if (this.reportAsyncHandlerResult(result, executionContext, 'form.events.code')) {
76
+ this.initializationIssues.push({
77
+ code: 'async_handler_unsupported',
78
+ message: 'Event initialization must be synchronous.',
79
+ });
80
+ }
70
81
  } finally {
71
- this.processEventOperations(closeEventOperationScope(scope));
82
+ const operations = closeEventOperationScope(scope);
83
+ if (operations.some((operation) => operation?.operation === 'INVALID')) {
84
+ this.initializationIssues.push({
85
+ code: 'invalid_context',
86
+ message: 'INVALID() cannot run while registering event handlers.',
87
+ });
88
+ }
89
+ this.processEventOperations(operations);
72
90
  }
73
91
  }
74
92
 
@@ -114,7 +132,7 @@ export class EventManager {
114
132
  then = result.then;
115
133
  } catch {
116
134
  this.emitHandlerWarning('async_handler_rejected', executionContext, handlerName);
117
- return;
135
+ return true;
118
136
  }
119
137
 
120
138
  if (typeof then !== 'function') return;
@@ -123,6 +141,7 @@ export class EventManager {
123
141
  Promise.resolve(result).catch(() => {
124
142
  this.emitHandlerWarning('async_handler_rejected', executionContext, handlerName);
125
143
  });
144
+ return true;
126
145
  }
127
146
 
128
147
  /**
@@ -249,7 +268,7 @@ export class EventManager {
249
268
  * @param {Object} event - The event object
250
269
  * @returns {*} The result of the event handler
251
270
  */
252
- executeHandlerWithContext(callback, event, scope) {
271
+ executeHandlerWithContext(callback, event, scope, managed = null) {
253
272
  // Create execution context for this event
254
273
  const executionContext = {
255
274
  type: 'event',
@@ -258,11 +277,13 @@ export class EventManager {
258
277
  };
259
278
 
260
279
  // Get the callback code for field reference analysis
261
- const callbackCode = this.callbackSources.getSource(callback);
280
+ const callbackCode = managed?.source ?? this.callbackSources.getSource(callback);
262
281
 
263
282
  // Build scoped context if context resolver is available
264
283
  let contextWithEvent;
265
- if (this.contextResolver && this.warningSystem) {
284
+ if (managed) {
285
+ contextWithEvent = managed.context;
286
+ } else if (this.contextResolver && this.warningSystem) {
266
287
  contextWithEvent = this.buildScopedEventContext(
267
288
  this.eventContext,
268
289
  executionContext,
@@ -285,6 +306,7 @@ export class EventManager {
285
306
  return runInEventOperationScope(scope, () =>
286
307
  runExpression(functionCall, contextWithEvent, this.securityConfig, true, this.schema, {
287
308
  evaluateAsExpression: true,
309
+ ...(managed?.reporting || {}),
288
310
  })
289
311
  );
290
312
  }
@@ -15,6 +15,7 @@ import { expandBuildingPlanSchema } from '../schema/building-plan-expander.js';
15
15
  import { buildCalculationDependencyPlan } from '../utilities/calculation-dependencies.js';
16
16
  import { createCalculationDiagnostics } from './calculation-diagnostics.js';
17
17
  import { formatLocalDate } from '../utilities/date-utils.js';
18
+ import { createManagedEventDispatcher } from './managed-events.js';
18
19
 
19
20
  /**
20
21
  * @typedef {Object} CalculationDiagnostic
@@ -27,6 +28,23 @@ import { formatLocalDate } from '../utilities/date-utils.js';
27
28
  * @property {Object} context JSON-safe metadata; no expression source, values or stack.
28
29
  */
29
30
 
31
+ /**
32
+ * @typedef {Object} ManagedValidationIssue
33
+ * @property {string} message Scope-level message, without an invented field association.
34
+ * @property {Object[]} instancePath Complete engine-owned row ancestry; [] for root.
35
+ *
36
+ * @typedef {Object} ManagedEventIssue
37
+ * @property {string} code Stable execution failure identifier.
38
+ * @property {string} message
39
+ * @property {string|null} eventType Null for a malformed event-type argument.
40
+ * @property {Object[]} instancePath
41
+ *
42
+ * @typedef {Object} ManagedEventResult
43
+ * @property {Object[]} operations Defensive accepted operation batch; [] on execution failure.
44
+ * @property {ManagedValidationIssue[]} validationIssues Fresh findings for this attempt.
45
+ * @property {ManagedEventIssue[]} issues Execution failures, separate from validation findings.
46
+ */
47
+
30
48
  /**
31
49
  * Create a form engine. Calculations collect diagnostics independently of validation.
32
50
  * @param {Object} options
@@ -40,6 +58,9 @@ import { formatLocalDate } from '../utilities/date-utils.js';
40
58
  * @param {{console?: boolean}} [options.diagnostics] Omit console to preserve legacy reporting.
41
59
  * @param {(snapshot: CalculationDiagnostic[]) => (void|Promise<void>)} [options.onDiagnostics]
42
60
  * Called once per completed evaluation, including empty/identical snapshots. Failures are isolated.
61
+ * @param {{scopeId?: string, instancePath: Array<{repeatableKey: string, instanceId: string|number|null, index: number, identity: 'id'|'position'}>}} [options.eventScope]
62
+ * Managed event ownership, fixed for this engine lifetime. Root is []; headless row engines must
63
+ * provide the complete repeatable path. triggerDetailed() never evaluates or applies operations.
43
64
  * @returns {Object} Engine with getDiagnostics(): defensive copy of latest evaluation (initially []).
44
65
  */
45
66
 
@@ -52,6 +73,7 @@ export function createFormEngine({
52
73
  runtimeDiagnostics = null,
53
74
  diagnostics = {},
54
75
  onDiagnostics = null,
76
+ eventScope = { instancePath: [] },
55
77
  }) {
56
78
  const { schema: preparedSchema, buildingPlanMeta } = expandBuildingPlanSchema(schema);
57
79
 
@@ -139,6 +161,7 @@ export function createFormEngine({
139
161
  const eventManager = new EventManager(form, contextResolver, sharedWarningSystem);
140
162
  eventManager.securityConfig = security; // Pass security config
141
163
  const eventHelpers = { ...eventBuiltins, ...helpers };
164
+ const dispatchManagedEvent = createManagedEventDispatcher(eventManager, eventScope);
142
165
 
143
166
  // Initialize event code if present
144
167
  if (form.events && form.events.code) {
@@ -199,9 +222,22 @@ export function createFormEngine({
199
222
  return { values, errors, visible, required, read_only };
200
223
  }
201
224
 
225
+ /**
226
+ * Dispatch synchronously within this engine's construction scope. No operations are applied.
227
+ * @param {string} eventType Registered event name.
228
+ * @param {string} [fieldKey] Field/repeatable data_name; omitted for record lifecycle events.
229
+ * @param {Object} [metadata] Portable host metadata; dispatcher-owned identity is authoritative.
230
+ * @returns {ManagedEventResult}
231
+ */
232
+ function triggerDetailed(eventType, fieldKey, metadata = {}) {
233
+ eventManager.eventContext = buildEventContext(values, eventHelpers, {});
234
+ return dispatchManagedEvent(eventType, fieldKey, metadata);
235
+ }
236
+
202
237
  return {
203
238
  eval: evalForm,
204
239
  trigger,
240
+ triggerDetailed,
205
241
  getState,
206
242
  getDiagnostics: calculationDiagnostics.getSnapshot,
207
243
  getWarningSystem: () => sharedWarningSystem,
@@ -0,0 +1,238 @@
1
+ import { getEventInfo, isValidEventType } from './event-registry.js';
2
+ import { closeEventOperationScope, createEventOperationScope } from './event-operation-scope.js';
3
+ import { getEventFieldReferences } from './event-field-references.js';
4
+
5
+ // Portable defensive copies also work in runtimes without structuredClone (including Hermes).
6
+ function copy(value, seen = new Map()) {
7
+ if (typeof value === 'function' || typeof value === 'symbol')
8
+ throw new Error('Event data must not contain functions or symbols.');
9
+ if (value === null || typeof value !== 'object') return value;
10
+ if (seen.has(value)) return seen.get(value);
11
+ if (value instanceof Date) return new Date(value.getTime());
12
+ if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
13
+ throw new Error('Event data must use plain objects, arrays or dates.');
14
+ const result = Array.isArray(value) ? [] : {};
15
+ seen.set(value, result);
16
+ for (const key of Object.keys(value)) {
17
+ Object.defineProperty(result, key, {
18
+ value: copy(value[key], seen),
19
+ enumerable: true,
20
+ writable: true,
21
+ configurable: true,
22
+ });
23
+ }
24
+ return result;
25
+ }
26
+
27
+ /** Internal adapter; the public synchronous API is engine.triggerDetailed(). */
28
+ export function createManagedEventDispatcher(manager, constructionScope) {
29
+ const instancePath = copy(constructionScope?.instancePath);
30
+ const scopeId = constructionScope?.scopeId;
31
+ if (scopeId !== undefined && (typeof scopeId !== 'string' || !scopeId.trim()))
32
+ throw new Error('eventScope.scopeId must be a nonempty string when supplied.');
33
+ const resolver = manager.contextResolver;
34
+ const fields = new Map(resolver.fieldOwnership);
35
+ const statusField = manager.schema?.status_field;
36
+ if (statusField?.enabled && typeof statusField.data_name === 'string')
37
+ fields.set(statusField.data_name, { field: statusField, parentPath: [] });
38
+ if (!Array.isArray(instancePath)) throw new Error('eventScope.instancePath must be an array.');
39
+ const ownershipPath = [];
40
+ for (const segment of instancePath) {
41
+ const repeatable = [...resolver.repeatableSectionTree.values()].find(
42
+ (entry) =>
43
+ entry.preferredKey === segment?.repeatableKey &&
44
+ JSON.stringify(entry.parentPath) === JSON.stringify(ownershipPath)
45
+ );
46
+ if (
47
+ !repeatable ||
48
+ JSON.stringify(repeatable.parentPath) !== JSON.stringify(ownershipPath) ||
49
+ !Number.isInteger(segment.index) ||
50
+ segment.index < 0 ||
51
+ !['id', 'position'].includes(segment.identity) ||
52
+ (segment.identity === 'id'
53
+ ? !(typeof segment.instanceId === 'string' && segment.instanceId.trim()) &&
54
+ !(typeof segment.instanceId === 'number' && Number.isFinite(segment.instanceId))
55
+ : segment.instanceId !== null)
56
+ ) {
57
+ throw new Error(
58
+ 'eventScope.instancePath must describe a complete repeatable ancestry with an ID or explicit positional identity.'
59
+ );
60
+ }
61
+ ownershipPath.push(repeatable.preferredKey);
62
+ }
63
+ const same = (path) => JSON.stringify(path) === JSON.stringify(ownershipPath);
64
+ const readable = (path) =>
65
+ path.length <= ownershipPath.length && path.every((key, index) => ownershipPath[index] === key);
66
+ const ownedField = (name) => fields.get(name) || resolver.repeatableSectionTree.get(name);
67
+ const referenceCache = new WeakMap();
68
+ return function dispatch(eventType, fieldKey, metadata = {}) {
69
+ const result = { operations: [], validationIssues: [], issues: [] };
70
+ const issue = (code, message) =>
71
+ result.issues.push({
72
+ code,
73
+ message,
74
+ eventType: typeof eventType === 'string' ? eventType : null,
75
+ instancePath: copy(instancePath),
76
+ });
77
+ if (
78
+ typeof eventType !== 'string' ||
79
+ !isValidEventType(eventType) ||
80
+ !metadata ||
81
+ typeof metadata !== 'object' ||
82
+ Array.isArray(metadata)
83
+ ) {
84
+ issue('invalid_request', 'A registered event type and metadata object are required.');
85
+ return result;
86
+ }
87
+ const info = getEventInfo(eventType);
88
+ const target = ownedField(fieldKey);
89
+ const routeValid =
90
+ info.category === 'record'
91
+ ? ownershipPath.length === 0 &&
92
+ (fieldKey === undefined ||
93
+ fieldKey === null ||
94
+ (eventType === 'change-status' &&
95
+ statusField?.enabled &&
96
+ fieldKey === statusField.data_name))
97
+ : info.category === 'repeatable'
98
+ ? resolver.repeatableSectionTree.has(fieldKey) && same(target.currentPath)
99
+ : typeof fieldKey === 'string' && target && same(target.parentPath);
100
+ if (!routeValid) {
101
+ issue(
102
+ 'invalid_routing_context',
103
+ 'The event target is not owned by the executing engine scope.'
104
+ );
105
+ return result;
106
+ }
107
+ for (const initializationIssue of manager.initializationIssues)
108
+ issue(initializationIssue.code, initializationIssue.message);
109
+ if (result.issues.length) return result;
110
+ let event;
111
+ try {
112
+ event = {
113
+ ...copy(metadata),
114
+ type: eventType,
115
+ fieldKey,
116
+ timestamp: Date.now(),
117
+ instancePath: copy(instancePath),
118
+ scopeId,
119
+ };
120
+ } catch {
121
+ issue('invalid_request', 'Event metadata must contain portable data.');
122
+ return result;
123
+ }
124
+ const validation = eventType === 'validate-record' || eventType === 'validate-repeatable';
125
+ const closing = /^(cancel|unload)-/.test(eventType);
126
+ const eventMap = manager.listeners.get(eventType);
127
+ const listeners = [...(eventMap?.get(fieldKey) || []), ...(eventMap?.get('*') || [])];
128
+ for (const callback of listeners) {
129
+ const scope = createEventOperationScope((operationName) => {
130
+ // Late async work cannot mutate this completed result or break another dispatch.
131
+ try {
132
+ manager.emitHandlerWarning(
133
+ 'late_event_operation_discarded',
134
+ { type: 'event', eventType, fieldName: fieldKey || null },
135
+ callback.name || '<anonymous>',
136
+ operationName
137
+ );
138
+ } catch {
139
+ /* Reporting is observational. */
140
+ }
141
+ });
142
+ let returned;
143
+ let collected;
144
+ try {
145
+ const source = manager.callbackSources.getSource(callback);
146
+ if (referenceCache.get(callback)?.source !== source)
147
+ referenceCache.set(callback, { source, references: getEventFieldReferences(source) });
148
+ let rejectedReference = false;
149
+ for (const name of referenceCache.get(callback).references) {
150
+ const field = fields.get(name);
151
+ if (!field || !readable(field.parentPath)) {
152
+ issue(
153
+ 'read_scope_restricted',
154
+ `Field '${name}' is not readable from the executing scope.`
155
+ );
156
+ rejectedReference = true;
157
+ }
158
+ }
159
+ if (rejectedReference) continue;
160
+ const context = { event: copy(event) };
161
+ for (const [key, value] of Object.entries(manager.eventContext)) {
162
+ if (!key.startsWith('$')) context[key] = value;
163
+ else {
164
+ const field = fields.get(key.slice(1));
165
+ if (field && readable(field.parentPath)) context[key] = copy(value);
166
+ }
167
+ }
168
+ context.event = copy(event);
169
+ returned = manager.executeHandlerWithContext(callback, event, scope, {
170
+ source,
171
+ context,
172
+ reporting: {
173
+ suppressConsoleWarning: true,
174
+ onDiagnostic: (diagnostic) => issue('handler_failed', diagnostic.message),
175
+ evalReporting: {
176
+ suppressConsole: true,
177
+ onDiagnostic: (diagnostic) => issue('read_scope_restricted', diagnostic.message),
178
+ },
179
+ },
180
+ });
181
+ if (returned && typeof returned.then === 'function') {
182
+ issue('async_handler_unsupported', 'Managed schema handlers must be synchronous.');
183
+ Promise.resolve(returned).catch(() => {});
184
+ }
185
+ } catch {
186
+ issue('handler_failed', 'The managed event handler failed.');
187
+ } finally {
188
+ collected = closeEventOperationScope(scope);
189
+ }
190
+ try {
191
+ if (returned?.type === 'UI_OPERATION' && !collected.includes(returned))
192
+ collected.push(copy(returned));
193
+ } catch {
194
+ issue('invalid_operation', 'The returned event operation must contain portable data.');
195
+ }
196
+ for (const operation of collected) {
197
+ if (operation?.type === 'EVENT_OPERATION') continue;
198
+ if (operation?.operation === 'INVALID') {
199
+ if (!validation)
200
+ issue('invalid_context', 'INVALID() is only available during managed validation.');
201
+ else
202
+ result.validationIssues.push({
203
+ message: operation.params.message,
204
+ instancePath: copy(instancePath),
205
+ });
206
+ } else if (validation || closing) {
207
+ issue(
208
+ 'operation_not_allowed',
209
+ 'Validation and close handlers cannot perform value or UI operations.'
210
+ );
211
+ } else if (operation?.type === 'FIELD_OPERATION' && operation.operation === 'SETVALUE') {
212
+ const field = fields.get(operation.params?.fieldDataName);
213
+ if (!field || !same(field.parentPath))
214
+ issue(
215
+ 'write_scope_restricted',
216
+ 'SETVALUE() may only write fields owned by the executing scope.'
217
+ );
218
+ else {
219
+ try {
220
+ result.operations.push(copy(operation));
221
+ } catch {
222
+ issue('invalid_operation', 'Event operations must contain portable data.');
223
+ }
224
+ }
225
+ } else {
226
+ try {
227
+ result.operations.push(copy(operation));
228
+ } catch {
229
+ issue('invalid_operation', 'Event operations must contain portable data.');
230
+ }
231
+ }
232
+ }
233
+ }
234
+ // A failed managed dispatch is not a partially applicable operation batch.
235
+ if (result.issues.length) result.operations = [];
236
+ return copy(result);
237
+ };
238
+ }
@@ -195,13 +195,22 @@ export class WarningSystem {
195
195
  * @param {Object} warning - Warning object
196
196
  */
197
197
  notifyCustomHandlers(warning, options = {}) {
198
+ const reportFailure = (error) => {
199
+ if (options.console ?? this.enableConsoleWarnings) {
200
+ try {
201
+ console.error('[form0] Warning handler failed:', error);
202
+ } catch {
203
+ // Reporting is observational, including a failed console adapter.
204
+ }
205
+ }
206
+ };
198
207
  for (const handler of this.warningHandlers) {
199
208
  try {
200
- handler(warning);
209
+ const result = handler(warning);
210
+ if (result && typeof result.then === 'function')
211
+ Promise.resolve(result).catch(reportFailure);
201
212
  } catch (err) {
202
- if (options.console ?? this.enableConsoleWarnings) {
203
- console.error('[form0] Warning handler failed:', err);
204
- }
213
+ reportFailure(err);
205
214
  // Don't remove the handler automatically - let the user decide
206
215
  }
207
216
  }
package/src/index.js CHANGED
@@ -85,3 +85,4 @@ export {
85
85
  validateFormAuthoringSchema,
86
86
  applyFormMutationBatch,
87
87
  } from './authoring/form-authoring.js';
88
+ export { INVALID } from './builtins/event/control/invalid.js';