@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.
- package/dist/@types/admin-types.d.ts +38 -23
- package/dist/@types/collection-types.d.ts +31 -0
- package/dist/@types/collection-types.js +36 -0
- package/dist/@types/populate-types.d.ts +3 -3
- package/dist/query/parse-where.d.ts +7 -0
- package/dist/query/parse-where.js +86 -57
- package/dist/query/parse-where.test.node.js +85 -7
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/populate.d.ts +8 -5
- package/dist/services/populate.js +25 -57
- package/dist/services/richtext-populate.d.ts +9 -5
- package/dist/services/richtext-populate.js +14 -53
- package/dist/services/walk-field-tree.d.ts +58 -0
- package/dist/services/walk-field-tree.js +88 -0
- package/dist/services/walk-field-tree.test.node.d.ts +8 -0
- package/dist/services/walk-field-tree.test.node.js +193 -0
- package/package.json +2 -2
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
235
|
-
* ? `${prefix}/${
|
|
236
|
-
* : `${prefix}/${
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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`
|
|
61
|
-
* keys
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// the
|
|
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
|
-
|
|
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: {
|
|
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: '
|
|
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: {
|
|
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: '
|
|
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: '
|
|
622
|
+
{ kind: 'docColumn', column: 'path', operator: '$eq', value: 'announcements' },
|
|
545
623
|
],
|
|
546
624
|
});
|
|
547
625
|
});
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
|
311
|
-
|
|
312
|
-
if (rawValue == null)
|
|
314
|
+
for (const leaf of walkFieldTree(fieldDefs, fields)) {
|
|
315
|
+
if (leaf.field.type !== 'relation')
|
|
313
316
|
continue;
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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.
|
|
44
|
-
*
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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.
|
|
31
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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.
|
|
82
|
+
"@byline/auth": "1.4.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.14",
|