@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.
- package/dist/client.d.ts +6 -3
- package/dist/client.js +3 -1
- package/dist/collection-handle.d.ts +33 -38
- package/dist/collection-handle.js +175 -137
- package/dist/index.d.ts +2 -1
- package/dist/read-context.d.ts +11 -0
- package/dist/read-context.js +36 -0
- package/dist/register.d.ts +44 -0
- package/dist/register.js +8 -0
- package/dist/search.js +23 -6
- package/dist/server/admin-context.d.ts +50 -0
- package/dist/server/admin-context.js +125 -0
- package/dist/server/admin-context.test.node.d.ts +8 -0
- package/dist/server/admin-context.test.node.js +256 -0
- package/dist/server/browser.d.ts +8 -0
- package/dist/server/browser.js +18 -0
- package/dist/server/clients.d.ts +92 -0
- package/dist/server/clients.js +170 -0
- package/dist/server/index.d.ts +26 -0
- package/dist/server/index.js +26 -0
- package/dist/server/preview-cookies.d.ts +19 -0
- package/dist/server/preview-cookies.js +69 -0
- package/dist/server/request-context-contract.test.node.d.ts +8 -0
- package/dist/server/request-context-contract.test.node.js +114 -0
- package/dist/server/request-context-factories.test.node.d.ts +8 -0
- package/dist/server/request-context-factories.test.node.js +116 -0
- package/dist/server/request-scope.d.ts +17 -0
- package/dist/server/request-scope.js +50 -0
- package/dist/server/request-scope.test.node.d.ts +8 -0
- package/dist/server/request-scope.test.node.js +89 -0
- package/dist/server/session-cookies.d.ts +34 -0
- package/dist/server/session-cookies.js +95 -0
- package/dist/types.d.ts +60 -23
- package/package.json +12 -5
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import { applyAfterRead,
|
|
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
|
|
40
|
-
const
|
|
41
|
-
const
|
|
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
|
-
|
|
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`
|
|
130
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
497
|
-
*
|
|
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
|
-
|
|
512
|
-
|
|
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
|
|
559
|
-
*
|
|
560
|
-
*
|
|
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
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
719
|
-
|
|
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*.
|
|
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
|
-
|
|
731
|
-
|
|
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
|
|
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
|
|
788
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
875
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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>;
|