@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.
Files changed (117) hide show
  1. package/dist/@types/db-types.d.ts +38 -4
  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 +25 -0
  13. package/dist/lib/errors.js +42 -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 +25 -0
  33. package/dist/services/document-lifecycle/committed-hook.js +96 -0
  34. package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
  35. package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
  36. package/dist/services/document-lifecycle/create.d.ts +1 -0
  37. package/dist/services/document-lifecycle/create.js +6 -21
  38. package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
  39. package/dist/services/document-lifecycle/delete-locale.js +42 -39
  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 +55 -43
  44. package/dist/services/document-lifecycle/index.d.ts +5 -1
  45. package/dist/services/document-lifecycle/index.js +4 -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 +21 -26
  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 +104 -82
  77. package/dist/services/document-lifecycle.test.node.js +556 -70
  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 +75 -73
  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 +75 -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/document-version-parent.d.ts +20 -0
  108. package/dist/storage/document-version-parent.js +34 -0
  109. package/dist/storage/document-version-parent.test.node.d.ts +8 -0
  110. package/dist/storage/document-version-parent.test.node.js +60 -0
  111. package/dist/storage/index.d.ts +1 -0
  112. package/dist/storage/index.js +1 -0
  113. package/dist/storage/read-snapshot.d.ts +3 -0
  114. package/dist/storage/read-snapshot.js +55 -0
  115. package/dist/storage/revision-store.test-helper.d.ts +3 -0
  116. package/dist/storage/revision-store.test-helper.js +16 -0
  117. 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 { appendTreeRoot } from './tree.js';
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
- // `tree: true` collections place every document in the tree by default:
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 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>;
@@ -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 default content locale is the document's anchor (path + source_locale)
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 `locale === defaultLocale`.
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
- // 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 —
45
50
  // it cannot be deleted (the other locales fall back to it).
46
- if (params.locale === defaultLocale) {
51
+ const sourceLocale = target.source_locale ?? defaultLocale;
52
+ if (params.locale === sourceLocale) {
47
53
  throw ERR_VALIDATION({
48
- message: `cannot delete the default content locale ('${defaultLocale}')`,
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 result = await commitContentVersionWithScheduleSuspension({
91
- ctx,
92
- documentId: params.documentId,
93
- write: async () => {
94
- const deleted = await db.commands.documents.deleteDocumentLocale({
95
- documentId: params.documentId,
96
- locale: params.locale,
97
- status: getDefaultStatus(definition),
98
- createdBy: actorId(ctx),
99
- });
100
- if (deleted == null) {
101
- throw ERR_NOT_FOUND({
102
- message: 'document not found',
103
- details: { documentId: params.documentId, collectionPath },
104
- }).log(ctx.logger);
105
- }
106
- return deleted;
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
- await invokeHook(hooks?.afterUpdate, {
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 { 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,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 db.queries.documents.getDocumentById({
109
- collection_id: collectionId,
110
- document_id: params.sourceDocumentId,
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
- if (source == null) {
117
- throw ERR_NOT_FOUND({
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
- try {
163
- result = await persistInitialDocumentVersion(ctx, {
164
- action: 'create',
165
- documentData: clonedFields,
166
- path: finalPath,
167
- status: defaultStatus,
168
- locale: 'all',
169
- orderKey,
170
- }).catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale, 'duplicate'));
171
- }
172
- catch (err) {
173
- if (!isPathConflictError(err)) {
174
- 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'));
175
173
  }
176
- // Single retry with a short UUID suffix. crypto.randomUUID() is
177
- // 36 chars; take the first 4 hex digits for a compact disambiguator.
178
- const shortDisambiguator = crypto.randomUUID().slice(0, 4);
179
- finalPath = `${candidatePath}-${shortDisambiguator}`;
180
- pathRetried = true;
181
- ctx.logger?.info({ candidatePath, retryPath: finalPath, sourceDocumentId: params.sourceDocumentId }, 'duplicateDocument: candidate path collided, retrying with short-UUID suffix');
182
- result = await persistInitialDocumentVersion(ctx, {
183
- action: 'create',
184
- documentData: clonedFields,
185
- path: finalPath,
186
- status: defaultStatus,
187
- locale: 'all',
188
- orderKey,
189
- }).catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale, 'duplicate'));
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 invokeHook(hooks?.afterCreate, {
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.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>;