@byline/core 3.13.3 → 3.15.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.
Files changed (34) hide show
  1. package/dist/@types/admin-types.d.ts +45 -7
  2. package/dist/@types/collection-types.d.ts +32 -4
  3. package/dist/@types/field-types.d.ts +43 -0
  4. package/dist/@types/index.d.ts +1 -0
  5. package/dist/@types/index.js +1 -0
  6. package/dist/@types/search-types.d.ts +275 -0
  7. package/dist/@types/search-types.js +8 -0
  8. package/dist/@types/site-config.d.ts +31 -1
  9. package/dist/auth/assert-actor-can-perform.test.node.js +9 -1
  10. package/dist/auth/register-collection-abilities.d.ts +8 -5
  11. package/dist/auth/register-collection-abilities.js +14 -4
  12. package/dist/auth/register-collection-abilities.test.node.js +11 -7
  13. package/dist/config/config.d.ts +12 -1
  14. package/dist/config/config.js +11 -0
  15. package/dist/config/resolve-item-view-columns.test.node.d.ts +8 -0
  16. package/dist/config/resolve-item-view-columns.test.node.js +30 -0
  17. package/dist/core.js +7 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/schemas/zod/builder.js +16 -5
  21. package/dist/services/build-search-document.d.ts +46 -0
  22. package/dist/services/build-search-document.js +220 -0
  23. package/dist/services/build-search-document.test.node.d.ts +8 -0
  24. package/dist/services/build-search-document.test.node.js +164 -0
  25. package/dist/services/index.d.ts +3 -1
  26. package/dist/services/index.js +3 -1
  27. package/dist/services/populate.d.ts +6 -0
  28. package/dist/services/populate.js +19 -1
  29. package/dist/services/relation-projection.d.ts +4 -15
  30. package/dist/services/relation-projection.js +19 -6
  31. package/dist/services/validate-search-config.d.ts +28 -0
  32. package/dist/services/validate-search-config.js +32 -0
  33. package/dist/storage/collection-fingerprint.test.node.js +1 -1
  34. package/package.json +2 -2
