@byline/client 4.19.0 → 5.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.
@@ -5,9 +5,10 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { AuditLogPage, ChangeStatusResult, CollectionDefinition, CreateDocumentResult, DeleteDocumentResult, RestoreVersionResult, UnpublishResult, UpdateDocumentResult } from '@byline/core';
8
+ import type { AuditLogPage, ChangeStatusResult, CollectionDefinition, CreateDocumentResult, DeleteDocumentResult, DocumentWritePrecondition, RestoreVersionResult, StructuralMutationReceipt, UnpublishResult, UpdateDocumentResult } from '@byline/core';
9
+ import { readSingletonForEdit } from './read-internals.js';
9
10
  import type { BylineClient } from './client.js';
10
- import type { AuditLogOptions, ClientDocument, ClientSearchResults, CollectionSearchOptions, ConfirmScheduledPublishOptions, CreateOptions, DocumentPublishScheduleInfo, FindByIdOptions, FindByPathOptions, FindByVersionOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeOptions, GetTreeParentOptions, HistoryOptions, PlaceTreeNodeOptions, ReindexResult, RemoveFromTreeOptions, SchedulePublishOptions, TreeNode, TreeParentResult, UpdateOptions } from './types.js';
11
+ import type { AuditLogOptions, ClientDocument, ClientSearchResults, CollectionSearchOptions, ConfirmScheduledPublishOptions, CreateOptions, DocumentPublishScheduleInfo, EditableDocument, EditableFindResult, EditableSingleton, EditableTreeNode, FindByIdForEditOptions, FindByIdOptions, FindByPathOptions, FindByVersionOptions, FindForEditOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSubtreeForEditOptions, GetSubtreeOptions, GetTreeParentOptions, HistoryOptions, PlaceTreeNodeOptions, ReindexResult, RemoveFromTreeOptions, SchedulePublishOptions, TreeNode, TreeParentResult, UpdateOptions } from './types.js';
11
12
  /**
12
13
  * A handle scoped to a single collection. Provides read (and eventually write)
13
14
  * operations against that collection's documents.
@@ -28,6 +29,7 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
28
29
  * (LATERAL JOINs).
29
30
  */
30
31
  find<F = TFields>(options?: FindOptions<F>): Promise<FindResult<F>>;
32
+ findForEdit<F = TFields>(options?: FindForEditOptions<F>): Promise<EditableFindResult<F>>;
31
33
  /**
32
34
  * Find a single document matching the given options. Returns `null` if no
33
35
  * document matches.
@@ -97,6 +99,9 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
97
99
  * Find a document by its logical document ID.
98
100
  */
99
101
  findById<F = TFields>(documentId: string, options?: FindByIdOptions<F>): Promise<ClientDocument<F> | null>;
102
+ findByIdForEdit<F = TFields>(documentId: string, options?: FindByIdForEditOptions<F>): Promise<EditableDocument<F> | null>;
103
+ [readSingletonForEdit]<F = TFields>(options?: FindByIdForEditOptions<F>): Promise<EditableSingleton<F> | null>;
104
+ private readOneForEdit;
100
105
  /**
101
106
  * Find a document by its URL path/slug. Returns `null` when no document
102
107
  * exists at the given path (the storage adapter resolves missing paths
@@ -116,34 +121,41 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
116
121
  * Creates a new immutable version row. Hooks receive the real previous
117
122
  * version as `originalData`.
118
123
  */
119
- update(documentId: string, data: Record<string, any>, options?: UpdateOptions): Promise<UpdateDocumentResult>;
124
+ update(documentId: string, data: Record<string, any>, options: UpdateOptions): Promise<UpdateDocumentResult>;
120
125
  /**
121
126
  * Change a document's workflow status. The transition is validated
122
127
  * against the collection's declared workflow (±1 step or reset-to-first);
123
128
  * transitioning to `'published'` auto-archives any other published
124
129
  * versions of the same document.
125
130
  */
126
- changeStatus(documentId: string, nextStatus: string): Promise<ChangeStatusResult>;
131
+ changeStatus(documentId: string, nextStatus: string, options: DocumentWritePrecondition): Promise<ChangeStatusResult>;
127
132
  /**
128
133
  * Arm or reschedule publication of the exact current version the caller
129
134
  * reviewed. The lifecycle requires both `changeStatus` and `publish` and
130
135
  * validates the instant against database time.
131
136
  */
