@byline/core 4.2.0 → 4.3.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.
@@ -270,7 +270,12 @@ export interface CollectionAdminConfig<T = any> {
270
270
  */
271
271
  layout?: LayoutDefinition;
272
272
  /**
273
- * Per-field rendering overrides, keyed by field name.
273
+ * Per-field rendering overrides, keyed by schema path: a top-level field
274
+ * name (`title`) or a dotted, index-free path through `group` / `array`
275
+ * structure fields (`files.filesGroup.publicationFile`). Paths address
276
+ * field *declarations*, never item instances (no `[0]` indices), and never
277
+ * traverse a `blocks` field — blocks take their overrides from the
278
+ * blockType-keyed `ClientConfig.blockAdmin` registry instead.
274
279
  * Placement is no longer expressed here — see the layout primitives above.
275
280
  */
276
281
  fields?: Record<string, FieldAdminConfig>;
@@ -405,25 +410,31 @@ export declare function defineAdmin<T = any>(schema: CollectionDefinition, confi
405
410
  * collection genuinely needs different presentation for "the same" block,
406
411
  * define a second block with a distinct `blockType`.
407
412
  *
408
- * v1 scope: `fields` keys address the block's **top-level** field names only —
409
- * the same limitation `CollectionAdminConfig.fields` has for collections.
410
- * Fields nested inside a group/array within the block inherit site-wide
411
- * defaults.
413
+ * `fields` keys are **schema paths** relative to the block root: a top-level
414
+ * field name (`quoteText`) or a dotted path through `group` / `array`
415
+ * structure fields (`faq.answer`). Schema paths are index-free they address
416
+ * field *declarations*, not item instances (`faq.answer`, never
417
+ * `faq[0].answer`) — the same notation `upload` validation and the upload
418
+ * executor use. Paths never traverse a nested `blocks` field: an inner block
419
+ * resolves its own `blockAdmin` registry entry wherever it renders.
412
420
  */
413
421
  export interface BlockAdminConfig {
414
422
  /** Must match the `blockType` of the corresponding `defineBlock()` definition. */
415
423
  blockType: string;
416
424
  /**
417
425
  * Per-field rendering overrides (`components` slots, richtext `editor`),
418
- * keyed by the block's top-level field names. Same shape and semantics as
426
+ * keyed by index-free schema paths relative to the block root (`alt`,
427
+ * `faq.answer`). Same shape and semantics as
419
428
  * `CollectionAdminConfig.fields`.
420
429
  */
421
430
  fields?: Record<string, FieldAdminConfig>;
422
431
  }
423
432
  /**
424
433
  * Type-safe factory for creating a `BlockAdminConfig` linked to a block
425
- * schema. Sets `blockType` from the block's `blockType` and constrains the
426
- * `fields` keys to the block's own top-level field names.
434
+ * schema. Sets `blockType` from the block's `blockType`. `fields` keys
435
+ * autocomplete the block's top-level field names; nested declarations are
436
+ * addressed with dotted, index-free schema paths (`faq.answer`), validated
437
+ * against the block's field tree at boot (`validateBlockAdminConfigs`).
427
438
  *
428
439
  * Lives on the admin side of the schema/admin split: block schema files stay
429
440
  * React-free and tsx-loadable, while this config may carry React component
@@ -444,5 +455,11 @@ export interface BlockAdminConfig {
444
455
  * ```
445
456
  */
446
457
  export declare function defineBlockAdmin<B extends Block>(block: B, config: {
447
- fields?: Partial<Record<Extract<keyof BlockFieldData<B>, string>, FieldAdminConfig>>;
458
+ /**
459
+ * Keyed by schema path relative to the block root. Top-level field names
460
+ * autocomplete; nested fields are addressed with dotted, index-free
461
+ * paths (`faq.answer`) — accepted as plain strings here and resolved
462
+ * against the block's field tree at boot.
463
+ */
464
+ fields?: Partial<Record<Extract<keyof BlockFieldData<B>, string> | (string & {}), FieldAdminConfig>>;
448
465
  }): BlockAdminConfig;
@@ -17,8 +17,10 @@ export function defineAdmin(schema, config) {
17
17
  }
18
18
  /**
19
19
  * Type-safe factory for creating a `BlockAdminConfig` linked to a block
20
- * schema. Sets `blockType` from the block's `blockType` and constrains the
21
- * `fields` keys to the block's own top-level field names.
20
+ * schema. Sets `blockType` from the block's `blockType`. `fields` keys
21
+ * autocomplete the block's top-level field names; nested declarations are
22
+ * addressed with dotted, index-free schema paths (`faq.answer`), validated
23
+ * against the block's field tree at boot (`validateBlockAdminConfigs`).
22
24
  *
23
25
  * Lives on the admin side of the schema/admin split: block schema files stay
24
26
  * React-free and tsx-loadable, while this config may carry React component
@@ -24,13 +24,7 @@ describe('resolveRoutes', () => {
24
24
  signIn: '/sign-in',
25
25
  });
26
26
  });
27
- it.each([
28
- '/../cms',
29
- '/cms?next=x',
30
- '/cms#section',
31
- '/cms\\users',
32
- '/%63ms',
33
- ])('rejects a non-segment admin route of %j', (admin) => {
27
+ it.each(['/../cms', '/cms?next=x', '/cms#section', '/cms\\users', '/%63ms'])('rejects a non-segment admin route of %j', (admin) => {
34
28
  expect(() => resolveRoutes({ admin })).toThrow(/routes\.admin/);
35
29
  });
36
30
  it('normalizes and validates the API route too', () => {
@@ -22,8 +22,9 @@ import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition } fr
22
22
  * are unique within an admin config and don't shadow schema field names.
23
23
  * 5. Nesting — `tabSets` only appear in `layout.main`; rows contain only
24
24
  * schema field names; groups exclude tabSets and nested groups.
25
- * 6. `fields` map sanity — every key in `admin.fields` matches a top-level
26
- * schema field name.
25
+ * 6. `fields` map sanity — every key in `admin.fields` is a dotted,
26
+ * index-free schema path resolving to a field declaration (top-level
27
+ * name or a path through group/array fields; never through blocks).
27
28
  * 7. `defaultSort` sanity — `field` resolves to a top-level schema field
28
29
  * or a document-level column (`createdAt` / `updatedAt` / `path`), the
29
30
  * direction (when given) is `asc` | `desc`, and the option is rejected
@@ -43,9 +44,11 @@ export declare function validateAdminConfigs(admins: readonly CollectionAdminCon
43
44
  * `type: 'blocks'` field across the registered collections (blocks have
44
45
  * no global registry; the collections walk is the source of truth).
45
46
  * 2. Uniqueness — no two entries share a `blockType`.
46
- * 3. `fields` map sanity — every key matches a top-level field name of the
47
- * block (when the same `blockType` appears in several collections, the
48
- * union of their top-level field names is accepted).
47
+ * 3. `fields` map sanity — every key is a dotted, index-free schema path
48
+ * resolving to a field declaration of the block (top-level name or a
49
+ * path through group/array fields; never through a nested blocks field).
50
+ * When the same `blockType` appears in several collections, a key is
51
+ * accepted if it resolves in any declaration site (union semantics).
49
52
  *
50
53
  * Throws a plain `Error` for the same reason `validateAdminConfigs` does —
51
54
  * this runs at startup, before the logger is necessarily wired up.
@@ -5,6 +5,61 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ /**
9
+ * Resolve a dotted schema path (`faq.answer`, `files.filesGroup.publicationFile`)
10
+ * against a field set. Schema paths address field *declarations*: every
11
+ * segment names a field, intermediate segments must be `group` / `array`
12
+ * structure fields, and no segment carries an item index. Returns:
13
+ *
14
+ * - `'ok'` — the path resolves to a field declaration;
15
+ * - `'blocks'` — the path tries to traverse a `type: 'blocks'` field
16
+ * (blocks resolve their own admin config from the
17
+ * blockType-keyed registry, so this is always an error);
18
+ * - `'unresolved'` — a segment doesn't name a field, or a value field
19
+ * appears mid-path.
20
+ */
21
+ function resolveSchemaPath(fields, path) {
22
+ const segments = path.split('.');
23
+ let current = fields;
24
+ for (let i = 0; i < segments.length; i++) {
25
+ const name = segments[i];
26
+ const field = current.find((f) => 'name' in f && f.name === name);
27
+ if (field == null)
28
+ return 'unresolved';
29
+ if (i === segments.length - 1)
30
+ return 'ok';
31
+ if (field.type === 'group' || field.type === 'array') {
32
+ current = field.fields;
33
+ }
34
+ else if (field.type === 'blocks') {
35
+ return 'blocks';
36
+ }
37
+ else {
38
+ return 'unresolved';
39
+ }
40
+ }
41
+ return 'unresolved';
42
+ }
43
+ /**
44
+ * Shared validation for a `fields{}` override map (collection- or block-level):
45
+ * keys must be dotted, index-free schema paths that resolve to a field
46
+ * declaration without traversing a `blocks` field. `subject` names the config
47
+ * in error messages (`Collection "docs"` / `Block "faqBlock"`).
48
+ */
49
+ function validateFieldAdminKeys(keys, resolve, fail) {
50
+ for (const key of keys) {
51
+ if (key.includes('[')) {
52
+ fail(`\`fields["${key}"]\` contains an item index. Field override keys are index-free schema paths addressing field declarations (e.g. "faq.answer"), not instance paths (e.g. "faq[0].answer").`);
53
+ }
54
+ const resolution = resolve(key);
55
+ if (resolution === 'blocks') {
56
+ fail(`\`fields["${key}"]\` traverses a \`type: 'blocks'\` field. Blocks resolve their own overrides from the blockType-keyed \`blockAdmin\` registry — register a block admin config for the inner block instead.`);
57
+ }
58
+ if (resolution === 'unresolved') {
59
+ fail(`\`fields["${key}"]\` does not resolve to a field declaration. Keys are dotted, index-free schema paths whose intermediate segments are \`group\` / \`array\` fields (e.g. "faq.answer").`);
60
+ }
61
+ }
62
+ }
8
63
  /**
9
64
  * Validate every admin config in a configuration.
10
65
  *
@@ -21,8 +76,9 @@
21
76
  * are unique within an admin config and don't shadow schema field names.
22
77
  * 5. Nesting — `tabSets` only appear in `layout.main`; rows contain only
23
78
  * schema field names; groups exclude tabSets and nested groups.
24
- * 6. `fields` map sanity — every key in `admin.fields` matches a top-level
25
- * schema field name.
79
+ * 6. `fields` map sanity — every key in `admin.fields` is a dotted,
80
+ * index-free schema path resolving to a field declaration (top-level
81
+ * name or a path through group/array fields; never through blocks).
26
82
  * 7. `defaultSort` sanity — `field` resolves to a top-level schema field
27
83
  * or a document-level column (`createdAt` / `updatedAt` / `path`), the
28
84
  * direction (when given) is `asc` | `desc`, and the option is rejected
@@ -52,9 +108,11 @@ export function validateAdminConfigs(admins, collections) {
52
108
  * `type: 'blocks'` field across the registered collections (blocks have
53
109
  * no global registry; the collections walk is the source of truth).
54
110
  * 2. Uniqueness — no two entries share a `blockType`.
55
- * 3. `fields` map sanity — every key matches a top-level field name of the
56
- * block (when the same `blockType` appears in several collections, the
57
- * union of their top-level field names is accepted).
111
+ * 3. `fields` map sanity — every key is a dotted, index-free schema path
112
+ * resolving to a field declaration of the block (top-level name or a
113
+ * path through group/array fields; never through a nested blocks field).
114
+ * When the same `blockType` appears in several collections, a key is
115
+ * accepted if it resolves in any declaration site (union semantics).
58
116
  *
59
117
  * Throws a plain `Error` for the same reason `validateAdminConfigs` does —
60
118
  * this runs at startup, before the logger is necessarily wired up.
@@ -62,19 +120,18 @@ export function validateAdminConfigs(admins, collections) {
62
120
  export function validateBlockAdminConfigs(blockAdmins, collections) {
63
121
  if (blockAdmins == null || blockAdmins.length === 0)
64
122
  return;
65
- // Collect blockType → union of top-level field names across every
66
- // declaration site (including blocks nested inside groups/arrays/blocks).
67
- const blockFieldNames = new Map();
123
+ // Collect blockType → declaration sites across every registered collection
124
+ // (including blocks nested inside groups/arrays/blocks). Structural drift
125
+ // between same-blockType declarations is possible, so keys validate against
126
+ // the union of sites.
127
+ const blocksByType = new Map();
68
128
  const walkBlock = (block) => {
69
- let names = blockFieldNames.get(block.blockType);
70
- if (names == null) {
71
- names = new Set();
72
- blockFieldNames.set(block.blockType, names);
73
- }
74
- for (const field of block.fields) {
75
- if ('name' in field)
76
- names.add(field.name);
129
+ let sites = blocksByType.get(block.blockType);
130
+ if (sites == null) {
131
+ sites = [];
132
+ blocksByType.set(block.blockType, sites);
77
133
  }
134
+ sites.push(block);
78
135
  walkFields(block.fields);
79
136
  };
80
137
  const walkFields = (fields) => {
@@ -99,17 +156,28 @@ export function validateBlockAdminConfigs(blockAdmins, collections) {
99
156
  }
100
157
  seen.add(entry.blockType);
101
158
  // Rule 1 — block pairing.
102
- const names = blockFieldNames.get(entry.blockType);
103
- if (names == null) {
159
+ const sites = blocksByType.get(entry.blockType);
160
+ if (sites == null) {
104
161
  throw new Error(`Block admin config "${entry.blockType}" has no matching block (no \`type: 'blocks'\` field of any registered collection declares a block with \`blockType: '${entry.blockType}'\`).`);
105
162
  }
106
- // Rule 3 — `fields` keys must be top-level field names of the block.
163
+ // Rule 3 — `fields` keys must be schema paths resolving within the block
164
+ // (union across declaration sites). 'blocks' beats 'unresolved' in the
165
+ // union so the traversal error surfaces when any site has the nested
166
+ // blocks field the key tried to walk through.
107
167
  if (entry.fields != null) {
108
- for (const key of Object.keys(entry.fields)) {
109
- if (!names.has(key)) {
110
- throw new Error(`Block "${entry.blockType}": \`fields["${key}"]\` references a name that is not a top-level field of the block. Per-field overrides apply only to the block's top-level fields.`);
168
+ validateFieldAdminKeys(Object.keys(entry.fields), (key) => {
169
+ let best = 'unresolved';
170
+ for (const block of sites) {
171
+ const resolution = resolveSchemaPath(block.fields, key);
172
+ if (resolution === 'ok')
173
+ return 'ok';
174
+ if (resolution === 'blocks')
175
+ best = 'blocks';
111
176
  }
112
- }
177
+ return best;
178
+ }, (msg) => {
179
+ throw new Error(`Block "${entry.blockType}": ${msg}`);
180
+ });
113
181
  }
114
182
  }
115
183
  }
@@ -272,13 +340,10 @@ function validateOne(admin, collectionsByPath) {
272
340
  }
273
341
  }
274
342
  }
275
- // Rule 6 — `fields` map keys must match top-level schema field names.
343
+ // Rule 6 — `fields` map keys must be schema paths resolving to a field
344
+ // declaration of the collection.
276
345
  if (admin.fields != null) {
277
- for (const key of Object.keys(admin.fields)) {
278
- if (!topLevelFieldNames.has(key)) {
279
- fail(`\`fields["${key}"]\` references a name that is not a top-level schema field. Per-field overrides apply only to top-level schema fields.`);
280
- }
281
- }
346
+ validateFieldAdminKeys(Object.keys(admin.fields), (key) => resolveSchemaPath(collection.fields, key), fail);
282
347
  }
283
348
  // Rule 2 + 3 — layout: name resolution + bookkeeping (every schema field
284
349
  // placed exactly once). Skipped when no `layout` is declared (the
@@ -199,7 +199,63 @@ describe('validateAdminConfigs', () => {
199
199
  ...baseAdmin,
200
200
  fields: { nonexistent: {} },
201
201
  };
202
- expect(() => validateAdminConfigs([admin], [collection])).toThrow(/not a top-level schema field/);
202
+ expect(() => validateAdminConfigs([admin], [collection])).toThrow(/does not resolve to a field declaration/);
203
+ });
204
+ // Rule 6 — dotted schema-path keys. A dedicated collection with nested
205
+ // structure: files (array) → filesGroup (group) → leaf fields, plus a
206
+ // blocks field to prove traversal through blocks is rejected.
207
+ const nestedCollection = {
208
+ path: 'library',
209
+ labels: { singular: 'Item', plural: 'Items' },
210
+ useAsPath: 'title',
211
+ fields: [
212
+ { name: 'title', label: 'Title', type: 'text' },
213
+ {
214
+ name: 'files',
215
+ label: 'Files',
216
+ type: 'array',
217
+ fields: [
218
+ {
219
+ name: 'filesGroup',
220
+ type: 'group',
221
+ fields: [
222
+ { name: 'publicationFile', label: 'File', type: 'file' },
223
+ { name: 'notes', label: 'Notes', type: 'richText' },
224
+ ],
225
+ },
226
+ ],
227
+ },
228
+ {
229
+ name: 'sections',
230
+ label: 'Sections',
231
+ type: 'blocks',
232
+ blocks: [
233
+ {
234
+ blockType: 'proseBlock',
235
+ fields: [{ name: 'body', label: 'Body', type: 'richText' }],
236
+ },
237
+ ],
238
+ },
239
+ ],
240
+ };
241
+ const nestedAdmin = (fields) => ({
242
+ slug: 'library',
243
+ fields: fields,
244
+ });
245
+ it('accepts a dotted fields key through array and group declarations', () => {
246
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files.filesGroup.notes': {} })], [nestedCollection])).not.toThrow();
247
+ });
248
+ it('rejects a dotted fields key whose leaf does not exist', () => {
249
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files.filesGroup.missing': {} })], [nestedCollection])).toThrow(/does not resolve to a field declaration/);
250
+ });
251
+ it('rejects a dotted fields key that walks through a value field', () => {
252
+ expect(() => validateAdminConfigs([nestedAdmin({ 'title.anything': {} })], [nestedCollection])).toThrow(/does not resolve to a field declaration/);
253
+ });
254
+ it('rejects a fields key carrying an item index', () => {
255
+ expect(() => validateAdminConfigs([nestedAdmin({ 'files[0].filesGroup.notes': {} })], [nestedCollection])).toThrow(/index-free schema paths/);
256
+ });
257
+ it('rejects a fields key that traverses a blocks field', () => {
258
+ expect(() => validateAdminConfigs([nestedAdmin({ 'sections.body': {} })], [nestedCollection])).toThrow(/blockAdmin/);
203
259
  });
204
260
  // Rule 7 — defaultSort sanity.
205
261
  it('accepts a defaultSort on a schema field', () => {
@@ -345,8 +401,42 @@ describe('validateBlockAdminConfigs', () => {
345
401
  expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock' }, { blockType: 'quoteBlock' }], [blockCollection])).toThrow(/registered more than once/);
346
402
  });
347
403
  // Rule 3 — fields map sanity.
348
- it('rejects a fields key that is not a top-level field of the block', () => {
349
- expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { doesNotExist: {} } }], [blockCollection])).toThrow(/not a top-level field of the block/);
404
+ it('rejects a fields key that is not a field of the block', () => {
405
+ expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { doesNotExist: {} } }], [blockCollection])).toThrow(/does not resolve to a field declaration/);
406
+ });
407
+ // Rule 3 — dotted schema-path keys inside a block (array-in-block, the
408
+ // FAQBlock shape).
409
+ const faqBlock = {
410
+ blockType: 'faqBlock',
411
+ fields: [
412
+ {
413
+ name: 'faq',
414
+ label: 'Questions',
415
+ type: 'array',
416
+ fields: [
417
+ { name: 'question', label: 'Question', type: 'text' },
418
+ { name: 'answer', label: 'Answer', type: 'richText' },
419
+ ],
420
+ },
421
+ ],
422
+ };
423
+ const faqCollection = {
424
+ path: 'docs',
425
+ labels: { singular: 'Doc', plural: 'Docs' },
426
+ useAsPath: 'title',
427
+ fields: [
428
+ { name: 'title', label: 'Title', type: 'text' },
429
+ { name: 'content', label: 'Content', type: 'blocks', blocks: [faqBlock] },
430
+ ],
431
+ };
432
+ it('accepts a dotted fields key addressing a field inside an array in the block', () => {
433
+ expect(() => validateBlockAdminConfigs([{ blockType: 'faqBlock', fields: { 'faq.answer': {} } }], [faqCollection])).not.toThrow();
434
+ });
435
+ it('rejects a dotted fields key whose leaf is not declared in the block', () => {
436
+ expect(() => validateBlockAdminConfigs([{ blockType: 'faqBlock', fields: { 'faq.missing': {} } }], [faqCollection])).toThrow(/does not resolve to a field declaration/);
437
+ });
438
+ it('rejects a block fields key carrying an item index', () => {
439
+ expect(() => validateBlockAdminConfigs([{ blockType: 'faqBlock', fields: { 'faq[0].answer': {} } }], [faqCollection])).toThrow(/index-free schema paths/);
350
440
  });
351
441
  it('accepts the union of field names when a blockType appears in several collections', () => {
352
442
  const other = {
@@ -48,9 +48,33 @@ const _applyDateTimeValidation = (schema, _field) => {
48
48
  export const fieldToZodSchema = (field, strict = true) => {
49
49
  let schema;
50
50
  switch (field.type) {
51
- case 'array':
52
- schema = z.any().array();
51
+ case 'array': {
52
+ // Homogeneous item objects built by recursing into the array's child
53
+ // fields — the item shape mirrors `createFieldsSchema` one level down,
54
+ // so requiredness and per-type validation apply inside items exactly
55
+ // as they do at the top level (threading `strict` keeps reads lenient
56
+ // for schema-evolved documents). `_id` is the synthetic item identity
57
+ // (assigned client-side on add, persisted via store_meta) — always
58
+ // accepted, never required. `.passthrough()` tolerates auxiliary keys
59
+ // (e.g. legacy seed-data `id` aliases) instead of stripping them.
60
+ // Group children stay `z.any()` via the `group` case below — the same
61
+ // depth boundary top-level groups have.
62
+ const itemShape = {
63
+ _id: z.string().optional(),
64
+ };
65
+ for (const child of field.fields ?? []) {
66
+ itemShape[child.name] = fieldToZodSchema(child, strict);
67
+ }
68
+ let arraySchema = z.array(z.object(itemShape).passthrough());
69
+ if (field.validation?.minLength != null) {
70
+ arraySchema = arraySchema.min(field.validation.minLength);
71
+ }
72
+ if (field.validation?.maxLength != null) {
73
+ arraySchema = arraySchema.max(field.validation.maxLength);
74
+ }
75
+ schema = arraySchema;
53
76
  break;
77
+ }
54
78
  case 'text': {
55
79
  let textSchema = z.string();
56
80
  textSchema = applyTextValidation(textSchema, field);
@@ -47,3 +47,75 @@ describe('collection Zod schemas', () => {
47
47
  expect(result.success).toBe(false);
48
48
  });
49
49
  });
50
+ // Array items recurse into the child field schemas (the FAQBlock shape:
51
+ // multiple direct value fields per item, plus an optional group child that
52
+ // stays permissive — the same depth boundary top-level groups have).
53
+ const Faqs = defineCollection({
54
+ path: 'faqs',
55
+ labels: { singular: 'FAQ', plural: 'FAQs' },
56
+ fields: [
57
+ { name: 'title', type: 'text' },
58
+ {
59
+ name: 'faq',
60
+ type: 'array',
61
+ validation: { minLength: 1 },
62
+ fields: [
63
+ { name: 'question', type: 'text' },
64
+ { name: 'answer', type: 'richText', optional: true },
65
+ {
66
+ name: 'meta',
67
+ type: 'group',
68
+ optional: true,
69
+ fields: [{ name: 'anchor', type: 'text' }],
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ });
75
+ const faqItem = { question: 'What is Byline?', answer: { root: {} } };
76
+ describe('array item schemas', () => {
77
+ const schemas = createCollectionSchemas(Faqs);
78
+ it('accepts complete items, with or without the synthetic _id', () => {
79
+ const result = schemas.fields.safeParse({
80
+ title: 'FAQ page',
81
+ faq: [faqItem, { ...faqItem, _id: '0198c0de-0000-7000-8000-000000000001' }],
82
+ });
83
+ expect(result.success).toBe(true);
84
+ });
85
+ it('rejects an item missing a required child field in the strict schema', () => {
86
+ const result = schemas.fields.safeParse({
87
+ title: 'FAQ page',
88
+ faq: [{ answer: { root: {} } }],
89
+ });
90
+ expect(result.success).toBe(false);
91
+ });
92
+ it('rejects a wrongly typed child value in the strict schema', () => {
93
+ const result = schemas.fields.safeParse({
94
+ title: 'FAQ page',
95
+ faq: [{ ...faqItem, question: 42 }],
96
+ });
97
+ expect(result.success).toBe(false);
98
+ });
99
+ it('enforces the array-level minLength bound', () => {
100
+ const result = schemas.fields.safeParse({ title: 'FAQ page', faq: [] });
101
+ expect(result.success).toBe(false);
102
+ });
103
+ it('leaves group children permissive inside items', () => {
104
+ const result = schemas.fields.safeParse({
105
+ title: 'FAQ page',
106
+ faq: [{ ...faqItem, meta: { anything: true } }],
107
+ });
108
+ expect(result.success).toBe(true);
109
+ });
110
+ it('keeps reads lenient — items with missing children still parse in the get schema', () => {
111
+ const parsed = schemas.get.parse({
112
+ id: '550e8400-e29b-41d4-a716-446655440000',
113
+ status: 'draft',
114
+ createdAt: '2026-07-15T12:00:00.000Z',
115
+ updatedAt: '2026-07-15T12:00:00.000Z',
116
+ // A schema-evolved document: item pre-dates the `question` field.
117
+ fields: { title: 'FAQ page', faq: [{ _id: 'abc', answer: null }] },
118
+ });
119
+ expect(parsed.fields.faq).toHaveLength(1);
120
+ });
121
+ });
@@ -152,10 +152,7 @@ describe('fingerprintCollection', () => {
152
152
  rel.hasMany = true;
153
153
  expect(await fingerprintCollection(b)).not.toBe(a);
154
154
  });
155
- it.each([
156
- 'minItems',
157
- 'maxItems',
158
- ])('changes when relation %s changes', async (constraint) => {
155
+ it.each(['minItems', 'maxItems'])('changes when relation %s changes', async (constraint) => {
159
156
  const a = baseCollection();
160
157
  const aRel = a.fields.find((f) => f.name === 'category');
161
158
  aRel.hasMany = true;
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": "4.2.0",
5
+ "version": "4.3.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -81,16 +81,16 @@
81
81
  "pino": "^10.3.1",
82
82
  "sharp": "^0.35.3",
83
83
  "zod": "^4.4.3",
84
- "@byline/auth": "4.2.0"
84
+ "@byline/auth": "4.3.0"
85
85
  },
86
86
  "devDependencies": {
87
- "@biomejs/biome": "2.5.2",
88
- "@types/node": "^26.1.0",
87
+ "@biomejs/biome": "2.5.4",
88
+ "@types/node": "^26.1.1",
89
89
  "chokidar": "^5.0.0",
90
90
  "chokidar-cli": "^3.0.0",
91
91
  "npm-run-all": "^4.1.5",
92
- "tsc-alias": "^1.9.0",
93
- "tsx": "^4.23.0",
92
+ "tsc-alias": "^1.9.1",
93
+ "tsx": "^4.23.1",
94
94
  "typescript": "^7.0.2",
95
95
  "vitest": "^4.1.10"
96
96
  },