@byline/core 3.13.3 → 3.14.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.
@@ -405,6 +405,27 @@ export interface RelationField extends NonlocalizableField {
405
405
  * summary. Falls back to the first text field if omitted.
406
406
  */
407
407
  displayField?: string;
408
+ /**
409
+ * When `true`, the field holds an **ordered list** of target references
410
+ * rather than a single one. The value shape becomes an array of relation
411
+ * envelopes (`RelatedDocumentValue[]`), persisted as indexed `store_relation`
412
+ * rows (`<field>.0`, `<field>.1`, …) and reconstructed in order. The admin
413
+ * widget renders an add / remove / drag-reorder list of summary tiles.
414
+ *
415
+ * A given target may appear at most once (the widget dedups on
416
+ * `targetDocumentId`). Defaults to single-target (`false` / absent).
417
+ */
418
+ hasMany?: boolean;
419
+ /**
420
+ * Minimum number of references required when `hasMany` is `true`. Enforced by
421
+ * the generated Zod schema. Ignored for single-target relations.
422
+ */
423
+ minItems?: number;
424
+ /**
425
+ * Maximum number of references allowed when `hasMany` is `true`. Enforced by
426
+ * the generated Zod schema. Ignored for single-target relations.
427
+ */
428
+ maxItems?: number;
408
429
  }
409
430
  export interface FileField extends NonlocalizableField {
410
431
  type: 'file';
@@ -155,7 +155,7 @@ export const fieldToZodSchema = (field, strict = true) => {
155
155
  // (recursive) and is not constrained here.
156
156
  schema = z.any();
157
157
  break;
158
- case 'relation':
158
+ case 'relation': {
159
159
  // Relation values are `RelatedDocumentValue` objects:
160
160
  // { targetDocumentId, targetCollectionId,
161
161
  // relationshipType?, cascadeDelete? }
@@ -165,15 +165,26 @@ export const fieldToZodSchema = (field, strict = true) => {
165
165
  //
166
166
  // Populated responses (depth > 0) skip this schema in the route layer
167
167
  // because the tree then contains nested documents, not bare refs.
168
- schema = z
169
- .object({
168
+ const relationValue = z.object({
170
169
  targetDocumentId: z.string(),
171
170
  targetCollectionId: z.string(),
172
171
  relationshipType: z.string().optional(),
173
172
  cascadeDelete: z.boolean().optional(),
174
- })
175
- .nullable();
173
+ });
174
+ if (field.hasMany) {
175
+ // Ordered list of relation values; `minItems` / `maxItems` bound it.
176
+ let arr = z.array(relationValue);
177
+ if (typeof field.minItems === 'number')
178
+ arr = arr.min(field.minItems);
179
+ if (typeof field.maxItems === 'number')
180
+ arr = arr.max(field.maxItems);
181
+ schema = arr;
182
+ }
183
+ else {
184
+ schema = relationValue.nullable();
185
+ }
176
186
  break;
187
+ }
177
188
  default:
178
189
  schema = z.string();
179
190
  }
@@ -317,6 +317,24 @@ function collectRelationLeaves(fields, fieldDefs, populate, acc) {
317
317
  const sub = matchesPopulate(leaf.field.name, populate);
318
318
  if (sub === undefined)
319
319
  continue;
320
+ // hasMany: the value is an ordered array of relation envelopes. Expand it
321
+ // into one leaf ref per element, whose `parent` is the array itself and
322
+ // `key` is the index — so the envelope-assignment pass writes each
323
+ // populated result back into its array slot, in place.
324
+ if (leaf.field.hasMany) {
325
+ if (!Array.isArray(leaf.value))
326
+ continue;
327
+ const arr = leaf.value;
328
+ for (let i = 0; i < arr.length; i++) {
329
+ const item = arr[i];
330
+ if (!isRelatedDocumentValue(item))
331
+ continue;
332
+ if ('_resolved' in item)
333
+ continue;
334
+ acc.push({ parent: arr, key: String(i), field: leaf.field, value: item, sub });
335
+ }
336
+ continue;
337
+ }
320
338
  if (!isRelatedDocumentValue(leaf.value))
321
339
  continue;
322
340
  // Skip leaves that have already been replaced (e.g. via shared-ref
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.13.3",
5
+ "version": "3.14.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "3.13.3"
82
+ "@byline/auth": "3.14.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",