@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
@@ -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,
@@ -335,6 +401,36 @@ describe('Document lifecycle service', () => {
335
401
  documentVersionId: 'ver-1',
336
402
  }));
337
403
  });
404
+ it('classifies an afterCreate failure as post-commit and retains committed ids', async () => {
405
+ const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
406
+ const { db, createDocumentVersion } = createMockDb();
407
+ const definition = { ...minimalCollection, hooks: { afterCreate } };
408
+ await expect(createDocument(buildCtx(db, definition), { data: { title: 'X' } })).rejects.toMatchObject({
409
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
410
+ details: {
411
+ phase: 'afterCreate',
412
+ documentId: 'doc-1',
413
+ documentVersionId: 'ver-1',
414
+ sideEffectCode: ErrorCodes.UNHANDLED,
415
+ },
416
+ });
417
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
418
+ });
419
+ it('preserves committed classification when diagnostic logging also fails', async () => {
420
+ const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
421
+ const { db } = createMockDb();
422
+ const definition = { ...minimalCollection, hooks: { afterCreate } };
423
+ const ctx = buildCtx(db, definition);
424
+ ctx.logger = {
425
+ ...noopLogger,
426
+ log: vi.fn(() => {
427
+ throw new Error('logger failed');
428
+ }),
429
+ };
430
+ await expect(createDocument(ctx, { data: { title: 'X' } })).rejects.toMatchObject({
431
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
432
+ });
433
+ });
338
434
  it('afterCreate receives the resolved canonical path', async () => {
339
435
  const afterCreate = vi.fn();
340
436
  const { db } = createMockDb();
@@ -529,11 +625,48 @@ describe('Document lifecycle service', () => {
529
625
  // updateDocument (PUT)
530
626
  // -----------------------------------------------------------------------
531
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
+ });
532
664
  it('passes the acting user id as createdBy for the audit trail', async () => {
533
665
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
534
666
  getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
535
667
  const ctx = buildCtx(db);
536
668
  await updateDocument(ctx, {
669
+ expectedRevision: 1,
537
670
  documentId: 'doc-1',
538
671
  data: { title: 'New' },
539
672
  });
@@ -546,6 +679,7 @@ describe('Document lifecycle service', () => {
546
679
  const definition = { ...minimalCollection, hooks: { beforeUpdate } };
547
680
  const ctx = buildCtx(db, definition);
548
681
  await updateDocument(ctx, {
682
+ expectedRevision: 1,
549
683
  documentId: 'doc-1',
550
684
  data: { title: 'New' },
551
685
  });
@@ -567,6 +701,7 @@ describe('Document lifecycle service', () => {
567
701
  });
568
702
  const definition = { ...numericCollection, hooks: { beforeUpdate } };
569
703
  await updateDocument(buildCtx(db, definition), {
704
+ expectedRevision: 1,
570
705
  documentId: 'doc-1',
571
706
  data: { quantity: '2', score: '2.5e1', price: 4.25 },
572
707
  });
@@ -583,6 +718,7 @@ describe('Document lifecycle service', () => {
583
718
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
584
719
  const ctx = buildCtx(db, definition);
585
720
  await updateDocument(ctx, {
721
+ expectedRevision: 1,
586
722
  documentId: 'doc-1',
587
723
  data: { title: 'New' },
588
724
  });
@@ -591,6 +727,26 @@ describe('Document lifecycle service', () => {
591
727
  documentVersionId: 'ver-1',
592
728
  }));
593
729
  });
730
+ it('classifies an afterUpdate failure as post-commit and retains committed ids', async () => {
731
+ const afterUpdate = vi.fn().mockRejectedValue(new Error('search unavailable'));
732
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
733
+ getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
734
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
735
+ await expect(updateDocument(buildCtx(db, definition), {
736
+ expectedRevision: 1,
737
+ documentId: 'doc-1',
738
+ data: { title: 'New' },
739
+ })).rejects.toMatchObject({
740
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
741
+ details: {
742
+ phase: 'afterUpdate',
743
+ documentId: 'doc-1',
744
+ documentVersionId: 'ver-1',
745
+ sideEffectCode: ErrorCodes.UNHANDLED,
746
+ },
747
+ });
748
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
749
+ });
594
750
  it('afterUpdate carries the sticky path forward when none is supplied', async () => {
595
751
  const afterUpdate = vi.fn();
596
752
  const { db, getDocumentById } = createMockDb();
@@ -601,7 +757,11 @@ describe('Document lifecycle service', () => {
601
757
  });
602
758
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
603
759
  const ctx = buildCtx(db, definition);
604
- 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
+ });
605
765
  expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'sticky-path' }));
606
766
  });
