@byline/client 3.12.2 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { AuditLogPage, ChangeStatusResult, CollectionDefinition, CreateDocumentResult, DeleteDocumentResult, RestoreVersionResult, UnpublishResult, UpdateDocumentResult } from '@byline/core';
9
9
  import type { BylineClient } from './client.js';
10
- import type { AuditLogOptions, ClientDocument, CreateOptions, FindByIdOptions, FindByPathOptions, FindByVersionOptions, FindOneOptions, FindOptions, FindResult, HistoryOptions, UpdateOptions } from './types.js';
10
+ import type { AuditLogOptions, ClientDocument, CreateOptions, FindByIdOptions, FindByPathOptions, FindByVersionOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, HistoryOptions, PlaceTreeNodeOptions, TreeNode, UpdateOptions } from './types.js';
11
11
  /**
12
12
  * A handle scoped to a single collection. Provides read (and eventually write)
13
13
  * operations against that collection's documents.
@@ -151,6 +151,52 @@ export declare class CollectionHandle {
151
151
  * document. Use `history()` instead if you want the access gate.
152
152
  */
153
153
  findByVersion<F = Record<string, any>>(versionId: string, options?: FindByVersionOptions<F>): Promise<ClientDocument<F> | null>;
154
+ /**
155
+ * Place or move a document within this collection's tree — a single upsert
156
+ * covering place, reorder, and re-parent (they differ only in whether the
157
+ * parent changes). Document-grain and unversioned: mints no new document
158
+ * version and does not touch workflow status. The storage layer enforces the
159
+ * cycle and same-collection invariants atomically. Returns the minted
160
+ * per-parent `order_key`.
161
+ */
162
+ placeTreeNode(documentId: string, options: PlaceTreeNodeOptions): Promise<{
163
+ orderKey: string;
164
+ }>;
165
+ /**
166
+ * Remove a document from the tree, returning it to the *unplaced* state (still
167
+ * in the collection, but not in any table of contents). Distinct from
168
+ * deleting the document. No-op when already unplaced.
169
+ */
170
+ removeFromTree(documentId: string): Promise<void>;
171
+ /**
172
+ * Read a node's subtree as a nested {@link TreeNode} forest, hydrated to
173
+ * `ClientDocument`s. `rootDocumentId: null` (default) returns the whole tree
174
+ * from the collection roots; a value returns the subtree rooted at (and
175
+ * including) that node. Children are ordered per-parent. `status` defaults to
176
+ * `'published'` (the client default) — an unpublished node hides its whole
177
+ * subtree in that mode.
178
+ */
179
+ getSubtree<F = Record<string, any>>(options?: GetSubtreeOptions<F>): Promise<TreeNode<F>[]>;
180
+ /**
181
+ * Walk a document's ancestor chain upward, returning the ancestors
182
+ * **root-first** (the breadcrumb trail, excluding the node itself), hydrated
183
+ * to `ClientDocument`s. In `'published'` mode (the client default) the walk
184
+ * applies status-at-edge: it stops at the first unpublished ancestor (a
185
+ * truncated chain), so a broken spine surfaces as a short chain the caller can
186
+ * detect rather than a silently-compacted one.
187
+ */
188
+ getAncestors<F = Record<string, any>>(documentId: string, options?: GetAncestorsOptions<F>): Promise<ClientDocument<F>[]>;
189
+ /**
190
+ * Resolve a document's placement state in the tree — the tri-state that
191
+ * `getAncestors` cannot express (it returns `[]` for both a root and an
192
+ * unplaced node). Returns `{ placed, parentDocumentId }`: `placed: false` is
193
+ * *unplaced* (no edge row), `placed: true` with a null parent is a *root*, and
194
+ * a non-null parent is a *child*. Structure-only — no document hydration.
195
+ */
196
+ getTreeParent(documentId: string): Promise<{
197
+ placed: boolean;
198
+ parentDocumentId: string | null;
199
+ }>;
154
200
  /**
155
201
  * Build a fresh `DocumentLifecycleContext` for a write call. Pulls the
156
202
  * resolved collection id, inherits the client-wide logger and storage
@@ -166,6 +212,23 @@ export declare class CollectionHandle {
166
212
  * other per-request state into the adapter without re-resolving.
167
213
  */
168
214
  private resolveAndAssertRead;
