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.
- package/README.md +50 -0
- package/package.json +6 -3
- package/src/authoring/form-authoring.js +16 -26
- package/src/builtins/builtin-definitions.js +27 -0
- package/src/builtins/builtin-metadata.js +12 -0
- package/src/builtins/control/eval.js +11 -2
- package/src/builtins/registry.js +4 -0
- package/src/builtins/repeatable/repeatablesum.js +12 -0
- package/src/builtins/repeatable/repeatablevalues.js +12 -0
- package/src/engine/calculation-bindings.js +76 -0
- package/src/engine/calculation.js +114 -38
- package/src/engine/evaluator.js +11 -5
- 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 +41 -30
- package/src/engine/events.js +42 -14
- package/src/engine/expression-compilation.js +39 -0
- package/src/engine/form-engine.js +57 -22
- package/src/engine/managed-events.js +53 -27
- package/src/engine/record-context.js +133 -0
- package/src/engine/record-evaluator.js +883 -0
- package/src/engine/record-freshness.js +19 -0
- package/src/engine/submission-validation.js +130 -0
- package/src/index.js +7 -1
- package/src/schema/schema-validator.js +5 -0
- package/src/security/validation.js +4 -4
- package/src/utilities/calculation-dependencies.js +2 -1
- package/src/utilities/calculation-editor.js +94 -1
- package/src/utilities/form-event-editor.js +4 -0
- package/src/utilities/record-data.js +69 -0
- package/src/utilities/record-transformer.js +110 -39
- package/src/utilities/repeatable-collections.js +170 -0
- package/src/utilities/schema-revision.js +30 -0
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,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
|
-
//
|
|
72
|
+
// Reuse a factory-owned compilation cache when supplied; values stay per evaluation.
|
|
74
73
|
phase = 'syntax';
|
|
75
|
-
const fn =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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) {
|
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,
|
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
527
|
-
|
|
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
|
-
//
|
|
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 (
|
|
542
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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);
|