form0-core 0.1.15 → 0.1.16

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
@@ -40,6 +40,26 @@ scope fields like `main_org_id`, and media or location settings. Operational cou
40
40
  application or API boundaries, not treated as engine-authored schema. A top-level
41
41
  `form.version` may still exist, but the engine does not bump or consume it.
42
42
 
43
+ ### Record-side contract
44
+
45
+ `form0-core` intentionally uses two different choice-value shapes:
46
+
47
+ - Live engine / renderer values use renderer shape:
48
+ - single / boolean: `{ choice, other }`
49
+ - multi: `{ choices, other }`
50
+ - Structured records use canonical stored shape:
51
+ - single / boolean: `{ choice_value, other_value }`
52
+ - multi: `{ choices_value, other_value }`
53
+
54
+ Record-side utilities follow this contract:
55
+
56
+ - `createStructuredRecord()` outputs canonical stored records
57
+ - `normalizeStructuredRecord()` consumes and returns canonical stored records
58
+ - `buildFormRecordSnapshot()` consumes canonical stored records and returns renderer snapshot values
59
+ - `projectDatasetRowValues()` consumes canonical stored rows
60
+
61
+ Record status remains top-level as `@status`; it is not stored inside `form_values`.
62
+
43
63
  ## Security
44
64
 
45
65
  See `SECURITY.md` for security modes and configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form0-core",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