215
+ /**
216
+ * Guard that this collection opts into the document-tree primitive. The
217
+ * tree commands are inert (and the read path meaningless) for a non-tree
218
+ * collection, so fail loudly rather than silently writing edge rows that
219
+ * nothing will read.
220
+ */
221
+ private assertTreeCollection;
222
+ /**
223
+ * Batch-hydrate a set of tree node document ids into shaped
224
+ * `ClientDocument`s, keyed by document id. Reuses the populate batch read
225
+ * (`getDocumentsByDocumentIds`) under the requested `readMode` — the tree's
226
+ * own status-at-edge filtering already happened structurally, so a node
227
+ * absent here in `'published'` mode simply has no published version. Fires
228
+ * `afterRead` per node (deduped within one shared `ReadContext`) to stay
229
+ * consistent with the other client read paths.
230
+ */
231
+ private hydrateTreeNodes;
169
232
  /**
170
233
  * Invoke `populateDocuments` on a freshly-read (raw, storage-shape) set
171
234
  * of documents when the caller asked for populate. No-op otherwise.
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { applyAfterRead, applyBeforeRead, assertActorCanPerform, changeDocumentStatus, createDocument, createReadContext, deleteDocument, mergePredicates, parseSort, parseWhere, populateDocuments, populateRichTextFields, restoreDocumentVersion, unpublishDocument, updateDocument, } from '@byline/core';
8
+ import { applyAfterRead, applyBeforeRead, assertActorCanPerform, changeDocumentStatus, createDocument, createReadContext, deleteDocument, ERR_VALIDATION, mergePredicates, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, restoreDocumentVersion, unpublishDocument, updateDocument, } from '@byline/core';
9
9
  import { shapeDocument, shapePopulatedInPlace } from './response.js';
10
10
  /**
11
11
  * A handle scoped to a single collection. Provides read (and eventually write)
@@ -424,6 +424,122 @@ export class CollectionHandle {
424
424
  return this.shapeWithPopulated(raw);
425
425
  }
426
426
  // -------------------------------------------------------------------------
427
+ // Document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md)
428
+ //
429
+ // A document-grain, unversioned single-parent ordered hierarchy. Reads/writes
430
+ // here go through the storage adapter's dedicated tree commands, NOT the
431
+ // store_relation / populate pipeline. Every method requires the collection to
432
+ // be a tree (`tree: true`); writes assert the `update` ability (structural
433
+ // moves are metadata-level updates), reads assert `read`.
434
+ // -------------------------------------------------------------------------
435
+ /**
436
+ * Place or move a document within this collection's tree — a single upsert
437
+ * covering place, reorder, and re-parent (they differ only in whether the
438
+ * parent changes). Document-grain and unversioned: mints no new document
439
+ * version and does not touch workflow status. The storage layer enforces the
440
+ * cycle and same-collection invariants atomically. Returns the minted
441
+ * per-parent `order_key`.
442
+ */
443
+ async placeTreeNode(documentId, options) {
444
+ this.assertTreeCollection();
445
+ const ctx = await this.buildLifecycleContext();
446
+ return placeTreeNodeLifecycle(ctx, {
447
+ documentId,
448
+ parentDocumentId: options.parentDocumentId,
449
+ beforeDocumentId: options.beforeDocumentId ?? null,
450
+ afterDocumentId: options.afterDocumentId ?? null,
451
+ });
452
+ }
453
+ /**
454
+ * Remove a document from the tree, returning it to the *unplaced* state (still
455
+ * in the collection, but not in any table of contents). Distinct from
456
+ * deleting the document. No-op when already unplaced.
457
+ */
458
+ async removeFromTree(documentId) {
459
+ this.assertTreeCollection();
460
+ const ctx = await this.buildLifecycleContext();
461
+ await removeFromTreeLifecycle(ctx, { documentId });
462
+ }
463
+ /**
464
+ * Read a node's subtree as a nested {@link TreeNode} forest, hydrated to
465
+ * `ClientDocument`s. `rootDocumentId: null` (default) returns the whole tree
466
+ * from the collection roots; a value returns the subtree rooted at (and
467
+ * including) that node. Children are ordered per-parent. `status` defaults to
468
+ * `'published'` (the client default) — an unpublished node hides its whole
469
+ * subtree in that mode.
470
+ */
471
+ async getSubtree(options = {}) {
472
+ this.assertTreeCollection();
473
+ await this.resolveAndAssertRead();
474
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
475
+ const locale = options.locale ?? this.client.defaultLocale;
476
+ const readMode = resolveReadMode(options.status);
477
+ const structure = await this.client.db.queries.documents.getTreeSubtree({
478
+ collectionId,
479
+ rootDocumentId: options.rootDocumentId ?? null,
480
+ maxDepth: options.depth,
481
+ readMode,
482
+ });
483
+ if (structure.length === 0)
484
+ return [];
485
+ const shapedById = await this.hydrateTreeNodes(collectionId, structure.map((n) => n.document_id), locale, readMode, options.select);
486
+ // Assemble the nested forest. Rows arrive pre-order, so a parent is always
487
+ // materialised before its children; a row whose parent is not in the result
488
+ // set (null parent, or the requested root's own parent) is a top-level node.
489
+ const nodeById = new Map();
490
+ const roots = [];
491
+ for (const row of structure) {
492
+ const document = shapedById.get(row.document_id);
493
+ if (document == null)
494
+ continue;
495
+ const node = { document, depth: row.depth, children: [] };
496
+ nodeById.set(row.document_id, node);
497
+ const parent = row.parent_document_id != null ? nodeById.get(row.parent_document_id) : undefined;
498
+ if (parent)
499
+ parent.children.push(node);
500
+ else
501
+ roots.push(node);
502
+ }
503
+ return roots;
504
+ }
505
+ /**
506
+ * Walk a document's ancestor chain upward, returning the ancestors
507
+ * **root-first** (the breadcrumb trail, excluding the node itself), hydrated
508
+ * to `ClientDocument`s. In `'published'` mode (the client default) the walk
509
+ * applies status-at-edge: it stops at the first unpublished ancestor (a
510
+ * truncated chain), so a broken spine surfaces as a short chain the caller can
511
+ * detect rather than a silently-compacted one.
512
+ */
513
+ async getAncestors(documentId, options = {}) {
514
+ this.assertTreeCollection();
515
+ await this.resolveAndAssertRead();
516
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
517
+ const locale = options.locale ?? this.client.defaultLocale;
518
+ const readMode = resolveReadMode(options.status);
519
+ const ancestors = await this.client.db.queries.documents.getTreeAncestors({
520
+ document_id: documentId,
521
+ readMode,
522
+ });
523
+ if (ancestors.length === 0)
524
+ return [];
525
+ const ids = ancestors.map((a) => a.document_id);
526
+ const shapedById = await this.hydrateTreeNodes(collectionId, ids, locale, readMode, options.select);
527
+ // Preserve root-first order; drop ancestors not visible in this read mode.
528
+ return ids.map((id) => shapedById.get(id)).filter((d) => d != null);
529
+ }
530
+ /**
531
+ * Resolve a document's placement state in the tree — the tri-state that
532
+ * `getAncestors` cannot express (it returns `[]` for both a root and an
533
+ * unplaced node). Returns `{ placed, parentDocumentId }`: `placed: false` is
534
+ * *unplaced* (no edge row), `placed: true` with a null parent is a *root*, and
535
+ * a non-null parent is a *child*. Structure-only — no document hydration.
536
+ */
537
+ async getTreeParent(documentId) {
538
+ this.assertTreeCollection();
539
+ await this.resolveAndAssertRead();
540
+ return this.client.db.queries.documents.getTreeParent({ document_id: documentId });
541
+ }
542
+ // -------------------------------------------------------------------------
427
543
  // Internals