@@ -0,0 +1,164 @@
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
+ * Contract tests for the document-grain search assembler. Verifies the
10
+ * role-based projection (body / facets / filters / zones), the schema-derived
11
+ * type enrichment, per-field boost, and that nothing outside the config is
12
+ * pulled (no leakage of unindexed content).
13
+ */
14
+ import { describe, expect, it } from 'vitest';
15
+ import { buildSearchDocument } from './build-search-document.js';
16
+ // A controlled-vocabulary facet collection: a `counter` id + a `name` term.
17
+ const topics = {
18
+ path: 'topics',
19
+ labels: { singular: 'Topic', plural: 'Topics' },
20
+ useAsTitle: 'name',
21
+ fields: [
22
+ { name: 'name', label: 'Name', type: 'text', localized: true },
23
+ { name: 'facetId', label: 'Facet Id', type: 'counter', group: 'publication-facets' },
24
+ ],
25
+ };
26
+ const publications = {
27
+ path: 'publications',
28
+ labels: { singular: 'Publication', plural: 'Publications' },
29
+ useAsTitle: 'title',
30
+ fields: [
31
+ { name: 'title', label: 'Title', type: 'text', localized: true },
32
+ { name: 'abstract', label: 'Abstract', type: 'richText', localized: true },
33
+ { name: 'editorialNotes', label: 'Editorial Notes', type: 'textArea' },
34
+ { name: 'publicationDate', label: 'Date', type: 'datetime' },
35
+ { name: 'citationCount', label: 'Citations', type: 'integer' },
36
+ {
37
+ name: 'topics',
38
+ label: 'Topics',
39
+ type: 'relation',
40
+ targetCollection: 'topics',
41
+ },
42
+ ],
43
+ search: {
44
+ body: ['title', { field: 'abstract', boost: 2 }],
45
+ facets: ['topics'],
46
+ filters: ['publicationDate', 'citationCount'],
47
+ zones: ['site', 'publications'],
48
+ },
49
+ };
50
+ const resolveTargetDefinition = (path) => (path === 'topics' ? topics : null);
51
+ // Stub `toText` seam — returns a string property so we don't depend on Lexical.
52
+ const richTextToText = ({ value }) => typeof value === 'object' && value != null ? String(value.text ?? '') : '';
53
+ function source() {
54
+ return {
55
+ documentId: 'doc-1',
56
+ locale: 'en',
57
+ status: 'published',
58
+ path: 'forest-restoration',
59
+ updatedAt: '2026-06-01T00:00:00.000Z',
60
+ fields: {
61
+ title: 'Forest Restoration',
62
+ abstract: { text: 'Methods for restoring degraded forest.' },
63
+ editorialNotes: 'Internal: chase the author for figures.',
64
+ publicationDate: '2026-05-01T00:00:00.000Z',
65
+ citationCount: 42,
66
+ topics: [
67
+ {
68
+ _resolved: true,
69
+ document: { documentId: 't-1', fields: { name: 'Ecology', facetId: 1 } },
70
+ },
71
+ {
72
+ _resolved: true,
73
+ document: { documentId: 't-2', fields: { name: 'Biodiversity', facetId: 2 } },
74
+ },
75
+ ],
76
+ },
77
+ };
78
+ }
79
+ function fieldByName(fields, name) {
80
+ return fields.find((f) => f.name === name);
81
+ }
82
+ describe('buildSearchDocument', () => {
83
+ const doc = buildSearchDocument(source(), publications, {
84
+ richTextToText,
85
+ resolveTargetDefinition,
86
+ });
87
+ it('sets identity, status, locale, path, and updatedAt', () => {
88
+ expect(doc.collectionPath).toBe('publications');
89
+ expect(doc.documentId).toBe('doc-1');
90
+ expect(doc.locale).toBe('en');
91
+ expect(doc.status).toBe('published');
92
+ expect(doc.title).toBe('Forest Restoration');
93
+ expect(doc.path).toBe('forest-restoration');
94
+ expect(doc.updatedAt).toBe('2026-06-01T00:00:00.000Z');
95
+ });
96
+ it('resolves zones from config', () => {
97
+ expect(doc.zones).toEqual(['site', 'publications']);
98
+ });
99
+ it('projects body text fields as type:text role:body', () => {
100
+ const title = fieldByName(doc.fields, 'title');
101
+ expect(title).toMatchObject({
102
+ name: 'title',
103
+ type: 'text',
104
+ role: 'body',
105
+ value: 'Forest Restoration',
106
+ });
107
+ });
108
+ it('extracts richText body via the toText seam and carries boost', () => {
109
+ const abstract = fieldByName(doc.fields, 'abstract');
110
+ expect(abstract).toMatchObject({
111
+ name: 'abstract',
112
+ type: 'text',
113
+ role: 'body',
114
+ value: 'Methods for restoring degraded forest.',
115
+ boost: 2,
116
+ });
117
+ });
118
+ it('resolves facets to {id, term} from target counter + useAsTitle', () => {
119
+ const topicsField = fieldByName(doc.fields, 'topics');
120
+ expect(topicsField).toMatchObject({ name: 'topics', type: 'facet', role: 'facet' });
121
+ expect(topicsField?.value).toEqual([
122
+ { id: 1, term: 'Ecology' },
123
+ { id: 2, term: 'Biodiversity' },
124
+ ]);
125
+ });
126
+ it('projects filters with schema-derived types', () => {
127
+ expect(fieldByName(doc.fields, 'publicationDate')).toMatchObject({
128
+ type: 'datetime',
129
+ role: 'filter',
130
+ value: '2026-05-01T00:00:00.000Z',
131
+ });
132
+ expect(fieldByName(doc.fields, 'citationCount')).toMatchObject({
133
+ type: 'integer',
134
+ role: 'filter',
135
+ value: 42,
136
+ });
137
+ });
138
+ it('does not pull fields outside the search config (no leakage)', () => {
139
+ expect(fieldByName(doc.fields, 'editorialNotes')).toBeUndefined();
140
+ });
141
+ it('defaults zones to the collection path when unset', () => {
142
+ const noZones = { ...publications, search: { body: ['title'] } };
143
+ const out = buildSearchDocument(source(), noZones);
144
+ expect(out.zones).toEqual(['publications']);
145
+ });
146
+ it('skips richText body fields when no toText seam is registered', () => {
147
+ const out = buildSearchDocument(source(), publications, { resolveTargetDefinition });
148
+ expect(fieldByName(out.fields, 'abstract')).toBeUndefined();
149
+ // Plain text body field still projects.
150
+ expect(fieldByName(out.fields, 'title')).toBeDefined();
151
+ });
152
+ it('resolves localized values to the requested locale', () => {
153
+ const localized = {
154
+ ...source(),
155
+ fields: { ...source().fields, title: { en: 'Forest Restoration', fr: 'Restauration' } },
156
+ };
157
+ const out = buildSearchDocument(localized, publications, {
158
+ richTextToText,
159
+ resolveTargetDefinition,
160
+ locale: 'fr',
161
+ });
162
+ expect(out.title).toBe('Restauration');
163
+ });
164
+ });
@@ -1,6 +1,7 @@
1
1
  export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
