form0-core 0.1.14 → 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.14",
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) ??
@@ -75,18 +76,44 @@ const toRepeatableLabel = (field) =>
75
76
  toTrimmedString(field?.key) ??
76
77
  'Repeatable section';
77
78
 
79
+ const normalizeCalculatedDisplayStyle = (value) => {
80
+ const normalized = toTrimmedString(value);
81
+ if (!normalized) {
82
+ return null;
83
+ }
84
+
85
+ switch (normalized.toLowerCase()) {
86
+ case 'number':
87
+ return 'numeric';
88
+ case 'datetime':
89
+ return 'date';
90
+ default:
91
+ return normalized.toLowerCase();
92
+ }
93
+ };
94
+
78
95
  const getCalculatedDisplayStyle = (field) => {
79
96
  if (!isRecord(field)) {
80
97
  return null;
81
98
  }
82
99
 
100
+ const directStyle =
101
+ normalizeCalculatedDisplayStyle(field.display_mode) ??
102
+ normalizeCalculatedDisplayStyle(field.display_style);
103
+ if (directStyle) {
104
+ return directStyle;
105
+ }
106
+
83
107
  const display = field.display;
84
108
  if (typeof display === 'string') {
85
- return toTrimmedString(display);
109
+ return normalizeCalculatedDisplayStyle(display);
86
110
  }
87
111
 
88
112
  if (isRecord(display)) {
89
- return toTrimmedString(display.style);
113
+ return (
114
+ normalizeCalculatedDisplayStyle(display.style) ??
115
+ normalizeCalculatedDisplayStyle(display.type)
116
+ );
90
117
  }
91
118
 
92
119
  return null;
@@ -317,173 +344,10 @@ export function getFieldQuerySemantics(field) {
317
344
  return { ...semantics };
318
345
  }
319
346
 
320
- const toChoiceValueList = (field) => {
321
- if (!Array.isArray(field?.choices)) {
322
- return [];
323
- }
324
-
325
- return field.choices.filter((choice) => isRecord(choice));
326
- };
327
-
328
- const isChoicePrimitive = (value) =>
329
- typeof value === 'string' ||
330
- typeof value === 'number' ||
331
- typeof value === 'boolean';
332
-
333
- const normalizeChoiceEntryValue = (entry) => {
334
- if (isChoicePrimitive(entry)) {
335
- return entry;
336
- }
337
-
338
- if (!isRecord(entry)) {
339
- return undefined;
340
- }
341
-
342
- if (isChoicePrimitive(entry.value)) {
343
- return entry.value;
344
- }
345
-
346
- if (isChoicePrimitive(entry.label)) {
347
- return entry.label;
348
- }
349
-
350
- return undefined;
351
- };
352
-
353
- const normalizeChoiceEntryDisplayValue = (entry) => {
354
- if (isChoicePrimitive(entry)) {
355
- return entry;
356
- }
357
-
358
- if (!isRecord(entry)) {
359
- return undefined;
360
- }
361
-
362
- if (isChoicePrimitive(entry.label)) {
363
- return entry.label;
364
- }
365
-
366
- if (isChoicePrimitive(entry.value)) {
367
- return entry.value;
368
- }
369
-
370
- return undefined;
371
- };
372
-
373
- const normalizeChoiceEntryLabel = (field, entry) => {
374
- const normalizedValue = normalizeChoiceEntryValue(entry);
375
- if (typeof normalizedValue === 'undefined') {
376
- return undefined;
377
- }
378
-
379
- const matched = toChoiceValueList(field).find(
380
- (choice) =>
381
- Object.prototype.hasOwnProperty.call(choice, 'value') &&
382
- choice.value === normalizedValue,
383
- );
384
- if (matched) {
385
- return toTrimmedString(matched.label) ?? normalizedValue;
386
- }
387
-
388
- if (isRecord(entry) && isChoicePrimitive(entry.label)) {
389
- return entry.label;
390
- }
391
-
392
- return normalizedValue;
393
- };
394
-
395
- const toSingleChoiceSelectionValues = (rawValue) => {
396
- if (isRecord(rawValue)) {
397
- const choiceValues = Array.isArray(rawValue.choice)
398
- ? rawValue.choice
399
- .map((entry) => normalizeChoiceEntryValue(entry))
400
- .filter((entry) => typeof entry !== 'undefined')
401
- : [];
402
- const otherValues = Array.isArray(rawValue.other)
403
- ? rawValue.other
404
- .map((entry) => normalizeChoiceEntryValue(entry))
405
- .filter((entry) => typeof entry !== 'undefined')
406
- : [];
407
-
408
- return [...choiceValues, ...otherValues];
409
- }
410
-
411
- const normalized = normalizeChoiceEntryValue(rawValue);
412
- return typeof normalized === 'undefined' ? [] : [normalized];
413
- };
414
-
415
- const toSingleChoiceDisplayValues = (field, rawValue) => {
416
- if (isRecord(rawValue)) {
417
- const choiceLabels = Array.isArray(rawValue.choice)
418
- ? rawValue.choice
419
- .map((entry) => normalizeChoiceEntryLabel(field, entry))
420
- .filter((entry) => typeof entry !== 'undefined')
421
- : [];
422
- const otherLabels = Array.isArray(rawValue.other)
423
- ? rawValue.other
424
- .map((entry) => normalizeChoiceEntryDisplayValue(entry))
425
- .filter((entry) => typeof entry !== 'undefined')
426
- : [];
427
-
428
- return [...choiceLabels, ...otherLabels];
429
- }
430
-
431
- const normalized = normalizeChoiceEntryLabel(field, rawValue);
432
- return typeof normalized === 'undefined' ? [] : [normalized];
433
- };
434
-
435
- const toMultiChoiceSelectionValues = (rawValue) => {
436
- if (isRecord(rawValue)) {
437
- const selectedValues = Array.isArray(rawValue.choices)
438
- ? rawValue.choices
439
- .map((entry) => normalizeChoiceEntryValue(entry))
440
- .filter((entry) => typeof entry !== 'undefined')
441
- : [];
442
- const otherValues = Array.isArray(rawValue.other)
443
- ? rawValue.other
444
- .map((entry) => normalizeChoiceEntryValue(entry))
445
- .filter((entry) => typeof entry !== 'undefined')
446
- : [];
447
-
448
- return [...selectedValues, ...otherValues];
449
- }
450
-
451
- if (Array.isArray(rawValue)) {
452
- return rawValue
453
- .map((entry) => normalizeChoiceEntryValue(entry))
454
- .filter((entry) => typeof entry !== 'undefined');
455
- }
456
-
457
- return [];
458
- };
459
-
460
- const toMultiChoiceDisplayValues = (field, rawValue) => {
461
- if (isRecord(rawValue)) {
462
- const selectedLabels = Array.isArray(rawValue.choices)
463
- ? rawValue.choices
464
- .map((entry) => normalizeChoiceEntryLabel(field, entry))
465
- .filter((entry) => typeof entry !== 'undefined')
466
- : [];
467
- const otherLabels = Array.isArray(rawValue.other)
468
- ? rawValue.other
469
- .map((entry) => normalizeChoiceEntryDisplayValue(entry))
470
- .filter((entry) => typeof entry !== 'undefined')
471
- : [];
472
-
473
- return [...selectedLabels, ...otherLabels];
474
- }
475
-
476
- if (Array.isArray(rawValue)) {
477
- return rawValue
478
- .map((entry) => normalizeChoiceEntryLabel(field, entry))
479
- .filter((entry) => typeof entry !== 'undefined');
480
- }
481
-
482
- return [];
483
- };
484
-
485
347
  const toSingleChoiceLabel = (field, rawValue) => {
486
- const labels = toSingleChoiceDisplayValues(field, rawValue);
348
+ const labels = readStoredChoiceDisplayValues(field, rawValue, {
349
+ context: 'projectDatasetRowValues',
350
+ });
487
351
  if (labels.length === 0) {
488
352
  return rawValue;
489
353
  }
@@ -492,7 +356,9 @@ const toSingleChoiceLabel = (field, rawValue) => {
492
356
  };
493
357
 
494
358
  const toMultiChoiceLabels = (field, rawValue) => {
495
- const labels = toMultiChoiceDisplayValues(field, rawValue);
359
+ const labels = readStoredChoiceDisplayValues(field, rawValue, {
360
+ context: 'projectDatasetRowValues',
361
+ });
496
362
  if (labels.length === 0) {
497
363
  return rawValue;
498
364
  }
@@ -619,7 +485,7 @@ const collectRepeatableDatasets = (elements, context, datasets) => {
619
485
  repeatable_output_path: descriptor.repeatable_output_path,
620
486
  parent_dataset_id: descriptor.id,
621
487
  },
622
- datasets,
488
+ datasets
623
489
  );
624
490
  });
