form0-core 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -172,6 +172,18 @@ Record utilities follow this contract:
172
172
 
173
173
  Record status remains top-level as `@status`; it is not stored inside `form_values`.
174
174
 
175
+ ## Authoring mutation validation
176
+
177
+ `getFormAuthoringContext({ schema })` includes `mutationOperationCatalog`: the installed operation
178
+ formats, required parameters, and examples. `mutationOperations` remains the list of supported names.
179
+ Use these formats with `applyFormMutationBatch`; for example, a rename uses
180
+ `{ op: 'updateForm', changes: { name: 'Renamed form' } }`, not a `patch` parameter.
181
+
182
+ Malformed operations, unknown operation parameters, and empty change objects are rejected atomically
183
+ with diagnostics, rather than silently ignored. Clearing a title/status field, calculation, or event
184
+ code requires its explicit parameter (for example, `field: null`), not an omitted value. Valid
185
+ idempotent batches remain valid in core; consumers should check actual changes before offering to save.
186
+
175
187
  ## ✅ Requirements
176
188
 
177
189
  - Node.js 22 or newer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -16,17 +16,77 @@ import {
16
16
  } from '../utilities/form-event-editor.js';
17
17
 
18
18
  const CONTAINER_TYPES = new Set(['Section', 'RepeatableSection', 'BuildingPlanSection']);
