@byline/client 3.15.2 → 3.16.1

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
@@ -8,7 +8,7 @@
8
8
  import { type RequestContext } from '@byline/auth';
9
9
  import type { BylineLogger, CollectionDefinition, IDbAdapter, IStorageProvider, RichTextPopulateFn, RichTextToTextFn, SearchProvider, SlugifierFn } from '@byline/core';
10
10
  import { CollectionHandle } from './collection-handle.js';
11
- import type { BylineClientConfig } from './types.js';
11
+ import type { BylineClientConfig, ClientSearchResults, ZoneSearchOptions } from './types.js';
12
12
  /**
13
13
  * The main Byline client instance. Holds the database adapter, collection
14
14
  * definitions, and optional storage provider. Use `collection(path)` to get
@@ -22,7 +22,12 @@ 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;
25
+ /**
26
+ * The registered `SearchProvider` (`ServerConfig.search`), if any.
27
+ * Renamed from `search` when the cross-collection `search()` *method*
28
+ * landed — the method is the query surface, this is the driver.
29
+ */
30
+ readonly searchProvider: SearchProvider | undefined;
26
31
  readonly richTextToText: RichTextToTextFn | undefined;
27
32
  readonly contentLocales: string[];
28
33
  /** Cache: collection path → database row id + schema version. */
