@byline/core 4.14.1 → 4.15.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 (62) hide show
  1. package/dist/@types/db-types.d.ts +222 -0
  2. package/dist/@types/site-config.d.ts +24 -0
  3. package/dist/codegen/index.test.node.js +1 -1
  4. package/dist/core.d.ts +8 -0
  5. package/dist/core.js +39 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +8 -11
  8. package/dist/scheduler/define-recurring-task.d.ts +19 -0
  9. package/dist/scheduler/define-recurring-task.js +20 -0
  10. package/dist/scheduler/index.d.ts +19 -0
  11. package/dist/scheduler/index.js +18 -0
  12. package/dist/scheduler/run-due-tasks.d.ts +39 -0
  13. package/dist/scheduler/run-due-tasks.js +324 -0
  14. package/dist/scheduler/run-due-tasks.test.node.d.ts +8 -0
  15. package/dist/scheduler/run-due-tasks.test.node.js +396 -0
  16. package/dist/scheduler/scheduled-publication-constants.d.ts +11 -0
  17. package/dist/scheduler/scheduled-publication-constants.js +11 -0
  18. package/dist/scheduler/scheduled-publication.d.ts +31 -0
  19. package/dist/scheduler/scheduled-publication.js +198 -0
  20. package/dist/scheduler/scheduled-publication.test.node.d.ts +8 -0
  21. package/dist/scheduler/scheduled-publication.test.node.js +109 -0
  22. package/dist/scheduler/scheduler-boot.test.node.d.ts +8 -0
  23. package/dist/scheduler/scheduler-boot.test.node.js +103 -0
  24. package/dist/scheduler/ticker.d.ts +34 -0
  25. package/dist/scheduler/ticker.js +144 -0
  26. package/dist/scheduler/ticker.test.node.d.ts +8 -0
  27. package/dist/scheduler/ticker.test.node.js +249 -0
  28. package/dist/scheduler/types.d.ts +241 -0
  29. package/dist/scheduler/types.js +8 -0
  30. package/dist/scheduler/validate-scheduler-config.d.ts +19 -0
  31. package/dist/scheduler/validate-scheduler-config.js +25 -0
  32. package/dist/scheduler/validate-scheduler-config.test.node.d.ts +8 -0
  33. package/dist/scheduler/validate-scheduler-config.test.node.js +38 -0
  34. package/dist/scheduler/validate-tasks.d.ts +14 -0
  35. package/dist/scheduler/validate-tasks.js +40 -0
  36. package/dist/scheduler/validate-tasks.test.node.d.ts +8 -0
  37. package/dist/scheduler/validate-tasks.test.node.js +49 -0
  38. package/dist/services/collection-bootstrap.test.node.js +2 -0
  39. package/dist/services/discover-counter-groups.test.node.js +2 -0
  40. package/dist/services/document-lifecycle/audit.d.ts +6 -0
  41. package/dist/services/document-lifecycle/audit.js +6 -0
  42. package/dist/services/document-lifecycle/copy-to-locale.js +15 -10
  43. package/dist/services/document-lifecycle/delete-locale.js +18 -10
  44. package/dist/services/document-lifecycle/delete.js +8 -0
  45. package/dist/services/document-lifecycle/index.d.ts +1 -0
  46. package/dist/services/document-lifecycle/index.js +1 -0
  47. package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +36 -0
  48. package/dist/services/document-lifecycle/publish-schedule-consistency.js +80 -0
  49. package/dist/services/document-lifecycle/restore.js +15 -10
  50. package/dist/services/document-lifecycle/scheduled-publish.d.ts +51 -0
  51. package/dist/services/document-lifecycle/scheduled-publish.js +351 -0
  52. package/dist/services/document-lifecycle/status-transition.d.ts +42 -0
  53. package/dist/services/document-lifecycle/status-transition.js +41 -0
  54. package/dist/services/document-lifecycle/status-transition.test.node.d.ts +8 -0
  55. package/dist/services/document-lifecycle/status-transition.test.node.js +145 -0
  56. package/dist/services/document-lifecycle/status.js +30 -23
  57. package/dist/services/document-lifecycle/tree.test.node.js +3 -0
  58. package/dist/services/document-lifecycle/update.js +35 -30
  59. package/dist/services/document-lifecycle.test.node.js +262 -2
  60. package/dist/services/field-upload.test.node.js +2 -0
  61. package/dist/services/populate.test.node.js +2 -0
  62. package/package.json +7 -2
