@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.
- package/dist/@types/admin-types.d.ts +45 -7
- package/dist/@types/collection-types.d.ts +32 -4
- package/dist/@types/field-types.d.ts +22 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/search-types.d.ts +275 -0
- package/dist/@types/search-types.js +8 -0
- package/dist/@types/site-config.d.ts +31 -1
- package/dist/auth/assert-actor-can-perform.test.node.js +9 -1
- package/dist/auth/register-collection-abilities.d.ts +8 -5
- package/dist/auth/register-collection-abilities.js +14 -4
- package/dist/auth/register-collection-abilities.test.node.js +11 -7
- package/dist/config/config.d.ts +12 -1
- package/dist/config/config.js +11 -0
- package/dist/config/resolve-item-view-columns.test.node.d.ts +8 -0
- package/dist/config/resolve-item-view-columns.test.node.js +30 -0
- package/dist/core.js +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/services/build-search-document.d.ts +46 -0
- package/dist/services/build-search-document.js +220 -0
- package/dist/services/build-search-document.test.node.d.ts +8 -0
- package/dist/services/build-search-document.test.node.js +164 -0
- package/dist/services/index.d.ts +3 -1
- package/dist/services/index.js +3 -1
- package/dist/services/populate.d.ts +6 -0
- package/dist/services/populate.js +1 -1
- package/dist/services/relation-projection.d.ts +4 -15
- package/dist/services/relation-projection.js +19 -6
- package/dist/services/validate-search-config.d.ts +28 -0
- package/dist/services/validate-search-config.js +32 -0
- package/dist/storage/collection-fingerprint.test.node.js +1 -1
- 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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
97
|
-
expect(buckets.get('collections.news')?.length).toBe(
|
|
98
|
-
expect(registry.size).toBe(
|
|
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', () => {
|
package/dist/config/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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';
|