@@ -0,0 +1,290 @@
1
+ const SINGLE_CHOICE_TYPES = new Set(['SingleChoiceField', 'BooleanField']);
2
+ const MULTI_CHOICE_TYPES = new Set(['MultiChoiceField']);
3
+ const RENDERER_ALIAS_KEYS = ['choice', 'choices', 'other'];
4
+
5
+ const isRecordObject = (value) =>
6
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
7
+
8
+ const cloneJson = (value) => {
9
+ if (typeof structuredClone === 'function') {
10
+ return structuredClone(value);
11
+ }
12
+ return JSON.parse(JSON.stringify(value));
13
+ };
14
+
15
+ const trimString = (value) => {
16
+ if (typeof value !== 'string') {
17
+ return null;
18
+ }
19
+
20
+ const trimmed = value.trim();
21
+ return trimmed.length > 0 ? trimmed : null;
22
+ };
23
+
24
+ const isChoicePrimitive = (value) =>
25
+ typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
26
+
27
+ const normalizeChoiceLabel = (value) => {
28
+ if (typeof value === 'string') {
29
+ const trimmed = value.trim();
30
+ return trimmed.length > 0 ? trimmed : null;
31
+ }
32
+
33
+ return isChoicePrimitive(value) ? value : null;
34
+ };
35
+
36
+ const toFieldType = (field) => trimString(field?.type) ?? trimString(field?.field_type);
37
+
38
+ const toFieldIdentifier = (field) =>
39
+ trimString(field?.data_name) ??
40
+ trimString(field?.output_key) ??
41
+ trimString(field?.key) ??
42
+ trimString(field?.field_id) ??
43
+ trimString(field?.label) ??
44
+ 'choice field';
45
+
46
+ const getChoiceKind = (field) => {
47
+ const fieldType = toFieldType(field);
48
+ if (SINGLE_CHOICE_TYPES.has(fieldType)) {
49
+ return 'single';
50
+ }
51
+
52
+ if (MULTI_CHOICE_TYPES.has(fieldType)) {
53
+ return 'multi';
54
+ }
55
+
56
+ return null;
57
+ };
58
+
59
+ const getCanonicalSelectionKey = (field) =>
60
+ getChoiceKind(field) === 'multi' ? 'choices_value' : 'choice_value';
61
+
62
+ const createChoiceShapeError = (context, field, message) => {
63
+ const prefix = trimString(context) ?? 'record-side choice utilities';
64
+ return new Error(`[form0] ${prefix}: field "${toFieldIdentifier(field)}" ${message}`);
65
+ };
66
+
67
+ const assertCanonicalChoiceShape = (field, rawValue, context) => {
68
+ if (rawValue === null || typeof rawValue === 'undefined') {
69
+ return;
70
+ }
71
+
72
+ if (!isRecordObject(rawValue)) {
73
+ throw createChoiceShapeError(
74
+ context,
75
+ field,
76
+ 'must be a canonical stored object using *_value arrays'
77
+ );
78
+ }
79
+
80
+ const rendererKeys = RENDERER_ALIAS_KEYS.filter((key) =>
81
+ Object.prototype.hasOwnProperty.call(rawValue, key)
82
+ );
83
+ if (rendererKeys.length > 0) {
84
+ throw createChoiceShapeError(
85
+ context,
86
+ field,
87
+ `must not use renderer keys (${rendererKeys.join(', ')})`
88
+ );
89
+ }
90
+
91
+ const kind = getChoiceKind(field);
92
+ if (kind === 'single' && Object.prototype.hasOwnProperty.call(rawValue, 'choices_value')) {
93
+ throw createChoiceShapeError(
94
+ context,
95
+ field,
96
+ 'must use "choice_value" instead of "choices_value"'
97
+ );
98
+ }
99
+
100
+ if (kind === 'multi' && Object.prototype.hasOwnProperty.call(rawValue, 'choice_value')) {
101
+ throw createChoiceShapeError(
102
+ context,
103
+ field,
104
+ 'must use "choices_value" instead of "choice_value"'
105
+ );
106
+ }
107
+ };
108
+
109
+ const resolveSchemaChoice = (field, rawValue) => {
110
+ if (!Array.isArray(field?.choices)) {
111
+ return null;
112
+ }
113
+
114
+ return (
115
+ field.choices.find(
116
+ (choice) =>
117
+ isRecordObject(choice) &&
118
+ Object.prototype.hasOwnProperty.call(choice, 'value') &&
119
+ choice.value === rawValue
120
+ ) ?? null
121
+ );
122
+ };
123
+
124
+ const normalizeStoredSelectedEntry = (field, entry, context) => {
125
+ if (!isRecordObject(entry)) {
126
+ throw createChoiceShapeError(context, field, 'must store selected entries as objects');
127
+ }
128
+
129
+ if (!Object.prototype.hasOwnProperty.call(entry, 'value')) {
130
+ throw createChoiceShapeError(context, field, 'must store selected entries with a "value"');
131
+ }
132
+
133
+ const matchedChoice = resolveSchemaChoice(field, entry.value);
134
+ const resolvedLabel = matchedChoice
135
+ ? (normalizeChoiceLabel(matchedChoice.label) ?? entry.value)
136
+ : (normalizeChoiceLabel(entry.label) ?? entry.value);
137
+
138
+ return {
139
+ ...entry,
140
+ value: entry.value,
141
+ label: resolvedLabel,
142
+ };
143
+ };
144
+
145
+ const normalizeStoredOtherEntry = (field, entry, context) => {
146
+ if (!isRecordObject(entry)) {
147
+ throw createChoiceShapeError(context, field, 'must store other entries as objects');
148
+ }
149
+
150
+ const hasPrimitiveValue = isChoicePrimitive(entry.value);
151
+ const normalizedLabel = normalizeChoiceLabel(entry.label);
152
+
153
+ if (!hasPrimitiveValue && normalizedLabel === null) {
154
+ throw createChoiceShapeError(
155
+ context,
156
+ field,
157
+ 'must store other entries with a primitive "label" or "value"'
158
+ );
159
+ }
160
+
161
+ return {
162
+ ...entry,
163
+ ...(hasPrimitiveValue ? { value: entry.value } : {}),
164
+ label: normalizedLabel ?? entry.value,
165
+ };
166
+ };
167
+
168
+ const normalizeChoiceEntryArray = (field, entries, normalizer, context, keyName) => {
169
+ if (typeof entries === 'undefined') {
170
+ return [];
171
+ }
172
+
173
+ if (!Array.isArray(entries)) {
174
+ throw createChoiceShapeError(context, field, `"${keyName}" must be an array`);
175
+ }
176
+
177
+ return entries.map((entry) => normalizer(field, entry, context));
178
+ };
179
+
180
+ export function isChoiceFieldLike(field) {
181
+ return getChoiceKind(field) !== null;
182
+ }
183
+
184
+ export function normalizeStoredChoiceValue(field, rawValue, options = {}) {
185
+ const context = options.context;
186
+ const kind = getChoiceKind(field);
187
+
188
+ if (!kind || rawValue === null || typeof rawValue === 'undefined') {
189
+ return rawValue;
190
+ }
191
+
192
+ assertCanonicalChoiceShape(field, rawValue, context);
193
+
194
+ const selectionKey = getCanonicalSelectionKey(field);
195
+ const normalized = {
196
+ ...rawValue,
197
+ [selectionKey]: normalizeChoiceEntryArray(
198
+ field,
199
+ rawValue[selectionKey],
200
+ normalizeStoredSelectedEntry,
201
+ context,
202
+ selectionKey
203
+ ),
204
+ other_value: normalizeChoiceEntryArray(
205
+ field,
206
+ rawValue.other_value,
207
+ normalizeStoredOtherEntry,
208
+ context,
209
+ 'other_value'
210
+ ),
211
+ };
212
+
213
+ return normalized;
214
+ }
215
+
216
+ export function toRendererChoiceValue(field, rawValue, options = {}) {
217
+ const kind = getChoiceKind(field);
218
+ if (!kind || rawValue === null || typeof rawValue === 'undefined') {
219
+ return rawValue;
220
+ }
221
+
222
+ const normalized = normalizeStoredChoiceValue(field, rawValue, options);
223
+ const selectionKey = getCanonicalSelectionKey(field);
224
+
225
+ if (kind === 'multi') {
226
+ return {
227
+ choices: cloneJson(normalized[selectionKey]),
228
+ other: cloneJson(normalized.other_value),
229
+ };
230
+ }
231
+
232
+ return {
233
+ choice: cloneJson(normalized[selectionKey]),
234
+ other: cloneJson(normalized.other_value),
235
+ };
236
+ }
237
+
238
+ export function readStoredChoiceSelectionValues(field, rawValue, options = {}) {
239
+ const kind = getChoiceKind(field);
240
+ if (!kind) {
241
+ return [];
242
+ }
243
+
244
+ const normalized = normalizeStoredChoiceValue(field, rawValue, options);
245
+ if (normalized === null || typeof normalized === 'undefined') {
246
+ return [];
247
+ }
248
+
249
+ const selectionKey = getCanonicalSelectionKey(field);
250
+ const selectedValues = normalized[selectionKey]
251
+ .map((entry) => entry.value)
252
+ .filter((entry) => typeof entry !== 'undefined');
253
+ const otherValues = normalized.other_value
254
+ .map((entry) =>
255
+ isChoicePrimitive(entry.value) ? entry.value : normalizeChoiceLabel(entry.label)
256
+ )
257
+ .filter((entry) => typeof entry !== 'undefined' && entry !== null);
258
+
259
+ return [...selectedValues, ...otherValues];
260
+ }
261
+
262
+ export function readStoredChoiceDisplayValues(field, rawValue, options = {}) {
263
+ const kind = getChoiceKind(field);
264
+ if (!kind) {
265
+ return [];
266
+ }
267
+
268
+ const normalized = normalizeStoredChoiceValue(field, rawValue, options);
269
+ if (normalized === null || typeof normalized === 'undefined') {
270
+ return [];
271
+ }
272
+
273
+ const selectionKey = getCanonicalSelectionKey(field);
274
+ const selectedLabels = normalized[selectionKey]
275
+ .map(
276
+ (entry) =>
277
+ normalizeChoiceLabel(entry.label) ??
278
+ (isChoicePrimitive(entry.value) ? entry.value : undefined)
279
+ )
280
+ .filter((entry) => typeof entry !== 'undefined' && entry !== null);
281
+ const otherLabels = normalized.other_value
282
+ .map(
283
+ (entry) =>
284
+ normalizeChoiceLabel(entry.label) ??
285
+ (isChoicePrimitive(entry.value) ? entry.value : undefined)
286
+ )
287
+ .filter((entry) => typeof entry !== 'undefined' && entry !== null);
288
+
289
+ return [...selectedLabels, ...otherLabels];
290
+ }
@@ -1,10 +1,13 @@
1
1
  import { flattenFields } from './field-helpers.js';