132
- schedulePublish(documentId: string, options: SchedulePublishOptions): Promise<DocumentPublishScheduleInfo>;
137
+ schedulePublish(documentId: string, options: SchedulePublishOptions): Promise<DocumentPublishScheduleInfo & {
138
+ revision: number;
139
+ }>;
133
140
  /** Re-authorize a content-edited schedule against the reviewed version. */
134
- confirmScheduledPublish(documentId: string, options: ConfirmScheduledPublishOptions): Promise<DocumentPublishScheduleInfo>;
141
+ confirmScheduledPublish(documentId: string, options: ConfirmScheduledPublishOptions): Promise<DocumentPublishScheduleInfo & {
142
+ revision: number;
143
+ }>;
135
144
  /**
136
145
  * Cancel the active schedule. A null result means no schedule remained when
137
146
  * the cancellation transaction acquired the row lock; callers must not
138
147
  * report that outcome as a successful cancellation.
139
148
  */
140
- cancelScheduledPublish(documentId: string): Promise<DocumentPublishScheduleInfo | null>;
149
+ cancelScheduledPublish(documentId: string, options: DocumentWritePrecondition): Promise<{
150
+ schedule: DocumentPublishScheduleInfo | null;
151
+ revision: number;
152
+ }>;
141
153
  /** Read the active document-grain schedule under the same two abilities. */
142
154
  getScheduledPublish(documentId: string): Promise<DocumentPublishScheduleInfo | null>;
143
155
  /**
144
156
  * Archive the currently-published version(s) of a document.
145
157
  */
146
- unpublish(documentId: string): Promise<UnpublishResult>;
158
+ unpublish(documentId: string, options: DocumentWritePrecondition): Promise<UnpublishResult>;
147
159
  /**
148
160
  * Restore a historical version as the new current version of this
149
161
  * document. Creates a new immutable version row whose content is copied
@@ -155,7 +167,7 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
155
167
  * a `restore: { sourceVersionId }` field on the hook context so userland
156
168
  * hooks can branch.
157
169
  */
158
- restoreVersion(documentId: string, sourceVersionId: string): Promise<RestoreVersionResult>;
170
+ restoreVersion(documentId: string, sourceVersionId: string, options: DocumentWritePrecondition): Promise<RestoreVersionResult>;
159
171
  /**
160
172
  * Soft-delete a document. All versions are flagged `is_deleted = true`
161
173
  * and disappear from read paths (the `current_documents` view filters
@@ -164,7 +176,7 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
164
176
  * every persisted variant on each upload-capable field are removed
165
177
  * after the DB soft-delete — failures there are logged but non-fatal.
166
178
  */
167
- delete(documentId: string): Promise<DeleteDocumentResult>;
179
+ delete(documentId: string, options: DocumentWritePrecondition): Promise<DeleteDocumentResult>;
168
180
  /**
169
181
  * Sum the current-version workflow status buckets visible to the actor,
170
182
  * optionally selecting one exact status. This is an editorial status-count
@@ -237,13 +249,13 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
237
249
  */
238
250
  placeTreeNode(documentId: string, options: PlaceTreeNodeOptions): Promise<{
239
251
  orderKey: string;
240
- }>;
252
+ } & StructuralMutationReceipt>;
241
253
  /**
242
254
  * Remove a document from the tree, returning it to the *unplaced* state (still
243
255
  * in the collection, but not in any table of contents). Distinct from
244
256
  * deleting the document. No-op when already unplaced.
245
257
  */
246
- removeFromTree(documentId: string, options?: RemoveFromTreeOptions): Promise<void>;
258
+ removeFromTree(documentId: string, options: RemoveFromTreeOptions): Promise<StructuralMutationReceipt>;
247
259
  /**
248
260
  * Read a node's subtree as a nested {@link TreeNode} forest, hydrated to
249
261
  * `ClientDocument`s. `rootDocumentId: null` (default) returns the whole tree
@@ -254,6 +266,13 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
254
266
  * a hidden node and its descendants are omitted, never promoted.
255
267
  */
256
268
  getSubtree<F = TFields>(options?: GetSubtreeOptions<F>): Promise<TreeNode<F>[]>;
