@byline/client 3.21.0 → 4.0.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.
@@ -7,7 +7,7 @@
7
7
  */
8
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, ClientSearchResults, 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, GetTreeParentOptions, HistoryOptions, PlaceTreeNodeOptions, ReindexResult, RemoveFromTreeOptions, TreeNode, TreeParentResult, 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.
@@ -55,8 +55,8 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
55
55
  * comes free) and attaches them as `hit.document`. Two consequences to be
56
56
  * aware of:
57
57
  *
58
- * - `total` (and facet counts) remain the provider's pre-authorization
59
- * numbers — approximate under row scoping, exact without it.
58
+ * - under row scoping, `total` is the authorized hit count for this page
59
+ * and facets are omitted rather than leaking provider-wide aggregates.
60
60
  * - a page of hits can come back shorter than `limit` when candidates
61
61
  * are dropped; paginate on `offset`, not on received length.
62
62
  *
@@ -150,8 +150,10 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
150
150
  */
151
151
  delete(documentId: string): Promise<DeleteDocumentResult>;
152
152
  /**
153
- * Count documents visible to the current actor, optionally filtered by
154
- * status.
153
+ * Sum the current-version workflow status buckets visible to the actor,
154
+ * optionally selecting one exact status. This is an editorial status-count
155
+ * API, not an ordinary published-view document count; it therefore
156
+ * authorizes as `readMode: 'any'` and rejects anonymous callers.
155
157
  *
156
158
  * Applies the collection's `beforeRead` predicate so the count reflects
157
159
  * only the rows the actor can see (multi-tenant scoping, owner-only
@@ -163,7 +165,8 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
163
165
  }): Promise<number>;
164
166
  /**
165
167
  * Per-status document counts for this collection. Used by admin status
166
- * bars / dashboards. Applies `beforeRead` so per-status counts reflect
168
+ * bars / dashboards. This always reads current versions across statuses,
169
+ * authorizes as `readMode: 'any'`, and applies `beforeRead` so counts reflect
167
170
  * only the actor's visible rows.
168
171
  */