607
767
  it('afterUpdate surfaces an explicitly-supplied path', async () => {
@@ -615,6 +775,7 @@ describe('Document lifecycle service', () => {
615
775
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
616
776
  const ctx = buildCtx(db, definition);
617
777
  await updateDocument(ctx, {
778
+ expectedRevision: 1,
618
779
  documentId: 'doc-1',
619
780
  data: { title: 'New' },
620
781
  path: 'new-path',
@@ -632,6 +793,7 @@ describe('Document lifecycle service', () => {
632
793
  const definition = { ...minimalCollection, useAsPath: 'title' };
633
794
  const ctx = buildCtx(db, definition);
634
795
  await updateDocument(ctx, {
796
+ expectedRevision: 1,
635
797
  documentId: 'doc-1',
636
798
  data: { title: 'Brand New Title' },
637
799
  });
@@ -649,6 +811,7 @@ describe('Document lifecycle service', () => {
649
811
  });
650
812
  const ctx = buildCtx(db);
651
813
  await updateDocument(ctx, {
814
+ expectedRevision: 1,
652
815
  documentId: 'doc-1',
653
816
  data: { title: 'New' },
654
817
  path: 'manually-set',
@@ -663,6 +826,7 @@ describe('Document lifecycle service', () => {
663
826
  });
664
827
  const ctx = buildCtx(db);
665
828
  await updateDocument(ctx, {
829
+ expectedRevision: 1,
666
830
  documentId: 'doc-1',
667
831
  data: { title: 'Updated' },
668
832
  });
@@ -681,6 +845,7 @@ describe('Document lifecycle service', () => {
681
845
  const warn = ctx.logger?.warn;
682
846
  warn.mockClear();
683
847
  await updateDocument(ctx, {
848
+ expectedRevision: 1,
684
849
  documentId: 'doc-1',
685
850
  data: { title: 'À propos' },
686
851
  locale: 'fr',
@@ -714,6 +879,7 @@ describe('Document lifecycle service', () => {
714
879
  });
715
880
  const ctx = buildCtx(db);
716
881
  await updateDocument(ctx, {
882
+ expectedRevision: 1,
717
883
  documentId: 'doc-1',
718
884
  data: { title: 'Über uns — neu' },
719
885
  locale: 'de',
@@ -733,6 +899,7 @@ describe('Document lifecycle service', () => {
733
899
  const warn = ctx.logger?.warn;
734
900
  warn.mockClear();
735
901
  await updateDocument(ctx, {
902
+ expectedRevision: 1,
736
903
  documentId: 'doc-1',
737
904
  data: { title: 'À propos' },
738
905
  locale: 'fr',
@@ -755,6 +922,7 @@ describe('Document lifecycle service', () => {
755
922
  const ctx = buildCtx(db);
756
923
  try {
757
924
  await updateDocument(ctx, {
925
+ expectedRevision: 1,
758
926
  documentId: 'doc-1',
759
927
  data: { title: 'About' },
760
928
  path: 'home', // collides
@@ -785,21 +953,32 @@ describe('Document lifecycle service', () => {
785
953
  const original = new Error('connection refused');
786
954
  createDocumentVersion.mockRejectedValueOnce(original);
787
955
  const ctx = buildCtx(db);
788
- 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);
789
962
  });
790
963
  it('supports an array of beforeUpdate and afterUpdate hooks', async () => {
791
964
  const callOrder = [];
792
965
  const hooks = {
793
966
  beforeUpdate: [
794
967
  vi.fn(async () => {
968
+ expect(db.revisions.isInTransaction()).toBe(false);
795
969
  callOrder.push('before-1');
796
970
  }),
797
971
  vi.fn(async () => {
798
972
  callOrder.push('before-2');
799
973
  }),
800
974
  ],
975
+ afterSystemFieldsChange: () => {
976
+ expect(db.revisions.isInTransaction()).toBe(false);
977
+ callOrder.push('after-system');
978
+ },
801
979
  afterUpdate: [
802
980
  vi.fn(async () => {
981
+ expect(db.revisions.isInTransaction()).toBe(false);
803
982
  callOrder.push('after-1');
804
983
  }),
805
984
  vi.fn(async () => {
@@ -815,8 +994,20 @@ describe('Document lifecycle service', () => {
815
994
  });
816
995
  const definition = { ...minimalCollection, hooks };
817
996
  const ctx = buildCtx(db, definition);
818
- await updateDocument(ctx, { documentId: 'doc-1', data: { title: 'New' } });
819
- 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
+ ]);
820
1011
  });
821
1012
  });
822
1013
  // -----------------------------------------------------------------------
@@ -828,22 +1019,44 @@ describe('Document lifecycle service', () => {
828
1019
  getDocumentById.mockResolvedValue(null);
829
1020
  const ctx = buildCtx(db);
830
1021
  await expect(updateDocumentWithPatches(ctx, {
1022
+ expectedRevision: 1,
831
1023
  documentId: 'doc-missing',
832
1024
  patches: [],
833
1025
  })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
834
1026
  });
835
- it('throws ERR_CONFLICT on version mismatch', async () => {
1027
+ it('classifies a post-patch afterUpdate failure without replaying the version write', async () => {
1028
+ const afterUpdate = vi
1029
+ .fn()
1030
+ .mockRejectedValue(Object.assign(new Error('storage unavailable'), { code: ErrorCodes.STORAGE }));
1031
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
1032
+ getDocumentById.mockResolvedValue({
1033
+ document_version_id: 'previous-version',
1034
+ fields: { title: 'Old' },
1035
+ });
1036
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
1037
+ await expect(updateDocumentWithPatches(buildCtx(db, definition), {
1038
+ expectedRevision: 1,
1039
+ documentId: 'doc-1',
1040
+ patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
1041
+ })).rejects.toMatchObject({
1042
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
1043
+ details: {
1044
+ phase: 'afterUpdate',
1045
+ documentId: 'doc-1',
1046
+ documentVersionId: 'ver-1',
1047
+ sideEffectCode: ErrorCodes.STORAGE,
1048
+ },
1049
+ });
1050
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
1051
+ });
1052
+ it('throws ERR_DOCUMENT_STALE on revision mismatch', async () => {
836
1053
  const { db, getDocumentById } = createMockDb();
837
1054
  getDocumentById.mockResolvedValue({
838
1055
  document_version_id: 'ver-current',
839
1056
  fields: { title: 'Old' },
840
1057
  });
841
1058
  const ctx = buildCtx(db);
842
- await expect(updateDocumentWithPatches(ctx, {
843
- documentId: 'doc-1',
844
- patches: [],
845
- documentVersionId: 'ver-stale',
846
- })).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);
847
1060
  });
848
1061
  it('throws ERR_PATCH_FAILED when applyPatches returns errors', async () => {
849
1062
  const { db, getDocumentById } = createMockDb();
@@ -851,6 +1064,7 @@ describe('Document lifecycle service', () => {
851
1064
  const ctx = buildCtx(db);
852
1065
  // array.move on a top-level (non-array) field should produce an error
853
1066
  await expect(updateDocumentWithPatches(ctx, {
1067
+ expectedRevision: 1,
854
1068
  documentId: 'doc-1',
855
1069
  patches: [{ kind: 'array.move', path: 'title', itemId: 'x', toIndex: 0 }],
856
1070
  })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.PATCH_FAILED);
@@ -862,6 +1076,7 @@ describe('Document lifecycle service', () => {
862
1076
  const definition = { ...minimalCollection, hooks: { afterUpdate } };
863
1077
  const ctx = buildCtx(db, definition);
864
1078
  await updateDocumentWithPatches(ctx, {
1079
+ expectedRevision: 1,
865
1080
  documentId: 'doc-1',
866
1081
  patches: [{ kind: 'field.set', path: 'title', value: 'Patched' }],
867
1082
  });
@@ -884,6 +1099,7 @@ describe('Document lifecycle service', () => {
884
1099
  createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
885
1100
  const ctx = buildCtx(db);
886
1101
  await expect(updateDocumentWithPatches(ctx, {
1102
+ expectedRevision: 1,
887
1103
  documentId: 'doc-1',
888
1104
  patches: [],
889
1105
  path: 'new-slug',
@@ -909,6 +1125,7 @@ describe('Document lifecycle service', () => {
909
1125
  });
910
1126
  const definition = { ...numericCollection, hooks: { beforeUpdate } };
911
1127
  await updateDocumentWithPatches(buildCtx(db, definition), {
1128
+ expectedRevision: 1,
912
1129
  documentId: 'doc-1',
913
1130
  patches: [
914
1131
  { kind: 'field.set', path: 'quantity', value: '7.0' },
@@ -941,10 +1158,11 @@ describe('Document lifecycle service', () => {
941
1158
  publishSchedule.mockResolvedValue({ status: 'scheduled', schedule, previous: null });
942
1159
  const ctx = buildCtx(db);
943
1160
  await expect(scheduleDocumentPublish(ctx, {
1161
+ expectedRevision: 1,
944
1162
  documentId: 'doc-1',
945
1163
  expectedVersionId: 'ver-1',
946
1164
  publishAt: schedule.publishAt.toISOString(),
947
- })).resolves.toEqual(schedule);
1165
+ })).resolves.toEqual({ ...schedule, revision: 2 });
948
1166
  expect(publishSchedule).toHaveBeenCalledWith(expect.objectContaining({
949
1167
  documentId: 'doc-1',
950
1168
  collectionId: 'col-1',
@@ -961,6 +1179,7 @@ describe('Document lifecycle service', () => {
961
1179
  }),
962
1180
  });
963
1181
  await expect(scheduleDocumentPublish(restricted, {
1182
+ expectedRevision: 1,
964
1183
  documentId: 'doc-1',
965
1184
  expectedVersionId: 'ver-1',
966
1185
  publishAt: schedule.publishAt.toISOString(),
@@ -971,6 +1190,7 @@ describe('Document lifecycle service', () => {
971
1190
  getCurrentVersionMetadata.mockResolvedValue(metadataRow);
972
1191
  publishSchedule.mockResolvedValue({ status: 'publish_at_not_future' });
973
1192
  await expect(scheduleDocumentPublish(buildCtx(db), {
1193
+ expectedRevision: 1,
974
1194
  documentId: 'doc-1',
975
1195
  expectedVersionId: 'ver-1',
976
1196
  publishAt: new Date(Date.now() + 60_000).toISOString(),
@@ -980,15 +1200,20 @@ describe('Document lifecycle service', () => {
980
1200
  document_version_id: 'ver-2',
981
1201
  });
982
1202
  await expect(scheduleDocumentPublish(buildCtx(db), {
1203
+ expectedRevision: 1,
983
1204
  documentId: 'doc-1',
984
1205
  expectedVersionId: 'ver-1',
985
1206
  publishAt: new Date(Date.now() + 60_000).toISOString(),
986
- })).rejects.toMatchObject({ code: ErrorCodes.CONFLICT });
1207
+ })).rejects.toMatchObject({
1208
+ code: ErrorCodes.DOCUMENT_STALE,
1209
+ details: { reason: 'version_parent_mismatch' },
1210
+ });
987
1211
  });
988
1212
  it('rejects date strings that are valid to JavaScript but are not ISO instants', async () => {
989
1213
  const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
990
1214
  getCurrentVersionMetadata.mockResolvedValue(metadataRow);
991
1215
  await expect(scheduleDocumentPublish(buildCtx(db), {
1216
+ expectedRevision: 1,
992
1217
  documentId: 'doc-1',
993
1218
  expectedVersionId: 'ver-1',
994
1219
  publishAt: 'August 23, 2026 12:00:00 UTC',
@@ -1011,10 +1236,11 @@ describe('Document lifecycle service', () => {
1011
1236
  });
1012
1237
  cancelPublishSchedule.mockResolvedValue(confirmed);
1013
1238
  await expect(confirmDocumentScheduledPublish(buildCtx(db), {
1239
+ expectedRevision: 1,
1014
1240
  documentId: 'doc-1',
1015
1241
  expectedVersionId: 'ver-1',
1016
- })).resolves.toEqual(confirmed);
1017
- 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 });
1018
1244
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.reconfirmed' }));
1019
1245
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
1020
1246
  expect(suspended.state).toBe('needs_reconfirm');
@@ -1035,10 +1261,12 @@ describe('Document lifecycle service', () => {
1035
1261
  });
1036
1262
  suspendPublishSchedule.mockResolvedValue({ status: 'suspended', schedule });
1037
1263
  await updateDocument(buildCtx(db), {
1264
+ expectedRevision: 1,
1038
1265
  documentId: 'doc-1',
1039
1266
  data: { title: 'After' },
1040
1267
  });
1041
1268
  expect(suspendPublishSchedule).toHaveBeenCalledWith({
1269
+ reason: 'content_edited',
1042
1270
  documentId: 'doc-1',
1043
1271
  collectionId: 'col-1',
1044
1272
  });
@@ -1061,19 +1289,49 @@ describe('Document lifecycle service', () => {
1061
1289
  suspendedReason: 'content_edited',
1062
1290
  }),
1063
1291
  });
1064
- await deleteLocale(buildCtx(db), { documentId: 'doc-1', locale: 'fr' });
1292
+ await deleteLocale(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1', locale: 'fr' });
1065
1293
  expect(deleteDocumentLocale).toHaveBeenCalledOnce();
1066
1294
  expect(suspendPublishSchedule).toHaveBeenCalledWith({
1295
+ reason: 'content_edited',
1067
1296
  documentId: 'doc-1',
1068
1297
  collectionId: 'col-1',
1069
1298
  });
1070
1299
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
1071
1300
  });
1301
+ it('classifies a delete-locale afterUpdate failure after the new version commits', async () => {
1302
+ const { db, deleteDocumentLocale, getDocumentById } = createMockDb();
1303
+ getDocumentById.mockResolvedValue({
1304
+ document_version_id: 'ver-1',
1305
+ document_id: 'doc-1',
1306
+ path: 'current-path',
1307
+ _availableVersionLocales: ['en', 'fr'],
1308
+ fields: { title: 'Bonjour' },
1309
+ });
1310
+ const definition = {
1311
+ ...minimalCollection,
1312
+ hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
1313
+ };
1314
+ await expect(deleteLocale(buildCtx(db, definition), {
1315
+ expectedRevision: 1,
1316
+ documentId: 'doc-1',
1317
+ locale: 'fr',
1318
+ })).rejects.toMatchObject({
1319
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
1320
+ details: {
1321
+ phase: 'afterUpdate',
1322
+ documentId: 'doc-1',
1323
+ documentVersionId: 'ver-2',
1324
+ sideEffectCode: ErrorCodes.UNHANDLED,
1325
+ },
1326
+ });
1327
+ expect(deleteDocumentLocale).toHaveBeenCalledOnce();
1328
+ });
1072
1329
  it('cancels an active schedule as an effect of an ordinary status transition', async () => {
1073
1330
  const { db, getCurrentVersionMetadata, cancelPublishSchedule, auditAppend } = createMockDb();
1074
1331
  getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1075
1332
  cancelPublishSchedule.mockResolvedValue(publishScheduleRow());
1076
1333
  await changeDocumentStatus(buildCtx(db), {
1334
+ expectedRevision: 1,
1077
1335
  documentId: 'doc-1',
1078
1336
  nextStatus: 'published',
1079
1337
  });
@@ -1137,6 +1395,7 @@ describe('Document lifecycle service', () => {
1137
1395
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1138
1396
  const ctx = buildCtx(db);
1139
1397
  const result = await changeDocumentStatus(ctx, {
1398
+ expectedRevision: 1,
1140
1399
  documentId: 'doc-1',
1141
1400
  nextStatus: 'published',
1142
1401
  });
@@ -1151,9 +1410,13 @@ describe('Document lifecycle service', () => {
1151
1410
  const { db, getCurrentVersionMetadata, auditAppend, withTransaction } = createMockDb();
1152
1411
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1153
1412
  const ctx = buildCtx(db);
1154
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1155
- // The mutation + audit row run inside one withTransaction (docs/07-auth-and-security/02-auditability.md).
1156
- 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);
1157
1420
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1158
1421
  documentId: 'doc-1',
1159
1422
  collectionId: 'col-1',
@@ -1169,14 +1432,22 @@ describe('Document lifecycle service', () => {
1169
1432
  const { db, getCurrentVersionMetadata } = createMockDb();
1170
1433
  getCurrentVersionMetadata.mockResolvedValue(null);
1171
1434
  const ctx = buildCtx(db);
1172
- 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);
1173
1440
  });
1174
1441
  it('throws ERR_INVALID_TRANSITION for an invalid transition', async () => {
1175
1442
  const { db, getCurrentVersionMetadata } = createMockDb();
1176
1443
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1177
1444
  const ctx = buildCtx(db);
1178
1445
  // draft → archived skips 'published', which is not ±1
1179
- 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);
1180
1451
  });
1181
1452
  it('invokes beforeStatusChange and afterStatusChange hooks', async () => {
1182
1453
  const callOrder = [];
@@ -1195,7 +1466,11 @@ describe('Document lifecycle service', () => {
1195
1466
  });
1196
1467
  const definition = { ...minimalCollection, hooks };
1197
1468
  const ctx = buildCtx(db, definition);
1198
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1469
+ await changeDocumentStatus(ctx, {
1470
+ expectedRevision: 1,
1471
+ documentId: 'doc-1',
1472
+ nextStatus: 'published',
1473
+ });
1199
1474
  expect(callOrder).toEqual(['before', 'persist', 'after']);
1200
1475
  expect(hooks.beforeStatusChange).toHaveBeenCalledWith(expect.objectContaining({
1201
1476
  previousStatus: 'draft',
@@ -1215,7 +1490,11 @@ describe('Document lifecycle service', () => {
1215
1490
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1216
1491
  const definition = { ...minimalCollection, hooks };
1217
1492
  const ctx = buildCtx(db, definition);
1218
- 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);
1219
1498
  expect(hooks.beforeStatusChange).not.toHaveBeenCalled();
1220
1499
  expect(hooks.afterStatusChange).not.toHaveBeenCalled();
1221
1500
  });
@@ -1223,7 +1502,11 @@ describe('Document lifecycle service', () => {
1223
1502
  const { db, getCurrentVersionMetadata, archivePublishedVersions } = createMockDb();
1224
1503
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1225
1504
  const ctx = buildCtx(db);
1226
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1505
+ await changeDocumentStatus(ctx, {
1506
+ expectedRevision: 1,
1507
+ documentId: 'doc-1',
1508
+ nextStatus: 'published',
1509
+ });
1227
1510
  expect(archivePublishedVersions).toHaveBeenCalledWith({
1228
1511
  document_id: 'doc-1',
1229
1512
  excludeVersionId: 'ver-1',
@@ -1256,7 +1539,11 @@ describe('Document lifecycle service', () => {
1256
1539
  });
1257
1540
  const definition = { ...minimalCollection, hooks };
1258
1541
  const ctx = buildCtx(db, definition);
1259
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
1542
+ await changeDocumentStatus(ctx, {
1543
+ expectedRevision: 1,
1544
+ documentId: 'doc-1',
1545
+ nextStatus: 'published',
1546
+ });
1260
1547
  expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
1261
1548
  });
1262
1549
  });
@@ -1266,16 +1553,30 @@ describe('Document lifecycle service', () => {
1266
1553
  describe('unpublishDocument', () => {
1267
1554
  it('calls archivePublishedVersions and returns count', async () => {
1268
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
+ });
1269
1563
  archivePublishedVersions.mockResolvedValue(1);
1270
1564
  const ctx = buildCtx(db);
1271
- const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
1565
+ const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1272
1566
  expect(result.archivedCount).toBe(1);
1273
1567
  expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
1274
1568
  });
1275
1569
  it('records an atomic published to archived status audit when versions change', async () => {
1276
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
+ });
1277
1578
  archivePublishedVersions.mockResolvedValue(2);
1278
- await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
1579
+ await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
1279
1580
  expect(withTransaction).toHaveBeenCalledOnce();
1280
1581
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1281
1582
  documentId: 'doc-1',
@@ -1290,16 +1591,31 @@ describe('Document lifecycle service', () => {
1290
1591
  });
1291
1592
  it('does not record an audit row when no published version changes', async () => {
1292
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
+ });
1293
1601
  archivePublishedVersions.mockResolvedValue(0);
1294
- await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
1602
+ await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
1295
1603
  expect(auditAppend).not.toHaveBeenCalled();
1296
1604
  });
1297
1605
  it('does not run afterUnpublish when the atomic audit unit fails', async () => {
1298
1606
  const afterUnpublish = vi.fn();
1299
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
+ });
1300
1615
  archivePublishedVersions.mockResolvedValue(1);
1301
1616
  auditAppend.mockRejectedValue(new Error('audit failed'));
1302
1617
  await expect(unpublishDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUnpublish } }), {
1618
+ expectedRevision: 1,
1303
1619
  documentId: 'doc-1',
1304
1620
  })).rejects.toThrow('audit failed');
1305
1621
  expect(afterUnpublish).not.toHaveBeenCalled();
@@ -1315,13 +1631,20 @@ describe('Document lifecycle service', () => {
1315
1631
  }),
1316
1632
  };
1317
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
+ });
1318
1641
  archivePublishedVersions.mockImplementation(async () => {
1319
1642
  callOrder.push('archive');
1320
1643
  return 2;
1321
1644
  });
1322
1645
  const definition = { ...minimalCollection, hooks };
1323
1646
  const ctx = buildCtx(db, definition);
1324
- await unpublishDocument(ctx, { documentId: 'doc-1' });
1647
+ await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1325
1648
  expect(callOrder).toEqual(['before', 'archive', 'after']);
1326
1649
  expect(hooks.beforeUnpublish).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'current-path' }));
1327
1650
  expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
@@ -1332,9 +1655,16 @@ describe('Document lifecycle service', () => {
1332
1655
  });
1333
1656
  it('works when no hooks are defined', async () => {
1334
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
+ });
1335
1665
  archivePublishedVersions.mockResolvedValue(0);
1336
1666
  const ctx = buildCtx(db);
1337
- const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
1667
+ const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1338
1668
  expect(result.archivedCount).toBe(0);
1339
1669
  });
1340
1670
  it('supports an array of beforeUnpublish and afterUnpublish hooks', async () => {
@@ -1358,13 +1688,20 @@ describe('Document lifecycle service', () => {
1358
1688
  ],
1359
1689
  };
1360
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
+ });
1361
1698
  archivePublishedVersions.mockImplementation(async () => {
1362
1699
  callOrder.push('archive');
1363
1700
  return 2;
1364
1701
  });
1365
1702
  const definition = { ...minimalCollection, hooks };
1366
1703
  const ctx = buildCtx(db, definition);
1367
- await unpublishDocument(ctx, { documentId: 'doc-1' });
1704
+ await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1368
1705
  expect(callOrder).toEqual(['before-1', 'before-2', 'archive', 'after-1', 'after-2']);
1369
1706
  });
1370
1707
  });
@@ -1384,7 +1721,7 @@ describe('Document lifecycle service', () => {
1384
1721
  });
1385
1722
  const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
1386
1723
  const ctx = buildCtx(db, definition);
1387
- await deleteDocument(ctx, { documentId: 'doc-1' });
1724
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1388
1725
  expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
1389
1726
  expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
1390
1727
  });
@@ -1397,7 +1734,7 @@ describe('Document lifecycle service', () => {
1397
1734
  fields: {},
1398
1735
  });
1399
1736
  const ctx = buildCtx(db);
1400
- await deleteDocument(ctx, { documentId: 'doc-1' });
1737
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1401
1738
  expect(withTransaction).toHaveBeenCalledOnce();
1402
1739
  expect(softDeleteDocument).toHaveBeenCalledWith({ document_id: 'doc-1' });
1403
1740
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
@@ -1420,7 +1757,7 @@ describe('Document lifecycle service', () => {
1420
1757
  fields: {},
1421
1758
  });
1422
1759
  const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
1423
- 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');
1424
1761
  expect(softDeleteDocument).not.toHaveBeenCalled();
1425
1762
  expect(afterDelete).not.toHaveBeenCalled();
1426
1763
  });
@@ -1466,8 +1803,8 @@ describe('Document lifecycle service', () => {
1466
1803
  ...buildCtx(db, definition),
1467
1804
  storage: { delete: storageDelete },
1468
1805
  };
1469
- await deleteDocument(ctx, { documentId: 'doc-1' });
1470
- expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: false }));
1806
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1807
+ expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
1471
1808
  expect(storageDelete).not.toHaveBeenCalled();
1472
1809
  });
1473
1810
  it('returns only allowlisted failures and keeps raw details in internal logs', async () => {
@@ -1511,9 +1848,13 @@ describe('Document lifecycle service', () => {
1511
1848
  storage: { delete: storageDelete },
1512
1849
  logger: { ...noopLogger, error: vi.fn() },
1513
1850
  };
1514
- const result = await deleteDocument(ctx, { documentId: 'doc-1' });
1851
+ const result = await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1515
1852
  expect(storageDelete).not.toHaveBeenCalled();
1516
1853
  expect(result).toEqual({
1854
+ affectedDocuments: [{ documentId: 'doc-1', revision: 2 }],
1855
+ scheduledPublicationsNeedReconfirmation: false,
1856
+ documentId: 'doc-1',
1857
+ revision: 2,
1517
1858
  deletedVersionCount: 1,
1518
1859
  outcome: 'committed-with-side-effect-failures',
1519
1860
  sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
@@ -1547,6 +1888,7 @@ describe('Document lifecycle service', () => {
1547
1888
  setupDoc(getDocumentSystemFieldsForUpdate);
1548
1889
  const ctx = buildCtx(db);
1549
1890
  const result = await updateDocumentSystemFields(ctx, {
1891
+ expectedRevision: 1,
1550
1892
  documentId: 'doc-1',
1551
1893
  path: 'new-slug',
1552
1894
  });
@@ -1570,6 +1912,7 @@ describe('Document lifecycle service', () => {
1570
1912
  db.commands.documents.updateDocumentPath.mockRejectedValueOnce(pathUniqueViolation());
1571
1913
  const ctx = buildCtx(db);
1572
1914
  await expect(updateDocumentSystemFields(ctx, {
1915
+ expectedRevision: 1,
1573
1916
  documentId: 'doc-1',
1574
1917
  path: 'new-slug',
1575
1918
  })).rejects.toMatchObject({
@@ -1591,6 +1934,7 @@ describe('Document lifecycle service', () => {
1591
1934
  hooks: { afterSystemFieldsChange },
1592
1935
  });
1593
1936
  const result = await updateDocumentSystemFields(ctx, {
1937
+ expectedRevision: 1,
1594
1938
  documentId: 'doc-1',
1595
1939
  path: 'old-slug',
1596
1940
  });
@@ -1605,6 +1949,7 @@ describe('Document lifecycle service', () => {
1605
1949
  setupDoc(getDocumentSystemFieldsForUpdate);
1606
1950
  const ctx = buildCtx(db);
1607
1951
  const result = await updateDocumentSystemFields(ctx, {
1952
+ expectedRevision: 1,
1608
1953
  documentId: 'doc-1',
1609
1954
  availableLocales: ['en', 'fr'],
1610
1955
  });
@@ -1630,6 +1975,7 @@ describe('Document lifecycle service', () => {
1630
1975
  hooks: { afterSystemFieldsChange },
1631
1976
  });
1632
1977
  const result = await updateDocumentSystemFields(ctx, {
1978
+ expectedRevision: 1,
1633
1979
  documentId: 'doc-1',
1634
1980
  availableLocales: ['fr', 'en', 'fr'],
1635
1981
  });
@@ -1643,10 +1989,10 @@ describe('Document lifecycle service', () => {
1643
1989
  const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1644
1990
  setupDoc(getDocumentSystemFieldsForUpdate);
1645
1991
  const order = [];
1646
- db.commands.documents.updateDocumentPath.mockImplementation(async () => {
1992
+ vi.mocked(db.commands.documents.updateDocumentPath).mockImplementation(async () => {
1647
1993
  order.push('path-write');
1648
1994
  });
1649
- db.commands.documents.setDocumentAvailableLocales.mockImplementation(async () => {
1995
+ vi.mocked(db.commands.documents.setDocumentAvailableLocales).mockImplementation(async () => {
1650
1996
  order.push('locales-write');
1651
1997
  });
1652
1998
  auditAppend.mockImplementation(async () => {
@@ -1670,6 +2016,7 @@ describe('Document lifecycle service', () => {
1670
2016
  hooks: { afterSystemFieldsChange: [firstHook, secondHook] },
1671
2017
  });
1672
2018
  await updateDocumentSystemFields(ctx, {
2019
+ expectedRevision: 1,
1673
2020
  documentId: 'doc-1',
1674
2021
  path: 'new-slug',
1675
2022
  availableLocales: ['fr', 'en', 'fr'],
@@ -1705,7 +2052,11 @@ describe('Document lifecycle service', () => {
1705
2052
  ...minimalCollection,
1706
2053
  hooks: { afterSystemFieldsChange },
1707
2054
  });
1708
- 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');
1709
2060
  expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
1710
2061
  expect(afterSystemFieldsChange).not.toHaveBeenCalled();
1711
2062
  });
@@ -1726,7 +2077,14 @@ describe('Document lifecycle service', () => {
1726
2077
  ...minimalCollection,
1727
2078
  hooks: { afterSystemFieldsChange },
1728
2079
  });
1729
- 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
+ });
1730
2088
  expect(committed).toBe(true);
1731
2089
  expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
1732
2090
  expect(afterSystemFieldsChange).toHaveBeenCalledOnce();
@@ -1742,9 +2100,17 @@ describe('Document lifecycle service', () => {
1742
2100
  ...minimalCollection,
1743
2101
  hooks: { afterSystemFieldsChange },
1744
2102
  });
1745
- 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
+ });
1746
2111
  setupDoc(getDocumentSystemFieldsForUpdate, { path: 'new-slug' });
1747
2112
  const retry = await updateDocumentSystemFields(ctx, {
2113
+ expectedRevision: 2,
1748
2114
  documentId: 'doc-1',
1749
2115
  path: 'new-slug',
1750
2116
  reconcile: true,
@@ -1778,6 +2144,7 @@ describe('Document lifecycle service', () => {
1778
2144
  hooks: { afterSystemFieldsChange },
1779
2145
  });
1780
2146
  await updateDocumentSystemFields(ctx, {
2147
+ expectedRevision: 1,
1781
2148
  documentId: 'doc-1',
1782
2149
  path: 'final-path',
1783
2150
  availableLocales: ['de'],
@@ -1811,7 +2178,7 @@ describe('Document lifecycle service', () => {
1811
2178
  };
1812
2179
  const mocks = createMockDb();
1813
2180
  const { db } = mocks;
1814
- db.queries.documents.getDocumentByVersion.mockResolvedValue({
2181
+ vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
1815
2182
  document_version_id: sourceVersionId,
1816
2183
  document_id: sourceDocumentId,
1817
2184
  path: 'long-ago-path',
@@ -1843,6 +2210,7 @@ describe('Document lifecycle service', () => {
1843
2210
  const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
1844
2211
  const ctx = buildCtx(db);
1845
2212
  const result = await restoreDocumentVersion(ctx, {
2213
+ expectedRevision: 1,
1846
2214
  documentId: 'doc-1',
1847
2215
  sourceVersionId,
1848
2216
  });
@@ -1859,13 +2227,18 @@ describe('Document lifecycle service', () => {
1859
2227
  expect(result).toEqual({
1860
2228
  documentId: 'doc-1',
1861
2229
  documentVersionId: 'ver-restored',
2230
+ revision: 2,
1862
2231
  sourceVersionId,
1863
2232
  });
1864
2233
  });
1865
2234
  it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
1866
2235
  const { db, createDocumentVersion, sourceVersionId } = setupRestore();
1867
2236
  const ctx = buildCtx(db);
1868
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2237
+ await restoreDocumentVersion(ctx, {
2238
+ expectedRevision: 1,
2239
+ documentId: 'doc-1',
2240
+ sourceVersionId,
2241
+ });
1869
2242
  // Source version was 'archived'; default status for minimalCollection is 'draft'.
1870
2243
  expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
1871
2244
  });
@@ -1874,7 +2247,11 @@ describe('Document lifecycle service', () => {
1874
2247
  currentPath: 'sticky-path',
1875
2248
  });
1876
2249
  const ctx = buildCtx(db);
1877
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2250
+ await restoreDocumentVersion(ctx, {
2251
+ expectedRevision: 1,
2252
+ documentId: 'doc-1',
2253
+ sourceVersionId,
2254
+ });
1878
2255
  // Restore never changes a document's path: the existing
1879
2256
  // byline_document_paths row carries forward unchanged. The storage
1880
2257
  // primitive only writes to document_paths when `path` is supplied.
@@ -1886,7 +2263,11 @@ describe('Document lifecycle service', () => {
1886
2263
  });
1887
2264
  const ctx = buildCtx(db);
1888
2265
  try {
1889
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2266
+ await restoreDocumentVersion(ctx, {
2267
+ expectedRevision: 1,
2268
+ documentId: 'doc-1',
2269
+ sourceVersionId,
2270
+ });
1890
2271
  expect.fail('expected ERR_VALIDATION');
1891
2272
  }
1892
2273
  catch (err) {
@@ -1899,7 +2280,7 @@ describe('Document lifecycle service', () => {
1899
2280
  const sourceVersionId = 'ver-source';
1900
2281
  const mocks = createMockDb();
1901
2282
  const { db } = mocks;
1902
- db.queries.documents.getDocumentByVersion.mockResolvedValue({
2283
+ vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
1903
2284
  document_version_id: sourceVersionId,
1904
2285
  document_id: 'doc-1',
1905
2286
  path: 'p',
@@ -1917,7 +2298,11 @@ describe('Document lifecycle service', () => {
1917
2298
  });
1918
2299
  const ctx = buildCtx(db);
1919
2300
  try {
1920
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2301
+ await restoreDocumentVersion(ctx, {
2302
+ expectedRevision: 1,
2303
+ documentId: 'doc-1',
2304
+ sourceVersionId,
2305
+ });
1921
2306
  expect.fail('expected ERR_INVALID_TRANSITION');
1922
2307
  }
1923
2308
  catch (err) {
@@ -1931,7 +2316,11 @@ describe('Document lifecycle service', () => {
1931
2316
  const { db, sourceVersionId } = setupRestore();
1932
2317
  const definition = { ...minimalCollection, hooks: { beforeUpdate, afterUpdate } };
1933
2318
  const ctx = buildCtx(db, definition);
1934
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2319
+ await restoreDocumentVersion(ctx, {
2320
+ expectedRevision: 1,
2321
+ documentId: 'doc-1',
2322
+ sourceVersionId,
2323
+ });
1935
2324
  expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
1936
2325
  collectionPath: 'articles',
1937
2326
  restore: { sourceVersionId },
@@ -1947,6 +2336,27 @@ describe('Document lifecycle service', () => {
1947
2336
  restore: { sourceVersionId },
1948
2337
  }));
1949
2338
  });
2339
+ it('classifies a restore afterUpdate failure after the restored version commits', async () => {
2340
+ const { db, sourceVersionId, createDocumentVersion } = setupRestore();
2341
+ const definition = {
2342
+ ...minimalCollection,
2343
+ hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
2344
+ };
2345
+ await expect(restoreDocumentVersion(buildCtx(db, definition), {
2346
+ expectedRevision: 1,
2347
+ documentId: 'doc-1',
2348
+ sourceVersionId,
2349
+ })).rejects.toMatchObject({
2350
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2351
+ details: {
2352
+ phase: 'afterUpdate',
2353
+ documentId: 'doc-1',
2354
+ documentVersionId: 'ver-restored',
2355
+ sideEffectCode: ErrorCodes.UNHANDLED,
2356
+ },
2357
+ });
2358
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
2359
+ });
1950
2360
  it('throws ERR_FORBIDDEN when actor lacks collections.<path>.update', async () => {
1951
2361
  const { db, sourceVersionId } = setupRestore();
1952
2362
  const ctx = buildCtx(db);
@@ -1956,7 +2366,11 @@ describe('Document lifecycle service', () => {
1956
2366
  });
1957
2367
  ctx.requestContext = createRequestContext({ actor });
1958
2368
  try {
1959
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2369
+ await restoreDocumentVersion(ctx, {
2370
+ expectedRevision: 1,
2371
+ documentId: 'doc-1',
2372
+ sourceVersionId,
2373
+ });
1960
2374
  expect.fail('expected ERR_FORBIDDEN');
1961
2375
  }
1962
2376
  catch (err) {
@@ -1972,7 +2386,11 @@ describe('Document lifecycle service', () => {
1972
2386
  abilities: ['collections.articles.update', 'collections.articles.read'],
1973
2387
  });
1974
2388
  ctx.requestContext = createRequestContext({ actor });
1975
- await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
2389
+ await restoreDocumentVersion(ctx, {
2390
+ expectedRevision: 1,
2391
+ documentId: 'doc-1',
2392
+ sourceVersionId,
2393
+ });
1976
2394
  expect(createDocumentVersion).toHaveBeenCalledOnce();
1977
2395
  });
1978
2396
  });
@@ -2017,7 +2435,7 @@ describe('Document lifecycle service', () => {
2017
2435
  const mocks = createMockDb();
2018
2436
  const { sourceDocumentId } = setupSource(mocks);
2019
2437
  const ctx = buildCtx(mocks.db, localizedCollection);
2020
- const result = await duplicateDocument(ctx, { sourceDocumentId });
2438
+ const result = await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId });
2021
2439
  expect(mocks.getDocumentById).toHaveBeenCalledWith(expect.objectContaining({
2022
2440
  collection_id: 'col-1',
2023
2441
  document_id: sourceDocumentId,
@@ -2044,7 +2462,7 @@ describe('Document lifecycle service', () => {
2044
2462
  },
2045
2463
  });
2046
2464
  const ctx = buildCtx(mocks.db, localizedCollection);
2047
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2465
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2048
2466
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2049
2467
  expect(call.documentData.title).toEqual({
2050
2468
  en: 'Hello (copy)',
@@ -2069,7 +2487,7 @@ describe('Document lifecycle service', () => {
2069
2487
  },
2070
2488
  });
2071
2489
  const ctx = buildCtx(mocks.db, nonLocalizedCollection);
2072
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2490
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2073
2491
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2074
2492
  expect(call.documentData.title).toBe('Hello (copy)');
2075
2493
  });
@@ -2082,7 +2500,10 @@ describe('Document lifecycle service', () => {
2082
2500
  },
2083
2501
  });
2084
2502
  const ctx = buildCtx(mocks.db, localizedCollection);
2085
- const result = await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2503
+ const result = await duplicateDocument(ctx, {
2504
+ expectedRevision: 1,
2505
+ sourceDocumentId: 'doc-source',
2506
+ });
2086
2507
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2087
2508
  // Default locale is 'en'; "Hello World (copy)" slugifies to "hello-world-copy".
2088
2509
  expect(call.path).toBe('hello-world-copy');
@@ -2108,7 +2529,7 @@ describe('Document lifecycle service', () => {
2108
2529
  },
2109
2530
  });
2110
2531
  const ctx = buildCtx(mocks.db, localizedCollection);
2111
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2532
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2112
2533
  const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2113
2534
  const sections = call.documentData.sections;
2114
2535
  expect(sections[0]._id).toBeUndefined();
@@ -2140,7 +2561,7 @@ describe('Document lifecycle service', () => {
2140
2561
  fieldCount: 5,
2141
2562
  });
2142
2563
  const ctx = buildCtx(mocks.db, localizedCollection);
2143
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2564
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2144
2565
  // Source title should be unchanged in memory.
2145
2566
  expect(originalFields.title).toEqual({ en: 'Hello', fr: 'Bonjour' });
2146
2567
  expect(originalFields.sections[0]?._id).toBe('sec-1');
@@ -2162,7 +2583,10 @@ describe('Document lifecycle service', () => {
2162
2583
  fieldCount: 5,
2163
2584
  });
2164
2585
  });
2165
- const result = await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2586
+ const result = await duplicateDocument(ctx, {
2587
+ expectedRevision: 1,
2588
+ sourceDocumentId: 'doc-source',
2589
+ });
2166
2590
  expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
2167
2591
  const firstPath = mocks.createDocumentVersion.mock.calls[0]?.[0].path;
2168
2592
  const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
@@ -2183,7 +2607,7 @@ describe('Document lifecycle service', () => {
2183
2607
  });
2184
2608
  let thrown;
2185
2609
  try {
2186
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2610
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2187
2611
  }
2188
2612
  catch (err) {
2189
2613
  thrown = err;
@@ -2206,7 +2630,7 @@ describe('Document lifecycle service', () => {
2206
2630
  mocks.getDocumentById.mockResolvedValue(null);
2207
2631
  const ctx = buildCtx(mocks.db, localizedCollection);
2208
2632
  try {
2209
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-missing' });
2633
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-missing' });
2210
2634
  expect.fail('expected ERR_NOT_FOUND');
2211
2635
  }
2212
2636
  catch (err) {
@@ -2227,7 +2651,7 @@ describe('Document lifecycle service', () => {
2227
2651
  const mocks = createMockDb();
2228
2652
  setupSource(mocks, { sourceDocumentId: 'doc-source' });
2229
2653
  const ctx = buildCtx(mocks.db, withHooks);
2230
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2654
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2231
2655
  expect(beforeCreate).toHaveBeenCalledOnce();
2232
2656
  const beforeCtx = beforeCreate.mock.calls[0]?.[0];
2233
2657
  expect(beforeCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
@@ -2246,6 +2670,27 @@ describe('Document lifecycle service', () => {
2246
2670
  expect(typeof afterCtx.path).toBe('string');
2247
2671
  expect(afterCtx.path).toBe(writtenPath);
2248
2672
  });
2673
+ it('classifies a duplicate afterCreate failure with the committed document ids', async () => {
2674
+ const withHooks = {
2675
+ ...localizedCollection,
2676
+ hooks: { afterCreate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
2677
+ };
2678
+ const mocks = createMockDb();
2679
+ setupSource(mocks);
2680
+ await expect(duplicateDocument(buildCtx(mocks.db, withHooks), {
2681
+ expectedRevision: 1,
2682
+ sourceDocumentId: 'doc-source',
2683
+ })).rejects.toMatchObject({
2684
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2685
+ details: {
2686
+ phase: 'afterCreate',
2687
+ documentId: 'doc-new',
2688
+ documentVersionId: 'ver-new',
2689
+ sideEffectCode: ErrorCodes.UNHANDLED,
2690
+ },
2691
+ });
2692
+ expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
2693
+ });
2249
2694
  it('enforces collections.<path>.create — rejects an admin actor missing the ability', async () => {
2250
2695
  const mocks = createMockDb();
2251
2696
  setupSource(mocks);
@@ -2257,7 +2702,7 @@ describe('Document lifecycle service', () => {
2257
2702
  });
2258
2703
  ctx.requestContext = createRequestContext({ actor });
2259
2704
  try {
2260
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2705
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2261
2706
  expect.fail('expected ERR_FORBIDDEN');
2262
2707
  }
2263
2708
  catch (err) {
@@ -2272,7 +2717,7 @@ describe('Document lifecycle service', () => {
2272
2717
  const ctx = buildCtx(mocks.db, localizedCollection);
2273
2718
  ctx.requestContext = undefined;
2274
2719
  try {
2275
- await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
2720
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2276
2721
  expect.fail('expected ERR_UNAUTHENTICATED');
2277
2722
  }
2278
2723
  catch (err) {
@@ -2351,6 +2796,7 @@ describe('Document lifecycle service', () => {
2351
2796
  const { mocks, currentVersionId } = setupSourceTarget();
2352
2797
  const ctx = buildCtx(mocks.db, mixedCollection);
2353
2798
  const result = await copyToLocale(ctx, {
2799
+ expectedRevision: 1,
2354
2800
  documentId: 'doc-1',
2355
2801
  sourceLocale: 'en',
2356
2802
  targetLocale: 'fr',
@@ -2390,6 +2836,7 @@ describe('Document lifecycle service', () => {
2390
2836
  });
2391
2837
  const ctx = buildCtx(mocks.db, mixedCollection);
2392
2838
  const result = await copyToLocale(ctx, {
2839
+ expectedRevision: 1,
2393
2840
  documentId: 'doc-1',
2394
2841
  sourceLocale: 'en',
2395
2842
  targetLocale: 'fr',
@@ -2421,6 +2868,7 @@ describe('Document lifecycle service', () => {
2421
2868
  });
2422
2869
  const ctx = buildCtx(mocks.db, mixedCollection);
2423
2870
  await copyToLocale(ctx, {
2871
+ expectedRevision: 1,
2424
2872
  documentId: 'doc-1',
2425
2873
  sourceLocale: 'en',
2426
2874
  targetLocale: 'fr',
@@ -2437,6 +2885,7 @@ describe('Document lifecycle service', () => {
2437
2885
  const { mocks } = setupSourceTarget();
2438
2886
  const ctx = buildCtx(mocks.db, mixedCollection);
2439
2887
  await copyToLocale(ctx, {
2888
+ expectedRevision: 1,
2440
2889
  documentId: 'doc-1',
2441
2890
  sourceLocale: 'en',
2442
2891
  targetLocale: 'fr',
@@ -2449,6 +2898,7 @@ describe('Document lifecycle service', () => {
2449
2898
  const ctx = buildCtx(mocks.db, mixedCollection);
2450
2899
  try {
2451
2900
  await copyToLocale(ctx, {
2901
+ expectedRevision: 1,
2452
2902
  documentId: 'doc-1',
2453
2903
  sourceLocale: 'en',
2454
2904
  targetLocale: 'en',
@@ -2467,6 +2917,7 @@ describe('Document lifecycle service', () => {
2467
2917
  const ctx = buildCtx(mocks.db, mixedCollection);
2468
2918
  try {
2469
2919
  await copyToLocale(ctx, {
2920
+ expectedRevision: 1,
2470
2921
  documentId: 'doc-1',
2471
2922
  sourceLocale: 'en',
2472
2923
  targetLocale: 'fr',
@@ -2497,6 +2948,7 @@ describe('Document lifecycle service', () => {
2497
2948
  const ctx = buildCtx(mocks.db, mixedCollection);
2498
2949
  try {
2499
2950
  await copyToLocale(ctx, {
2951
+ expectedRevision: 1,
2500
2952
  documentId: 'doc-1',
2501
2953
  sourceLocale: 'en',
2502
2954
  targetLocale: 'fr',
@@ -2519,6 +2971,7 @@ describe('Document lifecycle service', () => {
2519
2971
  const { mocks } = setupSourceTarget();
2520
2972
  const ctx = buildCtx(mocks.db, withHooks);
2521
2973
  await copyToLocale(ctx, {
2974
+ expectedRevision: 1,
2522
2975
  documentId: 'doc-1',
2523
2976
  sourceLocale: 'en',
2524
2977
  targetLocale: 'fr',
@@ -2537,6 +2990,29 @@ describe('Document lifecycle service', () => {
2537
2990
  // Sticky path read off the target-locale envelope.
2538
2991
  expect(afterUpdate.mock.calls[0]?.[0].path).toBe('hello');
2539
2992
  });
2993
+ it('classifies a copy-to-locale afterUpdate failure after the version commits', async () => {
2994
+ const withHooks = {
2995
+ ...mixedCollection,
2996
+ hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
2997
+ };
2998
+ const { mocks } = setupSourceTarget();
2999
+ await expect(copyToLocale(buildCtx(mocks.db, withHooks), {
3000
+ expectedRevision: 1,
3001
+ documentId: 'doc-1',
3002
+ sourceLocale: 'en',
3003
+ targetLocale: 'fr',
3004
+ overwrite: false,
3005
+ })).rejects.toMatchObject({
3006
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
3007
+ details: {
3008
+ phase: 'afterUpdate',
3009
+ documentId: 'doc-1',
3010
+ documentVersionId: 'ver-new',
3011
+ sideEffectCode: ErrorCodes.UNHANDLED,
3012
+ },
3013
+ });
3014
+ expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
3015
+ });
2540
3016
  it('enforces collections.<path>.update — rejects an admin actor missing the ability', async () => {
2541
3017
  const { mocks } = setupSourceTarget();
2542
3018
  const ctx = buildCtx(mocks.db, mixedCollection);
@@ -2547,6 +3023,7 @@ describe('Document lifecycle service', () => {
2547
3023
  ctx.requestContext = createRequestContext({ actor });
2548
3024
  try {
2549
3025
  await copyToLocale(ctx, {
3026
+ expectedRevision: 1,
2550
3027
  documentId: 'doc-1',
2551
3028
  sourceLocale: 'en',
2552
3029
  targetLocale: 'fr',
@@ -2566,6 +3043,7 @@ describe('Document lifecycle service', () => {
2566
3043
  ctx.requestContext = undefined;
2567
3044
  try {
2568
3045
  await copyToLocale(ctx, {
3046
+ expectedRevision: 1,
2569
3047
  documentId: 'doc-1',
2570
3048
  sourceLocale: 'en',
2571
3049
  targetLocale: 'fr',
@@ -2639,7 +3117,11 @@ describe('Document lifecycle service', () => {
2639
3117
  });
2640
3118
  ctx.requestContext = createRequestContext({ actor });
2641
3119
  try {
2642
- await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
3120
+ await changeDocumentStatus(ctx, {
3121
+ expectedRevision: 1,
3122
+ documentId: 'doc-1',
3123
+ nextStatus: 'published',
3124
+ });
2643
3125
  expect.fail('expected ERR_FORBIDDEN');
2644
3126
  }
2645
3127
  catch (err) {
@@ -2675,7 +3157,11 @@ describe('Document lifecycle service', () => {
2675
3157
  abilities: ['collections.articles.changeStatus'],
2676
3158
  });
2677
3159
  ctx.requestContext = createRequestContext({ actor });
2678
- 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
+ });
2679
3165
  expect(setDocumentStatus).toHaveBeenCalledOnce();
2680
3166
  });
2681
3167
  it('super-admin bypasses every check', async () => {
@@ -2700,7 +3186,7 @@ describe('Document lifecycle service', () => {
2700
3186
  });
2701
3187
  ctx.requestContext = createRequestContext({ actor });
2702
3188
  try {
2703
- await deleteDocument(ctx, { documentId: 'doc-1' });
3189
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
2704
3190
  expect.fail('expected ERR_FORBIDDEN');
2705
3191
  }
2706
3192
  catch (err) {