@byline/core 1.3.0 → 1.5.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.
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import type { CollectionDefinition, WorkflowStatus } from './collection-types.js';
9
9
  import type { FieldComponentSlots } from './field-types.js';
10
- import type { PopulateSpec } from './populate-types.js';
11
10
  /**
12
11
  * Props passed to a custom list-view component registered via
13
12
  * `CollectionAdminConfig.listView`.
@@ -205,35 +204,43 @@ export interface CollectionAdminConfig<T = any> {
205
204
  * the preview link defaults to `/${collectionPath}/${doc.path}` — fine
206
205
  * for collections whose public URL mirrors the collection path.
207
206
  *
208
- * Two parts:
207
+ * `url(doc, ctx)` — pure function returning the preview URL. Receives
208
+ * the loaded document and a small request-scoped context object
209
+ * carrying `locale`. Return `null` to indicate "no preview URL is
210
+ * meaningful for this document yet" — `<PreviewLink>` hides itself
211
+ * in that case (e.g. missing path, missing required relation, draft
212
+ * awaiting first save).
209
213
  *
210
- * - `populate` (optional) populate hint applied when the admin loads
211
- * the document for the preview link. Lets `url(doc, ctx)` see resolved
212
- * relation values (e.g. `doc.fields.area?.document?.path`) instead
213
- * of the bare `RelatedDocumentValue` envelope. Selective by design —
214
- * full populate per-row would be expensive for list views if/when
215
- * preview links land there too.
214
+ * What's available on `doc`:
216
215
  *
217
- * - `url(doc, ctx)`pure function returning the preview URL. Receives
218
- * the (optionally populated) document and a small request-scoped
219
- * context object carrying `locale`. Return `null` to indicate "no
220
- * preview URL is meaningful for this document yet" — `<PreviewLink>`
221
- * hides itself in that case (e.g. missing slug, missing required
222
- * relation, draft awaiting first save).
216
+ * - **Top-level columns**`id`, `path`, `status`. `path` is the
217
+ * slug derived server-side from the collection's `useAsPath`
218
+ * field; it is a reserved column on every document, not a
219
+ * user-defined field. Address as `doc.path`, not `doc.fields.path`.
223
220
  *
224
- * Example for a `pages` collection routed by `area` relation:
221
+ * - **Field values** under `doc.fields` every scalar / array /
222
+ * block field of the source collection.
223
+ *
224
+ * - **Direct relation targets** under `doc.fields.<name>?.document`
225
+ * — the edit-view loader applies a blanket depth-1 populate so
226
+ * relation tiles render with target data on first paint, and
227
+ * `url(...)` inherits the same populated tree. The projection
228
+ * follows the target's `picker` columns (plus top-level columns
229
+ * like `path`, which are always present). Deeper hops, or fields
230
+ * outside the target's picker projection, are NOT populated.
231
+ *
232
+ * Example for a `news` collection routed by its `category` relation:
225
233
  *
226
234
  * ```ts
227
235
  * preview: {
228
- * populate: { area: '*' },
229
236
  * url: (doc, { locale }) => {
230
- * const area = doc.fields.area?.document?.path
231
- * const slug = doc.fields.slug
232
- * if (!slug) return null
237
+ * if (!doc.path) return null
238
+ * // `category` is a direct relation — auto-populated to depth 1.
239
+ * const category = doc.fields.category?.document?.path
233
240
  * const prefix = locale && locale !== 'en' ? `/${locale}` : ''
234
- * return area && area !== 'root'
235
- * ? `${prefix}/${area}/${slug}`
236
- * : `${prefix}/${slug}`
241
+ * return category
242
+ * ? `${prefix}/news/${category}/${doc.path}`
243
+ * : `${prefix}/news/${doc.path}`
237
244
  * },
238
245
  * }
239
246
  * ```
@@ -241,9 +248,17 @@ export interface CollectionAdminConfig<T = any> {
241
248
  * Returned URLs may be relative (`/news/foo`) for same-origin hosts
242
249
  * or absolute (`https://example.com/news/foo`) for hosts deployed
243
250
  * separately from the admin.
251
+ *
252
+ * Future consideration — a per-collection `preview.populate` hint
253
+ * (`PopulateSpec`) was prototyped and removed. The edit-view loader
254
+ * already issues a depth-1 populate to render relation tiles, so any
255
+ * selective override would have to coexist with the picker projection
256
+ * (additive? overriding? both?) — extra surface area for a case no
257
+ * current collection needs. Revisit if a real use case emerges
258
+ * (deeper relation traversal, or a field outside the picker
259
+ * projection that the URL builder needs).
244
260
  */