625
491
  };
@@ -655,7 +521,7 @@ export function buildDatasetDescriptors(schema) {
655
521
  repeatable_output_path: [],
656
522
  parent_dataset_id: ROOT_DATASET_ID,
657
523
  },
658
- datasets,
524
+ datasets
659
525
  );
660
526
 
661
527
  return datasets;
@@ -676,11 +542,10 @@ const readFirstAliasValue = (value, aliases) => {
676
542
  };
677
543
 
678
544
  const normalizeScalarValue = (field, value) => {
679
- if (
680
- field?.field_type === 'SingleChoiceField' ||
681
- field?.field_type === 'BooleanField'
682
- ) {
683
- return toSingleChoiceSelectionValues(value)[0];
545
+ if (field?.field_type === 'SingleChoiceField' || field?.field_type === 'BooleanField') {
546
+ return readStoredChoiceSelectionValues(field, value, {
547
+ context: 'projectDatasetRowValues',
548
+ })[0];
684
549
  }
685
550
 
686
551
  if (Array.isArray(value)) {
@@ -699,7 +564,9 @@ const normalizeScalarValue = (field, value) => {
699
564
 
700
565
  const normalizeTermValues = (field, value) => {
701
566
  if (field?.field_type === 'MultiChoiceField') {
702
- return toMultiChoiceSelectionValues(value);
567
+ return readStoredChoiceSelectionValues(field, value, {
568
+ context: 'projectDatasetRowValues',
569
+ });
703
570
  }
704
571
 
705
572
  if (!Array.isArray(value)) {
@@ -707,13 +574,14 @@ const normalizeTermValues = (field, value) => {
707
574
  }
708
575
 
709
576
  return value.filter(
710
- (entry) =>
711
- typeof entry === 'string' ||
712
- typeof entry === 'number' ||
713
- typeof entry === 'boolean',
577
+ (entry) => typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean'
714
578
  );
715
579
  };
716
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
+ */
717
585
  export function projectDatasetRowValues(descriptor, rowValues) {
718
586
  const sourceRecord = isRecord(rowValues) ? rowValues : {};
719
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',