169
172
  countByStatus(options?: {
@@ -174,9 +177,8 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
174
177
  }>>;
175
178
  /**
176
179
  * Fetch the version history for a single document. Applies `beforeRead`
177
- * as an access gate via `findById` — if the actor can't see the
178
- * document at all, history returns an empty result rather than
179
- * leaking version metadata.
180
+ * independently to every immutable version before pagination and counting,
181
+ * so ownership changes cannot expose hidden version content or metadata.
180
182
  *
181
183
  * Each version in the response is a shaped `ClientDocument`. Pagination
182
184
  * mirrors the storage adapter's `{ documents, meta }` shape, then is
@@ -189,9 +191,9 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
189
191
  * available-locales), in-place status transitions, and the deletion event
190
192
  * the immutable version stream deliberately does not record an actor for.
191
193
  *
192
- * Applies `beforeRead` as an access gate via `findById` — exactly as
193
- * `history()` does — so an actor who cannot see the document at all gets an
194
- * empty log rather than leaking change metadata. Entries are newest-first.
194
+ * Applies `beforeRead` as a current-document access gate via `findById`, so
195
+ * an actor who cannot see the document at all gets an empty log rather than
196
+ * leaking change metadata. Entries are newest-first.
195
197
  * Actor *ids* are returned raw; resolving them to display labels is an
196
198
  * admin-realm concern handled above the SDK.
197
199
  *
@@ -203,12 +205,10 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
203
205
  * Fetch a specific version of a document by its `documentVersionId`.
204
206
  * Used by admin diff views.
205
207
  *
206
- * Pass-through to `getDocumentByVersion`; access enforcement falls back
207
- * to the collection-level `read` ability (asserted at the top of every
208
- * read entry point). Row-level `beforeRead` does **not** apply here —
209
- * version-by-id is a history-viewing primitive and the caller is
210
- * expected to have already passed an access check on the parent
211
- * document. Use `history()` instead if you want the access gate.
208
+ * The adapter query is constrained to this handle's collection and applies
209
+ * the collection's strict `beforeRead` predicate directly to the historical
210
+ * version. Unknown, cross-collection, and row-scoped version ids all return
211
+ * `null` without revealing ownership.
212
212
  */
213
213
  findByVersion<F = TFields>(versionId: string, options?: FindByVersionOptions<F>): Promise<ClientDocument<F> | null>;
214
214
  /**
@@ -227,14 +227,15 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
227
227
  * in the collection, but not in any table of contents). Distinct from
228
228
  * deleting the document. No-op when already unplaced.
229
229
  */
230
- removeFromTree(documentId: string): Promise<void>;
230
+ removeFromTree(documentId: string, options?: RemoveFromTreeOptions): Promise<void>;
231
231
  /**
232
232
  * Read a node's subtree as a nested {@link TreeNode} forest, hydrated to
233
233
  * `ClientDocument`s. `rootDocumentId: null` (default) returns the whole tree
234
234
  * from the collection roots; a value returns the subtree rooted at (and
235
235
  * including) that node. Children are ordered per-parent. `status` defaults to
236
236
  * `'published'` (the client default) — an unpublished node hides its whole
237
- * subtree in that mode.
237
+ * subtree in that mode. `beforeRead` visibility follows the same edge rule:
238
+ * a hidden node and its descendants are omitted, never promoted.
238
239
  */
239
240
  getSubtree<F = TFields>(options?: GetSubtreeOptions<F>): Promise<TreeNode<F>[]>;
240
241
  /**
@@ -243,7 +244,8 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
243
244
  * to `ClientDocument`s. In `'published'` mode (the client default) the walk
244
245
  * applies status-at-edge: it stops at the first unpublished ancestor (a
245
246
  * truncated chain), so a broken spine surfaces as a short chain the caller can
246
- * detect rather than a silently-compacted one.
247
+ * detect rather than a silently-compacted one. A `beforeRead`-hidden ancestor
248
+ * breaks the edge identically.
247
249
  */
248
250
  getAncestors<F = TFields>(documentId: string, options?: GetAncestorsOptions<F>): Promise<ClientDocument<F>[]>;
249
251
  /**
@@ -251,12 +253,11 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
251
253
  * `getAncestors` cannot express (it returns `[]` for both a root and an
252
254
  * unplaced node). Returns `{ placed, parentDocumentId }`: `placed: false` is
253
255
  * *unplaced* (no edge row), `placed: true` with a null parent is a *root*, and
254
- * a non-null parent is a *child*. Structure-only — no document hydration.
256
+ * a non-null parent is a *child*. A hidden queried node reports unplaced; a
257
+ * visible child of a hidden parent remains placed but the parent id is
258
+ * redacted to null. Structure-only — no document hydration.
255
259
  */
256
- getTreeParent(documentId: string): Promise<{
257
- placed: boolean;
258
- parentDocumentId: string | null;
259
- }>;
260
+ getTreeParent(documentId: string, options?: GetTreeParentOptions): Promise<TreeParentResult>;
260
261
  /**
261
262
  * Build a fresh `DocumentLifecycleContext` for a write call. Pulls the
262
263
  * resolved collection id, inherits the client-wide logger and storage
@@ -283,8 +284,8 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
283
284
  * Batch-hydrate a set of tree node document ids into shaped
284
285
  * `ClientDocument`s, keyed by document id. Reuses the populate batch read
285
286
  * (`getDocumentsByDocumentIds`) under the requested `readMode` — the tree's
286
- * own status-at-edge filtering already happened structurally, so a node
287
- * absent here in `'published'` mode simply has no published version. Fires
287
+ * own status/beforeRead edge filtering already happened structurally, and the
288
+ * same filters are applied again to close the structure/hydration race. Fires
288
289
  * `afterRead` per node (deduped within one shared `ReadContext`) to stay
289
290
  * consistent with the other client read paths.
290
291
  */
@@ -309,17 +310,11 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
309
310
  * is registered.
310
311
  */
311
312
  private richTextPopulateSources;
313
+ /** Complete the common post-reconstruction pipeline in security order. */
314
+ private finishReadDocuments;
312
315
  /**
313
- * Resolve and cache the `beforeRead` hook predicate for this collection
314
- * inside the current `ReadContext`. Honours `_bypassBeforeRead` (admin
315
- * tooling, seeds, migrations).
316
- */
317
- private resolveBeforeReadPredicate;
318
- /**
319
- * Like `resolveBeforeReadPredicate`, but parses the predicate to the
320
- * adapter-facing `DocumentFilter[]` shape used by `getDocumentById` /
321
- * `getDocumentByPath`. Returns `undefined` (not `[]`) when there is no
322
- * scoping, so the adapter can skip the loop entirely.
316
+ * Resolve the cached strict security filters used by every read shape.
317
+ * Returns `undefined` when no scoping applies.
323
318
  */
324
319
  private resolveBeforeReadFilters;
325
320
  /**
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
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';
8
+ import { applyAfterRead, assertActorCanPerform, buildSearchDocument, changeDocumentStatus, createDocument, createReadContext, createRichTextDocumentReader, deleteDocument, ERR_VALIDATION, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, resolveIdentityField, restoreDocumentVersion, unpublishDocument, updateDocument, } from '@byline/core';
9
+ import { getReadSecurityDomain, resolveReadRequestContext, resolveReadSecurityFilters, } from './read-context.js';
9
10
  import { shapeDocument, shapePopulatedInPlace } from './response.js';
10
11
  import { finalizeSearchHits } from './search.js';
11
12
  /**
@@ -31,18 +32,19 @@ export class CollectionHandle {
31
32
  * (LATERAL JOINs).
32
33
  */
33
34
  async find(options = {}) {
34
- const requestContext = await this.resolveAndAssertRead();
35
- const collectionId = await this.client.resolveCollectionId(this.definition.path);
36
- const { where, select, sort, locale = this.client.defaultLocale, page = 1, pageSize = 20, } = options;
37
35
  const readMode = resolveReadMode(options.status);
38
36
  const readCtx = options._readContext ?? createReadContext();
39
- const hookPredicate = await this.resolveBeforeReadPredicate(requestContext, readCtx, options._bypassBeforeRead);
40
- const merged = mergePredicates(hookPredicate, where);
41
- const parsedWhere = await parseWhere(merged, this.definition, {
37
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
38
+ const { where, select, sort, locale = this.client.defaultLocale, page = 1, pageSize = 20, } = options;
39
+ const securityFilters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
40
+ const parsedWhere = await parseWhere(where, this.definition, {
42
41
  collections: this.client.collections,
43
42
  resolveCollectionId: (path) => this.client.resolveCollectionId(path),
44
43
  logger: this.client.logger,
45
44
  });
45
+ if (securityFilters)
46
+ parsedWhere.filters.push(...securityFilters);
47
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
46
48
  const parsedSort = parseSort(sort, this.definition);
47
49
  const result = await this.client.db.queries.documents.findDocuments({
48
50
  collection_id: collectionId,
@@ -64,16 +66,7 @@ export class CollectionHandle {
64
66
  ...options,
65
67
  _readContext: readCtx,
66
68
  });
67
- // Richtext populate runs *after* relation populate (so populated relation
68
- // targets are already attached) and *before* afterRead (so user-land
69
- // hooks observe the refreshed rich-text content).
70
- await this.richTextPopulateSources(result.documents, readCtx);
71
- // Fire afterRead for each source document AFTER populate so the hook
72
- // sees the fully populated tree. Targets were already fired inside
73
- // populate. applyAfterRead deduplicates via readCtx.afterReadFired.
74
- for (const d of result.documents) {
75
- await applyAfterRead({ doc: d, definition: this.definition, readContext: readCtx });
76
- }
69
+ await this.finishReadDocuments(result.documents, readCtx, requestContext, locale, readMode, options._bypassBeforeRead, select, readMaterialization(options.populate, options.depth));
77
70
  return {
78
71
  docs: result.documents.map((d) => this.shapeWithPopulated(d)),
79
72
  meta: {
@@ -126,8 +119,8 @@ export class CollectionHandle {
126
119
  * comes free) and attaches them as `hit.document`. Two consequences to be
127
120
  * aware of:
128
121
  *
129
- * - `total` (and facet counts) remain the provider's pre-authorization
130
- * numbers — approximate under row scoping, exact without it.
122
+ * - under row scoping, `total` is the authorized hit count for this page
123
+ * and facets are omitted rather than leaking provider-wide aggregates.
131
124
  * - a page of hits can come back shorter than `limit` when candidates
132
125
  * are dropped; paginate on `offset`, not on received length.
133
126
  *
@@ -137,7 +130,11 @@ export class CollectionHandle {
137
130
  * Throws `ERR_VALIDATION` when no provider is registered.
138
131
  */
139
132
  async search(options) {
140
- const requestContext = await this.resolveAndAssertRead();
133
+ const readMode = resolveReadMode(options.status);
134
+ const readCtx = createReadContext();
135
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
136
+ const aggregateRestricted = (await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead)) !=
137
+ null;
141
138
  const provider = this.client.searchProvider;
142
139
  if (provider == null) {
143
140
  throw ERR_VALIDATION({
@@ -149,7 +146,7 @@ export class CollectionHandle {
149
146
  query: options.query,
150
147
  collectionPath: this.definition.path,
151
148
  locale: options.locale ?? this.client.defaultLocale,
152
- status: resolveReadMode(options.status),
149
+ status: readMode,
153
150
  where: options.where,
154
151
  facets: options.facets,
155
152
  limit: options.limit,
@@ -166,8 +163,11 @@ export class CollectionHandle {
166
163
  status: options.status,
167
164
  hydrate: options.hydrate,
168
165
  bypassBeforeRead: options._bypassBeforeRead,
166
+ readContext: readCtx,
169
167
  });
170
- return { hits, total: results.total, facets: results.facets };
168
+ return aggregateRestricted
169
+ ? { hits, total: hits.length }
170
+ : { hits, total: results.total, facets: results.facets };
171
171
  }
172
172
  // -------------------------------------------------------------------------
173
173
  // Search index maintenance
@@ -298,12 +298,12 @@ export class CollectionHandle {
298
298
  * Find a document by its logical document ID.
299
299
  */
300
300
  async findById(documentId, options = {}) {
301
- const requestContext = await this.resolveAndAssertRead();
302
- const collectionId = await this.client.resolveCollectionId(this.definition.path);
303
- const { locale = this.client.defaultLocale } = options;
304
301
  const readMode = resolveReadMode(options.status);
305
302
  const readCtx = options._readContext ?? createReadContext();
303
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
304
+ const { locale = this.client.defaultLocale } = options;
306
305
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
306
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
307
307
  const raw = await this.client.db.queries.documents.getDocumentById({
308
308
  collection_id: collectionId,
309
309
  document_id: documentId,
@@ -325,12 +325,7 @@ export class CollectionHandle {
325
325
  ...options,
326
326
  _readContext: readCtx,
327
327
  });
328
- await this.richTextPopulateSources([raw], readCtx);
329
- await applyAfterRead({
330
- doc: raw,
331
- definition: this.definition,
332
- readContext: readCtx,
333
- });
328
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
334
329
  return this.shapeWithPopulated(raw);
335
330
  }
336
331
  /**
@@ -339,12 +334,12 @@ export class CollectionHandle {
339
334
  * to `null` rather than throwing).
340
335
  */
341
336
  async findByPath(path, options = {}) {
342
- const requestContext = await this.resolveAndAssertRead();
343
- const collectionId = await this.client.resolveCollectionId(this.definition.path);
344
- const { locale = this.client.defaultLocale } = options;
345
337
  const readMode = resolveReadMode(options.status);
346
338
  const readCtx = options._readContext ?? createReadContext();
339
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
340
+ const { locale = this.client.defaultLocale } = options;
347
341
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
342
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
348
343
  const raw = await this.client.db.queries.documents.getDocumentByPath({
349
344
  collection_id: collectionId,
350
345
  path,
@@ -363,12 +358,7 @@ export class CollectionHandle {
363
358
  ...options,
364
359
  _readContext: readCtx,
365
360
  });
366
- await this.richTextPopulateSources([raw], readCtx);
367
- await applyAfterRead({
368
- doc: raw,
369
- definition: this.definition,
370
- readContext: readCtx,
371
- });
361
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
372
362
  return this.shapeWithPopulated(raw);
373
363
  }
374
364
  // -------------------------------------------------------------------------
@@ -460,8 +450,10 @@ export class CollectionHandle {
460
450
  return deleteDocument(ctx, { documentId });
461
451
  }
462
452
  /**
463
- * Count documents visible to the current actor, optionally filtered by
464
- * status.
453
+ * Sum the current-version workflow status buckets visible to the actor,
454
+ * optionally selecting one exact status. This is an editorial status-count
455
+ * API, not an ordinary published-view document count; it therefore
456
+ * authorizes as `readMode: 'any'` and rejects anonymous callers.
465
457
  *
466
458
  * Applies the collection's `beforeRead` predicate so the count reflects
467
459
  * only the rows the actor can see (multi-tenant scoping, owner-only
@@ -479,13 +471,15 @@ export class CollectionHandle {
479
471
  }
480
472
  /**
481
473
  * Per-status document counts for this collection. Used by admin status
482
- * bars / dashboards. Applies `beforeRead` so per-status counts reflect
474
+ * bars / dashboards. This always reads current versions across statuses,
475
+ * authorizes as `readMode: 'any'`, and applies `beforeRead` so counts reflect
483
476
  * only the actor's visible rows.
484
477
  */
485
478
  async countByStatus(options = {}) {
486
- const requestContext = await this.resolveAndAssertRead();
479
+ const readCtx = createReadContext();
480
+ const requestContext = await this.resolveAndAssertRead('any', readCtx);
481
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
487
482
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
488
- const filters = await this.resolveBeforeReadFilters(requestContext, createReadContext(), options._bypassBeforeRead);
489
483
  return this.client.db.queries.documents.getDocumentCountsByStatus({
490
484
  collection_id: collectionId,
491
485
  filters,
@@ -493,43 +487,21 @@ export class CollectionHandle {
493
487
  }
494
488
  /**
495
489
  * Fetch the version history for a single document. Applies `beforeRead`
496
- * as an access gate via `findById` — if the actor can't see the
497
- * document at all, history returns an empty result rather than
498
- * leaking version metadata.
490
+ * independently to every immutable version before pagination and counting,
491
+ * so ownership changes cannot expose hidden version content or metadata.
499
492
  *
500
493
  * Each version in the response is a shaped `ClientDocument`. Pagination
501
494
  * mirrors the storage adapter's `{ documents, meta }` shape, then is
502
495
  * mapped to the same `{ docs, meta }` envelope `find()` returns.
503
496
  */
504
497
  async history(documentId, options = {}) {
505
- const _requestContext = await this.resolveAndAssertRead();
506
- const collectionId = await this.client.resolveCollectionId(this.definition.path);
507
498
  const readCtx = options._readContext ?? createReadContext();
499
+ const requestContext = await this.resolveAndAssertRead('any', readCtx);
508
500
  const locale = options.locale ?? this.client.defaultLocale;
509
501
  const page = options.page ?? 1;
510
502
  const pageSize = options.pageSize ?? 20;
511
- // Access gate. `findById` runs `beforeRead`; a `null` here means either
512
- // the document does not exist or the actor's predicate excludes it. In
513
- // both cases an empty history is the correct response.
514
- //
515
- // `status: 'any'` so the gate asks "can the actor see *any* version of
516
- // this document?" rather than the client's default "is there a
517
- // published version they can see?". A draft-only document with the
518
- // owning actor should still surface history; using the published-only
519
- // default would gate them out incorrectly.
520
- if (!options._bypassBeforeRead) {
521
- const accessible = await this.findById(documentId, {
522
- locale,
523
- status: 'any',
524
- _readContext: readCtx,
525
- });
526
- if (accessible == null) {
527
- return {
528
- docs: [],
529
- meta: { total: 0, page, pageSize, totalPages: 0 },
530
- };
531
- }
532
- }
503
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
504
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
533
505
  const result = await this.client.db.queries.documents.getDocumentHistory({
534
506
  collection_id: collectionId,
535
507
  document_id: documentId,
@@ -538,7 +510,9 @@ export class CollectionHandle {
538
510
  page_size: pageSize,
539
511
  order: options.order,
540
512
  desc: options.desc,
513
+ filters,
541
514
  });
515
+ await this.finishReadDocuments(result.documents, readCtx, requestContext, locale, 'any', options._bypassBeforeRead, undefined, 'historical-version');
542
516
  return {
543
517
  docs: result.documents.map((d) => this.shapeWithPopulated(d)),
544
518
  meta: {
@@ -555,9 +529,9 @@ export class CollectionHandle {
555
529
  * available-locales), in-place status transitions, and the deletion event
556
530
  * the immutable version stream deliberately does not record an actor for.
557
531
  *
558
- * Applies `beforeRead` as an access gate via `findById` — exactly as
559
- * `history()` does — so an actor who cannot see the document at all gets an
560
- * empty log rather than leaking change metadata. Entries are newest-first.
532
+ * Applies `beforeRead` as a current-document access gate via `findById`, so
533
+ * an actor who cannot see the document at all gets an empty log rather than
534
+ * leaking change metadata. Entries are newest-first.
561
535
  * Actor *ids* are returned raw; resolving them to display labels is an
562
536
  * admin-realm concern handled above the SDK.
563
537
  *
@@ -565,8 +539,8 @@ export class CollectionHandle {
565
539
  * (`queries.audit` absent) — the same graceful shape as a gated-out read.
566
540
  */
567
541
  async auditLog(documentId, options = {}) {
568
- await this.resolveAndAssertRead();
569
542
  const readCtx = options._readContext ?? createReadContext();
543
+ await this.resolveAndAssertRead('any', readCtx);
570
544
  const locale = options.locale ?? this.client.defaultLocale;
571
545
  const page = options.page ?? 1;
572
546
  const pageSize = options.pageSize ?? 20;
@@ -596,22 +570,29 @@ export class CollectionHandle {
596
570
  * Fetch a specific version of a document by its `documentVersionId`.
597
571
  * Used by admin diff views.
598
572
  *
599
- * Pass-through to `getDocumentByVersion`; access enforcement falls back
600
- * to the collection-level `read` ability (asserted at the top of every
601
- * read entry point). Row-level `beforeRead` does **not** apply here —
602
- * version-by-id is a history-viewing primitive and the caller is
603
- * expected to have already passed an access check on the parent
604
- * document. Use `history()` instead if you want the access gate.
573
+ * The adapter query is constrained to this handle's collection and applies
574
+ * the collection's strict `beforeRead` predicate directly to the historical
575
+ * version. Unknown, cross-collection, and row-scoped version ids all return
576
+ * `null` without revealing ownership.
605
577
  */
606
578
  async findByVersion(versionId, options = {}) {
607
- await this.resolveAndAssertRead();
579
+ const readCtx = options._readContext ?? createReadContext();
580
+ const requestContext = await this.resolveAndAssertRead('any', readCtx);
581
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
582
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
608
583
  const locale = options.locale ?? this.client.defaultLocale;
609
584
  const raw = await this.client.db.queries.documents.getDocumentByVersion({
610
585
  document_version_id: versionId,
611
586
  locale,
587
+ collection_id: collectionId,
588
+ filters,
612
589
  });
613
590
  if (raw == null)
614
591
  return null;
592
+ if (options.select?.length) {
593
+ trimFields(raw, options.select);
594
+ }
595
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, 'any', options._bypassBeforeRead, options.select, 'historical-version');
615
596
  return this.shapeWithPopulated(raw);
616
597
  }
617
598
  // -------------------------------------------------------------------------
@@ -639,6 +620,7 @@ export class CollectionHandle {
639
620
  parentDocumentId: options.parentDocumentId,
640
621
  beforeDocumentId: options.beforeDocumentId ?? null,
641
622
  afterDocumentId: options.afterDocumentId ?? null,
623
+ reconcile: options.reconcile,
642
624
  });
643
625
  }
644
626
  /**
@@ -646,10 +628,10 @@ export class CollectionHandle {
646
628
  * in the collection, but not in any table of contents). Distinct from
647
629
  * deleting the document. No-op when already unplaced.
648
630
  */
649
- async removeFromTree(documentId) {
631
+ async removeFromTree(documentId, options = {}) {
650
632
  this.assertTreeCollection();
651
633
  const ctx = await this.buildLifecycleContext();
652
- await removeFromTreeLifecycle(ctx, { documentId });
634
+ await removeFromTreeLifecycle(ctx, { documentId, reconcile: options.reconcile });
653
635
  }
654
636
  /**
655
637
  * Read a node's subtree as a nested {@link TreeNode} forest, hydrated to
@@ -657,26 +639,32 @@ export class CollectionHandle {
657
639
  * from the collection roots; a value returns the subtree rooted at (and
658
640
  * including) that node. Children are ordered per-parent. `status` defaults to
659
641
  * `'published'` (the client default) — an unpublished node hides its whole
660
- * subtree in that mode.
642
+ * subtree in that mode. `beforeRead` visibility follows the same edge rule:
643
+ * a hidden node and its descendants are omitted, never promoted.
661
644
  */
662
645
  async getSubtree(options = {}) {
663
646
  this.assertTreeCollection();
664
- await this.resolveAndAssertRead();
665
- const collectionId = await this.client.resolveCollectionId(this.definition.path);
666
- const locale = options.locale ?? this.client.defaultLocale;
667
647
  const readMode = resolveReadMode(options.status);
648
+ const readCtx = options._readContext ?? createReadContext();
649
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
650
+ const locale = options.locale ?? this.client.defaultLocale;
651
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
652
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
668
653
  const structure = await this.client.db.queries.documents.getTreeSubtree({
669
654
  collectionId,
670
655
  rootDocumentId: options.rootDocumentId ?? null,
671
656
  maxDepth: options.depth,
672
657
  readMode,
658
+ locale,
659
+ filters,
673
660
  });
674
661
  if (structure.length === 0)
675
662
  return [];
676
- const shapedById = await this.hydrateTreeNodes(collectionId, structure.map((n) => n.document_id), locale, readMode, options.select);
663
+ const shapedById = await this.hydrateTreeNodes(collectionId, structure.map((n) => n.document_id), locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead);
677
664
  // Assemble the nested forest. Rows arrive pre-order, so a parent is always
678
665
  // materialised before its children; a row whose parent is not in the result
679
666
  // set (null parent, or the requested root's own parent) is a top-level node.
667
+ const structuralIds = new Set(structure.map((row) => row.document_id));
680
668
  const nodeById = new Map();
681
669
  const roots = [];
682
670
  for (const row of structure) {
@@ -686,10 +674,12 @@ export class CollectionHandle {
686
674
  const node = { document, depth: row.depth, children: [] };
687
675
  nodeById.set(row.document_id, node);
688
676
  const parent = row.parent_document_id != null ? nodeById.get(row.parent_document_id) : undefined;
689
- if (parent)
677
+ if (parent) {
690
678
  parent.children.push(node);
691
- else
679
+ }
680
+ else if (row.parent_document_id == null || !structuralIds.has(row.parent_document_id)) {
692
681
  roots.push(node);
682
+ }
693
683
  }
694
684
  return roots;
695
685
  }
@@ -699,36 +689,71 @@ export class CollectionHandle {
699
689
  * to `ClientDocument`s. In `'published'` mode (the client default) the walk
700
690
  * applies status-at-edge: it stops at the first unpublished ancestor (a
701
691
  * truncated chain), so a broken spine surfaces as a short chain the caller can
702
- * detect rather than a silently-compacted one.
692
+ * detect rather than a silently-compacted one. A `beforeRead`-hidden ancestor
693
+ * breaks the edge identically.
703
694
  */
704
695
  async getAncestors(documentId, options = {}) {
705
696
  this.assertTreeCollection();
706
- await this.resolveAndAssertRead();
707
- const collectionId = await this.client.resolveCollectionId(this.definition.path);
708
- const locale = options.locale ?? this.client.defaultLocale;
709
697
  const readMode = resolveReadMode(options.status);
698
+ const readCtx = options._readContext ?? createReadContext();
699
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
700
+ const locale = options.locale ?? this.client.defaultLocale;
701
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
702
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
710
703
  const ancestors = await this.client.db.queries.documents.getTreeAncestors({
711
704
  document_id: documentId,
712
705
  readMode,
706
+ locale,
707
+ filters,
713
708
  });
714
709
  if (ancestors.length === 0)
715
710
  return [];
716
711
  const ids = ancestors.map((a) => a.document_id);
717
- const shapedById = await this.hydrateTreeNodes(collectionId, ids, locale, readMode, options.select);
718
- // Preserve root-first order; drop ancestors not visible in this read mode.
719
- return ids.map((id) => shapedById.get(id)).filter((d) => d != null);
712
+ const shapedById = await this.hydrateTreeNodes(collectionId, ids, locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead);
713
+ // A hydration-time miss truncates the edge rather than compacting past a
714
+ // newly-hidden ancestor.
715
+ const visible = [];
716
+ for (let i = ids.length - 1; i >= 0; i--) {
717
+ const id = ids[i];
718
+ if (id == null)
719
+ break;
720
+ const document = shapedById.get(id);
721
+ if (document == null)
722
+ break;
723
+ visible.unshift(document);
724
+ }
725
+ return visible;
720
726
  }
721
727
  /**
722
728
  * Resolve a document's placement state in the tree — the tri-state that
723
729
  * `getAncestors` cannot express (it returns `[]` for both a root and an
724
730
  * unplaced node). Returns `{ placed, parentDocumentId }`: `placed: false` is
725
731
  * *unplaced* (no edge row), `placed: true` with a null parent is a *root*, and
726
- * a non-null parent is a *child*. Structure-only — no document hydration.
732
+ * a non-null parent is a *child*. A hidden queried node reports unplaced; a
733
+ * visible child of a hidden parent remains placed but the parent id is
734
+ * redacted to null. Structure-only — no document hydration.
727
735
  */
728
- async getTreeParent(documentId) {
736
+ async getTreeParent(documentId, options = {}) {
729
737
  this.assertTreeCollection();
730
- await this.resolveAndAssertRead();
731
- return this.client.db.queries.documents.getTreeParent({ document_id: documentId });
738
+ const readMode = resolveReadMode(options.status);
739
+ const readCtx = options._readContext ?? createReadContext();
740
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
741
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
742
+ const result = await this.client.db.queries.documents.getTreeParent({
743
+ document_id: documentId,
744
+ readMode,
745
+ locale: options.locale ?? this.client.defaultLocale,
746
+ filters,
747
+ });
748
+ return {
749
+ placed: result.placed,
750
+ parentDocumentId: result.parentDocumentId,
751
+ parentVisibility: result.parentRedacted
752
+ ? 'redacted'
753
+ : result.parentDocumentId != null
754
+ ? 'visible'
755
+ : 'none',
756
+ };
732
757
  }
733
758
  // -------------------------------------------------------------------------
734
759
  // Internals
@@ -761,8 +786,8 @@ export class CollectionHandle {
761
786
  * Returns the resolved context so callers can thread `readMode` and
762
787
  * other per-request state into the adapter without re-resolving.
763
788
  */
764
- async resolveAndAssertRead() {
765
- const requestContext = await this.client.resolveRequestContext();
789
+ async resolveAndAssertRead(readMode, readContext) {
790
+ const requestContext = await resolveReadRequestContext(this.client, readContext, readMode);
766
791
  assertActorCanPerform(requestContext, this.definition.path, 'read');
767
792
  return requestContext;
768
793
  }
@@ -784,12 +809,12 @@ export class CollectionHandle {
784
809
  * Batch-hydrate a set of tree node document ids into shaped
785
810
  * `ClientDocument`s, keyed by document id. Reuses the populate batch read
786
811
  * (`getDocumentsByDocumentIds`) under the requested `readMode` — the tree's
787
- * own status-at-edge filtering already happened structurally, so a node
788
- * absent here in `'published'` mode simply has no published version. Fires
812
+ * own status/beforeRead edge filtering already happened structurally, and the
813
+ * same filters are applied again to close the structure/hydration race. Fires
789
814
  * `afterRead` per node (deduped within one shared `ReadContext`) to stay
790
815
  * consistent with the other client read paths.
791
816
  */
792
- async hydrateTreeNodes(collectionId, documentIds, locale, readMode, select) {
817
+ async hydrateTreeNodes(collectionId, documentIds, locale, readMode, select, filters, readContext, requestContext, bypassBeforeRead) {
793
818
  const shapedById = new Map();
794
819
  if (documentIds.length === 0)
795
820
  return shapedById;
@@ -799,11 +824,11 @@ export class CollectionHandle {
799
824
  locale,
800
825
  readMode,
801
826
  fields: select,
827
+ filters,
802
828
  });
803
- const readContext = createReadContext();
829
+ await this.finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, select, 'tree');
804
830
  for (const raw of rawDocs) {
805
831
  const doc = raw;
806
- await applyAfterRead({ doc, definition: this.definition, readContext });
807
832
  shapedById.set(doc.document_id, this.shapeWithPopulated(doc));
808
833
  }
809
834
  return shapedById;
@@ -831,6 +856,7 @@ export class CollectionHandle {
831
856
  readMode,
832
857
  readContext: options._readContext,
833
858
  requestContext,
859
+ securityDomain: getReadSecurityDomain(this.client),
834
860
  bypassBeforeRead: options._bypassBeforeRead,
835
861
  richTextPopulate: this.client.richTextPopulate,
836
862
  });
@@ -844,7 +870,7 @@ export class CollectionHandle {
844
870
  * `populateRelationsOnRead` inside the service. No-ops when no adapter
845
871
  * is registered.
846
872
  */
847
- async richTextPopulateSources(rawDocs, readContext) {
873
+ async richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead) {
848
874
  const populate = this.client.richTextPopulate;
849
875
  if (!populate || rawDocs.length === 0)
850
876
  return;
@@ -854,38 +880,45 @@ export class CollectionHandle {
854
880
  documents: rawDocs,
855
881
  populate,
856
882
  readContext,
857
- });
858
- }
859
- /**
860
- * Resolve and cache the `beforeRead` hook predicate for this collection
861
- * inside the current `ReadContext`. Honours `_bypassBeforeRead` (admin
862
- * tooling, seeds, migrations).
863
- */
864
- async resolveBeforeReadPredicate(requestContext, readContext, bypass) {
865
- if (bypass)
866
- return null;
867
- return applyBeforeRead({
868
- definition: this.definition,
869
883
  requestContext,
870
- readContext,
884
+ readMode,
885
+ readDocuments: createRichTextDocumentReader({
886
+ db: this.client.db,
887
+ collections: this.client.collections,
888
+ requestContext,
889
+ readContext,
890
+ readMode,
891
+ locale,
892
+ bypassBeforeRead,
893
+ richTextPopulate: populate,
894
+ securityDomain: getReadSecurityDomain(this.client),
895
+ }),
871
896
  });
872
897
  }
898
+ /** Complete the common post-reconstruction pipeline in security order. */
899
+ async finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, projection, materialization) {
900
+ // Rich-text targets must be authorised and refreshed before user hooks see
901
+ // the document, regardless of which read entry point materialised it.
902
+ await this.richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead);
903
+ for (const doc of rawDocs) {
904
+ await applyAfterRead({
905
+ doc,
906
+ definition: this.definition,
907
+ readContext,
908
+ requestContext,
909
+ locale,
910
+ readMode,
911
+ projection,
912
+ materialization,
913
+ });
914
+ }
915
+ }
873
916
  /**
874
- * Like `resolveBeforeReadPredicate`, but parses the predicate to the
875
- * adapter-facing `DocumentFilter[]` shape used by `getDocumentById` /
876
- * `getDocumentByPath`. Returns `undefined` (not `[]`) when there is no
877
- * scoping, so the adapter can skip the loop entirely.
917
+ * Resolve the cached strict security filters used by every read shape.
918
+ * Returns `undefined` when no scoping applies.
878
919
  */
879
920
  async resolveBeforeReadFilters(requestContext, readContext, bypass) {
880
- const predicate = await this.resolveBeforeReadPredicate(requestContext, readContext, bypass);
881
- if (predicate == null)
882
- return undefined;
883
- const parsed = await parseWhere(predicate, this.definition, {
884
- collections: this.client.collections,
885
- resolveCollectionId: (path) => this.client.resolveCollectionId(path),
886
- logger: this.client.logger,
887
- });
888
- return parsed.filters.length > 0 ? parsed.filters : undefined;
921
+ return resolveReadSecurityFilters(this.client, this.definition, requestContext, readContext, bypass);
889
922
  }
890
923
  /**
891
924
  * Shape a raw doc to `ClientDocument<F>`, then recursively shape any
@@ -911,6 +944,11 @@ export class CollectionHandle {
911
944
  function resolveReadMode(status) {
912
945
  return status ?? 'published';
913
946
  }
947
+ function readMaterialization(populate, depth) {
948
+ if (populate === undefined)
949
+ return 'document';
950
+ return `populate:${depth ?? 1}:${typeof populate === 'object' ? JSON.stringify(populate) : populate}`;
951
+ }
914
952
  /** Mutate `raw.fields` to retain only the entries matching `select`. */
915
953
  function trimFields(raw, select) {
916
954
  const fields = raw.fields;
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, 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';
11
+ export type { AuditLogOptions, BylineClientConfig, ClientDocument, ClientSearchResults, CollectionSearchOptions, CreateOptions, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, GetTreeParentOptions, HydratedSearchHit, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, RemoveFromTreeOptions, SearchFacetBucket, SearchHit, SearchResults, SortDirection, SortSpec, TreeNode, TreeParentResult, UpdateOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, ZoneSearchOptions, } from './types.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Bind one authenticated request identity to a logical ReadContext without
3
+ * putting forgeable auth state on the public ReadContext shape. Each operation
4
+ * gets an immutable clone carrying its effective read mode.
5
+ */
6
+ import type { RequestContext } from '@byline/auth';
7
+ import type { CollectionDefinition, DocumentFilter, ReadContext, ReadMode } from '@byline/core';
8
+ import type { BylineClient } from './client.js';
9
+ export declare function getReadSecurityDomain(client: BylineClient<any>): object;
10
+ export declare function resolveReadRequestContext(client: BylineClient<any>, readContext: ReadContext, readMode: ReadMode, supplied?: RequestContext): Promise<RequestContext>;
11
+ export declare function resolveReadSecurityFilters(client: BylineClient<any>, definition: CollectionDefinition, requestContext: RequestContext, readContext: ReadContext, bypass?: true): Promise<DocumentFilter[] | undefined>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Bind one authenticated request identity to a logical ReadContext without
3
+ * putting forgeable auth state on the public ReadContext shape. Each operation
4
+ * gets an immutable clone carrying its effective read mode.
5
+ */
6
+ import { bindReadContextAuthority, compileBeforeReadFilters } from '@byline/core';
7
+ const readSecurityDomains = new WeakMap();
8
+ export function getReadSecurityDomain(client) {
9
+ let domain = readSecurityDomains.get(client);
10
+ if (!domain) {
11
+ domain = {};
12
+ readSecurityDomains.set(client, domain);
13
+ }
14
+ return domain;
15
+ }
16
+ export async function resolveReadRequestContext(client, readContext, readMode, supplied) {
17
+ const candidate = supplied ?? (await client.resolveRequestContext());
18
+ const base = Object.freeze({ ...candidate });
19
+ bindReadContextAuthority(readContext, base);
20
+ return { ...base, readMode };
21
+ }
22
+ export function resolveReadSecurityFilters(client, definition, requestContext, readContext, bypass) {
23
+ if (bypass)
24
+ return Promise.resolve(undefined);
25
+ return compileBeforeReadFilters({
26
+ definition,
27
+ requestContext,
28
+ readContext,
29
+ securityDomain: getReadSecurityDomain(client),
30
+ parseContext: {
31
+ collections: client.collections,
32
+ resolveCollectionId: (path) => client.resolveCollectionId(path),
33
+ logger: client.logger,
34
+ },
35
+ });
36
+ }
package/dist/search.js CHANGED
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { applyBeforeRead, assertActorCanPerform, createReadContext, ERR_VALIDATION, getCollectionAdminConfig, resolveItemViewColumns, resolveSearchZones, } from '@byline/core';
8
+ import { assertActorCanPerform, createReadContext, ERR_VALIDATION, getCollectionAdminConfig, resolveItemViewColumns, resolveSearchZones, } from '@byline/core';
9
+ import { resolveReadRequestContext, resolveReadSecurityFilters } from './read-context.js';
9
10
  /**
10
11
  * Authorise (and optionally hydrate) provider hits, per collection:
11
12
  *
@@ -75,8 +76,8 @@ export async function finalizeSearchHits(params) {
75
76
  allowedByCollection.set(collectionPath, null);
76
77
  continue;
77
78
  }
78
- const predicate = await applyBeforeRead({ definition, requestContext, readContext: readCtx });
79
- if (predicate == null) {
79
+ const securityFilters = await resolveReadSecurityFilters(client, definition, requestContext, readCtx);
80
+ if (securityFilters == null) {
80
81
  allowedByCollection.set(collectionPath, null);
81
82
  continue;
82
83
  }
@@ -143,7 +144,9 @@ export async function zoneSearch(client, options) {
143
144
  }
144
145
  // Per-member read gate: exclude collections the actor can't read; only
145
146
  // when *none* are readable does the ability error surface.
146
- const requestContext = await client.resolveRequestContext();
147
+ const readMode = options.status ?? 'published';
148
+ const readCtx = createReadContext();
149
+ const requestContext = await resolveReadRequestContext(client, readCtx, readMode);
147
150
  const readable = new Set();
148
151
  let firstAbilityError;
149
152
  for (const member of members) {
@@ -159,11 +162,22 @@ export async function zoneSearch(client, options) {
159
162
  if (readable.size === 0) {
160
163
  throw firstAbilityError;
161
164
  }
165
+ let aggregateRestricted = readable.size !== members.length;
166
+ if (!options._bypassBeforeRead) {
167
+ for (const member of members) {
168
+ if (!readable.has(member.path))
169
+ continue;
170
+ const securityFilters = await resolveReadSecurityFilters(client, member, requestContext, readCtx);
171
+ if (securityFilters == null)
172
+ continue;
173
+ aggregateRestricted = true;
174
+ }
175
+ }
162
176
  const results = await provider.search({
163
177
  query: options.query,
164
178
  zone: options.zone,
165
179
  locale: options.locale ?? client.defaultLocale,
166
- status: options.status === 'any' ? 'any' : 'published',
180
+ status: readMode,
167
181
  where: options.where,
168
182
  facets: options.facets,
169
183
  limit: options.limit,
@@ -179,6 +193,9 @@ export async function zoneSearch(client, options) {
179
193
  status: options.status,
180
194
  hydrate: options.hydrate,
181
195
  bypassBeforeRead: options._bypassBeforeRead,
196
+ readContext: readCtx,
182
197
  });
183
- return { hits, total: results.total, facets: results.facets };
198
+ return aggregateRestricted
199
+ ? { hits, total: hits.length }
200
+ : { hits, total: results.total, facets: results.facets };
184
201
  }
package/dist/types.d.ts CHANGED
@@ -97,9 +97,18 @@ export interface BylineClientConfig {
97
97
  * - a static `RequestContext` — convenient for long-lived processes
98
98
  * that authenticate once (seeds, migrations, CLI tooling); or
99
99
  * - a factory `() => RequestContext | Promise<RequestContext>` —
100
- * resolved per-call so each operation picks up the current
101
- * authenticated request (the pattern Phase 5's admin webapp will
102
- * use to thread middleware-derived actors).
100
+ * invoked per operation so each one picks up the current
101
+ * authenticated request (the pattern the admin webapp uses to
102
+ * thread middleware-derived actors).
103
+ *
104
+ * **Factory contract — request-stable resolution.** Within one logical
105
+ * request the factory must return the same context instance (same
106
+ * `requestId`) on every call. Reads that share a `ReadContext` bind an
107
+ * immutable request authority whose token includes `requestId`, so a
108
+ * factory that mints a fresh context per call makes the second such
109
+ * read throw `ERR_VALIDATION` ('ReadContext cannot be reused across
110
+ * request authorities'). Host adapters should memoize resolution per
111
+ * request — see `oncePerRequest` in `@byline/host-tanstack-start`.
103
112
  *
104
113
  * When omitted, calls fail closed with `ERR_UNAUTHENTICATED`. Scripts
105
114
  * and tests pass `createSuperAdminContext()` from `@byline/auth`.
@@ -112,12 +121,10 @@ export interface BylineClientConfig {
112
121
  * documents; `depth` caps the traversal (default 1 when populate is set,
113
122
  * 0 otherwise). Clamped to the request's internal `ReadContext.maxDepth`.
114
123
  */
115
- interface PopulateControls {
116
- populate?: PopulateSpec;
117
- depth?: number;
124
+ interface ReadContextControls {
118
125
  /**
119
126
  * Escape hatch for read-side hook re-entry. Carries the visited set,
120
- * depth clamp, and `afterReadFired` set across nested reads so the
127
+ * depth clamp, and `afterRead` recursion/object state across nested reads so the
121
128
  * A→B→A recursion guard stays intact.
122
129
  *
123
130
  * Threading rules:
@@ -138,6 +145,10 @@ interface PopulateControls {
138
145
  */
139
146
  _readContext?: ReadContext;
140
147
  }
148
+ interface PopulateControls extends ReadContextControls {
149
+ populate?: PopulateSpec;
150
+ depth?: number;
151
+ }
141
152
  /**
142
153
  * Read-mode selector shared by every read method.
143
154
  *
@@ -227,9 +238,9 @@ export interface HydratedSearchHit extends SearchHit {
227
238
  }
228
239
  /**
229
240
  * 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.
241
+ * widened to carry an optional hydrated document. When authorization removes
242
+ * collections or applies row predicates, `total` is conservatively reduced to
243
+ * the authorized hits in this page and facets are omitted.
233
244
  */
234
245
  export interface ClientSearchResults {
235
246
  hits: HydratedSearchHit[];
@@ -324,8 +335,8 @@ export interface FindByPathOptions<F = Record<string, any>> extends PopulateCont
324
335
  /**
325
336
  * Options for `CollectionHandle.history(documentId, options)`. The history
326
337
  * endpoint is paginated; `order` / `desc` mirror the storage adapter's
327
- * version-row sort axes. `_bypassBeforeRead` skips the `findById` access
328
- * gate for admin tooling.
338
+ * version-row sort axes. `_bypassBeforeRead` skips per-version row scoping
339
+ * for admin tooling.
329
340
  */
330
341
  export interface HistoryOptions extends BeforeReadControls {
331
342
  locale?: string;
@@ -353,12 +364,11 @@ export interface AuditLogOptions extends BeforeReadControls {
353
364
  _readContext?: ReadContext;
354
365
  }
355
366
  /**
356
- * Options for `CollectionHandle.findByVersion(versionId, options)`. No
357
- * `BeforeReadControls` — `findByVersion` is a low-level pass-through
358
- * intended for admin diff views; row-level scoping is the caller's
359
- * responsibility (typically by gating with a prior `findById` call).
367
+ * Options for `CollectionHandle.findByVersion(versionId, options)`. Historical
368
+ * reads are collection-bound and apply the same `beforeRead` predicate as
369
+ * current reads.
360
370
  */
361
- export interface FindByVersionOptions<F = Record<string, any>> {
371
+ export interface FindByVersionOptions<F = Record<string, any>> extends BeforeReadControls, ReadContextControls {
362
372
  select?: (keyof F & string)[] | string[];
363
373
  locale?: string;
364
374
  }
@@ -418,6 +428,13 @@ export interface PlaceTreeNodeOptions {
418
428
  beforeDocumentId?: string | null;
419
429
  /** Right neighbour — the node lands immediately before it. */
420
430
  afterDocumentId?: string | null;
431
+ /** Re-run `afterTreeChange` when this exact placement is already current. */
432
+ reconcile?: boolean;
433
+ }
434
+ /** Options for an idempotent tree removal or post-commit hook retry. */
435
+ export interface RemoveFromTreeOptions {
436
+ /** Re-run `afterTreeChange` when the document is already unplaced. */
437
+ reconcile?: boolean;
421
438
  }
422
439
  /**
423
440
  * Options for `CollectionHandle.getSubtree(options)`. Reads a node's subtree as
@@ -426,8 +443,10 @@ export interface PlaceTreeNodeOptions {
426
443
  * at (and including) that node. `depth` bounds the descent (0-based; the root is
427
444
  * depth 0). `status` follows the usual published/any selector — in `'published'`
428
445
  * mode an unpublished node hides its entire subtree (the spine breaks).
446
+ * `beforeRead`-hidden nodes use the same edge semantics. `_bypassBeforeRead`
447
+ * is reserved for internal tooling.
429
448
  */
430
- export interface GetSubtreeOptions<F = Record<string, any>> extends StatusControls {
449
+ export interface GetSubtreeOptions<F = Record<string, any>> extends StatusControls, BeforeReadControls, ReadContextControls {
431
450
  rootDocumentId?: string | null;
432
451
  depth?: number;
433
452
  /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
@@ -439,14 +458,30 @@ export interface GetSubtreeOptions<F = Record<string, any>> extends StatusContro
439
458
  * Options for `CollectionHandle.getAncestors(documentId, options)`. Walks the
440
459
  * node's ancestor chain upward, returning the ancestors **root-first** (the
441
460
  * breadcrumb trail, excluding the node itself). In `'published'` mode only
442
- * ancestors with a published version are returned.
461
+ * ancestors with a published version are returned. The walk also stops at the
462
+ * first ancestor excluded by `beforeRead`.
443
463
  */
444
- export interface GetAncestorsOptions<F = Record<string, any>> extends StatusControls {
464
+ export interface GetAncestorsOptions<F = Record<string, any>> extends StatusControls, BeforeReadControls, ReadContextControls {
445
465
  /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
446
466
  locale?: string;
447
467
  /** Return only these fields on each ancestor. Omit for all fields. */
448
468
  select?: (keyof F & string)[] | string[];
449
469
  }
470
+ /**
471
+ * Options for `CollectionHandle.getTreeParent(documentId, options)`. A hidden
472
+ * queried node is indistinguishable from an unplaced node. When only the parent
473
+ * is hidden, placement remains true but its id is redacted to `null`.
474
+ */
475
+ export interface GetTreeParentOptions extends StatusControls, BeforeReadControls, ReadContextControls {
476
+ /** Locale used to evaluate localized `beforeRead` field predicates. */
477
+ locale?: string;
478
+ }
479
+ export interface TreeParentResult {
480
+ placed: boolean;
481
+ parentDocumentId: string | null;
482
+ /** Distinguishes a true root/unplaced node from a hidden parent id. */
483
+ parentVisibility: 'none' | 'visible' | 'redacted';
484
+ }
450
485
  /**
451
486
  * A node in a hydrated document tree. `document` is the fully-shaped
452
487
  * `ClientDocument`; `depth` is 0-based from the subtree root; `children` are the
@@ -598,16 +633,18 @@ export interface PopulatedRelation<T> {
598
633
  document?: ClientDocument<T>;
599
634
  }
600
635
  /**
601
- * Re-type one key of a schema-inferred fields type as a populated relation.
636
+ * Re-type one key of a generated fields type as a populated relation.
602
637
  * Use to overlay populate's per-call enrichment on top of the unpopulated
603
- * shape that `CollectionFieldData<typeof X>` derives from the schema.
638
+ * shape emitted for the configured collection.
604
639
  *
605
640
  * Homomorphic mapped form preserves the optionality of the underlying key —
606
641
  * an optional relation field stays optional after populate is layered on.
607
642
  *
608
643
  * ```ts
644
+ * import type { MediaFields, NewsCategoriesFields, NewsFields } from './byline/generated/collection-types.js'
645
+ *
609
646
  * type NewsPopulated = WithPopulated<
610
- * WithPopulated<NewsFields, 'category', NewsCategoryFields>,
647
+ * WithPopulated<NewsFields, 'category', NewsCategoriesFields>,
611
648
  * 'featureImage', MediaFields
612
649
  * >
613
650
  * client.collection('news').find<NewsPopulated>({ populate: { category: '*', featureImage: '*' } })
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.21.0",
5
+ "version": "4.0.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -37,8 +37,8 @@
37
37
  "dist"
38
38
  ],
39
39
  "dependencies": {
40
- "@byline/auth": "3.21.0",
41
- "@byline/core": "3.21.0"
40
+ "@byline/core": "4.0.0",
41
+ "@byline/auth": "4.0.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@biomejs/biome": "2.5.2",
@@ -51,8 +51,8 @@
51
51
  "tsx": "^4.23.0",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10",
54
- "@byline/db-postgres": "3.21.0",
55
- "@byline/search-postgres": "3.21.0"
54
+ "@byline/db-postgres": "4.0.0",
55
+ "@byline/search-postgres": "4.0.0"
56
56
  },
57
57
  "publishConfig": {
58
58
  "access": "public",