form0-core 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +50 -0
  2. package/package.json +6 -3
  3. package/src/authoring/form-authoring.js +16 -26
  4. package/src/builtins/builtin-definitions.js +27 -0
  5. package/src/builtins/builtin-metadata.js +12 -0
  6. package/src/builtins/control/eval.js +11 -2
  7. package/src/builtins/registry.js +4 -0
  8. package/src/builtins/repeatable/repeatablesum.js +12 -0
  9. package/src/builtins/repeatable/repeatablevalues.js +12 -0
  10. package/src/engine/calculation-bindings.js +76 -0
  11. package/src/engine/calculation.js +114 -38
  12. package/src/engine/evaluator.js +11 -5
  13. package/src/engine/event-operation-scope.js +7 -3
  14. package/src/engine/event-policy.js +61 -0
  15. package/src/engine/event-registration.js +38 -0
  16. package/src/engine/event-source.js +41 -30
  17. package/src/engine/events.js +42 -14
  18. package/src/engine/expression-compilation.js +39 -0
  19. package/src/engine/form-engine.js +57 -22
  20. package/src/engine/managed-events.js +53 -27
  21. package/src/engine/record-context.js +133 -0
  22. package/src/engine/record-evaluator.js +883 -0
  23. package/src/engine/record-freshness.js +19 -0
  24. package/src/engine/submission-validation.js +130 -0
  25. package/src/index.js +7 -1
  26. package/src/schema/schema-validator.js +5 -0
  27. package/src/security/validation.js +4 -4
  28. package/src/utilities/calculation-dependencies.js +2 -1
  29. package/src/utilities/calculation-editor.js +94 -1
  30. package/src/utilities/form-event-editor.js +4 -0
  31. package/src/utilities/record-data.js +69 -0
  32. package/src/utilities/record-transformer.js +110 -39
  33. package/src/utilities/repeatable-collections.js +170 -0
  34. package/src/utilities/schema-revision.js +30 -0
@@ -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,8 +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 = Object.keys(secureContext);
58
- const values = Object.values(secureContext);
59
58
 
60
59
  // Execute expression