2
2
  export { normaliseDateFields } from '../utils/normalise-dates.js';
3
3
  export { type AssignCounterValuesInput, assignCounterValues } from './assign-counter-values.js';
4
+ export { type BuildSearchDocumentOptions, buildSearchDocument, type SearchSourceDocument, } from './build-search-document.js';
4
5
  export { type CollectionRecord, type EnsureCollectionsInput, ensureCollections, } from './collection-bootstrap.js';
5
6
  export { type DiscoverCounterGroupsInput, discoverCounterGroups, } from './discover-counter-groups.js';
6
7
  export * from './document-lifecycle/index.js';
@@ -8,8 +9,9 @@ export * from './document-read.js';
8
9
  export * from './document-to-markdown.js';
9
10
  export * from './field-upload.js';
10
11
  export { type InterfaceI18nConfig, type TranslationDriftWarning, type ValidateTranslationsResult, validateTranslations, } from './i18n-validator.js';
11
- export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, type UnresolvedRelationValue, } from './populate.js';
12
+ export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, resolveIdentityField, type UnresolvedRelationValue, } from './populate.js';
12
13
  export { buildRelationSummaryPopulateMap, type RelationTargetResolver, resolveRelationProjection, } from './relation-projection.js';
13
14
  export { type EmbedRichTextFieldsOptions, embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
14
15
  export { collectRichTextLeaves, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextAdapterPresence, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
16
+ export { type SearchProviderPresence, validateSearchConfig, } from './validate-search-config.js';
15
17
  export { type FieldLeaf, walkFieldTree } from './walk-field-tree.js';
@@ -2,6 +2,7 @@
2
2
  export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
3
3
  export { normaliseDateFields } from '../utils/normalise-dates.js';
4
4
  export { assignCounterValues } from './assign-counter-values.js';
5
+ export { buildSearchDocument, } from './build-search-document.js';
5
6
  export { ensureCollections, } from './collection-bootstrap.js';
6
7
  export { discoverCounterGroups, } from './discover-counter-groups.js';
7
8
  export * from './document-lifecycle/index.js';
@@ -9,8 +10,9 @@ export * from './document-read.js';
9
10
  export * from './document-to-markdown.js';
10
11
  export * from './field-upload.js';
11
12
  export { validateTranslations, } from './i18n-validator.js';
12
- export { createReadContext, populateDocuments, } from './populate.js';
13
+ export { createReadContext, populateDocuments, resolveIdentityField, } from './populate.js';
13
14
  export { buildRelationSummaryPopulateMap, resolveRelationProjection, } from './relation-projection.js';
14
15
  export { embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
15
16
  export { collectRichTextLeaves, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
17
+ export { validateSearchConfig, } from './validate-search-config.js';
16
18
  export { walkFieldTree } from './walk-field-tree.js';
@@ -276,6 +276,12 @@ declare function matchesPopulate(fieldName: string, populate: PopulateSpec): Pop
276
276
  * not need to appear in the `fields` list.
277
277
  */
278
278
  declare function buildBatchSelect(leaves: RelationLeafRef[], targetDef: CollectionDefinition | undefined): string[] | undefined;
279
+ /**
280
+ * The field that represents a target document's identity for populate's
281
+ * default projection. Prefers `useAsTitle` (server-safe schema-level
282
+ * config), falling back to the first declared text field.
283
+ */
284
+ export declare function resolveIdentityField(def: CollectionDefinition): string | undefined;
279
285
  /**
280
286
  * Merge the per-leaf sub-populate specs for all leaves pointing at a
281
287
  * single (now-populated) target document into a single PopulateSpec for
@@ -317,6 +317,24 @@ function collectRelationLeaves(fields, fieldDefs, populate, acc) {
317
317
  const sub = matchesPopulate(leaf.field.name, populate);
318
318
  if (sub === undefined)
319
319
  continue;
320
+ // hasMany: the value is an ordered array of relation envelopes. Expand it
321
+ // into one leaf ref per element, whose `parent` is the array itself and
322
+ // `key` is the index — so the envelope-assignment pass writes each
323
+ // populated result back into its array slot, in place.
324
+ if (leaf.field.hasMany) {
325
+ if (!Array.isArray(leaf.value))
326
+ continue;
327
+ const arr = leaf.value;
328
+ for (let i = 0; i < arr.length; i++) {
329
+ const item = arr[i];
330
+ if (!isRelatedDocumentValue(item))
331
+ continue;
332
+ if ('_resolved' in item)
333
+ continue;
334
+ acc.push({ parent: arr, key: String(i), field: leaf.field, value: item, sub });
335
+ }
336
+ continue;
337
+ }
320
338
  if (!isRelatedDocumentValue(leaf.value))
321
339
  continue;
322
340
  // Skip leaves that have already been replaced (e.g. via shared-ref
@@ -387,7 +405,7 @@ function buildBatchSelect(leaves, targetDef) {
387
405
  * default projection. Prefers `useAsTitle` (server-safe schema-level
388
406
  * config), falling back to the first declared text field.
389
407
  */
390
- function resolveIdentityField(def) {
408
+ export function resolveIdentityField(def) {
391
409
  if (def.useAsTitle)
392
410
  return def.useAsTitle;
393
411
  const firstText = def.fields.find((f) => f.type === 'text');
@@ -5,17 +5,6 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- /**
9
- * Relation projection helpers.
10
- *
11
- * Translate a source collection's relation fields into a `PopulateMap`
12
- * projection that fetches just enough data from each target to render
13
- * a relation-summary tile on the admin edit view (picker columns,
14
- * `useAsTitle`, plus an optional source-side `displayField` override).
15
- *
16
- * Consumed by the admin webapp's edit-route server fn so relation tiles
17
- * arrive pre-hydrated on first paint — no client-side fetch per relation.
18
- */
19
8
  import type { CollectionAdminConfig, CollectionDefinition, FieldSet, RelationField } from '../@types/index.js';
20
9
  import type { PopulateMap } from './populate.js';
21
10
  /**
@@ -25,10 +14,10 @@ import type { PopulateMap } from './populate.js';
25
14
  * - `sourceField.displayField` (explicit per-relation override)
26
15
  * - target's `useAsTitle`
27
16
  * - target's first declared text field (safety fallback)
28
- * - every `picker[].fieldName` on the target admin config that maps
29
- * to a real schema field (picker columns may reference metadata
30
- * like `status` or `updated_at` which ride on the document row
31
- * and don't need a projection entry)
17
+ * - every item-view column `fieldName` on the target admin config that
18
+ * maps to a real schema field (item-view columns may reference metadata
19
+ * like `status` or `updated_at` which ride on the document row and don't
20
+ * need a projection entry)
32
21
  */
33
22
  export declare function resolveRelationProjection(sourceField: RelationField, targetDef: CollectionDefinition | null, targetAdmin: CollectionAdminConfig | null): string[];
34
23
  export type RelationTargetResolver = (targetPath: string) => {
@@ -5,6 +5,18 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ /**
9
+ * Relation projection helpers.
10
+ *
11
+ * Translate a source collection's relation fields into a `PopulateMap`
12
+ * projection that fetches just enough data from each target to render
13
+ * a relation-summary tile on the admin edit view (picker columns,
14
+ * `useAsTitle`, plus an optional source-side `displayField` override).
15
+ *
16
+ * Consumed by the admin webapp's edit-route server fn so relation tiles
17
+ * arrive pre-hydrated on first paint — no client-side fetch per relation.
18
+ */
19
+ import { resolveItemViewColumns } from '../config/config.js';
8
20
  /**
9
21
  * Union of the target-collection field names needed to render a
10
22
  * relation-summary tile for the given source relation field.
@@ -12,10 +24,10 @@
12
24
  * - `sourceField.displayField` (explicit per-relation override)
13
25
  * - target's `useAsTitle`
14
26
  * - target's first declared text field (safety fallback)
15
- * - every `picker[].fieldName` on the target admin config that maps
16
- * to a real schema field (picker columns may reference metadata
17
- * like `status` or `updated_at` which ride on the document row
18
- * and don't need a projection entry)
27
+ * - every item-view column `fieldName` on the target admin config that
28
+ * maps to a real schema field (item-view columns may reference metadata
29
+ * like `status` or `updated_at` which ride on the document row and don't
30
+ * need a projection entry)
19
31
  */
20
32
  export function resolveRelationProjection(sourceField, targetDef, targetAdmin) {
21
33
  const out = new Set();
@@ -26,8 +38,9 @@ export function resolveRelationProjection(sourceField, targetDef, targetAdmin) {
26
38
  const firstText = targetDef?.fields.find((f) => f.type === 'text')?.name;
27
39
  if (firstText)
28
40
  out.add(firstText);
29
- if (targetAdmin?.picker) {
30
- for (const col of targetAdmin.picker) {
41
+ const itemViewColumns = resolveItemViewColumns(targetAdmin);
42
+ if (itemViewColumns) {
43
+ for (const col of itemViewColumns) {
31
44
  const name = String(col.fieldName);
32
45
  if (targetDef?.fields.some((f) => f.name === name))
33
46
  out.add(name);
@@ -0,0 +1,28 @@
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 type { CollectionDefinition } from '../@types/collection-types.js';
9
+ /** Whether a `SearchProvider` is registered on `ServerConfig.search`. */
10
+ export interface SearchProviderPresence {
11
+ /** `ServerConfig.search != null` */
12
+ provider: boolean;
13
+ }
14
+ /**
15
+ * Validate search configuration across every collection. Throws when any
16
+ * collection opts into search (`CollectionDefinition.search`) but no
17
+ * `SearchProvider` is registered on `ServerConfig.search` — otherwise
18
+ * indexing and `client.search()` would silently no-op.
19
+ *
20
+ * Called once at `initBylineCore()` time, right after the richText field
21
+ * validation. Fail-fast at boot is the right posture; the alternative is a
22
+ * collection that declares it's searchable but never gets indexed.
23
+ *
24
+ * Note: a missing provider is only an error when at least one collection
25
+ * actually opts in. Installations that don't use search leave
26
+ * `ServerConfig.search` unset and pass cleanly.
27
+ */
28
+ export declare function validateSearchConfig(collections: CollectionDefinition[], adapters: SearchProviderPresence): void;
@@ -0,0 +1,32 @@
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
+ * Validate search configuration across every collection. Throws when any
10
+ * collection opts into search (`CollectionDefinition.search`) but no
11
+ * `SearchProvider` is registered on `ServerConfig.search` — otherwise
12
+ * indexing and `client.search()` would silently no-op.
13
+ *
14
+ * Called once at `initBylineCore()` time, right after the richText field
15
+ * validation. Fail-fast at boot is the right posture; the alternative is a
16
+ * collection that declares it's searchable but never gets indexed.
17
+ *
18
+ * Note: a missing provider is only an error when at least one collection
19
+ * actually opts in. Installations that don't use search leave
20
+ * `ServerConfig.search` unset and pass cleanly.
21
+ */
22
+ export function validateSearchConfig(collections, adapters) {
23
+ if (adapters.provider)
24
+ return;
25
+ const optedIn = collections.filter((def) => def.search != null).map((def) => def.path);
26
+ if (optedIn.length === 0)
27
+ return;
28
+ throw new Error(`initBylineCore: ${optedIn.length} collection(s) opt into search ` +
29
+ `(${optedIn.join(', ')}) but no search provider is registered. ` +
30
+ `Wire one via ServerConfig.search — see \`@byline/search-postgres\` → ` +
31
+ `\`postgresSearch()\` for the built-in Postgres full-text driver.`);
32
+ }
@@ -89,7 +89,7 @@ describe('fingerprintCollection', () => {
89
89
  it('ignores search / showStats (admin UX only)', async () => {
90
90
  const a = await fingerprintCollection(baseCollection());
91
91
  const b = baseCollection();
92
- b.search = { fields: ['title', 'body.heading'] };
92
+ b.search = { body: ['title', 'body.heading'] };
93
93
  b.showStats = true;
94
94
  expect(await fingerprintCollection(b)).toBe(a);
95
95
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.13.3",
5
+ "version": "3.15.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "3.13.3"
82
+ "@byline/auth": "3.15.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",