@byline/client 3.21.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/client.d.ts +6 -3
  2. package/dist/client.js +3 -1
  3. package/dist/collection-handle.d.ts +33 -38
  4. package/dist/collection-handle.js +175 -137
  5. package/dist/index.d.ts +2 -1
  6. package/dist/read-context.d.ts +11 -0
  7. package/dist/read-context.js +36 -0
  8. package/dist/register.d.ts +44 -0
  9. package/dist/register.js +8 -0
  10. package/dist/search.js +23 -6
  11. package/dist/server/admin-context.d.ts +50 -0
  12. package/dist/server/admin-context.js +125 -0
  13. package/dist/server/admin-context.test.node.d.ts +8 -0
  14. package/dist/server/admin-context.test.node.js +256 -0
  15. package/dist/server/browser.d.ts +8 -0
  16. package/dist/server/browser.js +18 -0
  17. package/dist/server/clients.d.ts +92 -0
  18. package/dist/server/clients.js +170 -0
  19. package/dist/server/index.d.ts +26 -0
  20. package/dist/server/index.js +26 -0
  21. package/dist/server/preview-cookies.d.ts +19 -0
  22. package/dist/server/preview-cookies.js +69 -0
  23. package/dist/server/request-context-contract.test.node.d.ts +8 -0
  24. package/dist/server/request-context-contract.test.node.js +114 -0
  25. package/dist/server/request-context-factories.test.node.d.ts +8 -0
  26. package/dist/server/request-context-factories.test.node.js +116 -0
  27. package/dist/server/request-scope.d.ts +17 -0
  28. package/dist/server/request-scope.js +50 -0
  29. package/dist/server/request-scope.test.node.d.ts +8 -0
  30. package/dist/server/request-scope.test.node.js +89 -0
  31. package/dist/server/session-cookies.d.ts +34 -0
  32. package/dist/server/session-cookies.js +95 -0
  33. package/dist/types.d.ts +60 -23
  34. package/package.json +12 -5
@@ -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,5 @@
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 { Register, RegisteredCollections } from './register.js';
12
+ 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>;