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.
@@ -1,16 +1,21 @@
1
- import { prepareRecordContext, visitRecordScopes } from './record-context.js';
2
- import { validateSubmissionWithContext, validateScope } from './submission-validation.js';
1
+ import { prepareRecordContext, visitRecordScopes, iterateRecordScopes } from './record-context.js';
2
+ import {
3
+ validateSubmissionWithContext,
4
+ validateScope,
5
+ summarizeSubmissionFindings,
6
+ } from './submission-validation.js';
3
7
  import { getDefaultValue } from './form-engine.js';
4
8
  import { evaluateCalculatedField } from './calculation.js';
5
9
  import { evaluateVisibility, evaluateRequirement, evaluateReadOnly } from './conditions.js';
6
10
  import { createCalculationDiagnostics } from './calculation-diagnostics.js';
7
11
  import { createManagedEventDispatcher } from './managed-events.js';
12
+ import { isLiteralEventRegistration } from './event-registration.js';
8
13
  import { EventManager } from './events.js';
9
14
  import { WarningSystem } from './warning-system.js';
10
15
  import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
11
16
  import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
12
17
  import { validateFieldValue } from '../schema/field-value-registry.js';
13
- import { copyRecordData } from '../utilities/record-data.js';
18
+ import { copyRecordData, copyRecordDataCooperatively } from '../utilities/record-data.js';
14
19
  import { areStructuredValuesEqual } from '../utilities/value-equality.js';
15
20
  import { projectCollectionValue } from '../utilities/repeatable-collections.js';
16
21
  import { getFormSchemaRevision } from '../utilities/schema-revision.js';
@@ -34,6 +39,42 @@ function problem(code, message) {
34
39
  return Object.assign(new Error(message), { code });
35
40
  }
36
41
 