@@ -0,0 +1,145 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { commitDocumentStatusTransition } from './status-transition.js';
10
+ function createTransitionHarness() {
11
+ const order = [];
12
+ const auditRows = [];
13
+ let status = 'draft';
14
+ let archived = false;
15
+ const db = {
16
+ commands: {
17
+ documents: {
18
+ setDocumentStatus: async ({ status: nextStatus }) => {
19
+ order.push('status-write');
20
+ status = nextStatus;
21
+ },
22
+ archivePublishedVersions: async () => {
23
+ order.push('auto-archive');
24
+ archived = true;
25
+ return 1;
26
+ },
27
+ },
28
+ audit: {
29
+ append: async (input) => {
30
+ order.push('audit-append');
31
+ auditRows.push(input);
32
+ return { id: 'audit-1' };
33
+ },
34
+ },
35
+ },
36
+ withTransaction: async (fn) => {
37
+ const snapshot = {
38
+ status,
39
+ archived,
40
+ auditLength: auditRows.length,
41
+ };
42
+ order.push('transaction-start');
43
+ try {
44
+ const result = await fn();
45
+ order.push('transaction-commit');
46
+ return result;
47
+ }
48
+ catch (error) {
49
+ status = snapshot.status;
50
+ archived = snapshot.archived;
51
+ auditRows.length = snapshot.auditLength;
52
+ order.push('transaction-rollback');
53
+ throw error;
54
+ }
55
+ },
56
+ };
57
+ return {
58
+ db,
59
+ order,
60
+ auditRows,
61
+ get status() {
62
+ return status;
63
+ },
64
+ get archived() {
65
+ return archived;
66
+ },
67
+ };
68
+ }
69
+ function transitionParams(db) {
70
+ return {
71
+ db,
72
+ documentId: 'doc-1',
73
+ documentVersionId: 'ver-1',
74
+ collectionId: 'col-1',
75
+ previousStatus: 'draft',
76
+ nextStatus: 'published',
77
+ actor: {
78
+ actorId: '01901234-0000-7000-8000-000000000001',
79
+ actorRealm: 'admin',
80
+ },
81
+ };
82
+ }
83
+ describe('commitDocumentStatusTransition', () => {
84
+ it('runs contributions inside the transaction at the specified boundaries', async () => {
85
+ const harness = createTransitionHarness();
86
+ await commitDocumentStatusTransition({
87
+ ...transitionParams(harness.db),
88
+ contributions: {
89
+ beforeStatusWrite: () => {
90
+ harness.order.push('before-contribution');
91
+ },
92
+ afterAuditAppend: () => {
93
+ harness.order.push('after-contribution');
94
+ },
95
+ },
96
+ });
97
+ expect(harness.order).toEqual([
98
+ 'transaction-start',
99
+ 'before-contribution',
100
+ 'status-write',
101
+ 'auto-archive',
102
+ 'audit-append',
103
+ 'after-contribution',
104
+ 'transaction-commit',
105
+ ]);
106
+ });
107
+ it('aborts before any mutation or audit when the before contribution throws', async () => {
108
+ const harness = createTransitionHarness();
109
+ const error = new Error('guard rejected');
110
+ await expect(commitDocumentStatusTransition({
111
+ ...transitionParams(harness.db),
112
+ contributions: {
113
+ beforeStatusWrite: () => {
114
+ throw error;
115
+ },
116
+ },
117
+ })).rejects.toBe(error);
118
+ expect(harness.status).toBe('draft');
119
+ expect(harness.archived).toBe(false);
120
+ expect(harness.auditRows).toEqual([]);
121
+ expect(harness.order).toEqual(['transaction-start', 'transaction-rollback']);
122
+ });
123
+ it('propagates an after-contribution failure so the transaction rolls back', async () => {
124
+ const harness = createTransitionHarness();
125
+ const error = new Error('schedule deletion failed');
126
+ await expect(commitDocumentStatusTransition({
127
+ ...transitionParams(harness.db),
128
+ contributions: {
129
+ afterAuditAppend: () => {
130
+ throw error;
131
+ },
132
+ },
133
+ })).rejects.toBe(error);
134
+ expect(harness.status).toBe('draft');
135
+ expect(harness.archived).toBe(false);
136
+ expect(harness.auditRows).toEqual([]);
137
+ expect(harness.order).toEqual([
138
+ 'transaction-start',
139
+ 'status-write',
140
+ 'auto-archive',
141
+ 'audit-append',
142
+ 'transaction-rollback',
143
+ ]);
144
+ });
145
+ });
@@ -12,6 +12,8 @@ import { withLogContext } from '../../lib/logger.js';
12
12
  import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