269
+ getSubtreeForEdit<F = TFields>(options?: GetSubtreeForEditOptions<F>): Promise<EditableTreeNode<F>[]>;
270
+ /** Whole admin tree and scoped unplaced documents share one structural snapshot. */
271
+ getTreeForEdit<F = TFields>(options?: Omit<GetSubtreeForEditOptions<F>, 'rootDocumentId' | 'depth'>): Promise<{
272
+ forest: EditableTreeNode<F>[];
273
+ unplaced: EditableDocument<F>[];
274
+ }>;
275
+ private readTreeForEdit;
257
276
  /**
258
277
  * Walk a document's ancestor chain upward, returning the ancestors
259
278
  * **root-first** (the breadcrumb trail, excluding the node itself), hydrated
@@ -274,6 +293,8 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
274
293
  * redacted to null. Structure-only — no document hydration.
275
294
  */
276
295
  getTreeParent(documentId: string, options?: GetTreeParentOptions): Promise<TreeParentResult>;
296
+ private captureEditObservations;
297
+ private shapeEditable;
277
298
  /**
278
299
  * Build a fresh `DocumentLifecycleContext` for a write call. Pulls the
279
300
  * resolved collection id, inherits the client-wide logger and storage
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { applyAfterRead, assertActorCanPerform, buildSearchDocument, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, createDocument, createReadContext, createRichTextDocumentReader, deleteDocument, ERR_VALIDATION, getDocumentScheduledPublish, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, resolveIdentityField, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, } from '@byline/core';
8
+ import { applyAfterRead, assertActorCanPerform, buildSearchDocument, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, createDocument, createReadContext, createRichTextDocumentReader, deleteDocument, documentAbilityKey, documentRevisionFromDatabase, ERR_VALIDATION, getDocumentScheduledPublish, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, resolveIdentityField, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, } from '@byline/core';
9
9
  import { getReadSecurityDomain, resolveReadRequestContext, resolveReadSecurityFilters, } from './read-context.js';
10
- import { expectedDocumentId } from './read-internals.js';
10
+ import { expectedDocumentId, readSingletonForEdit, } from './read-internals.js';
11
11
  import { shapeDocument, shapePopulatedInPlace } from './response.js';
12
12
  import { assertSearchFieldScope, assertSearchQueryLength, finalizeSearchHits } from './search.js';
13
13
  function toScheduleInfo(schedule) {
@@ -82,6 +82,57 @@ export class CollectionHandle {
82
82
  },
83
83
  };
84
84
  }
85
+ async findForEdit(options = {}) {
86
+ assertEditableOptions(options);
87
+ const readMode = 'any';
88
+ const readCtx = options._readContext ?? createReadContext();
89
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
90
+ const { where, select, sort, locale = this.client.defaultLocale, page = 1, pageSize = 20, } = options;
91
+ const securityFilters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
92
+ const parsedWhere = await parseWhere(where, this.definition, {
93
+ collections: this.client.collections,
94
+ resolveCollectionId: (path) => this.client.resolveCollectionId(path),
95
+ logger: this.client.logger,
96
+ });
97
+ if (securityFilters)
98
+ parsedWhere.filters.push(...securityFilters);
99
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
100
+ const parsedSort = parseSort(sort, this.definition);
101
+ const { result, observations } = await this.client.db.withReadSnapshot(async (queries) => {
102
+ const result = await queries.documents.findDocuments({
103
+ collection_id: collectionId,
104
+ filters: parsedWhere.filters,
105
+ status: parsedWhere.status,
106
+ pathFilter: parsedWhere.pathFilter,
107
+ query: parsedWhere.query,
108
+ sort: parsedSort.fieldSort,
109
+ orderBy: parsedSort.orderBy,
110
+ orderDirection: parsedSort.orderDirection,
111
+ locale,
112
+ page,
113
+ pageSize,
114
+ fields: select,
115
+ readMode,
116
+ onMissingLocale: options.onMissingLocale ?? 'fallback',
117
+ });
118
+ const observations = await this.captureEditObservations(queries, collectionId, result.documents, requestContext);
119
+ return { result, observations };
120
+ });
121
+ await this.populateIfRequested(collectionId, result.documents, locale, readMode, requestContext, {
122
+ ...options,
123
+ _readContext: readCtx,
124
+ });
125
+ await this.finishReadDocuments(result.documents, readCtx, requestContext, locale, readMode, options._bypassBeforeRead, select, readMaterialization(options.populate, options.depth));
126
+ return {
127
+ docs: result.documents.map((d, index) => this.shapeEditable(d, observations[index])),
128
+ meta: {
129
+ total: result.total,
130
+ page,
131
+ pageSize,
132
+ totalPages: Math.ceil(result.total / pageSize),
133
+ },
134
+ };
135
+ }
85
136
  /**
86
137
  * Find a single document matching the given options. Returns `null` if no
87
138
  * document matches.
@@ -337,6 +388,63 @@ export class CollectionHandle {
337
388
  await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
338
389
  return this.shapeWithPopulated(raw);
339
390
  }
391
+ async findByIdForEdit(documentId, options = {}) {
392
+ if (typeof documentId !== 'string' || !documentId)
393
+ throw ERR_VALIDATION({ message: 'A document ID is required' });
394
+ const result = await this.readOneForEdit(documentId, options);
395
+ return result === 'empty' ? null : result;
396
+ }
397
+ async [readSingletonForEdit](options = {}) {
398
+ const result = await this.readOneForEdit(null, options);
399
+ if (result === 'empty')
400
+ return { state: 'empty' };
401
+ if (result == null)
402
+ return null;
403
+ const { path: _path, ...document } = result;
404
+ return { state: 'document', document };
405
+ }
406
+ async readOneForEdit(documentId, options = {}) {
407
+ assertEditableOptions(options);
408
+ const readMode = 'any';
409
+ const readCtx = options._readContext ?? createReadContext();
410
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
411
+ const { locale = this.client.defaultLocale } = options;
412
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
413
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
414
+ const snapshot = await this.client.db.withReadSnapshot(async (queries) => {
415
+ const sourceId = documentId ?? (await queries.singletons.getMappedDocumentId(collectionId));
416
+ if (sourceId == null)
417
+ return 'empty';
418
+ const raw = await queries.documents.getDocumentById({
419
+ collection_id: collectionId,
420
+ document_id: sourceId,
421
+ locale,
422
+ reconstruct: true,
423
+ readMode,
424
+ filters,
425
+ lenient: options.lenient,
426
+ onMissingLocale: options.onMissingLocale ?? 'fallback',
427
+ });
428
+ if (raw == null)
429
+ return null;
430
+ const observations = await this.captureEditObservations(queries, collectionId, [raw], requestContext);
431
+ return { raw, observation: observations[0] };
432
+ });
433
+ if (snapshot == null || snapshot === 'empty')
434
+ return snapshot;
435
+ const { raw, observation } = snapshot;
436
+ // Trim to selected fields BEFORE populate so populate doesn't waste work
437
+ // on relations the caller filtered out. Mutates `raw.fields` in place.
438
+ if (options.select?.length) {
439
+ trimFields(raw, options.select);
440
+ }
441
+ await this.populateIfRequested(collectionId, [raw], locale, readMode, requestContext, {
442
+ ...options,
443
+ _readContext: readCtx,
444
+ });
445
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
446
+ return this.shapeEditable(raw, observation);
447
+ }
340
448
  /**
341
449
  * Find a document by its URL path/slug. Returns `null` when no document
342
450
  * exists at the given path (the storage adapter resolves missing paths
@@ -404,14 +512,15 @@ export class CollectionHandle {
404
512
  * Creates a new immutable version row. Hooks receive the real previous
405
513
  * version as `originalData`.
406
514
  */