245
261
  preview?: {
246
- populate?: PopulateSpec;
247
262
  url: (doc: PreviewDocument<T>, ctx: {
248
263
  locale?: string;
249
264
  }) => string | null;
@@ -756,6 +756,37 @@ export type BlockData<B extends Block> = Prettify<{
756
756
  * ```
757
757
  */
758
758
  export type BlocksUnion<Bs extends readonly Block[]> = Bs[number] extends infer B ? B extends Block ? BlockData<B> : never : never;
759
+ /**
760
+ * Type-safe factory for creating a single `Field`. Returns the definition
761
+ * as-is, but locks in literal types for `name`, `type`, select option
762
+ * `value`s, etc. — so `FieldData<typeof MyField>` resolves precisely.
763
+ *
764
+ * Useful for fields that are shared across multiple collections (e.g. a
765
+ * `publishedOnField` factory) or for surfacing definition-site type errors
766
+ * on hand-authored fields without waiting for them to be placed inside a
767
+ * `fields: [...]` array. Replaces the `as const satisfies Field` pattern.
768
+ *
769
+ * For factories that *generate* a field shape from input (e.g. mapped-type
770
+ * driven fields like `availableLanguagesField`), a custom return type is
771
+ * still the right tool — `defineField` is for identity / passthrough cases.
772
+ *
773
+ * The companion data-shape extractor `FieldData<F>` lives in
774
+ * `field-data-types.ts` and is re-exported from the package root.
775
+ *
776
+ * @example
777
+ * ```ts
778
+ * // A shared "publishedOn" field used across many collections.
779
+ * export const publishedOnField = defineField({
780
+ * name: 'publishedOn',
781
+ * label: 'Published On',
782
+ * type: 'datetime',
783
+ * mode: 'datetime',
784
+ * })
785
+ *
786
+ * // FieldData<typeof publishedOnField> resolves to `Date`.
787
+ * ```
788
+ */
789
+ export declare function defineField<const F extends Field>(definition: F & Field): F;
759
790
  export type CollectionData<C extends CollectionDefinition> = Prettify<{
760
791
  document_id: string;
761
792
  document_version_id: string;
@@ -169,6 +169,42 @@ export function defineCollection(definition) {
169
169
  export function defineBlock(definition) {
170
170
  return definition;
171
171
  }
172
+ // ---------------------------------------------------------------------------
173
+ // Field helpers — mirror of defineCollection / defineBlock for single fields.
174
+ // ---------------------------------------------------------------------------
175
+ /**
176
+ * Type-safe factory for creating a single `Field`. Returns the definition
177
+ * as-is, but locks in literal types for `name`, `type`, select option
178
+ * `value`s, etc. — so `FieldData<typeof MyField>` resolves precisely.
179
+ *
180
+ * Useful for fields that are shared across multiple collections (e.g. a
181
+ * `publishedOnField` factory) or for surfacing definition-site type errors
182
+ * on hand-authored fields without waiting for them to be placed inside a
183
+ * `fields: [...]` array. Replaces the `as const satisfies Field` pattern.
184
+ *
185
+ * For factories that *generate* a field shape from input (e.g. mapped-type
186
+ * driven fields like `availableLanguagesField`), a custom return type is
187
+ * still the right tool — `defineField` is for identity / passthrough cases.
188
+ *
189
+ * The companion data-shape extractor `FieldData<F>` lives in
190
+ * `field-data-types.ts` and is re-exported from the package root.
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * // A shared "publishedOn" field used across many collections.
195
+ * export const publishedOnField = defineField({
196
+ * name: 'publishedOn',
197
+ * label: 'Published On',
198
+ * type: 'datetime',
199
+ * mode: 'datetime',
200
+ * })
201
+ *
202
+ * // FieldData<typeof publishedOnField> resolves to `Date`.
203
+ * ```
204
+ */
205
+ export function defineField(definition) {
206
+ return definition;
207
+ }
172
208
  /**
173
209
  * Strips all function-valued properties from a `CollectionDefinition`,
174
210
  * producing a version safe for JSON serialization.
@@ -9,9 +9,9 @@
9
9
  * Pure type aliases for the populate DSL. The runtime orchestrator
10
10
  * (`populateDocuments`) lives in `services/populate.ts` and re-exports
11
11
  * these for backward compatibility, but they live here so other type
12
- * surfaces notably `CollectionAdminConfig.preview.populate` can
13
- * reference them without importing the services layer (which would
14
- * create a cycle, since `populate.ts` imports from this folder).
12
+ * surfaces (e.g. `@byline/client`'s `FindOptions.populate`) can reference
13
+ * them without importing the services layer which would create a cycle,
14
+ * since `populate.ts` imports from this folder.
15
15
  */
16
16
  /**
17
17
  * Per-field populate options. `select` names the target's fields to
@@ -75,6 +75,13 @@ declare const DOCUMENT_SORT_COLUMNS: Record<string, string>;
75
75
  * are resolved into `RelationFilter` entries; otherwise only
76
76
  * direct/operator predicates against the relation's own
77
77
  * `target_document_id` are emitted.
78
+ *
79
+ * Reserved-key rules inside a nested sub-clause: `status` and `path`
80
+ * downshift to `DocumentColumnFilter` entries against the target
81
+ * version's `document_versions` columns (the adapter wires these to
82
+ * `td${depth}.status` / `td${depth}.path` via the inner relation scope);
83
+ * `query` is dropped with a debug log because text search has no
84
+ * sensible composition through a relation hop.
78
85
  */
79
86
  export declare function parseWhere(where: WhereClause | undefined, definition: CollectionDefinition, ctx?: ParseContext): Promise<ParsedWhere>;
80
87
  /**
@@ -31,6 +31,13 @@ const DOCUMENT_SORT_COLUMNS = {
31
31
  * are resolved into `RelationFilter` entries; otherwise only
32
32
  * direct/operator predicates against the relation's own
33
33
  * `target_document_id` are emitted.
34
+ *
35
+ * Reserved-key rules inside a nested sub-clause: `status` and `path`
36
+ * downshift to `DocumentColumnFilter` entries against the target
37
+ * version's `document_versions` columns (the adapter wires these to
38
+ * `td${depth}.status` / `td${depth}.path` via the inner relation scope);
39
+ * `query` is dropped with a debug log because text search has no
40
+ * sensible composition through a relation hop.
34
41
  */
35
42
  export async function parseWhere(where, definition, ctx) {
36
43
  return parseWhereInternal(where, definition, ctx, { isNested: false, inCombinator: false });
@@ -57,10 +64,15 @@ export function mergePredicates(hookPredicate, userWhere) {
57
64
  return { $and: [hookPredicate, userWhere] };
58
65
  }
59
66
  /**
60
- * Recursion entry point. `isNested: true` disables the top-level reserved
61
- * keys (`status`, `query`, `path`) so that on a nested sub-where, those
62
- * names resolve as ordinary fields on the target collection (where `path`
63
- * and `status` are typically real text fields).
67
+ * Recursion entry point. `isNested: true` rewires the document-level
68
+ * reserved keys: `status` / `path` downshift to `DocumentColumnFilter`
69
+ * entries against the target version's columns (the adapter resolves
70
+ * those via the inner relation scope, e.g. `td${depth}.status`); `query`
71
+ * is dropped with a debug log because text search has no sensible
72
+ * composition through a relation hop. Reserved keys still take precedence
73
+ * over field lookups — a target collection that happens to declare a
74
+ * `path` or `status` field will not see those clauses resolve as field
75
+ * filters; rename the offending field if that conflict matters.
64
76
  */
65
77
  async function parseWhereInternal(where, definition, ctx, { isNested, inCombinator }) {
66
78
  const result = { filters: [] };
@@ -134,65 +146,82 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
134
146
  continue;
135
147
  }
136
148
  // --- Document-level reserved keys --------------------------------------
137
- // At the top level (not nested in a relation, not inside a combinator)
138
- // these keys map to direct adapter scalar parameters
139
- // (`ParsedWhere.status` / `query` / `pathFilter`) which compile to the
140
- // outermost WHERE clause. Inside a combinator that mapping no longer
141
- // composes — the predicate needs to OR with siblings, which the scalar
142
- // parameter form cannot express so `status` / `path` downshift to a
143
- // `DocumentColumnFilter` and `query` is dropped (text search has no
144
- // sensible OR-composing form here).
145
- if (!isNested) {
146
- if (key === 'status') {
147
- if (inCombinator) {
148
- const parsed = normaliseToOperator(rawValue);
149
- if (parsed) {
150
- result.filters.push({
151
- kind: 'docColumn',
152
- column: 'status',
153
- operator: parsed.operator,
154
- value: parsed.value === null ? null : String(parsed.value),
155
- });
156
- }
157
- continue;
158
- }
159
- if (typeof rawValue === 'string') {
160
- result.status = rawValue;
149
+ // `status` / `path` / `query` are reserved on every collection they
150
+ // never resolve to a field of the same name (no field-shadow exception),
151
+ // mirroring the consumer-intuitive rule that these names refer to the
152
+ // document version's metadata columns.
153
+ //
154
+ // Where they compile depends on scope:
155
+ //
156
+ // Top level (not nested in a relation, not inside a combinator):
157
+ // map to direct adapter scalar parameters
158
+ // (`ParsedWhere.status` / `query` / `pathFilter`) which compile to
159
+ // the outermost WHERE clause.
160
+ //
161
+ // • Inside a combinator, OR inside a relation sub-clause: the scalar-
162
+ // parameter mapping no longer composes (the predicate needs to OR
163
+ // with siblings, or anchor against the *target's* doc version), so
164
+ // `status` / `path` downshift to a `DocumentColumnFilter`. The SQL
165
+ // compiler reads `outerScope.status` / `outerScope.path`, which the
166
+ // adapter rewires per-scope (`d.path` at the top level,
167
+ // `td${depth}.path` inside a relation hop).
168
+ //
169
+ // • `query` is dropped with a debug log in both downshift cases —
170
+ // text search has no sensible OR-composing form, and a per-target
171
+ // EXISTS over `store_text` is intentionally deferred.
172
+ if (key === 'status') {
173
+ if (isNested || inCombinator) {
174
+ const parsed = normaliseToOperator(rawValue);
175
+ if (parsed) {
176
+ result.filters.push({
177
+ kind: 'docColumn',
178
+ column: 'status',
179
+ operator: parsed.operator,
180
+ value: parsed.value === null ? null : String(parsed.value),
181
+ });
161
182
  }
162
183
  continue;
163
184
  }
164
- if (key === 'query') {
165
- if (inCombinator) {
166
- ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a combinator — text search does not compose with OR/AND');
167
- continue;
168
- }
169
- if (typeof rawValue === 'string') {
170
- result.query = rawValue;
171
- }
185
+ if (typeof rawValue === 'string') {
186
+ result.status = rawValue;
187
+ }
188
+ continue;
189
+ }
190
+ if (key === 'query') {
191
+ if (isNested) {
192
+ ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a relation sub-clause — text search does not compose through a relation hop');
172
193
  continue;
173
194
  }
174
- if (key === 'path') {
175
- if (inCombinator) {
176
- const parsed = normaliseToOperator(rawValue);
177
- if (parsed) {
178
- result.filters.push({
179
- kind: 'docColumn',
180
- column: 'path',
181
- operator: parsed.operator,
182
- value: parsed.value === null ? null : String(parsed.value),
183
- });
184
- }
185
- continue;
186
- }
195
+ if (inCombinator) {
196
+ ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a combinator — text search does not compose with OR/AND');
197
+ continue;
198
+ }
199
+ if (typeof rawValue === 'string') {
200
+ result.query = rawValue;
201
+ }
202
+ continue;
203
+ }
204
+ if (key === 'path') {
205
+ if (isNested || inCombinator) {
187
206
  const parsed = normaliseToOperator(rawValue);
188
207
  if (parsed) {
189
- result.pathFilter = {
208
+ result.filters.push({
209
+ kind: 'docColumn',
210
+ column: 'path',
190
211
  operator: parsed.operator,
191
- value: String(parsed.value),
192
- };
212
+ value: parsed.value === null ? null : String(parsed.value),
213
+ });
193
214
  }
194
215
  continue;
195
216
  }
217
+ const parsed = normaliseToOperator(rawValue);
218
+ if (parsed) {
219
+ result.pathFilter = {
220
+ operator: parsed.operator,
221
+ value: String(parsed.value),
222
+ };
223
+ }
224
+ continue;
196
225
  }
197
226
  // --- Field-level keys --------------------------------------------------
198
227
  const field = definition.fields.find((f) => f.name === key);
@@ -226,11 +255,11 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
226
255
  isNested: true,
227
256
  inCombinator: false,
228
257
  });
229
- // Flatten nested: only field-level / relation-level conditions make
230
- // sense inside a relation subclause. Document-level keys (status,
231
- // path, query) on the target are deliberately out of scope for this
232
- // first phase they can be added later by promoting them into
233
- // the nested filter list here.
258
+ // Splice nested filters straight in. The nested parse runs with
259
+ // `isNested: true`, which promotes `status` / `path` into
260
+ // `DocumentColumnFilter` entries the adapter resolves against the
261
+ // target version's columns (`td${depth}.status` / `td${depth}.path`)
262
+ // via the inner relation scope. `query` is dropped one level up.
234
263
  result.filters.push({
235
264
  kind: 'relation',
236
265
  fieldName: key,
@@ -40,7 +40,11 @@ const categoriesCollection = defineCollection({
40
40
  }),
41
41
  fields: [
42
42
  { name: 'name', type: 'text', label: 'Name', localized: true },
43
- { name: 'path', type: 'text', label: 'Path' },
43
+ // `slug`, not `path` `path` is a reserved key that resolves to the
44
+ // target version's `document_versions.path` column inside a nested
45
+ // sub-clause (same precedence as the top level), so a real `path`
46
+ // field would be unreachable through the where clause.
47
+ { name: 'slug', type: 'text', label: 'Slug' },
44
48
  {
45
49
  name: 'parent',
46
50
  type: 'relation',
@@ -220,8 +224,8 @@ describe('parseWhere', () => {
220
224
  value: ['cat-a', 'cat-b'],
221
225
  });
222
226
  });
223
- it('should emit a RelationFilter for a nested plain-object sub-where', async () => {
224
- const result = await parseWhere({ category: { path: 'news' } }, testCollection, ctx);
227
+ it('should emit a RelationFilter for a nested plain-object sub-where (field key)', async () => {
228
+ const result = await parseWhere({ category: { slug: 'news' } }, testCollection, ctx);
225
229
  expect(result.filters).toHaveLength(1);
226
230
  expect(result.filters[0]).toEqual({
227
231
  kind: 'relation',
@@ -230,7 +234,7 @@ describe('parseWhere', () => {
230
234
  nested: [
231
235
  {
232
236
  kind: 'field',
233
- fieldName: 'path',
237
+ fieldName: 'slug',
234
238
  storeType: 'text',
235
239
  valueColumn: 'value',
236
240
  operator: '$eq',
@@ -239,6 +243,55 @@ describe('parseWhere', () => {
239
243
  ],
240
244
  });
241
245
  });
246
+ it('promotes `path` inside a nested sub-where to a DocumentColumnFilter', async () => {
247
+ // Reserved-key precedence: `path` inside a relation sub-clause maps to
248
+ // the target version's `document_versions.path` column, never to a
249
+ // field of the same name. The adapter wires it to `td${depth}.path`
250
+ // via the inner relation scope.
251
+ const result = await parseWhere({ category: { path: 'news' } }, testCollection, ctx);
252
+ expect(result.filters).toHaveLength(1);
253
+ expect(result.filters[0]).toEqual({
254
+ kind: 'relation',
255
+ fieldName: 'category',
256
+ targetCollectionId: 'id-test-categories',
257
+ nested: [
258
+ {
259
+ kind: 'docColumn',
260
+ column: 'path',
261
+ operator: '$eq',
262
+ value: 'news',
263
+ },
264
+ ],
265
+ });
266
+ });
267
+ it('promotes `status` inside a nested sub-where to a DocumentColumnFilter', async () => {
268
+ const result = await parseWhere({ category: { status: 'draft' } }, testCollection, ctx);
269
+ expect(result.filters).toHaveLength(1);
270
+ expect(result.filters[0]).toEqual({
271
+ kind: 'relation',
272
+ fieldName: 'category',
273
+ targetCollectionId: 'id-test-categories',
274
+ nested: [
275
+ {
276
+ kind: 'docColumn',
277
+ column: 'status',
278
+ operator: '$eq',
279
+ value: 'draft',
280
+ },
281
+ ],
282
+ });
283
+ });
284
+ it('drops `query` inside a nested sub-where (no nested filter emitted)', async () => {
285
+ // Same rationale as `query` inside a combinator: text search has no
286
+ // sensible composition through a relation hop.
287
+ const result = await parseWhere({ category: { query: 'news' } }, testCollection, ctx);
288
+ expect(result.filters).toHaveLength(1);
289
+ const relation = result.filters[0];
290
+ expect(relation?.kind).toBe('relation');
291
+ if (relation?.kind !== 'relation')
292
+ return;
293
+ expect(relation.nested).toEqual([]);
294
+ });
242
295
  it('should support operator objects inside a nested sub-where', async () => {
243
296
  const result = await parseWhere({ category: { name: { $contains: 'news' } } }, testCollection, ctx);
244
297
  const relation = result.filters[0];
@@ -257,7 +310,7 @@ describe('parseWhere', () => {
257
310
  ]);
258
311
  });
259
312
  it('should recurse into multi-hop relation sub-wheres', async () => {
260
- const result = await parseWhere({ category: { parent: { path: 'news' } } }, testCollection, ctx);
313
+ const result = await parseWhere({ category: { parent: { slug: 'news' } } }, testCollection, ctx);
261
314
  const top = result.filters[0];
262
315
  expect(top?.kind).toBe('relation');
263
316
  if (top?.kind !== 'relation')
@@ -274,7 +327,7 @@ describe('parseWhere', () => {
274
327
  expect(inner.nested).toEqual([
275
328
  {
276
329
  kind: 'field',
277
- fieldName: 'path',
330
+ fieldName: 'slug',
278
331
  storeType: 'text',
279
332
  valueColumn: 'value',
280
333
  operator: '$eq',
@@ -282,6 +335,26 @@ describe('parseWhere', () => {
282
335
  },
283
336
  ]);
284
337
  });
338
+ it('recurses multi-hop with the doc-column form (target.path at depth 2)', async () => {
339
+ const result = await parseWhere({ category: { parent: { path: 'news' } } }, testCollection, ctx);
340
+ const top = result.filters[0];
341
+ expect(top?.kind).toBe('relation');
342
+ if (top?.kind !== 'relation')
343
+ return;
344
+ expect(top.nested).toHaveLength(1);
345
+ const inner = top.nested[0];
346
+ expect(inner?.kind).toBe('relation');
347
+ if (inner?.kind !== 'relation')
348
+ return;
349
+ expect(inner.nested).toEqual([
350
+ {
351
+ kind: 'docColumn',
352
+ column: 'path',
353
+ operator: '$eq',
354
+ value: 'news',
355
+ },
356
+ ]);
357
+ });
285
358
  it('should skip nested sub-where when ctx is not provided', async () => {
286
359
  const result = await parseWhere({ category: { path: 'news' } }, testCollection);
287
360
  expect(result.filters).toEqual([]);
@@ -526,6 +599,11 @@ describe('parseWhere — combinators', () => {
526
599
  expect(result.filters).toEqual([]);
527
600
  });
528
601
  it('parses combinators inside a nested relation sub-where', async () => {
602
+ // Inside the nested sub-where: `name` is a real field on the target
603
+ // (resolves to a FieldFilter) and `path` is a reserved key that
604
+ // downshifts to a `DocumentColumnFilter` against the target version's
605
+ // path column. Inside an `$or` either side composes — the OR is
606
+ // emitted as a CombinatorFilter wrapping both children.
529
607
  const result = await parseWhere({
530
608
  category: {
531
609
  $or: [{ name: 'News' }, { path: 'announcements' }],
@@ -541,7 +619,7 @@ describe('parseWhere — combinators', () => {
541
619
  kind: 'or',
542
620
  children: [
543
621
  { kind: 'field', fieldName: 'name' },
544
- { kind: 'field', fieldName: 'path' },
622
+ { kind: 'docColumn', column: 'path', operator: '$eq', value: 'announcements' },
545
623
  ],
546
624
  });
547
625
  });
@@ -7,3 +7,4 @@ export * from './field-upload.js';
7
7
  export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, type UnresolvedRelationValue, } from './populate.js';
8
8
  export { buildRelationSummaryPopulateMap, type RelationTargetResolver, resolveRelationProjection, } from './relation-projection.js';
9
9
  export { collectRichTextLeaves, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
10
+ export { type FieldLeaf, walkFieldTree } from './walk-field-tree.js';
@@ -8,3 +8,4 @@ export * from './field-upload.js';
8
8
  export { createReadContext, populateDocuments, } from './populate.js';
9
9
  export { buildRelationSummaryPopulateMap, resolveRelationProjection, } from './relation-projection.js';
10
10
  export { collectRichTextLeaves, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
11
+ export { walkFieldTree } from './walk-field-tree.js';
@@ -249,11 +249,14 @@ interface RelationLeafRef {
249
249
  sub: PopulateFieldSpec;
250
250
  }
251
251
  /**
252
- * Walk `fields` against `fieldDefs` and collect every relation leaf whose
253
- * name matches `populate`. Recurses through `group` / `array` / `blocks`
254
- * using the same populate spec (structure field names do not scope the
255
- * match — if `populate: { author: true }` is given, every `author`
256
- * relation found in the tree matches).
252
+ * Collect every relation leaf whose name matches `populate`. Structure
253
+ * field names do not scope the match — if `populate: { author: true }`
254
+ * is given, every `author` relation found anywhere in the tree matches.
255
+ *
256
+ * Tree traversal is delegated to the shared `walkFieldTree` walker; this
257
+ * function applies the relation-specific filters: populate-spec match,
258
+ * envelope shape, and "skip already-populated" (via the `_resolved`
259
+ * discriminator left behind by a previous populate pass).
257
260
  */
258
261
  declare function collectRelationLeaves(fields: Record<string, any>, fieldDefs: FieldSet, populate: PopulateSpec, acc: RelationLeafRef[]): void;
259
262
  declare function matchesPopulate(fieldName: string, populate: PopulateSpec): PopulateFieldSpec | undefined;
@@ -10,6 +10,7 @@ import { ERR_READ_BUDGET_EXCEEDED } from '../lib/errors.js';
10
10
  import { parseWhere } from '../query/parse-where.js';
11
11
  import { applyAfterRead } from './document-read.js';
12
12
  import { populateRichTextFields } from './richtext-populate.js';
13
+ import { walkFieldTree } from './walk-field-tree.js';
13
14
  // ---------------------------------------------------------------------------
14
15
  // ReadContext — recursion guard
15
16
  // ---------------------------------------------------------------------------
@@ -300,69 +301,36 @@ async function resolveBeforeReadFiltersForTarget(params) {
300
301
  return parsed.filters.length > 0 ? parsed.filters : undefined;
301
302
  }
302
303
  /**
303
- * Walk `fields` against `fieldDefs` and collect every relation leaf whose
304
- * name matches `populate`. Recurses through `group` / `array` / `blocks`
305
- * using the same populate spec (structure field names do not scope the
306
- * match — if `populate: { author: true }` is given, every `author`
307
- * relation found in the tree matches).
304
+ * Collect every relation leaf whose name matches `populate`. Structure
305
+ * field names do not scope the match — if `populate: { author: true }`
306
+ * is given, every `author` relation found anywhere in the tree matches.
307
+ *
308
+ * Tree traversal is delegated to the shared `walkFieldTree` walker; this
309
+ * function applies the relation-specific filters: populate-spec match,
310
+ * envelope shape, and "skip already-populated" (via the `_resolved`
311
+ * discriminator left behind by a previous populate pass).
308
312
  */
309
313
  function collectRelationLeaves(fields, fieldDefs, populate, acc) {
310
- for (const def of fieldDefs) {
311
- const rawValue = fields[def.name];
312
- if (rawValue == null)
314
+ for (const leaf of walkFieldTree(fieldDefs, fields)) {
315
+ if (leaf.field.type !== 'relation')
313
316
  continue;
314
- if (def.type === 'relation') {
315
- const sub = matchesPopulate(def.name, populate);
316
- if (sub === undefined)
317
- continue;
318
- if (!isRelatedDocumentValue(rawValue))
319
- continue;
320
- // Skip leaves that have already been replaced (e.g. via shared-ref
321
- // duplication at the previous level); only raw RelatedDocumentValues
322
- // are candidates for population.
323
- if ('_resolved' in rawValue)
324
- continue;
325
- acc.push({
326
- parent: fields,
327
- key: def.name,
328
- field: def,
329
- value: rawValue,
330
- sub,
331
- });
317
+ const sub = matchesPopulate(leaf.field.name, populate);
318
+ if (sub === undefined)
332
319
  continue;
333
- }
334
- if (def.type === 'group') {
335
- if (typeof rawValue === 'object' && !Array.isArray(rawValue)) {
336
- collectRelationLeaves(rawValue, def.fields, populate, acc);
337
- }
320
+ if (!isRelatedDocumentValue(leaf.value))
338
321
  continue;
339
- }
340
- if (def.type === 'array') {
341
- if (Array.isArray(rawValue)) {
342
- for (const item of rawValue) {
343
- if (item && typeof item === 'object' && !Array.isArray(item)) {
344
- collectRelationLeaves(item, def.fields, populate, acc);
345
- }
346
- }
347
- }
322
+ // Skip leaves that have already been replaced (e.g. via shared-ref
323
+ // duplication at the previous level); only raw RelatedDocumentValues
324
+ // are candidates for population.
325
+ if ('_resolved' in leaf.value)
348
326
  continue;
349
- }
350
- if (def.type === 'blocks') {
351
- if (Array.isArray(rawValue)) {
352
- for (const item of rawValue) {
353
- if (item && typeof item === 'object' && !Array.isArray(item)) {
354
- // Reconstructed block items carry `_type` set to the variant's `blockType`.
355
- const blockType = item._type;
356
- if (typeof blockType !== 'string')
357
- continue;
358
- const block = def.blocks.find((b) => b.blockType === blockType);
359
- if (!block)
360
- continue;
361
- collectRelationLeaves(item, block.fields, populate, acc);
362
- }
363
- }
364
- }
365
- }
327
+ acc.push({
328
+ parent: leaf.parent,
329
+ key: leaf.key,
330
+ field: leaf.field,
331
+ value: leaf.value,
332
+ sub,
333
+ });
366
334
  }
367
335
  }
368
336
  function matchesPopulate(fieldName, populate) {
@@ -40,13 +40,17 @@ export interface RichTextLeaf {
40
40
  /**
41
41
  * Walk a field set and a matching reconstructed data tree in lockstep,
42
42
  * yielding every rich-text leaf the schema declares regardless of nesting
43
- * depth. Recurses through `group` (nested object), `array` (array of
44
- * sub-objects), and `blocks` (array of `_type`-discriminated sub-objects).
43
+ * depth.
44
+ *
45
+ * Tree traversal is delegated to the shared `walkFieldTree` walker; this
46
+ * function is the rich-text-specific filter — it surfaces only leaves
47
+ * whose declared `type === 'richText'` and re-shapes the leaf as a
48
+ * `RichTextLeaf` for downstream callers.
45
49
  *
46
50
  * Tolerates missing data — a `group` whose data is absent simply yields
47
- * nothing under that subtree, rather than throwing. The schema is the
48
- * source of truth for *where* a richText might be; the data is the source
49
- * of truth for *whether one is currently set*.
51
+ * nothing under that subtree. The schema is the source of truth for
52
+ * *where* a richText might be; the data is the source of truth for
53
+ * *whether one is currently set*.
50
54
  */
51
55
  export declare function collectRichTextLeaves(fields: FieldSet, data: Record<string, any> | null | undefined, pathPrefix?: string): Generator<RichTextLeaf, void, void>;
52
56
  export interface PopulateRichTextFieldsOptions {
@@ -24,67 +24,28 @@
24
24
  * adapters can implement per-leaf caching if needed.
25
25
  */
26
26
  import { isArrayField, isBlocksField, isGroupField, } from '../@types/field-types.js';
27
+ import { walkFieldTree } from './walk-field-tree.js';
27
28
  /**
28
29
  * Walk a field set and a matching reconstructed data tree in lockstep,
29
30
  * yielding every rich-text leaf the schema declares regardless of nesting
30
- * depth. Recurses through `group` (nested object), `array` (array of
31
- * sub-objects), and `blocks` (array of `_type`-discriminated sub-objects).
31
+ * depth.
32
+ *
33
+ * Tree traversal is delegated to the shared `walkFieldTree` walker; this
34
+ * function is the rich-text-specific filter — it surfaces only leaves
35
+ * whose declared `type === 'richText'` and re-shapes the leaf as a
36
+ * `RichTextLeaf` for downstream callers.
32
37
  *
33
38
  * Tolerates missing data — a `group` whose data is absent simply yields
34
- * nothing under that subtree, rather than throwing. The schema is the
35
- * source of truth for *where* a richText might be; the data is the source
36
- * of truth for *whether one is currently set*.
39
+ * nothing under that subtree. The schema is the source of truth for
40
+ * *where* a richText might be; the data is the source of truth for
41
+ * *whether one is currently set*.
37
42
  */
38
43
  export function* collectRichTextLeaves(fields, data, pathPrefix = '') {
39
- if (data == null)
40
- return;
41
- for (const field of fields) {
42
- const here = pathPrefix === '' ? field.name : `${pathPrefix}.${field.name}`;
43
- yield* walkField(field, data[field.name], here);
44
- }
45
- }
46
- function* walkField(field, value, fieldPath) {
47
- if (field.type === 'richText') {
48
- if (value === undefined || value === null)
49
- return;
50
- yield { field, value, fieldPath };
51
- return;
52
- }
53
- if (isGroupField(field)) {
54
- if (value == null || typeof value !== 'object')
55
- return;
56
- yield* collectRichTextLeaves(field.fields, value, fieldPath);
57
- return;
58
- }
59
- if (isArrayField(field)) {
60
- if (!Array.isArray(value))
61
- return;
62
- for (let i = 0; i < value.length; i++) {
63
- const item = value[i];
64
- if (item == null || typeof item !== 'object')
65
- continue;
66
- yield* collectRichTextLeaves(field.fields, item, `${fieldPath}.${i}`);
67
- }
68
- return;
69
- }
70
- if (isBlocksField(field)) {
71
- if (!Array.isArray(value))
72
- return;
73
- for (let i = 0; i < value.length; i++) {
74
- const item = value[i];
75
- if (item == null || typeof item !== 'object')
76
- continue;
77
- const blockType = item._type;
78
- if (!blockType)
79
- continue;
80
- const block = field.blocks.find((b) => b.blockType === blockType);
81
- if (!block)
82
- continue;
83
- yield* collectRichTextLeaves(block.fields, item, `${fieldPath}.${i}`);
84
- }
85
- return;
44
+ for (const leaf of walkFieldTree(fields, data, pathPrefix)) {
45
+ if (leaf.field.type !== 'richText')
46
+ continue;
47
+ yield { field: leaf.field, value: leaf.value, fieldPath: leaf.fieldPath };
86
48
  }
87
- // Any other field type — value-only leaves with no nested richText.
88
49
  }
89
50
  /**
90
51
  * Resolve the effective `populateRelationsOnRead` for a richText field.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Shared field-tree walker.
10
+ *
11
+ * `walkFieldTree(fields, data)` traverses a `(FieldSet, data)` pair in
12
+ * lockstep, descending through `group` / `array` / `blocks` structure
13
+ * fields and yielding every value-leaf it finds. Consumers filter by
14
+ * `field.type` and apply their own domain checks (e.g. relation envelope
15
+ * shape, populate-spec match, `_resolved` skip, richText null handling).
16
+ *
17
+ * Both `collectRelationLeaves` (populate.ts) and `collectRichTextLeaves`
18
+ * (richtext-populate.ts) are now thin filters over this primitive — see
19
+ * docs/TODO.md "walkFieldTree" entry for the rationale.
20
+ */
21
+ import { type Field, type FieldSet } from '../@types/field-types.js';
22
+ /**
23
+ * One value-leaf yielded by `walkFieldTree`. The walker hands back a
24
+ * reference to the *parent container* (`parent[key]`) so consumers can
25
+ * mutate or replace the value in place — `parent[key] === value` always
26
+ * holds at yield time.
27
+ *
28
+ * `fieldPath` is the dotted path from the root of the walk, with array
29
+ * indices spelled inline (`faq.0.answer`, `content.1.richText`). Suitable
30
+ * for error messages and debug logging.
31
+ */
32
+ export interface FieldLeaf {
33
+ field: Field;
34
+ value: unknown;
35
+ parent: Record<string, any>;
36
+ key: string;
37
+ fieldPath: string;
38
+ }
39
+ /**
40
+ * Walk a field set and a matching reconstructed data tree in lockstep,
41
+ * yielding every value-leaf the schema declares regardless of nesting
42
+ * depth.
43
+ *
44
+ * **What counts as a leaf:** every non-structure field whose value is
45
+ * non-null. Structure fields (`group` / `array` / `blocks`) are descended
46
+ * into, never yielded themselves. Null / undefined values are skipped
47
+ * silently — the schema is the source of truth for *where* a leaf might
48
+ * be; the data is the source of truth for *whether one is currently set*.
49
+ *
50
+ * Tolerates malformed data gracefully:
51
+ * - a `group` whose data is missing or non-object yields nothing
52
+ * - an `array` whose data isn't an array yields nothing
53
+ * - a `blocks` item with a missing or unknown `_type` is skipped
54
+ *
55
+ * The walker is synchronous and lazy (a generator). Async work — DB
56
+ * fetches, hook fan-out — happens in the consumer after the walk yields.
57
+ */
58
+ export declare function walkFieldTree(fields: FieldSet, data: Record<string, any> | null | undefined, pathPrefix?: string): Generator<FieldLeaf, void, void>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Shared field-tree walker.
10
+ *
11
+ * `walkFieldTree(fields, data)` traverses a `(FieldSet, data)` pair in
12
+ * lockstep, descending through `group` / `array` / `blocks` structure
13
+ * fields and yielding every value-leaf it finds. Consumers filter by
14
+ * `field.type` and apply their own domain checks (e.g. relation envelope
15
+ * shape, populate-spec match, `_resolved` skip, richText null handling).
16
+ *
17
+ * Both `collectRelationLeaves` (populate.ts) and `collectRichTextLeaves`
18
+ * (richtext-populate.ts) are now thin filters over this primitive — see
19
+ * docs/TODO.md "walkFieldTree" entry for the rationale.
20
+ */
21
+ import { isArrayField, isBlocksField, isGroupField, } from '../@types/field-types.js';
22
+ /**
23
+ * Walk a field set and a matching reconstructed data tree in lockstep,
24
+ * yielding every value-leaf the schema declares regardless of nesting
25
+ * depth.
26
+ *
27
+ * **What counts as a leaf:** every non-structure field whose value is
28
+ * non-null. Structure fields (`group` / `array` / `blocks`) are descended
29
+ * into, never yielded themselves. Null / undefined values are skipped
30
+ * silently — the schema is the source of truth for *where* a leaf might
31
+ * be; the data is the source of truth for *whether one is currently set*.
32
+ *
33
+ * Tolerates malformed data gracefully:
34
+ * - a `group` whose data is missing or non-object yields nothing
35
+ * - an `array` whose data isn't an array yields nothing
36
+ * - a `blocks` item with a missing or unknown `_type` is skipped
37
+ *
38
+ * The walker is synchronous and lazy (a generator). Async work — DB
39
+ * fetches, hook fan-out — happens in the consumer after the walk yields.
40
+ */
41
+ export function* walkFieldTree(fields, data, pathPrefix = '') {
42
+ if (data == null || typeof data !== 'object' || Array.isArray(data))
43
+ return;
44
+ for (const field of fields) {
45
+ const fieldPath = pathPrefix === '' ? field.name : `${pathPrefix}.${field.name}`;
46
+ yield* walkField(field, data, field.name, fieldPath);
47
+ }
48
+ }
49
+ function* walkField(field, parent, key, fieldPath) {
50
+ const value = parent[key];
51
+ if (value == null)
52
+ return;
53
+ if (isGroupField(field)) {
54
+ if (typeof value !== 'object' || Array.isArray(value))
55
+ return;
56
+ yield* walkFieldTree(field.fields, value, fieldPath);
57
+ return;
58
+ }
59
+ if (isArrayField(field)) {
60
+ if (!Array.isArray(value))
61
+ return;
62
+ for (let i = 0; i < value.length; i++) {
63
+ const item = value[i];
64
+ if (item == null || typeof item !== 'object' || Array.isArray(item))
65
+ continue;
66
+ yield* walkFieldTree(field.fields, item, `${fieldPath}.${i}`);
67
+ }
68
+ return;
69
+ }
70
+ if (isBlocksField(field)) {
71
+ if (!Array.isArray(value))
72
+ return;
73
+ for (let i = 0; i < value.length; i++) {
74
+ const item = value[i];
75
+ if (item == null || typeof item !== 'object' || Array.isArray(item))
76
+ continue;
77
+ const blockType = item._type;
78
+ if (typeof blockType !== 'string')
79
+ continue;
80
+ const block = field.blocks.find((b) => b.blockType === blockType);
81
+ if (!block)
82
+ continue;
83
+ yield* walkFieldTree(block.fields, item, `${fieldPath}.${i}`);
84
+ }
85
+ return;
86
+ }
87
+ yield { field, value, parent, key, fieldPath };
88
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,193 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { walkFieldTree } from './walk-field-tree.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Fixture — every nesting shape the walker has to handle, plus a few
12
+ // near-misses (unknown block type, missing _type, non-array `array` data,
13
+ // non-object `group` data) to exercise the tolerance paths.
14
+ // ---------------------------------------------------------------------------
15
+ const fields = [
16
+ { name: 'title', type: 'text', label: 'Title' },
17
+ { name: 'body', type: 'richText', label: 'Body' },
18
+ {
19
+ name: 'meta',
20
+ type: 'group',
21
+ label: 'Meta',
22
+ fields: [
23
+ { name: 'summary', type: 'text', label: 'Summary' },
24
+ {
25
+ name: 'inner',
26
+ type: 'group',
27
+ label: 'Inner',
28
+ fields: [{ name: 'note', type: 'text', label: 'Note' }],
29
+ },
30
+ ],
31
+ },
32
+ {
33
+ name: 'faq',
34
+ type: 'array',
35
+ label: 'FAQ',
36
+ fields: [{ name: 'answer', type: 'richText', label: 'Answer' }],
37
+ },
38
+ {
39
+ name: 'content',
40
+ type: 'blocks',
41
+ label: 'Content',
42
+ blocks: [
43
+ {
44
+ blockType: 'photoBlock',
45
+ fields: [
46
+ { name: 'caption', type: 'richText', label: 'Caption' },
47
+ { name: 'alt', type: 'text', label: 'Alt' },
48
+ ],
49
+ },
50
+ {
51
+ blockType: 'richTextBlock',
52
+ fields: [
53
+ // Same field name as `photoBlock.alt` but a different shape — guards
54
+ // against any cross-variant leakage in the dispatch.
55
+ { name: 'alt', type: 'richText', label: 'Alt-rich' },
56
+ ],
57
+ },
58
+ ],
59
+ },
60
+ ];
61
+ // ---------------------------------------------------------------------------
62
+ // Path coverage — every nesting shape yields the expected dotted paths.
63
+ // ---------------------------------------------------------------------------
64
+ describe('walkFieldTree — paths', () => {
65
+ it('yields top-level value-leaves with field name as path', () => {
66
+ const data = { title: 'hello', body: { kind: 'rt' } };
67
+ const out = Array.from(walkFieldTree(fields, data));
68
+ const paths = out.map((l) => l.fieldPath).sort();
69
+ expect(paths).toEqual(['body', 'title']);
70
+ });
71
+ it('descends into nested group fields', () => {
72
+ const data = { meta: { summary: 's', inner: { note: 'n' } } };
73
+ const out = Array.from(walkFieldTree(fields, data));
74
+ const paths = out.map((l) => l.fieldPath).sort();
75
+ expect(paths).toEqual(['meta.inner.note', 'meta.summary']);
76
+ });
77
+ it('inlines numeric indices into array paths', () => {
78
+ const data = {
79
+ faq: [{ answer: { kind: 'a0' } }, { answer: { kind: 'a1' } }],
80
+ };
81
+ const out = Array.from(walkFieldTree(fields, data));
82
+ const paths = out.map((l) => l.fieldPath).sort();
83
+ expect(paths).toEqual(['faq.0.answer', 'faq.1.answer']);
84
+ });
85
+ it('dispatches blocks on _type and recurses into the matching variant', () => {
86
+ const data = {
87
+ content: [
88
+ { _id: 'a', _type: 'photoBlock', caption: { kind: 'c' }, alt: 'plain' },
89
+ { _id: 'b', _type: 'richTextBlock', alt: { kind: 'rt' } },
90
+ ],
91
+ };
92
+ const out = Array.from(walkFieldTree(fields, data));
93
+ const paths = out.map((l) => l.fieldPath).sort();
94
+ expect(paths).toEqual(['content.0.alt', 'content.0.caption', 'content.1.alt']);
95
+ });
96
+ it('honours pathPrefix at the top of the walk', () => {
97
+ const data = { title: 't' };
98
+ const out = Array.from(walkFieldTree(fields, data, 'root'));
99
+ expect(out.map((l) => l.fieldPath)).toEqual(['root.title']);
100
+ });
101
+ });
102
+ // ---------------------------------------------------------------------------
103
+ // parent[key] === value invariant — consumers rely on this to mutate /
104
+ // replace the leaf in place (e.g. populate writing the resolved doc back).
105
+ // ---------------------------------------------------------------------------
106
+ describe('walkFieldTree — parent / key invariant', () => {
107
+ it('parent[key] resolves to value at every yield', () => {
108
+ const data = {
109
+ title: 'hi',
110
+ meta: { summary: 's' },
111
+ faq: [{ answer: { kind: 'rt' } }],
112
+ content: [{ _id: 'a', _type: 'photoBlock', alt: 'plain' }],
113
+ };
114
+ const out = Array.from(walkFieldTree(fields, data));
115
+ expect(out.length).toBeGreaterThan(0);
116
+ for (const leaf of out) {
117
+ expect(leaf.parent[leaf.key]).toBe(leaf.value);
118
+ }
119
+ });
120
+ it('mutation through parent[key] is visible to the caller', () => {
121
+ const data = { title: 'before' };
122
+ const out = Array.from(walkFieldTree(fields, data));
123
+ const leaf = out.find((l) => l.fieldPath === 'title');
124
+ leaf.parent[leaf.key] = 'after';
125
+ expect(data.title).toBe('after');
126
+ });
127
+ });
128
+ // ---------------------------------------------------------------------------
129
+ // Tolerance paths — malformed / missing data is skipped, never thrown.
130
+ // ---------------------------------------------------------------------------
131
+ describe('walkFieldTree — tolerance', () => {
132
+ it('returns an empty iterator for null / undefined data', () => {
133
+ expect(Array.from(walkFieldTree(fields, null))).toEqual([]);
134
+ expect(Array.from(walkFieldTree(fields, undefined))).toEqual([]);
135
+ });
136
+ it('returns an empty iterator for non-object top-level data', () => {
137
+ expect(Array.from(walkFieldTree(fields, []))).toEqual([]);
138
+ });
139
+ it('skips leaves whose value is null / undefined', () => {
140
+ const data = { title: null, body: undefined };
141
+ expect(Array.from(walkFieldTree(fields, data))).toEqual([]);
142
+ });
143
+ it('skips a group whose value is non-object', () => {
144
+ const data = { meta: 'not-an-object' };
145
+ expect(Array.from(walkFieldTree(fields, data))).toEqual([]);
146
+ });
147
+ it('skips an array field whose value is not actually an array', () => {
148
+ const data = { faq: { answer: { kind: 'rt' } } };
149
+ expect(Array.from(walkFieldTree(fields, data))).toEqual([]);
150
+ });
151
+ it('skips block items missing _type', () => {
152
+ const data = {
153
+ content: [{ _id: 'a', caption: { kind: 'rt' } }],
154
+ };
155
+ expect(Array.from(walkFieldTree(fields, data))).toEqual([]);
156
+ });
157
+ it('skips block items with an unknown _type', () => {
158
+ const data = {
159
+ content: [
160
+ { _id: 'a', _type: 'unknownBlock', caption: { kind: 'rt' } },
161
+ { _id: 'b', _type: 'richTextBlock', alt: { kind: 'rt' } },
162
+ ],
163
+ };
164
+ const out = Array.from(walkFieldTree(fields, data));
165
+ expect(out.map((l) => l.fieldPath)).toEqual(['content.1.alt']);
166
+ });
167
+ it('skips a non-object array item silently', () => {
168
+ const data = { faq: [null, 'string', { answer: { kind: 'rt' } }] };
169
+ const out = Array.from(walkFieldTree(fields, data));
170
+ expect(out.map((l) => l.fieldPath)).toEqual(['faq.2.answer']);
171
+ });
172
+ });
173
+ // ---------------------------------------------------------------------------
174
+ // Field identity — the FieldLeaf carries the actual schema field def, so
175
+ // consumers can branch on `field.type` and read field-specific options.
176
+ // ---------------------------------------------------------------------------
177
+ describe('walkFieldTree — field identity', () => {
178
+ it('yields the same Field reference declared in the schema', () => {
179
+ const data = { title: 'hi' };
180
+ const out = Array.from(walkFieldTree(fields, data));
181
+ const titleLeaf = out.find((l) => l.fieldPath === 'title');
182
+ expect(titleLeaf?.field).toBe(fields[0]);
183
+ });
184
+ it('yields the block-variant field reference for blocks descents', () => {
185
+ const data = {
186
+ content: [{ _id: 'a', _type: 'photoBlock', alt: 'plain' }],
187
+ };
188
+ const out = Array.from(walkFieldTree(fields, data));
189
+ const altLeaf = out.find((l) => l.fieldPath === 'content.0.alt');
190
+ expect(altLeaf?.field.type).toBe('text'); // photoBlock.alt is a text field
191
+ expect(altLeaf?.field.name).toBe('alt');
192
+ });
193
+ });
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": "1.3.0",
5
+ "version": "1.5.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.2",
82
- "@byline/auth": "1.3.0"
82
+ "@byline/auth": "1.4.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.14",