@byline/client 4.18.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.
- package/dist/collection-handle.d.ts +33 -12
- package/dist/collection-handle.js +293 -25
- package/dist/index.d.ts +2 -2
- package/dist/read-internals.d.ts +2 -0
- package/dist/read-internals.js +2 -0
- package/dist/server/request-context-contract.test.node.js +3 -2
- package/dist/singleton-handle.d.ts +18 -9
- package/dist/singleton-handle.js +32 -14
- package/dist/test-adapter.test-helper.d.ts +26 -0
- package/dist/test-adapter.test-helper.js +117 -0
- package/dist/types.d.ts +33 -6
- package/package.json +6 -6
|
@@ -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
|
|
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<
|
|
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
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
449
|
-
|
|
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, {
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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';
|
package/dist/read-internals.d.ts
CHANGED
|
@@ -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;
|
package/dist/read-internals.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
package/dist/singleton-handle.js
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
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, {
|
|
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
|
|
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
|
|
428
|
-
/** Locale for field value resolution. Defaults to the client's
|
|
429
|
+
export type UpdateSingletonOptions = SingletonSavePrecondition & {
|
|
430
|
+
/** Locale for field value resolution. Defaults to the client's defaultLocale. */
|
|
429
431
|
locale?: string;
|
|
430
|
-
|
|
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": "
|
|
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": "
|
|
48
|
-
"@byline/core": "
|
|
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/search-postgres": "
|
|
61
|
-
"@byline/db-postgres": "
|
|
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
|
}
|