19
- const MUTATION_OPERATIONS = Object.freeze([
20
- 'updateForm',
21
- 'addField',
22
- 'updateField',
23
- 'moveField',
24
- 'removeField',
25
- 'setTitleField',
26
- 'setStatusField',
27
- 'setCalculation',
28
- 'setFormEventCode',
19
+ const FIELD_KEY_PARAMETER = Object.freeze({ type: 'string', minLength: 1 });
20
+ const CHANGES_PARAMETER = Object.freeze({ type: 'object', minProperties: 1 });
21
+ const PLACEMENT_PARAMETERS = Object.freeze({
22
+ parentKey: { type: ['string', 'null'], minLength: 1 },
23
+ position: {
24
+ type: ['object', 'null'],
25
+ properties: { beforeKey: FIELD_KEY_PARAMETER, afterKey: FIELD_KEY_PARAMETER },
26
+ maxProperties: 1,
27
+ description: 'Use one sibling key, or omit position to append in the target container.',
28
+ },
29
+ });
30
+ const MUTATION_OPERATION_CATALOG = Object.freeze([
31
+ {
32
+ op: 'updateForm',
33
+ description:
34
+ 'Update form attributes; elements, events, title_field and status_field use dedicated operations.',
35
+ required: ['changes'],
36
+ parameters: { changes: CHANGES_PARAMETER },
37
+ example: { op: 'updateForm', changes: { name: 'Renamed form' } },
38
+ },
39
+ {
40
+ op: 'addField',
41
+ description: 'Add a complete field matching fieldSpecs; missing keys are generated.',
42
+ required: ['field'],
43
+ parameters: { field: { type: 'object', minProperties: 1 }, ...PLACEMENT_PARAMETERS },
44
+ },
45
+ {
46
+ op: 'updateField',
47
+ description: 'Update an existing field by key; type changes require remove/add.',
48
+ required: ['fieldKey', 'changes'],
49
+ parameters: { fieldKey: FIELD_KEY_PARAMETER, changes: CHANGES_PARAMETER },
50
+ example: { op: 'updateField', fieldKey: 'existing-field-key', changes: { label: 'New label' } },
51
+ },
52
+ {
53
+ op: 'moveField',
54
+ description: 'Move an existing field by key to a container/sibling position.',
55
+ required: ['fieldKey'],
56
+ parameters: { fieldKey: FIELD_KEY_PARAMETER, ...PLACEMENT_PARAMETERS },
57
+ },
58
+ {
59
+ op: 'removeField',
60
+ description: 'Remove an existing field by key only if it is unreferenced.',
61
+ required: ['fieldKey'],
62
+ parameters: { fieldKey: FIELD_KEY_PARAMETER },
63
+ },
64
+ {
65
+ op: 'setTitleField',
66
+ description: 'Set a complete title field, or explicitly pass field: null to clear it.',
67
+ required: ['field'],
68
+ parameters: { field: { type: ['object', 'null'] } },
69
+ },
70
+ {
71
+ op: 'setStatusField',
72
+ description: 'Set a complete status field, or explicitly pass field: null to clear it.',
73
+ required: ['field'],
74
+ parameters: { field: { type: ['object', 'null'] } },
75
+ },
76
+ {
77
+ op: 'setCalculation',
78
+ description: 'Set a CalculatedField expression by key; null explicitly clears it.',
79
+ required: ['fieldKey', 'expression'],
80
+ parameters: { fieldKey: FIELD_KEY_PARAMETER, expression: { type: ['string', 'null'] } },
81
+ },
82
+ {
83
+ op: 'setFormEventCode',
84
+ description: 'Set form event code; an empty string or null explicitly clears it.',
85
+ required: ['code'],
86
+ parameters: { code: { type: ['string', 'null'] } },
87
+ },
29
88
  ]);
89
+ const MUTATION_OPERATIONS = Object.freeze(MUTATION_OPERATION_CATALOG.map(({ op }) => op));
30
90
 
31
91
  const CALCULATION_GUIDANCE = Object.freeze({
32
92
  version: 1,
@@ -306,16 +366,66 @@ function mutationError(index, operation, error) {
306
366
  };
307
367
  }
308
368
 
369
+ function validateMutationParameter(value, spec, path) {
370
+ const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
371
+ const types = Array.isArray(spec.type) ? spec.type : [spec.type];
372
+ if (!types.includes(type)) throw new Error(`${path} must be ${types.join(' or ')}`);
373
+ if (value === null) return;
374
+ if (spec.minLength && value.length < spec.minLength) {
375
+ throw new Error(`${path} must not be empty`);
376
+ }
377
+ if (spec.minProperties && Object.keys(value).length < spec.minProperties) {
378
+ throw new Error(`${path} must be a non-empty object`);
379
+ }
380
+ if (spec.maxProperties && Object.keys(value).length > spec.maxProperties) {
381
+ throw new Error(`${path} must specify at most one property`);
382
+ }
383
+ if (spec.properties) {
384
+ for (const key of Object.keys(value)) {
385
+ if (!Object.hasOwn(spec.properties, key)) {
386
+ throw new Error(`Unexpected mutation parameter '${path}.${key}'`);
387
+ }
388
+ validateMutationParameter(value[key], spec.properties[key], `${path}.${key}`);
389
+ }
390
+ }
391
+ }
392
+
393
+ function validateMutationOperation(operation) {
394
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) {
395
+ throw new Error('A mutation operation must be an object with an op property');
396
+ }
397
+ const definition = MUTATION_OPERATION_CATALOG.find(({ op }) => op === operation.op);
398
+ if (!definition) throw new Error(`Unsupported mutation operation '${operation.op}'`);
399
+ for (const name of definition.required) {
400
+ if (!Object.hasOwn(operation, name) || operation[name] === undefined) {
401
+ const example = definition.example ? ` Example: ${JSON.stringify(definition.example)}` : '';
402
+ throw new Error(`${operation.op} requires '${name}'.${example}`);
403
+ }
404
+ }
405
+ for (const name of Object.keys(operation)) {
406
+ if (name === 'op') continue;
407
+ if (!Object.hasOwn(definition.parameters, name)) {
408
+ throw new Error(`Unexpected ${operation.op} parameter '${name}'`);
409
+ }
410
+ validateMutationParameter(
411
+ operation[name],
412
+ definition.parameters[name],
413
+ `${operation.op}.${name}`
414
+ );
415
+ }
416
+ }
417
+
309
418
  function applyMutation(root, operation) {
419
+ validateMutationOperation(operation);
310
420
  const form = root.form;
311
421
  const fields = indexFields(form.elements);
312
422
  switch (operation.op) {
313
423
  case 'updateForm': {
314
424
  const forbidden = new Set(['elements', 'events', 'title_field', 'status_field']);
315
- for (const key of Object.keys(operation.changes || {})) {
425
+ for (const key of Object.keys(operation.changes)) {
316
426
  if (forbidden.has(key)) throw new Error(`updateForm cannot change '${key}'`);
317
427
  }
318
- Object.assign(form, deepClone(operation.changes || {}));
428
+ Object.assign(form, deepClone(operation.changes));
319
429
  return `Updated form attributes`;
320
430
  }
321
431
  case 'addField': {
@@ -340,7 +450,7 @@ function applyMutation(root, operation) {
340
450
  }
341
451
  const original = deepClone(target.field);
342
452
  const children = target.field.elements;
343
- Object.assign(target.field, deepClone(operation.changes || {}));
453
+ Object.assign(target.field, deepClone(operation.changes));
344
454
  if (
345
455
  target.field.data_name !== original.data_name &&
346
456
  [...fields.values()].some(
@@ -473,6 +583,7 @@ export function getFormAuthoringContext({ schema, coreVersion = null }) {
473
583
  revision: getFormSchemaRevision(root),
474
584
  schema: deepClone(root),
475
585
  mutationOperations: [...MUTATION_OPERATIONS],
586
+ mutationOperationCatalog: deepClone(MUTATION_OPERATION_CATALOG),
476
587
  fieldSpecs,
477
588
  operators,
478
589
  eventTypes: getAllEventTypes(),