@byline/client 3.20.4 → 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.
- package/dist/client.d.ts +5 -5
- package/dist/collection-handle.d.ts +43 -48
- package/dist/collection-handle.js +175 -137
- package/dist/index.d.ts +1 -1
- package/dist/read-context.d.ts +11 -0
- package/dist/read-context.js +36 -0
- package/dist/search.d.ts +2 -2
- package/dist/search.js +23 -6
- package/dist/types.d.ts +61 -24
- package/package.json +5 -5
package/dist/client.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { type RequestContext } from '@byline/auth';
|
|
9
|
-
import type { BylineLogger, CollectionDefinition, IDbAdapter, IStorageProvider, RichTextPopulateFn, RichTextToTextFn, SearchProvider, SlugifierFn } from '@byline/core';
|
|
9
|
+
import type { BylineLogger, CollectionDefinition, CollectionRegistry, IDbAdapter, IStorageProvider, RichTextPopulateFn, RichTextToTextFn, SearchProvider, SlugifierFn } from '@byline/core';
|
|
10
10
|
import { CollectionHandle } from './collection-handle.js';
|
|
11
11
|
import type { BylineClientConfig, ClientSearchResults, ZoneSearchOptions } from './types.js';
|
|
12
12
|
/**
|
|
@@ -14,9 +14,9 @@ import type { BylineClientConfig, ClientSearchResults, ZoneSearchOptions } from
|
|
|
14
14
|
* definitions, and optional storage provider. Use `collection(path)` to get
|
|
15
15
|
* a scoped handle for querying and mutating documents.
|
|
16
16
|
*/
|
|
17
|
-
export declare class BylineClient {
|
|
17
|
+
export declare class BylineClient<TRegistry extends CollectionRegistry = CollectionRegistry> {
|
|
18
18
|
readonly db: IDbAdapter;
|
|
19
|
-
readonly collections: CollectionDefinition[];
|
|
19
|
+
readonly collections: readonly CollectionDefinition[];
|
|
20
20
|
readonly storage: IStorageProvider | undefined;
|
|
21
21
|
readonly logger: BylineLogger;
|
|
22
22
|
readonly defaultLocale: string;
|
|
@@ -54,7 +54,7 @@ export declare class BylineClient {
|
|
|
54
54
|
* Get a handle scoped to a single collection. All subsequent read/write
|
|
55
55
|
* operations on the handle are performed against this collection.
|
|
56
56
|
*/
|
|
57
|
-
collection(path:
|
|
57
|
+
collection<TPath extends keyof TRegistry & string>(path: TPath): CollectionHandle<TRegistry[TPath]>;
|
|
58
58
|
/**
|
|
59
59
|
* Resolve a collection path to its database row id and current schema
|
|
60
60
|
* version. Cached for the lifetime of this client instance. The version
|
|
@@ -73,4 +73,4 @@ export declare class BylineClient {
|
|
|
73
73
|
/**
|
|
74
74
|
* Create a new Byline client instance.
|
|
75
75
|
*/
|
|
76
|
-
export declare function createBylineClient(config: BylineClientConfig): BylineClient
|
|
76
|
+
export declare function createBylineClient<TRegistry extends CollectionRegistry = CollectionRegistry>(config: BylineClientConfig): BylineClient<TRegistry>;
|
|
@@ -7,17 +7,17 @@
|
|
|
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.
|
|
14
14
|
*
|
|
15
15
|
* Created via `client.collection('posts')`.
|
|
16
16
|
*/
|
|
17
|
-
export declare class CollectionHandle {
|
|
17
|
+
export declare class CollectionHandle<TFields extends Record<string, any> = Record<string, any>> {
|
|
18
18
|
private client;
|
|
19
19
|
private definition;
|
|
20
|
-
constructor(client: BylineClient
|
|
20
|
+
constructor(client: BylineClient<any>, definition: CollectionDefinition);
|
|
21
21
|
/**
|
|
22
22
|
* Find documents with optional filtering, sorting, pagination, and field
|
|
23
23
|
* selection.
|
|
@@ -27,12 +27,12 @@ export declare class CollectionHandle {
|
|
|
27
27
|
* (EXISTS subqueries against EAV store tables), and field-level sorting
|
|
28
28
|
* (LATERAL JOINs).
|
|
29
29
|
*/
|
|
30
|
-
find<F =
|
|
30
|
+
find<F = TFields>(options?: FindOptions<F>): Promise<FindResult<F>>;
|
|
31
31
|
/**
|
|
32
32
|
* Find a single document matching the given options. Returns `null` if no
|
|
33
33
|
* document matches.
|
|
34
34
|
*/
|
|
35
|
-
findOne<F =
|
|
35
|
+
findOne<F = TFields>(options?: FindOneOptions<F>): Promise<ClientDocument<F> | null>;
|
|
36
36
|
/**
|
|
37
37
|
* Ranked full-text search scoped to this collection, delegated to the
|
|
38
38
|
* registered `SearchProvider` (see `ServerConfig.search`). Returns the
|
|
@@ -55,8 +55,8 @@ export declare class CollectionHandle {
|
|
|
55
55
|
* comes free) and attaches them as `hit.document`. Two consequences to be
|
|
56
56
|
* aware of:
|
|
57
57
|
*
|
|
58
|
-
* - `total`
|
|
59
|
-
*
|
|
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
|
*
|
|
@@ -96,13 +96,13 @@ export declare class CollectionHandle {
|
|
|
96
96
|
/**
|
|
97
97
|
* Find a document by its logical document ID.
|
|
98
98
|
*/
|
|
99
|
-
findById<F =
|
|
99
|
+
findById<F = TFields>(documentId: string, options?: FindByIdOptions<F>): Promise<ClientDocument<F> | null>;
|
|
100
100
|
/**
|
|
101
101
|
* Find a document by its URL path/slug. Returns `null` when no document
|
|
102
102
|
* exists at the given path (the storage adapter resolves missing paths
|
|
103
103
|
* to `null` rather than throwing).
|
|
104
104
|
*/
|
|
105
|
-
findByPath<F =
|
|
105
|
+
findByPath<F = TFields>(path: string, options?: FindByPathOptions<F>): Promise<ClientDocument<F> | null>;
|
|
106
106
|
/**
|
|
107
107
|
* Create a new document in this collection.
|
|
108
108
|
*
|
|
@@ -150,8 +150,10 @@ export declare class CollectionHandle {
|
|
|
150
150
|
*/
|
|
151
151
|
delete(documentId: string): Promise<DeleteDocumentResult>;
|
|
152
152
|
/**
|
|
153
|
-
*
|
|
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 {
|
|
|
163
165
|
}): Promise<number>;
|
|
164
166
|
/**
|
|
165
167
|
* Per-status document counts for this collection. Used by admin status
|
|
166
|
-
* bars / dashboards.
|
|
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,24 +177,23 @@ export declare class CollectionHandle {
|
|
|
174
177
|
}>>;
|
|
175
178
|
/**
|
|
176
179
|
* Fetch the version history for a single document. Applies `beforeRead`
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
183
185
|
* mapped to the same `{ docs, meta }` envelope `find()` returns.
|
|
184
186
|
*/
|
|
185
|
-
history<F =
|
|
187
|
+
history<F = TFields>(documentId: string, options?: HistoryOptions): Promise<FindResult<F>>;
|
|
186
188
|
/**
|
|
187
189
|
* Fetch the document-grain audit log for a single document (docs/06-auth-and-security/02-auditability.md —
|
|
188
190
|
* Workstream 3): the non-versioned system-field writes (path,
|
|
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
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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,14 +205,12 @@ export declare class CollectionHandle {
|
|
|
203
205
|
* Fetch a specific version of a document by its `documentVersionId`.
|
|
204
206
|
* Used by admin diff views.
|
|
205
207
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
-
findByVersion<F =
|
|
213
|
+
findByVersion<F = TFields>(versionId: string, options?: FindByVersionOptions<F>): Promise<ClientDocument<F> | null>;
|
|
214
214
|
/**
|
|
215
215
|
* Place or move a document within this collection's tree — a single upsert
|
|
216
216
|
* covering place, reorder, and re-parent (they differ only in whether the
|
|
@@ -227,36 +227,37 @@ export declare class CollectionHandle {
|
|
|
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
|
-
getSubtree<F =
|
|
240
|
+
getSubtree<F = TFields>(options?: GetSubtreeOptions<F>): Promise<TreeNode<F>[]>;
|
|
240
241
|
/**
|
|
241
242
|
* Walk a document's ancestor chain upward, returning the ancestors
|
|
242
243
|
* **root-first** (the breadcrumb trail, excluding the node itself), hydrated
|
|
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
|
-
getAncestors<F =
|
|
250
|
+
getAncestors<F = TFields>(documentId: string, options?: GetAncestorsOptions<F>): Promise<ClientDocument<F>[]>;
|
|
249
251
|
/**
|
|
250
252
|
* Resolve a document's placement state in the tree — the tri-state that
|
|
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*.
|
|
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 {
|
|
|
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
|
|
287
|
-
*
|
|
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 {
|
|
|
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
|
|
314
|
-
*
|
|
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,
|
|
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,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.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { ReadContext, ReadMode, SearchHit } from '@byline/core';
|
|
|
21
21
|
import type { BylineClient } from './client.js';
|
|
22
22
|
import type { ClientSearchResults, HydratedSearchHit, ZoneSearchOptions } from './types.js';
|
|
23
23
|
export interface FinalizeSearchHitsParams {
|
|
24
|
-
client: BylineClient
|
|
24
|
+
client: BylineClient<any>;
|
|
25
25
|
requestContext: RequestContext;
|
|
26
26
|
hits: SearchHit[];
|
|
27
27
|
locale?: string;
|
|
@@ -57,4 +57,4 @@ export declare function finalizeSearchHits(params: FinalizeSearchHitsParams): Pr
|
|
|
57
57
|
* `ERR_VALIDATION` for a zone no collection indexes into; rethrows the
|
|
58
58
|
* ability error only when the actor can read none of the members.
|
|
59
59
|
*/
|
|
60
|
-
export declare function zoneSearch(client: BylineClient
|
|
60
|
+
export declare function zoneSearch(client: BylineClient<any>, options: ZoneSearchOptions): Promise<ClientSearchResults>;
|
package/dist/search.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
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
|
|
79
|
-
if (
|
|
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
|
|
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:
|
|
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
|
|
198
|
+
return aggregateRestricted
|
|
199
|
+
? { hits, total: hits.length }
|
|
200
|
+
: { hits, total: results.total, facets: results.facets };
|
|
184
201
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface BylineClientConfig {
|
|
|
31
31
|
/** The database adapter (e.g. from @byline/db-postgres). Required when `config` is omitted. */
|
|
32
32
|
db?: IDbAdapter;
|
|
33
33
|
/** All registered collection definitions. Required when `config` is omitted. */
|
|
34
|
-
collections?: CollectionDefinition[];
|
|
34
|
+
collections?: readonly CollectionDefinition[];
|
|
35
35
|
/** Optional storage provider — needed for delete file cleanup. */
|
|
36
36
|
storage?: IStorageProvider;
|
|
37
37
|
/**
|
|
@@ -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
|
-
*
|
|
101
|
-
* authenticated request (the pattern
|
|
102
|
-
*
|
|
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
|
|
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 `
|
|
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.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
328
|
-
*
|
|
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)`.
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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
|
|
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
|
|
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',
|
|
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": "
|
|
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/
|
|
41
|
-
"@byline/
|
|
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/
|
|
55
|
-
"@byline/
|
|
54
|
+
"@byline/db-postgres": "4.0.0",
|
|
55
|
+
"@byline/search-postgres": "4.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
58
58
|
"access": "public",
|