@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,7 +7,9 @@
7
7
  */
8
8
  import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
- import { BylineError, DbErrorCodes, ErrorCodes } from '../lib/errors.js';
10
+ import { BylineError, DbErrorCodes, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ErrorCodes, } from '../lib/errors.js';
11
+ import { runReadSnapshot } from '../storage/read-snapshot.js';
12
+ import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
11
13
  import { cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copyToLocale, createDocument, deleteDocument, deleteLocale, duplicateDocument, listDocumentPublishSchedules, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
12
14
  // ---------------------------------------------------------------------------
13
15
  // Fixtures / Helpers
@@ -38,6 +40,7 @@ function publishScheduleRow(overrides = {}) {
38
40
  documentId: 'doc-1',
39
41
  collectionId: 'col-1',
40
42
  targetVersionId: 'ver-1',
43
+ authorizedRevision: 1,
41
44
  publishAt: new Date('2026-08-23T12:00:00.000Z'),
42
45
  state: 'armed',
43
46
  suspendedAt: null,
@@ -66,7 +69,10 @@ function createMockDb() {
66
69
  const archivePublishedVersions = vi.fn().mockResolvedValue(0);
67
70
  const softDeleteDocument = vi.fn().mockResolvedValue(1);
68
71
  const deleteDocumentLocale = vi.fn().mockResolvedValue({ newVersionId: 'ver-2' });
69
- const getDocumentById = vi.fn().mockResolvedValue(null);
72
+ const getDocumentById = vi.fn().mockImplementation(async () => {
73
+ const metadata = await getCurrentVersionMetadata();
74
+ return metadata == null ? null : { ...metadata, fields: {}, path: await getCurrentPath() };
75
+ });
70
76
  const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
71
77
  const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
72
78
  const getCurrentPath = vi.fn().mockResolvedValue('current-path');
@@ -85,7 +91,20 @@ function createMockDb() {
85
91
  // in unit tests (runs the unit of work immediately, no real tx); `append`
86
92
  // records the calls so write-point tests can assert the audit rows emitted.
87
93
  const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
88
- const withTransaction = vi.fn(async (fn) => fn());
94
+ let transactionDepth = 0;
95
+ const revisions = new Map();
96
+ const transaction = {
97
+ run: async (fn) => {
98
+ transactionDepth++;
99
+ try {
100
+ return await fn();
101
+ }
102
+ finally {
103
+ transactionDepth--;
104
+ }
105
+ },
106
+ };
107
+ const withTransaction = vi.spyOn(transaction, 'run');
89
108
  const fail = () => {
90
109
  throw new Error('unexpected singleton mapping call');
91
110
  };
@@ -103,14 +122,60 @@ function createMockDb() {
103
122
  });
104
123
  const db = {
105
124
  classifyError,
125
+ revisions: {
126
+ ...unusedRevisionStore,
127
+ isInTransaction: () => transactionDepth > 0,
128
+ lock: vi.fn(async (targets) => {
129
+ const observations = [];
130
+ for (const target of targets) {
131
+ const revision = revisions.get(target.documentId) ?? 1;
132
+ if (target.expectedRevision !== revision)
133
+ throw ERR_DOCUMENT_STALE({
134
+ message: 'stale',
135
+ details: {
136
+ reason: 'revision_mismatch',
137
+ documentId: target.documentId,
138
+ expectedRevision: target.expectedRevision,
139
+ currentRevision: revision,
140
+ },
141
+ });
142
+ const system = await getDocumentSystemFieldsForUpdate({
143
+ collection_id: target.collectionId,
144
+ document_id: target.documentId,
145
+ });
146
+ const source = await getDocumentById.mock.results.at(-1)?.value;
147
+ if (!system && !source)
148
+ throw ERR_NOT_FOUND({ message: 'document not found' });
149
+ observations.push({
150
+ documentId: target.documentId,
151
+ collectionId: target.collectionId,
152
+ revision,
153
+ currentVersionId: source?.document_version_id ?? 'ver-1',
154
+ status: source?.status ?? 'draft',
155
+ sourceLocale: system?.source_locale ?? source?.source_locale ?? 'en',
156
+ path: system?.path ?? source?.path ?? null,
157
+ availableLocales: system?.availableLocales ?? source?.availableLocales ?? [],
158
+ });
159
+ }
160
+ return observations;
161
+ }),
162
+ advance: vi.fn(async (locked) => {
163
+ const revision = locked.revision + 1;
164
+ revisions.set(locked.documentId, revision);
165
+ return { documentId: locked.documentId, revision };
166
+ }),
167
+ },
168
+ withReadSnapshot: (fn) => runReadSnapshot(db.queries, fn),
106
169
  commands: {
107
170
  collections: {
171
+ lockCollectionRegistration: vi.fn(async () => { }),
108
172
  create: vi.fn(),
109
173
  update: vi.fn(),
110
174
  delete: vi.fn(),
111
175
  },
112
176
  documents: {
113
177
  publishSchedules: {
178
+ lockDocuments: vi.fn(async () => { }),
114
179
  schedule: publishSchedule,
115
180
  confirm: confirmPublishSchedule,
116
181
  cancel: cancelPublishSchedule,
@@ -150,12 +215,12 @@ function createMockDb() {
150
215
  },
151
216
  audit: { append: auditAppend },
152
217
  singletons: {
153
- lockSlot: vi.fn(fail),
218
+ lockSlot: vi.fn(async () => { }),
154
219
  setMapping: vi.fn(fail),
155
220
  clearMapping: vi.fn(fail),
156
221
  },
157
222
  },
158
- withTransaction: withTransaction,
223
+ withTransaction: transaction.run,
159
224
  queries: {
160
225
  collections: {
161
226
  getAllCollections: vi.fn(),
@@ -167,6 +232,7 @@ function createMockDb() {
167
232
  get: getPublishSchedule,
168
233
  list: listPublishSchedules,
169
234
  },
235
+ getDocumentRevision: async ({ document_id }) => revisions.get(document_id) ?? 1,
170
236
  getDocumentSystemFieldsForUpdate,
171
237
  getDocumentById,
172
238
  getCurrentVersionMetadata,
@@ -559,11 +625,48 @@ describe('Document lifecycle service', () => {
559
625
  // updateDocument (PUT)
560
626
  // -----------------------------------------------------------------------
561
627
  describe('updateDocument', () => {
628
+ it('normalizes a stale parent from persistence without firing success hooks', async () => {
629
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
630
+ getDocumentById.mockResolvedValue({
631
+ document_version_id: 'observed',
632
+ status: 'draft',
633
+ fields: { title: 'Old' },
634
+ });
635
+ const raw = new BylineError(ErrorCodes.CONFLICT, {
636
+ message: 'previous document version is stale',
637
+ details: {
638
+ reason: 'stale',
639
+ documentId: 'doc-1',
640
+ previousVersionId: 'observed',
641
+ currentVersionId: 'concurrent',
642
+ },
643
+ });
644
+ createDocumentVersion.mockRejectedValue(raw);
645
+ const afterUpdate = vi.fn();
646
+ await expect(updateDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUpdate } }), {
647
+ expectedRevision: 1,
648
+ documentId: 'doc-1',
649
+ data: { title: 'New' },
650
+ })).rejects.toMatchObject({
651
+ code: ErrorCodes.DOCUMENT_STALE,
652
+ cause: raw,
653
+ details: {
654
+ reason: 'version_parent_mismatch',
655
+ documentId: 'doc-1',
656
+ previousVersionId: 'observed',
657
+ currentVersionId: 'concurrent',
658
+ },
659
+ });
660
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
661
+ expect(afterUpdate).not.toHaveBeenCalled();
662
+ expect(db.commands.documents.publishSchedules.suspendForContentEdit).not.toHaveBeenCalled();
663
+ });
562
664
  it('passes the acting user id as createdBy for the audit trail', async () => {
563
665
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
564
666
  getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
565
667
  const ctx = buildCtx(db);
566
668
  await updateDocument(ctx, {
669
+ expectedRevision: 1,
567
670
  documentId: 'doc-1',
568
671
  data: { title: 'New' },
569
672
  });
@@ -576,6 +679,7 @@ describe('Document lifecycle service', () => {
576
679
  const definition = { ...minimalCollection, hooks: { beforeUpdate } };
577
680
  const ctx = buildCtx(db, definition);
578
681
  await updateDocument(ctx, {
682
+ expectedRevision: 1,
579
683
  documentId: 'doc-1',
580
684
  data: { title: 'New' },
581
685
  });
@@ -597,6 +701,7 @@ describe('Document lifecycle service', () => {
597
701
  });
598
702
  const definition = { ...numericCollection, hooks: { beforeUpdate } };
599
703
  await updateDocument(buildCtx(db, definition), {
704
+ expectedRevision: 1,
600
705
  documentId: 'doc-1',
601
706
  data: { quantity: '2', score: '2.5e1', price: 4.25 },
602
707
  });
@@ -613,6 +718,7 @@ describe('Document lifecycle service', () => {
613
718
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
614
719
  const ctx = buildCtx(db, definition);
615
720
  await updateDocument(ctx, {
721
+ expectedRevision: 1,
616
722
  documentId: 'doc-1',
617
723
  data: { title: 'New' },
618
724
  });
@@ -627,6 +733,7 @@ describe('Document lifecycle service', () => {
627
733
  getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
628
734
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
629
735
  await expect(updateDocument(buildCtx(db, definition), {
736
+ expectedRevision: 1,
630
737
  documentId: 'doc-1',
631
738
  data: { title: 'New' },
632
739
  })).rejects.toMatchObject({
@@ -650,7 +757,11 @@ describe('Document lifecycle service', () => {
650
757
  });
651
758
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
652
759
  const ctx = buildCtx(db, definition);
653
- await updateDocument(ctx, { documentId: 'doc-1', data: { title: 'New' } });
760
+ await updateDocument(ctx, {
761
+ expectedRevision: 1,
762
+ documentId: 'doc-1',
763
+ data: { title: 'New' },
764
+ });
654
765
  expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'sticky-path' }));
655
766
  });
656
767
  it('afterUpdate surfaces an explicitly-supplied path', async () => {
@@ -664,6 +775,7 @@ describe('Document lifecycle service', () => {
664
775
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
665
776
  const ctx = buildCtx(db, definition);
666
777
  await updateDocument(ctx, {
778
+ expectedRevision: 1,
667
779
  documentId: 'doc-1',
668
780
  data: { title: 'New' },
669
781
  path: 'new-path',
@@ -681,6 +793,7 @@ describe('Document lifecycle service', () => {
681
793
  const definition = { ...minimalCollection, useAsPath: 'title' };
682
794
  const ctx = buildCtx(db, definition);
683
795
  await updateDocument(ctx, {
796
+ expectedRevision: 1,
684
797
  documentId: 'doc-1',
685
798
  data: { title: 'Brand New Title' },
686
799
  });
@@ -698,6 +811,7 @@ describe('Document lifecycle service', () => {
698
811
  });
699
812
  const ctx = buildCtx(db);
700
813
  await updateDocument(ctx, {
814
+ expectedRevision: 1,
701
815
  documentId: 'doc-1',
702
816
  data: { title: 'New' },
703
817
  path: 'manually-set',
@@ -712,6 +826,7 @@ describe('Document lifecycle service', () => {
712
826
  });
713
827
  const ctx = buildCtx(db);
714
828
  await updateDocument(ctx, {
829
+ expectedRevision: 1,
715
830
  documentId: 'doc-1',
716
831
  data: { title: 'Updated' },
717
832
  });
@@ -730,6 +845,7 @@ describe('Document lifecycle service', () => {
730
845
  const warn = ctx.logger?.warn;
731
846
  warn.mockClear();
732
847
  await updateDocument(ctx, {
848
+ expectedRevision: 1,
733
849
  documentId: 'doc-1',
734
850
  data: { title: 'À propos' },
735
851
  locale: 'fr',
@@ -763,6 +879,7 @@ describe('Document lifecycle service', () => {
763
879
  });
764
880
  const ctx = buildCtx(db);
765
881
  await updateDocument(ctx, {
882
+ expectedRevision: 1,
766
883
  documentId: 'doc-1',
767
884
  data: { title: 'Über uns — neu' },
768
885
  locale: 'de',
@@ -782,6 +899,7 @@ describe('Document lifecycle service', () => {
782
899
  const warn = ctx.logger?.warn;
783
900
  warn.mockClear();
784
901
  await updateDocument(ctx, {
902
+ expectedRevision: 1,
785
903
  documentId: 'doc-1',
786
904
  data: { title: 'À propos' },
787
905
  locale: 'fr',
@@ -804,6 +922,7 @@ describe('Document lifecycle service', () => {
804
922
  const ctx = buildCtx(db);
805
923
  try {
806
924
  await updateDocument(ctx, {
925
+ expectedRevision: 1,
807
926
  documentId: 'doc-1',
808
927
  data: { title: 'About' },
809
928
  path: 'home', // collides
@@ -834,21 +953,32 @@ describe('Document lifecycle service', () => {
834
953
  const original = new Error('connection refused');
835
954
  createDocumentVersion.mockRejectedValueOnce(original);
836
955
  const ctx = buildCtx(db);
837
- await expect(updateDocument(ctx, { documentId: 'doc-1', data: { title: 'X' }, path: 'home' })).rejects.toBe(original);
956
+ await expect(updateDocument(ctx, {
957
+ expectedRevision: 1,
958
+ documentId: 'doc-1',
959
+ data: { title: 'X' },
960
+ path: 'home',
961
+ })).rejects.toBe(original);
838
962
  });
839
963
  it('supports an array of beforeUpdate and afterUpdate hooks', async () => {
840
964
  const callOrder = [];
841
965
  const hooks = {
842
966
  beforeUpdate: [
843
967
  vi.fn(async () => {
968
+ expect(db.revisions.isInTransaction()).toBe(false);
844
969
  callOrder.push('before-1');
845
970
  }),
846
971
  vi.fn(async () => {
847
972
  callOrder.push('before-2');
848
973
  }),
849
974
  ],
975
+ afterSystemFieldsChange: () => {
976
+ expect(db.revisions.isInTransaction()).toBe(false);
977
+ callOrder.push('after-system');
978
+ },
850
979
  afterUpdate: [
851
980
  vi.fn(async () => {
981
+ expect(db.revisions.isInTransaction()).toBe(false);
852
982
  callOrder.push('after-1');
853
983
  }),
854
984
  vi.fn(async () => {
@@ -864,8 +994,20 @@ describe('Document lifecycle service', () => {
864
994
  });
865
995
  const definition = { ...minimalCollection, hooks };
866
996
  const ctx = buildCtx(db, definition);
867
- await updateDocument(ctx, { documentId: 'doc-1', data: { title: 'New' } });
868
- expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
997
+ await updateDocument(ctx, {
998
+ expectedRevision: 1,
999
+ documentId: 'doc-1',
1000
+ data: { title: 'New' },
1001
+ availableLocales: ['en', 'fr'],
1002
+ });
1003
+ expect(callOrder).toEqual([
1004
+ 'before-1',
1005
+ 'before-2',
1006
+ 'persist',
1007
+ 'after-system',
1008
+ 'after-1',
1009
+ 'after-2',
1010
+ ]);
869
1011
  });
870
1012
  });
871
1013
  // -----------------------------------------------------------------------
@@ -877,6 +1019,7 @@ describe('Document lifecycle service', () => {
877
1019
  getDocumentById.mockResolvedValue(null);
878
1020
  const ctx = buildCtx(db);
879
1021
  await expect(updateDocumentWithPatches(ctx, {
1022
+ expectedRevision: 1,
880
1023
  documentId: 'doc-missing',
881
1024
  patches: [],
882
1025
  })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
@@ -892,8 +1035,8 @@ describe('Document lifecycle service', () => {
892
1035
  });
893
1036
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
894
1037
  await expect(updateDocumentWithPatches(buildCtx(db, definition), {
1038
+ expectedRevision: 1,
895
1039
  documentId: 'doc-1',
896
- documentVersionId: 'previous-version',
897
1040
  patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
898
1041
  })).rejects.toMatchObject({
899
1042
  code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
@@ -906,18 +1049,14 @@ describe('Document lifecycle service', () => {
906
1049
  });
907
1050
  expect(createDocumentVersion).toHaveBeenCalledOnce();
908
1051
  });
909
- it('throws ERR_CONFLICT on version mismatch', async () => {
1052
+ it('throws ERR_DOCUMENT_STALE on revision mismatch', async () => {
910
1053
  const { db, getDocumentById } = createMockDb();
911
1054
  getDocumentById.mockResolvedValue({
912
1055
  document_version_id: 'ver-current',
913
1056
  fields: { title: 'Old' },
914
1057
  });
915
1058
  const ctx = buildCtx(db);
916
- await expect(updateDocumentWithPatches(ctx, {
917
- documentId: 'doc-1',
918
- patches: [],
919
- documentVersionId: 'ver-stale',
920
- })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.CONFLICT);
1059
+ await expect(updateDocumentWithPatches(ctx, { expectedRevision: 2, documentId: 'doc-1', patches: [] })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.DOCUMENT_STALE);
921
1060
  });
922
1061
  it('throws ERR_PATCH_FAILED when applyPatches returns errors', async () => {
923
1062
  const { db, getDocumentById } = createMockDb();
@@ -925,6 +1064,7 @@ describe('Document lifecycle service', () => {
925
1064
  const ctx = buildCtx(db);
926
1065
  // array.move on a top-level (non-array) field should produce an error
927
1066
  await expect(updateDocumentWithPatches(ctx, {
1067
+ expectedRevision: 1,
928
1068
  documentId: 'doc-1',
929
1069
  patches: [{ kind: 'array.move', path: 'title', itemId: 'x', toIndex: 0 }],
930
1070
  })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.PATCH_FAILED);
@@ -936,6 +1076,7 @@ describe('Document lifecycle service', () => {
936
1076
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
937
1077
  const ctx = buildCtx(db, definition);
938
1078
  await updateDocumentWithPatches(ctx, {
1079
+ expectedRevision: 1,
939
1080
  documentId: 'doc-1',
940
1081
  patches: [{ kind: 'field.set', path: 'title', value: 'Patched' }],
941
1082
  });
@@ -958,6 +1099,7 @@ describe('Document lifecycle service', () => {
958
1099
  createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
959
1100
  const ctx = buildCtx(db);
960
1101
  await expect(updateDocumentWithPatches(ctx, {
1102
+ expectedRevision: 1,
961
1103
  documentId: 'doc-1',
962
1104
  patches: [],
963
1105
  path: 'new-slug',
@@ -983,6 +1125,7 @@ describe('Document lifecycle service', () => {
983
1125
  });
984
1126
  const definition = { ...numericCollection, hooks: { beforeUpdate } };
985
1127
  await updateDocumentWithPatches(buildCtx(db, definition), {
1128
+ expectedRevision: 1,
986
1129
  documentId: 'doc-1',
987
1130
  patches: [
988
1131
  { kind: 'field.set', path: 'quantity', value: '7.0' },
@@ -1015,10 +1158,11 @@ describe('Document lifecycle service', () => {
1015
1158
  publishSchedule.mockResolvedValue({ status: 'scheduled', schedule, previous: null });
1016
1159
  const ctx = buildCtx(db);
1017
1160
  await expect(scheduleDocumentPublish(ctx, {
1161
+ expectedRevision: 1,
1018
1162
  documentId: 'doc-1',
1019
1163
  expectedVersionId: 'ver-1',
1020
1164
  publishAt: schedule.publishAt.toISOString(),
1021
- })).resolves.toEqual(schedule);
1165
+ })).resolves.toEqual({ ...schedule, revision: 2 });
1022
1166
  expect(publishSchedule).toHaveBeenCalledWith(expect.objectContaining({
1023
1167
  documentId: 'doc-1',
1024
1168
  collectionId: 'col-1',
@@ -1035,6 +1179,7 @@ describe('Document lifecycle service', () => {
1035
1179
  }),
1036
1180
  });
1037
1181
  await expect(scheduleDocumentPublish(restricted, {
1182
+ expectedRevision: 1,
1038
1183
  documentId: 'doc-1',
1039
1184
  expectedVersionId: 'ver-1',
1040
1185
  publishAt: schedule.publishAt.toISOString(),
@@ -1045,6 +1190,7 @@ describe('Document lifecycle service', () => {
1045
1190
  getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1046
1191
  publishSchedule.mockResolvedValue({ status: 'publish_at_not_future' });
1047
1192
  await expect(scheduleDocumentPublish(buildCtx(db), {
1193
+ expectedRevision: 1,
1048
1194
  documentId: 'doc-1',
1049
1195
  expectedVersionId: 'ver-1',
1050
1196
  publishAt: new Date(Date.now() + 60_000).toISOString(),
@@ -1054,15 +1200,20 @@ describe('Document lifecycle service', () => {
1054
1200
  document_version_id: 'ver-2',
1055
1201
  });
1056
1202
  await expect(scheduleDocumentPublish(buildCtx(db), {
1203
+ expectedRevision: 1,
1057
1204
  documentId: 'doc-1',
1058
1205
  expectedVersionId: 'ver-1',
1059
1206
  publishAt: new Date(Date.now() + 60_000).toISOString(),
1060
- })).rejects.toMatchObject({ code: ErrorCodes.CONFLICT });
1207
+ })).rejects.toMatchObject({
1208
+ code: ErrorCodes.DOCUMENT_STALE,
1209
+ details: { reason: 'version_parent_mismatch' },
1210
+ });
1061
1211
  });
1062
1212
  it('rejects date strings that are valid to JavaScript but are not ISO instants', async () => {
1063
1213
  const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
1064
1214
  getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1065
1215
  await expect(scheduleDocumentPublish(buildCtx(db), {
1216
+ expectedRevision: 1,
1066
1217
  documentId: 'doc-1',
1067
1218
  expectedVersionId: 'ver-1',
1068
1219
  publishAt: 'August 23, 2026 12:00:00 UTC',
@@ -1085,10 +1236,11 @@ describe('Document lifecycle service', () => {
1085
1236
  });
1086
1237
  cancelPublishSchedule.mockResolvedValue(confirmed);
1087
1238
  await expect(confirmDocumentScheduledPublish(buildCtx(db), {
1239
+ expectedRevision: 1,
1088
1240
  documentId: 'doc-1',
1089
1241
  expectedVersionId: 'ver-1',
1090
- })).resolves.toEqual(confirmed);
1091
- await expect(cancelDocumentScheduledPublish(buildCtx(db), { documentId: 'doc-1' })).resolves.toEqual(confirmed);
1242
+ })).resolves.toEqual({ ...confirmed, revision: 2 });
1243
+ await expect(cancelDocumentScheduledPublish(buildCtx(db), { expectedRevision: 2, documentId: 'doc-1' })).resolves.toEqual({ schedule: confirmed, revision: 3 });
1092
1244
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.reconfirmed' }));
1093
1245
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
1094
1246
  expect(suspended.state).toBe('needs_reconfirm');
@@ -1109,10 +1261,12 @@ describe('Document lifecycle service', () => {
1109
1261
  });
1110
1262
  suspendPublishSchedule.mockResolvedValue({ status: 'suspended', schedule });
1111
1263
  await updateDocument(buildCtx(db), {
1264
+ expectedRevision: 1,
1112
1265
  documentId: 'doc-1',
1113
1266
  data: { title: 'After' },
1114
1267
  });
1115
1268
  expect(suspendPublishSchedule).toHaveBeenCalledWith({
1269
+ reason: 'content_edited',
1116
1270
  documentId: 'doc-1',
1117
1271
  collectionId: 'col-1',
1118
1272
  });
@@ -1135,9 +1289,10 @@ describe('Document lifecycle service', () => {
1135
1289
  suspendedReason: 'content_edited',
1136
1290
  }),
1137
1291
  });
1138
- await deleteLocale(buildCtx(db), { documentId: 'doc-1', locale: 'fr' });
1292
+ await deleteLocale(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1', locale: 'fr' });
1139
1293
  expect(deleteDocumentLocale).toHaveBeenCalledOnce();
1140
1294
  expect(suspendPublishSchedule).toHaveBeenCalledWith({
1295
+ reason: 'content_edited',
1141
1296
  documentId: 'doc-1',
1142
1297
  collectionId: 'col-1',
1143
1298
  });
@@ -1156,7 +1311,11 @@ describe('Document lifecycle service', () => {
1156
1311
  ...minimalCollection,
1157
1312
  hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
1158
1313
  };
1159
- await expect(deleteLocale(buildCtx(db, definition), { documentId: 'doc-1', locale: 'fr' })).rejects.toMatchObject({
1314
+ await expect(deleteLocale(buildCtx(db, definition), {
1315
+ expectedRevision: 1,
1316
+ documentId: 'doc-1',
1317
+ locale: 'fr',
1318
+ })).rejects.toMatchObject({
1160
1319
  code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
1161
1320
  details: {
1162
1321
  phase: 'afterUpdate',
@@ -1172,6 +1331,7 @@ describe('Document lifecycle service', () => {
1172
1331
  getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1173
1332
  cancelPublishSchedule.mockResolvedValue(publishScheduleRow());
1174
1333
  await changeDocumentStatus(buildCtx(db), {
1334
+ expectedRevision: 1,
1175
1335
  documentId: 'doc-1',
1176
1336
  nextStatus: 'published',
1177
1337
  });
@@ -1235,6 +1395,7 @@ describe('Document lifecycle service', () => {
1235
1395
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1236
1396
  const ctx = buildCtx(db);
1237
1397
  const result = await changeDocumentStatus(ctx, {
1398
+ expectedRevision: 1,
1238
1399
  documentId: 'doc-1',
1239
1400
  nextStatus: 'published',
1240
1401
  });
@@ -1249,9 +1410,13 @@ describe('Document lifecycle service', () => {
1249
1410
  const { db, getCurrentVersionMetadata, auditAppend, withTransaction } = createMockDb();
1250
1411
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1251
1412
  const ctx = buildCtx(db);
1252
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1253
- // The mutation + audit row run inside one withTransaction (docs/07-auth-and-security/02-auditability.md).
1254
- expect(withTransaction).toHaveBeenCalledOnce();
1413
+ await changeDocumentStatus(ctx, {
1414
+ expectedRevision: 1,
1415
+ documentId: 'doc-1',
1416
+ nextStatus: 'published',
1417
+ });
1418
+ // The guarded transaction contains a nested status savepoint (docs/07-auth-and-security/02-auditability.md).
1419
+ expect(withTransaction).toHaveBeenCalledTimes(2);
1255
1420
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1256
1421
  documentId: 'doc-1',
1257
1422
  collectionId: 'col-1',
@@ -1267,14 +1432,22 @@ describe('Document lifecycle service', () => {
1267
1432
  const { db, getCurrentVersionMetadata } = createMockDb();
1268
1433
  getCurrentVersionMetadata.mockResolvedValue(null);
1269
1434
  const ctx = buildCtx(db);
1270
- await expect(changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
1435
+ await expect(changeDocumentStatus(ctx, {
1436
+ expectedRevision: 1,
1437
+ documentId: 'doc-1',
1438
+ nextStatus: 'published',
1439
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
1271
1440
  });
1272
1441
  it('throws ERR_INVALID_TRANSITION for an invalid transition', async () => {
1273
1442
  const { db, getCurrentVersionMetadata } = createMockDb();
1274
1443
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1275
1444
  const ctx = buildCtx(db);
1276
1445
  // draft → archived skips 'published', which is not ±1
1277
- await expect(changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'archived' })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
1446
+ await expect(changeDocumentStatus(ctx, {
1447
+ expectedRevision: 1,
1448
+ documentId: 'doc-1',
1449
+ nextStatus: 'archived',
1450
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
1278
1451
  });
1279
1452
  it('invokes beforeStatusChange and afterStatusChange hooks', async () => {
1280
1453
  const callOrder = [];
@@ -1293,7 +1466,11 @@ describe('Document lifecycle service', () => {
1293
1466
  });
1294
1467
  const definition = { ...minimalCollection, hooks };
1295
1468
  const ctx = buildCtx(db, definition);
1296
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1469
+ await changeDocumentStatus(ctx, {
1470
+ expectedRevision: 1,
1471
+ documentId: 'doc-1',
1472
+ nextStatus: 'published',
1473
+ });
1297
1474
  expect(callOrder).toEqual(['before', 'persist', 'after']);
1298
1475
  expect(hooks.beforeStatusChange).toHaveBeenCalledWith(expect.objectContaining({
1299
1476
  previousStatus: 'draft',
@@ -1313,7 +1490,11 @@ describe('Document lifecycle service', () => {
1313
1490
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1314
1491
  const definition = { ...minimalCollection, hooks };
1315
1492
  const ctx = buildCtx(db, definition);
1316
- await expect(changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'archived' })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
1493
+ await expect(changeDocumentStatus(ctx, {
1494
+ expectedRevision: 1,
1495
+ documentId: 'doc-1',
1496
+ nextStatus: 'archived',
1497
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
1317
1498
  expect(hooks.beforeStatusChange).not.toHaveBeenCalled();
1318
1499
  expect(hooks.afterStatusChange).not.toHaveBeenCalled();
1319
1500
  });
@@ -1321,7 +1502,11 @@ describe('Document lifecycle service', () => {
1321
1502
  const { db, getCurrentVersionMetadata, archivePublishedVersions } = createMockDb();
1322
1503
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1323
1504
  const ctx = buildCtx(db);
1324
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1505
+ await changeDocumentStatus(ctx, {
1506
+ expectedRevision: 1,
1507
+ documentId: 'doc-1',
1508
+ nextStatus: 'published',
1509
+ });
1325
1510
  expect(archivePublishedVersions).toHaveBeenCalledWith({
1326
1511
  document_id: 'doc-1',
1327
1512
  excludeVersionId: 'ver-1',
@@ -1354,7 +1539,11 @@ describe('Document lifecycle service', () => {
1354
1539
  });
1355
1540
  const definition = { ...minimalCollection, hooks };
1356
1541
  const ctx = buildCtx(db, definition);
1357
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1542
+ await changeDocumentStatus(ctx, {
1543
+ expectedRevision: 1,
1544
+ documentId: 'doc-1',
1545
+ nextStatus: 'published',
1546
+ });
1358
1547
  expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
1359
1548
  });
1360
1549
  });
@@ -1364,16 +1553,30 @@ describe('Document lifecycle service', () => {
1364
1553
  describe('unpublishDocument', () => {
1365
1554
  it('calls archivePublishedVersions and returns count', async () => {
1366
1555
  const { db, archivePublishedVersions } = createMockDb();
1556
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1557
+ document_id: 'doc-1',
1558
+ document_version_id: 'ver-1',
1559
+ status: 'published',
1560
+ path: 'current-path',
1561
+ fields: {},
1562
+ });
1367
1563
  archivePublishedVersions.mockResolvedValue(1);
1368
1564
  const ctx = buildCtx(db);
1369
- const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
1565
+ const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1370
1566
  expect(result.archivedCount).toBe(1);
1371
1567
  expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
1372
1568
  });
1373
1569
  it('records an atomic published to archived status audit when versions change', async () => {
1374
1570
  const { db, archivePublishedVersions, auditAppend, withTransaction } = createMockDb();
1571
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1572
+ document_id: 'doc-1',
1573
+ document_version_id: 'ver-1',
1574
+ status: 'published',
1575
+ path: 'current-path',
1576
+ fields: {},
1577
+ });
1375
1578
  archivePublishedVersions.mockResolvedValue(2);
1376
- await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
1579
+ await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
1377
1580
  expect(withTransaction).toHaveBeenCalledOnce();
1378
1581
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1379
1582
  documentId: 'doc-1',
@@ -1388,16 +1591,31 @@ describe('Document lifecycle service', () => {
1388
1591
  });
1389
1592
  it('does not record an audit row when no published version changes', async () => {
1390
1593
  const { db, archivePublishedVersions, auditAppend } = createMockDb();
1594
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1595
+ document_id: 'doc-1',
1596
+ document_version_id: 'ver-1',
1597
+ status: 'published',
1598
+ path: 'current-path',
1599
+ fields: {},
1600
+ });
1391
1601
  archivePublishedVersions.mockResolvedValue(0);
1392
- await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
1602
+ await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
1393
1603
  expect(auditAppend).not.toHaveBeenCalled();
1394
1604
  });
1395
1605
  it('does not run afterUnpublish when the atomic audit unit fails', async () => {
1396
1606
  const afterUnpublish = vi.fn();
1397
1607
  const { db, archivePublishedVersions, auditAppend } = createMockDb();
1608
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1609
+ document_id: 'doc-1',
1610
+ document_version_id: 'ver-1',
1611
+ status: 'published',
1612
+ path: 'current-path',
1613
+ fields: {},
1614
+ });
1398
1615
  archivePublishedVersions.mockResolvedValue(1);
1399
1616
  auditAppend.mockRejectedValue(new Error('audit failed'));
1400
1617
  await expect(unpublishDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUnpublish } }), {
1618
+ expectedRevision: 1,
1401
1619
  documentId: 'doc-1',
1402
1620
  })).rejects.toThrow('audit failed');
1403
1621
  expect(afterUnpublish).not.toHaveBeenCalled();
@@ -1413,13 +1631,20 @@ describe('Document lifecycle service', () => {
1413
1631
  }),
1414
1632
  };
1415
1633
  const { db, archivePublishedVersions } = createMockDb();
1634
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1635
+ document_id: 'doc-1',
1636
+ document_version_id: 'ver-1',
1637
+ status: 'published',
1638
+ path: 'current-path',
1639
+ fields: {},
1640
+ });
1416
1641
  archivePublishedVersions.mockImplementation(async () => {
1417
1642
  callOrder.push('archive');
1418
1643
  return 2;
1419
1644
  });
1420
1645
  const definition = { ...minimalCollection, hooks };
1421
1646
  const ctx = buildCtx(db, definition);
1422
- await unpublishDocument(ctx, { documentId: 'doc-1' });
1647
+ await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1423
1648
  expect(callOrder).toEqual(['before', 'archive', 'after']);
1424
1649
  expect(hooks.beforeUnpublish).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'current-path' }));
1425
1650
  expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
@@ -1430,9 +1655,16 @@ describe('Document lifecycle service', () => {
1430
1655
  });
1431
1656
  it('works when no hooks are defined', async () => {
1432
1657
  const { db, archivePublishedVersions } = createMockDb();
1658
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1659
+ document_id: 'doc-1',
1660
+ document_version_id: 'ver-1',
1661
+ status: 'published',
1662
+ path: 'current-path',
1663
+ fields: {},
1664
+ });
1433
1665
  archivePublishedVersions.mockResolvedValue(0);
1434
1666
  const ctx = buildCtx(db);
1435
- const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
1667
+ const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1436
1668
  expect(result.archivedCount).toBe(0);
1437
1669
  });
1438
1670
  it('supports an array of beforeUnpublish and afterUnpublish hooks', async () => {
@@ -1456,13 +1688,20 @@ describe('Document lifecycle service', () => {
1456
1688
  ],
1457
1689
  };
1458
1690
  const { db, archivePublishedVersions } = createMockDb();
1691
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1692
+ document_id: 'doc-1',
1693
+ document_version_id: 'ver-1',
1694
+ status: 'published',
1695
+ path: 'current-path',
1696
+ fields: {},
1697
+ });
1459
1698
  archivePublishedVersions.mockImplementation(async () => {
1460
1699
  callOrder.push('archive');
1461
1700
  return 2;
1462
1701
  });
1463
1702
  const definition = { ...minimalCollection, hooks };
1464
1703
  const ctx = buildCtx(db, definition);
1465
- await unpublishDocument(ctx, { documentId: 'doc-1' });
1704
+ await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1466
1705
  expect(callOrder).toEqual(['before-1', 'before-2', 'archive', 'after-1', 'after-2']);
1467
1706
  });
1468
1707
  });
@@ -1482,7 +1721,7 @@ describe('Document lifecycle service', () => {
1482
1721
  });
1483
1722
  const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
1484
1723
  const ctx = buildCtx(db, definition);
1485
- await deleteDocument(ctx, { documentId: 'doc-1' });
1724
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1486
1725
  expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
1487
1726
  expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
1488
1727
  });
@@ -1495,7 +1734,7 @@ describe('Document lifecycle service', () => {
1495
1734
  fields: {},
1496
1735
  });
1497
1736
  const ctx = buildCtx(db);
1498
- await deleteDocument(ctx, { documentId: 'doc-1' });
1737
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1499
1738
  expect(withTransaction).toHaveBeenCalledOnce();
1500
1739
  expect(softDeleteDocument).toHaveBeenCalledWith({ document_id: 'doc-1' });
1501
1740
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
@@ -1518,7 +1757,7 @@ describe('Document lifecycle service', () => {
1518
1757
  fields: {},
1519
1758
  });
1520
1759
  const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
1521
- await expect(deleteDocument(buildCtx(db, definition), { documentId: 'doc-1' })).rejects.toThrow('pre-commit hook failed');
1760
+ await expect(deleteDocument(buildCtx(db, definition), { expectedRevision: 1, documentId: 'doc-1' })).rejects.toThrow('pre-commit hook failed');
1522
1761
  expect(softDeleteDocument).not.toHaveBeenCalled();
1523
1762
  expect(afterDelete).not.toHaveBeenCalled();
1524
1763
  });
@@ -1564,8 +1803,8 @@ describe('Document lifecycle service', () => {
1564
1803
  ...buildCtx(db, definition),
1565
1804
  storage: { delete: storageDelete },
1566
1805
  };
1567
- await deleteDocument(ctx, { documentId: 'doc-1' });
1568
- expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: false }));
1806
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1807
+ expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
1569
1808
  expect(storageDelete).not.toHaveBeenCalled();
1570
1809
  });
1571
1810
  it('returns only allowlisted failures and keeps raw details in internal logs', async () => {
@@ -1609,9 +1848,13 @@ describe('Document lifecycle service', () => {
1609
1848
  storage: { delete: storageDelete },
1610
1849
  logger: { ...noopLogger, error: vi.fn() },
1611
1850
  };
1612
- const result = await deleteDocument(ctx, { documentId: 'doc-1' });
1851
+ const result = await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1613
1852
  expect(storageDelete).not.toHaveBeenCalled();
1614
1853
  expect(result).toEqual({
1854
+ affectedDocuments: [{ documentId: 'doc-1', revision: 2 }],
1855
+ scheduledPublicationsNeedReconfirmation: false,
1856
+ documentId: 'doc-1',
1857
+ revision: 2,
1615
1858
  deletedVersionCount: 1,
1616
1859
  outcome: 'committed-with-side-effect-failures',
1617
1860
  sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
@@ -1645,6 +1888,7 @@ describe('Document lifecycle service', () => {
1645
1888
  setupDoc(getDocumentSystemFieldsForUpdate);
1646
1889
  const ctx = buildCtx(db);
1647
1890
  const result = await updateDocumentSystemFields(ctx, {
1891
+ expectedRevision: 1,
1648
1892
  documentId: 'doc-1',
1649
1893
  path: 'new-slug',
1650
1894
  });
@@ -1668,6 +1912,7 @@ describe('Document lifecycle service', () => {
1668
1912
  db.commands.documents.updateDocumentPath.mockRejectedValueOnce(pathUniqueViolation());
1669
1913
  const ctx = buildCtx(db);
1670
1914
  await expect(updateDocumentSystemFields(ctx, {
1915
+ expectedRevision: 1,
1671
1916
  documentId: 'doc-1',
1672
1917
  path: 'new-slug',
1673
1918
  })).rejects.toMatchObject({
@@ -1689,6 +1934,7 @@ describe('Document lifecycle service', () => {
1689
1934
  hooks: { afterSystemFieldsChange },
1690
1935
  });
1691
1936
  const result = await updateDocumentSystemFields(ctx, {
1937
+ expectedRevision: 1,
1692
1938
  documentId: 'doc-1',
1693
1939
  path: 'old-slug',
1694
1940
  });
@@ -1703,6 +1949,7 @@ describe('Document lifecycle service', () => {
1703
1949
  setupDoc(getDocumentSystemFieldsForUpdate);
1704
1950
  const ctx = buildCtx(db);
1705
1951
  const result = await updateDocumentSystemFields(ctx, {
1952
+ expectedRevision: 1,
1706
1953
  documentId: 'doc-1',
1707
1954
  availableLocales: ['en', 'fr'],
1708
1955
  });
@@ -1728,6 +1975,7 @@ describe('Document lifecycle service', () => {
1728
1975
  hooks: { afterSystemFieldsChange },
1729
1976
  });
1730
1977
  const result = await updateDocumentSystemFields(ctx, {
1978
+ expectedRevision: 1,
1731
1979
  documentId: 'doc-1',
1732
1980
  availableLocales: ['fr', 'en', 'fr'],
1733
1981
  });
@@ -1741,10 +1989,10 @@ describe('Document lifecycle service', () => {
1741
1989
  const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1742
1990
  setupDoc(getDocumentSystemFieldsForUpdate);
1743
1991
  const order = [];
1744
- db.commands.documents.updateDocumentPath.mockImplementation(async () => {
1992
+ vi.mocked(db.commands.documents.updateDocumentPath).mockImplementation(async () => {
1745
1993
  order.push('path-write');
1746
1994
  });
1747
- db.commands.documents.setDocumentAvailableLocales.mockImplementation(async () => {
1995
+ vi.mocked(db.commands.documents.setDocumentAvailableLocales).mockImplementation(async () => {
1748
1996
  order.push('locales-write');
1749
1997
  });
1750
1998
  auditAppend.mockImplementation(async () => {
@@ -1768,6 +2016,7 @@ describe('Document lifecycle service', () => {
1768
2016
  hooks: { afterSystemFieldsChange: [firstHook, secondHook] },
1769
2017
  });
1770
2018
  await updateDocumentSystemFields(ctx, {
2019
+ expectedRevision: 1,
1771
2020
  documentId: 'doc-1',
1772
2021
  path: 'new-slug',
1773
2022
  availableLocales: ['fr', 'en', 'fr'],
@@ -1803,7 +2052,11 @@ describe('Document lifecycle service', () => {
1803
2052
  ...minimalCollection,
1804
2053
  hooks: { afterSystemFieldsChange },
1805
2054
  });
1806
- await expect(updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' })).rejects.toThrow('audit failed');
2055
+ await expect(updateDocumentSystemFields(ctx, {
2056
+ expectedRevision: 1,
2057
+ documentId: 'doc-1',
2058
+ path: 'new-slug',
2059
+ })).rejects.toThrow('audit failed');
1807
2060
  expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
1808
2061
  expect(afterSystemFieldsChange).not.toHaveBeenCalled();
1809
2062
  });
@@ -1824,7 +2077,14 @@ describe('Document lifecycle service', () => {
1824
2077
  ...minimalCollection,
1825
2078
  hooks: { afterSystemFieldsChange },
1826
2079
  });
1827
- await expect(updateDocumentSystemFields(ctx, { documentId: 'doc-1', availableLocales: ['en', 'fr'] })).rejects.toThrow('cache unavailable');
2080
+ await expect(updateDocumentSystemFields(ctx, {
2081
+ expectedRevision: 1,
2082
+ documentId: 'doc-1',
2083
+ availableLocales: ['en', 'fr'],
2084
+ })).rejects.toMatchObject({
2085
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2086
+ details: { revision: 2 },
2087
+ });
1828
2088
  expect(committed).toBe(true);
1829
2089
  expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
1830
2090
  expect(afterSystemFieldsChange).toHaveBeenCalledOnce();
@@ -1840,9 +2100,17 @@ describe('Document lifecycle service', () => {
1840
2100
  ...minimalCollection,
1841
2101
  hooks: { afterSystemFieldsChange },
1842
2102
  });
1843
- await expect(updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' })).rejects.toThrow('search unavailable');
2103
+ await expect(updateDocumentSystemFields(ctx, {
2104
+ expectedRevision: 1,
2105
+ documentId: 'doc-1',
2106
+ path: 'new-slug',
2107
+ })).rejects.toMatchObject({
2108
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2109
+ details: { revision: 2 },
2110
+ });
1844
2111
  setupDoc(getDocumentSystemFieldsForUpdate, { path: 'new-slug' });
1845
2112
  const retry = await updateDocumentSystemFields(ctx, {
2113
+ expectedRevision: 2,
1846
2114
  documentId: 'doc-1',
1847
2115
  path: 'new-slug',
1848
2116
  reconcile: true,
@@ -1876,6 +2144,7 @@ describe('Document lifecycle service', () => {
1876
2144
  hooks: { afterSystemFieldsChange },
1877
2145
  });
1878
2146
  await updateDocumentSystemFields(ctx, {
2147
+ expectedRevision: 1,
1879
2148
  documentId: 'doc-1',
1880
2149
  path: 'final-path',
1881
2150
  availableLocales: ['de'],
@@ -1909,7 +2178,7 @@ describe('Document lifecycle service', () => {
1909
2178
  };
1910
2179
  const mocks = createMockDb();
1911
2180
  const { db } = mocks;
1912
- db.queries.documents.getDocumentByVersion.mockResolvedValue({
2181
+ vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
1913
2182
  document_version_id: sourceVersionId,
1914
2183
  document_id: sourceDocumentId,
1915
2184
  path: 'long-ago-path',
@@ -1941,6 +2210,7 @@ describe('Document lifecycle service', () => {
1941
2210
  const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
1942
2211
  const ctx = buildCtx(db);
1943
2212
  const result = await restoreDocumentVersion(ctx, {
2213
+ expectedRevision: 1,
1944
2214
  documentId: 'doc-1',
1945
2215
  sourceVersionId,
1946
2216
  });
@@ -1957,13 +2227,18 @@ describe('Document lifecycle service', () => {
1957
2227
  expect(result).toEqual({
1958
2228
  documentId: 'doc-1',
1959
2229
  documentVersionId: 'ver-restored',
2230
+ revision: 2,
1960
2231
  sourceVersionId,
1961
2232
  });
1962
2233
  });
1963
2234
  it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
1964
2235
  const { db, createDocumentVersion, sourceVersionId } = setupRestore();
1965
2236
  const ctx = buildCtx(db);
1966
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2237
+ await restoreDocumentVersion(ctx, {
2238
+ expectedRevision: 1,
2239
+ documentId: 'doc-1',
2240
+ sourceVersionId,
2241
+ });
1967
2242
  // Source version was 'archived'; default status for minimalCollection is 'draft'.
1968
2243
  expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
1969
2244
  });
@@ -1972,7 +2247,11 @@ describe('Document lifecycle service', () => {
1972
2247
  currentPath: 'sticky-path',
1973
2248
  });
1974
2249
  const ctx = buildCtx(db);
1975
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2250
+ await restoreDocumentVersion(ctx, {
2251
+ expectedRevision: 1,
2252
+ documentId: 'doc-1',
2253
+ sourceVersionId,
2254
+ });
1976
2255
  // Restore never changes a document's path: the existing
1977
2256
  // byline_document_paths row carries forward unchanged. The storage
1978
2257
  // primitive only writes to document_paths when `path` is supplied.
@@ -1984,7 +2263,11 @@ describe('Document lifecycle service', () => {
1984
2263
  });
1985
2264
  const ctx = buildCtx(db);
1986
2265
  try {
1987
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2266
+ await restoreDocumentVersion(ctx, {
2267
+ expectedRevision: 1,
2268
+ documentId: 'doc-1',
2269
+ sourceVersionId,
2270
+ });
1988
2271
  expect.fail('expected ERR_VALIDATION');
1989
2272
  }
1990
2273
  catch (err) {
@@ -1997,7 +2280,7 @@ describe('Document lifecycle service', () => {
1997
2280
  const sourceVersionId = 'ver-source';
1998
2281
  const mocks = createMockDb();
1999
2282
  const { db } = mocks;
2000
- db.queries.documents.getDocumentByVersion.mockResolvedValue({
2283
+ vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
2001
2284
  document_version_id: sourceVersionId,
2002
2285
  document_id: 'doc-1',
2003
2286
  path: 'p',
@@ -2015,7 +2298,11 @@ describe('Document lifecycle service', () => {
2015
2298
  });
2016
2299
  const ctx = buildCtx(db);
2017
2300
  try {
2018
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2301
+ await restoreDocumentVersion(ctx, {
2302
+ expectedRevision: 1,
2303
+ documentId: 'doc-1',
2304
+ sourceVersionId,
2305
+ });
2019
2306
  expect.fail('expected ERR_INVALID_TRANSITION');
2020
2307
  }
2021
2308
  catch (err) {
@@ -2029,7 +2316,11 @@ describe('Document lifecycle service', () => {
2029
2316
  const { db, sourceVersionId } = setupRestore();
2030
2317
  const definition = { ...minimalCollection, hooks: { beforeUpdate, afterUpdate } };
2031
2318
  const ctx = buildCtx(db, definition);
2032
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2319
+ await restoreDocumentVersion(ctx, {
2320
+ expectedRevision: 1,
2321
+ documentId: 'doc-1',
2322
+ sourceVersionId,
2323
+ });
2033
2324
  expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
2034
2325
  collectionPath: 'articles',
2035
2326
  restore: { sourceVersionId },
@@ -2052,6 +2343,7 @@ describe('Document lifecycle service', () => {
2052
2343
  hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
2053
2344
  };
2054
2345
  await expect(restoreDocumentVersion(buildCtx(db, definition), {
2346
+ expectedRevision: 1,
2055
2347
  documentId: 'doc-1',
2056
2348
  sourceVersionId,
2057
2349
  })).rejects.toMatchObject({
@@ -2074,7 +2366,11 @@ describe('Document lifecycle service', () => {
2074
2366
  });
2075
2367
  ctx.requestContext = createRequestContext({ actor });
2076
2368
  try {
2077
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2369
+ await restoreDocumentVersion(ctx, {
2370
+ expectedRevision: 1,
2371
+ documentId: 'doc-1',
2372
+ sourceVersionId,
2373
+ });
2078
2374
  expect.fail('expected ERR_FORBIDDEN');
2079
2375
  }
2080
2376
  catch (err) {
@@ -2090,7 +2386,11 @@ describe('Document lifecycle service', () => {
2090
2386
  abilities: ['collections.articles.update', 'collections.articles.read'],
2091
2387
  });
2092
2388
  ctx.requestContext = createRequestContext({ actor });
2093
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2389
+ await restoreDocumentVersion(ctx, {
2390
+ expectedRevision: 1,
2391
+ documentId: 'doc-1',
2392
+ sourceVersionId,
2393
+ });
2094
2394
  expect(createDocumentVersion).toHaveBeenCalledOnce();
2095
2395
  });
2096
2396
  });
@@ -2135,7 +2435,7 @@ describe('Document lifecycle service', () => {
2135
2435
  const mocks = createMockDb();
2136
2436
  const { sourceDocumentId } = setupSource(mocks);
2137
2437
  const ctx = buildCtx(mocks.db, localizedCollection);
2138
- const result = await duplicateDocument(ctx, { sourceDocumentId });
2438
+ const result = await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId });
2139
2439
  expect(mocks.getDocumentById).toHaveBeenCalledWith(expect.objectContaining({
2140
2440
  collection_id: 'col-1',
2141
2441
  document_id: sourceDocumentId,
@@ -2162,7 +2462,7 @@ describe('Document lifecycle service', () => {
2162
2462
  },
2163
2463
  });
2164
2464
  const ctx = buildCtx(mocks.db, localizedCollection);
2165
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2465
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2166
2466
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2167
2467
  expect(call.documentData.title).toEqual({
2168
2468
  en: 'Hello (copy)',
@@ -2187,7 +2487,7 @@ describe('Document lifecycle service', () => {
2187
2487
  },
2188
2488
  });
2189
2489
  const ctx = buildCtx(mocks.db, nonLocalizedCollection);
2190
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2490
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2191
2491
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2192
2492
  expect(call.documentData.title).toBe('Hello (copy)');
2193
2493
  });
@@ -2200,7 +2500,10 @@ describe('Document lifecycle service', () => {
2200
2500
  },
2201
2501
  });
2202
2502
  const ctx = buildCtx(mocks.db, localizedCollection);
2203
- const result = await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2503
+ const result = await duplicateDocument(ctx, {
2504
+ expectedRevision: 1,
2505
+ sourceDocumentId: 'doc-source',
2506
+ });
2204
2507
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2205
2508
  // Default locale is 'en'; "Hello World (copy)" slugifies to "hello-world-copy".
2206
2509
  expect(call.path).toBe('hello-world-copy');
@@ -2226,7 +2529,7 @@ describe('Document lifecycle service', () => {
2226
2529
  },
2227
2530
  });
2228
2531
  const ctx = buildCtx(mocks.db, localizedCollection);
2229
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2532
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2230
2533
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2231
2534
  const sections = call.documentData.sections;
2232
2535
  expect(sections[0]._id).toBeUndefined();
@@ -2258,7 +2561,7 @@ describe('Document lifecycle service', () => {
2258
2561
  fieldCount: 5,
2259
2562
  });
2260
2563
  const ctx = buildCtx(mocks.db, localizedCollection);
2261
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2564
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2262
2565
  // Source title should be unchanged in memory.
2263
2566
  expect(originalFields.title).toEqual({ en: 'Hello', fr: 'Bonjour' });
2264
2567
  expect(originalFields.sections[0]?._id).toBe('sec-1');
@@ -2280,7 +2583,10 @@ describe('Document lifecycle service', () => {
2280
2583
  fieldCount: 5,
2281
2584
  });
2282
2585
  });
2283
- const result = await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2586
+ const result = await duplicateDocument(ctx, {
2587
+ expectedRevision: 1,
2588
+ sourceDocumentId: 'doc-source',
2589
+ });
2284
2590
  expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
2285
2591
  const firstPath = mocks.createDocumentVersion.mock.calls[0]?.[0].path;
2286
2592
  const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
@@ -2301,7 +2607,7 @@ describe('Document lifecycle service', () => {
2301
2607
  });
2302
2608
  let thrown;
2303
2609
  try {
2304
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2610
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2305
2611
  }
2306
2612
  catch (err) {
2307
2613
  thrown = err;
@@ -2324,7 +2630,7 @@ describe('Document lifecycle service', () => {
2324
2630
  mocks.getDocumentById.mockResolvedValue(null);
2325
2631
  const ctx = buildCtx(mocks.db, localizedCollection);
2326
2632
  try {
2327
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-missing' });
2633
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-missing' });
2328
2634
  expect.fail('expected ERR_NOT_FOUND');
2329
2635
  }
2330
2636
  catch (err) {
@@ -2345,7 +2651,7 @@ describe('Document lifecycle service', () => {
2345
2651
  const mocks = createMockDb();
2346
2652
  setupSource(mocks, { sourceDocumentId: 'doc-source' });
2347
2653
  const ctx = buildCtx(mocks.db, withHooks);
2348
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2654
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2349
2655
  expect(beforeCreate).toHaveBeenCalledOnce();
2350
2656
  const beforeCtx = beforeCreate.mock.calls[0]?.[0];
2351
2657
  expect(beforeCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
@@ -2371,7 +2677,10 @@ describe('Document lifecycle service', () => {
2371
2677
  };
2372
2678
  const mocks = createMockDb();
2373
2679
  setupSource(mocks);
2374
- await expect(duplicateDocument(buildCtx(mocks.db, withHooks), { sourceDocumentId: 'doc-source' })).rejects.toMatchObject({
2680
+ await expect(duplicateDocument(buildCtx(mocks.db, withHooks), {
2681
+ expectedRevision: 1,
2682
+ sourceDocumentId: 'doc-source',
2683
+ })).rejects.toMatchObject({
2375
2684
  code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2376
2685
  details: {
2377
2686
  phase: 'afterCreate',
@@ -2393,7 +2702,7 @@ describe('Document lifecycle service', () => {
2393
2702
  });
2394
2703
  ctx.requestContext = createRequestContext({ actor });
2395
2704
  try {
2396
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2705
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2397
2706
  expect.fail('expected ERR_FORBIDDEN');
2398
2707
  }
2399
2708
  catch (err) {
@@ -2408,7 +2717,7 @@ describe('Document lifecycle service', () => {
2408
2717
  const ctx = buildCtx(mocks.db, localizedCollection);
2409
2718
  ctx.requestContext = undefined;
2410
2719
  try {
2411
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2720
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2412
2721
  expect.fail('expected ERR_UNAUTHENTICATED');
2413
2722
  }
2414
2723
  catch (err) {
@@ -2487,6 +2796,7 @@ describe('Document lifecycle service', () => {
2487
2796
  const { mocks, currentVersionId } = setupSourceTarget();
2488
2797
  const ctx = buildCtx(mocks.db, mixedCollection);
2489
2798
  const result = await copyToLocale(ctx, {
2799
+ expectedRevision: 1,
2490
2800
  documentId: 'doc-1',
2491
2801
  sourceLocale: 'en',
2492
2802
  targetLocale: 'fr',
@@ -2526,6 +2836,7 @@ describe('Document lifecycle service', () => {
2526
2836
  });
2527
2837
  const ctx = buildCtx(mocks.db, mixedCollection);
2528
2838
  const result = await copyToLocale(ctx, {
2839
+ expectedRevision: 1,
2529
2840
  documentId: 'doc-1',
2530
2841
  sourceLocale: 'en',
2531
2842
  targetLocale: 'fr',
@@ -2557,6 +2868,7 @@ describe('Document lifecycle service', () => {
2557
2868
  });
2558
2869
  const ctx = buildCtx(mocks.db, mixedCollection);
2559
2870
  await copyToLocale(ctx, {
2871
+ expectedRevision: 1,
2560
2872
  documentId: 'doc-1',
2561
2873
  sourceLocale: 'en',
2562
2874
  targetLocale: 'fr',
@@ -2573,6 +2885,7 @@ describe('Document lifecycle service', () => {
2573
2885
  const { mocks } = setupSourceTarget();
2574
2886
  const ctx = buildCtx(mocks.db, mixedCollection);
2575
2887
  await copyToLocale(ctx, {
2888
+ expectedRevision: 1,
2576
2889
  documentId: 'doc-1',
2577
2890
  sourceLocale: 'en',
2578
2891
  targetLocale: 'fr',
@@ -2585,6 +2898,7 @@ describe('Document lifecycle service', () => {
2585
2898
  const ctx = buildCtx(mocks.db, mixedCollection);
2586
2899
  try {
2587
2900
  await copyToLocale(ctx, {
2901
+ expectedRevision: 1,
2588
2902
  documentId: 'doc-1',
2589
2903
  sourceLocale: 'en',
2590
2904
  targetLocale: 'en',
@@ -2603,6 +2917,7 @@ describe('Document lifecycle service', () => {
2603
2917
  const ctx = buildCtx(mocks.db, mixedCollection);
2604
2918
  try {
2605
2919
  await copyToLocale(ctx, {
2920
+ expectedRevision: 1,
2606
2921
  documentId: 'doc-1',
2607
2922
  sourceLocale: 'en',
2608
2923
  targetLocale: 'fr',
@@ -2633,6 +2948,7 @@ describe('Document lifecycle service', () => {
2633
2948
  const ctx = buildCtx(mocks.db, mixedCollection);
2634
2949
  try {
2635
2950
  await copyToLocale(ctx, {
2951
+ expectedRevision: 1,
2636
2952
  documentId: 'doc-1',
2637
2953
  sourceLocale: 'en',
2638
2954
  targetLocale: 'fr',
@@ -2655,6 +2971,7 @@ describe('Document lifecycle service', () => {
2655
2971
  const { mocks } = setupSourceTarget();
2656
2972
  const ctx = buildCtx(mocks.db, withHooks);
2657
2973
  await copyToLocale(ctx, {
2974
+ expectedRevision: 1,
2658
2975
  documentId: 'doc-1',
2659
2976
  sourceLocale: 'en',
2660
2977
  targetLocale: 'fr',
@@ -2680,6 +2997,7 @@ describe('Document lifecycle service', () => {
2680
2997
  };
2681
2998
  const { mocks } = setupSourceTarget();
2682
2999
  await expect(copyToLocale(buildCtx(mocks.db, withHooks), {
3000
+ expectedRevision: 1,
2683
3001
  documentId: 'doc-1',
2684
3002
  sourceLocale: 'en',
2685
3003
  targetLocale: 'fr',
@@ -2705,6 +3023,7 @@ describe('Document lifecycle service', () => {
2705
3023
  ctx.requestContext = createRequestContext({ actor });
2706
3024
  try {
2707
3025
  await copyToLocale(ctx, {
3026
+ expectedRevision: 1,
2708
3027
  documentId: 'doc-1',
2709
3028
  sourceLocale: 'en',
2710
3029
  targetLocale: 'fr',
@@ -2724,6 +3043,7 @@ describe('Document lifecycle service', () => {
2724
3043
  ctx.requestContext = undefined;
2725
3044
  try {
2726
3045
  await copyToLocale(ctx, {
3046
+ expectedRevision: 1,
2727
3047
  documentId: 'doc-1',
2728
3048
  sourceLocale: 'en',
2729
3049
  targetLocale: 'fr',
@@ -2797,7 +3117,11 @@ describe('Document lifecycle service', () => {
2797
3117
  });
2798
3118
  ctx.requestContext = createRequestContext({ actor });
2799
3119
  try {
2800
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
3120
+ await changeDocumentStatus(ctx, {
3121
+ expectedRevision: 1,
3122
+ documentId: 'doc-1',
3123
+ nextStatus: 'published',
3124
+ });
2801
3125
  expect.fail('expected ERR_FORBIDDEN');
2802
3126
  }
2803
3127
  catch (err) {
@@ -2833,7 +3157,11 @@ describe('Document lifecycle service', () => {
2833
3157
  abilities: ['collections.articles.changeStatus'],
2834
3158
  });
2835
3159
  ctx.requestContext = createRequestContext({ actor });
2836
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'in_review' });
3160
+ await changeDocumentStatus(ctx, {
3161
+ expectedRevision: 1,
3162
+ documentId: 'doc-1',
3163
+ nextStatus: 'in_review',
3164
+ });
2837
3165
  expect(setDocumentStatus).toHaveBeenCalledOnce();
2838
3166
  });
2839
3167
  it('super-admin bypasses every check', async () => {
@@ -2858,7 +3186,7 @@ describe('Document lifecycle service', () => {
2858
3186
  });
2859
3187
  ctx.requestContext = createRequestContext({ actor });
2860
3188
  try {
2861
- await deleteDocument(ctx, { documentId: 'doc-1' });
3189
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
2862
3190
  expect.fail('expected ERR_FORBIDDEN');
2863
3191
  }
2864
3192
  catch (err) {