@@ -36,6 +41,15 @@ export declare class BylineClient {
36
41
  * configured — every read and write on the client requires one.
37
42
  */
38
43
  resolveRequestContext(): Promise<RequestContext>;
44
+ /**
45
+ * Cross-collection (zone) search — heterogeneous hits ranked together
46
+ * across every collection indexed into the named zone. Collections the
47
+ * actor cannot `read` are excluded; `beforeRead` row scoping applies per
48
+ * collection; `hydrate: true` attaches a shaped `ClientDocument` per hit.
49
+ * For homogeneous, single-collection search use
50
+ * `client.collection(path).search()`.
51
+ */
52
+ search(options: ZoneSearchOptions): Promise<ClientSearchResults>;
39
53
  /**
40
54
  * Get a handle scoped to a single collection. All subsequent read/write
41
55
  * operations on the handle are performed against this collection.
package/dist/client.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { ERR_UNAUTHENTICATED } from '@byline/auth';
9
9
  import { ERR_NOT_FOUND, getLogger } from '@byline/core';
10
10
  import { CollectionHandle } from './collection-handle.js';
11
+ import { zoneSearch } from './search.js';
11
12
  /**
12
13
  * Resolve a logger for the client in priority order:
13
14
  * 1. explicit `config.logger`
@@ -56,7 +57,12 @@ export class BylineClient {
56
57
  defaultLocale;
57
58
  slugifier;
58
59
  richTextPopulate;
59
- search;
60
+ /**
61
+ * The registered `SearchProvider` (`ServerConfig.search`), if any.
62
+ * Renamed from `search` when the cross-collection `search()` *method*
63
+ * landed — the method is the query surface, this is the driver.
64
+ */
65
+ searchProvider;
60
66
  richTextToText;
61
67
  contentLocales;
62
68
  /** Cache: collection path → database row id + schema version. */
@@ -85,7 +91,7 @@ export class BylineClient {
85
91
  this.slugifier = config.slugifier ?? fromConfig?.slugifier;
86
92
  this.richTextPopulate =
87
93
  config.richTextPopulate ?? fromConfig?.fields?.richText?.populate ?? undefined;
88
- this.search = config.search ?? fromConfig?.search ?? undefined;
94
+ this.searchProvider = config.search ?? fromConfig?.search ?? undefined;
89
95
  this.richTextToText = config.richTextToText ?? fromConfig?.fields?.richText?.toText ?? undefined;
90
96
  this.contentLocales = config.contentLocales ??
91
97
  fromConfig?.i18n?.content?.locales ?? [this.defaultLocale];
@@ -110,6 +116,17 @@ export class BylineClient {
110
116
  }
111
117
  return source;
112
118
  }
119
+ /**
120
+ * Cross-collection (zone) search — heterogeneous hits ranked together
121
+ * across every collection indexed into the named zone. Collections the
122
+ * actor cannot `read` are excluded; `beforeRead` row scoping applies per
123
+ * collection; `hydrate: true` attaches a shaped `ClientDocument` per hit.
124
+ * For homogeneous, single-collection search use
125
+ * `client.collection(path).search()`.
126
+ */
127
+ async search(options) {
128
+ return zoneSearch(this, options);
129
+ }
113
130
  /**
114
131
  * Get a handle scoped to a single collection. All subsequent read/write
115
132
  * operations on the handle are performed against this collection.
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { AuditLogPage, ChangeStatusResult, CollectionDefinition, CreateDocumentResult, DeleteDocumentResult, RestoreVersionResult, SearchResults, UnpublishResult, UpdateDocumentResult } from '@byline/core';
8
+ import type { AuditLogPage, ChangeStatusResult, CollectionDefinition, CreateDocumentResult, DeleteDocumentResult, RestoreVersionResult, UnpublishResult, UpdateDocumentResult } from '@byline/core';
9
9
  import type { BylineClient } from './client.js';
10
- import type { AuditLogOptions, ClientDocument, CollectionSearchOptions, CreateOptions, FindByIdOptions, FindByPathOptions, FindByVersionOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, HistoryOptions, PlaceTreeNodeOptions, ReindexResult, TreeNode, UpdateOptions } from './types.js';
10
+ import type { AuditLogOptions, ClientDocument, ClientSearchResults, 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.
@@ -45,9 +45,27 @@ export declare class CollectionHandle {
45
45
  * sees published content — which is also all the index holds, since
46
46
  * indexing is published-only.
47
47
  *
48
+ * **Row-level authorization** — "rank in the provider, authorise in core":
49
+ * when the collection configures a `beforeRead` hook, the provider's
50
+ * candidate hits are re-resolved through the normal read path (the same
51
+ * predicate merge + SQL compile every other read uses) and hits whose
52
+ * document doesn't survive the scoping are dropped. Collections without a
53
+ * hook skip the second query entirely. `hydrate: true` batch-reads the
54
+ * hits into shaped `ClientDocument`s in the same query (authorisation
55
+ * comes free) and attaches them as `hit.document`. Two consequences to be
56
+ * aware of:
57
+ *
58
+ * - `total` (and facet counts) remain the provider's pre-authorization
59
+ * numbers — approximate under row scoping, exact without it.
60
+ * - a page of hits can come back shorter than `limit` when candidates
61
+ * are dropped; paginate on `offset`, not on received length.
62
+ *
63
+ * `_bypassBeforeRead: true` is the same system-operation escape hatch the
64
+ * read methods take.
65
+ *
48
66
  * Throws `ERR_VALIDATION` when no provider is registered.
49
67
  */
50
- search(options: CollectionSearchOptions): Promise<SearchResults>;
68
+ search(options: CollectionSearchOptions): Promise<ClientSearchResults>;
51
69
  /**
52
70
  * Re-sync one document into the search index across every content locale.
53
71
  * Reads the document's *published* view per locale (`onMissingLocale: 'omit'`)
@@ -166,7 +184,7 @@ export declare class CollectionHandle {
166
184
  */
167
185
  history<F = Record<string, any>>(documentId: string, options?: HistoryOptions): Promise<FindResult<F>>;
168
186
  /**
169
- * Fetch the document-grain audit log for a single document (docs/AUDIT.md —
187
+ * Fetch the document-grain audit log for a single document (docs/06-auth-and-security/02-auditability.md —
170
188
  * Workstream 3): the non-versioned system-field writes (path,
171
189
  * available-locales), in-place status transitions, and the deletion event
172
190
  * the immutable version stream deliberately does not record an actor for.
@@ -7,6 +7,7 @@
7
7
  */
8
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
+ import { finalizeSearchHits } from './search.js';
10
11
  /**
11
12
  * A handle scoped to a single collection. Provides read (and eventually write)
12
13
  * operations against that collection's documents.
@@ -115,18 +116,36 @@ export class CollectionHandle {
115
116
  * sees published content — which is also all the index holds, since
116
117
  * indexing is published-only.
117
118
  *
119
+ * **Row-level authorization** — "rank in the provider, authorise in core":
120
+ * when the collection configures a `beforeRead` hook, the provider's
121
+ * candidate hits are re-resolved through the normal read path (the same
122
+ * predicate merge + SQL compile every other read uses) and hits whose
123
+ * document doesn't survive the scoping are dropped. Collections without a
124
+ * hook skip the second query entirely. `hydrate: true` batch-reads the
125
+ * hits into shaped `ClientDocument`s in the same query (authorisation
126
+ * comes free) and attaches them as `hit.document`. Two consequences to be
127
+ * aware of:
128
+ *
129
+ * - `total` (and facet counts) remain the provider's pre-authorization
130
+ * numbers — approximate under row scoping, exact without it.
131
+ * - a page of hits can come back shorter than `limit` when candidates
132
+ * are dropped; paginate on `offset`, not on received length.
133
+ *
134
+ * `_bypassBeforeRead: true` is the same system-operation escape hatch the
135
+ * read methods take.
136
+ *
118
137
  * Throws `ERR_VALIDATION` when no provider is registered.
119
138
  */
120
139
  async search(options) {
121
- await this.resolveAndAssertRead();
122
- const provider = this.client.search;
140
+ const requestContext = await this.resolveAndAssertRead();
141
+ const provider = this.client.searchProvider;
123
142
  if (provider == null) {
124
143
  throw ERR_VALIDATION({
125
144
  message: 'No search provider is registered. Register one on ServerConfig.search — ' +
126
145
  'see `@byline/search-postgres` → `postgresSearch()` for the built-in driver.',
127
146
  });
128
147
  }
129
- return provider.search({
148
+ const results = await provider.search({
130
149
  query: options.query,
131
150
  collectionPath: this.definition.path,
132
151
  locale: options.locale ?? this.client.defaultLocale,
@@ -136,6 +155,19 @@ export class CollectionHandle {
136
155
  limit: options.limit,
137
156
  offset: options.offset,
138
157
  });
158
+ // Row-level authorization (+ optional hydration) — the shared finishing
159
+ // pipeline the zone entry point uses too. Collections without a
160
+ // beforeRead predicate and no hydrate request pass through untouched.
161
+ const hits = await finalizeSearchHits({
162
+ client: this.client,
163
+ requestContext,
164
+ hits: results.hits,
165
+ locale: options.locale,
166
+ status: options.status,
167
+ hydrate: options.hydrate,
168
+ bypassBeforeRead: options._bypassBeforeRead,
169
+ });
170
+ return { hits, total: results.total, facets: results.facets };
139
171
  }
140
172
  // -------------------------------------------------------------------------
141
173
  // Search index maintenance
@@ -154,7 +186,7 @@ export class CollectionHandle {
154
186
  * path. The index always mirrors what a public reader can see.
155
187
  */
156
188
  async indexDocument(documentId) {
157
- const provider = this.client.search;
189
+ const provider = this.client.searchProvider;
158
190
  if (provider == null || this.definition.search == null)
159
191
  return;
160
192
  const populate = this.buildSearchFacetPopulateMap();
@@ -186,7 +218,7 @@ export class CollectionHandle {
186
218
  }
187
219
  /** Remove one document from the search index entirely (all locales). */
188
220
  async removeFromIndex(documentId) {
189
- const provider = this.client.search;
221
+ const provider = this.client.searchProvider;
190
222
  if (provider == null)
191
223
  return;
192
224
  await provider.remove({ collectionPath: this.definition.path, documentId });
@@ -203,7 +235,7 @@ export class CollectionHandle {
203
235
  async reindex() {
204
236
  const requestContext = await this.client.resolveRequestContext();
205
237
  assertActorCanPerform(requestContext, this.definition.path, 'reindex');
206
- const provider = this.client.search;
238
+ const provider = this.client.searchProvider;
207
239
  const result = { collectionPath: this.definition.path, documents: 0, indexed: 0 };
208
240
  if (provider == null || this.definition.search == null)
209
241
  return result;
@@ -518,7 +550,7 @@ export class CollectionHandle {
518
550
  };
519
551
  }
520
552
  /**
521
- * Fetch the document-grain audit log for a single document (docs/AUDIT.md —
553
+ * Fetch the document-grain audit log for a single document (docs/06-auth-and-security/02-auditability.md —
522
554
  * Workstream 3): the non-versioned system-field writes (path,
523
555
  * available-locales), in-place status transitions, and the deletion event
524
556
  * the immutable version stream deliberately does not record an actor for.
@@ -583,7 +615,7 @@ export class CollectionHandle {
583
615
  return this.shapeWithPopulated(raw);
584
616
  }
585
617
  // -------------------------------------------------------------------------
586
- // Document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md)
618
+ // Document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md)
587
619
  //
588
620
  // A document-grain, unversioned single-parent ordered hierarchy. Reads/writes
589
621
  // here go through the storage adapter's dedicated tree commands, NOT the
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, 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';
11
+ export type { AuditLogOptions, BylineClientConfig, ClientDocument, ClientSearchResults, CollectionSearchOptions, CreateOptions, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, HydratedSearchHit, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, SearchFacetBucket, SearchHit, SearchResults, SortDirection, SortSpec, TreeNode, UpdateOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, ZoneSearchOptions, } from './types.js';
package/dist/response.js CHANGED
@@ -43,7 +43,7 @@ export function shapeDocument(raw) {
43
43
  // `created_by` / `event_type`).
44
44
  // `created_by` is NULL on rows written before audit wiring or by
45
45
  // internal tooling without a request context — omitted here, rendered as
46
- // "unknown" by consumers. See docs/AUDIT.md — Workstream 1.
46
+ // "unknown" by consumers. See docs/06-auth-and-security/02-auditability.md — Workstream 1.
47
47
  if (typeof raw.created_by === 'string') {
48
48
  shaped.createdBy = raw.created_by;
49
49
  }
@@ -55,7 +55,7 @@ export function shapeDocument(raw) {
55
55
  // Storage raw keys already match the surface names (passthrough) —
56
56
  // `availableLocales` is the editorial advertised set (document-grain,
57
57
  // stored), `_availableVersionLocales` is the structural ledger fact
58
- // (version-grain, computed). See docs/I18N.md.
58
+ // (version-grain, computed). See docs/07-internationalization/index.md.
59
59
  if (Array.isArray(raw.availableLocales)) {
60
60
  shaped.availableLocales = raw.availableLocales;
61
61
  }
@@ -0,0 +1,60 @@
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
+ * Client-side search finishing pipeline + the cross-collection (zone)
10
+ * entry point.
11
+ *
12
+ * The provider ranks; core authorises and hydrates. Both `client.search({
13
+ * zone })` and `CollectionHandle.search()` hand their provider hits to
14
+ * `finalizeSearchHits`, which — per collection represented in the hits —
15
+ * applies `beforeRead` row scoping (re-resolving candidate ids through the
16
+ * normal read path) and, when `hydrate` is requested, batch-reads the hits
17
+ * into shaped `ClientDocument`s in the same query.
18
+ */
19
+ import type { RequestContext } from '@byline/auth';
20
+ import type { ReadContext, ReadMode, SearchHit } from '@byline/core';
21
+ import type { BylineClient } from './client.js';
22
+ import type { ClientSearchResults, HydratedSearchHit, ZoneSearchOptions } from './types.js';
23
+ export interface FinalizeSearchHitsParams {
24
+ client: BylineClient;
25
+ requestContext: RequestContext;
26
+ hits: SearchHit[];
27
+ locale?: string;
28
+ status?: ReadMode;
29
+ hydrate?: boolean;
30
+ bypassBeforeRead?: true;
31
+ /** Shared per-request read context (beforeRead cache). Created when omitted. */
32
+ readContext?: ReadContext;
33
+ }
34
+ /**
35
+ * Authorise (and optionally hydrate) provider hits, per collection:
36
+ *
37
+ * - `hydrate: true` — batch-read each collection's hit ids through the
38
+ * normal read path (`find` with `id: { $in }`). The read applies
39
+ * `beforeRead` row scoping as a side effect, so authorisation and
40
+ * hydration cost one query per collection. Hits whose document doesn't
41
+ * come back — dropped by scoping, or a stale index entry whose document
42
+ * no longer resolves — are removed; the rest carry `hit.document`.
43
+ * - `hydrate` off — collections with a `beforeRead` predicate get the
44
+ * trimmed id re-resolution (identity-field projection); collections
45
+ * without one pass through untouched (no second query).
46
+ *
47
+ * Hits whose `collectionPath` isn't registered in the runtime config are
48
+ * dropped with a debug log (an index can outlive a collection). Original
49
+ * ranking order is preserved.
50
+ */
51
+ export declare function finalizeSearchHits(params: FinalizeSearchHitsParams): Promise<HydratedSearchHit[]>;
52
+ /**
53
+ * Cross-collection search over a named zone. Membership is resolved from
54
+ * the runtime collection definitions (`search.zones`, defaulting to the
55
+ * collection's own path — the same rule the indexing assembler applies);
56
+ * collections the actor cannot `read` are excluded from the scope. Throws
57
+ * `ERR_VALIDATION` for a zone no collection indexes into; rethrows the
58
+ * ability error only when the actor can read none of the members.
59
+ */
60
+ export declare function zoneSearch(client: BylineClient, options: ZoneSearchOptions): Promise<ClientSearchResults>;
package/dist/search.js ADDED
@@ -0,0 +1,184 @@
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 { applyBeforeRead, assertActorCanPerform, createReadContext, ERR_VALIDATION, getCollectionAdminConfig, resolveItemViewColumns, resolveSearchZones, } from '@byline/core';
9
+ /**
10
+ * Authorise (and optionally hydrate) provider hits, per collection:
11
+ *
12
+ * - `hydrate: true` — batch-read each collection's hit ids through the
13
+ * normal read path (`find` with `id: { $in }`). The read applies
14
+ * `beforeRead` row scoping as a side effect, so authorisation and
15
+ * hydration cost one query per collection. Hits whose document doesn't
16
+ * come back — dropped by scoping, or a stale index entry whose document
17
+ * no longer resolves — are removed; the rest carry `hit.document`.
18
+ * - `hydrate` off — collections with a `beforeRead` predicate get the
19
+ * trimmed id re-resolution (identity-field projection); collections
20
+ * without one pass through untouched (no second query).
21
+ *
22
+ * Hits whose `collectionPath` isn't registered in the runtime config are
23
+ * dropped with a debug log (an index can outlive a collection). Original
24
+ * ranking order is preserved.
25
+ */
26
+ export async function finalizeSearchHits(params) {
27
+ const { client, requestContext, hits, locale, status, hydrate, bypassBeforeRead } = params;
28
+ if (hits.length === 0)
29
+ return hits;
30
+ const readCtx = params.readContext ?? createReadContext();
31
+ // Group hit ids by collection, preserving overall ranking order for the
32
+ // final reassembly.
33
+ const byCollection = new Map();
34
+ for (const hit of hits) {
35
+ const group = byCollection.get(hit.collectionPath);
36
+ if (group)
37
+ group.push(hit);
38
+ else
39
+ byCollection.set(hit.collectionPath, [hit]);
40
+ }
41
+ // Per collection: resolve either a hydrated document map or an allowed-id
42
+ // set. `null` means "no filtering — pass the group through".
43
+ const documentsByCollection = new Map();
44
+ const allowedByCollection = new Map();
45
+ for (const [collectionPath, group] of byCollection) {
46
+ const definition = client.collections.find((c) => c.path === collectionPath);
47
+ if (definition == null) {
48
+ client.logger.debug({ collectionPath }, 'search: dropping hits for a collection not registered in the runtime config');
49
+ allowedByCollection.set(collectionPath, new Set());
50
+ continue;
51
+ }
52
+ const ids = group.map((h) => h.documentId);
53
+ const handle = client.collection(collectionPath);
54
+ if (hydrate) {
55
+ // itemView columns are the projection when the admin config is
56
+ // registered in this runtime; otherwise read the full field set.
57
+ const itemView = resolveItemViewColumns(getCollectionAdminConfig(collectionPath));
58
+ const select = itemView != null && itemView.length > 0
59
+ ? Array.from(new Set([...itemView.map((c) => String(c.fieldName)), definition.useAsTitle].filter((f) => typeof f === 'string' && f.length > 0)))
60
+ : undefined;
61
+ const result = await handle.find({
62
+ where: { id: { $in: ids } },
63
+ select,
64
+ locale,
65
+ status,
66
+ page: 1,
67
+ pageSize: ids.length,
68
+ _readContext: readCtx,
69
+ ...(bypassBeforeRead ? { _bypassBeforeRead: true } : {}),
70
+ });
71
+ documentsByCollection.set(collectionPath, new Map(result.docs.map((d) => [d.id, d])));
72
+ continue;
73
+ }
74
+ if (bypassBeforeRead) {
75
+ allowedByCollection.set(collectionPath, null);
76
+ continue;
77
+ }
78
+ const predicate = await applyBeforeRead({ definition, requestContext, readContext: readCtx });
79
+ if (predicate == null) {
80
+ allowedByCollection.set(collectionPath, null);
81
+ continue;
82
+ }
83
+ // Row scoping applies — re-resolve the candidate ids through the normal
84
+ // read path (find re-applies the cached predicate AND-merged with the id
85
+ // set), projected to the identity field.
86
+ const result = await handle.find({
87
+ where: { id: { $in: ids } },
88
+ select: definition.useAsTitle != null ? [definition.useAsTitle] : undefined,
89
+ locale,
90
+ status,
91
+ page: 1,
92
+ pageSize: ids.length,
93
+ _readContext: readCtx,
94
+ });
95
+ allowedByCollection.set(collectionPath, new Set(result.docs.map((d) => d.id)));
96
+ }
97
+ // Reassemble in the provider's ranking order.
98
+ const finished = [];
99
+ for (const hit of hits) {
100
+ if (hydrate) {
101
+ const doc = documentsByCollection.get(hit.collectionPath)?.get(hit.documentId);
102
+ if (doc == null)
103
+ continue;
104
+ finished.push({ ...hit, document: doc });
105
+ continue;
106
+ }
107
+ const allowed = allowedByCollection.get(hit.collectionPath);
108
+ if (allowed === null) {
109
+ finished.push(hit);
110
+ }
111
+ else if (allowed?.has(hit.documentId)) {
112
+ finished.push(hit);
113
+ }
114
+ }
115
+ return finished;
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Zone (cross-collection) entry point
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Cross-collection search over a named zone. Membership is resolved from
122
+ * the runtime collection definitions (`search.zones`, defaulting to the
123
+ * collection's own path — the same rule the indexing assembler applies);
124
+ * collections the actor cannot `read` are excluded from the scope. Throws
125
+ * `ERR_VALIDATION` for a zone no collection indexes into; rethrows the
126
+ * ability error only when the actor can read none of the members.
127
+ */
128
+ export async function zoneSearch(client, options) {
129
+ const provider = client.searchProvider;
130
+ if (provider == null) {
131
+ throw ERR_VALIDATION({
132
+ message: 'No search provider is registered. Register one on ServerConfig.search — ' +
133
+ 'see `@byline/search-postgres` → `postgresSearch()` for the built-in driver.',
134
+ });
135
+ }
136
+ const members = client.collections.filter((c) => (resolveSearchZones(c) ?? []).includes(options.zone));
137
+ if (members.length === 0) {
138
+ throw ERR_VALIDATION({
139
+ message: `No collection indexes into search zone '${options.zone}'. Declare zone ` +
140
+ 'membership via the collection `search.zones` config (a collection without ' +
141
+ 'explicit zones belongs to the implicit zone named after its own path).',
142
+ });
143
+ }
144
+ // Per-member read gate: exclude collections the actor can't read; only
145
+ // when *none* are readable does the ability error surface.
146
+ const requestContext = await client.resolveRequestContext();
147
+ const readable = new Set();
148
+ let firstAbilityError;
149
+ for (const member of members) {
150
+ try {
151
+ assertActorCanPerform(requestContext, member.path, 'read');
152
+ readable.add(member.path);
153
+ }
154
+ catch (err) {
155
+ firstAbilityError ??= err;
156
+ client.logger.debug({ collectionPath: member.path, zone: options.zone }, 'zone search: excluding collection the actor cannot read');
157
+ }
158
+ }
159
+ if (readable.size === 0) {
160
+ throw firstAbilityError;
161
+ }
162
+ const results = await provider.search({
163
+ query: options.query,
164
+ zone: options.zone,
165
+ locale: options.locale ?? client.defaultLocale,
166
+ status: options.status === 'any' ? 'any' : 'published',
167
+ where: options.where,
168
+ facets: options.facets,
169
+ limit: options.limit,
170
+ offset: options.offset,
171
+ });
172
+ // Constrain to readable member collections, then authorise / hydrate.
173
+ const scoped = results.hits.filter((h) => readable.has(h.collectionPath));
174
+ const hits = await finalizeSearchHits({
175
+ client,
176
+ requestContext,
177
+ hits: scoped,
178
+ locale: options.locale,
179
+ status: options.status,
180
+ hydrate: options.hydrate,
181
+ bypassBeforeRead: options._bypassBeforeRead,
182
+ });
183
+ return { hits, total: results.total, facets: results.facets };
184
+ }
package/dist/types.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, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, RichTextToTextFn, SearchProvider, ServerConfig, SlugifierFn, SortSpec } from '@byline/core';
9
+ import type { BylineLogger, CollectionDefinition, IDbAdapter, IStorageProvider, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, RichTextToTextFn, SearchFacetBucket, SearchHit, SearchProvider, ServerConfig, SlugifierFn, SortSpec } from '@byline/core';
10
10
  export type { FilterOperators, PredicateValue, QueryPredicate, SearchFacetBucket, SearchHit, SearchProvider, SearchResults, SortDirection, SortSpec, } from '@byline/core';
11
11
  export interface BylineClientConfig {
12
12
  /**
@@ -164,10 +164,48 @@ interface StatusControls {
164
164
  * `collectionPath` is implied by the handle, so callers supply only the
165
165
  * query and optional scoping. `status` defaults to `'published'` (public
166
166
  * readers); pass `'any'` for admin contexts.
167
+ *
168
+ * When the collection configures a `beforeRead` hook, hits are re-resolved
169
+ * through the row-scoping pipeline before being returned; `_bypassBeforeRead`
170
+ * is the same system-operation escape hatch the read methods take.
171
+ */
172
+ export interface CollectionSearchOptions extends BeforeReadControls {
173
+ /** Free-text query string. */
174
+ query: string;
175
+ /** Restrict to a single content locale (defaults to the client default). */
176
+ locale?: string;
177
+ /** Read mode — `'published'` (default) or `'any'`. */
178
+ status?: ReadMode;
179
+ /** Structured filters AND-merged with the text query (driver-dependent). */
180
+ where?: QueryPredicate;
181
+ /** Field names to compute facet buckets for (driver-capability gated). */
182
+ facets?: string[];
183
+ /** Max hits to return. */
184
+ limit?: number;
185
+ /** Offset for pagination. */
186
+ offset?: number;
187
+ /**
188
+ * Attach a shaped `ClientDocument` to each hit (`hit.document`), batch-read
189
+ * through the normal read path — the two-tier results model's rich tier.
190
+ * Projection is the collection's `admin.itemView` columns when that config
191
+ * is registered in the runtime, otherwise the full field set. Hits whose
192
+ * document no longer resolves (stale index entry, or dropped by `beforeRead`
193
+ * row scoping) are removed.
194
+ */
195
+ hydrate?: boolean;
196
+ }
197
+ /**
198
+ * Options for `client.search(...)` — the cross-collection (zone) entry
199
+ * point. Every collection indexed into `zone` participates; hits come back
200
+ * heterogeneous, ranked together, each carrying its `collectionPath`.
201
+ * Collections the actor cannot `read` are excluded from the results (the
202
+ * call only throws when the actor can read none of the zone's members).
167
203
  */
168
- export interface CollectionSearchOptions {
204
+ export interface ZoneSearchOptions extends BeforeReadControls {
169
205
  /** Free-text query string. */
170
206
  query: string;
207
+ /** The zone (named cross-collection scope) to search. */
208
+ zone: string;
171
209
  /** Restrict to a single content locale (defaults to the client default). */
172
210
  locale?: string;
173
211
  /** Read mode — `'published'` (default) or `'any'`. */
@@ -180,6 +218,23 @@ export interface CollectionSearchOptions {
180
218
  limit?: number;
181
219
  /** Offset for pagination. */
182
220
  offset?: number;
221
+ /** Attach a shaped `ClientDocument` per hit — see `CollectionSearchOptions.hydrate`. */
222
+ hydrate?: boolean;
223
+ }
224
+ /** A search hit, optionally carrying its hydrated document (`hydrate: true`). */
225
+ export interface HydratedSearchHit extends SearchHit {
226
+ document?: ClientDocument<Record<string, any>>;
227
+ }
228
+ /**
229
+ * The client-side search result envelope — `SearchResults` with the hits
230
+ * widened to carry an optional hydrated document. `total` (and facet
231
+ * counts) are the provider's pre-authorization numbers: approximate when
232
+ * row scoping or hydration drops hits, exact otherwise.
233
+ */
234
+ export interface ClientSearchResults {
235
+ hits: HydratedSearchHit[];
236
+ total: number;
237
+ facets?: Record<string, SearchFacetBucket[]>;
183
238
  }
184
239
  /** Outcome of a collection `reindex()` — counts for reporting. */
185
240
  export interface ReindexResult {
@@ -209,7 +264,7 @@ export interface ReindexResult {
209
264
  * in every locale.
210
265
  *
211
266
  * Availability is the version-grain ledger described in
212
- * `docs/I18N.md`. Relationship population always behaves
267
+ * `docs/07-internationalization/index.md`. Relationship population always behaves
213
268
  * as `'fallback'` so a populated tree never has holes.
214
269
  */
215
270
  interface MissingLocaleControls {
@@ -283,7 +338,7 @@ export interface HistoryOptions extends BeforeReadControls {
283
338
  }
284
339
  /**
285
340
  * Options for `CollectionHandle.auditLog(documentId, options)`. The
286
- * document-grain audit log (docs/AUDIT.md — Workstream 3) records the
341
+ * document-grain audit log (docs/06-auth-and-security/02-auditability.md — Workstream 3) records the
287
342
  * non-versioned changes the immutable version stream does not capture an
288
343
  * actor for: system-field writes (path, available-locales), in-place status
289
344
  * transitions, and the deletion event. Entries are newest-first and paged;
@@ -325,7 +380,7 @@ export interface CreateOptions {
325
380
  * The editorial advertised-locale set, stored document-grain in
326
381
  * `byline_document_available_locales`. When omitted, a new document starts
327
382
  * with an empty set; an explicit array (empty included) is stored verbatim.
328
- * Surfaced on reads as `availableLocales`. See `docs/I18N.md`.
383
+ * Surfaced on reads as `availableLocales`. See `docs/07-internationalization/index.md`.
329
384
  */
330
385
  availableLocales?: string[];
331
386
  }
@@ -341,7 +396,7 @@ export interface UpdateOptions {
341
396
  * The editorial advertised-locale set. When omitted, the existing set
342
397
  * carries forward unchanged (sticky — document-grain, like `path`); an
343
398
  * explicit array (empty included) replaces it wholesale. Surfaced on reads
344
- * as `availableLocales`. See `docs/I18N.md`.
399
+ * as `availableLocales`. See `docs/07-internationalization/index.md`.
345
400
  */
346
401
  availableLocales?: string[];
347
402
  }
@@ -462,7 +517,7 @@ export interface ClientDocument<F = Record<string, any>> {
462
517
  * yardstick). Stable, document-grain. Surfaced so consumers / the admin can
463
518
  * indicate a document whose primary language differs from the system default.
464
519
  * Present on `find` / `findById` / `findByPath`. See
465
- * `docs/I18N.md`.
520
+ * `docs/07-internationalization/index.md`.
466
521
  */
467
522
  sourceLocale?: string;
468
523
  /** When this version was created. */
@@ -475,7 +530,7 @@ export interface ClientDocument<F = Record<string, any>> {
475
530
  * the creator of the current version is also "who last updated the
476
531
  * document". Absent on rows written before audit wiring or by
477
532
  * internal tooling without a request context. Raw id only — display-name
478
- * resolution is an admin-realm concern (see docs/AUDIT.md — Workstream 1).
533
+ * resolution is an admin-realm concern (see docs/06-auth-and-security/02-auditability.md — Workstream 1).
479
534
  */
480
535
  createdBy?: string;
481
536
  /**
@@ -503,7 +558,7 @@ export interface ClientDocument<F = Record<string, any>> {
503
558
  * `_availableVersionLocales` fact below; the public advertised set is their
504
559
  * intersection (`availableLocales ∩ _availableVersionLocales`), which the
505
560
  * host composes for hreflang / sitemap / "Also available in…" menus.
506
- * Present on `find` / `findById` / `findByPath`. See `docs/I18N.md`.
561
+ * Present on `find` / `findById` / `findByPath`. See `docs/07-internationalization/index.md`.
507
562
  */
508
563
  availableLocales?: string[];
509
564
  /**
@@ -513,7 +568,7 @@ export interface ClientDocument<F = Record<string, any>> {
513
568
  * Present on `find` / `findById` / `findByPath` (absent on version/history
514
569
  * reads); the published-available set on a normal (published) read. The
515
570
  * structural fact reconciled against the editorial `availableLocales` above.
516
- * See `docs/I18N.md`.
571
+ * See `docs/07-internationalization/index.md`.
517
572
  */
518
573
  _availableVersionLocales?: string[];
519
574
  /**
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.15.2",
5
+ "version": "3.16.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -37,9 +37,8 @@
37
37
  "dist"
38
38
  ],
39
39
  "dependencies": {
40
- "npm-run-all": "^4.1.5",
41
- "@byline/auth": "3.15.2",
42
- "@byline/core": "3.15.2"
40
+ "@byline/auth": "3.16.1",
41
+ "@byline/core": "3.16.1"
43
42
  },
44
43
  "devDependencies": {
45
44
  "@biomejs/biome": "2.4.15",
@@ -47,11 +46,13 @@
47
46
  "chokidar": "^5.0.0",
48
47
  "chokidar-cli": "^3.0.0",
49
48
  "dotenv": "^17.4.2",
49
+ "npm-run-all": "^4.1.5",
50
50
  "tsc-alias": "^1.8.17",
51
51
  "tsx": "^4.22.3",
52
52
  "typescript": "6.0.3",
53
53
  "vitest": "^4.1.7",
54
- "@byline/db-postgres": "3.15.2"
54
+ "@byline/db-postgres": "3.16.1",
55
+ "@byline/search-postgres": "3.16.1"
55
56
  },
56
57
  "publishConfig": {
57
58
  "access": "public",