@byline/core 4.14.1 → 4.16.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,396 @@
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, vi } from 'vitest';
9
+ import { defineRecurringTask } from './define-recurring-task.js';
10
+ import { runDueTasks, runDueTasksWithDeps } from './run-due-tasks.js';
11
+ const silentLogger = {
12
+ log: vi.fn(),
13
+ fatal: vi.fn(),
14
+ error: vi.fn(),
15
+ warn: vi.fn(),
16
+ info: vi.fn(),
17
+ debug: vi.fn(),
18
+ trace: vi.fn(),
19
+ silent: vi.fn(),
20
+ };
21
+ function claimed(name, recoveredExpiredLease = false) {
22
+ return {
23
+ name,
24
+ leaseToken: `token-${name}`,
25
+ scheduledFor: new Date('2026-08-22T00:00:00Z'),
26
+ databaseNow: new Date('2026-08-22T00:00:01Z'),
27
+ recoveredExpiredLease,
28
+ };
29
+ }
30
+ function fakeStore(overrides = {}) {
31
+ return {
32
+ reconcile: vi.fn(async () => { }),
33
+ claim: vi.fn(async () => null),
34
+ renew: vi.fn(async () => true),
35
+ complete: vi.fn(async () => true),
36
+ fail: vi.fn(async () => true),
37
+ health: vi.fn(async () => []),
38
+ ...overrides,
39
+ };
40
+ }
41
+ function task(name, run = async () => { }) {
42
+ return defineRecurringTask({
43
+ name,
44
+ intervalMs: 60_000,
45
+ leaseMs: 60_000,
46
+ run,
47
+ });
48
+ }
49
+ describe('runDueTasks', () => {
50
+ it('does nothing when no task is due', async () => {
51
+ const run = vi.fn(async () => { });
52
+ const store = fakeStore();
53
+ const summary = await runDueTasksWithDeps({
54
+ store,
55
+ tasks: [task('a', run)],
56
+ owner: 'test',
57
+ logger: silentLogger,
58
+ });
59
+ expect(run).not.toHaveBeenCalled();
60
+ expect(summary).toEqual({ claimed: 0, succeeded: 0, failed: 0, aborted: 0 });
61
+ });
62
+ it('reconciles the registered definitions before attempting claims', async () => {
63
+ const operations = [];
64
+ const store = fakeStore({
65
+ reconcile: vi.fn(async (definitions) => {
66
+ operations.push('reconcile');
67
+ expect(definitions).toEqual([{ name: 'a', intervalMs: 60_000 }]);
68
+ }),
69
+ claim: vi.fn(async () => {
70
+ operations.push('claim');
71
+ return null;
72
+ }),
73
+ });
74
+ await runDueTasksWithDeps({
75
+ store,
76
+ tasks: [task('a')],
77
+ owner: 'test',
78
+ logger: silentLogger,
79
+ });
80
+ expect(operations).toEqual(['reconcile', 'claim']);
81
+ });
82
+ it('rejects a pass when reconciliation fails so external cron observes the outage', async () => {
83
+ const error = new Error('reconcile unavailable');
84
+ const logger = { ...silentLogger, error: vi.fn() };
85
+ const store = fakeStore({
86
+ reconcile: vi.fn(async () => {
87
+ throw error;
88
+ }),
89
+ });
90
+ await expect(runDueTasksWithDeps({
91
+ store,
92
+ tasks: [task('a')],
93
+ owner: 'test',
94
+ logger,
95
+ })).rejects.toBe(error);
96
+ expect(store.claim).not.toHaveBeenCalled();
97
+ expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: 'scheduler.reconcile-error', err: error }), expect.any(String));
98
+ });
99
+ it('runs a claimed task and completes it', async () => {
100
+ const run = vi.fn(async () => { });
101
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a')) });
102
+ const summary = await runDueTasksWithDeps({
103
+ store,
104
+ tasks: [task('a', run)],
105
+ owner: 'test',
106
+ logger: silentLogger,
107
+ });
108
+ expect(run).toHaveBeenCalledTimes(1);
109
+ expect(store.complete).toHaveBeenCalledWith(expect.objectContaining({
110
+ name: 'a',
111
+ leaseToken: 'token-a',
112
+ durationMs: expect.any(Number),
113
+ workRemaining: false,
114
+ }));
115
+ expect(summary).toEqual({ claimed: 1, succeeded: 1, failed: 0, aborted: 0 });
116
+ });
117
+ it('passes workRemaining through to complete', async () => {
118
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a')) });
119
+ const recurringTask = defineRecurringTask({
120
+ name: 'a',
121
+ intervalMs: 3_600_000,
122
+ leaseMs: 60_000,
123
+ run: async () => ({ workRemaining: true }),
124
+ });
125
+ await runDueTasksWithDeps({
126
+ store,
127
+ tasks: [recurringTask],
128
+ owner: 'test',
129
+ logger: silentLogger,
130
+ });
131
+ expect(store.complete).toHaveBeenCalledWith(expect.objectContaining({ workRemaining: true }));
132
+ });
133
+ it('sanitizes and bounds a handler error before recording failure', async () => {
134
+ const error = new Error(`boom\u0000${'x'.repeat(3_000)}\nstack-like detail`);
135
+ const logger = { ...silentLogger, error: vi.fn() };
136
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a')) });
137
+ const recurringTask = task('a', async () => {
138
+ throw error;
139
+ });
140
+ const summary = await runDueTasksWithDeps({
141
+ store,
142
+ tasks: [recurringTask],
143
+ owner: 'test',
144
+ logger,
145
+ });
146
+ const failure = vi.mocked(store.fail).mock.calls[0]?.[0];
147
+ expect(failure?.error).toHaveLength(2_048);
148
+ expect(failure?.error).not.toContain('\u0000');
149
+ expect(failure?.error).not.toContain('\r');
150
+ expect(failure?.error).not.toContain('\n');
151
+ expect(failure?.error).not.toContain('stack-like detail');
152
+ expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ err: error, name: 'a', owner: 'test' }), expect.any(String));
153
+ expect(summary).toEqual({ claimed: 1, succeeded: 0, failed: 1, aborted: 0 });
154
+ });
155
+ it('continues other tasks when a handler fails', async () => {
156
+ const ranB = vi.fn(async () => { });
157
+ const store = fakeStore({ claim: vi.fn(async ({ name }) => claimed(name)) });
158
+ const tasks = [
159
+ task('a', async () => {
160
+ throw new Error('boom');
161
+ }),
162
+ task('b', ranB),
163
+ ];
164
+ const summary = await runDueTasksWithDeps({
165
+ store,
166
+ tasks,
167
+ owner: 'test',
168
+ logger: silentLogger,
169
+ });
170
+ expect(ranB).toHaveBeenCalledTimes(1);
171
+ expect(summary).toEqual({ claimed: 2, succeeded: 1, failed: 1, aborted: 0 });
172
+ });
173
+ it('contains and counts store failures without rejecting the pass', async () => {
174
+ const ranB = vi.fn(async () => { });
175
+ const store = fakeStore({
176
+ claim: vi.fn(async ({ name }) => {
177
+ if (name === 'a')
178
+ throw new Error('db down');
179
+ return claimed(name);
180
+ }),
181
+ });
182
+ await expect(runDueTasksWithDeps({
183
+ store,
184
+ tasks: [task('a'), task('b', ranB)],
185
+ owner: 'test',
186
+ logger: silentLogger,
187
+ })).resolves.toEqual({ claimed: 1, succeeded: 1, failed: 1, aborted: 0 });
188
+ expect(ranB).toHaveBeenCalledTimes(1);
189
+ });
190
+ it('contains a completion-store rejection without attempting fail', async () => {
191
+ const store = fakeStore({
192
+ claim: vi.fn(async () => claimed('a')),
193
+ complete: vi.fn(async () => {
194
+ throw new Error('completion unavailable');
195
+ }),
196
+ });
197
+ await expect(runDueTasksWithDeps({
198
+ store,
199
+ tasks: [task('a')],
200
+ owner: 'test',
201
+ logger: silentLogger,
202
+ })).resolves.toEqual({ claimed: 1, succeeded: 0, failed: 1, aborted: 0 });
203
+ expect(store.fail).not.toHaveBeenCalled();
204
+ });
205
+ it('contains a failure-store rejection without rejecting the pass', async () => {
206
+ const store = fakeStore({
207
+ claim: vi.fn(async () => claimed('a')),
208
+ fail: vi.fn(async () => {
209
+ throw new Error('failure recorder unavailable');
210
+ }),
211
+ });
212
+ await expect(runDueTasksWithDeps({
213
+ store,
214
+ tasks: [
215
+ task('a', async () => {
216
+ throw new Error('handler failed');
217
+ }),
218
+ ],
219
+ owner: 'test',
220
+ logger: silentLogger,
221
+ })).resolves.toEqual({ claimed: 1, succeeded: 0, failed: 1, aborted: 0 });
222
+ });
223
+ it('aborts before heartbeat rejects and never finalizes a known-lost lease', async () => {
224
+ const store = fakeStore({
225
+ claim: vi.fn(async () => claimed('a')),
226
+ renew: vi.fn(async () => false),
227
+ });
228
+ let abortedWhenHeartbeatRejected = false;
229
+ const recurringTask = defineRecurringTask({
230
+ name: 'a',
231
+ intervalMs: 3_600_000,
232
+ leaseMs: 60_000,
233
+ run: async (context) => {
234
+ try {
235
+ await context.heartbeat();
236
+ }
237
+ catch {
238
+ abortedWhenHeartbeatRejected = context.signal.aborted;
239
+ }
240
+ },
241
+ });
242
+ const summary = await runDueTasksWithDeps({
243
+ store,
244
+ tasks: [recurringTask],
245
+ owner: 'test',
246
+ logger: silentLogger,
247
+ });
248
+ expect(abortedWhenHeartbeatRejected).toBe(true);
249
+ expect(store.complete).not.toHaveBeenCalled();
250
+ expect(store.fail).not.toHaveBeenCalled();
251
+ expect(summary).toEqual({ claimed: 1, succeeded: 0, failed: 1, aborted: 0 });
252
+ });
253
+ it('aborts and avoids finalization when the heartbeat store call rejects', async () => {
254
+ const store = fakeStore({
255
+ claim: vi.fn(async () => claimed('a')),
256
+ renew: vi.fn(async () => {
257
+ throw new Error('renew unavailable');
258
+ }),
259
+ });
260
+ let heartbeatRejectedAfterAbort = false;
261
+ const recurringTask = defineRecurringTask({
262
+ name: 'a',
263
+ intervalMs: 60_000,
264
+ leaseMs: 60_000,
265
+ run: async (context) => {
266
+ try {
267
+ await context.heartbeat();
268
+ }
269
+ catch {
270
+ heartbeatRejectedAfterAbort = context.signal.aborted;
271
+ }
272
+ },
273
+ });
274
+ await expect(runDueTasksWithDeps({
275
+ store,
276
+ tasks: [recurringTask],
277
+ owner: 'test',
278
+ logger: silentLogger,
279
+ })).resolves.toEqual({ claimed: 1, succeeded: 0, failed: 1, aborted: 0 });
280
+ expect(heartbeatRejectedAfterAbort).toBe(true);
281
+ expect(store.complete).not.toHaveBeenCalled();
282
+ expect(store.fail).not.toHaveBeenCalled();
283
+ });
284
+ it('does not finalize after the incoming signal aborts an active handler', async () => {
285
+ const controller = new AbortController();
286
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a')) });
287
+ const recurringTask = defineRecurringTask({
288
+ name: 'a',
289
+ intervalMs: 60_000,
290
+ leaseMs: 60_000,
291
+ run: async (context) => {
292
+ controller.abort();
293
+ expect(context.signal.aborted).toBe(true);
294
+ },
295
+ });
296
+ const summary = await runDueTasksWithDeps({
297
+ store,
298
+ tasks: [recurringTask],
299
+ owner: 'test',
300
+ logger: silentLogger,
301
+ signal: controller.signal,
302
+ });
303
+ expect(store.complete).not.toHaveBeenCalled();
304
+ expect(store.fail).not.toHaveBeenCalled();
305
+ expect(summary).toEqual({ claimed: 1, succeeded: 0, failed: 0, aborted: 1 });
306
+ });
307
+ it('stops claiming new definitions after the incoming signal aborts', async () => {
308
+ const controller = new AbortController();
309
+ const store = fakeStore({
310
+ claim: vi.fn(async ({ name }) => {
311
+ controller.abort();
312
+ return claimed(name);
313
+ }),
314
+ });
315
+ await runDueTasksWithDeps({
316
+ store,
317
+ tasks: [task('a'), task('b')],
318
+ owner: 'test',
319
+ logger: silentLogger,
320
+ signal: controller.signal,
321
+ concurrency: 1,
322
+ });
323
+ expect(store.claim).toHaveBeenCalledTimes(1);
324
+ expect(store.claim).toHaveBeenCalledWith({ name: 'a', leaseMs: 60_000, owner: 'test' });
325
+ });
326
+ it('defaults to two concurrent handlers while still attempting every definition', async () => {
327
+ let active = 0;
328
+ let maximumActive = 0;
329
+ const releases = [];
330
+ const store = fakeStore({ claim: vi.fn(async ({ name }) => claimed(name)) });
331
+ const tasks = ['a', 'b', 'c', 'd'].map((name) => task(name, () => new Promise((resolve) => {
332
+ active += 1;
333
+ maximumActive = Math.max(maximumActive, active);
334
+ releases.push(() => {
335
+ active -= 1;
336
+ resolve();
337
+ });
338
+ })));
339
+ const pass = runDueTasksWithDeps({
340
+ store,
341
+ tasks,
342
+ owner: 'test',
343
+ logger: silentLogger,
344
+ });
345
+ await vi.waitFor(() => expect(releases).toHaveLength(2));
346
+ releases.shift()?.();
347
+ await vi.waitFor(() => expect(releases).toHaveLength(2));
348
+ releases.shift()?.();
349
+ await vi.waitFor(() => expect(releases).toHaveLength(2));
350
+ releases.splice(0).forEach((release) => {
351
+ release();
352
+ });
353
+ await expect(pass).resolves.toEqual({ claimed: 4, succeeded: 4, failed: 0, aborted: 0 });
354
+ expect(maximumActive).toBe(2);
355
+ expect(store.claim).toHaveBeenCalledTimes(4);
356
+ });
357
+ it('treats a rejected completion fence as lease loss without calling fail', async () => {
358
+ const store = fakeStore({
359
+ claim: vi.fn(async () => claimed('a')),
360
+ complete: vi.fn(async () => false),
361
+ });
362
+ const summary = await runDueTasksWithDeps({
363
+ store,
364
+ tasks: [task('a')],
365
+ owner: 'test',
366
+ logger: silentLogger,
367
+ });
368
+ expect(store.fail).not.toHaveBeenCalled();
369
+ expect(summary).toEqual({ claimed: 1, succeeded: 0, failed: 1, aborted: 0 });
370
+ });
371
+ it('logs recovery when a claim takes over an expired lease', async () => {
372
+ const logger = { ...silentLogger, warn: vi.fn() };
373
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a', true)) });
374
+ await runDueTasksWithDeps({
375
+ store,
376
+ tasks: [task('a')],
377
+ owner: 'test',
378
+ logger,
379
+ });
380
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: 'scheduler.recovered-expired-lease', name: 'a' }), expect.any(String));
381
+ });
382
+ it('uses only core.recurringTasks and reports a missing scheduler capability', async () => {
383
+ const coreRun = vi.fn(async () => { });
384
+ const injectedRun = vi.fn(async () => { });
385
+ const store = fakeStore({ claim: vi.fn(async ({ name }) => claimed(name)) });
386
+ const core = {
387
+ db: { scheduler: store },
388
+ recurringTasks: [task('core-task', coreRun)],
389
+ logger: silentLogger,
390
+ };
391
+ await runDueTasks(core, { owner: 'test', tasks: [task('injected-task', injectedRun)] });
392
+ expect(coreRun).toHaveBeenCalledTimes(1);
393
+ expect(injectedRun).not.toHaveBeenCalled();
394
+ await expect(runDueTasks({ ...core, db: {} }, { owner: 'test' })).rejects.toThrow(/scheduler capability/i);
395
+ });
396
+ });
@@ -0,0 +1,11 @@
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
+ /** Inert built-in task metadata, safe for core initialization to import. */
9
+ export declare const SCHEDULED_PUBLICATION_TASK_NAME = "documents.publish-scheduled";
10
+ export declare const SCHEDULED_PUBLICATION_INTERVAL_MS = 60000;
11
+ export declare const SCHEDULED_PUBLICATION_LEASE_MS: number;
@@ -0,0 +1,11 @@
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
+ /** Inert built-in task metadata, safe for core initialization to import. */
9
+ export const SCHEDULED_PUBLICATION_TASK_NAME = 'documents.publish-scheduled';
10
+ export const SCHEDULED_PUBLICATION_INTERVAL_MS = 60_000;
11
+ export const SCHEDULED_PUBLICATION_LEASE_MS = 5 * 60_000;
@@ -0,0 +1,31 @@
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 type { BylineCore } from '../core.js';
9
+ import type { BylineLogger } from '../lib/logger.js';
10
+ export interface ScheduledPublicationSweepOptions {
11
+ /** Abort before claiming another batch. */
12
+ signal?: AbortSignal;
13
+ /** Stop claiming new work after this process-time budget. */
14
+ budgetMs?: number;
15
+ /** Maximum schedules claimed per batch. */
16
+ batchSize?: number;
17
+ /** Renew an enclosing scheduler lease between batches. */
18
+ heartbeat?: () => Promise<void>;
19
+ logger?: BylineLogger;
20
+ }
21
+ export interface ScheduledPublicationSweepResult {
22
+ published: number;
23
+ failed: number;
24
+ workRemaining: boolean;
25
+ }
26
+ /**
27
+ * Drain due publication schedules without depending on the in-process ticker.
28
+ * Database claims provide the execution fence, so independent external
29
+ * orchestrators may invoke this operation concurrently.
30
+ */
31
+ export declare function runScheduledPublicationSweep(core: BylineCore, options?: ScheduledPublicationSweepOptions): Promise<ScheduledPublicationSweepResult>;
@@ -0,0 +1,198 @@
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 { createSuperAdminContext } from '@byline/auth';
9
+ import { AUDIT_ACTIONS, requireAuditCapability } from '../services/document-lifecycle/audit.js';
10
+ import { publishScheduleAuditValue } from '../services/document-lifecycle/publish-schedule-consistency.js';
11
+ import { publishClaimedScheduledDocument } from '../services/document-lifecycle/scheduled-publish.js';
12
+ import { SCHEDULED_PUBLICATION_LEASE_MS } from './scheduled-publication-constants.js';
13
+ const DEFAULT_BATCH_SIZE = 25;
14
+ const DEFAULT_BUDGET_MS = 45_000;
15
+ function throwIfAborted(signal) {
16
+ if (!signal?.aborted)
17
+ return;
18
+ if (signal.reason instanceof Error)
19
+ throw signal.reason;
20
+ throw new DOMException('scheduled publication sweep aborted', 'AbortError');
21
+ }
22
+ function errorMessage(error) {
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+ function resolveCollection(core, collectionId) {
26
+ for (const definition of core.collections) {
27
+ const record = core.collectionRecords.get(definition.path);
28
+ if (record?.collectionId === collectionId)
29
+ return { definition, version: record.version };
30
+ }
31
+ return null;
32
+ }
33
+ function buildSystemContext(core, claim, logger) {
34
+ const collection = resolveCollection(core, claim.collectionId);
35
+ if (collection === null)
36
+ return null;
37
+ return {
38
+ db: core.db,
39
+ definition: collection.definition,
40
+ collectionId: claim.collectionId,
41
+ collectionVersion: collection.version,
42
+ collectionPath: collection.definition.path,
43
+ storage: core.storage,
44
+ logger,
45
+ defaultLocale: core.config.i18n.content.defaultLocale,
46
+ slugifier: core.config.slugifier,
47
+ requestContext: createSuperAdminContext({ id: 'scheduled-publication' }),
48
+ };
49
+ }
50
+ async function finalizeClaim(params) {
51
+ const audit = requireAuditCapability(params.core.db);
52
+ return audit.withTransaction(async () => {
53
+ const locked = await params.core.db.commands.documents.publishSchedules.lockClaim({
54
+ documentId: params.claim.documentId,
55
+ executionToken: params.claim.executionToken,
56
+ });
57
+ if (locked === null)
58
+ return false;
59
+ const changed = params.outcome === 'suspend'
60
+ ? await params.core.db.commands.documents.publishSchedules.suspendClaimForContentEdit({
61
+ documentId: params.claim.documentId,
62
+ executionToken: params.claim.executionToken,
63
+ })
64
+ : await params.core.db.commands.documents.publishSchedules.deleteClaim({
65
+ documentId: params.claim.documentId,
66
+ executionToken: params.claim.executionToken,
67
+ });
68
+ if (!changed)
69
+ return false;
70
+ await audit.append({
71
+ documentId: params.claim.documentId,
72
+ collectionId: params.claim.collectionId,
73
+ actorRealm: 'system',
74
+ action: params.outcome === 'suspend'
75
+ ? AUDIT_ACTIONS.publishScheduleSuspended
76
+ : AUDIT_ACTIONS.publishScheduleDiscarded,
77
+ field: 'scheduled_publish',
78
+ before: publishScheduleAuditValue(locked),
79
+ after: params.outcome === 'suspend'
80
+ ? { state: 'needs_reconfirm', reason: params.reason }
81
+ : { reason: params.reason },
82
+ });
83
+ return true;
84
+ });
85
+ }
86
+ async function processClaim(params) {
87
+ try {
88
+ const ctx = buildSystemContext(params.core, params.claim, params.logger);
89
+ if (ctx === null) {
90
+ await finalizeClaim({
91
+ core: params.core,
92
+ claim: params.claim,
93
+ outcome: 'discard',
94
+ reason: 'collection_not_registered',
95
+ });
96
+ params.logger.warn({ documentId: params.claim.documentId, collectionId: params.claim.collectionId }, 'discarded scheduled publication for an unregistered collection');
97
+ return 'handled';
98
+ }
99
+ const result = await publishClaimedScheduledDocument(ctx, {
100
+ documentId: params.claim.documentId,
101
+ executionToken: params.claim.executionToken,
102
+ });
103
+ if (result.status === 'published')
104
+ return 'published';
105
+ if (result.status === 'claim_lost')
106
+ return 'handled';
107
+ if (result.status === 'target_changed') {
108
+ await finalizeClaim({
109
+ core: params.core,
110
+ claim: params.claim,
111
+ outcome: 'suspend',
112
+ reason: 'content_edited',
113
+ });
114
+ return 'handled';
115
+ }
116
+ await finalizeClaim({
117
+ core: params.core,
118
+ claim: params.claim,
119
+ outcome: 'discard',
120
+ reason: result.reason,
121
+ });
122
+ params.logger.warn({ documentId: params.claim.documentId, reason: result.reason }, 'discarded terminal scheduled publication');
123
+ return 'handled';
124
+ }
125
+ catch (error) {
126
+ try {
127
+ params.logger.error({ err: error, documentId: params.claim.documentId }, 'scheduled publication attempt failed');
128
+ }
129
+ catch {
130
+ // A logger failure must not prevent release of the execution claim.
131
+ }
132
+ const released = await params.core.db.commands.documents.publishSchedules.releaseClaim({
133
+ documentId: params.claim.documentId,
134
+ executionToken: params.claim.executionToken,
135
+ error: errorMessage(error),
136
+ });
137
+ return released ? 'failed' : 'handled';
138
+ }
139
+ }
140
+ /**
141
+ * Drain due publication schedules without depending on the in-process ticker.
142
+ * Database claims provide the execution fence, so independent external
143
+ * orchestrators may invoke this operation concurrently.
144
+ */
145
+ export async function runScheduledPublicationSweep(core, options = {}) {
146
+ const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
147
+ const budgetMs = options.budgetMs ?? DEFAULT_BUDGET_MS;
148
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
149
+ throw new TypeError('scheduled publication batchSize must be a positive integer');
150
+ }
151
+ if (!Number.isFinite(budgetMs) || budgetMs < 0) {
152
+ throw new TypeError('scheduled publication budgetMs must be a finite non-negative number');
153
+ }
154
+ const logger = options.logger ?? core.logger;
155
+ const startedAt = performance.now();
156
+ let published = 0;
157
+ let failed = 0;
158
+ let completedBatch = false;
159
+ while (true) {
160
+ throwIfAborted(options.signal);
161
+ if (performance.now() - startedAt >= budgetMs) {
162
+ return { published, failed, workRemaining: true };
163
+ }
164
+ if (completedBatch)
165
+ await options.heartbeat?.();
166
+ const claims = await core.db.commands.documents.publishSchedules.claimDue({
167
+ batchSize,
168
+ leaseMs: SCHEDULED_PUBLICATION_LEASE_MS,
169
+ });
170
+ if (claims.length === 0)
171
+ return { published, failed, workRemaining: false };
172
+ for (const claim of claims) {
173
+ try {
174
+ const result = await processClaim({ core, claim, logger });
175
+ if (result === 'published')
176
+ published++;
177
+ if (result === 'failed')
178
+ failed++;
179
+ }
180
+ catch (error) {
181
+ // Claim recovery is best effort. A database outage may make both the
182
+ // document operation and its token release fail; the claim then
183
+ // expires naturally, but the rest of this batch must still run.
184
+ failed++;
185
+ try {
186
+ logger.error({ err: error, documentId: claim.documentId }, 'scheduled publication failure could not release its execution claim');
187
+ }
188
+ catch {
189
+ // Diagnostic logging cannot turn one item into a batch failure.
190
+ }
191
+ }
192
+ }
193
+ completedBatch = true;
194
+ if (claims.length < batchSize) {
195
+ return { published, failed, workRemaining: false };
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,8 @@
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
+ export {};