@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.
Files changed (111) hide show
  1. package/dist/@types/db-types.d.ts +32 -1
  2. package/dist/@types/document-revision.d.ts +92 -0
  3. package/dist/@types/document-revision.js +8 -0
  4. package/dist/@types/index.d.ts +1 -0
  5. package/dist/@types/index.js +1 -0
  6. package/dist/config/attach-hooks.test.node.js +2 -1
  7. package/dist/config/config-hooks.test.node.js +2 -1
  8. package/dist/core.js +8 -0
  9. package/dist/core.test.node.js +27 -2
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.js +3 -1
  12. package/dist/lib/errors.d.ts +15 -0
  13. package/dist/lib/errors.js +32 -0
  14. package/dist/lib/lock-conflict.test.node.d.ts +1 -0
  15. package/dist/lib/lock-conflict.test.node.js +31 -0
  16. package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
  17. package/dist/scheduler/run-due-tasks.test.node.js +3 -2
  18. package/dist/scheduler/scheduled-publication.d.ts +16 -1
  19. package/dist/scheduler/scheduled-publication.js +27 -1
  20. package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
  21. package/dist/scheduler/scheduled-publication.test.node.js +18 -3
  22. package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
  23. package/dist/scheduler/scheduler-boot.test.node.js +40 -12
  24. package/dist/scheduler/ticker.test.node.d.ts +0 -7
  25. package/dist/scheduler/ticker.test.node.js +3 -2
  26. package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
  27. package/dist/services/collection-bootstrap.test.node.js +12 -3
  28. package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
  29. package/dist/services/discover-counter-groups.test.node.js +19 -3
  30. package/dist/services/document-lifecycle/audit.d.ts +1 -0
  31. package/dist/services/document-lifecycle/audit.js +1 -0
  32. package/dist/services/document-lifecycle/committed-hook.d.ts +2 -1
  33. package/dist/services/document-lifecycle/committed-hook.js +19 -2
  34. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  35. package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
  36. package/dist/services/document-lifecycle/create.d.ts +1 -0
  37. package/dist/services/document-lifecycle/create.js +4 -20
  38. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  39. package/dist/services/document-lifecycle/delete-locale.js +35 -37
  40. package/dist/services/document-lifecycle/delete.d.ts +8 -2
  41. package/dist/services/document-lifecycle/delete.js +24 -15
  42. package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
  43. package/dist/services/document-lifecycle/duplicate.js +48 -41
  44. package/dist/services/document-lifecycle/index.d.ts +3 -1
  45. package/dist/services/document-lifecycle/index.js +3 -1
  46. package/dist/services/document-lifecycle/persistence.js +18 -7
  47. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
  48. package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
  49. package/dist/services/document-lifecycle/reorder.d.ts +11 -0
  50. package/dist/services/document-lifecycle/reorder.js +83 -0
  51. package/dist/services/document-lifecycle/restore.d.ts +2 -0
  52. package/dist/services/document-lifecycle/restore.js +19 -25
  53. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
  54. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
  55. package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
  56. package/dist/services/document-lifecycle/revision-guard.js +154 -0
  57. package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
  58. package/dist/services/document-lifecycle/same-document-data.js +17 -0
  59. package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
  60. package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
  61. package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
  62. package/dist/services/document-lifecycle/stale-document.js +95 -0
  63. package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
  64. package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
  65. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
  66. package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
  67. package/dist/services/document-lifecycle/status.d.ts +5 -21
  68. package/dist/services/document-lifecycle/status.js +90 -133
  69. package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
  70. package/dist/services/document-lifecycle/system-fields.js +118 -116
  71. package/dist/services/document-lifecycle/tree.d.ts +9 -6
  72. package/dist/services/document-lifecycle/tree.js +65 -39
  73. package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
  74. package/dist/services/document-lifecycle/tree.test.node.js +152 -35
  75. package/dist/services/document-lifecycle/update.d.ts +14 -6
  76. package/dist/services/document-lifecycle/update.js +101 -80
  77. package/dist/services/document-lifecycle.test.node.js +401 -73
  78. package/dist/services/field-upload.d.ts +1 -0
  79. package/dist/services/field-upload.js +5 -1
  80. package/dist/services/field-upload.test.node.d.ts +0 -7
  81. package/dist/services/field-upload.test.node.js +38 -2
  82. package/dist/services/populate.test.node.d.ts +0 -7
  83. package/dist/services/populate.test.node.js +19 -3
  84. package/dist/services/richtext-populate.test.node.d.ts +0 -7
  85. package/dist/services/richtext-populate.test.node.js +3 -2
  86. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
  87. package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
  88. package/dist/services/singleton-lifecycle/index.d.ts +1 -1
  89. package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
  90. package/dist/services/singleton-lifecycle/internals.js +74 -77
  91. package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
  92. package/dist/services/singleton-lifecycle/restore.js +3 -1
  93. package/dist/services/singleton-lifecycle/update.d.ts +6 -5
  94. package/dist/services/singleton-lifecycle/update.js +158 -91
  95. package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
  96. package/dist/services/singleton-lifecycle.test.node.js +67 -23
  97. package/dist/storage/db-adapter.test-helper.d.ts +26 -0
  98. package/dist/storage/db-adapter.test-helper.js +117 -0
  99. package/dist/storage/document-revision-capability.d.ts +3 -0
  100. package/dist/storage/document-revision-capability.js +15 -0
  101. package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
  102. package/dist/storage/document-revision-capability.test.node.js +53 -0
  103. package/dist/storage/document-revision.d.ts +12 -0
  104. package/dist/storage/document-revision.js +37 -0
  105. package/dist/storage/document-revision.test.node.d.ts +1 -0
  106. package/dist/storage/document-revision.test.node.js +48 -0
  107. package/dist/storage/read-snapshot.d.ts +3 -0
  108. package/dist/storage/read-snapshot.js +55 -0
  109. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  110. package/dist/storage/revision-store.test-helper.js +16 -0
  111. 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 default content locale is the document's anchor (path + source_locale)
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 `locale === defaultLocale`.
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 default content locale is the document's anchor (path + source_locale)
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 `locale === defaultLocale`.
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
- // The default locale anchors the document's path and source_locale —
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
- if (params.locale === defaultLocale) {
51
+ const sourceLocale = target.source_locale ?? defaultLocale;
52
+ if (params.locale === sourceLocale) {
48
53
  throw ERR_VALIDATION({
49
- message: `cannot delete the default content locale ('${defaultLocale}')`,
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 result = await commitContentVersionWithScheduleSuspension({
92
- ctx,
93
- documentId: params.documentId,
94
- write: async () => {
95
- const deleted = await db.commands.documents.deleteDocumentLocale({
96
- documentId: params.documentId,
97
- locale: params.locale,
98
- status: getDefaultStatus(definition),
99
- createdBy: actorId(ctx),
100
- });
101
- if (deleted == null) {
102
- throw ERR_NOT_FOUND({
103
- message: 'document not found',
104
- details: { documentId: params.documentId, collectionPath },
105
- }).log(ctx.logger);
106
- }
107
- return deleted;
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 { ERR_NOT_FOUND, ErrorCodes } from '../../lib/errors.js';
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 audit.withTransaction(async () => {
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 { deletedVersionCount, outcome: 'committed', sideEffectFailures: [] };
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 db.queries.documents.getDocumentById({
110
- collection_id: collectionId,
111
- document_id: params.sourceDocumentId,
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
- if (source == null) {
118
- throw ERR_NOT_FOUND({
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
- try {
164
- result = await persistInitialDocumentVersion(ctx, {
165
- action: 'create',
166
- documentData: clonedFields,
167
- path: finalPath,
168
- status: defaultStatus,
169
- locale: 'all',
170
- orderKey,
171
- }).catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale, 'duplicate'));
172
- }
173
- catch (err) {
174
- if (!isPathConflictError(err)) {
175
- throw err;
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
- // Single retry with a short UUID suffix. crypto.randomUUID() is
178
- // 36 chars; take the first 4 hex digits for a compact disambiguator.
179
- const shortDisambiguator = crypto.randomUUID().slice(0, 4);
180
- finalPath = `${candidatePath}-${shortDisambiguator}`;
181
- pathRetried = true;
182
- ctx.logger?.info({ candidatePath, retryPath: finalPath, sourceDocumentId: params.sourceDocumentId }, 'duplicateDocument: candidate path collided, retrying with short-UUID suffix');
183
- result = await persistInitialDocumentVersion(ctx, {
184
- action: 'create',
185
- documentData: clonedFields,
186
- path: finalPath,
187
- status: defaultStatus,
188
- locale: 'all',
189
- orderKey,
190
- }).catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale, 'duplicate'));
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.commands.documents.createDocumentVersion({
18
- ...write,
19
- collectionId: ctx.collectionId,
20
- collectionVersion: ctx.collectionVersion,
21
- collectionConfig: ctx.definition,
22
- createdBy: actorId(ctx),
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
- const suspension = await params.ctx.db.commands.documents.publishSchedules.suspendForContentEdit({
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
+ }>;