428
544
  // -------------------------------------------------------------------------
429
545
  /**
@@ -459,6 +575,48 @@ export class CollectionHandle {
459
575
  assertActorCanPerform(requestContext, this.definition.path, 'read');
460
576
  return requestContext;
461
577
  }
578
+ /**
579
+ * Guard that this collection opts into the document-tree primitive. The
580
+ * tree commands are inert (and the read path meaningless) for a non-tree
581
+ * collection, so fail loudly rather than silently writing edge rows that
582
+ * nothing will read.
583
+ */
584
+ assertTreeCollection() {
585
+ if (this.definition.tree !== true) {
586
+ throw ERR_VALIDATION({
587
+ message: `collection '${this.definition.path}' is not a document tree; set \`tree: true\` on its collection definition to use the tree API`,
588
+ details: { collectionPath: this.definition.path },
589
+ });
590
+ }
591
+ }
592
+ /**
593
+ * Batch-hydrate a set of tree node document ids into shaped
594
+ * `ClientDocument`s, keyed by document id. Reuses the populate batch read
595
+ * (`getDocumentsByDocumentIds`) under the requested `readMode` — the tree's
596
+ * own status-at-edge filtering already happened structurally, so a node
597
+ * absent here in `'published'` mode simply has no published version. Fires
598
+ * `afterRead` per node (deduped within one shared `ReadContext`) to stay
599
+ * consistent with the other client read paths.
600
+ */
601
+ async hydrateTreeNodes(collectionId, documentIds, locale, readMode, select) {
602
+ const shapedById = new Map();
603
+ if (documentIds.length === 0)
604
+ return shapedById;
605
+ const rawDocs = await this.client.db.queries.documents.getDocumentsByDocumentIds({
606
+ collection_id: collectionId,
607
+ document_ids: documentIds,
608
+ locale,
609
+ readMode,
610
+ fields: select,
611
+ });
612
+ const readContext = createReadContext();
613
+ for (const raw of rawDocs) {
614
+ const doc = raw;
615
+ await applyAfterRead({ doc, definition: this.definition, readContext });
616
+ shapedById.set(doc.document_id, this.shapeWithPopulated(doc));
617
+ }
618
+ return shapedById;
619
+ }
462
620
  /**
463
621
  * Invoke `populateDocuments` on a freshly-read (raw, storage-shape) set
464
622
  * of documents when the caller asked for populate. No-op otherwise.
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, CreateOptions, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, PopulatedRelation, SortDirection, SortSpec, UpdateOptions, WhereClause, WhereValue, WithPopulated, } from './types.js';
11
+ export type { AuditLogOptions, BylineClientConfig, ClientDocument, CreateOptions, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, PlaceTreeNodeOptions, PopulatedRelation, SortDirection, SortSpec, TreeNode, UpdateOptions, WhereClause, WhereValue, WithPopulated, } from './types.js';
package/dist/types.d.ts CHANGED
@@ -293,6 +293,63 @@ export interface UpdateOptions {
293
293
  */