61
60
  const executeExpression = () => {
@@ -70,9 +69,10 @@ export function runExpression(
70
69
  isMultilineCalculationExpression(sourceExpression);
71
70
 
72
71
  if (isMultiLine) {
73
- // Execute as code block (recompile each time for now)
72
+ // Reuse a factory-owned compilation cache when supplied; values stay per evaluation.
74
73
  phase = 'syntax';
75
- const fn = new Function(...keys, expr);
74
+ const { fn, keys } = compileExpression(expr, secureContext, 'body', options.compileCache);
75
+ const values = keys.map((key) => secureContext[key]);
76
76
  phase = 'runtime';
77
77
  const result = fn(...values);
78
78
  // Check for consumed result from SETRESULT() calls in multiline code
@@ -85,7 +85,13 @@ export function runExpression(
85
85
  // Execute as expression (existing behavior)
86
86
  const inlineExpression = normalizeInlineCalculationExpression(expr);
87
87
  phase = 'syntax';
88
- const fn = new Function(...keys, `return (${inlineExpression});`);
88
+ const { fn, keys } = compileExpression(
89
+ inlineExpression,
90
+ secureContext,
91
+ 'expression',
92
+ options.compileCache
93
+ );
94
+ const values = keys.map((key) => secureContext[key]);
89
95
  phase = 'runtime';
90
96
  const result = fn(...values);
91
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,46 @@
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() {
4
+ export function createEventSourceRegistry(preparationCache = new Map()) {
5
5
  const sources = new WeakMap();
6
6
 
7
- function prepare(code, context) {
7
+ function bindPrepared(prepared, context, ownsContext) {
8
+ const propertyNames = new Map();
9
+ const capture = (callback, source, name) => {
10
+ sources.set(callback, source);
11
+ if (name && !callback.name)
12
+ Object.defineProperty(callback, 'name', { value: name, configurable: true });
13
+ return callback;
14
+ };
15
+ capture.key = (value, id) => {
16
+ const key = Reflect.ownKeys({ [value]: null })[0];
17
+ propertyNames.set(
18
+ id,
19
+ typeof key === 'symbol'
20
+ ? key.description === undefined
21
+ ? ''
22
+ : `[${key.description}]`
23
+ : key
24
+ );
25
+ return key;
26
+ };
27
+ capture.nameFor = (id) => propertyNames.get(id);
28
+
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 };
37
+ }
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) {
41
+ const cached = preparationCache?.get(code);
42
+ if (cached && !Object.hasOwn(context, cached.captureName))
43
+ return bindPrepared(cached, context, ownsContext);
8
44
  let tree;
9
45
  try {
10
46
  // Event scripts are Function bodies, rather than modules or global scripts.
@@ -27,7 +63,6 @@ export function createEventSourceRegistry() {
27
63
  let captureName = '__form0CaptureEventSource';
28
64
  while (identifiers.has(captureName) || Object.hasOwn(context, captureName)) captureName += '_';
29
65
  const edits = [];
30
- const propertyNames = new Map();
31
66
 
32
67
  function inferredName(node, parent) {
33
68
  if (node.id) return '';
@@ -120,33 +155,9 @@ export function createEventSourceRegistry() {
120
155
  prepared = prepared.slice(0, position) + text + prepared.slice(position);
121
156
  }
122
157
 
123
- const capture = (callback, source, name) => {
124
- sources.set(callback, source);
125
- if (name && !callback.name)
126
- Object.defineProperty(callback, 'name', { value: name, configurable: true });
127
- return callback;
128
- };
129
- capture.key = (value, id) => {
130
- const key = Reflect.ownKeys({ [value]: null })[0];
131
- propertyNames.set(
132
- id,
133
- typeof key === 'symbol'
134
- ? key.description === undefined
135
- ? ''
136
- : `[${key.description}]`
137
- : key
138
- );
139
- return key;
140
- };
141
- capture.nameFor = (id) => propertyNames.get(id);
142
-
143
- return {
144
- code: prepared,
145
- context: {
146
- ...context,
147
- [captureName]: capture,
148
- },
149
- };
158
+ const result = { code: prepared, captureName };
159
+ preparationCache?.set(code, result);
160
+ return bindPrepared(result, context, ownsContext);
150
161
  }
151
162
 
152
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,
@@ -17,7 +18,7 @@ const _loggedHandlers = new Set();
17
18
  * EventManager handles form event registration and execution
18
19
  */
19
20
  export class EventManager {
20
- constructor(schema = null, contextResolver = null, warningSystem = null) {
21
+ constructor(schema = null, contextResolver = null, warningSystem = null, preparation = {}) {
21
22
  this.listeners = new Map(); // eventType -> Map<fieldKey, callback[]>
22
23
  this.eventContext = null;
23
24
  this.securityConfig = null;
@@ -26,7 +27,9 @@ export class EventManager {
26
27
  this.contextResolver = contextResolver || (schema ? new ContextResolver(schema) : null);
27
28
  this.warningSystem = warningSystem || new WarningSystem();
28
29
  this.schema = schema;
29
- this.callbackSources = createEventSourceRegistry();
30
+ this.callbackSources = createEventSourceRegistry(preparation.sourceCache);
31
+ this.compilationCache = preparation.compilationCache;
32
+ this.referenceSourceCache = preparation.referenceSourceCache;
30
33
  this.initializationIssues = [];
31
34
  }
32
35
 
@@ -61,10 +64,11 @@ export class EventManager {
61
64
 
62
65
  try {
63
66
  // Execute the code to register event listeners (same security as calculated fields).
64
- const prepared = this.callbackSources.prepare(code, scopedContext);
67
+ const prepared = this.callbackSources.prepare(code, scopedContext, true);
65
68
  const result = runInEventOperationScope(scope, () =>
66
69
  runExpression(prepared.code, prepared.context, this.securityConfig, true, this.schema, {
67
70
  sourceExpression: code,
71
+ compileCache: this.compilationCache,
68
72
  onDiagnostic: (diagnostic) =>
69
73
  this.initializationIssues.push({
70
74
  code: 'initialization_failed',
@@ -298,13 +302,18 @@ export class EventManager {
298
302
  };
299
303
  }
300
304
 
301
- 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);
302
307
 
303
308
  // Execute the callback function with the scoped context
304
309
  // This ensures EVAL() and other builtins have access to field values
305
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);
306
313
  return runInEventOperationScope(scope, () =>
307
- runExpression(functionCall, contextWithEvent, this.securityConfig, true, this.schema, {
314
+ runExpression(prepared.code, prepared.context, this.securityConfig, true, this.schema, {
315
+ sourceExpression: functionCall,
316
+ compileCache: this.compilationCache,
308
317
  evaluateAsExpression: true,
309
318
  ...(managed?.reporting || {}),
310
319
  })
@@ -483,8 +492,10 @@ export class EventManager {
483
492
  * @param {Object} eventMetadata - Additional event metadata
484
493
  * @returns {Array} Array of operation descriptors
485
494
  */
486
- trigger(eventType, fieldKey, eventMetadata) {
495
+ trigger(eventType, fieldKey, eventMetadata, mode = 'edit') {
487
496
  const operations = [];
497
+ const registrations = [];
498
+ let rejectedBatch = false;
488
499
  const eventMap = this.listeners.get(eventType);
489
500
 
490
501
  if (!eventMap) return operations;
@@ -496,6 +507,7 @@ export class EventManager {
496
507
  timestamp: Date.now(),
497
508
  // TODO: Add more metadata as needed
498
509
  ...eventMetadata,
510
+ mode,
499
511
  };
500
512
 
501
513
  // Create execution context for operation validation
@@ -517,35 +529,51 @@ export class EventManager {
517
529
  try {
518
530
  // Execute the callback with current form context
519
531
  // This ensures EVAL() and other builtins have access to field values
520
- result = this.executeHandlerWithContext(callback, event, scope);
532
+ result = this.executeHandlerWithContext(callback, { ...event, mode }, scope);
521
533
  } catch (error) {
522
534
  // Simple error message for now
523
535
  console.warn(`[form0] Event listener failed: ${error.message}`);
524
536
  } finally {
525
537
  collectedOps = closeEventOperationScope(scope);
526
- const runtimeOps = collectedOps.filter(
527
- (operation) => operation?.type !== 'EVENT_OPERATION'
528
- );
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
+ }
529
547
 
530
548
  // Validate operations against context restrictions before adding them
531
549
  const validOps = this.validateOperations(runtimeOps, executionContext);
532
550
  operations.push(...validOps);
533
551
 
534
- // EVENT_OPERATION processing is initialization-only. ON/OFF calls inside a handler neither
535
- // 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.
536
553
  }
537
554
 
538
555
  try {
539
556
  this.reportAsyncHandlerResult(result, executionContext, handlerName);
540
557
  // For backward compatibility, still handle returned operations
541
- if (result && result.type === 'UI_OPERATION' && !collectedOps.includes(result)) {
542
- 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));
543
569
  }
544
570
  } catch (error) {
545
571
  console.warn(`[form0] Event listener failed: ${error.message}`);
546
572
  }
547
573
  });
548
574
 
575
+ if (rejectedBatch) return [];
576
+ this.processEventOperations(registrations);
549
577
  return operations;
550
578
  }
551
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
+ }
@@ -17,6 +17,9 @@ import { buildCalculationDependencyPlan } from '../utilities/calculation-depende
17
17
  import { createCalculationDiagnostics } from './calculation-diagnostics.js';
18
18
  import { formatLocalDate } from '../utilities/date-utils.js';
19
19
  import { createManagedEventDispatcher } from './managed-events.js';
20
+ import { validateInteractionMode } from './event-policy.js';
21
+ import { copyRecordData } from '../utilities/record-data.js';
22
+ import { areStructuredValuesEqual } from '../utilities/value-equality.js';
20
23
 
21
24
  /**
22
25
  * @typedef {Object} CalculationDiagnostic
@@ -53,6 +56,7 @@ import { createManagedEventDispatcher } from './managed-events.js';
53
56
  * @param {Object} [options.initialValues]
54
57
  * @param {Object} [options.helpers]
55
58
  * @param {Object} [options.security]
59
+ * @param {'edit'|'readonly'} [options.mode='edit'] View preserves supplied calculations/defaults.
56
60
  * @param {WarningSystem} [options.warningSystem]
57
61
  * @param {Array} [options.runtimeDiagnostics] Deprecated append-only compatibility adapter.
58
62
  * Use getDiagnostics() for the latest snapshot or onDiagnostics for evaluation notifications.
@@ -75,15 +79,18 @@ export function createFormEngine({
75
79
  diagnostics = {},
76
80
  onDiagnostics = null,
77
81
  eventScope = { instancePath: [] },
82
+ mode = 'edit',
78
83
  }) {
84
+ let interactionMode = validateInteractionMode(mode);
79
85
  const { schema: preparedSchema, buildingPlanMeta } = expandBuildingPlanSchema(schema);
80
86
 
81
87
  const contextResolver = new ContextResolver(preparedSchema.form);
82
88
  validateSchemaWithContext(preparedSchema.form, contextResolver);
83
89
 
84
90
  const { form } = preparedSchema;
85
- const values = { ...initialValues };
91
+ const values = copyRecordData(initialValues);
86
92
  const allFields = flattenFields(form.elements);
93
+ const pendingDefaults = new Map();
87
94
 
88
95
  // Create a lookup map for choice fields
89
96
  const choiceFieldMap = new Map();
@@ -104,7 +111,11 @@ export function createFormEngine({
104
111
  for (const field of allFields) {
105
112
  if (!(field.data_name in values)) {
106
113
  // Initialize field with default value if specified
107
- values[field.data_name] = getDefaultValue(field);
114
+ values[field.data_name] = getDefaultValue(
115
+ interactionMode === 'readonly' ? { ...field, default_value: null } : field
116
+ );
117
+ if (interactionMode === 'readonly')
118
+ pendingDefaults.set(field, copyRecordData(values[field.data_name]));
108
119
  } else if (field.type === 'SingleChoiceField' || field.type === 'BooleanField') {
109
120
  // Enrich SingleChoiceField and BooleanField values with labels from schema
110
121
  const fieldValue = values[field.data_name];
@@ -163,11 +174,16 @@ export function createFormEngine({
163
174
  const eventManager = new EventManager(form, contextResolver, sharedWarningSystem);
164
175
  eventManager.securityConfig = security; // Pass security config
165
176
  const eventHelpers = { ...eventBuiltins, ...helpers };
166
- const dispatchManagedEvent = createManagedEventDispatcher(eventManager, eventScope);
177
+ const getMode = () => interactionMode;
178
+ const dispatchManagedEvent = createManagedEventDispatcher(eventManager, eventScope, getMode);
167
179
 
168
180
  // Initialize event code if present
169
181
  if (form.events && form.events.code) {
170
- const eventContext = buildEventContext(values, eventHelpers, {});
182
+ const eventContext = buildEventContext(
183
+ interactionMode === 'readonly' ? copyRecordData(values) : values,
184
+ eventHelpers,
185
+ {}
186
+ );
171
187
  eventManager.initializeEventCode(form.events.code, eventContext);
172
188
  }
173
189
 
@@ -187,17 +203,18 @@ export function createFormEngine({
187
203
  function evalForm() {
188
204
  const frame = calculationDiagnostics.begin();
189
205
  try {
190
- evaluateCalculatedFields(
191
- form,
192
- values,
193
- allHelpers,
194
- security,
195
- contextResolver,
196
- sharedWarningSystem,
197
- runtimeDiagnostics,
198
- calculationPlan,
199
- calculationDiagnostics
200
- );
206
+ if (interactionMode === 'edit')
207
+ evaluateCalculatedFields(
208
+ form,
209
+ values,
210
+ allHelpers,
211
+ security,
212
+ contextResolver,
213
+ sharedWarningSystem,
214
+ runtimeDiagnostics,
215
+ calculationPlan,
216
+ calculationDiagnostics
217
+ );
201
218
  evaluateRequirement(form, values, required, conditionContext);
202
219
  evaluateVisibility(form, values, visible, conditionContext);
203
220
  evaluateReadOnly(form, values, read_only, conditionContext);
@@ -211,19 +228,35 @@ export function createFormEngine({
211
228
 
212
229
  function trigger(eventType, fieldKey, metadata = {}) {
213
230
  // Update event context with current values for event execution
214
- const eventContext = buildEventContext(values, eventHelpers, {
215
- eventType,
216
- fieldKey,
217
- ...metadata,
218
- });
231
+ const eventContext = buildEventContext(
232
+ interactionMode === 'readonly' ? copyRecordData(values) : values,
233
+ eventHelpers,
234
+ {
235
+ eventType,
236
+ fieldKey,
237
+ ...metadata,
238
+ }
239
+ );
219
240
  eventManager.eventContext = eventContext; // Update the event context
220
- return eventManager.trigger(eventType, fieldKey, metadata);
241
+ return eventManager.trigger(eventType, fieldKey, metadata, interactionMode);
221
242
  }
222
243
 
223
244
  function getState() {
224
245
  return { values, errors, visible, required, read_only };
225
246
  }
226
247
 
248
+ /** Host-owned mode changes do not replay events, restore a record or evaluate implicitly. */
249
+ function setMode(nextMode) {
250
+ interactionMode = validateInteractionMode(nextMode);
251
+ if (interactionMode === 'edit') {
252
+ for (const [field, placeholder] of pendingDefaults) {
253
+ if (areStructuredValuesEqual(values[field.data_name], placeholder))
254
+ values[field.data_name] = copyRecordData(getDefaultValue(field));
255
+ }
256
+ pendingDefaults.clear();
257
+ }
258
+ }
259
+
227
260
  /**
228
261
  * Dispatch synchronously within this engine's construction scope. No operations are applied.
229
262
  * @param {string} eventType Registered event name.
@@ -238,6 +271,8 @@ export function createFormEngine({
238
271
 
239
272
  return {
240
273
  eval: evalForm,
274
+ getMode,
275
+ setMode,
241
276
  trigger,
242
277
  triggerDetailed,
243
278
  getState,
@@ -249,7 +284,7 @@ export function createFormEngine({
249
284
  };
250
285
  }
251
286
 
252
- function getDefaultValue(field) {
287
+ export function getDefaultValue(field) {
253
288
  const spec = FIELD_SPECS[field.type];
254
289
  if (spec && spec.defaultProducer) {
255
290
  return spec.defaultProducer(field);