@byline/core 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.
- package/dist/@types/db-types.d.ts +32 -1
- package/dist/@types/document-revision.d.ts +92 -0
- package/dist/@types/document-revision.js +8 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/config/attach-hooks.test.node.js +2 -1
- package/dist/config/config-hooks.test.node.js +2 -1
- package/dist/core.js +8 -0
- package/dist/core.test.node.js +27 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lib/errors.d.ts +15 -0
- package/dist/lib/errors.js +32 -0
- package/dist/lib/lock-conflict.test.node.d.ts +1 -0
- package/dist/lib/lock-conflict.test.node.js +31 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
- package/dist/scheduler/run-due-tasks.test.node.js +3 -2
- package/dist/scheduler/scheduled-publication.d.ts +16 -1
- package/dist/scheduler/scheduled-publication.js +27 -1
- package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
- package/dist/scheduler/scheduled-publication.test.node.js +18 -3
- package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
- package/dist/scheduler/scheduler-boot.test.node.js +40 -12
- package/dist/scheduler/ticker.test.node.d.ts +0 -7
- package/dist/scheduler/ticker.test.node.js +3 -2
- package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
- package/dist/services/collection-bootstrap.test.node.js +12 -3
- package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
- package/dist/services/discover-counter-groups.test.node.js +19 -3
- package/dist/services/document-lifecycle/audit.d.ts +1 -0
- package/dist/services/document-lifecycle/audit.js +1 -0
- package/dist/services/document-lifecycle/committed-hook.d.ts +2 -1
- package/dist/services/document-lifecycle/committed-hook.js +19 -2
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +4 -20
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +35 -37
- package/dist/services/document-lifecycle/delete.d.ts +8 -2
- package/dist/services/document-lifecycle/delete.js +24 -15
- package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
- package/dist/services/document-lifecycle/duplicate.js +48 -41
- package/dist/services/document-lifecycle/index.d.ts +3 -1
- package/dist/services/document-lifecycle/index.js +3 -1
- package/dist/services/document-lifecycle/persistence.js +18 -7
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
- package/dist/services/document-lifecycle/reorder.d.ts +11 -0
- package/dist/services/document-lifecycle/reorder.js +83 -0
- package/dist/services/document-lifecycle/restore.d.ts +2 -0
- package/dist/services/document-lifecycle/restore.js +19 -25
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
- package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
- package/dist/services/document-lifecycle/revision-guard.js +154 -0
- package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
- package/dist/services/document-lifecycle/same-document-data.js +17 -0
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
- package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
- package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
- package/dist/services/document-lifecycle/stale-document.js +95 -0
- package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
- package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
- package/dist/services/document-lifecycle/status.d.ts +5 -21
- package/dist/services/document-lifecycle/status.js +90 -133
- package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
- package/dist/services/document-lifecycle/system-fields.js +118 -116
- package/dist/services/document-lifecycle/tree.d.ts +9 -6
- package/dist/services/document-lifecycle/tree.js +65 -39
- package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/tree.test.node.js +152 -35
- package/dist/services/document-lifecycle/update.d.ts +14 -6
- package/dist/services/document-lifecycle/update.js +101 -80
- package/dist/services/document-lifecycle.test.node.js +401 -73
- package/dist/services/field-upload.d.ts +1 -0
- package/dist/services/field-upload.js +5 -1
- package/dist/services/field-upload.test.node.d.ts +0 -7
- package/dist/services/field-upload.test.node.js +38 -2
- package/dist/services/populate.test.node.d.ts +0 -7
- package/dist/services/populate.test.node.js +19 -3
- package/dist/services/richtext-populate.test.node.d.ts +0 -7
- package/dist/services/richtext-populate.test.node.js +3 -2
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
- package/dist/services/singleton-lifecycle/index.d.ts +1 -1
- package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
- package/dist/services/singleton-lifecycle/internals.js +74 -77
- package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.js +3 -1
- package/dist/services/singleton-lifecycle/update.d.ts +6 -5
- package/dist/services/singleton-lifecycle/update.js +158 -91
- package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
- package/dist/services/singleton-lifecycle.test.node.js +67 -23
- package/dist/storage/db-adapter.test-helper.d.ts +26 -0
- package/dist/storage/db-adapter.test-helper.js +117 -0
- package/dist/storage/document-revision-capability.d.ts +3 -0
- package/dist/storage/document-revision-capability.js +15 -0
- package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
- package/dist/storage/document-revision-capability.test.node.js +53 -0
- package/dist/storage/document-revision.d.ts +12 -0
- package/dist/storage/document-revision.js +37 -0
- package/dist/storage/document-revision.test.node.d.ts +1 -0
- package/dist/storage/document-revision.test.node.js +48 -0
- package/dist/storage/read-snapshot.d.ts +3 -0
- package/dist/storage/read-snapshot.js +55 -0
- package/dist/storage/revision-store.test-helper.d.ts +3 -0
- package/dist/storage/revision-store.test-helper.js +16 -0
- package/package.json +2 -2
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { DocumentLifecycleContext } from './context.js';
|
|
9
9
|
export interface DeleteLocaleResult {
|
|
10
|
+
revision: number;
|
|
10
11
|
documentId: string;
|
|
11
12
|
/** The newly-created version that omits the deleted locale's content. */
|
|
12
13
|
documentVersionId: string;
|
|
@@ -19,7 +20,7 @@ export interface DeleteLocaleResult {
|
|
|
19
20
|
* store rows (every other locale and all non-localized `'all'` rows are
|
|
20
21
|
* carried forward by the storage primitive).
|
|
21
22
|
*
|
|
22
|
-
* The
|
|
23
|
+
* The document's source content locale is its anchor (path + source_locale)
|
|
23
24
|
* and can never be removed — rejected up front. The new version lands as the
|
|
24
25
|
* workflow's default status (a fresh draft), exactly like `copyToLocale`: the
|
|
25
26
|
* previously-published version keeps serving — including the locale being
|
|
@@ -29,7 +30,7 @@ export interface DeleteLocaleResult {
|
|
|
29
30
|
*
|
|
30
31
|
* Flow:
|
|
31
32
|
* 1. `assertActorCanPerform('update')` — removing a translation is an edit.
|
|
32
|
-
* 2. Reject
|
|
33
|
+
* 2. Reject deletion of the document’s source locale.
|
|
33
34
|
* 3. Read the document in the target locale (validates existence; supplies
|
|
34
35
|
* `originalData` for hooks and the availability set for the presence
|
|
35
36
|
* check).
|
|
@@ -40,5 +41,6 @@ export interface DeleteLocaleResult {
|
|
|
40
41
|
*/
|
|
41
42
|
export declare function deleteLocale(ctx: DocumentLifecycleContext, params: {
|
|
42
43
|
documentId: string;
|
|
44
|
+
expectedRevision: number;
|
|
43
45
|
locale: string;
|
|
44
46
|
}): Promise<DeleteLocaleResult>;
|
|
@@ -13,13 +13,14 @@ import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
|
13
13
|
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
14
14
|
import { actorId, invokeHook } from './internals.js';
|
|
15
15
|
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
16
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
16
17
|
/**
|
|
17
18
|
* Remove one content locale's data from a document, in place on the same
|
|
18
19
|
* document, by writing a new immutable version that omits that locale's
|
|
19
20
|
* store rows (every other locale and all non-localized `'all'` rows are
|
|
20
21
|
* carried forward by the storage primitive).
|
|
21
22
|
*
|
|
22
|
-
* The
|
|
23
|
+
* The document's source content locale is its anchor (path + source_locale)
|
|
23
24
|
* and can never be removed — rejected up front. The new version lands as the
|
|
24
25
|
* workflow's default status (a fresh draft), exactly like `copyToLocale`: the
|
|
25
26
|
* previously-published version keeps serving — including the locale being
|
|
@@ -29,7 +30,7 @@ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-c
|
|
|
29
30
|
*
|
|
30
31
|
* Flow:
|
|
31
32
|
* 1. `assertActorCanPerform('update')` — removing a translation is an edit.
|
|
32
|
-
* 2. Reject
|
|
33
|
+
* 2. Reject deletion of the document’s source locale.
|
|
33
34
|
* 3. Read the document in the target locale (validates existence; supplies
|
|
34
35
|
* `originalData` for hooks and the availability set for the presence
|
|
35
36
|
* check).
|
|
@@ -39,34 +40,24 @@ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-c
|
|
|
39
40
|
* 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
|
|
40
41
|
*/
|
|
41
42
|
export async function deleteLocale(ctx, params) {
|
|
43
|
+
params = { ...params };
|
|
42
44
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteLocale' }, async () => {
|
|
43
45
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
44
46
|
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
45
|
-
|
|
47
|
+
const target = await readDocumentForMutation(ctx, { ...params, lenient: true });
|
|
48
|
+
const previousVersionId = target.document_version_id;
|
|
49
|
+
// The document's source locale anchors its path and fallback reads —
|
|
46
50
|
// it cannot be deleted (the other locales fall back to it).
|
|
47
|
-
|
|
51
|
+
const sourceLocale = target.source_locale ?? defaultLocale;
|
|
52
|
+
if (params.locale === sourceLocale) {
|
|
48
53
|
throw ERR_VALIDATION({
|
|
49
|
-
message: `cannot delete the
|
|
54
|
+
message: `cannot delete the source content locale ('${sourceLocale}')`,
|
|
50
55
|
details: { documentId: params.documentId, locale: params.locale, collectionPath },
|
|
51
56
|
}).log(ctx.logger);
|
|
52
57
|
}
|
|
53
58
|
// Read the document in the locale being removed — validates existence,
|
|
54
59
|
// supplies originalData for hooks, and the availability set for the
|
|
55
60
|
// content-presence check below.
|
|
56
|
-
const target = await db.queries.documents.getDocumentById({
|
|
57
|
-
collection_id: collectionId,
|
|
58
|
-
document_id: params.documentId,
|
|
59
|
-
locale: params.locale,
|
|
60
|
-
reconstruct: true,
|
|
61
|
-
lenient: true,
|
|
62
|
-
requestContext: ctx.requestContext,
|
|
63
|
-
});
|
|
64
|
-
if (target == null) {
|
|
65
|
-
throw ERR_NOT_FOUND({
|
|
66
|
-
message: 'document not found',
|
|
67
|
-
details: { documentId: params.documentId, collectionPath },
|
|
68
|
-
}).log(ctx.logger);
|
|
69
|
-
}
|
|
70
61
|
const targetRecord = target;
|
|
71
62
|
// `_availableVersionLocales` is the derived (path-coverage) set; it is
|
|
72
63
|
// the same source the editor's Delete-Locale picker is built from, so a
|
|
@@ -88,29 +79,35 @@ export async function deleteLocale(ctx, params) {
|
|
|
88
79
|
collectionPath,
|
|
89
80
|
deleteLocale: deleteLocaleMarker,
|
|
90
81
|
});
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
82
|
+
const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId }, async () => {
|
|
83
|
+
const result = await commitContentVersionWithScheduleSuspension({
|
|
84
|
+
ctx,
|
|
85
|
+
documentId: params.documentId,
|
|
86
|
+
write: async () => {
|
|
87
|
+
const deleted = await db.commands.documents.deleteDocumentLocale({
|
|
88
|
+
documentId: params.documentId,
|
|
89
|
+
locale: params.locale,
|
|
90
|
+
status: getDefaultStatus(definition),
|
|
91
|
+
createdBy: actorId(ctx),
|
|
92
|
+
});
|
|
93
|
+
if (deleted == null) {
|
|
94
|
+
throw ERR_NOT_FOUND({
|
|
95
|
+
message: 'document not found',
|
|
96
|
+
details: { documentId: params.documentId, collectionPath },
|
|
97
|
+
}).log(ctx.logger);
|
|
98
|
+
}
|
|
99
|
+
return deleted;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
return { value: result, changed: true };
|
|
109
103
|
});
|
|
104
|
+
const result = committed.value;
|
|
105
|
+
const revision = committed.revision;
|
|
110
106
|
await runCommittedDocumentHook(ctx, {
|
|
111
107
|
phase: 'afterUpdate',
|
|
112
108
|
documentId: params.documentId,
|
|
113
109
|
documentVersionId: result.newVersionId,
|
|
110
|
+
revision,
|
|
114
111
|
}, () => invokeHook(hooks?.afterUpdate, {
|
|
115
112
|
data: originalData,
|
|
116
113
|
originalData,
|
|
@@ -126,6 +123,7 @@ export async function deleteLocale(ctx, params) {
|
|
|
126
123
|
documentId: params.documentId,
|
|
127
124
|
documentVersionId: result.newVersionId,
|
|
128
125
|
locale: params.locale,
|
|
126
|
+
revision,
|
|
129
127
|
};
|
|
130
128
|
});
|
|
131
129
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { ErrorCodes } from '../../lib/errors.js';
|
|
9
|
+
import type { StructuralMutationReceipt } from '../../@types/index.js';
|
|
9
10
|
import type { DocumentLifecycleContext } from './context.js';
|
|
10
11
|
export type DeleteDocumentOutcome = 'committed' | 'committed-with-side-effect-failures';
|
|
11
12
|
export type DeleteDocumentSideEffectPhase = 'afterTreeChange' | 'afterDelete';
|
|
@@ -14,12 +15,16 @@ export interface DeleteDocumentSideEffectFailure {
|
|
|
14
15
|
phase: DeleteDocumentSideEffectPhase;
|
|
15
16
|
code: DeleteDocumentSideEffectCode;
|
|
16
17
|
}
|
|
17
|
-
export interface DeleteDocumentCommittedResult {
|
|
18
|
+
export interface DeleteDocumentCommittedResult extends StructuralMutationReceipt {
|
|
19
|
+
documentId: string;
|
|
20
|
+
revision: number;
|
|
18
21
|
deletedVersionCount: number;
|
|
19
22
|
outcome: 'committed';
|
|
20
23
|
sideEffectFailures: [];
|
|
21
24
|
}
|
|
22
|
-
export interface DeleteDocumentCommittedWithSideEffectFailuresResult {
|
|
25
|
+
export interface DeleteDocumentCommittedWithSideEffectFailuresResult extends StructuralMutationReceipt {
|
|
26
|
+
documentId: string;
|
|
27
|
+
revision: number;
|
|
23
28
|
deletedVersionCount: number;
|
|
24
29
|
outcome: 'committed-with-side-effect-failures';
|
|
25
30
|
sideEffectFailures: [DeleteDocumentSideEffectFailure, ...DeleteDocumentSideEffectFailure[]];
|
|
@@ -42,4 +47,5 @@ export type DeleteDocumentResult = DeleteDocumentCommittedResult | DeleteDocumen
|
|
|
42
47
|
*/
|
|
43
48
|
export declare function deleteDocument(ctx: DocumentLifecycleContext, params: {
|
|
44
49
|
documentId: string;
|
|
50
|
+
expectedRevision: number;
|
|
45
51
|
}): Promise<DeleteDocumentResult>;
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { resolveHooks } from '../../@types/index.js';
|
|
9
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
|
-
import {
|
|
10
|
+
import { ErrorCodes } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { AUDIT_ACTIONS, auditActor, requireAuditCapability, requireTreeAuditCapability, } from './audit.js';
|
|
13
13
|
import { invokeHook } from './internals.js';
|
|
14
14
|
import { appendPublishScheduleCancellationAudit, cancelPublishScheduleInTransaction, } from './publish-schedule-consistency.js';
|
|
15
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
15
16
|
import { firePromoteTreeChange, reconcileTreeOnDeleteInTransaction } from './tree.js';
|
|
16
17
|
function readErrorCode(error) {
|
|
17
18
|
try {
|
|
@@ -47,24 +48,16 @@ function serializeSideEffectFailure(phase, error) {
|
|
|
47
48
|
* 5. `hooks.afterDelete({ documentId, collectionPath })`
|
|
48
49
|
*/
|
|
49
50
|
export async function deleteDocument(ctx, params) {
|
|
51
|
+
params = { ...params };
|
|
50
52
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteDocument' }, async () => {
|
|
51
53
|
const { db, collectionPath, definition, logger } = ctx;
|
|
52
54
|
assertActorCanPerform(ctx.requestContext, definition, 'delete');
|
|
55
|
+
const latest = await readDocumentForMutation(ctx, { ...params, lenient: true });
|
|
56
|
+
const previousVersionId = latest.document_version_id;
|
|
53
57
|
const hooks = await resolveHooks(definition);
|
|
54
58
|
// 1. Verify the document exists. Soft delete retains field rows and
|
|
55
59
|
// uploaded objects, so only the envelope and original path projection
|
|
56
60
|
// are needed for hooks.
|
|
57
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
58
|
-
collection_id: ctx.collectionId,
|
|
59
|
-
document_id: params.documentId,
|
|
60
|
-
reconstruct: false,
|
|
61
|
-
});
|
|
62
|
-
if (latest == null) {
|
|
63
|
-
throw ERR_NOT_FOUND({
|
|
64
|
-
message: 'document not found',
|
|
65
|
-
details: { documentId: params.documentId },
|
|
66
|
-
}).log(ctx.logger);
|
|
67
|
-
}
|
|
68
61
|
const hookCtx = {
|
|
69
62
|
documentId: params.documentId,
|
|
70
63
|
collectionPath,
|
|
@@ -87,7 +80,7 @@ export async function deleteDocument(ctx, params) {
|
|
|
87
80
|
const actor = auditActor(ctx);
|
|
88
81
|
let deletedVersionCount = 0;
|
|
89
82
|
let treeResult;
|
|
90
|
-
await
|
|
83
|
+
const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId }, async () => {
|
|
91
84
|
const cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
|
|
92
85
|
deletedVersionCount = await db.commands.documents.softDeleteDocument({
|
|
93
86
|
document_id: params.documentId,
|
|
@@ -108,7 +101,11 @@ export async function deleteDocument(ctx, params) {
|
|
|
108
101
|
schedule: cancelledSchedule,
|
|
109
102
|
reason: 'soft_deleted',
|
|
110
103
|
});
|
|
111
|
-
|
|
104
|
+
return {
|
|
105
|
+
value: undefined,
|
|
106
|
+
changed: deletedVersionCount > 0 || cancelledSchedule !== null,
|
|
107
|
+
};
|
|
108
|
+
}, { collectionLock: 'exclusive' });
|
|
112
109
|
// Everything below is post-commit. Each operation and the logger get an
|
|
113
110
|
// independent attempt; none can turn the committed delete into a rejection.
|
|
114
111
|
const sideEffectFailures = [];
|
|
@@ -154,10 +151,22 @@ export async function deleteDocument(ctx, params) {
|
|
|
154
151
|
}
|
|
155
152
|
return {
|
|
156
153
|
deletedVersionCount,
|
|
154
|
+
documentId: params.documentId,
|
|
155
|
+
revision: committed.revision,
|
|
156
|
+
affectedDocuments: committed.affectedDocuments,
|
|
157
|
+
scheduledPublicationsNeedReconfirmation: committed.scheduledPublicationsNeedReconfirmation,
|
|
157
158
|
outcome: 'committed-with-side-effect-failures',
|
|
158
159
|
sideEffectFailures: [firstFailure, ...remainingFailures],
|
|
159
160
|
};
|
|
160
161
|
}
|
|
161
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
deletedVersionCount,
|
|
164
|
+
documentId: params.documentId,
|
|
165
|
+
revision: committed.revision,
|
|
166
|
+
affectedDocuments: committed.affectedDocuments,
|
|
167
|
+
scheduledPublicationsNeedReconfirmation: committed.scheduledPublicationsNeedReconfirmation,
|
|
168
|
+
outcome: 'committed',
|
|
169
|
+
sideEffectFailures: [],
|
|
170
|
+
};
|
|
162
171
|
});
|
|
163
172
|
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { DocumentLifecycleContext } from './context.js';
|
|
9
9
|
export interface DuplicateDocumentResult {
|
|
10
|
+
revision: number;
|
|
11
|
+
sourceRevision: number;
|
|
10
12
|
/** The newly-created document's id. */
|
|
11
13
|
documentId: string;
|
|
12
14
|
/** The newly-created version id (every duplicate starts at version 1). */
|
|
@@ -57,4 +59,5 @@ export interface DuplicateDocumentResult {
|
|
|
57
59
|
*/
|
|
58
60
|
export declare function duplicateDocument(ctx: DocumentLifecycleContext, params: {
|
|
59
61
|
sourceDocumentId: string;
|
|
62
|
+
expectedRevision: number;
|
|
60
63
|
}): Promise<DuplicateDocumentResult>;
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { resolveHooks } from '../../@types/index.js';
|
|
9
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
|
-
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
11
10
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
11
|
import { slugify } from '../../utils/slugify.js';
|
|
13
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
@@ -15,6 +14,7 @@ import { assignCounterValues } from '../assign-counter-values.js';
|
|
|
15
14
|
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
16
15
|
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
|
|
17
16
|
import { persistInitialDocumentVersion } from './persistence.js';
|
|
17
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
18
18
|
/**
|
|
19
19
|
* Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
|
|
20
20
|
* duplicate's data tree. Handles both shapes:
|
|
@@ -100,26 +100,21 @@ function deriveDuplicateCandidatePath(definition, fields, defaultLocale, slugifi
|
|
|
100
100
|
* confirmation modal that calls this out.
|
|
101
101
|
*/
|
|
102
102
|
export async function duplicateDocument(ctx, params) {
|
|
103
|
+
params = { ...params };
|
|
103
104
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'duplicateDocument' }, async () => {
|
|
104
105
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
105
106
|
assertActorCanPerform(ctx.requestContext, definition, 'create');
|
|
107
|
+
assertActorCanPerform(ctx.requestContext, definition, 'read');
|
|
106
108
|
const slugifier = ctx.slugifier ?? slugify;
|
|
107
|
-
const hooks = await resolveHooks(definition);
|
|
108
109
|
// 1. Read source with locale='all' — single read, full multi-locale tree.
|
|
109
|
-
const source = await
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
const source = await readDocumentForMutation(ctx, {
|
|
111
|
+
documentId: params.sourceDocumentId,
|
|
112
|
+
expectedRevision: params.expectedRevision,
|
|
112
113
|
locale: 'all',
|
|
113
|
-
reconstruct: true,
|
|
114
114
|
lenient: true,
|
|
115
|
-
requestContext: ctx.requestContext,
|
|
116
115
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
message: 'source document not found',
|
|
120
|
-
details: { sourceDocumentId: params.sourceDocumentId, collectionPath },
|
|
121
|
-
}).log(ctx.logger);
|
|
122
|
-
}
|
|
116
|
+
const previousVersionId = source.document_version_id;
|
|
117
|
+
const hooks = await resolveHooks(definition);
|
|
123
118
|
const sourceRecord = source;
|
|
124
119
|
const sourceFields = sourceRecord.fields ?? {};
|
|
125
120
|
// 2. Deep clone — we'll mutate (suffix titles, strip meta ids).
|
|
@@ -160,35 +155,44 @@ export async function duplicateDocument(ctx, params) {
|
|
|
160
155
|
// Embed walker (no-op for multi-locale richtext leaves — see
|
|
161
156
|
// restoreDocumentVersion for the same caveat).
|
|
162
157
|
await applyRichTextEmbed(ctx, clonedFields);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
158
|
+
const committed = await commitGuardedDocumentMutation(ctx, {
|
|
159
|
+
documentId: params.sourceDocumentId,
|
|
160
|
+
expectedRevision: params.expectedRevision,
|
|
161
|
+
previousVersionId,
|
|
162
|
+
locale: 'all',
|
|
163
|
+
}, async () => {
|
|
164
|
+
try {
|
|
165
|
+
result = await persistInitialDocumentVersion(ctx, {
|
|
166
|
+
action: 'create',
|
|
167
|
+
documentData: clonedFields,
|
|
168
|
+
path: finalPath,
|
|
169
|
+
status: defaultStatus,
|
|
170
|
+
locale: 'all',
|
|
171
|
+
orderKey,
|
|
172
|
+
}).catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale, 'duplicate'));
|
|
176
173
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (!isPathConflictError(err)) {
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
// Single retry with a short UUID suffix. crypto.randomUUID() is
|
|
179
|
+
// 36 chars; take the first 4 hex digits for a compact disambiguator.
|
|
180
|
+
const shortDisambiguator = crypto.randomUUID().slice(0, 4);
|
|
181
|
+
finalPath = `${candidatePath}-${shortDisambiguator}`;
|
|
182
|
+
pathRetried = true;
|
|
183
|
+
ctx.logger?.info({ candidatePath, retryPath: finalPath, sourceDocumentId: params.sourceDocumentId }, 'duplicateDocument: candidate path collided, retrying with short-UUID suffix');
|
|
184
|
+
result = await persistInitialDocumentVersion(ctx, {
|
|
185
|
+
action: 'create',
|
|
186
|
+
documentData: clonedFields,
|
|
187
|
+
path: finalPath,
|
|
188
|
+
status: defaultStatus,
|
|
189
|
+
locale: 'all',
|
|
190
|
+
orderKey,
|
|
191
|
+
}).catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale, 'duplicate'));
|
|
192
|
+
}
|
|
193
|
+
return { value: result, changed: false };
|
|
194
|
+
});
|
|
195
|
+
result = committed.value;
|
|
192
196
|
const newDocumentId = extractDocumentId(result.document);
|
|
193
197
|
const newDocumentVersionId = extractVersionId(result.document);
|
|
194
198
|
// 8. afterCreate hook with duplicate marker.
|
|
@@ -196,6 +200,7 @@ export async function duplicateDocument(ctx, params) {
|
|
|
196
200
|
phase: 'afterCreate',
|
|
197
201
|
documentId: newDocumentId,
|
|
198
202
|
documentVersionId: newDocumentVersionId,
|
|
203
|
+
revision: 1,
|
|
199
204
|
}, () => invokeHook(hooks?.afterCreate, {
|
|
200
205
|
data: clonedFields,
|
|
201
206
|
collectionPath,
|
|
@@ -210,6 +215,8 @@ export async function duplicateDocument(ctx, params) {
|
|
|
210
215
|
sourceDocumentId: params.sourceDocumentId,
|
|
211
216
|
newPath: finalPath,
|
|
212
217
|
pathRetried,
|
|
218
|
+
revision: 1,
|
|
219
|
+
sourceRevision: committed.revision,
|
|
213
220
|
};
|
|
214
221
|
});
|
|
215
222
|
}
|
|
@@ -28,12 +28,14 @@ export { createDocument } from './create.js';
|
|
|
28
28
|
export { deleteDocument } from './delete.js';
|
|
29
29
|
export { deleteLocale } from './delete-locale.js';
|
|
30
30
|
export { duplicateDocument } from './duplicate.js';
|
|
31
|
+
export { reorderDocument } from './reorder.js';
|
|
31
32
|
export { restoreDocumentVersion } from './restore.js';
|
|
32
33
|
export { cancelDocumentScheduledPublish, confirmDocumentScheduledPublish, getDocumentScheduledPublish, listDocumentPublishSchedules, scheduleDocumentPublish, } from './scheduled-publish.js';
|
|
34
|
+
export { getDocumentRevisionValidationDetails, getDocumentStaleDetails, } from './stale-document.js';
|
|
33
35
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
34
36
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
35
37
|
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
36
|
-
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
38
|
+
export { replaceDocumentFieldsPreservingStatus, saveDocument, updateDocument, updateDocumentWithPatches, } from './update.js';
|
|
37
39
|
export type { DocumentHookCommittedDetails, DocumentHookCommittedPhase, DocumentHookSideEffectCode, } from './committed-hook.js';
|
|
38
40
|
export type { DocumentLifecycleContext } from './context.js';
|
|
39
41
|
export type { CopyToLocaleResult } from './copy-to-locale.js';
|
|
@@ -28,9 +28,11 @@ export { createDocument } from './create.js';
|
|
|
28
28
|
export { deleteDocument } from './delete.js';
|
|
29
29
|
export { deleteLocale } from './delete-locale.js';
|
|
30
30
|
export { duplicateDocument } from './duplicate.js';
|
|
31
|
+
export { reorderDocument } from './reorder.js';
|
|
31
32
|
export { restoreDocumentVersion } from './restore.js';
|
|
32
33
|
export { cancelDocumentScheduledPublish, confirmDocumentScheduledPublish, getDocumentScheduledPublish, listDocumentPublishSchedules, scheduleDocumentPublish, } from './scheduled-publish.js';
|
|
34
|
+
export { getDocumentRevisionValidationDetails, getDocumentStaleDetails, } from './stale-document.js';
|
|
33
35
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
34
36
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
35
37
|
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
36
|
-
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
38
|
+
export { replaceDocumentFieldsPreservingStatus, saveDocument, updateDocument, updateDocumentWithPatches, } from './update.js';
|
|
@@ -5,21 +5,30 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { requireAuditCapability } from './audit.js';
|
|
8
9
|
import { actorId } from './internals.js';
|
|
9
10
|
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
11
|
+
import { normalizeDocumentVersionParentError } from './stale-document.js';
|
|
12
|
+
import { appendTreeRoot } from './tree.js';
|
|
10
13
|
/**
|
|
11
14
|
* Persist the first version of a logical document with lifecycle-owned
|
|
12
15
|
* registration and actor metadata. Authentication, hooks, normalization,
|
|
13
16
|
* counters, path derivation, ordering, and rich-text work stay in the caller
|
|
14
17
|
* so their sequencing remains explicit.
|
|
15
18
|
*/
|
|
16
|
-
export function persistInitialDocumentVersion(ctx, write) {
|
|
17
|
-
return ctx.db.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
export async function persistInitialDocumentVersion(ctx, write) {
|
|
20
|
+
return requireAuditCapability(ctx.db).withTransaction(async () => {
|
|
21
|
+
await ctx.db.commands.collections.lockCollectionRegistration(ctx.collectionId, ctx.definition.tree === true || ctx.definition.singleton === true ? 'exclusive' : 'shared');
|
|
22
|
+
const result = await ctx.db.commands.documents.createDocumentVersion({
|
|
23
|
+
...write,
|
|
24
|
+
collectionId: ctx.collectionId,
|
|
25
|
+
collectionVersion: ctx.collectionVersion,
|
|
26
|
+
collectionConfig: ctx.definition,
|
|
27
|
+
createdBy: actorId(ctx),
|
|
28
|
+
});
|
|
29
|
+
if (ctx.definition.tree === true)
|
|
30
|
+
await appendTreeRoot(ctx, result.document.document_id);
|
|
31
|
+
return result;
|
|
23
32
|
});
|
|
24
33
|
}
|
|
25
34
|
/**
|
|
@@ -38,5 +47,7 @@ export function persistExistingDocumentVersion(ctx, write) {
|
|
|
38
47
|
collectionConfig: ctx.definition,
|
|
39
48
|
createdBy: actorId(ctx),
|
|
40
49
|
}),
|
|
50
|
+
}).catch((error) => {
|
|
51
|
+
throw normalizeDocumentVersionParentError(error);
|
|
41
52
|
});
|
|
42
53
|
}
|
|
@@ -34,3 +34,5 @@ export declare function commitContentVersionWithScheduleSuspension<T>(params: {
|
|
|
34
34
|
documentId: string;
|
|
35
35
|
write: () => Promise<T>;
|
|
36
36
|
}): Promise<T>;
|
|
37
|
+
/** Called after the primary writes, under their document lock and transaction. */
|
|
38
|
+
export declare function suspendPublishScheduleForEdit(ctx: DocumentLifecycleContext, audit: AuditCapability, documentId: string, reason: 'content_edited' | 'document_metadata_changed'): Promise<boolean>;
|
|
@@ -12,6 +12,7 @@ export function publishScheduleAuditValue(schedule) {
|
|
|
12
12
|
state: schedule.state,
|
|
13
13
|
publishAt: schedule.publishAt.toISOString(),
|
|
14
14
|
targetVersionId: schedule.targetVersionId,
|
|
15
|
+
authorizedRevision: schedule.authorizedRevision,
|
|
15
16
|
lastAuthorizedBy: schedule.lastAuthorizedBy,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
@@ -49,32 +50,35 @@ export async function appendPublishScheduleCancellationAudit(params) {
|
|
|
49
50
|
*/
|
|
50
51
|
export async function commitContentVersionWithScheduleSuspension(params) {
|
|
51
52
|
const audit = requireAuditCapability(params.ctx.db);
|
|
52
|
-
const actor = auditActor(params.ctx);
|
|
53
53
|
return audit.withTransaction(async () => {
|
|
54
54
|
const result = await params.write();
|
|
55
|
-
|
|
56
|
-
documentId: params.documentId,
|
|
57
|
-
collectionId: params.ctx.collectionId,
|
|
58
|
-
});
|
|
59
|
-
if (suspension.status === 'suspended') {
|
|
60
|
-
await audit.append({
|
|
61
|
-
documentId: params.documentId,
|
|
62
|
-
collectionId: params.ctx.collectionId,
|
|
63
|
-
actorId: actor.actorId,
|
|
64
|
-
actorRealm: actor.actorRealm,
|
|
65
|
-
action: AUDIT_ACTIONS.publishScheduleSuspended,
|
|
66
|
-
field: 'scheduled_publish',
|
|
67
|
-
before: {
|
|
68
|
-
state: 'armed',
|
|
69
|
-
targetVersionId: suspension.schedule.targetVersionId,
|
|
70
|
-
publishAt: suspension.schedule.publishAt.toISOString(),
|
|
71
|
-
},
|
|
72
|
-
after: {
|
|
73
|
-
state: 'needs_reconfirm',
|
|
74
|
-
reason: 'content_edited',
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
}
|
|
55
|
+
await suspendPublishScheduleForEdit(params.ctx, audit, params.documentId, 'content_edited');
|
|
78
56
|
return result;
|
|
79
57
|
});
|
|
80
58
|
}
|
|
59
|
+
/** Called after the primary writes, under their document lock and transaction. */
|
|
60
|
+
export async function suspendPublishScheduleForEdit(ctx, audit, documentId, reason) {
|
|
61
|
+
const actor = auditActor(ctx);
|
|
62
|
+
const suspension = await ctx.db.commands.documents.publishSchedules.suspendForContentEdit({
|
|
63
|
+
documentId,
|
|
64
|
+
collectionId: ctx.collectionId,
|
|
65
|
+
reason,
|
|
66
|
+
});
|
|
67
|
+
if (suspension.status !== 'suspended')
|
|
68
|
+
return false;
|
|
69
|
+
await audit.append({
|
|
70
|
+
documentId,
|
|
71
|
+
collectionId: ctx.collectionId,
|
|
72
|
+
actorId: actor.actorId,
|
|
73
|
+
actorRealm: actor.actorRealm,
|
|
74
|
+
action: AUDIT_ACTIONS.publishScheduleSuspended,
|
|
75
|
+
field: 'scheduled_publish',
|
|
76
|
+
before: {
|
|
77
|
+
state: 'armed',
|
|
78
|
+
targetVersionId: suspension.schedule.targetVersionId,
|
|
79
|
+
publishAt: suspension.schedule.publishAt.toISOString(),
|
|
80
|
+
},
|
|
81
|
+
after: { state: 'needs_reconfirm', reason },
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StructuralMutationReceipt } from '../../@types/index.js';
|
|
2
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
3
|
+
/** Flat ordering and any sibling-key repairs share one guarded transaction. */
|
|
4
|
+
export declare function reorderDocument(ctx: DocumentLifecycleContext, params: {
|
|
5
|
+
documentId: string;
|
|
6
|
+
expectedRevision: number;
|
|
7
|
+
beforeDocumentId?: string | null;
|
|
8
|
+
afterDocumentId?: string | null;
|
|
9
|
+
}): Promise<StructuralMutationReceipt & {
|
|
10
|
+
orderKey: string;
|
|
11
|
+
}>;
|