2
+ import {
3
+ readStoredChoiceDisplayValues,
4
+ readStoredChoiceSelectionValues,
5
+ } from './choice-value-shapes.js';
2
6
 
3
7
  const ROOT_DATASET_ID = '__root__';
4
8
  const CONTAINER_TYPES = new Set(['Section', 'BuildingPlanSection']);
5
9
 
6
- const isRecord = (value) =>
7
- Boolean(value) && typeof value === 'object' && !Array.isArray(value);
10
+ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
8
11
 
9
12
  const toTrimmedString = (value) => {
10
13
  if (typeof value !== 'string') {
@@ -57,11 +60,9 @@ const uniqueAliases = (...values) => {
57
60
  return aliases;
58
61
  };
59
62
 
60
- const toFieldId = (field) =>
61
- toTrimmedString(field?.key) ?? toTrimmedString(field?.data_name);
63
+ const toFieldId = (field) => toTrimmedString(field?.key) ?? toTrimmedString(field?.data_name);
62
64
 
63
- const toOutputKey = (field) =>
64
- toTrimmedString(field?.data_name) ?? toTrimmedString(field?.key);
65
+ const toOutputKey = (field) => toTrimmedString(field?.data_name) ?? toTrimmedString(field?.key);
65
66
 
66
67
  const toFieldLabel = (field) =>
67
68
  toTrimmedString(field?.label) ??
@@ -343,173 +344,10 @@ export function getFieldQuerySemantics(field) {
343
344
  return { ...semantics };
344
345
  }
345
346
 
346
- const toChoiceValueList = (field) => {
347
- if (!Array.isArray(field?.choices)) {
348
- return [];
349
- }
350
-
351
- return field.choices.filter((choice) => isRecord(choice));
352
- };
353
-
354
- const isChoicePrimitive = (value) =>
355
- typeof value === 'string' ||
356
- typeof value === 'number' ||
357
- typeof value === 'boolean';
358
-
359
- const normalizeChoiceEntryValue = (entry) => {
360
- if (isChoicePrimitive(entry)) {
361
- return entry;
362
- }
363
-
364
- if (!isRecord(entry)) {
365
- return undefined;
366
- }
367
-
368
- if (isChoicePrimitive(entry.value)) {
369
- return entry.value;
370
- }
371
-
372
- if (isChoicePrimitive(entry.label)) {
373
- return entry.label;
374
- }
375
-
376
- return undefined;
377
- };
378
-
379
- const normalizeChoiceEntryDisplayValue = (entry) => {
380
- if (isChoicePrimitive(entry)) {
381
- return entry;
382
- }
383
-
384
- if (!isRecord(entry)) {
385
- return undefined;
386
- }
387
-
388
- if (isChoicePrimitive(entry.label)) {
389
- return entry.label;
390
- }
391
-
392
- if (isChoicePrimitive(entry.value)) {
393
- return entry.value;
394
- }
395
-
396
- return undefined;
397
- };
398
-
399
- const normalizeChoiceEntryLabel = (field, entry) => {
400
- const normalizedValue = normalizeChoiceEntryValue(entry);
401
- if (typeof normalizedValue === 'undefined') {
402
- return undefined;
403
- }
404
-
405
- const matched = toChoiceValueList(field).find(
406
- (choice) =>
407
- Object.prototype.hasOwnProperty.call(choice, 'value') &&
408
- choice.value === normalizedValue,
409
- );
410
- if (matched) {
411
- return toTrimmedString(matched.label) ?? normalizedValue;
412
- }
413
-
414
- if (isRecord(entry) && isChoicePrimitive(entry.label)) {
415
- return entry.label;
416
- }
417
-
418
- return normalizedValue;
419
- };
420
-
421
- const toSingleChoiceSelectionValues = (rawValue) => {
422
- if (isRecord(rawValue)) {
423
- const choiceValues = Array.isArray(rawValue.choice)
424
- ? rawValue.choice
425
- .map((entry) => normalizeChoiceEntryValue(entry))
426
- .filter((entry) => typeof entry !== 'undefined')
427
- : [];
428
- const otherValues = Array.isArray(rawValue.other)
429
- ? rawValue.other
430
- .map((entry) => normalizeChoiceEntryValue(entry))
431
- .filter((entry) => typeof entry !== 'undefined')
432
- : [];
433
-
434
- return [...choiceValues, ...otherValues];
435
- }
436
-
437
- const normalized = normalizeChoiceEntryValue(rawValue);
438
- return typeof normalized === 'undefined' ? [] : [normalized];
439
- };
440
-
441
- const toSingleChoiceDisplayValues = (field, rawValue) => {
442
- if (isRecord(rawValue)) {
443
- const choiceLabels = Array.isArray(rawValue.choice)
444
- ? rawValue.choice
445
- .map((entry) => normalizeChoiceEntryLabel(field, entry))
446
- .filter((entry) => typeof entry !== 'undefined')
447
- : [];
448
- const otherLabels = Array.isArray(rawValue.other)
449
- ? rawValue.other
450
- .map((entry) => normalizeChoiceEntryDisplayValue(entry))
451
- .filter((entry) => typeof entry !== 'undefined')
452
- : [];
453
-
454
- return [...choiceLabels, ...otherLabels];
455
- }
456
-
457
- const normalized = normalizeChoiceEntryLabel(field, rawValue);
458
- return typeof normalized === 'undefined' ? [] : [normalized];
459
- };
460
-
461
- const toMultiChoiceSelectionValues = (rawValue) => {
462
- if (isRecord(rawValue)) {
463
- const selectedValues = Array.isArray(rawValue.choices)
464
- ? rawValue.choices
465
- .map((entry) => normalizeChoiceEntryValue(entry))
466
- .filter((entry) => typeof entry !== 'undefined')
467
- : [];
468
- const otherValues = Array.isArray(rawValue.other)
469
- ? rawValue.other
470
- .map((entry) => normalizeChoiceEntryValue(entry))
471
- .filter((entry) => typeof entry !== 'undefined')
472
- : [];
473
-
474
- return [...selectedValues, ...otherValues];
475
- }
476
-
477
- if (Array.isArray(rawValue)) {
478
- return rawValue
479
- .map((entry) => normalizeChoiceEntryValue(entry))
480
- .filter((entry) => typeof entry !== 'undefined');
481
- }
482
-
483
- return [];
484
- };
485
-
486
- const toMultiChoiceDisplayValues = (field, rawValue) => {
487
- if (isRecord(rawValue)) {
488
- const selectedLabels = Array.isArray(rawValue.choices)
489
- ? rawValue.choices
490
- .map((entry) => normalizeChoiceEntryLabel(field, entry))
491
- .filter((entry) => typeof entry !== 'undefined')
492
- : [];
493
- const otherLabels = Array.isArray(rawValue.other)
494
- ? rawValue.other
495
- .map((entry) => normalizeChoiceEntryDisplayValue(entry))
496
- .filter((entry) => typeof entry !== 'undefined')
497
- : [];
498
-
499
- return [...selectedLabels, ...otherLabels];
500
- }
501
-
502
- if (Array.isArray(rawValue)) {
503
- return rawValue
504
- .map((entry) => normalizeChoiceEntryLabel(field, entry))
505
- .filter((entry) => typeof entry !== 'undefined');
506
- }
507
-
508
- return [];
509
- };
510
-
511
347
  const toSingleChoiceLabel = (field, rawValue) => {
512
- const labels = toSingleChoiceDisplayValues(field, rawValue);
348
+ const labels = readStoredChoiceDisplayValues(field, rawValue, {
349
+ context: 'projectDatasetRowValues',
350
+ });
513
351
  if (labels.length === 0) {
514
352
  return rawValue;
515
353
  }
@@ -518,7 +356,9 @@ const toSingleChoiceLabel = (field, rawValue) => {
518
356
  };
519
357
 
520
358
  const toMultiChoiceLabels = (field, rawValue) => {
521
- const labels = toMultiChoiceDisplayValues(field, rawValue);
359
+ const labels = readStoredChoiceDisplayValues(field, rawValue, {
360
+ context: 'projectDatasetRowValues',
361
+ });
522
362
  if (labels.length === 0) {
523
363
  return rawValue;
524
364
  }
@@ -645,7 +485,7 @@ const collectRepeatableDatasets = (elements, context, datasets) => {
645
485
  repeatable_output_path: descriptor.repeatable_output_path,
646
486
  parent_dataset_id: descriptor.id,
647
487
  },
648
- datasets,
488
+ datasets
649
489
  );
650
490
  });
651
491
  };
