@byline/core 3.13.3 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/@types/admin-types.d.ts +45 -7
  2. package/dist/@types/collection-types.d.ts +32 -4
  3. package/dist/@types/field-types.d.ts +43 -0
  4. package/dist/@types/index.d.ts +1 -0
  5. package/dist/@types/index.js +1 -0
  6. package/dist/@types/search-types.d.ts +275 -0
  7. package/dist/@types/search-types.js +8 -0
  8. package/dist/@types/site-config.d.ts +31 -1
  9. package/dist/auth/assert-actor-can-perform.test.node.js +9 -1
  10. package/dist/auth/register-collection-abilities.d.ts +8 -5
  11. package/dist/auth/register-collection-abilities.js +14 -4
  12. package/dist/auth/register-collection-abilities.test.node.js +11 -7
  13. package/dist/config/config.d.ts +12 -1
  14. package/dist/config/config.js +11 -0
  15. package/dist/config/resolve-item-view-columns.test.node.d.ts +8 -0
  16. package/dist/config/resolve-item-view-columns.test.node.js +30 -0
  17. package/dist/core.js +7 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/schemas/zod/builder.js +16 -5
  21. package/dist/services/build-search-document.d.ts +46 -0
  22. package/dist/services/build-search-document.js +220 -0
  23. package/dist/services/build-search-document.test.node.d.ts +8 -0
  24. package/dist/services/build-search-document.test.node.js +164 -0
  25. package/dist/services/index.d.ts +3 -1
  26. package/dist/services/index.js +3 -1
  27. package/dist/services/populate.d.ts +6 -0
  28. package/dist/services/populate.js +19 -1
  29. package/dist/services/relation-projection.d.ts +4 -15
  30. package/dist/services/relation-projection.js +19 -6
  31. package/dist/services/validate-search-config.d.ts +28 -0
  32. package/dist/services/validate-search-config.js +32 -0
  33. package/dist/storage/collection-fingerprint.test.node.js +1 -1
  34. package/package.json +2 -2
@@ -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 when it appears as the target
186
- * of a relation picker (the modal opened from a `relation` field widget).
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
- * Shape matches `ColumnDefinition` so formatters like a thumbnail cell or
189
- * date formatter can be reused across list and picker. Omit to fall back
190
- * to a single-line render of `useAsTitle` + `path`.
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
- * Purely a UI concern does not affect populate's default projection,
193
- * which uses `CollectionDefinition.useAsTitle` server-side.
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
- * Configures which text fields are searched when the admin list view's
899
- * search box is used. Only `store_text` fields are supported for now.
900
- * Falls back to `{ fields: ['title'] }` when omitted.
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
- fields: string[];
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 {};
@@ -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';
@@ -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 = ['read', 'create', 'update', 'delete', 'publish', 'changeStatus'];
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 six abilities, all in
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. Keeping the
28
- * six-ability contract uniform makes the role editor UI predictable and
29
- * avoids hidden conditional logic downstream.
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 six abilities, all in
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. Keeping the
26
- * six-ability contract uniform makes the role editor UI predictable and
27
- * avoids hidden conditional logic downstream.
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) {