13
13
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
14
14
  import { invokeHook } from './internals.js';
15
+ import { appendPublishScheduleCancellationAudit, cancelPublishScheduleInTransaction, } from './publish-schedule-consistency.js';
16
+ import { commitDocumentStatusTransition } from './status-transition.js';
15
17
  /**
16
18
  * Change a document's workflow status.
17
19
  *
@@ -90,29 +92,27 @@ export async function changeDocumentStatus(ctx, params) {
90
92
  // record. Status mutates the version row rather than minting a new
91
93
  // version, so the version stream never captures *who* changed it —
92
94
  // the audit log is its only accountability home (docs/07-auth-and-security/02-auditability.md).
93
- const audit = requireAuditCapability(db);
94
- const actor = auditActor(ctx);
95
- await audit.withTransaction(async () => {
96
- await db.commands.documents.setDocumentStatus({
97
- document_version_id: documentVersionId,
98
- status: params.nextStatus,
99
- });
100
- if (params.nextStatus === 'published') {
101
- await db.commands.documents.archivePublishedVersions({
102
- document_id: params.documentId,
103
- excludeVersionId: documentVersionId,
104
- });
105
- }
106
- await audit.append({
107
- documentId: params.documentId,
108
- collectionId,
109
- actorId: actor.actorId,
110
- actorRealm: actor.actorRealm,
111
- action: AUDIT_ACTIONS.statusChanged,
112
- field: 'status',
113
- before: currentStatus,
114
- after: params.nextStatus,
115
- });
95
+ const transitionAudit = requireAuditCapability(db);
96
+ let cancelledSchedule = null;
97
+ await commitDocumentStatusTransition({
98
+ db,
99
+ documentId: params.documentId,
100
+ documentVersionId,
101
+ collectionId,
102
+ previousStatus: currentStatus,
103
+ nextStatus: params.nextStatus,
104
+ actor: auditActor(ctx),
105
+ contributions: {
106
+ beforeStatusWrite: async () => {
107
+ cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
108
+ },
109
+ afterAuditAppend: () => appendPublishScheduleCancellationAudit({
110
+ ctx,
111
+ audit: transitionAudit,
112
+ schedule: cancelledSchedule,
113
+ reason: 'status_changed',
114
+ }),
115
+ },
116
116
  });
117
117
  // 6. afterStatusChange hook.
118
118
  await invokeHook(hooks?.afterStatusChange, hookCtx);
@@ -156,6 +156,7 @@ export async function unpublishDocument(ctx, params) {
156
156
  const audit = requireAuditCapability(db);
157
157
  const actor = auditActor(ctx);
158
158
  const archivedCount = await audit.withTransaction(async () => {
159
+ const cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
159
160
  const count = await db.commands.documents.archivePublishedVersions({
160
161
  document_id: params.documentId,
161
162
  });
@@ -171,6 +172,12 @@ export async function unpublishDocument(ctx, params) {
171
172
  after: 'archived',
172
173
  });
173
174
  }
175
+ await appendPublishScheduleCancellationAudit({
176
+ ctx,
177
+ audit,
178
+ schedule: cancelledSchedule,
179
+ reason: 'unpublished',
180
+ });
174
181
  return count;
175
182
  });
176
183
  await invokeHook(hooks?.afterUnpublish, {
@@ -213,6 +213,9 @@ function createHarness(options = {}) {
213
213
  const db = {
214
214
  commands: {
215
215
  documents: {
216
+ publishSchedules: {
217
+ cancel: vi.fn(async () => null),
218
+ },
216
219
  placeTreeNode: place,
217
220
  removeFromTree: remove,
218
221
  promoteChildrenAndRemoveFromTree: promote,
@@ -15,6 +15,7 @@ import { getDefaultStatus } from '../../workflow/workflow.js';
15
15
  import { assignCounterValues } from '../assign-counter-values.js';
16
16
  import { normalizeNumericFields } from '../normalize-numeric-fields.js';
17
17
  import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
18
+ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
18
19
  import { selfHealTreePlacement } from './tree.js';
19
20
  /**
20
21
  * Update a document via full replacement (PUT semantics).
@@ -76,22 +77,24 @@ export async function updateDocument(ctx, params) {
76
77
  logger: ctx.logger,
77
78
  });
78
79
  await applyRichTextEmbed(ctx, data);
79
- const result = await db.commands.documents
80
- .createDocumentVersion({
80
+ const result = await commitContentVersionWithScheduleSuspension({
81
+ ctx,
81
82
  documentId: params.documentId,
82
- collectionId,
83
- collectionVersion: ctx.collectionVersion,
84
- collectionConfig: definition,
85
- action: 'update',
86
- documentData: data,
87
- path: pathForCommand,
88
- availableLocales: params.availableLocales,
89
- status: defaultStatus,
90
- locale: requestLocale,
91
- previousVersionId: originalData.document_version_id,
92
- createdBy: actorId(ctx),
93
- })
94
- .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
83
+ write: () => db.commands.documents.createDocumentVersion({
84
+ documentId: params.documentId,
85
+ collectionId,
86
+ collectionVersion: ctx.collectionVersion,
87
+ collectionConfig: definition,
88
+ action: 'update',
89
+ documentData: data,
90
+ path: pathForCommand,
91
+ availableLocales: params.availableLocales,
92
+ status: defaultStatus,
93
+ locale: requestLocale,
94
+ previousVersionId: originalData.document_version_id,
95
+ createdBy: actorId(ctx),
96
+ }),
97
+ }).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
95
98
  const documentId = extractDocumentId(result.document) || params.documentId;
96
99
  const documentVersionId = extractVersionId(result.document);
97
100
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -195,22 +198,24 @@ export async function updateDocumentWithPatches(ctx, params) {
195
198
  logger: ctx.logger,
196
199
  });
197
200
  await applyRichTextEmbed(ctx, nextData);
198
- const result = await db.commands.documents
199
- .createDocumentVersion({
201
+ const result = await commitContentVersionWithScheduleSuspension({
202
+ ctx,
200
203
  documentId: params.documentId,
201
- collectionId,
202
- collectionVersion: ctx.collectionVersion,
203
- collectionConfig: definition,
204
- action: 'update',
205
- documentData: nextData,
206
- path: pathForCommand,
207
- availableLocales: params.availableLocales,
208
- status: defaultStatus,
209
- locale: requestLocale,
210
- previousVersionId: originalData.document_version_id,
211
- createdBy: actorId(ctx),
212
- })
213
- .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
204
+ write: () => db.commands.documents.createDocumentVersion({
205
+ documentId: params.documentId,
206
+ collectionId,
207
+ collectionVersion: ctx.collectionVersion,
208
+ collectionConfig: definition,
209
+ action: 'update',
210
+ documentData: nextData,
211
+ path: pathForCommand,
212
+ availableLocales: params.availableLocales,
213
+ status: defaultStatus,
214
+ locale: requestLocale,
215
+ previousVersionId: originalData.document_version_id,
216
+ createdBy: actorId(ctx),
217
+ }),
218
+ }).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
214
219
  const documentId = extractDocumentId(result.document) || params.documentId;
215
220
  const documentVersionId = extractVersionId(result.document);
216
221
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -8,7 +8,7 @@
8
8
  import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
10
  import { BylineError, DbErrorCodes, ErrorCodes } from '../lib/errors.js';
11
- import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
11
+ import { cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copyToLocale, createDocument, deleteDocument, deleteLocale, duplicateDocument, listDocumentPublishSchedules, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Fixtures / Helpers
14
14
  // ---------------------------------------------------------------------------
@@ -32,6 +32,30 @@ const numericCollection = {
32
32
  { name: 'price', type: 'decimal' },
33
33
  ],
34
34
  };
35
+ function publishScheduleRow(overrides = {}) {
36
+ const now = new Date('2026-08-22T12:00:00.000Z');
37
+ return {
38
+ documentId: 'doc-1',
39
+ collectionId: 'col-1',
40
+ targetVersionId: 'ver-1',
41
+ publishAt: new Date('2026-08-23T12:00:00.000Z'),
42
+ state: 'armed',
43
+ suspendedAt: null,
44
+ suspendedReason: null,
45
+ scheduledBy: TEST_ACTOR_ID,
46
+ lastAuthorizedBy: TEST_ACTOR_ID,
47
+ lastAuthorizedAt: now,
48
+ scheduledAt: now,
49
+ updatedAt: now,
50
+ executionToken: null,
51
+ executionExpiresAt: null,
52
+ lastAttemptAt: null,
53
+ nextAttemptAt: new Date('2026-08-23T12:00:00.000Z'),
54
+ attemptCount: 0,
55
+ lastError: null,
56
+ ...overrides,
57
+ };
58
+ }
35
59
  /** Build a mock IDbAdapter. Returns the adapter plus individual mock fns. */
