@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.
- 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 +43 -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/schemas/zod/builder.js +16 -5
- 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 +19 -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
|
@@ -182,15 +182,26 @@ export interface CollectionAdminConfig<T = any> {
|
|
|
182
182
|
/** Column definitions for the collection list view. */
|
|
183
183
|
columns?: ColumnDefinition<T>[];
|
|
184
184
|
/**
|
|
185
|
-
* Column definitions for this collection
|
|
186
|
-
*
|
|
185
|
+
* Column definitions for rendering this collection as a compact **item
|
|
186
|
+
* row / tile** wherever a single document is shown outside its own list —
|
|
187
|
+
* the relation picker modal, relation-summary tiles, `hasMany` relation
|
|
188
|
+
* tiles, and (planned) cross-collection search-result rows.
|
|
187
189
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
190
|
+
* It is a per-collection *item contract*: it declares both **what to fetch**
|
|
191
|
+
* (the projection — which fields hydrate the row, including relation
|
|
192
|
+
* `displayField`s) and **how to render** (the columns + formatters, e.g. a
|
|
193
|
+
* thumbnail cell or date formatter). Shape matches `ColumnDefinition` so
|
|
194
|
+
* formatters are shared with the list view. Omit to fall back to a
|
|
195
|
+
* single-line render of `useAsTitle` + `path`.
|
|
191
196
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
197
|
+
* Resolve it through `resolveItemViewColumns(config)` rather than reading
|
|
198
|
+
* the field directly, so the deprecated `picker` alias keeps working.
|
|
199
|
+
*/
|
|
200
|
+
itemView?: ColumnDefinition<T>[];
|
|
201
|
+
/**
|
|
202
|
+
* @deprecated Renamed to {@link itemView}. Kept as a backwards-compatible
|
|
203
|
+
* alias — `itemView` wins when both are present. Read both via
|
|
204
|
+
* `resolveItemViewColumns(config)`. Will be removed in a future major.
|
|
194
205
|
*/
|
|
195
206
|
picker?: ColumnDefinition<T>[];
|
|
196
207
|
/** Default columns to show when no explicit column config is provided. */
|
|
@@ -299,6 +310,33 @@ export interface CollectionAdminConfig<T = any> {
|
|
|
299
310
|
* ```
|
|
300
311
|
*/
|
|
301
312
|
listView?: (props: ListViewComponentProps) => any;
|
|
313
|
+
/**
|
|
314
|
+
* Header action components for the **default** list view — rendered in the
|
|
315
|
+
* list header alongside the Create button. Each receives a
|
|
316
|
+
* {@link ListActionComponentProps} (`{ collectionPath }`). The reusable
|
|
317
|
+
* injection point for collection-level admin actions (reindex search,
|
|
318
|
+
* export, bulk operations, …) without replacing the whole `listView`.
|
|
319
|
+
*
|
|
320
|
+
* Ignored when a custom `listView` is provided (that component owns its own
|
|
321
|
+
* chrome). Components are responsible for their own permission gating.
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```ts
|
|
325
|
+
* import { ReindexButton } from '@byline/host-tanstack-start/admin-shell/collections/reindex-button'
|
|
326
|
+
* // In your CollectionAdminConfig:
|
|
327
|
+
* listActions: [ReindexButton],
|
|
328
|
+
* ```
|
|
329
|
+
*/
|
|
330
|
+
listActions?: Array<(props: ListActionComponentProps) => any>;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Props passed to each `CollectionAdminConfig.listActions` component. Kept
|
|
334
|
+
* minimal and framework-agnostic (`@byline/core` is React-free); components
|
|
335
|
+
* resolve everything else (server fns, abilities, toasts) from the host.
|
|
336
|
+
*/
|
|
337
|
+
export interface ListActionComponentProps {
|
|
338
|
+
/** The collection path the list view is showing, e.g. `'docs'`. */
|
|
339
|
+
collectionPath: string;
|
|
302
340
|
}
|
|
303
341
|
/**
|
|
304
342
|
* Type-safe factory for creating a `CollectionAdminConfig` linked to a schema.
|
|
@@ -10,6 +10,7 @@ import type { ReadContext } from './db-types.js';
|
|
|
10
10
|
import type { FieldSetData, FieldSetDataAllLocales, StoredFileValue } from './field-data-types.js';
|
|
11
11
|
import type { Block, DefaultValue, Field, FileField, ImageField } from './field-types.js';
|
|
12
12
|
import type { QueryPredicate } from './query-predicate.js';
|
|
13
|
+
import type { SearchFieldDecl } from './search-types.js';
|
|
13
14
|
import type { IStorageProvider } from './storage-types.js';
|
|
14
15
|
import type { Prettify } from './type-utils.js';
|
|
15
16
|
/** Output format for Sharp-generated image variants. */
|
|
@@ -895,12 +896,39 @@ export interface CollectionDefinition {
|
|
|
895
896
|
*/
|
|
896
897
|
hooks?: CollectionHooks | CollectionHooksLoader;
|
|
897
898
|
/**
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
899
|
+
* Search configuration for this collection — a **role-based** declaration
|
|
900
|
+
* of what to index. The implementor names fields by the role they play;
|
|
901
|
+
* core derives each field's type from the schema and assembles the
|
|
902
|
+
* type-enriched `SearchDocument` (see the `SearchProvider` seam in
|
|
903
|
+
* `docs/05-reading-and-delivery/07-search.md`). Nothing is auto-pulled, so
|
|
904
|
+
* unindexed content (editorial notes, internal fields) never leaks into
|
|
905
|
+
* the index.
|
|
906
|
+
*
|
|
907
|
+
* - `body` — fields whose text feeds the full-text searchable content.
|
|
908
|
+
* Text fields contribute their value; `richText` fields are extracted to
|
|
909
|
+
* plain text via the registered `fields.richText.toText` seam. Each entry
|
|
910
|
+
* is a field path, or `{ field, boost }` to weight it for scoring
|
|
911
|
+
* providers that support `capabilities.weighting`. Drives the admin
|
|
912
|
+
* list-view search box (its `store_text` subset). Falls back to the
|
|
913
|
+
* identity field (`useAsTitle`) when omitted.
|
|
914
|
+
* - `facets` — relation field paths to controlled-vocabulary collections.
|
|
915
|
+
* Core resolves each target's `counter` field (the stable aggregation id)
|
|
916
|
+
* and its `useAsTitle` (the term, folded into searchable text). `{ field,
|
|
917
|
+
* boost }` weights the indexed term.
|
|
918
|
+
* - `filters` — scalar field paths projected for filtering / sorting (not
|
|
919
|
+
* scored).
|
|
920
|
+
* - `zones` — the search scope(s) this collection belongs to. A collection
|
|
921
|
+
* can belong to more than one zone (e.g. both a dedicated `publications`
|
|
922
|
+
* archive search and a general `site` search). When a collection opts
|
|
923
|
+
* into search without naming zones, it gets a single implicit zone equal
|
|
924
|
+
* to its collection path, so single-collection search always works and
|
|
925
|
+
* shared `site`-style zones are opt-in.
|
|
901
926
|
*/
|
|
902
927
|
search?: {
|
|
903
|
-
|
|
928
|
+
body?: SearchFieldDecl[];
|
|
929
|
+
facets?: SearchFieldDecl[];
|
|
930
|
+
filters?: string[];
|
|
931
|
+
zones?: string[];
|
|
904
932
|
};
|
|
905
933
|
/**
|
|
906
934
|
* The field that represents this document's identity — used anywhere a
|
|
@@ -405,6 +405,27 @@ export interface RelationField extends NonlocalizableField {
|
|
|
405
405
|
* summary. Falls back to the first text field if omitted.
|
|
406
406
|
*/
|
|
407
407
|
displayField?: string;
|
|
408
|
+
/**
|
|
409
|
+
* When `true`, the field holds an **ordered list** of target references
|
|
410
|
+
* rather than a single one. The value shape becomes an array of relation
|
|
411
|
+
* envelopes (`RelatedDocumentValue[]`), persisted as indexed `store_relation`
|
|
412
|
+
* rows (`<field>.0`, `<field>.1`, …) and reconstructed in order. The admin
|
|
413
|
+
* widget renders an add / remove / drag-reorder list of summary tiles.
|
|
414
|
+
*
|
|
415
|
+
* A given target may appear at most once (the widget dedups on
|
|
416
|
+
* `targetDocumentId`). Defaults to single-target (`false` / absent).
|
|
417
|
+
*/
|
|
418
|
+
hasMany?: boolean;
|
|
419
|
+
/**
|
|
420
|
+
* Minimum number of references required when `hasMany` is `true`. Enforced by
|
|
421
|
+
* the generated Zod schema. Ignored for single-target relations.
|
|
422
|
+
*/
|
|
423
|
+
minItems?: number;
|
|
424
|
+
/**
|
|
425
|
+
* Maximum number of references allowed when `hasMany` is `true`. Enforced by
|
|
426
|
+
* the generated Zod schema. Ignored for single-target relations.
|
|
427
|
+
*/
|
|
428
|
+
maxItems?: number;
|
|
408
429
|
}
|
|
409
430
|
export interface FileField extends NonlocalizableField {
|
|
410
431
|
type: 'file';
|
|
@@ -687,4 +708,26 @@ export interface RichTextToMarkdownContext {
|
|
|
687
708
|
* by contract — output is never re-imported.
|
|
688
709
|
*/
|
|
689
710
|
export type RichTextToMarkdownFn = (ctx: RichTextToMarkdownContext) => string;
|
|
711
|
+
/**
|
|
712
|
+
* Context passed to the richtext plain-text extractor for one rich-text
|
|
713
|
+
* field value. Read-only and synchronous — walks the stored editor JSON
|
|
714
|
+
* directly (no editor instantiation, no DB reads).
|
|
715
|
+
*/
|
|
716
|
+
export interface RichTextToTextContext {
|
|
717
|
+
/** The richText field's value (raw editor JSON, possibly stringified). */
|
|
718
|
+
value: unknown;
|
|
719
|
+
/** Field path within the document — e.g. `'body'` or `'content.0.caption'`. */
|
|
720
|
+
fieldPath: string;
|
|
721
|
+
/** Collection path the document belongs to. */
|
|
722
|
+
collectionPath: string;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Server-side plain-text extractor contract — flattens a rich-text value to
|
|
726
|
+
* indexable plain text (a recursive text-node accumulator, no markdown
|
|
727
|
+
* syntax). Editor adapters export an implementation (e.g. `lexicalToText`
|
|
728
|
+
* from `@byline/richtext-lexical/server`); installations register one via
|
|
729
|
+
* `ServerConfig.fields.richText.toText`. Consumed by search indexing
|
|
730
|
+
* (`buildSearchDocument`) to feed `body`. One-way and lossy by contract.
|
|
731
|
+
*/
|
|
732
|
+
export type RichTextToTextFn = (ctx: RichTextToTextContext) => string;
|
|
690
733
|
export {};
|
package/dist/@types/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './field-data-types.js';
|
|
|
12
12
|
export * from './field-types.js';
|
|
13
13
|
export * from './populate-types.js';
|
|
14
14
|
export * from './query-predicate.js';
|
|
15
|
+
export * from './search-types.js';
|
|
15
16
|
export * from './site-config.js';
|
|
16
17
|
export * from './storage-types.js';
|
|
17
18
|
export * from './store-types.js';
|
package/dist/@types/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from './field-data-types.js';
|
|
|
12
12
|
export * from './field-types.js';
|
|
13
13
|
export * from './populate-types.js';
|
|
14
14
|
export * from './query-predicate.js';
|
|
15
|
+
export * from './search-types.js';
|
|
15
16
|
export * from './site-config.js';
|
|
16
17
|
export * from './storage-types.js';
|
|
17
18
|
export * from './store-types.js';
|
|
@@ -0,0 +1,275 @@
|
|
|
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
|
+
* The `SearchProvider` seam — a provider-agnostic search interface,
|
|
10
|
+
* registered on `ServerConfig.search` and composed by `initBylineCore()`,
|
|
11
|
+
* mirroring how `IDbAdapter`, `IStorageProvider`, and the
|
|
12
|
+
* `fields.richText.*` adapters plug into core.
|
|
13
|
+
*
|
|
14
|
+
* Core normalises each document into a flat, provider-agnostic,
|
|
15
|
+
* **type-enriched** {@link SearchDocument} and hands that to the provider —
|
|
16
|
+
* the provider never sees EAV store rows. The document carries a typed,
|
|
17
|
+
* role-tagged field projection ({@link SearchField}) so a driver can map
|
|
18
|
+
* each field onto its own index schema (Postgres store columns + weighted
|
|
19
|
+
* tsvector, Solr dynamic fields, a vector store's payload, …) without
|
|
20
|
+
* re-deriving types. A built-in Postgres full-text driver ships as
|
|
21
|
+
* `@byline/search-postgres`; external drivers implement the same interface
|
|
22
|
+
* rather than forking the read path.
|
|
23
|
+
*
|
|
24
|
+
* The provider factory (e.g. `postgresSearch({ getClient })`) lives in the
|
|
25
|
+
* driver package, not here — core declares only the interface and its data
|
|
26
|
+
* types, the same way `RichTextPopulateFn` is declared in core while
|
|
27
|
+
* `lexicalEditorPopulateServer()` lives in `@byline/richtext-lexical`. This
|
|
28
|
+
* keeps `@byline/core` from depending on `@byline/client`.
|
|
29
|
+
*
|
|
30
|
+
* See `docs/05-reading-and-delivery/07-search.md` for the full design.
|
|
31
|
+
*/
|
|
32
|
+
import type { QueryPredicate } from './query-predicate.js';
|
|
33
|
+
/**
|
|
34
|
+
* One field declaration in a collection's role-based `search` config — a
|
|
35
|
+
* field path, or `{ field, boost }` to weight it for scoring providers that
|
|
36
|
+
* support `capabilities.weighting`. The bare-string shorthand uses the
|
|
37
|
+
* provider's default weight.
|
|
38
|
+
*/
|
|
39
|
+
export type SearchFieldDecl = string | {
|
|
40
|
+
field: string;
|
|
41
|
+
boost?: number;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The index value type of a projected field — schema-derived by core, so a
|
|
45
|
+
* driver can map the field onto its own schema without re-inspecting the
|
|
46
|
+
* collection definition (Solr dynamic-field suffixes like `_txt` / `_i` /
|
|
47
|
+
* `_ss`, Postgres store columns, etc.).
|
|
48
|
+
*/
|
|
49
|
+
export type SearchFieldType =
|
|
50
|
+
/** Free-text, full-text-tokenised (text / textArea / select label / extracted rich-text). */
|
|
51
|
+
'text'
|
|
52
|
+
/** Exact-match string, not tokenised (slug, enum value). */
|
|
53
|
+
| 'keyword' | 'integer' | 'float' | 'boolean' | 'datetime'
|
|
54
|
+
/** Controlled-vocabulary reference — value is {@link SearchFacetValue}[]. */
|
|
55
|
+
| 'facet';
|
|
56
|
+
/**
|
|
57
|
+
* What a projected field is *for*, declared by the collection's role-based
|
|
58
|
+
* `search` config:
|
|
59
|
+
* - `body` — feeds the full-text searchable content
|
|
60
|
+
* - `facet` — a controlled-vocabulary reference (term indexed, id aggregated)
|
|
61
|
+
* - `filter` — a scalar projected for filtering / sorting (not scored)
|
|
62
|
+
*/
|
|
63
|
+
export type SearchFieldRole = 'body' | 'facet' | 'filter';
|
|
64
|
+
/**
|
|
65
|
+
* One controlled-vocabulary facet value: the aggregation key (the target's
|
|
66
|
+
* `counter` field value — the stable small-int id used by facet URLs and
|
|
67
|
+
* the aggregator) plus the localized term (the target's `useAsTitle`),
|
|
68
|
+
* which is folded into the searchable text so a free-text query matches it.
|
|
69
|
+
*/
|
|
70
|
+
export interface SearchFacetValue {
|
|
71
|
+
id: number | string;
|
|
72
|
+
term: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A single field in the {@link SearchDocument} projection — a value plus the
|
|
76
|
+
* type and role a driver needs to index it. `name` is the field path and
|
|
77
|
+
* the default index field name.
|
|
78
|
+
*/
|
|
79
|
+
export interface SearchField {
|
|
80
|
+
/** Field path within the document, e.g. `'title'`, `'abstract'`, `'topics'`. */
|
|
81
|
+
name: string;
|
|
82
|
+
/** Schema-derived index value type. */
|
|
83
|
+
type: SearchFieldType;
|
|
84
|
+
/** Config-declared role. */
|
|
85
|
+
role: SearchFieldRole;
|
|
86
|
+
/** Extracted, locale-resolved value. */
|
|
87
|
+
value: string | number | boolean | SearchFacetValue[] | null;
|
|
88
|
+
/**
|
|
89
|
+
* Optional relevance weight for scoring providers that support it
|
|
90
|
+
* (`capabilities.weighting`). Postgres maps it to a `setweight` class,
|
|
91
|
+
* Solr to a `qf` field boost; providers without weighting ignore it.
|
|
92
|
+
* Unset means the provider default.
|
|
93
|
+
*/
|
|
94
|
+
boost?: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The flat, provider-agnostic, type-enriched representation of a document
|
|
98
|
+
* that core feeds to a `SearchProvider`. Assembled from the EAV store by
|
|
99
|
+
* core; the provider indexes it without any knowledge of store tables or
|
|
100
|
+
* path notation.
|
|
101
|
+
*
|
|
102
|
+
* One `SearchDocument` exists per `(collectionPath, documentId, locale)` —
|
|
103
|
+
* `upsert` is idempotent on that triple.
|
|
104
|
+
*/
|
|
105
|
+
export interface SearchDocument {
|
|
106
|
+
/** Collection path, e.g. `"docs"` or `"publications"`. */
|
|
107
|
+
collectionPath: string;
|
|
108
|
+
/** The document's stable id (shared across versions and locales). */
|
|
109
|
+
documentId: string;
|
|
110
|
+
/** Content locale this index entry represents. */
|
|
111
|
+
locale: string;
|
|
112
|
+
/**
|
|
113
|
+
* Lifecycle status of the indexed version (e.g. `"published"`). Carried
|
|
114
|
+
* so the provider can apply published-only filtering at query time.
|
|
115
|
+
*/
|
|
116
|
+
status: string;
|
|
117
|
+
/**
|
|
118
|
+
* Resolved search-scope membership for this document, derived from its
|
|
119
|
+
* collection's `search.zones` config at index time. A document with no
|
|
120
|
+
* declared zones gets a single implicit zone equal to its collection
|
|
121
|
+
* path, so single-collection search always works.
|
|
122
|
+
*/
|
|
123
|
+
zones: string[];
|
|
124
|
+
/**
|
|
125
|
+
* The collection's identity value — resolved via `useAsTitle`
|
|
126
|
+
* (`resolveIdentityField`), never assumed to be a literal `title` field.
|
|
127
|
+
* Always present for hit display, independent of the `body` projection.
|
|
128
|
+
*/
|
|
129
|
+
title: string;
|
|
130
|
+
/** The document's URL path, or `null` when the collection has none. */
|
|
131
|
+
path: string | null;
|
|
132
|
+
/**
|
|
133
|
+
* The typed, role-tagged field projection the driver consumes. Built only
|
|
134
|
+
* from the fields the collection's `search` config opts into — nothing is
|
|
135
|
+
* auto-pulled, so unindexed content (editorial notes, etc.) never leaks.
|
|
136
|
+
*/
|
|
137
|
+
fields: SearchField[];
|
|
138
|
+
/** ISO-8601 timestamp of the indexed version. */
|
|
139
|
+
updatedAt: string;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* A search request. Either collection-scoped (`collectionPath`) for
|
|
143
|
+
* homogeneous results, or zone-scoped (`zone`) for heterogeneous
|
|
144
|
+
* cross-collection results — see the two `client.search()` entry points.
|
|
145
|
+
*/
|
|
146
|
+
export interface SearchQuery {
|
|
147
|
+
/** The free-text query string. */
|
|
148
|
+
query: string;
|
|
149
|
+
/**
|
|
150
|
+
* Cross-collection scope — every collection indexed into this zone.
|
|
151
|
+
* Mutually exclusive with `collectionPath` in practice.
|
|
152
|
+
*/
|
|
153
|
+
zone?: string;
|
|
154
|
+
/** Single-collection scope. */
|
|
155
|
+
collectionPath?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Structured filters AND-merged with the text query. Reuses the same
|
|
158
|
+
* predicate shape as the client `where` clause / `beforeRead` scoping.
|
|
159
|
+
*/
|
|
160
|
+
where?: QueryPredicate;
|
|
161
|
+
/** Field names to compute facet buckets for (driver-capability gated). */
|
|
162
|
+
facets?: string[];
|
|
163
|
+
/** Restrict to a single content locale. */
|
|
164
|
+
locale?: string;
|
|
165
|
+
/**
|
|
166
|
+
* Status filter. Defaults to `'published'` (safe for public readers);
|
|
167
|
+
* `'any'` is for admin contexts.
|
|
168
|
+
*/
|
|
169
|
+
status?: 'published' | 'any';
|
|
170
|
+
/** Max hits to return. */
|
|
171
|
+
limit?: number;
|
|
172
|
+
/** Offset for pagination. */
|
|
173
|
+
offset?: number;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A single ranked result. Always carries the lightweight projection needed
|
|
177
|
+
* to render a plain-text row without hydration; the consumer hydrates ids
|
|
178
|
+
* into shaped `ClientDocument`s separately (the two-tier results model).
|
|
179
|
+
*/
|
|
180
|
+
export interface SearchHit {
|
|
181
|
+
collectionPath: string;
|
|
182
|
+
documentId: string;
|
|
183
|
+
locale: string;
|
|
184
|
+
/** The collection's identity value (see {@link SearchDocument.title}). */
|
|
185
|
+
title: string;
|
|
186
|
+
path: string | null;
|
|
187
|
+
/** Provider-assigned relevance score; higher is more relevant. */
|
|
188
|
+
score: number;
|
|
189
|
+
/**
|
|
190
|
+
* Matched snippets per field, when the driver supports highlighting
|
|
191
|
+
* (`capabilities.highlights`). Enough to render a result row inline.
|
|
192
|
+
*/
|
|
193
|
+
highlights?: Record<string, string[]>;
|
|
194
|
+
}
|
|
195
|
+
/** A single facet bucket — a distinct value and its match count. */
|
|
196
|
+
export interface SearchFacetBucket {
|
|
197
|
+
value: string;
|
|
198
|
+
count: number;
|
|
199
|
+
}
|
|
200
|
+
/** The result envelope returned by `SearchProvider.search`. */
|
|
201
|
+
export interface SearchResults {
|
|
202
|
+
hits: SearchHit[];
|
|
203
|
+
/** Total matches across all pages (not just the returned `hits`). */
|
|
204
|
+
total: number;
|
|
205
|
+
/**
|
|
206
|
+
* Facet buckets keyed by field name, when `facets` was requested and the
|
|
207
|
+
* driver supports them.
|
|
208
|
+
*/
|
|
209
|
+
facets?: Record<string, SearchFacetBucket[]>;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* What a given driver can actually do. Lets consumers (the admin UI, the
|
|
213
|
+
* MCP tool) light up facets / typo-tolerance / semantic / weighting features
|
|
214
|
+
* only where the registered driver supports them, and keeps optional
|
|
215
|
+
* capabilities (e.g. BM25, which depends on a Postgres extension that isn't
|
|
216
|
+
* universally available) honest about a deployment's real reach rather than
|
|
217
|
+
* assumed.
|
|
218
|
+
*/
|
|
219
|
+
export interface SearchCapabilities {
|
|
220
|
+
/** Faceted aggregation over indexed fields. */
|
|
221
|
+
facets: boolean;
|
|
222
|
+
/** Fuzzy / typo-tolerant matching (e.g. `pg_trgm`). */
|
|
223
|
+
typoTolerance: boolean;
|
|
224
|
+
/** Vector / semantic / hybrid retrieval. */
|
|
225
|
+
semantic: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* BM25-quality ranking (IDF-aware). The built-in Postgres `tsvector`
|
|
228
|
+
* floor is `false` here; satisfied by `lakebase_text`, ParadeDB, or
|
|
229
|
+
* `pg_textsearch` where available.
|
|
230
|
+
*/
|
|
231
|
+
bm25: boolean;
|
|
232
|
+
/** Per-field relevance weighting (`SearchField.boost`). */
|
|
233
|
+
weighting: boolean;
|
|
234
|
+
/** Matched-snippet highlighting in results. */
|
|
235
|
+
highlights: boolean;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The provider-agnostic search seam. A driver implements indexing
|
|
239
|
+
* (`upsert` / `remove`), querying (`search`), and optional bulk rebuild
|
|
240
|
+
* (`reindex`), and declares its {@link SearchCapabilities} statically.
|
|
241
|
+
*
|
|
242
|
+
* Registered as `ServerConfig.search`; `initBylineCore()` fails fast when a
|
|
243
|
+
* collection opts into search but no provider is registered.
|
|
244
|
+
*/
|
|
245
|
+
export interface SearchProvider {
|
|
246
|
+
/**
|
|
247
|
+
* What this driver supports. Static — a driver knows its own shape at
|
|
248
|
+
* construction. Read by consumers to gate UI/feature exposure.
|
|
249
|
+
*/
|
|
250
|
+
readonly capabilities: SearchCapabilities;
|
|
251
|
+
/**
|
|
252
|
+
* Add or replace a document in the index. Idempotent on
|
|
253
|
+
* `(collectionPath, documentId, locale)`.
|
|
254
|
+
*/
|
|
255
|
+
upsert(doc: SearchDocument): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* Remove a document from the index — all locales when `locale` is
|
|
258
|
+
* omitted, or a single `(collectionPath, documentId, locale)` entry.
|
|
259
|
+
*/
|
|
260
|
+
remove(ref: {
|
|
261
|
+
collectionPath: string;
|
|
262
|
+
documentId: string;
|
|
263
|
+
locale?: string;
|
|
264
|
+
}): Promise<void>;
|
|
265
|
+
/** Execute a query and return ranked hits. */
|
|
266
|
+
search(query: SearchQuery): Promise<SearchResults>;
|
|
267
|
+
/**
|
|
268
|
+
* Drop and rebuild a collection's slice (or the whole index). Used for
|
|
269
|
+
* first-time backfill, driver swaps, and after a `search` config change.
|
|
270
|
+
* Optional — not every driver supports bulk rebuild.
|
|
271
|
+
*/
|
|
272
|
+
reindex?(opts: {
|
|
273
|
+
collectionPath?: string;
|
|
274
|
+
}): Promise<void>;
|
|
275
|
+
}
|
|
@@ -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 {};
|
|
@@ -10,7 +10,8 @@ import type { SlugifierFn } from '../utils/slugify.js';
|
|
|
10
10
|
import type { CollectionAdminConfig } from './admin-types.js';
|
|
11
11
|
import type { CollectionDefinition } from './collection-types.js';
|
|
12
12
|
import type { IDbAdapter } from './db-types.js';
|
|
13
|
-
import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn } from './field-types.js';
|
|
13
|
+
import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn, RichTextToTextFn } from './field-types.js';
|
|
14
|
+
import type { SearchProvider } from './search-types.js';
|
|
14
15
|
import type { IStorageProvider } from './storage-types.js';
|
|
15
16
|
export type DbAdapterFn = (args: {
|
|
16
17
|
connectionString: string;
|
|
@@ -314,6 +315,35 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
|
|
|
314
315
|
* it. Synchronous and read-only — it walks stored editor JSON.
|
|
315
316
|
*/
|
|
316
317
|
toMarkdown?: RichTextToMarkdownFn;
|
|
318
|
+
/**
|
|
319
|
+
* `toText` — plain-text extractor for search indexing. Flattens a
|
|
320
|
+
* rich-text value to indexable plain text (a recursive text-node
|
|
321
|
+
* accumulator, no markdown). Consumed by `buildSearchDocument` to feed
|
|
322
|
+
* a collection's searchable `body`. Required when any collection's
|
|
323
|
+
* `search.body` includes a `richText` field.
|
|
324
|
+
*/
|
|
325
|
+
toText?: RichTextToTextFn;
|
|
317
326
|
};
|
|
318
327
|
};
|
|
328
|
+
/**
|
|
329
|
+
* Search provider — the `SearchProvider` seam. Registered top-level
|
|
330
|
+
* beside `db` and `storage`, composed by `initBylineCore()`.
|
|
331
|
+
*
|
|
332
|
+
* Drivers ship as separate packages and are constructed via a factory,
|
|
333
|
+
* mirroring the richText server adapters. The built-in Postgres
|
|
334
|
+
* full-text driver is `@byline/search-postgres`. When any collection
|
|
335
|
+
* opts into search (`CollectionDefinition.search`) but no provider is
|
|
336
|
+
* registered, `initBylineCore()` fails fast.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* import { postgresSearch } from '@byline/search-postgres'
|
|
341
|
+
*
|
|
342
|
+
* defineServerConfig({
|
|
343
|
+
* // ...
|
|
344
|
+
* search: postgresSearch({ getClient: getAdminBylineClient }),
|
|
345
|
+
* })
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
search?: SearchProvider;
|
|
319
349
|
}
|
|
@@ -104,7 +104,15 @@ describe('assertActorCanPerform', () => {
|
|
|
104
104
|
describe('super-admin', () => {
|
|
105
105
|
it('passes every verb on every collection without explicit abilities', () => {
|
|
106
106
|
const ctx = createSuperAdminContext();
|
|
107
|
-
const verbs = [
|
|
107
|
+
const verbs = [
|
|
108
|
+
'read',
|
|
109
|
+
'create',
|
|
110
|
+
'update',
|
|
111
|
+
'delete',
|
|
112
|
+
'publish',
|
|
113
|
+
'changeStatus',
|
|
114
|
+
'reindex',
|
|
115
|
+
];
|
|
108
116
|
for (const verb of verbs) {
|
|
109
117
|
expect(() => assertActorCanPerform(ctx, 'any-collection', verb)).not.toThrow();
|
|
110
118
|
}
|
|
@@ -10,7 +10,7 @@ import type { CollectionDefinition } from '../@types/index.js';
|
|
|
10
10
|
/**
|
|
11
11
|
* Auto-register the CRUD + workflow abilities contributed by a collection.
|
|
12
12
|
*
|
|
13
|
-
* Every registered collection contributes exactly
|
|
13
|
+
* Every registered collection contributes exactly seven abilities, all in
|
|
14
14
|
* the `collections.<path>` group:
|
|
15
15
|
*
|
|
16
16
|
* - `collections.<path>.read` — enumerate / fetch documents
|
|
@@ -21,19 +21,22 @@ import type { CollectionDefinition } from '../@types/index.js';
|
|
|
21
21
|
* `published` status
|
|
22
22
|
* - `collections.<path>.changeStatus` — any other workflow transition
|
|
23
23
|
* (draft → custom state, etc.)
|
|
24
|
+
* - `collections.<path>.reindex` — rebuild the collection's search
|
|
25
|
+
* index (admin maintenance task)
|
|
24
26
|
*
|
|
25
27
|
* Registration is unconditional: every collection in Byline has a workflow
|
|
26
28
|
* (the default `draft → published → archived` one when not explicitly
|
|
27
|
-
* configured), so `publish` and `changeStatus` always apply
|
|
28
|
-
*
|
|
29
|
-
*
|
|
29
|
+
* configured), so `publish` and `changeStatus` always apply; `reindex` is
|
|
30
|
+
* likewise uniform (a no-op for collections without a `search` config).
|
|
31
|
+
* Keeping the seven-ability contract uniform makes the role editor UI
|
|
32
|
+
* predictable and avoids hidden conditional logic downstream.
|
|
30
33
|
*
|
|
31
34
|
* Called from `initBylineCore()` for each declared collection. See
|
|
32
35
|
* docs/AUTHN-AUTHZ.md.
|
|
33
36
|
*/
|
|
34
37
|
export declare function registerCollectionAbilities(registry: AbilityRegistry, definition: CollectionDefinition): void;
|
|
35
38
|
/** The ability suffixes that every collection contributes. Exposed for contract tests. */
|
|
36
|
-
export declare const COLLECTION_ABILITY_VERBS: readonly ["read", "create", "update", "delete", "publish", "changeStatus"];
|
|
39
|
+
export declare const COLLECTION_ABILITY_VERBS: readonly ["read", "create", "update", "delete", "publish", "changeStatus", "reindex"];
|
|
37
40
|
export type CollectionAbilityVerb = (typeof COLLECTION_ABILITY_VERBS)[number];
|
|
38
41
|
/** Compute the full ability key for a collection path and verb. */
|
|
39
42
|
export declare function collectionAbilityKey(path: string, verb: CollectionAbilityVerb): string;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* Auto-register the CRUD + workflow abilities contributed by a collection.
|
|
10
10
|
*
|
|
11
|
-
* Every registered collection contributes exactly
|
|
11
|
+
* Every registered collection contributes exactly seven abilities, all in
|
|
12
12
|
* the `collections.<path>` group:
|
|
13
13
|
*
|
|
14
14
|
* - `collections.<path>.read` — enumerate / fetch documents
|
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
* `published` status
|
|
20
20
|
* - `collections.<path>.changeStatus` — any other workflow transition
|
|
21
21
|
* (draft → custom state, etc.)
|
|
22
|
+
* - `collections.<path>.reindex` — rebuild the collection's search
|
|
23
|
+
* index (admin maintenance task)
|
|
22
24
|
*
|
|
23
25
|
* Registration is unconditional: every collection in Byline has a workflow
|
|
24
26
|
* (the default `draft → published → archived` one when not explicitly
|
|
25
|
-
* configured), so `publish` and `changeStatus` always apply
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* configured), so `publish` and `changeStatus` always apply; `reindex` is
|
|
28
|
+
* likewise uniform (a no-op for collections without a `search` config).
|
|
29
|
+
* Keeping the seven-ability contract uniform makes the role editor UI
|
|
30
|
+
* predictable and avoids hidden conditional logic downstream.
|
|
28
31
|
*
|
|
29
32
|
* Called from `initBylineCore()` for each declared collection. See
|
|
30
33
|
* docs/AUTHN-AUTHZ.md.
|
|
@@ -70,6 +73,12 @@ export function registerCollectionAbilities(registry, definition) {
|
|
|
70
73
|
group,
|
|
71
74
|
source: 'collection',
|
|
72
75
|
});
|
|
76
|
+
registry.register({
|
|
77
|
+
key: `${base}.reindex`,
|
|
78
|
+
label: `Reindex ${plural} search`,
|
|
79
|
+
group,
|
|
80
|
+
source: 'collection',
|
|
81
|
+
});
|
|
73
82
|
}
|
|
74
83
|
/** The ability suffixes that every collection contributes. Exposed for contract tests. */
|
|
75
84
|
export const COLLECTION_ABILITY_VERBS = [
|
|
@@ -79,6 +88,7 @@ export const COLLECTION_ABILITY_VERBS = [
|
|
|
79
88
|
'delete',
|
|
80
89
|
'publish',
|
|
81
90
|
'changeStatus',
|
|
91
|
+
'reindex',
|
|
82
92
|
];
|
|
83
93
|
/** Compute the full ability key for a collection path and verb. */
|
|
84
94
|
export function collectionAbilityKey(path, verb) {
|