407
- async update(documentId, data, options = {}) {
515
+ async update(documentId, data, options) {
408
516
  const ctx = await this.buildLifecycleContext();
409
517
  return updateDocument(ctx, {
410
518
  documentId,
411
519
  data,
412
- locale: options.locale,
413
- path: options.path,
414
- availableLocales: options.availableLocales,
520
+ expectedRevision: options?.expectedRevision,
521
+ locale: options?.locale,
522
+ path: options?.path,
523
+ availableLocales: options?.availableLocales,
415
524
  });
416
525
  }
417
526
  /**
@@ -420,9 +529,13 @@ export class CollectionHandle {
420
529
  * transitioning to `'published'` auto-archives any other published
421
530
  * versions of the same document.
422
531
  */
423
- async changeStatus(documentId, nextStatus) {
532
+ async changeStatus(documentId, nextStatus, options) {
424
533
  const ctx = await this.buildLifecycleContext();
425
- return changeDocumentStatus(ctx, { documentId, nextStatus });
534
+ return changeDocumentStatus(ctx, {
535
+ documentId,
536
+ nextStatus,
537
+ expectedRevision: options?.expectedRevision,
538
+ });
426
539
  }
427
540
  /**
428
541
  * Arm or reschedule publication of the exact current version the caller
@@ -431,22 +544,30 @@ export class CollectionHandle {
431
544
  */
432
545
  async schedulePublish(documentId, options) {
433
546
  const ctx = await this.buildLifecycleContext();
434
- return toScheduleInfo(await scheduleDocumentPublish(ctx, { documentId, ...options }));
547
+ const result = await scheduleDocumentPublish(ctx, { documentId, ...options });
548
+ return { ...toScheduleInfo(result), revision: result.revision };
435
549
  }
436
550
  /** Re-authorize a content-edited schedule against the reviewed version. */
437
551
  async confirmScheduledPublish(documentId, options) {
438
552
  const ctx = await this.buildLifecycleContext();
439
- return toScheduleInfo(await confirmDocumentScheduledPublish(ctx, { documentId, ...options }));
553
+ const result = await confirmDocumentScheduledPublish(ctx, { documentId, ...options });
554
+ return { ...toScheduleInfo(result), revision: result.revision };
440
555
  }
441
556
  /**
442
557
  * Cancel the active schedule. A null result means no schedule remained when
443
558
  * the cancellation transaction acquired the row lock; callers must not
444
559
  * report that outcome as a successful cancellation.
445
560
  */
446
- async cancelScheduledPublish(documentId) {
561
+ async cancelScheduledPublish(documentId, options) {
447
562
  const ctx = await this.buildLifecycleContext();
448
- const schedule = await cancelDocumentScheduledPublish(ctx, { documentId });
449
- return schedule == null ? null : toScheduleInfo(schedule);
563
+ const result = await cancelDocumentScheduledPublish(ctx, {
564
+ documentId,
565
+ expectedRevision: options?.expectedRevision,
566
+ });
567
+ return {
568
+ schedule: result.schedule == null ? null : toScheduleInfo(result.schedule),
569
+ revision: result.revision,
570
+ };
450
571
  }
451
572
  /** Read the active document-grain schedule under the same two abilities. */
452
573
  async getScheduledPublish(documentId) {
@@ -457,9 +578,9 @@ export class CollectionHandle {
457
578
  /**
458
579
  * Archive the currently-published version(s) of a document.
459
580
  */
460
- async unpublish(documentId) {
581
+ async unpublish(documentId, options) {
461
582
  const ctx = await this.buildLifecycleContext();
462
- return unpublishDocument(ctx, { documentId });
583
+ return unpublishDocument(ctx, { documentId, expectedRevision: options?.expectedRevision });
463
584
  }
464
585
  /**
465
586
  * Restore a historical version as the new current version of this
@@ -472,9 +593,13 @@ export class CollectionHandle {
472
593
  * a `restore: { sourceVersionId }` field on the hook context so userland
473
594
  * hooks can branch.
474
595
  */
475
- async restoreVersion(documentId, sourceVersionId) {
596
+ async restoreVersion(documentId, sourceVersionId, options) {
476
597
  const ctx = await this.buildLifecycleContext();
477
- return restoreDocumentVersion(ctx, { documentId, sourceVersionId });
598
+ return restoreDocumentVersion(ctx, {
599
+ documentId,
600
+ sourceVersionId,
601
+ expectedRevision: options?.expectedRevision,
602
+ });
478
603
  }
479
604
  /**
480
605
  * Soft-delete a document. All versions are flagged `is_deleted = true`
@@ -484,9 +609,9 @@ export class CollectionHandle {
484
609
  * every persisted variant on each upload-capable field are removed
485
610
  * after the DB soft-delete — failures there are logged but non-fatal.
486
611
  */
487
- async delete(documentId) {
612
+ async delete(documentId, options) {
488
613
  const ctx = await this.buildLifecycleContext();
489
- return deleteDocument(ctx, { documentId });
614
+ return deleteDocument(ctx, { documentId, expectedRevision: options?.expectedRevision });
490
615
  }
491
616
  /**
492
617
  * Sum the current-version workflow status buckets visible to the actor,
@@ -658,10 +783,11 @@ export class CollectionHandle {
658
783
  const ctx = await this.buildLifecycleContext();
659
784
  return placeTreeNodeLifecycle(ctx, {
660
785
  documentId,
661
- parentDocumentId: options.parentDocumentId,
662
- beforeDocumentId: options.beforeDocumentId ?? null,
663
- afterDocumentId: options.afterDocumentId ?? null,
664
- reconcile: options.reconcile,
786
+ expectedRevision: options?.expectedRevision,
787
+ parentDocumentId: options?.parentDocumentId,
788
+ beforeDocumentId: options?.beforeDocumentId ?? null,
789
+ afterDocumentId: options?.afterDocumentId ?? null,
790
+ reconcile: options?.reconcile,
665
791
  });
666
792
  }
667
793
  /**
@@ -669,10 +795,14 @@ export class CollectionHandle {
669
795
  * in the collection, but not in any table of contents). Distinct from
670
796
  * deleting the document. No-op when already unplaced.
671
797
  */
672
- async removeFromTree(documentId, options = {}) {
798
+ async removeFromTree(documentId, options) {
673
799
  this.assertTreeCollection();
674
800
  const ctx = await this.buildLifecycleContext();
675
- await removeFromTreeLifecycle(ctx, { documentId, reconcile: options.reconcile });
801
+ return removeFromTreeLifecycle(ctx, {
802
+ documentId,
803
+ expectedRevision: options?.expectedRevision,
804
+ reconcile: options?.reconcile,
805
+ });
676
806
  }
677
807
  /**
678
808
  * Read a node's subtree as a nested {@link TreeNode} forest, hydrated to
@@ -724,6 +854,99 @@ export class CollectionHandle {
724
854
  }
725
855
  return roots;
726
856
  }
857
+ async getSubtreeForEdit(options = {}) {
858
+ return (await this.readTreeForEdit(options, false)).forest;
859
+ }
860
+ /** Whole admin tree and scoped unplaced documents share one structural snapshot. */
861
+ async getTreeForEdit(options = {}) {
862
+ return this.readTreeForEdit(options, true);
863
+ }
864
+ async readTreeForEdit(options, includeUnplaced) {
865
+ this.assertTreeCollection();
866
+ assertEditableOptions(options);
867
+ const readMode = 'any';
868
+ const readCtx = options._readContext ?? createReadContext();
869
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
870
+ const locale = options.locale ?? this.client.defaultLocale;
871
+ const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
872
+ const collectionId = await this.client.resolveCollectionId(this.definition.path);
873
+ const { structure, raws, observations } = await this.client.db.withReadSnapshot(async (queries) => {
874
+ const structure = await queries.documents.getTreeSubtree({
875
+ collectionId,
876
+ rootDocumentId: options.rootDocumentId ?? null,
877
+ maxDepth: options.depth,
878
+ readMode,
879
+ locale,
880
+ filters,
881
+ });
882
+ const raws = structure.length === 0
883
+ ? []
884
+ : await queries.documents.getDocumentsByDocumentIds({
885
+ collection_id: collectionId,
886
+ document_ids: structure.map((row) => row.document_id),
887
+ locale,
888
+ readMode,
889
+ filters,
890
+ fields: options.select,
891
+ });
892
+ if (includeUnplaced) {
893
+ const seen = new Set(raws.map((raw) => raw.document_id));
894
+ // Preserve the admin's scoped unplaced read, with all pages in the same snapshot.
895
+ for (let page = 1;; page++) {
896
+ const result = await queries.documents.findDocuments({
897
+ collection_id: collectionId,
898
+ filters,
899
+ locale,
900
+ readMode,
901
+ fields: options.select,
902
+ page,
903
+ pageSize: 1000,
904
+ orderBy: 'document_id',
905
+ orderDirection: 'asc',
906
+ });
907
+ for (const raw of result.documents) {
908
+ if (!seen.has(raw.document_id)) {
909
+ raws.push(raw);
910
+ seen.add(raw.document_id);
911
+ }
912
+ }
913
+ if (page * 1000 >= result.total)
914
+ break;
915
+ }
916
+ }
917
+ const observations = await this.captureEditObservations(queries, collectionId, raws, requestContext);
918
+ return { structure, raws, observations };
919
+ });
920
+ await this.finishReadDocuments(raws, readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, 'tree');
921
+ const shapedById = new Map(raws.map((raw, index) => {
922
+ const document = this.shapeEditable(raw, observations[index]);
923
+ return [document.id, document];
924
+ }));
925
+ // Assemble the nested forest. Rows arrive pre-order, so a parent is always
926
+ // materialised before its children; a row whose parent is not in the result
927
+ // set (null parent, or the requested root's own parent) is a top-level node.
928
+ const structuralIds = new Set(structure.map((row) => row.document_id));
929
+ const nodeById = new Map();
930
+ const roots = [];
931
+ for (const row of structure) {
932
+ const document = shapedById.get(row.document_id);
933
+ if (document == null)
934
+ continue;
935
+ const node = { document, depth: row.depth, children: [] };
936
+ nodeById.set(row.document_id, node);
937
+ const parent = row.parent_document_id != null ? nodeById.get(row.parent_document_id) : undefined;
938
+ if (parent) {
939
+ parent.children.push(node);
940
+ }
941
+ else if (row.parent_document_id == null || !structuralIds.has(row.parent_document_id)) {
942
+ roots.push(node);
943
+ }
944
+ }
945
+ return {
946
+ forest: roots,
947
+ unplaced: [...shapedById.values()].filter((doc) => !structuralIds.has(doc.id)),
948
+ };
949
+ }
727
950
  /**
728
951
  * Walk a document's ancestor chain upward, returning the ancestors
729
952
  * **root-first** (the breadcrumb trail, excluding the node itself), hydrated
@@ -799,6 +1022,41 @@ export class CollectionHandle {
799
1022
  // -------------------------------------------------------------------------
800
1023
  // Internals
801
1024
  // -------------------------------------------------------------------------
1025
+ async captureEditObservations(queries, collectionId, raws, requestContext) {
1026
+ const actor = requestContext.actor;
1027
+ const canSchedule = actor?.hasAbility(documentAbilityKey(this.definition, 'changeStatus')) === true &&
1028
+ actor.hasAbility(documentAbilityKey(this.definition, 'publish'));
1029
+ const observations = [];
1030
+ for (const raw of raws) {
1031
+ const revision = documentRevisionFromDatabase(await queries.documents.getDocumentRevision({
1032
+ collection_id: collectionId,
1033
+ document_id: raw.document_id,
1034
+ }));
1035
+ const schedule = canSchedule
1036
+ ? await queries.documents.publishSchedules.get({
1037
+ collectionId,
1038
+ documentId: raw.document_id,
1039
+ })
1040
+ : null;
1041
+ // Capture action identity and status independently of mutable hook input.
1042
+ const { id, versionId, status } = shapeDocument(raw);
1043
+ const source = { id, versionId, status };
1044
+ observations.push({
1045
+ source: structuredClone(source),
1046
+ revision,
1047
+ scheduledPublication: schedule == null ? null : toScheduleInfo(schedule),
1048
+ });
1049
+ }
1050
+ return observations;
1051
+ }
1052
+ shapeEditable(raw, observation) {
1053
+ return {
1054
+ ...this.shapeWithPopulated(raw),
1055
+ ...observation.source,
1056
+ revision: observation.revision,
1057
+ scheduledPublication: observation.scheduledPublication,
1058
+ };
1059
+ }
802
1060
  /**
803
1061
  * Build a fresh `DocumentLifecycleContext` for a write call. Pulls the
804
1062
  * resolved collection id, inherits the client-wide logger and storage
@@ -1001,3 +1259,13 @@ function trimFields(raw, select) {
1001
1259
  delete fields[k];
1002
1260
  }
1003
1261
  }
1262
+ function assertEditableOptions(options) {
1263
+ const input = options;
1264
+ if (input.status !== undefined ||
1265
+ input.versionId !== undefined ||
1266
+ input.documentVersionId !== undefined) {
1267
+ throw ERR_VALIDATION({
1268
+ message: 'Editable reads select current state and do not accept status or version selectors',
1269
+ });
1270
+ }
1271
+ }
package/dist/index.d.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
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 SingletonSaveResult, type UnpublishResult, type UnresolvedRelationValue, type UpdateDocumentResult, } from '@byline/core';
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 SingletonSaveResult, type UnpublishResult, type UnresolvedRelationValue, type UpdateDocumentResult, type UpdateSingletonResult, } from '@byline/core';
9
9
  export { BylineClient, createBylineClient } from './client.js';
10
10
  export { CollectionHandle } from './collection-handle.js';
11
11
  export { SingletonHandle } from './singleton-handle.js';
12
12
  export type { Register, RegisteredCollections, RegisteredSingletons } from './register.js';
13
- export type { AuditLogOptions, BylineClientConfig, ClientDocument, ClientSearchResults, CollectionSearchOptions, ConfirmScheduledPublishOptions, CreateOptions, DocumentPublishScheduleInfo, FilterOperators, FindByIdOptions, FindByPathOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSingletonOptions, GetSubtreeOptions, GetTreeParentOptions, HydratedSearchHit, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, RemoveFromTreeOptions, SchedulePublishOptions, SearchFacetBucket, SearchHit, SearchResults, SingletonDocument, SortDirection, SortSpec, TreeNode, TreeParentResult, UpdateOptions, UpdateSingletonOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, ZoneSearchOptions, } from './types.js';
13
+ export type { AuditLogOptions, BylineClientConfig, ClientDocument, ClientSearchResults, CollectionSearchOptions, ConfirmScheduledPublishOptions, CreateOptions, DocumentPublishScheduleInfo, EditableDocument, EditableFindResult, EditableSingleton, EditableTreeNode, FilterOperators, FindByIdForEditOptions, FindByIdOptions, FindByPathOptions, FindForEditOptions, FindOneOptions, FindOptions, FindResult, GetAncestorsOptions, GetSingletonOptions, GetSubtreeForEditOptions, GetSubtreeOptions, GetTreeParentOptions, HydratedSearchHit, PlaceTreeNodeOptions, PopulatedRelation, ReindexResult, RemoveFromTreeOptions, SchedulePublishOptions, SearchFacetBucket, SearchHit, SearchResults, SingletonDocument, SortDirection, SortSpec, TreeNode, TreeParentResult, UpdateOptions, UpdateSingletonOptions, WhereClause, WhereValue, WithPopulated, WithPopulatedMany, ZoneSearchOptions, } from './types.js';
@@ -3,3 +3,5 @@ export declare const expectedDocumentId: unique symbol;
3
3
  export interface DocumentBoundFindByVersionOptions {
4
4
  [expectedDocumentId]?: string;
5
5
  }
6
+ /** Internal singleton slot read; shares the collection reader's authorization pipeline. */
7
+ export declare const readSingletonForEdit: unique symbol;
@@ -1,2 +1,4 @@
1
1
  /** Internal ownership constraint for singleton historical reads. */
2
2
  export const expectedDocumentId = Symbol('byline.expectedDocumentId');
3
+ /** Internal singleton slot read; shares the collection reader's authorization pipeline. */
4
+ export const readSingletonForEdit = Symbol('byline.readSingletonForEdit');
@@ -6,45 +6,13 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  /**
9
- * Request-scoped auth context for admin server functions.
9
+ * Verification-only request context. Renewal belongs to the explicit host endpoint.
10
10
  *
11
- * Reads the session cookies, verifies the access token, transparently
12
- * refreshes when needed, and returns a `RequestContext` carrying the
13
- * authenticated `AdminAuth`. Every admin server fn calls this as its
14
- * first step — it is the single point where the admin transport boundary
15
- * meets the actor/ability machinery.
16
- *
17
- * Flow, in order:
18
- *
19
- * 1. Read the access cookie.
20
- * 2. If present, try `sessionProvider.verifyAccessToken`. On success,
21
- * return the context — no DB write, no cookie churn.
22
- * 3. On verify failure (or missing access cookie), read the refresh
23
- * cookie. If present, call `sessionProvider.refreshSession` — this
24
- * rotates the refresh token atomically and issues a new access
25
- * token. Write both fresh cookies to the response.
26
- * 4. Verify the new access token (populate the actor) and return the
27
- * context.
28
- * 5. Any failure along the way clears both cookies so the browser
29
- * stops sending a session the server has already rejected, and
30
- * throws `ERR_UNAUTHENTICATED`.
31
- *
32
- * Burns a refresh-token rotation only when the access token actually
33
- * fails verification — not on every request. The "one session" UX is
34
- * the consequence of this helper working invisibly behind each call.
35
- *
36
- * Resolution is memoized per request (`oncePerRequest`): every call within
37
- * one logical request returns the same `RequestContext` instance — same
38
- * actor snapshot, same `requestId`. Two invariants depend on this:
39
- *
40
- * - The read-authorization layer binds each `ReadContext` to one
41
- * immutable authority token that includes `requestId`; a fresh id per
42
- * call makes any two reads sharing a `ReadContext` (the admin tree
43
- * view, hook-threaded nested reads) throw
44
- * 'ReadContext cannot be reused across request authorities'.
45
- * - The refresh path rotates the refresh token; a second unmemoized call
46
- * in the same request would re-run the dance against the
47
- * already-rotated cookie and sign the admin out.
11
+ * oncePerRequest preserves the same context, actor snapshot, and requestId for
12
+ * every call in one logical request. The read-authorization layer binds each
13
+ * ReadContext to one authority token including requestId. Generating a new id
14
+ * per call would make nested reads sharing a ReadContext throw:
15
+ * "ReadContext cannot be reused across request authorities".
48
16
  */
49
17
  import { type RequestContext } from '@byline/auth';
50
18
  export declare function getAdminRequestContext(): Promise<RequestContext>;