@byline/client 4.19.0 → 5.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.
@@ -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');
@@ -38,6 +38,7 @@ vi.mock('@byline/core', async () => {
38
38
  };
39
39
  });
40
40
  import { createBylineClient } from '../client.js';
41
+ import { testAdapter } from '../test-adapter.test-helper.js';
41
42
  import { getAdminRequestContext } from './admin-context.js';
42
43
  const BRIDGE_SLOT = Symbol.for('__byline_host_request_bridge__');
43
44
  const previousBridge = globalThis[BRIDGE_SLOT];
@@ -52,7 +53,7 @@ const postsCollection = {
52
53
  fields: [{ name: 'title', type: 'text', label: 'Title' }],
53
54
  };
54
55
  function mockDb() {
55
- return {
56
+ return testAdapter({
56
57
  commands: { collections: {}, documents: {}, counters: {} },
57
58
  queries: {
58
59
  collections: {
@@ -63,7 +64,7 @@ function mockDb() {
63
64
  getDocumentsByDocumentIds: vi.fn().mockResolvedValue([]),
64
65
  },
65
66
  },
66
- };
67
+ });
67
68
  }
68
69
  describe('request factory ↔ client authority contract', () => {
69
70
  beforeEach(() => {
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { ChangeStatusResult, SingletonDefinition, SingletonSaveResult, UnpublishResult } from '@byline/core';
8
+ import type { ChangeStatusResult, DocumentWritePrecondition, SingletonDefinition, SingletonSaveResult, UnpublishResult, UpdateSingletonResult } from '@byline/core';
9
9
  import type { BylineClient } from './client.js';
10
- import type { ClientDocument, ConfirmScheduledPublishOptions, DocumentPublishScheduleInfo, FindByVersionOptions, FindResult, GetSingletonOptions, HistoryOptions, SchedulePublishOptions, SingletonDocument, UpdateSingletonOptions } from './types.js';
10
+ import type { ClientDocument, ConfirmScheduledPublishOptions, DocumentPublishScheduleInfo, EditableSingleton, FindByIdForEditOptions, FindByVersionOptions, FindResult, GetSingletonOptions, HistoryOptions, SchedulePublishOptions, SingletonDocument, UpdateSingletonOptions } from './types.js';
11
11
  /**
12
12
  * A document-ID-free handle for one registered singleton slot.
13
13
  *
@@ -22,17 +22,26 @@ export declare class SingletonHandle<TFields extends Record<string, any> = Recor
22
22
  private readonly reader;
23
23
  constructor(client: BylineClient<any, any>, definition: SingletonDefinition);
24
24
  get(options?: GetSingletonOptions<TFields>): Promise<SingletonDocument<TFields> | null>;
25
- update(data: TFields, options?: UpdateSingletonOptions): Promise<SingletonSaveResult>;
26
- changeStatus(nextStatus: string): Promise<ChangeStatusResult>;
27
- unpublish(): Promise<UnpublishResult>;
28
- schedulePublish(options: SchedulePublishOptions): Promise<DocumentPublishScheduleInfo>;
29
- confirmScheduledPublish(options: ConfirmScheduledPublishOptions): Promise<DocumentPublishScheduleInfo>;
30
- cancelScheduledPublish(): Promise<DocumentPublishScheduleInfo | null>;
25
+ getForEdit(options?: FindByIdForEditOptions<TFields>): Promise<EditableSingleton<TFields> | null>;
26
+ update(data: TFields, options: UpdateSingletonOptions): Promise<UpdateSingletonResult>;
27
+ changeStatus(nextStatus: string, options: DocumentWritePrecondition): Promise<ChangeStatusResult>;
28
+ unpublish(options: DocumentWritePrecondition): Promise<UnpublishResult>;
29
+ schedulePublish(options: SchedulePublishOptions): Promise<DocumentPublishScheduleInfo & {
30
+ revision: number;
31
+ }>;
32
+ confirmScheduledPublish(options: ConfirmScheduledPublishOptions): Promise<DocumentPublishScheduleInfo & {
33
+ revision: number;
34
+ }>;
35
+ cancelScheduledPublish(options: DocumentWritePrecondition): Promise<{
36
+ schedule: DocumentPublishScheduleInfo | null;
37
+ revision: number;
38
+ }>;
31
39
  getScheduledPublish(): Promise<DocumentPublishScheduleInfo | null>;
32
40
  history<F = TFields>(options?: HistoryOptions): Promise<FindResult<F>>;
33
41
  findByVersion<F = TFields>(versionId: string, options?: FindByVersionOptions<F>): Promise<ClientDocument<F> | null>;
34
- restoreVersion(sourceVersionId: string): Promise<SingletonSaveResult>;
42
+ restoreVersion(sourceVersionId: string, options: DocumentWritePrecondition): Promise<SingletonSaveResult>;
35
43
  copyToLocale(args: {
44
+ expectedRevision: number;
36
45
  sourceLocale: string;
37
46
  targetLocale: string;
38
47
  overwrite?: boolean;
@@ -8,7 +8,7 @@
8
8
  import { assertActorCanPerform, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copySingletonToLocale, createReadContext, ERR_NOT_FOUND, getDocumentScheduledPublish, resolveSingletonDocumentId, restoreSingletonVersion, scheduleDocumentPublish, unpublishDocument, updateSingleton, } from '@byline/core';
9
9
  import { CollectionHandle } from './collection-handle.js';
10
10
  import { resolveReadRequestContext } from './read-context.js';
11
- import { expectedDocumentId } from './read-internals.js';
11
+ import { expectedDocumentId, readSingletonForEdit, } from './read-internals.js';
12
12
  function toScheduleInfo(schedule) {
13
13
  const { executionToken: _executionToken, executionExpiresAt: _executionExpiresAt, ...info } = schedule;
14
14
  return info;
@@ -45,33 +45,48 @@ export class SingletonHandle {
45
45
  const { path: _path, ...singleton } = document;
46
46
  return singleton;
47
47
  }
48
- async update(data, options = {}) {
48
+ async getForEdit(options = {}) {
49
+ return this.reader[readSingletonForEdit](options);
50
+ }
51
+ async update(data, options) {
49
52
  const ctx = await this.buildAuthorizedLifecycleContext(['update']);
50
53
  return updateSingleton(ctx, { data, ...options });
51
54
  }
52
- async changeStatus(nextStatus) {
55
+ async changeStatus(nextStatus, options) {
53
56
  const abilities = ['changeStatus'];
54
57
  if (nextStatus === 'published')
55
58
  abilities.push('publish');
56
59
  const { ctx, documentId } = await this.resolveRequiredDocument(abilities);
57
- return changeDocumentStatus(ctx, { documentId, nextStatus });
60
+ return changeDocumentStatus(ctx, {
61
+ documentId,
62
+ nextStatus,
63
+ expectedRevision: options?.expectedRevision,
64
+ });
58
65
  }
59
- async unpublish() {
66
+ async unpublish(options) {
60
67
  const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus']);
61
- return unpublishDocument(ctx, { documentId });
68
+ return unpublishDocument(ctx, { documentId, expectedRevision: options?.expectedRevision });
62
69
  }
63
70
  async schedulePublish(options) {
64
71
  const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus', 'publish']);
65
- return toScheduleInfo(await scheduleDocumentPublish(ctx, { documentId, ...options }));
72
+ const result = await scheduleDocumentPublish(ctx, { documentId, ...options });
73
+ return { ...toScheduleInfo(result), revision: result.revision };
66
74
  }
67
75
  async confirmScheduledPublish(options) {
68
76
  const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus', 'publish']);
69
- return toScheduleInfo(await confirmDocumentScheduledPublish(ctx, { documentId, ...options }));
77
+ const result = await confirmDocumentScheduledPublish(ctx, { documentId, ...options });
78
+ return { ...toScheduleInfo(result), revision: result.revision };
70
79
  }
71
- async cancelScheduledPublish() {
80
+ async cancelScheduledPublish(options) {
72
81
  const { ctx, documentId } = await this.resolveRequiredDocument(['changeStatus', 'publish']);
73
- const schedule = await cancelDocumentScheduledPublish(ctx, { documentId });
74
- return schedule == null ? null : toScheduleInfo(schedule);
82
+ const result = await cancelDocumentScheduledPublish(ctx, {
83
+ documentId,
84
+ expectedRevision: options?.expectedRevision,
85
+ });
86
+ return {
87
+ schedule: result.schedule == null ? null : toScheduleInfo(result.schedule),
88
+ revision: result.revision,
89
+ };
75
90
  }
76
91
  async getScheduledPublish() {
77
92
  const ctx = await this.buildAuthorizedLifecycleContext(['changeStatus', 'publish']);
@@ -105,13 +120,16 @@ export class SingletonHandle {
105
120
  };
106
121
  return this.reader.findByVersion(versionId, boundOptions);
107
122
  }
108
- async restoreVersion(sourceVersionId) {
123
+ async restoreVersion(sourceVersionId, options) {
109
124
  const ctx = await this.buildAuthorizedLifecycleContext(['update']);
110
- return restoreSingletonVersion(ctx, { sourceVersionId });
125
+ return restoreSingletonVersion(ctx, {
126
+ sourceVersionId,
127
+ expectedRevision: options?.expectedRevision,
128
+ });
111
129
  }
112
130
  async copyToLocale(args) {
113
131
  const ctx = await this.buildAuthorizedLifecycleContext(['update']);
114
- return copySingletonToLocale(ctx, { ...args, overwrite: args.overwrite ?? false });
132
+ return copySingletonToLocale(ctx, { ...args, overwrite: args?.overwrite ?? false });
115
133
  }
116
134
  async authorizeRead(readMode, readContext) {
117
135
  const requestContext = await resolveReadRequestContext(this.client, readContext, readMode);
@@ -0,0 +1,26 @@
1
+ import type { IDbAdapter } from '@byline/core';
2
+ /** Unit fixture only: unused capabilities throw, including transactions and snapshots.
3
+ * Tests must explicitly implement every operation they exercise. Live adapter tests
4
+ * supply the transaction/rollback evidence; this helper cannot simulate it. */
5
+ type Overrides = Partial<Omit<IDbAdapter, 'commands' | 'queries' | 'revisions'>> & {
6
+ revisions?: Partial<IDbAdapter['revisions']>;
7
+ commands?: {
8
+ collections?: Partial<IDbAdapter['commands']['collections']>;
9
+ documents?: Partial<Omit<IDbAdapter['commands']['documents'], 'publishSchedules'>> & {
10
+ publishSchedules?: Partial<IDbAdapter['commands']['documents']['publishSchedules']>;
11
+ };
12
+ counters?: Partial<IDbAdapter['commands']['counters']>;
13
+ audit?: Partial<IDbAdapter['commands']['audit']>;
14
+ singletons?: Partial<IDbAdapter['commands']['singletons']>;
15
+ };
16
+ queries?: {
17
+ collections?: Partial<IDbAdapter['queries']['collections']>;
18
+ documents?: Partial<Omit<IDbAdapter['queries']['documents'], 'publishSchedules'>> & {
19
+ publishSchedules?: Partial<IDbAdapter['queries']['documents']['publishSchedules']>;
20
+ };
21
+ audit?: Partial<IDbAdapter['queries']['audit']>;
22
+ singletons?: Partial<IDbAdapter['queries']['singletons']>;
23
+ };
24
+ };
25
+ export declare function testAdapter(overrides?: Overrides): IDbAdapter;
26
+ export {};
@@ -0,0 +1,117 @@
1
+ export function testAdapter(overrides = {}) {
2
+ const unused = () => {
3
+ throw new Error('Unexpected adapter operation in unit fixture');
4
+ };
5
+ return {
6
+ withTransaction: unused,
7
+ withReadSnapshot: unused,
8
+ ...overrides,
9
+ revisions: {
10
+ readStructure: unused,
11
+ assertCompatibleSchema: unused,
12
+ isInTransaction: () => false,
13
+ lock: unused,
14
+ advance: unused,
15
+ ...overrides.revisions,
16
+ },
17
+ commands: {
18
+ collections: {
19
+ lockCollectionRegistration: unused,
20
+ create: unused,
21
+ update: unused,
22
+ delete: unused,
23
+ ...overrides.commands?.collections,
24
+ },
25
+ documents: {
26
+ createDocumentVersion: unused,
27
+ updateDocumentPath: unused,
28
+ setDocumentAvailableLocales: unused,
29
+ setDocumentStatus: unused,
30
+ archivePublishedVersions: unused,
31
+ softDeleteDocument: unused,
32
+ restoreSoftDeletedDocument: unused,
33
+ deleteDocumentLocale: unused,
34
+ setOrderKey: unused,
35
+ placeTreeNode: unused,
36
+ removeFromTree: unused,
37
+ promoteChildrenAndRemoveFromTree: unused,
38
+ ...overrides.commands?.documents,
39
+ publishSchedules: {
40
+ lockDocuments: unused,
41
+ schedule: unused,
42
+ confirm: unused,
43
+ cancel: unused,
44
+ suspendForContentEdit: unused,
45
+ claimDue: unused,
46
+ lockClaim: unused,
47
+ deleteClaim: unused,
48
+ suspendClaimForContentEdit: unused,
49
+ releaseClaim: unused,
50
+ ...overrides.commands?.documents?.publishSchedules,
51
+ },
52
+ },
53
+ counters: {
54
+ ensureCounterGroup: unused,
55
+ nextCounterValue: unused,
56
+ nextScopedCounterValue: unused,
57
+ ...overrides.commands?.counters,
58
+ },
59
+ audit: {
60
+ append: unused,
61
+ ...overrides.commands?.audit,
62
+ },
63
+ singletons: {
64
+ lockSlot: unused,
65
+ setMapping: unused,
66
+ clearMapping: unused,
67
+ ...overrides.commands?.singletons,
68
+ },
69
+ },
70
+ queries: {
71
+ collections: {
72
+ getAllCollections: unused,
73
+ getCollectionByPath: unused,
74
+ getCollectionById: unused,
75
+ ...overrides.queries?.collections,
76
+ },
77
+ documents: {
78
+ getDocumentRevision: unused,
79
+ getDocumentSystemFieldsForUpdate: unused,
80
+ getDocumentById: unused,
81
+ getCurrentVersionMetadata: unused,
82
+ getCurrentPath: unused,
83
+ getDocumentByPath: unused,
84
+ getDocumentByVersion: unused,
85
+ getDocumentsByVersionIds: unused,
86
+ getDocumentsByDocumentIds: unused,
87
+ getDocumentHistory: unused,
88
+ getPublishedVersion: unused,
89
+ getPublishedDocumentIds: unused,
90
+ getDocumentCountsByStatus: unused,
91
+ findDocuments: unused,
92
+ getLastOrderKey: unused,
93
+ getNeighborOrderKeys: unused,
94
+ getCanonicalDocumentOrder: unused,
95
+ getTreeAncestors: unused,
96
+ getTreeChildren: unused,
97
+ getTreeParent: unused,
98
+ getTreeSubtree: unused,
99
+ ...overrides.queries?.documents,
100
+ publishSchedules: {
101
+ get: unused,
102
+ list: unused,
103
+ ...overrides.queries?.documents?.publishSchedules,
104
+ },
105
+ },
106
+ audit: {
107
+ getDocumentAuditLog: unused,
108
+ findAuditLog: unused,
109
+ ...overrides.queries?.audit,
110
+ },
111
+ singletons: {
112
+ getMappedDocumentId: unused,
113
+ ...overrides.queries?.singletons,
114
+ },
115
+ },
116
+ };
117
+ }
package/dist/types.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, DocumentPublishSchedule, IDbAdapter, IStorageProvider, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, RichTextToTextFn, SearchFacetBucket, SearchHit, SearchMatching, SearchProvider, ServerConfig, SlugifierFn, SortSpec } from '@byline/core';
9
+ import type { BylineLogger, CollectionDefinition, DocumentPublishSchedule, IDbAdapter, IStorageProvider, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, RichTextToTextFn, SearchFacetBucket, SearchHit, SearchMatching, SearchProvider, ServerConfig, SingletonSavePrecondition, SlugifierFn, SortSpec } from '@byline/core';
10
10
  export type { FilterOperators, PredicateValue, QueryPredicate, SearchFacetBucket, SearchHit, SearchProvider, SearchResults, SortDirection, SortSpec, } from '@byline/core';
11
11
  export interface BylineClientConfig {
12
12
  /**
@@ -408,6 +408,8 @@ export interface CreateOptions {
408
408
  availableLocales?: string[];
409
409
  }
410
410
  export interface UpdateOptions {
411
+ /** Revision from the editable document observed before preparing this update. */
412
+ expectedRevision: number;
411
413
  /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
412
414
  locale?: string;
413
415
  /**
@@ -424,14 +426,13 @@ export interface UpdateOptions {
424
426
  availableLocales?: string[];
425
427
  }
426
428
  /** Write controls for `SingletonHandle.update()`. */
427
- export interface UpdateSingletonOptions {
428
- /** Locale for field value resolution. Defaults to the client's `defaultLocale`. */
429
+ export type UpdateSingletonOptions = SingletonSavePrecondition & {
430
+ /** Locale for field value resolution. Defaults to the client's defaultLocale. */
429
431
  locale?: string;
430
- /** Reject the save when this is not the slot's current version id. */
431
- expectedVersionId?: string;
432
- }
432
+ };
433
433
  /** Optimistic, absolute-time input for `CollectionHandle.schedulePublish()`. */
434
434
  export interface SchedulePublishOptions {
435
+ expectedRevision: number;
435
436
  /** ISO instant carrying an explicit UTC offset or `Z`. */
436
437
  publishAt: string;
437
438
  /** The current version the editor reviewed and intends to publish. */
@@ -439,6 +440,7 @@ export interface SchedulePublishOptions {
439
440
  }
440
441
  /** Reviewed current version used to re-arm a suspended publication. */
441
442
  export interface ConfirmScheduledPublishOptions {
443
+ expectedRevision: number;
442
444
  expectedVersionId: string;
443
445
  }
444
446
  /**
@@ -459,6 +461,7 @@ export type DocumentPublishScheduleInfo = Omit<DocumentPublishSchedule, 'executi
459
461
  * may be null/omitted (append as last child, or prepend before a given sibling).
460
462
  */
461
463
  export interface PlaceTreeNodeOptions {
464
+ expectedRevision: number;
462
465
  /** The new parent; `null` makes the node a root. */
463
466
  parentDocumentId: string | null;
464
467
  /** Left neighbour — the node lands immediately after it. */
@@ -470,6 +473,7 @@ export interface PlaceTreeNodeOptions {
470
473
  }
471
474
  /** Options for an idempotent tree removal or post-commit hook retry. */
472
475
  export interface RemoveFromTreeOptions {
476
+ expectedRevision: number;
473
477
  /** Re-run `afterTreeChange` when the document is already unplaced. */
474
478
  reconcile?: boolean;
475
479
  }
@@ -706,3 +710,26 @@ export type WithPopulated<F, K extends keyof F, Target> = {
706
710
  export type WithPopulatedMany<F, K extends keyof F, Target> = {
707
711
  [P in keyof F]: P extends K ? Array<PopulatedRelation<Target>> : F[P];
708
712
  };
713
+ /** Only explicit current editable reads carry mutation observations. */
714
+ export interface EditableDocument<F = Record<string, any>> extends ClientDocument<F> {
715
+ revision: number;
716
+ scheduledPublication: DocumentPublishScheduleInfo | null;
717
+ }
718
+ export type EditableSingleton<F = Record<string, any>> = {
719
+ state: 'empty';
720
+ } | {
721
+ state: 'document';
722
+ document: Omit<EditableDocument<F>, 'path'>;
723
+ };
724
+ export interface EditableFindResult<F = Record<string, any>> {
725
+ docs: EditableDocument<F>[];
726
+ meta: FindResult<F>['meta'];
727
+ }
728
+ export interface EditableTreeNode<F = Record<string, any>> {
729
+ document: EditableDocument<F>;
730
+ depth: number;
731
+ children: EditableTreeNode<F>[];
732
+ }
733
+ export type FindByIdForEditOptions<F = Record<string, any>> = Omit<FindByIdOptions<F>, 'status'>;
734
+ export type FindForEditOptions<F = Record<string, any>> = Omit<FindOptions<F>, 'status'>;
735
+ export type GetSubtreeForEditOptions<F = Record<string, any>> = Omit<GetSubtreeOptions<F>, 'status'>;
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": "4.19.0",
5
+ "version": "5.0.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -44,8 +44,8 @@
44
44
  ],
45
45
  "dependencies": {
46
46
  "uuid": "^14.0.2",
47
- "@byline/auth": "4.19.0",
48
- "@byline/core": "4.19.0"
47
+ "@byline/auth": "5.0.0",
48
+ "@byline/core": "5.0.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "2.5.10",
@@ -57,8 +57,8 @@
57
57
  "tsc-alias": "^1.9.2",
58
58
  "typescript": "^7.0.2",
59
59
  "vitest": "^4.1.11",
60
- "@byline/db-postgres": "4.19.0",
61
- "@byline/search-postgres": "4.19.0"
60
+ "@byline/search-postgres": "5.0.0",
61
+ "@byline/db-postgres": "5.0.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public",
@@ -73,6 +73,6 @@
73
73
  "test": "vitest run --mode=node",
74
74
  "test:watch": "vitest --mode=node",
75
75
  "test:integration": "vitest run --mode=integration",
76
- "typecheck": "tsc --noEmit"
76
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json"
77
77
  }
78
78
  }