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 +51 -1
- package/package.json +4 -4
- package/src/authoring/form-authoring.js +124 -13
- package/src/builtins/builtin-definitions.js +13 -0
- package/src/builtins/event/control/invalid.js +17 -0
- package/src/builtins/event/event-operations-collector.js +1 -0
- package/src/builtins/registry.js +3 -0
- package/src/engine/event-field-references.js +127 -0
- package/src/engine/event-operation-scope.js +1 -1
- package/src/engine/event-registry.js +9 -2
- package/src/engine/events.js +28 -6
- package/src/engine/form-engine.js +36 -0
- package/src/engine/managed-events.js +238 -0
- package/src/engine/warning-system.js +13 -4
- package/src/index.js +1 -0
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;
|
|
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.
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
}
|
package/src/builtins/registry.js
CHANGED
|
@@ -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
|
|
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
|
|
63
|
+
return Object.hasOwn(EVENT_REGISTRY, eventType);
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
/**
|
package/src/engine/events.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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