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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getEventInfo, isValidEventType } from './event-registry.js';
|
|
2
2
|
import { closeEventOperationScope, createEventOperationScope } from './event-operation-scope.js';
|
|
3
3
|
import { getEventFieldReferences } from './event-field-references.js';
|
|
4
|
+
import { eventOperationIssue, reportEventOperationIssue } from './event-policy.js';
|
|
4
5
|
|
|
5
6
|
// Portable defensive copies also work in runtimes without structuredClone (including Hermes).
|
|
6
7
|
function copy(value, seen = new Map()) {
|
|
@@ -30,25 +31,38 @@ function copy(value, seen = new Map()) {
|
|
|
30
31
|
return result;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
const managedReferenceCaches = new WeakMap();
|
|
35
|
+
const managedFieldCaches = new WeakMap();
|
|
36
|
+
const managedRepeatableCaches = new WeakMap();
|
|
37
|
+
|
|
33
38
|
/** Internal adapter; the public synchronous API is engine.triggerDetailed(). */
|
|
34
|
-
export function createManagedEventDispatcher(manager, constructionScope) {
|
|
39
|
+
export function createManagedEventDispatcher(manager, constructionScope, getMode = () => 'edit') {
|
|
35
40
|
const instancePath = copy(constructionScope?.instancePath);
|
|
36
41
|
const scopeId = constructionScope?.scopeId;
|
|
37
42
|
if (scopeId !== undefined && (typeof scopeId !== 'string' || !scopeId.trim()))
|
|
38
43
|
throw new Error('eventScope.scopeId must be a nonempty string when supplied.');
|
|
39
44
|
const resolver = manager.contextResolver;
|
|
40
|
-
|
|
45
|
+
if (!managedFieldCaches.has(resolver))
|
|
46
|
+
managedFieldCaches.set(resolver, new Map(resolver.fieldOwnership));
|
|
47
|
+
const fields = managedFieldCaches.get(resolver);
|
|
48
|
+
if (!managedRepeatableCaches.has(resolver))
|
|
49
|
+
managedRepeatableCaches.set(
|
|
50
|
+
resolver,
|
|
51
|
+
new Map(
|
|
52
|
+
[...resolver.repeatableSectionTree.values()].map((entry) => [
|
|
53
|
+
JSON.stringify([entry.parentPath, entry.preferredKey]),
|
|
54
|
+
entry,
|
|
55
|
+
])
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
const repeatableIndex = managedRepeatableCaches.get(resolver);
|
|
41
59
|
const statusField = manager.schema?.status_field;
|
|
42
60
|
if (statusField?.enabled && typeof statusField.data_name === 'string')
|
|
43
61
|
fields.set(statusField.data_name, { field: statusField, parentPath: [] });
|
|
44
62
|
if (!Array.isArray(instancePath)) throw new Error('eventScope.instancePath must be an array.');
|
|
45
63
|
const ownershipPath = [];
|
|
46
64
|
for (const segment of instancePath) {
|
|
47
|
-
const repeatable =
|
|
48
|
-
(entry) =>
|
|
49
|
-
entry.preferredKey === segment?.repeatableKey &&
|
|
50
|
-
JSON.stringify(entry.parentPath) === JSON.stringify(ownershipPath)
|
|
51
|
-
);
|
|
65
|
+
const repeatable = repeatableIndex.get(JSON.stringify([ownershipPath, segment?.repeatableKey]));
|
|
52
66
|
if (
|
|
53
67
|
!repeatable ||
|
|
54
68
|
JSON.stringify(repeatable.parentPath) !== JSON.stringify(ownershipPath) ||
|
|
@@ -70,8 +84,10 @@ export function createManagedEventDispatcher(manager, constructionScope) {
|
|
|
70
84
|
const readable = (path) =>
|
|
71
85
|
path.length <= ownershipPath.length && path.every((key, index) => ownershipPath[index] === key);
|
|
72
86
|
const ownedField = (name) => fields.get(name) || resolver.repeatableSectionTree.get(name);
|
|
73
|
-
|
|
87
|
+
if (!managedReferenceCaches.has(manager)) managedReferenceCaches.set(manager, new WeakMap());
|
|
88
|
+
const referenceCache = managedReferenceCaches.get(manager);
|
|
74
89
|
return function dispatch(eventType, fieldKey, metadata = {}) {
|
|
90
|
+
const mode = getMode();
|
|
75
91
|
const result = { operations: [], validationIssues: [], issues: [] };
|
|
76
92
|
const issue = (code, message) =>
|
|
77
93
|
result.issues.push({
|
|
@@ -122,13 +138,13 @@ export function createManagedEventDispatcher(manager, constructionScope) {
|
|
|
122
138
|
timestamp: Date.now(),
|
|
123
139
|
instancePath: copy(instancePath),
|
|
124
140
|
scopeId,
|
|
141
|
+
mode,
|
|
125
142
|
};
|
|
126
143
|
} catch {
|
|
127
144
|
issue('invalid_request', 'Event metadata must contain portable data.');
|
|
128
145
|
return result;
|
|
129
146
|
}
|
|
130
|
-
const
|
|
131
|
-
const closing = /^(cancel|unload)-/.test(eventType);
|
|
147
|
+
const registrations = [];
|
|
132
148
|
const eventMap = manager.listeners.get(eventType);
|
|
133
149
|
const listeners = [...(eventMap?.get(fieldKey) || []), ...(eventMap?.get('*') || [])];
|
|
134
150
|
for (const callback of listeners) {
|
|
@@ -149,8 +165,12 @@ export function createManagedEventDispatcher(manager, constructionScope) {
|
|
|
149
165
|
let collected;
|
|
150
166
|
try {
|
|
151
167
|
const source = manager.callbackSources.getSource(callback);
|
|
152
|
-
if (referenceCache.get(callback)?.source !== source)
|
|
153
|
-
|
|
168
|
+
if (referenceCache.get(callback)?.source !== source) {
|
|
169
|
+
const references =
|
|
170
|
+
manager.referenceSourceCache?.get(source) ?? getEventFieldReferences(source);
|
|
171
|
+
manager.referenceSourceCache?.set(source, references);
|
|
172
|
+
referenceCache.set(callback, { source, references });
|
|
173
|
+
}
|
|
154
174
|
let rejectedReference = false;
|
|
155
175
|
for (const name of referenceCache.get(callback).references) {
|
|
156
176
|
const field = fields.get(name);
|
|
@@ -194,26 +214,31 @@ export function createManagedEventDispatcher(manager, constructionScope) {
|
|
|
194
214
|
collected = closeEventOperationScope(scope);
|
|
195
215
|
}
|
|
196
216
|
try {
|
|
197
|
-
if (
|
|
217
|
+
if (
|
|
218
|
+
typeof returned?.type === 'string' &&
|
|
219
|
+
returned.type.endsWith('_OPERATION') &&
|
|
220
|
+
!collected.includes(returned)
|
|
221
|
+
)
|
|
198
222
|
collected.push(copy(returned));
|
|
199
223
|
} catch {
|
|
200
224
|
issue('invalid_operation', 'The returned event operation must contain portable data.');
|
|
201
225
|
}
|
|
202
226
|
for (const operation of collected) {
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
227
|
+
const rejected = eventOperationIssue(operation, mode, eventType);
|
|
228
|
+
if (rejected) {
|
|
229
|
+
issue(rejected.code, rejected.message);
|
|
230
|
+
if (
|
|
231
|
+
rejected.code === 'readonly_operation_not_allowed' ||
|
|
232
|
+
rejected.code === 'invalid_operation'
|
|
233
|
+
)
|
|
234
|
+
reportEventOperationIssue(manager.warningSystem, rejected, eventType);
|
|
235
|
+
} else if (operation.type === 'EVENT_OPERATION') {
|
|
236
|
+
registrations.push(operation);
|
|
237
|
+
} else if (operation.operation === 'INVALID') {
|
|
238
|
+
result.validationIssues.push({
|
|
239
|
+
message: operation.params.message,
|
|
240
|
+
instancePath: copy(instancePath),
|
|
241
|
+
});
|
|
217
242
|
} else if (operation?.type === 'FIELD_OPERATION' && operation.operation === 'SETVALUE') {
|
|
218
243
|
const field = fields.get(operation.params?.fieldDataName);
|
|
219
244
|
if (!field || !same(field.parentPath))
|
|
@@ -239,6 +264,7 @@ export function createManagedEventDispatcher(manager, constructionScope) {
|
|
|
239
264
|
}
|
|
240
265
|
// A failed managed dispatch is not a partially applicable operation batch.
|
|
241
266
|
if (result.issues.length) result.operations = [];
|
|
267
|
+
else manager.processEventOperations(registrations);
|
|
242
268
|
return copy(result);
|
|
243
269
|
};
|
|
244
270
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ContextResolver } from './context-resolver.js';
|
|
2
|
+
import { expandBuildingPlanSchema } from '../schema/building-plan-expander.js';
|
|
3
|
+
import { validateSchemaWithContext } from '../schema/schema-validator.js';
|
|
4
|
+
import { buildCalculationDependencyPlan } from '../utilities/calculation-dependencies.js';
|
|
5
|
+
import { analyzeRepeatableCollections } from '../utilities/repeatable-collections.js';
|
|
6
|
+
import { createConditionContext } from './condition-context.js';
|
|
7
|
+
import { copyRecordData } from '../utilities/record-data.js';
|
|
8
|
+
|
|
9
|
+
/** Shared immutable schema preparation; never owns live row engines. */
|
|
10
|
+
export function prepareRecordContext(inputSchema, warningSystem = null) {
|
|
11
|
+
const sourceSchema = copyRecordData(inputSchema);
|
|
12
|
+
const { schema } = expandBuildingPlanSchema(sourceSchema);
|
|
13
|
+
const resolver = new ContextResolver(schema.form);
|
|
14
|
+
validateSchemaWithContext(schema.form, resolver);
|
|
15
|
+
const calculationPlan = buildCalculationDependencyPlan(schema.form, resolver);
|
|
16
|
+
const conditionContext = createConditionContext(schema.form, resolver, warningSystem);
|
|
17
|
+
const templates = new Map();
|
|
18
|
+
const repeatableByPath = new Map(
|
|
19
|
+
[...resolver.repeatableSectionTree.values()].map((entry) => [
|
|
20
|
+
JSON.stringify(entry.currentPath),
|
|
21
|
+
entry,
|
|
22
|
+
])
|
|
23
|
+
);
|
|
24
|
+
const calculations = new Map();
|
|
25
|
+
for (const [name, node] of calculationPlan.nodesByDataName) {
|
|
26
|
+
calculations.set(name, {
|
|
27
|
+
...node,
|
|
28
|
+
collection: analyzeRepeatableCollections(node.field.calculate, resolver, name),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function template(elements, path) {
|
|
32
|
+
const fields = [];
|
|
33
|
+
const chains = new Map();
|
|
34
|
+
function project(items, ancestors = []) {
|
|
35
|
+
return items.map((field) => {
|
|
36
|
+
fields.push(field);
|
|
37
|
+
chains.set(field.data_name, ancestors);
|
|
38
|
+
if (field.type === 'RepeatableSection') {
|
|
39
|
+
const key = resolver.repeatableSectionTree.get(field.data_name).preferredKey;
|
|
40
|
+
template(field.elements || [], [...path, key]);
|
|
41
|
+
return { ...field, elements: [] };
|
|
42
|
+
}
|
|
43
|
+
if (field.type === 'Section' || field.type === 'BuildingPlanSection')
|
|
44
|
+
return {
|
|
45
|
+
...field,
|
|
46
|
+
elements: project(field.elements || [], [...ancestors, field.data_name]),
|
|
47
|
+
};
|
|
48
|
+
return field;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const localForm = { elements: project(elements) };
|
|
52
|
+
const status = path.length === 0 ? schema.form.status_field : null;
|
|
53
|
+
if (status?.enabled) {
|
|
54
|
+
fields.push(status);
|
|
55
|
+
chains.set(status.data_name, []);
|
|
56
|
+
}
|
|
57
|
+
const result = {
|
|
58
|
+
path,
|
|
59
|
+
fields,
|
|
60
|
+
chains,
|
|
61
|
+
form: localForm,
|
|
62
|
+
conditions: { ...conditionContext, fields },
|
|
63
|
+
};
|
|
64
|
+
templates.set(JSON.stringify(path), result);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
template(schema.form.elements, []);
|
|
68
|
+
return {
|
|
69
|
+
schema,
|
|
70
|
+
sourceSchema,
|
|
71
|
+
resolver,
|
|
72
|
+
calculationPlan,
|
|
73
|
+
templates,
|
|
74
|
+
calculations,
|
|
75
|
+
repeatableByPath,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function visitRecordScopes(snapshot, context, visit) {
|
|
80
|
+
for (const scope of iterateRecordScopes(snapshot, context)) visit(scope);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Same traversal for synchronous validation and budgeted private-tree preparation. */
|
|
84
|
+
export function* iterateRecordScopes(snapshot, context) {
|
|
85
|
+
function* walk(record, path, instancePath, ancestors, participating) {
|
|
86
|
+
const template = context.templates.get(JSON.stringify(path));
|
|
87
|
+
const scope = { record, template, instancePath, ancestors, participating };
|
|
88
|
+
yield scope;
|
|
89
|
+
for (const field of template.fields) {
|
|
90
|
+
if (field.type !== 'RepeatableSection') continue;
|
|
91
|
+
const info = context.resolver.repeatableSectionTree.get(field.data_name);
|
|
92
|
+
const rows = record.repeatable?.[info.preferredKey] ?? [];
|
|
93
|
+
if (!Array.isArray(rows))
|
|
94
|
+
throw new Error(`RepeatableSection '${field.data_name}' must contain an array of rows.`);
|
|
95
|
+
const identities = new Set();
|
|
96
|
+
for (let index = 0; index < rows.length; index++) {
|
|
97
|
+
if (!(index in rows)) continue;
|
|
98
|
+
const row = rows[index];
|
|
99
|
+
if (!row || typeof row !== 'object' || Array.isArray(row))
|
|
100
|
+
throw new Error('Repeatable rows must be record objects.');
|
|
101
|
+
const id = row.id ?? row.record_id;
|
|
102
|
+
const identified =
|
|
103
|
+
(typeof id === 'string' && id.trim() !== '') ||
|
|
104
|
+
(typeof id === 'number' && Number.isFinite(id));
|
|
105
|
+
if (identified) {
|
|
106
|
+
const identity = `${typeof id}:${id}`;
|
|
107
|
+
if (identities.has(identity))
|
|
108
|
+
throw new Error(`Duplicate row identity in '${field.data_name}'.`);
|
|
109
|
+
identities.add(identity);
|
|
110
|
+
}
|
|
111
|
+
const segment = {
|
|
112
|
+
repeatableKey: info.preferredKey,
|
|
113
|
+
instanceId: identified ? id : null,
|
|
114
|
+
index,
|
|
115
|
+
identity: identified ? 'id' : 'position',
|
|
116
|
+
};
|
|
117
|
+
const visible =
|
|
118
|
+
record.visible?.[field.data_name] !== false &&
|
|
119
|
+
(template.chains.get(field.data_name) || []).every(
|
|
120
|
+
(name) => record.visible?.[name] !== false
|
|
121
|
+
);
|
|
122
|
+
yield* walk(
|
|
123
|
+
row,
|
|
124
|
+
info.currentPath,
|
|
125
|
+
[...instancePath, segment],
|
|
126
|
+
[...ancestors, scope],
|
|
127
|
+
participating && visible
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
yield* walk(snapshot, [], [], [], true);
|
|
133
|
+
}
|