@byline/client 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/client.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { type RequestContext } from '@byline/auth';
9
- import type { BylineLogger, CollectionDefinition, IDbAdapter, IStorageProvider, RichTextPopulateFn, SlugifierFn } from '@byline/core';
9
+ import type { BylineLogger, CollectionDefinition, IDbAdapter, IStorageProvider, RichTextPopulateFn, RichTextToTextFn, SearchProvider, SlugifierFn } from '@byline/core';
10
10
  import { CollectionHandle } from './collection-handle.js';
11
11
  import type { BylineClientConfig } from './types.js';
12
12
  /**
@@ -22,6 +22,9 @@ export declare class BylineClient {
22
22
  readonly defaultLocale: string;
23
23
  readonly slugifier: SlugifierFn | undefined;
24
24
  readonly richTextPopulate: RichTextPopulateFn | undefined;
25
+ readonly search: SearchProvider | undefined;
26
+ readonly richTextToText: RichTextToTextFn | undefined;
27
+ readonly contentLocales: string[];
25
28
  /** Cache: collection path → database row id + schema version. */
26
29
  private collectionRecordCache;
27
30
  /** The raw `requestContext` config value, stored for per-call resolution. */
package/dist/client.js CHANGED
@@ -56,6 +56,9 @@ export class BylineClient {
56
56
  defaultLocale;
57
57
  slugifier;
58
58
  richTextPopulate;
59
+ search;
60
+ richTextToText;
61
+ contentLocales;
59
62
  /** Cache: collection path → database row id + schema version. */
60
63
  collectionRecordCache = new Map();
61
64
  /** The raw `requestContext` config value, stored for per-call resolution. */
@@ -82,6 +85,10 @@ export class BylineClient {
82
85
  this.slugifier = config.slugifier ?? fromConfig?.slugifier;
83
86
  this.richTextPopulate =
84
87
  config.richTextPopulate ?? fromConfig?.fields?.richText?.populate ?? undefined;
88
+ this.search = config.search ?? fromConfig?.search ?? undefined;
89
+ this.richTextToText = config.richTextToText ?? fromConfig?.fields?.richText?.toText ?? undefined;
90
+ this.contentLocales = config.contentLocales ??
91
+ fromConfig?.i18n?.content?.locales ?? [this.defaultLocale];
85
92
  this.requestContextSource = config.requestContext;
86
93
  }
87
94
  /**
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { AuditLogPage, ChangeStatusResult, CollectionDefinition, CreateDocumentResult, DeleteDocumentResult, RestoreVersionResult, UnpublishResult, UpdateDocumentResult } from '@byline/core';
8
+ import type { AuditLogPage, ChangeStatusResult, CollectionDefinition, CreateDocumentResult, DeleteDocumentResult, RestoreVersionResult, SearchResults, UnpublishResult, UpdateDocumentResult } from '@byline/core';
9
9
  import type { BylineClient } from './client.js';
10
- import type { AuditLogOptions, ClientDocument, CreateOptions, FindByIdOptions, FindByPathOptions, FindByVersionOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, HistoryOptions, PlaceTreeNodeOptions, TreeNode, UpdateOptions } from './types.js';
10
+ import type { AuditLogOptions, ClientDocument, CollectionSearchOptions, CreateOptions, FindByIdOptions, FindByPathOptions, FindByVersionOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, HistoryOptions, PlaceTreeNodeOptions, ReindexResult, TreeNode, UpdateOptions } from './types.js';
11
11
  /**
12
12
  * A handle scoped to a single collection. Provides read (and eventually write)
13
13
  * operations against that collection's documents.
@@ -33,6 +33,48 @@ export declare class CollectionHandle {
33
33
  * document matches.
34
34
  */
35
35
  findOne<F = Record<string, any>>(options?: FindOneOptions<F>): Promise<ClientDocument<F> | null>;
36
+ /**
37
+ * Ranked full-text search scoped to this collection, delegated to the
38
+ * registered `SearchProvider` (see `ServerConfig.search`). Returns the
39
+ * lightweight hit tier — `title`, `path`, `score`, and matched-snippet
40
+ * `highlights` — enough to render a results list without hydration; fetch
41
+ * the hit ids via `findById` when a richer item is needed.
42
+ *
43
+ * Asserts the collection `read` ability first (same gate as the other
44
+ * reads), and defaults `status` to `'published'`, so a public viewer only
45
+ * sees published content — which is also all the index holds, since
46
+ * indexing is published-only.
47
+ *
48
+ * Throws `ERR_VALIDATION` when no provider is registered.
49
+ */
50
+ search(options: CollectionSearchOptions): Promise<SearchResults>;
51
+ /**
52
+ * Re-sync one document into the search index across every content locale.
53
+ * Reads the document's *published* view per locale (`onMissingLocale: 'omit'`)
54
+ * and `upsert`s where present, `remove`s where absent — so publish, unpublish,
55
+ * draft-over-published, and plain edits all converge on the same idempotent
56
+ * path. The index always mirrors what a public reader can see.
57
+ */
58
+ indexDocument(documentId: string): Promise<void>;
59
+ /** Remove one document from the search index entirely (all locales). */
60
+ removeFromIndex(documentId: string): Promise<void>;
61
+ /**
62
+ * Rebuild this collection's entire search index from its published
63
+ * documents. Clears the existing slice (dropping orphans for deleted docs),
64
+ * then walks every published document (paginated) and re-indexes it. Used for
65
+ * first-time backfill, after a `search` config change, or a driver swap.
66
+ *
67
+ * Asserts the collection `reindex` ability. No-op (returns zero counts) when
68
+ * the collection doesn't opt into search or no provider is registered.
69
+ */
70
+ reindex(): Promise<ReindexResult>;
71
+ /**
72
+ * Build the populate map for this collection's facet relation fields so each
73
+ * target arrives with the two fields the assembler needs: its `counter` field
74
+ * (the aggregation id) and its identity field (`useAsTitle`, the term).
75
+ * Returns `undefined` when the collection declares no facets.
76
+ */
77
+ private buildSearchFacetPopulateMap;
36
78
  /**
37
79
  * Find a document by its logical document ID.
38
80
  */
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { applyAfterRead, applyBeforeRead, assertActorCanPerform, changeDocumentStatus, createDocument, createReadContext, deleteDocument, ERR_VALIDATION, mergePredicates, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, restoreDocumentVersion, unpublishDocument, updateDocument, } from '@byline/core';
8
+ import { applyAfterRead, applyBeforeRead, assertActorCanPerform, buildSearchDocument, changeDocumentStatus, createDocument, createReadContext, deleteDocument, ERR_VALIDATION, mergePredicates, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, resolveIdentityField, restoreDocumentVersion, unpublishDocument, updateDocument, } from '@byline/core';
9
9
  import { shapeDocument, shapePopulatedInPlace } from './response.js';
10
10
  /**
11
11
  * A handle scoped to a single collection. Provides read (and eventually write)
@@ -103,6 +103,165 @@ export class CollectionHandle {
103
103
  });
104
104
  return result.docs[0] ?? null;
105
105
  }
106
+ /**
107
+ * Ranked full-text search scoped to this collection, delegated to the
108
+ * registered `SearchProvider` (see `ServerConfig.search`). Returns the
109
+ * lightweight hit tier — `title`, `path`, `score`, and matched-snippet
110
+ * `highlights` — enough to render a results list without hydration; fetch
111
+ * the hit ids via `findById` when a richer item is needed.
112
+ *
113
+ * Asserts the collection `read` ability first (same gate as the other
114
+ * reads), and defaults `status` to `'published'`, so a public viewer only
115
+ * sees published content — which is also all the index holds, since
116
+ * indexing is published-only.
117
+ *
118
+ * Throws `ERR_VALIDATION` when no provider is registered.
119
+ */
120
+ async search(options) {
121
+ await this.resolveAndAssertRead();
122
+ const provider = this.client.search;
123
+ if (provider == null) {
124
+ throw ERR_VALIDATION({
125
+ message: 'No search provider is registered. Register one on ServerConfig.search — ' +
126
+ 'see `@byline/search-postgres` → `postgresSearch()` for the built-in driver.',
127
+ });
128
+ }
129
+ return provider.search({
130
+ query: options.query,
131
+ collectionPath: this.definition.path,
132
+ locale: options.locale ?? this.client.defaultLocale,
133
+ status: resolveReadMode(options.status),
134
+ where: options.where,
135
+ facets: options.facets,
136
+ limit: options.limit,
137
+ offset: options.offset,
138
+ });
139
+ }
140
+ // -------------------------------------------------------------------------
141
+ // Search index maintenance
142
+ //
143
+ // `indexDocument` / `removeFromIndex` are the canonical per-document sync the
144
+ // collection lifecycle hooks call; `reindex` is the bulk rebuild (backfill /
145
+ // config change / driver swap). All are no-ops when the collection doesn't
146
+ // opt into search or no provider is registered. System operations — reads use
147
+ // `_bypassBeforeRead` so the index reflects every published document.
148
+ // -------------------------------------------------------------------------
149
+ /**
150
+ * Re-sync one document into the search index across every content locale.
151
+ * Reads the document's *published* view per locale (`onMissingLocale: 'omit'`)
152
+ * and `upsert`s where present, `remove`s where absent — so publish, unpublish,
153
+ * draft-over-published, and plain edits all converge on the same idempotent
154
+ * path. The index always mirrors what a public reader can see.
155
+ */
156
+ async indexDocument(documentId) {
157
+ const provider = this.client.search;
158
+ if (provider == null || this.definition.search == null)
159
+ return;
160
+ const populate = this.buildSearchFacetPopulateMap();
161
+ for (const locale of this.client.contentLocales) {
162
+ const view = await this.findById(documentId, {
163
+ locale,
164
+ status: 'published',
165
+ onMissingLocale: 'omit',
166
+ populate,
167
+ _bypassBeforeRead: true,
168
+ });
169
+ if (view == null) {
170
+ await provider.remove({ collectionPath: this.definition.path, documentId, locale });
171
+ continue;
172
+ }
173
+ await provider.upsert(buildSearchDocument({
174
+ documentId: view.id,
175
+ locale,
176
+ status: view.status,
177
+ path: view.path,
178
+ fields: view.fields,
179
+ updatedAt: view.updatedAt,
180
+ }, this.definition, {
181
+ locale,
182
+ richTextToText: this.client.richTextToText,
183
+ resolveTargetDefinition: (path) => this.client.collections.find((c) => c.path === path) ?? null,
184
+ }));
185
+ }
186
+ }
187
+ /** Remove one document from the search index entirely (all locales). */
188
+ async removeFromIndex(documentId) {
189
+ const provider = this.client.search;
190
+ if (provider == null)
191
+ return;
192
+ await provider.remove({ collectionPath: this.definition.path, documentId });
193
+ }
194
+ /**
195
+ * Rebuild this collection's entire search index from its published
196
+ * documents. Clears the existing slice (dropping orphans for deleted docs),
197
+ * then walks every published document (paginated) and re-indexes it. Used for
198
+ * first-time backfill, after a `search` config change, or a driver swap.
199
+ *
200
+ * Asserts the collection `reindex` ability. No-op (returns zero counts) when
201
+ * the collection doesn't opt into search or no provider is registered.
202
+ */
203
+ async reindex() {
204
+ const requestContext = await this.client.resolveRequestContext();
205
+ assertActorCanPerform(requestContext, this.definition.path, 'reindex');
206
+ const provider = this.client.search;
207
+ const result = { collectionPath: this.definition.path, documents: 0, indexed: 0 };
208
+ if (provider == null || this.definition.search == null)
209
+ return result;
210
+ // Clear the slice so deleted documents don't leave orphan rows.
211
+ await provider.reindex?.({ collectionPath: this.definition.path });
212
+ const pageSize = 100;
213
+ let page = 1;
214
+ for (;;) {
215
+ const batch = await this.find({
216
+ status: 'published',
217
+ page,
218
+ pageSize,
219
+ _bypassBeforeRead: true,
220
+ });
221
+ for (const doc of batch.docs) {
222
+ await this.indexDocument(doc.id);
223
+ result.documents++;
224
+ }
225
+ if (page >= batch.meta.totalPages || batch.docs.length === 0)
226
+ break;
227
+ page++;
228
+ }
229
+ // `indexed` mirrors documents × locales present; the provider upserts are
230
+ // the source of truth, so report the document count walked.
231
+ result.indexed = result.documents;
232
+ return result;
233
+ }
234
+ /**
235
+ * Build the populate map for this collection's facet relation fields so each
236
+ * target arrives with the two fields the assembler needs: its `counter` field
237
+ * (the aggregation id) and its identity field (`useAsTitle`, the term).
238
+ * Returns `undefined` when the collection declares no facets.
239
+ */
240
+ buildSearchFacetPopulateMap() {
241
+ const facets = this.definition.search?.facets;
242
+ if (facets == null || facets.length === 0)
243
+ return undefined;
244
+ const map = {};
245
+ for (const decl of facets) {
246
+ const name = typeof decl === 'string' ? decl : decl.field;
247
+ const field = this.definition.fields.find((f) => f.name === name);
248
+ if (field == null || field.type !== 'relation')
249
+ continue;
250
+ const targetPath = field.targetCollection;
251
+ const targetDef = targetPath
252
+ ? this.client.collections.find((c) => c.path === targetPath)
253
+ : undefined;
254
+ const select = [];
255
+ const idField = targetDef?.fields.find((f) => f.type === 'counter')?.name;
256
+ const termField = targetDef ? resolveIdentityField(targetDef) : undefined;
257
+ if (idField)
258
+ select.push(idField);
259
+ if (termField)
260
+ select.push(termField);
261
+ map[name] = select.length > 0 ? { select } : true;
262
+ }
263
+ return Object.keys(map).length > 0 ? map : undefined;
264
+ }
106
265
  /**
107
266
  * Find a document by its logical document ID.
108
267
  */
package/dist/index.d.ts CHANGED
@@ -8,4 +8,4 @@
8
8
  export { type AuditLogEntry, type AuditLogPage, type ChangeStatusResult, type CreateDocumentResult, type CycleRelationValue, createReadContext, type DeleteDocumentResult, ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateSpec, type ReadContext, type ReadMode, type RestoreVersionResult, type UnpublishResult, type UnresolvedRelationValue, type UpdateDocumentResult, } from '@byline/core';
9
9
  export { BylineClient, createBylineClient } from './client.js';
10
10
  export { CollectionHandle } from './collection-handle.js';
11
- export type { AuditLogOptions, BylineClientConfig, ClientDocument, CreateOptions, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, PlaceTreeNodeOptions, PopulatedRelation, SortDirection, SortSpec, TreeNode, UpdateOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, } from './types.js';
11
+ export type { AuditLogOptions, BylineClientConfig, ClientDocument, CollectionSearchOptions, CreateOptions, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, SearchFacetBucket, SearchHit, SearchResults, SortDirection, SortSpec, TreeNode, UpdateOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, } from './types.js';
package/dist/types.d.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { RequestContext } from '@byline/auth';
9
- import type { BylineLogger, CollectionDefinition, IDbAdapter, IStorageProvider, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, ServerConfig, SlugifierFn, SortSpec } from '@byline/core';
10
- export type { FilterOperators, PredicateValue, QueryPredicate, SortDirection, SortSpec, } from '@byline/core';
9
+ import type { BylineLogger, CollectionDefinition, IDbAdapter, IStorageProvider, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, RichTextToTextFn, SearchProvider, ServerConfig, SlugifierFn, SortSpec } from '@byline/core';
10
+ export type { FilterOperators, PredicateValue, QueryPredicate, SearchFacetBucket, SearchHit, SearchProvider, SearchResults, SortDirection, SortSpec, } from '@byline/core';
11
11
  export interface BylineClientConfig {
12
12
  /**
13
13
  * Convenience shorthand: pass an already-resolved `ServerConfig` (typically
@@ -34,6 +34,26 @@ export interface BylineClientConfig {
34
34
  collections?: CollectionDefinition[];
35
35
  /** Optional storage provider — needed for delete file cleanup. */
36
36
  storage?: IStorageProvider;
37
+ /**
38
+ * Optional search provider (`ServerConfig.search`). When present,
39
+ * `collection(path).search(...)` delegates ranked queries to it. When
40
+ * omitted (and not provided via the `config` shorthand's `search`), calling
41
+ * `search()` throws a clear error pointing at provider registration.
42
+ */
43
+ search?: SearchProvider;
44
+ /**
45
+ * Optional richtext plain-text extractor (`ServerConfig.fields.richText.toText`).
46
+ * Used by the search-indexing methods (`indexDocument` / `reindex`) to feed
47
+ * a collection's searchable `body` from rich-text fields. When omitted, those
48
+ * fields are skipped.
49
+ */
50
+ richTextToText?: RichTextToTextFn;
51
+ /**
52
+ * The installation's content locales (`ServerConfig.i18n.content.locales`).
53
+ * The search-indexing methods iterate them to index one row per
54
+ * `(document, locale)`. Falls back to `[defaultLocale]` when not provided.
55
+ */
56
+ contentLocales?: string[];
37
57
  /**
38
58
  * Optional logger. Used by the write path to emit structured events
39
59
  * from `document-lifecycle`. When omitted, the client falls back to
@@ -138,6 +158,38 @@ interface PopulateControls {
138
158
  interface StatusControls {
139
159
  status?: ReadMode;
140
160
  }
161
+ /**
162
+ * Options for `CollectionHandle.search(...)` — a ranked full-text query
163
+ * scoped to this collection, delegated to the registered `SearchProvider`.
164
+ * `collectionPath` is implied by the handle, so callers supply only the
165
+ * query and optional scoping. `status` defaults to `'published'` (public
166
+ * readers); pass `'any'` for admin contexts.
167
+ */
168
+ export interface CollectionSearchOptions {
169
+ /** Free-text query string. */
170
+ query: string;
171
+ /** Restrict to a single content locale (defaults to the client default). */
172
+ locale?: string;
173
+ /** Read mode — `'published'` (default) or `'any'`. */
174
+ status?: ReadMode;
175
+ /** Structured filters AND-merged with the text query (driver-dependent). */
176
+ where?: QueryPredicate;
177
+ /** Field names to compute facet buckets for (driver-capability gated). */
178
+ facets?: string[];
179
+ /** Max hits to return. */
180
+ limit?: number;
181
+ /** Offset for pagination. */
182
+ offset?: number;
183
+ }
184
+ /** Outcome of a collection `reindex()` — counts for reporting. */
185
+ export interface ReindexResult {
186
+ /** Collection that was reindexed. */
187
+ collectionPath: string;
188
+ /** Number of published documents walked. */
189
+ documents: number;
190
+ /** Number of `(document, locale)` index rows upserted. */
191
+ indexed: number;
192
+ }
141
193
  /**
142
194
  * What a read does when the requested content locale is missing. Shared by
143
195
  * every read method. `@byline/client` defaults this to `'fallback'`.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/client",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.14.0",
5
+ "version": "3.15.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -38,8 +38,8 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "npm-run-all": "^4.1.5",
41
- "@byline/auth": "3.14.0",
42
- "@byline/core": "3.14.0"
41
+ "@byline/auth": "3.15.0",
42
+ "@byline/core": "3.15.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@biomejs/biome": "2.4.15",
@@ -51,7 +51,7 @@
51
51
  "tsx": "^4.22.3",
52
52
  "typescript": "6.0.3",
53
53
  "vitest": "^4.1.7",
54
- "@byline/db-postgres": "3.14.0"
54
+ "@byline/db-postgres": "3.15.0"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public",