2020 * - select / radio → an option *value*
2121 * - multiselect / checkboxes / tags → an array of option values
2222 * - lookup / master_detail / user / reference → a record id (resolved async)
23+ * - file / image → a file id / url (as-is)
24+ *
25+ * Any of the last four whose field is flagged `multiple: true` (per the spec,
26+ * `multiple` applies to select / lookup / file / image; `radio`/`user` share
27+ * their branch) instead store an **array** — the cell is split on the export
28+ * separator and each token coerced individually. See `isMultiValueField`.
2329 *
2430 * Contract: when a field carries no usable metadata the value passes through
2531 * untouched, so an import stays byte-identical to the pre-coercion behaviour.
@@ -37,6 +43,31 @@ const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']);
3743const NUMBER_TYPES = new Set ( [ 'number' , 'currency' , 'percent' , 'rating' , 'slider' ] ) ;
3844/** Boolean field types (store a real boolean). */
3945const BOOL_TYPES = new Set ( [ 'boolean' , 'toggle' ] ) ;
46+ /** Attachment field types (store a file id / url, or an array when `multiple`). */
47+ const FILE_TYPES = new Set ( [ 'file' , 'image' ] ) ;
48+
49+ /**
50+ * Single-value field types that become an ARRAY when flagged `multiple: true`.
51+ * Mirrors objectql `record-validator.ts` (`MULTI_CAPABLE_TYPES`): per the spec
52+ * (`field.zod.ts`), `multiple` applies to select / lookup / file / image;
53+ * `radio` shares the select branch and `user` is stored identically to `lookup`.
54+ * master_detail / reference / tree are NOT multi-capable, so a stray
55+ * `multiple: true` on them is ignored (they stay single — same as the engine).
56+ */
57+ const MULTI_CAPABLE_TYPES = new Set ( [ 'select' , 'radio' , 'lookup' , 'user' , 'file' , 'image' ] ) ;
58+
59+ /**
60+ * Whether a field's stored value is an array — an inherently-multi type
61+ * (multiselect / checkboxes / tags) or a multi-capable type flagged
62+ * `multiple: true`. Kept in lock-step with the engine's `isMultiValueField`
63+ * so a coerced cell has the SAME shape the engine will accept on insert.
64+ */
65+ function isMultiValueField ( meta : ExportFieldMeta | undefined ) : boolean {
66+ const t = meta ?. type ;
67+ if ( ! t ) return false ;
68+ if ( MULTI_OPTION_TYPES . has ( t ) ) return true ;
69+ return MULTI_CAPABLE_TYPES . has ( t ) && meta ?. multiple === true ;
70+ }
4071
4172/**
4273 * Structured outcome of a reference lookup. `id` set → a single record matched.
@@ -279,7 +310,24 @@ export async function coerceFieldValue(
279310 return { value : d } ;
280311 }
281312
282- if ( OPTION_TYPES . has ( t ) ) {
313+ // select / radio / multiselect / checkboxes / tags — match the cell against
314+ // the field's option list. Multi-valued when the type is inherently multi
315+ // (multiselect/…) OR a select/radio is flagged `multiple: true`; split then
316+ // and match each token, else match the whole cell as one option.
317+ if ( OPTION_TYPES . has ( t ) || MULTI_OPTION_TYPES . has ( t ) ) {
318+ if ( isMultiValueField ( meta ) ) {
319+ const parts = splitMulti ( raw ) ;
320+ const out : unknown [ ] = [ ] ;
321+ for ( const part of parts ) {
322+ const v = matchOption ( part , meta ?. options ) ;
323+ if ( v === undefined ) {
324+ if ( ctx . createMissingOptions ) { out . push ( part ) ; continue ; }
325+ return { error : { field, code : 'invalid_option' , message : `${ field } : "${ part } " is not a known option` } } ;
326+ }
327+ out . push ( v ) ;
328+ }
329+ return { value : out } ;
330+ }
283331 const v = matchOption ( raw , meta ?. options ) ;
284332 if ( v === undefined ) {
285333 if ( ctx . createMissingOptions ) return { value : String ( raw ) . trim ( ) } ;
@@ -288,21 +336,29 @@ export async function coerceFieldValue(
288336 return { value : v } ;
289337 }
290338
291- if ( MULTI_OPTION_TYPES . has ( t ) ) {
292- const parts = splitMulti ( raw ) ;
293- const out : unknown [ ] = [ ] ;
294- for ( const part of parts ) {
295- const v = matchOption ( part , meta ?. options ) ;
296- if ( v === undefined ) {
297- if ( ctx . createMissingOptions ) { out . push ( part ) ; continue ; }
298- return { error : { field, code : 'invalid_option' , message : `${ field } : "${ part } " is not a known option` } } ;
339+ if ( REFERENCE_TYPES . has ( t ) ) {
340+ // Multi-value reference (a `multiple: true` lookup / user): the cell holds
341+ // several display names joined by the export separator (`, ` / `;`). Split
342+ // first, then resolve each token; store an array of ids. Mirrors the
343+ // multi-option branch above and the export path's `formatReference` join.
344+ if ( isMultiValueField ( meta ) ) {
345+ const tokens = splitMulti ( raw ) ;
346+ // If we have no resolver / no target object, store the raw tokens and let
347+ // referential integrity be enforced downstream.
348+ if ( ! ctx . resolveRef || ! meta . reference ) return { value : tokens } ;
349+ const out : unknown [ ] = [ ] ;
350+ for ( const token of tokens ) {
351+ const m = normalizeRefMatch ( await ctx . resolveRef ( meta . reference , token , meta ) ) ;
352+ if ( m . ambiguous ) {
353+ return { error : { field, code : 'reference_ambiguous' , message : `${ field } : "${ token } " matches more than one ${ meta . reference } — use a unique value or the record id` } } ;
354+ }
355+ if ( m . id === undefined ) {
356+ return { error : { field, code : 'reference_not_found' , message : `${ field } : no ${ meta . reference } matches "${ token } "` } } ;
357+ }
358+ out . push ( m . id ) ;
299359 }
300- out . push ( v ) ;
360+ return { value : out } ;
301361 }
302- return { value : out } ;
303- }
304-
305- if ( REFERENCE_TYPES . has ( t ) ) {
306362 const display = String ( raw ) . trim ( ) ;
307363 // If it already looks resolved (an id was pasted) or we have no resolver /
308364 // no target object, store the raw value and let referential integrity be
@@ -318,8 +374,17 @@ export async function coerceFieldValue(
318374 return { value : match . id } ;
319375 }
320376
321- // Everything else (text, email, phone, json, html, file, …): pass through,
322- // trimming string cells so stray spreadsheet padding doesn't leak into storage.
377+ // Attachment fields (file / image): the value is a file id / url the importer
378+ // does not resolve. When `multiple: true` the cell holds several joined by the
379+ // export separator — split into an array so the stored shape matches what the
380+ // engine expects; a single-value attachment passes through untouched below.
381+ if ( FILE_TYPES . has ( t ) && isMultiValueField ( meta ) ) {
382+ return { value : splitMulti ( raw ) } ;
383+ }
384+
385+ // Everything else (text, email, phone, json, html, single file, …): pass
386+ // through, trimming string cells so stray spreadsheet padding doesn't leak
387+ // into storage.
323388 return { value : trim && typeof raw === 'string' ? raw . trim ( ) : raw } ;
324389}
325390
0 commit comments