36
60
  function createMockDb() {
37
61
  const createDocumentVersion = vi.fn().mockResolvedValue({
@@ -41,10 +65,22 @@ function createMockDb() {
41
65
  const setDocumentStatus = vi.fn().mockResolvedValue(undefined);
42
66
  const archivePublishedVersions = vi.fn().mockResolvedValue(0);
43
67
  const softDeleteDocument = vi.fn().mockResolvedValue(1);
68
+ const deleteDocumentLocale = vi.fn().mockResolvedValue({ newVersionId: 'ver-2' });
44
69
  const getDocumentById = vi.fn().mockResolvedValue(null);
45
70
  const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
46
71
  const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
47
72
  const getCurrentPath = vi.fn().mockResolvedValue('current-path');
73
+ const publishSchedule = vi.fn().mockResolvedValue({ status: 'document_not_found' });
74
+ const confirmPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
75
+ const cancelPublishSchedule = vi.fn().mockResolvedValue(null);
76
+ const suspendPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
77
+ const claimDuePublishSchedules = vi.fn().mockResolvedValue([]);
78
+ const lockPublishScheduleClaim = vi.fn().mockResolvedValue(null);
79
+ const deletePublishScheduleClaim = vi.fn().mockResolvedValue(false);
80
+ const suspendPublishScheduleClaim = vi.fn().mockResolvedValue(false);
81
+ const releasePublishScheduleClaim = vi.fn().mockResolvedValue(false);
82
+ const getPublishSchedule = vi.fn().mockResolvedValue(null);
83
+ const listPublishSchedules = vi.fn().mockResolvedValue({ schedules: [], total: 0 });
48
84
  // Audit capability (docs/07-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
49
85
  // in unit tests (runs the unit of work immediately, no real tx); `append`
50
86
  // records the calls so write-point tests can assert the audit rows emitted.
@@ -71,6 +107,17 @@ function createMockDb() {
71
107
  delete: vi.fn(),
72
108
  },
73
109
  documents: {
110
+ publishSchedules: {
111
+ schedule: publishSchedule,
112
+ confirm: confirmPublishSchedule,
113
+ cancel: cancelPublishSchedule,
114
+ suspendForContentEdit: suspendPublishSchedule,
115
+ claimDue: claimDuePublishSchedules,
116
+ lockClaim: lockPublishScheduleClaim,
117
+ deleteClaim: deletePublishScheduleClaim,
118
+ suspendClaimForContentEdit: suspendPublishScheduleClaim,
119
+ releaseClaim: releasePublishScheduleClaim,
120
+ },
74
121
  createDocumentVersion,
75
122
  updateDocumentPath: vi.fn().mockResolvedValue(undefined),
76
123
  setDocumentAvailableLocales: vi.fn().mockResolvedValue(undefined),
@@ -78,7 +125,7 @@ function createMockDb() {
78
125
  archivePublishedVersions,
79
126
  softDeleteDocument,
80
127
  restoreSoftDeletedDocument: vi.fn(),
81
- deleteDocumentLocale: vi.fn(),
128
+ deleteDocumentLocale: deleteDocumentLocale,
82
129
  setOrderKey: vi.fn(),
83
130
  placeTreeNode: vi.fn(),
84
131
  removeFromTree: vi.fn(),
@@ -108,6 +155,10 @@ function createMockDb() {
108
155
  getCollectionById: vi.fn(),
109
156
  },
110
157
  documents: {
158
+ publishSchedules: {
159
+ get: getPublishSchedule,
160
+ list: listPublishSchedules,
161
+ },
111
162
  getDocumentSystemFieldsForUpdate,
112
163
  getDocumentById,
113
164
  getCurrentVersionMetadata,
@@ -147,10 +198,22 @@ function createMockDb() {
147
198
  setDocumentStatus,
148
199
  archivePublishedVersions,
149
200
  softDeleteDocument,
201
+ deleteDocumentLocale,
150
202
  getDocumentById,
151
203
  getDocumentSystemFieldsForUpdate,
152
204
  getCurrentVersionMetadata,
153
205
  getCurrentPath,
206
+ publishSchedule,
207
+ confirmPublishSchedule,
208
+ cancelPublishSchedule,
209
+ suspendPublishSchedule,
210
+ claimDuePublishSchedules,
211
+ lockPublishScheduleClaim,
212
+ deletePublishScheduleClaim,
213
+ suspendPublishScheduleClaim,
214
+ releasePublishScheduleClaim,
215
+ getPublishSchedule,
216
+ listPublishSchedules,
154
217
  auditAppend,
155
218
  withTransaction,
156
219
  };
@@ -825,6 +888,203 @@ describe('Document lifecycle service', () => {
825
888
  });
826
889
  });
827
890
  // -----------------------------------------------------------------------
891
+ // scheduled publication lifecycle
892
+ // -----------------------------------------------------------------------
893
+ describe('scheduled publication lifecycle', () => {
894
+ const metadataRow = {
895
+ document_version_id: 'ver-1',
896
+ document_id: 'doc-1',
897
+ collection_id: 'col-1',
898
+ status: 'draft',
899
+ created_at: new Date(),
900
+ updated_at: new Date(),
901
+ };
902
+ it('requires both workflow abilities and records an atomic schedule audit', async () => {
903
+ const { db, getCurrentVersionMetadata, publishSchedule, auditAppend } = createMockDb();
904
+ const schedule = publishScheduleRow();
905
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
906
+ publishSchedule.mockResolvedValue({ status: 'scheduled', schedule, previous: null });
907
+ const ctx = buildCtx(db);
908
+ await expect(scheduleDocumentPublish(ctx, {
909
+ documentId: 'doc-1',
910
+ expectedVersionId: 'ver-1',
911
+ publishAt: schedule.publishAt.toISOString(),
912
+ })).resolves.toEqual(schedule);
913
+ expect(publishSchedule).toHaveBeenCalledWith(expect.objectContaining({
914
+ documentId: 'doc-1',
915
+ collectionId: 'col-1',
916
+ expectedVersionId: 'ver-1',
917
+ actorId: TEST_ACTOR_ID,
918
+ publishAt: schedule.publishAt,
919
+ }));
920
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.scheduled' }));
921
+ const restricted = buildCtx(db);
922
+ restricted.requestContext = createRequestContext({
923
+ actor: new AdminAuth({
924
+ id: TEST_ACTOR_ID,
925
+ abilities: ['collections.articles.changeStatus'],
926
+ }),
927
+ });
928
+ await expect(scheduleDocumentPublish(restricted, {
929
+ documentId: 'doc-1',
930
+ expectedVersionId: 'ver-1',
931
+ publishAt: schedule.publishAt.toISOString(),
932
+ })).rejects.toMatchObject({ code: AuthErrorCodes.FORBIDDEN });
933
+ });
934
+ it('maps a database-time past instant and a stale version to domain errors', async () => {
935
+ const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
936
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
937
+ publishSchedule.mockResolvedValue({ status: 'publish_at_not_future' });
938
+ await expect(scheduleDocumentPublish(buildCtx(db), {
939
+ documentId: 'doc-1',
940
+ expectedVersionId: 'ver-1',
941
+ publishAt: new Date(Date.now() + 60_000).toISOString(),
942
+ })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
943
+ getCurrentVersionMetadata.mockResolvedValue({
944
+ ...metadataRow,
945
+ document_version_id: 'ver-2',
946
+ });
947
+ await expect(scheduleDocumentPublish(buildCtx(db), {
948
+ documentId: 'doc-1',
949
+ expectedVersionId: 'ver-1',
950
+ publishAt: new Date(Date.now() + 60_000).toISOString(),
951
+ })).rejects.toMatchObject({ code: ErrorCodes.CONFLICT });
952
+ });
953
+ it('rejects date strings that are valid to JavaScript but are not ISO instants', async () => {
954
+ const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
955
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
956
+ await expect(scheduleDocumentPublish(buildCtx(db), {
957
+ documentId: 'doc-1',
958
+ expectedVersionId: 'ver-1',
959
+ publishAt: 'August 23, 2026 12:00:00 UTC',
960
+ })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
961
+ expect(publishSchedule).not.toHaveBeenCalled();
962
+ });
963
+ it('re-confirms and explicitly cancels through audited lifecycle operations', async () => {
964
+ const { db, getCurrentVersionMetadata, confirmPublishSchedule, cancelPublishSchedule, auditAppend, } = createMockDb();
965
+ const suspended = publishScheduleRow({
966
+ state: 'needs_reconfirm',
967
+ suspendedAt: new Date(),
968
+ suspendedReason: 'content_edited',
969
+ });
970
+ const confirmed = publishScheduleRow();
971
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
972
+ confirmPublishSchedule.mockResolvedValue({
973
+ status: 'confirmed',
974
+ schedule: confirmed,
975
+ previousTargetVersionId: 'ver-0',
976
+ });
977
+ cancelPublishSchedule.mockResolvedValue(confirmed);
978
+ await expect(confirmDocumentScheduledPublish(buildCtx(db), {
979
+ documentId: 'doc-1',
980
+ expectedVersionId: 'ver-1',
981
+ })).resolves.toEqual(confirmed);
982
+ await expect(cancelDocumentScheduledPublish(buildCtx(db), { documentId: 'doc-1' })).resolves.toEqual(confirmed);
983
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.reconfirmed' }));
984
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
985
+ expect(suspended.state).toBe('needs_reconfirm');
986
+ });
987
+ it('suspends an armed schedule atomically when an update creates a version', async () => {
988
+ const { db, getDocumentById, suspendPublishSchedule, auditAppend } = createMockDb();
989
+ const schedule = publishScheduleRow({
990
+ state: 'needs_reconfirm',
991
+ suspendedAt: new Date(),
992
+ suspendedReason: 'content_edited',
993
+ });
994
+ getDocumentById.mockResolvedValue({
995
+ document_version_id: 'ver-0',
996
+ document_id: 'doc-1',
997
+ path: 'current-path',
998
+ source_locale: 'en',
999
+ fields: { title: 'Before' },
1000
+ });
1001
+ suspendPublishSchedule.mockResolvedValue({ status: 'suspended', schedule });
1002
+ await updateDocument(buildCtx(db), {
1003
+ documentId: 'doc-1',
1004
+ data: { title: 'After' },
1005
+ });
1006
+ expect(suspendPublishSchedule).toHaveBeenCalledWith({
1007
+ documentId: 'doc-1',
1008
+ collectionId: 'col-1',
1009
+ });
1010
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
1011
+ });
1012
+ it('treats deleting a locale as a version-creating edit that suspends the schedule', async () => {
1013
+ const { db, deleteDocumentLocale, getDocumentById, suspendPublishSchedule, auditAppend } = createMockDb();
1014
+ getDocumentById.mockResolvedValue({
1015
+ document_version_id: 'ver-1',
1016
+ document_id: 'doc-1',
1017
+ path: 'current-path',
1018
+ _availableVersionLocales: ['en', 'fr'],
1019
+ fields: { title: 'Bonjour' },
1020
+ });
1021
+ suspendPublishSchedule.mockResolvedValue({
1022
+ status: 'suspended',
1023
+ schedule: publishScheduleRow({
1024
+ state: 'needs_reconfirm',
1025
+ suspendedAt: new Date(),
1026
+ suspendedReason: 'content_edited',
1027
+ }),
1028
+ });
1029
+ await deleteLocale(buildCtx(db), { documentId: 'doc-1', locale: 'fr' });
1030
+ expect(deleteDocumentLocale).toHaveBeenCalledOnce();
1031
+ expect(suspendPublishSchedule).toHaveBeenCalledWith({
1032
+ documentId: 'doc-1',
1033
+ collectionId: 'col-1',
1034
+ });
1035
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
1036
+ });
1037
+ it('cancels an active schedule as an effect of an ordinary status transition', async () => {
1038
+ const { db, getCurrentVersionMetadata, cancelPublishSchedule, auditAppend } = createMockDb();
1039
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1040
+ cancelPublishSchedule.mockResolvedValue(publishScheduleRow());
1041
+ await changeDocumentStatus(buildCtx(db), {
1042
+ documentId: 'doc-1',
1043
+ nextStatus: 'published',
1044
+ });
1045
+ expect(cancelPublishSchedule).toHaveBeenCalledWith({
1046
+ documentId: 'doc-1',
1047
+ collectionId: 'col-1',
1048
+ });
1049
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
1050
+ });
1051
+ it('resolves cross-collection visibility above storage into a collection-id allowlist', async () => {
1052
+ const { db, listPublishSchedules } = createMockDb();
1053
+ const secret = {
1054
+ ...minimalCollection,
1055
+ path: 'secret',
1056
+ labels: { singular: 'Secret', plural: 'Secrets' },
1057
+ };
1058
+ const core = {
1059
+ collections: [minimalCollection, secret],
1060
+ db,
1061
+ getCollectionRecord: (path) => ({
1062
+ collectionId: path === 'articles' ? 'col-1' : 'col-2',
1063
+ version: 1,
1064
+ schemaHash: 'test',
1065
+ }),
1066
+ };
1067
+ const requestContext = createRequestContext({
1068
+ actor: new AdminAuth({
1069
+ id: TEST_ACTOR_ID,
1070
+ abilities: [
1071
+ 'collections.articles.changeStatus',
1072
+ 'collections.articles.publish',
1073
+ 'collections.secret.changeStatus',
1074
+ ],
1075
+ }),
1076
+ });
1077
+ await listDocumentPublishSchedules(core, requestContext, { page: 1, pageSize: 20 });
1078
+ expect(listPublishSchedules).toHaveBeenCalledWith({
1079
+ collectionIds: ['col-1'],
1080
+ states: undefined,
1081
+ lastAuthorizedBy: undefined,
1082
+ page: 1,
1083
+ pageSize: 20,
1084
+ });
1085
+ });
1086
+ });
1087
+ // -----------------------------------------------------------------------
828
1088
  // changeDocumentStatus
829
1089
  // -----------------------------------------------------------------------
830
1090
  describe('changeDocumentStatus', () => {
@@ -53,6 +53,7 @@ function createMockDb() {
53
53
  delete: vi.fn(),
54
54
  },
55
55
  documents: {
56
+ publishSchedules: {},
56
57
  createDocumentVersion,
57
58
  updateDocumentPath: vi.fn(),
58
59
  setDocumentAvailableLocales: vi.fn(),
@@ -89,6 +90,7 @@ function createMockDb() {
89
90
  getCollectionById: vi.fn(),
90
91
  },
91
92
  documents: {
93
+ publishSchedules: {},
92
94
  getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
93
95
  getDocumentById: vi.fn(),
94
96
  getCurrentVersionMetadata: vi.fn(),