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 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.7.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
- const ctx = { ...helpers };
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
- for (const [name, identifier] of preparation.bindings)
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
  }
@@ -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 cacheKey = JSON.stringify(['body', keys, expr]);
78
- const fn = options.compileCache?.get(cacheKey) ?? new Function(...keys, expr);
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 cacheKey = JSON.stringify(['expression', keys, inlineExpression]);
93
- const fn =
94
- options.compileCache?.get(cacheKey) ??
95
- new Function(...keys, `return (${inlineExpression});`);
96
- options.compileCache?.set(cacheKey, fn);
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 = ['ALERT', 'SETVALUE', 'ON', 'OFF', 'INVALID'];
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 = null) {
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
- return {
30
- code: prepared.code,
31
- context: {
32
- ...context,
33
- [prepared.captureName]: capture,
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
- function prepare(code, context) {
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)) return bindPrepared(cached, context);
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) {
@@ -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
- contextWithEvent = bindEventBuiltins(contextWithEvent, eventBuiltins, scope);
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(functionCall, contextWithEvent, this.securityConfig, true, this.schema, {
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 = collectedOps.filter(
531
- (operation) => operation?.type !== 'EVENT_OPERATION'
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
- // EVENT_OPERATION processing is initialization-only. ON/OFF calls inside a handler neither
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 (result && result.type === 'UI_OPERATION' && !collectedOps.includes(result)) {
546
- operations.push(result);
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
+ }