form0-core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +3 -2
- package/src/builtins/builtin-definitions.js +5 -0
- package/src/builtins/builtin-metadata.js +12 -0
- package/src/engine/calculation-bindings.js +76 -0
- package/src/engine/calculation.js +15 -2
- package/src/engine/evaluator.js +10 -12
- package/src/engine/event-operation-scope.js +7 -3
- package/src/engine/event-policy.js +61 -0
- package/src/engine/event-registration.js +38 -0
- package/src/engine/event-source.js +15 -12
- package/src/engine/events.js +36 -12
- package/src/engine/expression-compilation.js +39 -0
- package/src/engine/form-engine.js +56 -21
- package/src/engine/managed-events.js +26 -18
- package/src/engine/record-context.js +13 -6
- package/src/engine/record-evaluator.js +282 -56
- package/src/engine/submission-validation.js +5 -0
- package/src/index.js +4 -1
- package/src/security/validation.js +4 -4
- package/src/utilities/form-event-editor.js +4 -0
- package/src/utilities/record-data.js +50 -4
- package/src/utilities/record-transformer.js +110 -39
package/README.md
CHANGED
|
@@ -289,3 +289,20 @@ ordinary validation failures. Persist the returned `freshness` companion separat
|
|
|
289
289
|
and supply it to the host on reopening; do not clear stored pending status before persistence succeeds.
|
|
290
290
|
`managedEvents: true` enables root and participating closed-row custom validation only. No save or
|
|
291
291
|
closed-row lifecycle hooks are replayed. Renderer/worker/Native/CLI UI adoption is separate work.
|
|
292
|
+
|
|
293
|
+
For an open editor, `await evaluator.evaluateScope(snapshot, { instancePath: [] })` evaluates only
|
|
294
|
+
that record. Use complete ancestry to target a child. It reads current supplied ancestors and accepted
|
|
295
|
+
other rows, returning the selected `state`, built-in validation summary, changes and separate
|
|
296
|
+
execution issues/diagnostics without another complete tree. It leaves caller inputs and closed-row
|
|
297
|
+
calculations unchanged. See [the record evaluation contract](analyses/RECORD_EVALUATION_CONTRACT.md).
|
|
298
|
+
|
|
299
|
+
For accepted-snapshot viewing, supply `{ interactionMode: 'readonly' }` to `evaluateScope()`. It
|
|
300
|
+
computes field state and intrinsic/submission findings from supplied values without running
|
|
301
|
+
calculations or applying missing schema defaults. Editing is the default. Whole-tree evaluation and
|
|
302
|
+
submission preparation retain their accepted/refresh policies.
|
|
303
|
+
|
|
304
|
+
Scalar engines accept `mode: 'readonly' | 'edit'` (default `edit`) and expose `getMode()`/`setMode()`.
|
|
305
|
+
Call `eval()` after a host mode change. Events receive authoritative `event.mode`; view-time data
|
|
306
|
+
writes fail before host delegation. Load remains available for reads, alerts and scoped ON/OFF
|
|
307
|
+
registration. Move writable initialization to new/edit handlers when it must run after View → Edit.
|
|
308
|
+
See [the event contract](EVENT_CONTRACT.md) for permissions, lifecycle order and migration guidance.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"SECURITY.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/condition-scope.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 && node tests/record-evaluator.test.js && node tests/complex-record-evaluator.test.js",
|
|
15
|
+
"test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/condition-scope.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/expression-compilation.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 && node tests/record-evaluator.test.js && node tests/complex-record-evaluator.test.js && node tests/record-scope.test.js",
|
|
16
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
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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
}
|
|
53
53
|
],
|
|
54
54
|
"license": "MIT",
|
|
55
|
+
"funding": "https://opencollective.com/form0",
|
|
55
56
|
"bugs": {
|
|
56
57
|
"url": "https://github.com/paqu-io/form0-core/issues"
|
|
57
58
|
},
|
|
@@ -238,6 +238,7 @@ export const SETRESULT_METADATA = defineBuiltinMetadata({
|
|
|
238
238
|
|
|
239
239
|
export const ALERT_METADATA = defineBuiltinMetadata({
|
|
240
240
|
name: 'ALERT',
|
|
241
|
+
operation: { type: 'UI_OPERATION', effects: ['presentation'] },
|
|
241
242
|
category: 'event',
|
|
242
243
|
signature: "ALERT(title, message = '')",
|
|
243
244
|
description: 'Display an alert from a form event handler.',
|
|
@@ -247,6 +248,7 @@ export const ALERT_METADATA = defineBuiltinMetadata({
|
|
|
247
248
|
|
|
248
249
|
export const SETVALUE_METADATA = defineBuiltinMetadata({
|
|
249
250
|
name: 'SETVALUE',
|
|
251
|
+
operation: { type: 'FIELD_OPERATION', effects: ['record-write'] },
|
|
250
252
|
category: 'event',
|
|
251
253
|
signature: 'SETVALUE(fieldDataName, valueToSet)',
|
|
252
254
|
description: 'Set a field value from a form event handler.',
|
|
@@ -256,6 +258,7 @@ export const SETVALUE_METADATA = defineBuiltinMetadata({
|
|
|
256
258
|
|
|
257
259
|
export const ON_METADATA = defineBuiltinMetadata({
|
|
258
260
|
name: 'ON',
|
|
261
|
+
operation: { type: 'EVENT_OPERATION', effects: ['listeners'] },
|
|
259
262
|
category: 'event',
|
|
260
263
|
signature: 'ON(eventType, fieldKeyOrCallback, callback)',
|
|
261
264
|
description: 'Register an event handler within form event code.',
|
|
@@ -265,6 +268,7 @@ export const ON_METADATA = defineBuiltinMetadata({
|
|
|
265
268
|
|
|
266
269
|
export const OFF_METADATA = defineBuiltinMetadata({
|
|
267
270
|
name: 'OFF',
|
|
271
|
+
operation: { type: 'EVENT_OPERATION', effects: ['listeners'] },
|
|
268
272
|
category: 'event',
|
|
269
273
|
signature: 'OFF(eventType, fieldKeyOrCallback, callback)',
|
|
270
274
|
description: 'Remove event handlers within form event code.',
|
|
@@ -274,6 +278,7 @@ export const OFF_METADATA = defineBuiltinMetadata({
|
|
|
274
278
|
|
|
275
279
|
export const INVALID_METADATA = defineBuiltinMetadata({
|
|
276
280
|
name: 'INVALID',
|
|
281
|
+
operation: { type: 'VALIDATION_OPERATION', effects: ['validation'] },
|
|
277
282
|
category: 'event',
|
|
278
283
|
signature: 'INVALID(message)',
|
|
279
284
|
description:
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @property {string} description
|
|
9
9
|
* @property {string[]} examples
|
|
10
10
|
* @property {BuiltinContext[]} contexts
|
|
11
|
+
* @property {{type: string, effects: string[]}} [operation] Declared descriptor effects.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
export const BUILTIN_CONTEXTS = Object.freeze({
|
|
@@ -26,6 +27,14 @@ export function defineBuiltinMetadata(definition) {
|
|
|
26
27
|
...definition,
|
|
27
28
|
examples: Object.freeze([...(definition.examples ?? [])]),
|
|
28
29
|
contexts: Object.freeze([...(definition.contexts ?? [])]),
|
|
30
|
+
...(definition.operation
|
|
31
|
+
? {
|
|
32
|
+
operation: Object.freeze({
|
|
33
|
+
...definition.operation,
|
|
34
|
+
effects: Object.freeze([...definition.operation.effects]),
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
37
|
+
: {}),
|
|
29
38
|
});
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -40,5 +49,8 @@ export function cloneBuiltinMetadata(definition) {
|
|
|
40
49
|
...definition,
|
|
41
50
|
examples: [...definition.examples],
|
|
42
51
|
contexts: [...definition.contexts],
|
|
52
|
+
...(definition.operation
|
|
53
|
+
? { operation: { ...definition.operation, effects: [...definition.operation.effects] } }
|
|
54
|
+
: {}),
|
|
43
55
|
};
|
|
44
56
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Private conservative fast path for scalar expressions in a schema-bound record evaluator.
|
|
2
|
+
// Unknown calls, EVAL, arguments, member access and complex JavaScript retain full bindings.
|
|
3
|
+
import { parse } from 'acorn';
|
|
4
|
+
import { calculationBuiltins } from '../builtins/registry.js';
|
|
5
|
+
// These two canonical operations never consult ambient field context or invoke author callbacks.
|
|
6
|
+
// Other builtins keep full bindings, including math functions that can be overridden by a host.
|
|
7
|
+
const pureCalls = new Set(['SETRESULT', 'IF']);
|
|
8
|
+
export function prepareScalarBindings(code) {
|
|
9
|
+
const names = new Set(),
|
|
10
|
+
calls = new Set();
|
|
11
|
+
function visit(node) {
|
|
12
|
+
if (!node) return true;
|
|
13
|
+
switch (node.type) {
|
|
14
|
+
case 'Program':
|
|
15
|
+
return node.body.length === 1 && visit(node.body[0]);
|
|
16
|
+
case 'ExpressionStatement':
|
|
17
|
+
return visit(node.expression);
|
|
18
|
+
case 'Literal':
|
|
19
|
+
return !node.regex;
|
|
20
|
+
case 'Identifier':
|
|
21
|
+
if (!node.name.startsWith('$') && !['undefined', 'NaN', 'Infinity'].includes(node.name))
|
|
22
|
+
return false;
|
|
23
|
+
names.add(node.name);
|
|
24
|
+
return true;
|
|
25
|
+
case 'UnaryExpression':
|
|
26
|
+
return node.operator !== 'delete' && visit(node.argument);
|
|
27
|
+
case 'BinaryExpression':
|
|
28
|
+
case 'LogicalExpression':
|
|
29
|
+
return visit(node.left) && visit(node.right);
|
|
30
|
+
case 'ConditionalExpression':
|
|
31
|
+
return visit(node.test) && visit(node.consequent) && visit(node.alternate);
|
|
32
|
+
case 'CallExpression':
|
|
33
|
+
if (node.optional || node.callee.type !== 'Identifier' || !pureCalls.has(node.callee.name))
|
|
34
|
+
return false;
|
|
35
|
+
names.add(node.callee.name);
|
|
36
|
+
calls.add(node.callee.name);
|
|
37
|
+
return node.arguments.every(visit);
|
|
38
|
+
default:
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return visit(parse(code, { ecmaVersion: 'latest' }))
|
|
44
|
+
? { names, calls, fieldCount: [...names].filter((name) => name.startsWith('$')).length }
|
|
45
|
+
: null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function selectScalarBindings(preparation, values, helpers) {
|
|
51
|
+
const plan = preparation.scalar;
|
|
52
|
+
if (!plan) return null;
|
|
53
|
+
for (const name of plan.calls) if (helpers[name] !== calculationBuiltins[name]) return null;
|
|
54
|
+
const bindings = (preparation.scalarBindings ??= preparation.bindings.filter(([, identifier]) =>
|
|
55
|
+
plan.names.has(identifier)
|
|
56
|
+
));
|
|
57
|
+
// Missing/restricted names can fall through to host globals in legacy JavaScript.
|
|
58
|
+
// Specialize only when every field identifier resolves to a captured primitive value.
|
|
59
|
+
if (bindings.length !== plan.fieldCount) return null;
|
|
60
|
+
for (const [name] of bindings)
|
|
61
|
+
if (
|
|
62
|
+
!Object.hasOwn(values, name) ||
|
|
63
|
+
(values[name] !== null &&
|
|
64
|
+
!['undefined', 'string', 'number', 'boolean', 'bigint'].includes(typeof values[name]))
|
|
65
|
+
)
|
|
66
|
+
return null;
|
|
67
|
+
for (const name of plan.names)
|
|
68
|
+
if (
|
|
69
|
+
Object.hasOwn(helpers, name) &&
|
|
70
|
+
helpers[name] !== calculationBuiltins[name] &&
|
|
71
|
+
helpers[name] !== null &&
|
|
72
|
+
!['undefined', 'string', 'number', 'boolean', 'bigint'].includes(typeof helpers[name])
|
|
73
|
+
)
|
|
74
|
+
return null;
|
|
75
|
+
return { names: plan.names, bindings };
|
|
76
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prepareScalarBindings, selectScalarBindings } from './calculation-bindings.js';
|
|
1
2
|
import { analyzeRepeatableCollections } from '../utilities/repeatable-collections.js';
|
|
2
3
|
import { runExpression } from './evaluator.js';
|
|
3
4
|
import { ContextResolver } from './context-resolver.js';
|
|
@@ -111,7 +112,7 @@ function buildScopedContext(
|
|
|
111
112
|
diagnostics,
|
|
112
113
|
preparation = null
|
|
113
114
|
) {
|
|
114
|
-
|
|
115
|
+
let ctx;
|
|
115
116
|
|
|
116
117
|
const referencedFields = preparation?.references ?? extractStaticFieldReferences(expressionCode);
|
|
117
118
|
const access = (fieldName) => {
|
|
@@ -136,9 +137,20 @@ function buildScopedContext(
|
|
|
136
137
|
preparation.bindings ??= [...contextResolver.fieldOwnership.keys()]
|
|
137
138
|
.filter((name) => access(name) === 'accessible')
|
|
138
139
|
.map((name) => [name, `$${name}`]);
|
|
139
|
-
|
|
140
|
+
// Use fewer parameters only when syntax and current primitive bindings prove it safe.
|
|
141
|
+
// Keep the full context for dynamic expressions, helper overrides and object inputs.
|
|
142
|
+
const scalar = selectScalarBindings(preparation, values, helpers);
|
|
143
|
+
ctx = scalar
|
|
144
|
+
? Object.fromEntries(
|
|
145
|
+
[...scalar.names]
|
|
146
|
+
.filter((name) => Object.hasOwn(helpers, name))
|
|
147
|
+
.map((name) => [name, helpers[name]])
|
|
148
|
+
)
|
|
149
|
+
: { ...helpers };
|
|
150
|
+
for (const [name, identifier] of scalar?.bindings ?? preparation.bindings)
|
|
140
151
|
if (Object.hasOwn(values, name)) ctx[identifier] = values[name];
|
|
141
152
|
} else {
|
|
153
|
+
ctx = { ...helpers };
|
|
142
154
|
for (const [fieldName, value] of Object.entries(values)) {
|
|
143
155
|
const accessLevel = access(fieldName);
|
|
144
156
|
|
|
@@ -308,6 +320,7 @@ export function evaluateCalculatedField(
|
|
|
308
320
|
context: buildCalculationExecutionContext(schema, field.data_name, resolver),
|
|
309
321
|
references: extractStaticFieldReferences(field.calculate),
|
|
310
322
|
access: new Map(),
|
|
323
|
+
scalar: prepareScalarBindings(field.calculate),
|
|
311
324
|
};
|
|
312
325
|
execution.preparationCache.set(field.data_name, preparation);
|
|
313
326
|
}
|
package/src/engine/evaluator.js
CHANGED
|
@@ -3,6 +3,7 @@ import { __setEvalContext } from '../builtins/control/eval.js';
|
|
|
3
3
|
import { __setDataNamesContext } from '../builtins/schema/datanames.js';
|
|
4
4
|
import { validateExpression, createSecureContext, withTimeout } from '../security/validation.js';
|
|
5
5
|
import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
|
|
6
|
+
import { compileExpression } from './expression-compilation.js';
|
|
6
7
|
import {
|
|
7
8
|
isMultilineCalculationExpression,
|
|
8
9
|
normalizeInlineCalculationExpression,
|
|
@@ -54,10 +55,6 @@ export function runExpression(
|
|
|
54
55
|
|
|
55
56
|
// Create secure context based on security mode
|
|
56
57
|
const secureContext = createSecureContext(context, securityConfig);
|
|
57
|
-
const keys = options.compileCache
|
|
58
|
-
? Object.keys(secureContext).sort()
|
|
59
|
-
: Object.keys(secureContext);
|
|
60
|
-
const values = keys.map((key) => secureContext[key]);
|
|
61
58
|
|
|
62
59
|
// Execute expression
|
|
63
60
|
const executeExpression = () => {
|
|
@@ -74,9 +71,8 @@ export function runExpression(
|
|
|
74
71
|
if (isMultiLine) {
|
|
75
72
|
// Reuse a factory-owned compilation cache when supplied; values stay per evaluation.
|
|
76
73
|
phase = 'syntax';
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
options.compileCache?.set(cacheKey, fn);
|
|
74
|
+
const { fn, keys } = compileExpression(expr, secureContext, 'body', options.compileCache);
|
|
75
|
+
const values = keys.map((key) => secureContext[key]);
|
|
80
76
|
phase = 'runtime';
|
|
81
77
|
const result = fn(...values);
|
|
82
78
|
// Check for consumed result from SETRESULT() calls in multiline code
|
|
@@ -89,11 +85,13 @@ export function runExpression(
|
|
|
89
85
|
// Execute as expression (existing behavior)
|
|
90
86
|
const inlineExpression = normalizeInlineCalculationExpression(expr);
|
|
91
87
|
phase = 'syntax';
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
const { fn, keys } = compileExpression(
|
|
89
|
+
inlineExpression,
|
|
90
|
+
secureContext,
|
|
91
|
+
'expression',
|
|
92
|
+
options.compileCache
|
|
93
|
+
);
|
|
94
|
+
const values = keys.map((key) => secureContext[key]);
|
|
97
95
|
phase = 'runtime';
|
|
98
96
|
const result = fn(...values);
|
|
99
97
|
const consumed = __consumeResult();
|
|
@@ -2,8 +2,11 @@ import {
|
|
|
2
2
|
__invokeBoundEventBuiltin,
|
|
3
3
|
__runWithEventOperationScope,
|
|
4
4
|
} from '../builtins/event/event-operations-collector.js';
|
|
5
|
+
import { EVENT_BUILTIN_DEFINITIONS } from '../builtins/builtin-definitions.js';
|
|
5
6
|
|
|
6
|
-
const EVENT_OPERATION_BUILTINS =
|
|
7
|
+
const EVENT_OPERATION_BUILTINS = EVENT_BUILTIN_DEFINITIONS.filter((entry) => entry.operation).map(
|
|
8
|
+
(entry) => entry.name
|
|
9
|
+
);
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Create an operation scope owned by one synchronous event execution.
|
|
@@ -36,9 +39,10 @@ export function createEventOperationScope(onLateOperation) {
|
|
|
36
39
|
/**
|
|
37
40
|
* Bind canonical event operation builtins to their originating scope.
|
|
38
41
|
* Intentional helper overrides are left untouched.
|
|
42
|
+
* ownsContext is private: only a fresh context owned by this execution may be modified.
|
|
39
43
|
*/
|
|
40
|
-
export function bindEventBuiltins(context, canonicalBuiltins, scope) {
|
|
41
|
-
const boundContext = { ...context };
|
|
44
|
+
export function bindEventBuiltins(context, canonicalBuiltins, scope, ownsContext = false) {
|
|
45
|
+
const boundContext = ownsContext ? context : { ...context };
|
|
42
46
|
|
|
43
47
|
for (const name of EVENT_OPERATION_BUILTINS) {
|
|
44
48
|
const builtin = canonicalBuiltins[name];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { BUILTIN_DEFINITIONS } from '../builtins/builtin-definitions.js';
|
|
2
|
+
|
|
3
|
+
const operations = new Map(
|
|
4
|
+
BUILTIN_DEFINITIONS.filter((entry) => entry.operation).map((entry) => [
|
|
5
|
+
entry.name,
|
|
6
|
+
entry.operation,
|
|
7
|
+
])
|
|
8
|
+
);
|
|
9
|
+
const knownEffects = new Set(['record-write', 'presentation', 'listeners', 'validation']);
|
|
10
|
+
|
|
11
|
+
export function validateInteractionMode(mode) {
|
|
12
|
+
if (mode !== 'edit' && mode !== 'readonly') throw new Error('mode must be "edit" or "readonly".');
|
|
13
|
+
return mode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Shared descriptor policy; permission comes from the engine, never event metadata. */
|
|
17
|
+
export function eventOperationIssue(operation, mode, eventType) {
|
|
18
|
+
const declaration = operations.get(operation?.operation);
|
|
19
|
+
if (
|
|
20
|
+
!declaration ||
|
|
21
|
+
declaration.type !== operation?.type ||
|
|
22
|
+
!declaration.effects.length ||
|
|
23
|
+
declaration.effects.some((effect) => !knownEffects.has(effect))
|
|
24
|
+
)
|
|
25
|
+
return {
|
|
26
|
+
code: 'invalid_operation',
|
|
27
|
+
message: 'Event operations require declared, supported effects.',
|
|
28
|
+
};
|
|
29
|
+
const validation = /^(validate)-/.test(eventType);
|
|
30
|
+
const closing = /^(cancel|unload)-/.test(eventType);
|
|
31
|
+
if (declaration.effects.includes('validation')) {
|
|
32
|
+
return validation
|
|
33
|
+
? null
|
|
34
|
+
: {
|
|
35
|
+
code: 'invalid_context',
|
|
36
|
+
message: 'INVALID() is only available during managed validation.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (validation || closing)
|
|
40
|
+
return {
|
|
41
|
+
code: 'operation_not_allowed',
|
|
42
|
+
message: 'Validation and close handlers cannot perform value, listener or UI operations.',
|
|
43
|
+
};
|
|
44
|
+
if (mode === 'readonly' && declaration.effects.includes('record-write'))
|
|
45
|
+
return {
|
|
46
|
+
code: 'readonly_operation_not_allowed',
|
|
47
|
+
message:
|
|
48
|
+
'View-mode handlers cannot change record data. Guard SETVALUE() with event.mode or move writable initialization to a new/edit handler.',
|
|
49
|
+
};
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function reportEventOperationIssue(warningSystem, issue, eventType) {
|
|
54
|
+
warningSystem.emitWarning({
|
|
55
|
+
type: 'EVENT_OPERATION_WARNING',
|
|
56
|
+
reason: issue.code,
|
|
57
|
+
severity: 'error',
|
|
58
|
+
message: issue.message,
|
|
59
|
+
executionContext: { type: 'event', eventType, fieldName: null },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parse } from 'acorn';
|
|
2
|
+
import { ON } from '../builtins/event/control/on.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonical ON calls with literal routes only create handlers; their bodies are not executed.
|
|
6
|
+
* Anything else needs the complete initialization context. Do not restrict handler body syntax.
|
|
7
|
+
*/
|
|
8
|
+
export function isLiteralEventRegistration(code, on) {
|
|
9
|
+
if (on !== ON || typeof code !== 'string') return false;
|
|
10
|
+
try {
|
|
11
|
+
const tree = parse(code, { ecmaVersion: 'latest', sourceType: 'commonjs' });
|
|
12
|
+
return (
|
|
13
|
+
tree.body.length > 0 &&
|
|
14
|
+
tree.body.every((statement) => {
|
|
15
|
+
const call = statement.expression;
|
|
16
|
+
if (
|
|
17
|
+
statement.type !== 'ExpressionStatement' ||
|
|
18
|
+
call.type !== 'CallExpression' ||
|
|
19
|
+
call.optional ||
|
|
20
|
+
call.callee.type !== 'Identifier' ||
|
|
21
|
+
call.callee.name !== 'ON' ||
|
|
22
|
+
![2, 3].includes(call.arguments.length)
|
|
23
|
+
)
|
|
24
|
+
return false;
|
|
25
|
+
const callback = call.arguments.at(-1);
|
|
26
|
+
return (
|
|
27
|
+
['FunctionExpression', 'ArrowFunctionExpression'].includes(callback.type) &&
|
|
28
|
+
call.arguments
|
|
29
|
+
.slice(0, -1)
|
|
30
|
+
.every((argument) => argument.type === 'Literal' && typeof argument.value === 'string')
|
|
31
|
+
);
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
} catch {
|
|
35
|
+
// Existing validation/execution owns syntax diagnostics.
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { parse } from 'acorn';
|
|
2
2
|
|
|
3
3
|
/** Retains authored callback source without depending on engine-specific toString output. */
|
|
4
|
-
export function createEventSourceRegistry(preparationCache =
|
|
4
|
+
export function createEventSourceRegistry(preparationCache = new Map()) {
|
|
5
5
|
const sources = new WeakMap();
|
|
6
6
|
|
|
7
|
-
function bindPrepared(prepared, context) {
|
|
7
|
+
function bindPrepared(prepared, context, ownsContext) {
|
|
8
8
|
const propertyNames = new Map();
|
|
9
9
|
const capture = (callback, source, name) => {
|
|
10
10
|
sources.set(callback, source);
|
|
@@ -26,18 +26,21 @@ export function createEventSourceRegistry(preparationCache = null) {
|
|
|
26
26
|
};
|
|
27
27
|
capture.nameFor = (id) => propertyNames.get(id);
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
};
|
|
29
|
+
const captured = ownsContext ? context : { ...context };
|
|
30
|
+
Object.defineProperty(captured, prepared.captureName, {
|
|
31
|
+
value: capture,
|
|
32
|
+
enumerable: true,
|
|
33
|
+
configurable: true,
|
|
34
|
+
writable: true,
|
|
35
|
+
});
|
|
36
|
+
return { code: prepared.code, context: captured };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
// Metadata may be attached in place only to an execution-owned context, never host/engine state.
|
|
40
|
+
function prepare(code, context, ownsContext = false) {
|
|
39
41
|
const cached = preparationCache?.get(code);
|
|
40
|
-
if (cached && !Object.hasOwn(context, cached.captureName))
|
|
42
|
+
if (cached && !Object.hasOwn(context, cached.captureName))
|
|
43
|
+
return bindPrepared(cached, context, ownsContext);
|
|
41
44
|
let tree;
|
|
42
45
|
try {
|
|
43
46
|
// Event scripts are Function bodies, rather than modules or global scripts.
|
|
@@ -154,7 +157,7 @@ export function createEventSourceRegistry(preparationCache = null) {
|
|
|
154
157
|
|
|
155
158
|
const result = { code: prepared, captureName };
|
|
156
159
|
preparationCache?.set(code, result);
|
|
157
|
-
return bindPrepared(result, context);
|
|
160
|
+
return bindPrepared(result, context, ownsContext);
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
function getSource(callback) {
|
package/src/engine/events.js
CHANGED
|
@@ -3,6 +3,7 @@ import { eventBuiltins } from '../builtins/registry.js';
|
|
|
3
3
|
import { ContextResolver } from './context-resolver.js';
|
|
4
4
|
import { WarningSystem } from './warning-system.js';
|
|
5
5
|
import { createEventSourceRegistry } from './event-source.js';
|
|
6
|
+
import { eventOperationIssue, reportEventOperationIssue } from './event-policy.js';
|
|
6
7
|
import {
|
|
7
8
|
bindEventBuiltins,
|
|
8
9
|
closeEventOperationScope,
|
|
@@ -63,7 +64,7 @@ export class EventManager {
|
|
|
63
64
|
|
|
64
65
|
try {
|
|
65
66
|
// Execute the code to register event listeners (same security as calculated fields).
|
|
66
|
-
const prepared = this.callbackSources.prepare(code, scopedContext);
|
|
67
|
+
const prepared = this.callbackSources.prepare(code, scopedContext, true);
|
|
67
68
|
const result = runInEventOperationScope(scope, () =>
|
|
68
69
|
runExpression(prepared.code, prepared.context, this.securityConfig, true, this.schema, {
|
|
69
70
|
sourceExpression: code,
|
|
@@ -301,13 +302,17 @@ export class EventManager {
|
|
|
301
302
|
};
|
|
302
303
|
}
|
|
303
304
|
|
|
304
|
-
|
|
305
|
+
// Each branch above supplies a private execution context; the manager base stays untouched.
|
|
306
|
+
contextWithEvent = bindEventBuiltins(contextWithEvent, eventBuiltins, scope, true);
|
|
305
307
|
|
|
306
308
|
// Execute the callback function with the scoped context
|
|
307
309
|
// This ensures EVAL() and other builtins have access to field values
|
|
308
310
|
const functionCall = `(${callbackCode})(event)`;
|
|
311
|
+
// Retain nested ON callbacks created during a handler without depending on toString (Hermes).
|
|
312
|
+
const prepared = this.callbackSources.prepare(functionCall, contextWithEvent, true);
|
|
309
313
|
return runInEventOperationScope(scope, () =>
|
|
310
|
-
runExpression(
|
|
314
|
+
runExpression(prepared.code, prepared.context, this.securityConfig, true, this.schema, {
|
|
315
|
+
sourceExpression: functionCall,
|
|
311
316
|
compileCache: this.compilationCache,
|
|
312
317
|
evaluateAsExpression: true,
|
|
313
318
|
...(managed?.reporting || {}),
|
|
@@ -487,8 +492,10 @@ export class EventManager {
|
|
|
487
492
|
* @param {Object} eventMetadata - Additional event metadata
|
|
488
493
|
* @returns {Array} Array of operation descriptors
|
|
489
494
|
*/
|
|
490
|
-
trigger(eventType, fieldKey, eventMetadata) {
|
|
495
|
+
trigger(eventType, fieldKey, eventMetadata, mode = 'edit') {
|
|
491
496
|
const operations = [];
|
|
497
|
+
const registrations = [];
|
|
498
|
+
let rejectedBatch = false;
|
|
492
499
|
const eventMap = this.listeners.get(eventType);
|
|
493
500
|
|
|
494
501
|
if (!eventMap) return operations;
|
|
@@ -500,6 +507,7 @@ export class EventManager {
|
|
|
500
507
|
timestamp: Date.now(),
|
|
501
508
|
// TODO: Add more metadata as needed
|
|
502
509
|
...eventMetadata,
|
|
510
|
+
mode,
|
|
503
511
|
};
|
|
504
512
|
|
|
505
513
|
// Create execution context for operation validation
|
|
@@ -521,35 +529,51 @@ export class EventManager {
|
|
|
521
529
|
try {
|
|
522
530
|
// Execute the callback with current form context
|
|
523
531
|
// This ensures EVAL() and other builtins have access to field values
|
|
524
|
-
result = this.executeHandlerWithContext(callback, event, scope);
|
|
532
|
+
result = this.executeHandlerWithContext(callback, { ...event, mode }, scope);
|
|
525
533
|
} catch (error) {
|
|
526
534
|
// Simple error message for now
|
|
527
535
|
console.warn(`[form0] Event listener failed: ${error.message}`);
|
|
528
536
|
} finally {
|
|
529
537
|
collectedOps = closeEventOperationScope(scope);
|
|
530
|
-
const runtimeOps =
|
|
531
|
-
|
|
532
|
-
|
|
538
|
+
const runtimeOps = [];
|
|
539
|
+
for (const operation of collectedOps) {
|
|
540
|
+
const rejected = eventOperationIssue(operation, mode, eventType);
|
|
541
|
+
if (rejected) {
|
|
542
|
+
rejectedBatch = true;
|
|
543
|
+
reportEventOperationIssue(this.warningSystem, rejected, eventType);
|
|
544
|
+
} else if (operation.type === 'EVENT_OPERATION') registrations.push(operation);
|
|
545
|
+
else runtimeOps.push(operation);
|
|
546
|
+
}
|
|
533
547
|
|
|
534
548
|
// Validate operations against context restrictions before adding them
|
|
535
549
|
const validOps = this.validateOperations(runtimeOps, executionContext);
|
|
536
550
|
operations.push(...validOps);
|
|
537
551
|
|
|
538
|
-
//
|
|
539
|
-
// mutate the listener registry nor leak internal registration descriptors to the host.
|
|
552
|
+
// Listener updates are committed after the entire permitted dispatch, never sent to hosts.
|
|
540
553
|
}
|
|
541
554
|
|
|
542
555
|
try {
|
|
543
556
|
this.reportAsyncHandlerResult(result, executionContext, handlerName);
|
|
544
557
|
// For backward compatibility, still handle returned operations
|
|
545
|
-
if (
|
|
546
|
-
|
|
558
|
+
if (
|
|
559
|
+
typeof result?.type === 'string' &&
|
|
560
|
+
result.type.endsWith('_OPERATION') &&
|
|
561
|
+
!collectedOps.includes(result)
|
|
562
|
+
) {
|
|
563
|
+
const rejected = eventOperationIssue(result, mode, eventType);
|
|
564
|
+
if (rejected) {
|
|
565
|
+
rejectedBatch = true;
|
|
566
|
+
reportEventOperationIssue(this.warningSystem, rejected, eventType);
|
|
567
|
+
} else if (result.type === 'EVENT_OPERATION') registrations.push(result);
|
|
568
|
+
else operations.push(...this.validateOperations([result], executionContext));
|
|
547
569
|
}
|
|
548
570
|
} catch (error) {
|
|
549
571
|
console.warn(`[form0] Event listener failed: ${error.message}`);
|
|
550
572
|
}
|
|
551
573
|
});
|
|
552
574
|
|
|
575
|
+
if (rejectedBatch) return [];
|
|
576
|
+
this.processEventOperations(registrations);
|
|
553
577
|
return operations;
|
|
554
578
|
}
|
|
555
579
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Owned by the supplied compilation cache. Keep parameter names and functions only;
|
|
2
|
+
// never retain record values, helper closures, permissions or diagnostic callbacks.
|
|
3
|
+
const layoutsByCache = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export function compileExpression(code, context, mode, cache) {
|
|
6
|
+
const incomingKeys = Object.keys(context);
|
|
7
|
+
const body = mode === 'body' ? code : `return (${code});`;
|
|
8
|
+
if (!cache) return { keys: incomingKeys, fn: new Function(...incomingKeys, body) };
|
|
9
|
+
|
|
10
|
+
let expressions = layoutsByCache.get(cache);
|
|
11
|
+
if (!expressions) {
|
|
12
|
+
expressions = new Map();
|
|
13
|
+
layoutsByCache.set(cache, expressions);
|
|
14
|
+
}
|
|
15
|
+
const expressionKey = JSON.stringify([mode, code]);
|
|
16
|
+
const layouts = expressions.get(expressionKey) || [];
|
|
17
|
+
const index = layouts.findIndex(
|
|
18
|
+
(layout) =>
|
|
19
|
+
layout.incomingKeys.length === incomingKeys.length &&
|
|
20
|
+
layout.incomingKeys.every((key, position) => key === incomingKeys[position])
|
|
21
|
+
);
|
|
22
|
+
const existing = layouts[index];
|
|
23
|
+
// Respect explicit clearing/replacement of the caller-owned compilation cache.
|
|
24
|
+
if (existing && cache.get(existing.cacheKey) === existing.fn) return existing;
|
|
25
|
+
|
|
26
|
+
const keys = [...incomingKeys].sort();
|
|
27
|
+
const cacheKey = JSON.stringify([mode, keys, code]);
|
|
28
|
+
const fn = cache.get(cacheKey) ?? new Function(...keys, body);
|
|
29
|
+
cache.set(cacheKey, fn);
|
|
30
|
+
const layout = { incomingKeys, keys, cacheKey, fn };
|
|
31
|
+
const replacement =
|
|
32
|
+
index >= 0 ? index : layouts.findIndex((entry) => entry.cacheKey === cacheKey);
|
|
33
|
+
if (replacement >= 0) layouts[replacement] = layout;
|
|
34
|
+
else {
|
|
35
|
+
layouts.push(layout);
|
|
36
|
+
}
|
|
37
|
+
expressions.set(expressionKey, layouts);
|
|
38
|
+
return layout;
|
|
39
|
+
}
|