@byline/core 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/@types/db-types.d.ts +38 -4
- 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 +25 -0
- package/dist/lib/errors.js +42 -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 +25 -0
- package/dist/services/document-lifecycle/committed-hook.js +96 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +6 -21
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +42 -39
- 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 +55 -43
- package/dist/services/document-lifecycle/index.d.ts +5 -1
- package/dist/services/document-lifecycle/index.js +4 -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 +21 -26
- 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 +104 -82
- package/dist/services/document-lifecycle.test.node.js +556 -70
- 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 +75 -73
- 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 +75 -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/document-version-parent.d.ts +20 -0
- package/dist/storage/document-version-parent.js +34 -0
- package/dist/storage/document-version-parent.test.node.d.ts +8 -0
- package/dist/storage/document-version-parent.test.node.js +60 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -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
|
@@ -15,9 +15,10 @@ import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
16
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
17
17
|
import { requireTreeAuditCapability } from './audit.js';
|
|
18
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
18
19
|
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
19
20
|
import { persistInitialDocumentVersion } from './persistence.js';
|
|
20
|
-
import {
|
|
21
|
+
import { assertLifecycleTransactionOwnership } from './revision-guard.js';
|
|
21
22
|
/**
|
|
22
23
|
* Create a new document.
|
|
23
24
|
*
|
|
@@ -36,6 +37,7 @@ export async function createDocument(ctx, params) {
|
|
|
36
37
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'createDocument' }, async () => {
|
|
37
38
|
const { db, definition, collectionPath, defaultLocale } = ctx;
|
|
38
39
|
assertActorCanPerform(ctx.requestContext, definition, 'create');
|
|
40
|
+
assertLifecycleTransactionOwnership(ctx);
|
|
39
41
|
// Reject unsupported tree adapters before hooks, counters, or persistence
|
|
40
42
|
// can create a document that cannot be placed and audited safely.
|
|
41
43
|
if (definition.tree === true)
|
|
@@ -84,30 +86,13 @@ export async function createDocument(ctx, params) {
|
|
|
84
86
|
}).catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale, 'create'));
|
|
85
87
|
const documentId = extractDocumentId(result.document);
|
|
86
88
|
const documentVersionId = extractVersionId(result.document);
|
|
87
|
-
|
|
88
|
-
// a new document is appended as a root (a top-level nav entry) so it is
|
|
89
|
-
// never stranded in the "unplaced" limbo. This is a system step of create
|
|
90
|
-
// (the actor already passed the `create` ability), so it uses the internal
|
|
91
|
-
// placement primitive without an `update` re-assertion or separate tree
|
|
92
|
-
// event (afterCreate covers invalidation). Placement + audit are atomic.
|
|
93
|
-
// Post-version and best-effort: a runtime audit/storage failure leaves the
|
|
94
|
-
// document created-but-unplaced and is logged. Missing capability was
|
|
95
|
-
// rejected before persistence above.
|
|
96
|
-
if (definition.tree === true) {
|
|
97
|
-
try {
|
|
98
|
-
await appendTreeRoot(ctx, documentId);
|
|
99
|
-
}
|
|
100
|
-
catch (err) {
|
|
101
|
-
ctx.logger.error({ err, documentId }, 'failed to auto-place new document in tree');
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
await invokeHook(hooks?.afterCreate, {
|
|
89
|
+
await runCommittedDocumentHook(ctx, { phase: 'afterCreate', documentId, documentVersionId, revision: 1 }, () => invokeHook(hooks?.afterCreate, {
|
|
105
90
|
data,
|
|
106
91
|
collectionPath,
|
|
107
92
|
documentId,
|
|
108
93
|
documentVersionId,
|
|
109
94
|
path: resolvedPath,
|
|
110
|
-
});
|
|
111
|
-
return { documentId, documentVersionId };
|
|
95
|
+
}));
|
|
96
|
+
return { documentId, documentVersionId, revision: 1 };
|
|
112
97
|
});
|
|
113
98
|
}
|
|
@@ -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>;
|
|
@@ -10,15 +10,17 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
13
14
|
import { actorId, invokeHook } from './internals.js';
|
|
14
15
|
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
16
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
15
17
|
/**
|
|
16
18
|
* Remove one content locale's data from a document, in place on the same
|
|
17
19
|
* document, by writing a new immutable version that omits that locale's
|
|
18
20
|
* store rows (every other locale and all non-localized `'all'` rows are
|
|
19
21
|
* carried forward by the storage primitive).
|
|
20
22
|
*
|
|
21
|
-
* The
|
|
23
|
+
* The document's source content locale is its anchor (path + source_locale)
|
|
22
24
|
* and can never be removed — rejected up front. The new version lands as the
|
|
23
25
|
* workflow's default status (a fresh draft), exactly like `copyToLocale`: the
|
|
24
26
|
* previously-published version keeps serving — including the locale being
|
|
@@ -28,7 +30,7 @@ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-c
|
|
|
28
30
|
*
|
|
29
31
|
* Flow:
|
|
30
32
|
* 1. `assertActorCanPerform('update')` — removing a translation is an edit.
|
|
31
|
-
* 2. Reject
|
|
33
|
+
* 2. Reject deletion of the document’s source locale.
|
|
32
34
|
* 3. Read the document in the target locale (validates existence; supplies
|
|
33
35
|
* `originalData` for hooks and the availability set for the presence
|
|
34
36
|
* check).
|
|
@@ -38,34 +40,24 @@ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-c
|
|
|
38
40
|
* 7. `hooks.afterUpdate({ …, deleteLocale: { locale } })`.
|
|
39
41
|
*/
|
|
40
42
|
export async function deleteLocale(ctx, params) {
|
|
43
|
+
params = { ...params };
|
|
41
44
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteLocale' }, async () => {
|
|
42
45
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
43
46
|
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
44
|
-
|
|
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 —
|
|
45
50
|
// it cannot be deleted (the other locales fall back to it).
|
|
46
|
-
|
|
51
|
+
const sourceLocale = target.source_locale ?? defaultLocale;
|
|
52
|
+
if (params.locale === sourceLocale) {
|
|
47
53
|
throw ERR_VALIDATION({
|
|
48
|
-
message: `cannot delete the
|
|
54
|
+
message: `cannot delete the source content locale ('${sourceLocale}')`,
|
|
49
55
|
details: { documentId: params.documentId, locale: params.locale, collectionPath },
|
|
50
56
|
}).log(ctx.logger);
|
|
51
57
|
}
|
|
52
58
|
// Read the document in the locale being removed — validates existence,
|
|
53
59
|
// supplies originalData for hooks, and the availability set for the
|
|
54
60
|
// content-presence check below.
|
|
55
|
-
const target = await db.queries.documents.getDocumentById({
|
|
56
|
-
collection_id: collectionId,
|
|
57
|
-
document_id: params.documentId,
|
|
58
|
-
locale: params.locale,
|
|
59
|
-
reconstruct: true,
|
|
60
|
-
lenient: true,
|
|
61
|
-
requestContext: ctx.requestContext,
|
|
62
|
-
});
|
|
63
|
-
if (target == null) {
|
|
64
|
-
throw ERR_NOT_FOUND({
|
|
65
|
-
message: 'document not found',
|
|
66
|
-
details: { documentId: params.documentId, collectionPath },
|
|
67
|
-
}).log(ctx.logger);
|
|
68
|
-
}
|
|
69
61
|
const targetRecord = target;
|
|
70
62
|
// `_availableVersionLocales` is the derived (path-coverage) set; it is
|
|
71
63
|
// the same source the editor's Delete-Locale picker is built from, so a
|
|
@@ -87,26 +79,36 @@ export async function deleteLocale(ctx, params) {
|
|
|
87
79
|
collectionPath,
|
|
88
80
|
deleteLocale: deleteLocaleMarker,
|
|
89
81
|
});
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 };
|
|
108
103
|
});
|
|
109
|
-
|
|
104
|
+
const result = committed.value;
|
|
105
|
+
const revision = committed.revision;
|
|
106
|
+
await runCommittedDocumentHook(ctx, {
|
|
107
|
+
phase: 'afterUpdate',
|
|
108
|
+
documentId: params.documentId,
|
|
109
|
+
documentVersionId: result.newVersionId,
|
|
110
|
+
revision,
|
|
111
|
+
}, () => invokeHook(hooks?.afterUpdate, {
|
|
110
112
|
data: originalData,
|
|
111
113
|
originalData,
|
|
112
114
|
collectionPath,
|
|
@@ -116,11 +118,12 @@ export async function deleteLocale(ctx, params) {
|
|
|
116
118
|
// never touches it. Read it off the target envelope.
|
|
117
119
|
path: targetRecord.path ?? '',
|
|
118
120
|
deleteLocale: deleteLocaleMarker,
|
|
119
|
-
});
|
|
121
|
+
}));
|
|
120
122
|
return {
|
|
121
123
|
documentId: params.documentId,
|
|
122
124
|
documentVersionId: result.newVersionId,
|
|
123
125
|
locale: params.locale,
|
|
126
|
+
revision,
|
|
124
127
|
};
|
|
125
128
|
});
|
|
126
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,13 +7,14 @@
|
|
|
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';
|
|
14
13
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
14
|
+
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
15
15
|
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
|
|
16
16
|
import { persistInitialDocumentVersion } from './persistence.js';
|
|
17
|
+
import { commitGuardedDocumentMutation, readDocumentForMutation } from './revision-guard.js';
|
|
17
18
|
/**
|
|
18
19
|
* Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
|
|
19
20
|
* duplicate's data tree. Handles both shapes:
|
|
@@ -99,26 +100,21 @@ function deriveDuplicateCandidatePath(definition, fields, defaultLocale, slugifi
|
|
|
99
100
|
* confirmation modal that calls this out.
|
|
100
101
|
*/
|
|
101
102
|
export async function duplicateDocument(ctx, params) {
|
|
103
|
+
params = { ...params };
|
|
102
104
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'duplicateDocument' }, async () => {
|
|
103
105
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
104
106
|
assertActorCanPerform(ctx.requestContext, definition, 'create');
|
|
107
|
+
assertActorCanPerform(ctx.requestContext, definition, 'read');
|
|
105
108
|
const slugifier = ctx.slugifier ?? slugify;
|
|
106
|
-
const hooks = await resolveHooks(definition);
|
|
107
109
|
// 1. Read source with locale='all' — single read, full multi-locale tree.
|
|
108
|
-
const source = await
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const source = await readDocumentForMutation(ctx, {
|
|
111
|
+
documentId: params.sourceDocumentId,
|
|
112
|
+
expectedRevision: params.expectedRevision,
|
|
111
113
|
locale: 'all',
|
|
112
|
-
reconstruct: true,
|
|
113
114
|
lenient: true,
|
|
114
|
-
requestContext: ctx.requestContext,
|
|
115
115
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
message: 'source document not found',
|
|
119
|
-
details: { sourceDocumentId: params.sourceDocumentId, collectionPath },
|
|
120
|
-
}).log(ctx.logger);
|
|
121
|
-
}
|
|
116
|
+
const previousVersionId = source.document_version_id;
|
|
117
|
+
const hooks = await resolveHooks(definition);
|
|
122
118
|
const sourceRecord = source;
|
|
123
119
|
const sourceFields = sourceRecord.fields ?? {};
|
|
124
120
|
// 2. Deep clone — we'll mutate (suffix titles, strip meta ids).
|
|
@@ -159,52 +155,68 @@ export async function duplicateDocument(ctx, params) {
|
|
|
159
155
|
// Embed walker (no-op for multi-locale richtext leaves — see
|
|
160
156
|
// restoreDocumentVersion for the same caveat).
|
|
161
157
|
await applyRichTextEmbed(ctx, clonedFields);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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'));
|
|
175
173
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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;
|
|
191
196
|
const newDocumentId = extractDocumentId(result.document);
|
|
192
197
|
const newDocumentVersionId = extractVersionId(result.document);
|
|
193
198
|
// 8. afterCreate hook with duplicate marker.
|
|
194
|
-
await
|
|
199
|
+
await runCommittedDocumentHook(ctx, {
|
|
200
|
+
phase: 'afterCreate',
|
|
201
|
+
documentId: newDocumentId,
|
|
202
|
+
documentVersionId: newDocumentVersionId,
|
|
203
|
+
revision: 1,
|
|
204
|
+
}, () => invokeHook(hooks?.afterCreate, {
|
|
195
205
|
data: clonedFields,
|
|
196
206
|
collectionPath,
|
|
197
207
|
documentId: newDocumentId,
|
|
198
208
|
documentVersionId: newDocumentVersionId,
|
|
199
209
|
path: finalPath,
|
|
200
210
|
duplicate: duplicateMarker,
|
|
201
|
-
});
|
|
211
|
+
}));
|
|
202
212
|
return {
|
|
203
213
|
documentId: newDocumentId,
|
|
204
214
|
documentVersionId: newDocumentVersionId,
|
|
205
215
|
sourceDocumentId: params.sourceDocumentId,
|
|
206
216
|
newPath: finalPath,
|
|
207
217
|
pathRetried,
|
|
218
|
+
revision: 1,
|
|
219
|
+
sourceRevision: committed.revision,
|
|
208
220
|
};
|
|
209
221
|
});
|
|
210
222
|
}
|
|
@@ -22,17 +22,21 @@
|
|
|
22
22
|
* One module per operation; shared helpers live in `internals.ts` (not
|
|
23
23
|
* re-exported here — the public surface is exactly this barrel).
|
|
24
24
|
*/
|
|
25
|
+
export { getDocumentHookCommittedDetails } from './committed-hook.js';
|
|
25
26
|
export { copyToLocale } from './copy-to-locale.js';
|
|
26
27
|
export { createDocument } from './create.js';
|
|
27
28
|
export { deleteDocument } from './delete.js';
|
|
28
29
|
export { deleteLocale } from './delete-locale.js';
|
|
29
30
|
export { duplicateDocument } from './duplicate.js';
|
|
31
|
+
export { reorderDocument } from './reorder.js';
|
|
30
32
|
export { restoreDocumentVersion } from './restore.js';
|
|
31
33
|
export { cancelDocumentScheduledPublish, confirmDocumentScheduledPublish, getDocumentScheduledPublish, listDocumentPublishSchedules, scheduleDocumentPublish, } from './scheduled-publish.js';
|
|
34
|
+
export { getDocumentRevisionValidationDetails, getDocumentStaleDetails, } from './stale-document.js';
|
|
32
35
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
33
36
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
34
37
|
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
35
|
-
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
38
|
+
export { replaceDocumentFieldsPreservingStatus, saveDocument, updateDocument, updateDocumentWithPatches, } from './update.js';
|
|
39
|
+
export type { DocumentHookCommittedDetails, DocumentHookCommittedPhase, DocumentHookSideEffectCode, } from './committed-hook.js';
|
|
36
40
|
export type { DocumentLifecycleContext } from './context.js';
|
|
37
41
|
export type { CopyToLocaleResult } from './copy-to-locale.js';
|
|
38
42
|
export type { CreateDocumentResult } from './create.js';
|
|
@@ -22,14 +22,17 @@
|
|
|
22
22
|
* One module per operation; shared helpers live in `internals.ts` (not
|
|
23
23
|
* re-exported here — the public surface is exactly this barrel).
|
|
24
24
|
*/
|
|
25
|
+
export { getDocumentHookCommittedDetails } from './committed-hook.js';
|
|
25
26
|
export { copyToLocale } from './copy-to-locale.js';
|
|
26
27
|
export { createDocument } from './create.js';
|
|
27
28
|
export { deleteDocument } from './delete.js';
|
|
28
29
|
export { deleteLocale } from './delete-locale.js';
|
|
29
30
|
export { duplicateDocument } from './duplicate.js';
|
|
31
|
+
export { reorderDocument } from './reorder.js';
|
|
30
32
|
export { restoreDocumentVersion } from './restore.js';
|
|
31
33
|
export { cancelDocumentScheduledPublish, confirmDocumentScheduledPublish, getDocumentScheduledPublish, listDocumentPublishSchedules, scheduleDocumentPublish, } from './scheduled-publish.js';
|
|
34
|
+
export { getDocumentRevisionValidationDetails, getDocumentStaleDetails, } from './stale-document.js';
|
|
32
35
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
33
36
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
34
37
|
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
35
|
-
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>;
|