@byline/core 3.14.0 → 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 (33) 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 +22 -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/services/build-search-document.d.ts +46 -0
  21. package/dist/services/build-search-document.js +220 -0
  22. package/dist/services/build-search-document.test.node.d.ts +8 -0
  23. package/dist/services/build-search-document.test.node.js +164 -0
  24. package/dist/services/index.d.ts +3 -1
  25. package/dist/services/index.js +3 -1
  26. package/dist/services/populate.d.ts +6 -0
  27. package/dist/services/populate.js +1 -1
  28. package/dist/services/relation-projection.d.ts +4 -15
  29. package/dist/services/relation-projection.js +19 -6
  30. package/dist/services/validate-search-config.d.ts +28 -0
  31. package/dist/services/validate-search-config.js +32 -0
  32. package/dist/storage/collection-fingerprint.test.node.js +1 -1
  33. package/package.json +2 -2
@@ -42,6 +42,7 @@ describe('registerCollectionAbilities', () => {
42
42
  'collections.pages.delete',
43
43
  'collections.pages.publish',
44
44
  'collections.pages.changeStatus',
45
+ 'collections.pages.reindex',
45
46
  ]);
46
47
  });
47
48
  it('places every ability under the same `collections.<path>` group', () => {
@@ -49,7 +50,7 @@ describe('registerCollectionAbilities', () => {
49
50
  registerCollectionAbilities(registry, pageCollection());
50
51
  const buckets = registry.byGroup();
51
52
  expect(buckets.size).toBe(1);
52
- expect(buckets.get('collections.pages')?.length).toBe(6);
53
+ expect(buckets.get('collections.pages')?.length).toBe(7);
53
54
  });
54
55
  it('derives labels from the collection singular/plural labels', () => {
55
56
  const registry = new AbilityRegistry();
@@ -60,6 +61,7 @@ describe('registerCollectionAbilities', () => {
60
61
  expect(registry.get('collections.pages.delete')?.label).toBe('Delete Page');
61
62
  expect(registry.get('collections.pages.publish')?.label).toBe('Publish Pages');
62
63
  expect(registry.get('collections.pages.changeStatus')?.label).toBe('Change status of Pages');
64
+ expect(registry.get('collections.pages.reindex')?.label).toBe('Reindex Pages search');
63
65
  });
64
66
  it('tags every ability with source: "collection"', () => {
65
67
  const registry = new AbilityRegistry();
@@ -68,7 +70,7 @@ describe('registerCollectionAbilities', () => {
68
70
  expect(descriptor.source).toBe('collection');
69
71
  }
70
72
  });
71
- it('registers the same six-ability shape regardless of workflow complexity', () => {
73
+ it('registers the same seven-ability shape regardless of workflow complexity', () => {
72
74
  const registry = new AbilityRegistry();
73
75
  registerCollectionAbilities(registry, newsCollection());
74
76
  expect(registry.list().map((d) => d.key)).toEqual([
@@ -78,14 +80,15 @@ describe('registerCollectionAbilities', () => {
78
80
  'collections.news.delete',
79
81
  'collections.news.publish',
80
82
  'collections.news.changeStatus',
83
+ 'collections.news.reindex',
81
84
  ]);
82
85
  });
83
- it('is idempotent — calling twice leaves the registry with the same six entries', () => {
86
+ it('is idempotent — calling twice leaves the registry with the same seven entries', () => {
84
87
  const registry = new AbilityRegistry();
85
88
  const collection = pageCollection();
86
89
  registerCollectionAbilities(registry, collection);
87
90
  registerCollectionAbilities(registry, collection);
88
- expect(registry.size).toBe(6);
91
+ expect(registry.size).toBe(7);
89
92
  });
90
93
  it('keeps multiple collections isolated in distinct groups', () => {
91
94
  const registry = new AbilityRegistry();
@@ -93,9 +96,9 @@ describe('registerCollectionAbilities', () => {
93
96
  registerCollectionAbilities(registry, newsCollection());
94
97
  const buckets = registry.byGroup();
95
98
  expect(buckets.size).toBe(2);
96
- expect(buckets.get('collections.pages')?.length).toBe(6);
97
- expect(buckets.get('collections.news')?.length).toBe(6);
98
- expect(registry.size).toBe(12);
99
+ expect(buckets.get('collections.pages')?.length).toBe(7);
100
+ expect(buckets.get('collections.news')?.length).toBe(7);
101
+ expect(registry.size).toBe(14);
99
102
  });
100
103
  });
101
104
  describe('COLLECTION_ABILITY_VERBS / collectionAbilityKey', () => {
@@ -107,6 +110,7 @@ describe('COLLECTION_ABILITY_VERBS / collectionAbilityKey', () => {
107
110
  'delete',
108
111
  'publish',
109
112
  'changeStatus',
113
+ 'reindex',
110
114
  ]);
111
115
  });
112
116
  it('collectionAbilityKey composes a flat dotted key', () => {
@@ -1,4 +1,4 @@
1
- import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ServerConfig } from '../@types/index.js';
1
+ import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ColumnDefinition, ServerConfig } from '../@types/index.js';
2
2
  /**
3
3
  * Resolve a collection definition by `path`. Returns `null` either when
4
4
  * no config has been registered (e.g. unit tests, isolated tooling) or
@@ -12,6 +12,17 @@ import type { ClientConfig, CollectionAdminConfig, CollectionDefinition, ServerC
12
12
  */
13
13
  export declare const getCollectionDefinition: (path: string) => CollectionDefinition | null;
14
14
  export declare const getCollectionAdminConfig: (slug: string) => CollectionAdminConfig | null;
15
+ /**
16
+ * Resolve a collection's item-row/tile columns — the per-collection projection
17
+ * + presentation contract used by the relation picker, relation/`hasMany`
18
+ * tiles, and (planned) search-result rows.
19
+ *
20
+ * Prefers the canonical {@link CollectionAdminConfig.itemView}, falling back to
21
+ * the deprecated `picker` alias. Always read item-view columns through this
22
+ * helper rather than touching `config.picker` directly, so the alias keeps
23
+ * working until it is removed.
24
+ */
25
+ export declare const resolveItemViewColumns: (config: CollectionAdminConfig | null | undefined) => ColumnDefinition[] | undefined;
15
26
  export declare function defineClientConfig(config: ClientConfig): void;
16
27
  export declare function defineServerConfig(config: ServerConfig): void;
17
28
  export declare function getClientConfig(): ClientConfig;
@@ -54,6 +54,17 @@ export const getCollectionAdminConfig = (slug) => {
54
54
  return null;
55
55
  return clientConfig.admin?.find((admin) => admin.slug === slug) ?? null;
56
56
  };
57
+ /**
58
+ * Resolve a collection's item-row/tile columns — the per-collection projection
59
+ * + presentation contract used by the relation picker, relation/`hasMany`
60
+ * tiles, and (planned) search-result rows.
61
+ *
62
+ * Prefers the canonical {@link CollectionAdminConfig.itemView}, falling back to
63
+ * the deprecated `picker` alias. Always read item-view columns through this
64
+ * helper rather than touching `config.picker` directly, so the alias keeps
65
+ * working until it is removed.
66
+ */
67
+ export const resolveItemViewColumns = (config) => config?.itemView ?? config?.picker;
57
68
  export function defineClientConfig(config) {
58
69
  validateCollections(config.collections);
59
70
  validateAdminConfigs(config.admin, config.collections);
@@ -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,30 @@
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 { resolveItemViewColumns } from './config.js';
10
+ const itemViewCols = [{ fieldName: 'title', label: 'Title' }];
11
+ const pickerCols = [{ fieldName: 'name', label: 'Name' }];
12
+ const cfg = (over) => ({ slug: 'x', ...over });
13
+ describe('resolveItemViewColumns', () => {
14
+ it('returns itemView when present', () => {
15
+ expect(resolveItemViewColumns(cfg({ itemView: itemViewCols }))).toBe(itemViewCols);
16
+ });
17
+ it('falls back to the deprecated picker alias', () => {
18
+ expect(resolveItemViewColumns(cfg({ picker: pickerCols }))).toBe(pickerCols);
19
+ });
20
+ it('prefers itemView over picker when both are present', () => {
21
+ expect(resolveItemViewColumns(cfg({ itemView: itemViewCols, picker: pickerCols }))).toBe(itemViewCols);
22
+ });
23
+ it('returns undefined when neither is set', () => {
24
+ expect(resolveItemViewColumns(cfg({}))).toBeUndefined();
25
+ });
26
+ it('tolerates null / undefined config', () => {
27
+ expect(resolveItemViewColumns(null)).toBeUndefined();
28
+ expect(resolveItemViewColumns(undefined)).toBeUndefined();
29
+ });
30
+ });
package/dist/core.js CHANGED
@@ -14,6 +14,7 @@ import { ensureCollections } from './services/collection-bootstrap.js';
14
14
  import { discoverCounterGroups } from './services/discover-counter-groups.js';
15
15
  import { validateTranslations } from './services/i18n-validator.js';
16
16
  import { validateRichTextFieldFlags } from './services/richtext-populate.js';
17
+ import { validateSearchConfig } from './services/validate-search-config.js';
17
18
  /**
18
19
  * Initialize Byline CMS core services via the typed registry.
19
20
  *
@@ -46,6 +47,12 @@ export const initBylineCore = async (config, pinoLogger) => {
46
47
  populate: config.fields?.richText?.populate != null,
47
48
  embed: config.fields?.richText?.embed != null,
48
49
  });
50
+ // Validate search configuration: a collection that opts into search must
51
+ // have a SearchProvider registered, otherwise indexing / client.search()
52
+ // would silently no-op. Fail-fast at boot, same posture as richText above.
53
+ validateSearchConfig(composed.collections, {
54
+ provider: config.search != null,
55
+ });
49
56
  // Validate the admin i18n translation registry against the configured
50
57
  // interface locale set. Throws on structural errors (missing bundle for
51
58
  // a declared locale, defaultLocale outside the permitted set, …); soft
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from './@types/index.js';
2
2
  export { applyBeforeRead, assertActorCanPerform, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './auth/index.js';
3
- export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
3
+ export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
4
4
  export { resolveRoutes } from './config/routes.js';
5
5
  export { validateAdminConfigs } from './config/validate-admin-configs.js';
6
6
  export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // ---------------------------------------------------------------------------
17
17
  export * from './@types/index.js';
18
18
  export { applyBeforeRead, assertActorCanPerform, COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './auth/index.js';
19
- export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
19
+ export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
20
20
  export { resolveRoutes } from './config/routes.js';
21
21
  export { validateAdminConfigs } from './config/validate-admin-configs.js';
22
22
  export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
@@ -0,0 +1,46 @@
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, RichTextToTextFn, SearchDocument } from '../@types/index.js';
9
+ /** A locale-resolved document fed to the assembler — one locale's view. */
10
+ export interface SearchSourceDocument {
11
+ /** Stable document id (shared across versions and locales). */
12
+ documentId: string;
13
+ /** Content locale this view represents. */
14
+ locale: string;
15
+ /** Lifecycle status of the indexed version. */
16
+ status: string;
17
+ /** URL path, or null when the collection has none. */
18
+ path?: string | null;
19
+ /**
20
+ * Locale-resolved, camelCase field data (the `ClientDocument.fields`
21
+ * shape). Relation fields named in `search.facets` must be populated.
22
+ */
23
+ fields: Record<string, any>;
24
+ /** Timestamp of the indexed version. */
25
+ updatedAt?: Date | string;
26
+ }
27
+ export interface BuildSearchDocumentOptions {
28
+ /**
29
+ * Rich-text plain-text extractor (`ServerConfig.fields.richText.toText`).
30
+ * Required for `richText` fields named in `search.body`; without it those
31
+ * fields are skipped.
32
+ */
33
+ richTextToText?: RichTextToTextFn;
34
+ /**
35
+ * Resolve a target collection definition by path — used to find a facet
36
+ * target's identity field (the term) and `counter` field (the id).
37
+ */
38
+ resolveTargetDefinition?: (collectionPath: string) => CollectionDefinition | null;
39
+ /** Content locale, for defensive locale-envelope resolution. */
40
+ locale?: string;
41
+ }
42
+ /**
43
+ * Assemble one type-enriched `SearchDocument` from a locale-resolved
44
+ * document and its collection's role-based `search` config.
45
+ */
46
+ export declare function buildSearchDocument(doc: SearchSourceDocument, definition: CollectionDefinition, options?: BuildSearchDocumentOptions): SearchDocument;
@@ -0,0 +1,220 @@
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
+ * `buildSearchDocument` — the document-grain assembler for the
10
+ * `SearchProvider` seam. Walks a collection's role-based `search` config
11
+ * against one locale-resolved document and emits a single, type-enriched
12
+ * `SearchDocument` for a driver to index. See
13
+ * `docs/05-reading-and-delivery/07-search.md`.
14
+ *
15
+ * Role-based and explicit: only the fields named in `search.{body,facets,
16
+ * filters}` are projected — nothing is auto-pulled, so unindexed content
17
+ * never leaks into the index. Core derives each field's `SearchFieldType`
18
+ * from the schema (the "type enrichment") so a driver can map it onto its
19
+ * own index without re-inspecting the collection definition.
20
+ *
21
+ * Pure and synchronous, like `documentToMarkdown`: the rich-text plain-text
22
+ * extractor is the editor-agnostic `toText` seam passed via options, and
23
+ * relation targets are resolved through a caller-supplied definition
24
+ * resolver — no globals, no DB reads. The caller is responsible for handing
25
+ * in a document whose `facets` relation fields are already populated (depth
26
+ * 1) with the target's identity + counter fields.
27
+ *
28
+ * v1 scope: `search.{body,facets,filters}` name **top-level** fields. Deep
29
+ * paths into blocks / arrays are a follow-up.
30
+ */
31
+ import { resolveIdentityField } from './populate.js';
32
+ /**
33
+ * Assemble one type-enriched `SearchDocument` from a locale-resolved
34
+ * document and its collection's role-based `search` config.
35
+ */
36
+ export function buildSearchDocument(doc, definition, options = {}) {
37
+ const locale = options.locale ?? doc.locale;
38
+ const search = definition.search ?? {};
39
+ const fieldsData = doc.fields ?? {};
40
+ const title = stringValue(resolveLocalized(fieldsData[resolveIdentityField(definition) ?? ''], locale)) ?? '';
41
+ const zones = search.zones != null && search.zones.length > 0 ? search.zones : [definition.path];
42
+ const fields = [];
43
+ // --- body: searchable text -------------------------------------------------
44
+ for (const decl of search.body ?? []) {
45
+ const name = declName(decl);
46
+ const field = definition.fields.find((f) => f.name === name);
47
+ if (field == null)
48
+ continue;
49
+ let value;
50
+ if (field.type === 'richText') {
51
+ value = options.richTextToText
52
+ ? nonEmpty(options.richTextToText({
53
+ value: resolveLocalized(fieldsData[name], locale),
54
+ fieldPath: name,
55
+ collectionPath: definition.path,
56
+ }))
57
+ : null;
58
+ }
59
+ else {
60
+ value = stringValue(resolveLocalized(fieldsData[name], locale));
61
+ }
62
+ if (value != null) {
63
+ fields.push(withBoost({ name, type: 'text', role: 'body', value }, decl));
64
+ }
65
+ }
66
+ // --- facets: controlled-vocabulary references ------------------------------
67
+ for (const decl of search.facets ?? []) {
68
+ const name = declName(decl);
69
+ const field = definition.fields.find((f) => f.name === name);
70
+ if (field == null || field.type !== 'relation')
71
+ continue;
72
+ const targetPath = field.targetCollection;
73
+ const targetDef = targetPath ? (options.resolveTargetDefinition?.(targetPath) ?? null) : null;
74
+ const termField = targetDef ? resolveIdentityField(targetDef) : undefined;
75
+ const idField = targetDef?.fields.find((f) => f.type === 'counter')?.name;
76
+ const facetValues = toEnvelopeArray(fieldsData[name])
77
+ .map((env) => extractFacetValue(env, termField, idField, locale))
78
+ .filter((v) => v != null);
79
+ if (facetValues.length > 0) {
80
+ fields.push(withBoost({ name, type: 'facet', role: 'facet', value: facetValues }, decl));
81
+ }
82
+ }
83
+ // --- filters: scalar projections for filtering / sorting -------------------
84
+ for (const name of search.filters ?? []) {
85
+ const field = definition.fields.find((f) => f.name === name);
86
+ if (field == null)
87
+ continue;
88
+ const type = filterType(field.type);
89
+ const value = coerceFilterValue(resolveLocalized(fieldsData[name], locale), type);
90
+ if (value != null) {
91
+ fields.push({ name, type, role: 'filter', value });
92
+ }
93
+ }
94
+ return {
95
+ collectionPath: definition.path,
96
+ documentId: doc.documentId,
97
+ locale,
98
+ status: doc.status,
99
+ zones,
100
+ title,
101
+ path: doc.path ?? null,
102
+ fields,
103
+ updatedAt: dateValue(doc.updatedAt) ?? new Date().toISOString(),
104
+ };
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Internals
108
+ // ---------------------------------------------------------------------------
109
+ function declName(decl) {
110
+ return typeof decl === 'string' ? decl : decl.field;
111
+ }
112
+ function withBoost(field, decl) {
113
+ const boost = typeof decl === 'string' ? undefined : decl.boost;
114
+ return boost != null ? { ...field, boost } : field;
115
+ }
116
+ /** A populated single relation or array of relations → array of envelopes. */
117
+ function toEnvelopeArray(value) {
118
+ if (Array.isArray(value))
119
+ return value.filter((v) => v != null);
120
+ if (value != null && typeof value === 'object')
121
+ return [value];
122
+ return [];
123
+ }
124
+ function extractFacetValue(envelope, termField, idField, locale) {
125
+ const target = asRecord(envelope.document);
126
+ const targetFields = asRecord(target.fields);
127
+ const term = termField ? stringValue(resolveLocalized(targetFields[termField], locale)) : null;
128
+ if (term == null)
129
+ return null;
130
+ // Prefer the target's stable counter id (the aggregator's reporting key);
131
+ // fall back to its document id when the vocabulary has no counter field.
132
+ const rawId = idField != null ? targetFields[idField] : undefined;
133
+ const id = typeof rawId === 'number' || typeof rawId === 'string'
134
+ ? rawId
135
+ : (stringValue(target.documentId ?? target.id) ?? term);
136
+ return { id, term };
137
+ }
138
+ /** Map a collection field type to the filter-side `SearchFieldType`. */
139
+ function filterType(fieldType) {
140
+ switch (fieldType) {
141
+ case 'integer':
142
+ case 'counter':
143
+ return 'integer';
144
+ case 'float':
145
+ case 'decimal':
146
+ return 'float';
147
+ case 'boolean':
148
+ case 'checkbox':
149
+ return 'boolean';
150
+ case 'date':
151
+ case 'time':
152
+ case 'datetime':
153
+ return 'datetime';
154
+ default:
155
+ return 'keyword';
156
+ }
157
+ }
158
+ function coerceFilterValue(value, type) {
159
+ if (value == null)
160
+ return null;
161
+ switch (type) {
162
+ case 'integer':
163
+ case 'float': {
164
+ const n = typeof value === 'number' ? value : Number(value);
165
+ return Number.isFinite(n) ? n : null;
166
+ }
167
+ case 'boolean':
168
+ return typeof value === 'boolean' ? value : null;
169
+ case 'datetime':
170
+ return dateValue(value);
171
+ default:
172
+ return stringValue(value);
173
+ }
174
+ }
175
+ /**
176
+ * Locale-scoped reads deliver flat values; `locale: 'all'` reads deliver
177
+ * `{ en: …, fr: … }` envelopes. Pick the requested locale (or the first
178
+ * available) when an envelope sneaks through.
179
+ */
180
+ function resolveLocalized(value, locale) {
181
+ if (value != null &&
182
+ typeof value === 'object' &&
183
+ !Array.isArray(value) &&
184
+ !(value instanceof Date)) {
185
+ const record = value;
186
+ const keys = Object.keys(record);
187
+ const localeLike = keys.length > 0 && keys.every((k) => /^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(k));
188
+ if (localeLike) {
189
+ if (locale && locale in record)
190
+ return record[locale];
191
+ return record[keys[0]];
192
+ }
193
+ }
194
+ return value;
195
+ }
196
+ function asRecord(value) {
197
+ return value != null && typeof value === 'object' && !Array.isArray(value)
198
+ ? value
199
+ : {};
200
+ }
201
+ function nonEmpty(value) {
202
+ return value != null && value.trim().length > 0 ? value : null;
203
+ }
204
+ function stringValue(value) {
205
+ if (typeof value === 'string')
206
+ return value.trim().length > 0 ? value : null;
207
+ if (typeof value === 'number')
208
+ return String(value);
209
+ return null;
210
+ }
211
+ function dateValue(value) {
212
+ if (value instanceof Date)
213
+ return value.toISOString();
214
+ if (typeof value === 'string') {
215
+ const date = new Date(value);
216
+ if (!Number.isNaN(date.getTime()))
217
+ return date.toISOString();
218
+ }
219
+ return null;
220
+ }
@@ -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,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';