294
294
  availableLocales?: string[];
295
295
  }
296
+ /**
297
+ * Options for `CollectionHandle.placeTreeNode(documentId, options)`. Places or
298
+ * moves a node within the collection's single-parent ordered tree — one upsert
299
+ * covering place / reorder / re-parent.
300
+ *
301
+ * Neighbour semantics match `reorderCollectionDocument`: `beforeDocumentId` is
302
+ * the sibling the node should land immediately **after** (its left neighbour);
303
+ * `afterDocumentId` is the sibling it should land immediately **before** (its
304
+ * right neighbour). Both are resolved within the *target* parent group; either
305
+ * may be null/omitted (append as last child, or prepend before a given sibling).
306
+ */
307
+ export interface PlaceTreeNodeOptions {
308
+ /** The new parent; `null` makes the node a root. */
309
+ parentDocumentId: string | null;
310
+ /** Left neighbour — the node lands immediately after it. */
311
+ beforeDocumentId?: string | null;
312
+ /** Right neighbour — the node lands immediately before it. */
313
+ afterDocumentId?: string | null;
314
+ }
315
+ /**
316
+ * Options for `CollectionHandle.getSubtree(options)`. Reads a node's subtree as
317
+ * a nested {@link TreeNode} forest. `rootDocumentId: null` (the default) reads
318
+ * the whole tree from the collection's roots; a value reads the subtree rooted
319
+ * at (and including) that node. `depth` bounds the descent (0-based; the root is
320
+ * depth 0). `status` follows the usual published/any selector — in `'published'`
321
+ * mode an unpublished node hides its entire subtree (the spine breaks).
322
+ */
323
+ export interface GetSubtreeOptions<F = Record<string, any>> extends StatusControls {
324
+ rootDocumentId?: string | null;
325
+ depth?: number;
326
+ /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
327
+ locale?: string;
328
+ /** Return only these fields on each node. Omit for all fields. */
329
+ select?: (keyof F & string)[] | string[];
330
+ }
331
+ /**
332
+ * Options for `CollectionHandle.getAncestors(documentId, options)`. Walks the
333
+ * node's ancestor chain upward, returning the ancestors **root-first** (the
334
+ * breadcrumb trail, excluding the node itself). In `'published'` mode only
335
+ * ancestors with a published version are returned.
336
+ */
337
+ export interface GetAncestorsOptions<F = Record<string, any>> extends StatusControls {
338
+ /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
339
+ locale?: string;
340
+ /** Return only these fields on each ancestor. Omit for all fields. */
341
+ select?: (keyof F & string)[] | string[];
342
+ }
343
+ /**
344
+ * A node in a hydrated document tree. `document` is the fully-shaped
345
+ * `ClientDocument`; `depth` is 0-based from the subtree root; `children` are the
346
+ * node's ordered children (per-parent `order_key` order).
347
+ */
348
+ export interface TreeNode<F = Record<string, any>> {
349
+ document: ClientDocument<F>;
350
+ depth: number;
351
+ children: TreeNode<F>[];
352
+ }
296
353
  /**
297
354
  * A where clause maps field names (or reserved document-level names like
298
355
  * `status` and `path`) to either a bare value (equality), an operator
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/client",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.12.2",
5
+ "version": "3.13.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -38,8 +38,8 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "npm-run-all": "^4.1.5",
41
- "@byline/auth": "3.12.2",
42
- "@byline/core": "3.12.2"
41
+ "@byline/auth": "3.13.0",
42
+ "@byline/core": "3.13.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@biomejs/biome": "2.4.15",
@@ -51,7 +51,7 @@
51
51
  "tsx": "^4.22.3",
52
52
  "typescript": "6.0.3",
53
53
  "vitest": "^4.1.7",
54
- "@byline/db-postgres": "3.12.2"
54
+ "@byline/db-postgres": "3.13.0"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public",