@@ -681,7 +521,7 @@ export function buildDatasetDescriptors(schema) {
681
521
  repeatable_output_path: [],
682
522
  parent_dataset_id: ROOT_DATASET_ID,
683
523
  },
684
- datasets,
524
+ datasets
685
525
  );
686
526
 
687
527
  return datasets;
@@ -702,11 +542,10 @@ const readFirstAliasValue = (value, aliases) => {
702
542
  };
703
543
 
704
544
  const normalizeScalarValue = (field, value) => {
705
- if (
706
- field?.field_type === 'SingleChoiceField' ||
707
- field?.field_type === 'BooleanField'
708
- ) {
709
- return toSingleChoiceSelectionValues(value)[0];
545
+ if (field?.field_type === 'SingleChoiceField' || field?.field_type === 'BooleanField') {
546
+ return readStoredChoiceSelectionValues(field, value, {
547
+ context: 'projectDatasetRowValues',
548
+ })[0];
710
549
  }
711
550
 
712
551
  if (Array.isArray(value)) {
@@ -725,7 +564,9 @@ const normalizeScalarValue = (field, value) => {
725
564
 
726
565
  const normalizeTermValues = (field, value) => {
727
566
  if (field?.field_type === 'MultiChoiceField') {
728
- return toMultiChoiceSelectionValues(value);
567
+ return readStoredChoiceSelectionValues(field, value, {
568
+ context: 'projectDatasetRowValues',
569
+ });
729
570
  }
730
571
 
731
572
  if (!Array.isArray(value)) {
@@ -733,13 +574,14 @@ const normalizeTermValues = (field, value) => {
733
574
  }
734
575
 
735
576
  return value.filter(
736
- (entry) =>
737
- typeof entry === 'string' ||
738
- typeof entry === 'number' ||
739
- typeof entry === 'boolean',
577
+ (entry) => typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean'
740
578
  );
741
579
  };
742
580
 
581
+ /**
582
+ * Project a canonical stored row into dataset display/scalar/term values.
583
+ * Choice fields must already use the stored `*_value` record shape.
584
+ */
743
585
  export function projectDatasetRowValues(descriptor, rowValues) {
744
586
  const sourceRecord = isRecord(rowValues) ? rowValues : {};
745
587
  const source =
@@ -4,6 +4,11 @@ import {
4
4
  buildFieldIdentityMap,
5
5
  projectDatasetRowValues,
6
6
  } from './dataset-descriptors.js';
7
+ import {
8
+ isChoiceFieldLike,
9
+ normalizeStoredChoiceValue,
10
+ toRendererChoiceValue,
11
+ } from './choice-value-shapes.js';
7
12
 
8
13
  const CONTAINER_TYPES = new Set(['Section', 'BuildingPlanSection']);
9
14
  const TIMESTAMP_KEYS = [
@@ -131,102 +136,6 @@ const buildScopeFromElements = (elements) => {
131
136
  return scope;
132
137
  };
133
138
 
134
- const resolveChoiceLabel = (field, rawValue, fallbackLabel) => {
135
- const normalizedValue = trimString(rawValue);
136
- if (!normalizedValue) {
137
- return fallbackLabel ?? null;
138
- }
139
-
140
- if (Array.isArray(field?.choices)) {
141
- const matched = field.choices.find(
142
- (choice) =>
143
- isRecordObject(choice) &&
144
- Object.prototype.hasOwnProperty.call(choice, 'value') &&
145
- choice.value === normalizedValue
146
- );
147
- if (matched) {
148
- return trimString(matched.label) ?? normalizedValue;
149
- }
150
- }
151
-
152
- return fallbackLabel ?? normalizedValue;
153
- };
154
-
155
- const normalizeChoiceEntry = (field, entry) => {
156
- if (!isRecordObject(entry)) {
157
- return entry;
158
- }
159
-
160
- const rawValue = trimString(entry.value) ?? trimString(entry.label) ?? trimString(entry.id);
161
- if (!rawValue) {
162
- return { ...entry };
163
- }
164
-
165
- return {
166
- ...entry,
167
- value: rawValue,
168
- label: resolveChoiceLabel(field, rawValue, trimString(entry.label)),
169
- };
170
- };
171
-
172
- const normalizeChoiceList = (field, entries) => {
173
- if (!Array.isArray(entries)) {
174
- return entries;
175
- }
176
-
177
- return entries.map((entry) => normalizeChoiceEntry(field, entry));
178
- };
179
-
180
- const normalizeChoiceStructuredValue = (field, rawValue) => {
181
- if (!isRecordObject(rawValue)) {
182
- return rawValue;
183
- }
184
-
185
- const normalized = { ...rawValue };
186
-
187
- if (field?.type === 'SingleChoiceField' || field?.type === 'BooleanField') {
188
- if (Array.isArray(rawValue.choice_value)) {
189
- normalized.choice_value = normalizeChoiceList(field, rawValue.choice_value);
190
- }
191
- if (Array.isArray(rawValue.choice)) {
192
- normalized.choice = normalizeChoiceList(field, rawValue.choice);
193
- }
194
- if (Array.isArray(rawValue.other_value)) {
195
- normalized.other_value = rawValue.other_value.map((entry) =>
196
- isRecordObject(entry) ? { ...entry } : entry
197
- );
198
- }
199
- if (Array.isArray(rawValue.other)) {
200
- normalized.other = rawValue.other.map((entry) =>
201
- isRecordObject(entry) ? { ...entry } : entry
202
- );
203
- }
204
- return normalized;
205
- }
206
-
207
- if (field?.type === 'MultiChoiceField') {
208
- if (Array.isArray(rawValue.choices_value)) {
209
- normalized.choices_value = normalizeChoiceList(field, rawValue.choices_value);
210
- }
211
- if (Array.isArray(rawValue.choices)) {
212
- normalized.choices = normalizeChoiceList(field, rawValue.choices);
213
- }
214
- if (Array.isArray(rawValue.other_value)) {
215
- normalized.other_value = rawValue.other_value.map((entry) =>
216
- isRecordObject(entry) ? { ...entry } : entry
217
- );
218
- }
219
- if (Array.isArray(rawValue.other)) {
220
- normalized.other = rawValue.other.map((entry) =>
221
- isRecordObject(entry) ? { ...entry } : entry
222
- );
223
- }
224
- return normalized;
225
- }
226
-
227
- return normalized;
228
- };
229
-
230
139
  const buildFieldReferenceMap = (schema) => {
231
140
  const identityMap = buildFieldIdentityMap(schema);
232
141
  const references = new Map();
@@ -323,12 +232,10 @@ const normalizeRecordScope = (record, scope) => {
323
232
  return;
324
233
  }
325
234
 
326
- if (
327
- field.field?.type === 'SingleChoiceField' ||
328
- field.field?.type === 'BooleanField' ||
329
- field.field?.type === 'MultiChoiceField'
330
- ) {
331
- formValues[entry.key] = normalizeChoiceStructuredValue(field.field, entry.value);
235
+ if (isChoiceFieldLike(field.field)) {
236
+ formValues[entry.key] = normalizeStoredChoiceValue(field.field, entry.value, {
237
+ context: 'normalizeStructuredRecord',
238
+ });
332
239
  }
333
240
  });
334
241
 
@@ -363,7 +270,11 @@ const buildSnapshotState = (formValues, scope) => {
363
270
  return;
364
271
  }
365
272
 
366
- rawValues[field.dataName] = cloneJson(entry.value);
273
+ rawValues[field.dataName] = isChoiceFieldLike(field.field)
274
+ ? toRendererChoiceValue(field.field, entry.value, {
275
+ context: 'buildFormRecordSnapshot',
276
+ })
277
+ : cloneJson(entry.value);
367
278
  });
368
279
 
369
280
  scope.repeatables.forEach((repeatableField) => {
@@ -436,6 +347,10 @@ const extractSnapshotTimestamps = (record) => ({
436
347
  updated_at_server: toNullableString(record?.updated_at_server) ?? null,
437
348
  });
438
349
 
350
+ /**
351
+ * Normalize a canonical stored record for downstream record-side consumers.
352
+ * Renderer/live aliases are rejected; known choice labels are refreshed from the supplied schema.
353
+ */
439
354
  export function normalizeStructuredRecord(schema, record, options = {}) {
440
355
  const normalized = isRecordObject(record) ? cloneJson(record) : {};
441
356
  const mode = trimString(options.mode) ?? 'derived';
@@ -456,6 +371,10 @@ export function normalizeStructuredRecord(schema, record, options = {}) {
456
371
  return normalized;
457
372
  }
458
373
 
374
+ /**
375
+ * Build a renderer snapshot from a canonical stored record.
376
+ * Choice fields are converted back to renderer/live shape while record metadata stays top-level.
377
+ */
459
378
  export function buildFormRecordSnapshot(schema, record, options = {}) {
460
379
  const normalizedRecord = normalizeStructuredRecord(schema, record, {
461
380
  mode: 'derived',