42
+ function sameInstancePath(left, right) {
43
+ return (
44
+ left.length === right.length &&
45
+ left.every((part, index) => {
46
+ const other = right[index];
47
+ return (
48
+ part.repeatableKey === other.repeatableKey &&
49
+ part.identity === other.identity &&
50
+ (part.identity === 'id' ? part.instanceId === other.instanceId : part.index === other.index)
51
+ );
52
+ })
53
+ );
54
+ }
55
+
56
+ function validateInstancePath(path) {
57
+ if (
58
+ !Array.isArray(path) ||
59
+ path.some(
60
+ (part) =>
61
+ !part ||
62
+ typeof part.repeatableKey !== 'string' ||
63
+ !part.repeatableKey ||
64
+ !Number.isSafeInteger(part.index) ||
65
+ part.index < 0 ||
66
+ !['id', 'position'].includes(part.identity) ||
67
+ (part.identity === 'id' &&
68
+ !(
69
+ (typeof part.instanceId === 'string' && part.instanceId.trim()) ||
70
+ (typeof part.instanceId === 'number' && Number.isFinite(part.instanceId))
71
+ )) ||
72
+ (part.identity === 'position' && part.instanceId != null)
73
+ )
74
+ )
75
+ throw problem('invalid_instance_path', 'A complete record instancePath is required.');
76
+ }
77
+
37
78
  function scheduler(options) {
38
79
  let started = clock();
39
80
  const check = () => {
@@ -103,8 +144,25 @@ function scheduler(options) {
103
144
  * @property {string} [reviewId] Single-use factory-owned review handle.
104
145
  * @property {Array<'refresh'|'retain'|'cancel'>} [choices]
105
146
  *
147
+ * @typedef {Object} RecordScopeEvaluationOptions
148
+ * @property {'edit'|'readonly'} [interactionMode='edit'] Readonly computes field state from supplied values without calculations or defaults.
149
+ * @property {RecordInstanceSegment[]} [instancePath=[]] Complete record ancestry; an empty path selects root.
150
+ * @property {AbortSignal} [signal]
151
+ * @property {number} [budgetMs=8]
152
+ * @property {() => (void|Promise<void>)} [yieldToHost]
153
+ *
154
+ * @typedef {Object} RecordScopeEvaluationResult
155
+ * @property {'evaluated'|'failed'|'cancelled'} status
156
+ * @property {RecordInstanceSegment[]} [instancePath] Resolved identity and current ordering on success.
157
+ * @property {{values: Object, errors: Object, visible: Object, required: Object, read_only: Object}|null} state
158
+ * @property {import('./submission-validation.js').SubmissionValidationSummary} [validationSummary]
159
+ * @property {RecordFieldChange[]} [changes]
160
+ * @property {Object[]} diagnostics
161
+ * @property {Object[]} issues
162
+ *
106
163
  * @typedef {Object} RecordEvaluator
107
164
  * @property {(snapshot: RecordSnapshot, options?: RecordEvaluationOptions) => Promise<RecordEvaluationResult>} evaluate
165
+ * @property {(snapshot: RecordSnapshot, options?: RecordScopeEvaluationOptions) => Promise<RecordScopeEvaluationResult>} evaluateScope Evaluates only the selected editor record, without a complete tree response.
108
166
  * @property {(snapshot: RecordSnapshot, options?: SubmissionPreparationOptions) => Promise<RecordEvaluationResult>} prepareSubmission
109
167
  */
110
168
 
@@ -149,24 +207,52 @@ export function createRecordEvaluator({
149
207
  const calculationPreparationCache = new Map();
150
208
  const eventSourceCache = new Map();
151
209
  const eventReferenceCache = new Map();
210
+ const literalEventRegistration = isLiteralEventRegistration(
211
+ context.schema.form.events?.code,
212
+ Object.hasOwn(fixedHelpers, 'ON') ? fixedHelpers.ON : eventBuiltins.ON
213
+ );
152
214
  const evaluatorId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
153
215
  let generation = 0;
154
216
  let review = null;
155
217
 
156
- async function run(input, options = {}) {
218
+ async function run(input, options = {}, scopePath = null) {
157
219
  const mode = options.mode ?? 'accepted';
158
220
  if (!['accepted', 'refresh'].includes(mode))
159
221
  throw problem('invalid_request', 'Evaluation mode must be accepted or refresh.');
160
- input = copyRecordData(input);
161
- const snapshot = copyRecordData(input);
222
+ const scoped = scopePath !== null;
223
+ const viewing = scoped && options.interactionMode === 'readonly';
224
+ if (
225
+ scoped &&
226
+ options.interactionMode !== undefined &&
227
+ !['edit', 'readonly'].includes(options.interactionMode)
228
+ )
229
+ throw problem('invalid_request', 'interactionMode must be edit or readonly.');
230
+ if (options.signal?.aborted) throw problem('cancelled', 'Record evaluation was cancelled.');
231
+ const snapshot = scoped ? input : copyRecordData(input);
162
232
  if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
163
233
  throw problem('invalid_record_tree', 'A runtime record snapshot is required.');
164
234
  const pause = scheduler(options);
165
- await pause();
235
+ if (!scoped) await pause();
236
+ else if (options.signal?.aborted)
237
+ throw problem('cancelled', 'Record evaluation was cancelled.');
166
238
  const scopes = [];
167
239
  const byDefinition = new Map();
168
- visitRecordScopes(snapshot, context, (scope) => {
169
- const { record, template } = scope;
240
+ let selectedScope = null;
241
+ const prepareScope = (scope) => {
242
+ let { record } = scope;
243
+ const { template } = scope;
244
+ scope.sourceRecord = record;
245
+ // Capture only the calculation values needed by the change report. A second complete
246
+ // record-tree copy is unnecessary; all execution happens against our private snapshot.
247
+ if (!scoped)
248
+ scope.beforeCalculations = new Map(
249
+ template.fields
250
+ .filter((field) => field.type === 'CalculatedField')
251
+ .map((field) => [
252
+ field.data_name,
253
+ copyRecordData(record.values?.[field.data_name] ?? null),
254
+ ])
255
+ );
170
256
  if (
171
257
  record.values !== undefined &&
172
258
  (!record.values || typeof record.values !== 'object' || Array.isArray(record.values))
@@ -179,14 +265,23 @@ export function createRecordEvaluator({
179
265
  Array.isArray(record.repeatable))
180
266
  )
181
267
  throw problem('invalid_record_tree', 'Record repeatable collections must be an object.');
182
- record.values ??= {};
183
- record.errors = {};
184
- record.visible = {};
185
- record.required = {};
186
- record.read_only = {};
187
- for (const field of template.fields)
188
- if (!Object.hasOwn(record.values, field.data_name))
189
- record.values[field.data_name] = copyRecordData(getDefaultValue(field));
268
+ if (scoped && sameInstancePath(scope.instancePath, scopePath)) {
269
+ selectedScope = scope;
270
+ scope.beforeValues = copyRecordData(record.values || {});
271
+ record = scope.record = { ...record, values: copyRecordData(record.values || {}) };
272
+ }
273
+ if (!scoped || selectedScope === scope) {
274
+ record.values ??= {};
275
+ record.errors = {};
276
+ record.visible = {};
277
+ record.required = {};
278
+ record.read_only = {};
279
+ for (const field of template.fields)
280
+ if (!Object.hasOwn(record.values, field.data_name))
281
+ record.values[field.data_name] = copyRecordData(
282
+ getDefaultValue(viewing ? { ...field, default_value: null } : field)
283
+ );
284
+ }
190
285
  scope.uid = scopes.length;
191
286
  scope.lineage = [...scope.ancestors, scope];
192
287
  scope.nodes = new Map();
@@ -194,10 +289,24 @@ export function createRecordEvaluator({
194
289
  const key = JSON.stringify(template.path);
195
290
  if (!byDefinition.has(key)) byDefinition.set(key, []);
196
291
  byDefinition.get(key).push(scope);
197
- });
292
+ };
293
+ // Live scoped reads must all be captured before the first yield. Full runs already own
294
+ // their captured input, so scope preparation can give the host control between rows.
295
+ if (scoped) visitRecordScopes(snapshot, context, prepareScope);
296
+ else
297
+ for (const scope of iterateRecordScopes(snapshot, context)) {
298
+ prepareScope(scope);
299
+ await pause();
300
+ }
301
+ if (scoped && !selectedScope)
302
+ throw problem('invalid_instance_path', 'No record matches this instancePath.');
198
303
  const nodes = [];
199
304
  for (const scope of scopes) {
200
- if (scope.instancePath.length && mode === 'accepted') continue;
305
+ if (
306
+ viewing ||
307
+ (scoped ? scope !== selectedScope : scope.instancePath.length && mode === 'accepted')
308
+ )
309
+ continue;
201
310
  for (const field of scope.template.fields)
202
311
  if (field.type === 'CalculatedField' && field.calculate) {
203
312
  const definition = context.calculations.get(field.data_name);
@@ -212,6 +321,7 @@ export function createRecordEvaluator({
212
321
  scope.nodes.set(field.data_name, node);
213
322
  nodes.push(node);
214
323
  }
324
+ if (!scoped) await pause();
215
325
  }
216
326
  const ownerScope = (scope, name) => {
217
327
  const info = context.resolver.fieldOwnership.get(name);
@@ -236,6 +346,28 @@ export function createRecordEvaluator({
236
346
  );
237
347
  return queryCache.get(key);
238
348
  }
349
+ if (scoped) {
350
+ const captured = new Set([selectedScope]);
351
+ for (const ancestor of selectedScope.ancestors) {
352
+ ancestor.record = { values: copyRecordData(ancestor.sourceRecord.values || {}) };
353
+ captured.add(ancestor);
354
+ }
355
+ // Capture only the fields actually read from closed collections, before yielding.
356
+ for (const node of nodes)
357
+ for (const call of node.definition.collection.calls)
358
+ if (!node.definition.collection.issues.length)
359
+ for (const row of rows(selectedScope, call.section)) {
360
+ if (row === selectedScope) continue;
361
+ if (!captured.has(row)) {
362
+ row.record = { values: {} };
363
+ captured.add(row);
364
+ }
365
+ row.record.values[call.target] = copyRecordData(
366
+ row.sourceRecord.values?.[call.target]
367
+ );
368
+ }
369
+ await pause();
370
+ }
239
371
  for (const node of nodes) {
240
372
  for (const name of node.definition.directReferences) {
241
373
  const dependency = ownerScope(node.scope, name)?.nodes.get(name);
@@ -247,6 +379,7 @@ export function createRecordEvaluator({
247
379
  const dependency = row.nodes.get(call.target);
248
380
  if (dependency) node.dependencies.add(dependency);
249
381
  }
382
+ await pause();
250
383
  }
251
384
  const ordered = [];
252
385
  const visiting = [];
@@ -336,7 +469,7 @@ export function createRecordEvaluator({
336
469
  rows(node.scope, section).map((row) => {
337
470
  const dependency = row.nodes.get(target);
338
471
  if (dependency) execute(dependency);
339
- return copyRecordData(projectCollectionValue(field, row.record.values[target]));
472
+ return copyRecordData(projectCollectionValue(field, row.record.values?.[target]));
340
473
  })
341
474
  );
342
475
  }
@@ -415,6 +548,7 @@ export function createRecordEvaluator({
415
548
  await pause();
416
549
  }
417
550
  for (const scope of scopes) {
551
+ if (scoped && scope !== selectedScope) continue;
418
552
  const values = readable(scope);
419
553
  evaluateRequirement(
420
554
  scope.template.form,
@@ -441,14 +575,13 @@ export function createRecordEvaluator({
441
575
  await pause();
442
576
  }
443
577
  const changes = [];
444
- visitRecordScopes(snapshot, context, (scope) => {
445
- // Locate the original row by captured indices, retaining ID-zero identities in reports.
446
- let original = input;
447
- for (const segment of scope.instancePath)
448
- original = original.repeatable?.[segment.repeatableKey]?.[segment.index];
578
+ for (const scope of scopes) {
579
+ if (scoped && scope !== selectedScope) continue;
449
580
  for (const field of scope.template.fields)
450
581
  if (field.type === 'CalculatedField') {
451
- const before = original?.values?.[field.data_name] ?? null;
582
+ const before = scoped
583
+ ? (scope.beforeValues[field.data_name] ?? null)
584
+ : scope.beforeCalculations.get(field.data_name);
452
585
  const after = scope.record.values[field.data_name] ?? null;
453
586
  if (!areStructuredValuesEqual(before, after))
454
587
  changes.push({
@@ -459,7 +592,45 @@ export function createRecordEvaluator({
459
592
  after: copyRecordData(after),
460
593
  });
461
594
  }
462
- });
595
+ await pause();
596
+ }
597
+ if (scoped) {
598
+ // A scoped caller need not carry cached ancestor visibility maps. Resolve participation
599
+ // from the current supplied ancestors without refreshing any ancestor calculations.
600
+ selectedScope.participating = true;
601
+ for (const [index, ancestor] of selectedScope.ancestors.entries()) {
602
+ const visible = {};
603
+ evaluateVisibility(
604
+ ancestor.template.form,
605
+ readable(ancestor),
606
+ visible,
607
+ ancestor.template.conditions
608
+ );
609
+ const key = selectedScope.instancePath[index].repeatableKey;
610
+ const section = ancestor.template.fields.find(
611
+ (field) =>
612
+ field.type === 'RepeatableSection' &&
613
+ context.resolver.repeatableSectionTree.get(field.data_name).preferredKey === key
614
+ );
615
+ if (
616
+ visible[section.data_name] === false ||
617
+ !(ancestor.template.chains.get(section.data_name) || []).every(
618
+ (name) => visible[name] !== false
619
+ )
620
+ )
621
+ selectedScope.participating = false;
622
+ }
623
+ const { values, errors, visible, required, read_only } = selectedScope.record;
624
+ return {
625
+ status: 'evaluated',
626
+ instancePath: copyRecordData(selectedScope.instancePath),
627
+ state: { values, errors, visible, required, read_only },
628
+ validationSummary: summarizeSubmissionFindings(validateScope(selectedScope)),
629
+ changes,
630
+ diagnostics: reported,
631
+ issues: [],
632
+ };
633
+ }
463
634
  return { status: 'evaluated', snapshot, changes, diagnostics: reported, issues: [] };
464
635
  }
465
636
 
@@ -490,16 +661,27 @@ export function createRecordEvaluator({
490
661
  );
491
662
  eventManager.securityConfig = fixedSecurity;
492
663
  const initialContext = { ...eventBuiltins, ...fixedHelpers };
493
- for (const [name, info] of context.resolver.fieldOwnership)
494
- initialContext[`$${name}`] = copyRecordData(getDefaultValue(info.field));
495
- for (const [name, value] of Object.entries(values))
496
- initialContext[`$${name}`] = copyRecordData(value);
497
- if (context.schema.form.events?.code)
664
+ if (literalEventRegistration) {
665
+ // These calls only register functions. Handlers still execute with fresh scoped values.
498
666
  eventManager.initializeEventCode(context.schema.form.events.code, initialContext);
667
+ for (const [name, value] of Object.entries(values))
668
+ initialContext[`$${name}`] = copyRecordData(value);
669
+ } else {
670
+ for (const [name, info] of context.resolver.fieldOwnership)
671
+ initialContext[`$${name}`] = copyRecordData(getDefaultValue(info.field));
672
+ for (const [name, value] of Object.entries(values))
673
+ initialContext[`$${name}`] = copyRecordData(value);
674
+ if (context.schema.form.events?.code)
675
+ eventManager.initializeEventCode(context.schema.form.events.code, initialContext);
676
+ }
499
677
  eventManager.eventContext = initialContext;
500
- const dispatch = createManagedEventDispatcher(eventManager, {
501
- instancePath: scope.instancePath,
502
- });
678
+ const dispatch = createManagedEventDispatcher(
679
+ eventManager,
680
+ {
681
+ instancePath: scope.instancePath,
682
+ },
683
+ () => 'readonly'
684
+ );
503
685
  const row = scope.instancePath.length > 0;
504
686
  const definition = row
505
687
  ? context.repeatableByPath.get(JSON.stringify(scope.template.path))
@@ -546,6 +728,25 @@ export function createRecordEvaluator({
546
728
  }
547
729
  }
548
730
 
731
+ /** Evaluate one editor without refreshing or copying its accepted descendants. */
732
+ async function evaluateScope(snapshot, options = {}) {
733
+ try {
734
+ const path = options.instancePath ?? [];
735
+ validateInstancePath(path);
736
+ const result = await run(snapshot, { ...options, mode: 'accepted' }, path);
737
+ if (typeof onDiagnostics === 'function') {
738
+ try {
739
+ Promise.resolve(onDiagnostics(copyRecordData(result.diagnostics))).catch(() => {});
740
+ } catch {
741
+ /* Observational. */
742
+ }
743
+ }
744
+ return result;
745
+ } catch (error) {
746
+ return { ...failure(error), state: null };
747
+ }
748
+ }
749
+
549
750
  /**
550
751
  * Prepare only: no save hooks, persistence, editor acceptance or timestamp generation.
551
752
  * Resume a required decision with {decision:{reviewId, action:'refresh'|'retain'|'cancel'}}.
@@ -570,6 +771,7 @@ export function createRecordEvaluator({
570
771
  allowRetain: options.allowRetain === true,
571
772
  managedEvents: options.managedEvents === true,
572
773
  };
774
+ if (options.signal?.aborted) throw problem('cancelled', 'Record evaluation was cancelled.');
573
775
  let fresh, retained, selected;
574
776
  if (options.decision) {
575
777
  const decision = options.decision;
@@ -602,7 +804,10 @@ export function createRecordEvaluator({
602
804
  retained = prior.retained;
603
805
  selected = decision.action === 'retain' ? retained : fresh;
604
806
  } else {
605
- const source = copyRecordData(snapshot);
807
+ // run() captures its input before the first yield. Automatic submission has no
808
+ // retained branch, so only confirmation/drafts need another accepted-source copy.
809
+ const source =
810
+ intent === 'draft' || mode === 'confirm' ? copyRecordData(snapshot) : snapshot;
606
811
  fresh = await run(source, { ...options, mode: 'refresh' });
607
812
  if (attempt !== generation)
608
813
  throw problem(
@@ -619,20 +824,32 @@ export function createRecordEvaluator({
619
824
  );
620
825
  if (intent === 'submit' && mode === 'confirm' && childChanges.length) {
621
826
  const id = `${evaluatorId}:${attempt}`;
622
- review = { id, source, settings, fresh, retained };
623
- return {
624
- status: 'requires_decision',
625
- eligible: false,
626
- snapshot: null,
627
- proposal: copyRecordData(fresh.snapshot),
628
- reviewId: id,
629
- changes: copyRecordData(fresh.changes),
630
- validationSummary: validateSubmissionWithContext(fresh.snapshot, context),
631
- diagnostics: copyRecordData(fresh.diagnostics),
632
- issues: [],
633
- freshness: createRecordFreshness(schemaRevision, childChanges),
634
- choices: settings.allowRetain ? ['refresh', 'retain', 'cancel'] : ['refresh', 'cancel'],
635
- };
827
+ const pendingReview = { id, source, settings, fresh, retained };
828
+ const result = await copyRecordDataCooperatively(
829
+ {
830
+ status: 'requires_decision',
831
+ eligible: false,
832
+ snapshot: null,
833
+ proposal: fresh.snapshot,
834
+ reviewId: id,
835
+ changes: fresh.changes,
836
+ validationSummary: validateSubmissionWithContext(fresh.snapshot, context),
837
+ diagnostics: fresh.diagnostics,
838
+ issues: [],
839
+ freshness: createRecordFreshness(schemaRevision, childChanges),
840
+ choices: settings.allowRetain
841
+ ? ['refresh', 'retain', 'cancel']
842
+ : ['refresh', 'cancel'],
843
+ },
844
+ scheduler(options)
845
+ );
846
+ if (attempt !== generation)
847
+ throw problem(
848
+ 'stale_preparation',
849
+ 'A newer submission preparation superseded this attempt.'
850
+ );
851
+ review = pendingReview;
852
+ return result;
636
853
  }
637
854
  selected = intent === 'draft' ? retained : fresh;
638
855
  }
@@ -644,14 +861,23 @@ export function createRecordEvaluator({
644
861
  'stale_preparation',
645
862
  'A newer submission preparation superseded this attempt.'
646
863
  );
647
- return copyRecordData({
648
- ...prepared,
649
- changes: fresh.changes,
650
- freshness: createRecordFreshness(schemaRevision, pending ? childChanges : []),
651
- });
864
+ const result = await copyRecordDataCooperatively(
865
+ {
866
+ ...prepared,
867
+ changes: fresh.changes,
868
+ freshness: createRecordFreshness(schemaRevision, pending ? childChanges : []),
869
+ },
870
+ scheduler(options)
871
+ );
872
+ if (attempt !== generation)
873
+ throw problem(
874
+ 'stale_preparation',
875
+ 'A newer submission preparation superseded this attempt.'
876
+ );
877
+ return result;
652
878
  } catch (error) {
653
879
  return failure(error);
654
880
  }
655
881
  }
656
- return { evaluate, prepareSubmission };
882
+ return { evaluate, evaluateScope, prepareSubmission };
657
883
  }
@@ -106,6 +106,11 @@ export function validateSubmissionWithContext(snapshot, context, customIssues =
106
106
  for (const custom of issuesByScope.get(JSON.stringify(scope.instancePath)) || [])
107
107
  orderedIssues.push({ ...custom, instancePath: scope.instancePath, kind: 'custom' });
108
108
  });
109
+ return summarizeSubmissionFindings(orderedIssues);
110
+ }
111
+
112
+ /** Shared ordering/classification for a complete tree or a selected editor scope. */
113
+ export function summarizeSubmissionFindings(orderedIssues) {
109
114
  return {
110
115
  hasErrors: orderedIssues.length > 0,
111
116
  requiredFieldErrors: orderedIssues.filter((issue) => issue.kind === 'required'),
package/src/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  export { createFormEngine } from './engine/form-engine.js';
2
2
  export { WarningSystem } from './engine/warning-system.js';
3
- export { createStructuredRecord } from './utilities/record-transformer.js';
3
+ export {
4
+ createStructuredRecord,
5
+ createStructuredRecordAsync,
6
+ } from './utilities/record-transformer.js';
4
7
  export {
5
8
  normalizeStructuredRecord,
6
9
  buildFormRecordSnapshot,
@@ -4,14 +4,14 @@ import {
4
4
  } from '../builtins/builtin-definitions.js';
5
5
  import { DEFAULT_SECURITY_CONFIG, SAFE_SECURITY_CONFIG, SECURITY_MODES } from './config.js';
6
6
 
7
+ const calculationBuiltinNames = new Set(CALCULATION_BUILTIN_DEFINITIONS.map(({ name }) => name));
8
+ const eventBuiltinNames = new Set(EVENT_BUILTIN_DEFINITIONS.map(({ name }) => name));
9
+
7
10
  // Helper function to validate builtin function names in expressions
8
11
  function validateBuiltinNames(expr, includeEventBuiltins = false) {
9
12
  // Read the same immutable catalog used to build the runtime helpers, without
10
13
  // importing their implementations (EVAL itself depends on this validator).
11
- const definitions = includeEventBuiltins
12
- ? EVENT_BUILTIN_DEFINITIONS
13
- : CALCULATION_BUILTIN_DEFINITIONS;
14
- const allowedBuiltins = new Set(definitions.map(({ name }) => name));
14
+ const allowedBuiltins = includeEventBuiltins ? eventBuiltinNames : calculationBuiltinNames;
15
15
 
16
16
  // Extract function calls from the expression
17
17
  // This regex matches function calls like FUNCTIONNAME( allowing for whitespace
@@ -39,6 +39,7 @@ function toFormEventBuiltinDefinition(definition) {
39
39
  signature: definition.signature,
40
40
  description: definition.description,
41
41
  examples: Object.freeze([...definition.examples]),
42
+ ...(definition.operation ? { operation: definition.operation } : {}),
42
43
  status: getFormEventBuiltinStatus(definition.name),
43
44
  });
44
45
  }
@@ -247,6 +248,9 @@ function cloneCatalogDefinition(definition) {
247
248
  return {
248
249
  ...definition,
249
250
  examples: [...definition.examples],
251
+ ...(definition.operation
252
+ ? { operation: { ...definition.operation, effects: [...definition.operation.effects] } }
253
+ : {}),
250
254
  };
251
255
  }
252
256
 
@@ -1,14 +1,18 @@
1
+ function createContainer(value, seen) {
2
+ if (seen.has(value)) throw new Error('Record data must not contain cyclic references.');
3
+ if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
4
+ throw new Error('Record data must use plain objects, arrays, dates or native media handles.');
5
+ return Array.isArray(value) ? [] : {};
6
+ }
7
+
1
8
  /** Clone portable record data without copying immutable binary media into JS memory. */
2
9
  export function copyRecordData(value, seen = new Map()) {
3
10
  if (typeof value === 'function' || typeof value === 'symbol')
4
11
  throw new Error('Record data must not contain functions or symbols.');
5
12
  if (value === null || typeof value !== 'object') return value;
6
- if (seen.has(value)) throw new Error('Record data must not contain cyclic references.');
7
13
  if (value instanceof Date) return new Date(value.getTime());
8
14
  if (typeof Blob === 'function' && value instanceof Blob) return value;
9
- if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
10
- throw new Error('Record data must use plain objects, arrays, dates or native media handles.');
11
- const result = Array.isArray(value) ? [] : {};
15
+ const result = createContainer(value, seen);
12
16
  seen.set(value, result);
13
17
  for (const key of Object.keys(value)) {
14
18
  Object.defineProperty(result, key, {
@@ -21,3 +25,45 @@ export function copyRecordData(value, seen = new Map()) {
21
25
  seen.delete(value);
22
26
  return result;
23
27
  }
28
+
29
+ /** Copy privately captured results in bounded batches; never use this to capture live inputs. */
30
+ export async function copyRecordDataCooperatively(value, checkpoint) {
31
+ const seen = new Map();
32
+ const frames = [];
33
+ function begin(source) {
34
+ if (
35
+ source === null ||
36
+ typeof source !== 'object' ||
37
+ source instanceof Date ||
38
+ (typeof Blob === 'function' && source instanceof Blob)
39
+ )
40
+ return copyRecordData(source, seen);
41
+ const target = createContainer(source, seen);
42
+ seen.set(source, target);
43
+ frames.push({ source, target, keys: Object.keys(source), index: 0 });
44
+ return target;
45
+ }
46
+ const result = begin(value);
47
+ let work = 0;
48
+ while (frames.length) {
49
+ const frame = frames[frames.length - 1];
50
+ if (frame.index === frame.keys.length) {
51
+ seen.delete(frame.source);
52
+ frames.pop();
53
+ continue;
54
+ }
55
+ const key = frame.keys[frame.index++];
56
+ Object.defineProperty(frame.target, key, {
57
+ value: begin(frame.source[key]),
58
+ enumerable: true,
59
+ writable: true,
60
+ configurable: true,
61
+ });
62
+ if (++work === 128) {
63
+ work = 0;
64
+ await checkpoint();
65
+ }
66
+ }
67
+ await checkpoint();
68
+ return result;
69
+ }