@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,109 @@
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 { runScheduledPublicationSweep } from './scheduled-publication.js';
10
+ const logger = {
11
+ log: vi.fn(),
12
+ fatal: vi.fn(),
13
+ error: vi.fn(),
14
+ warn: vi.fn(),
15
+ info: vi.fn(),
16
+ debug: vi.fn(),
17
+ trace: vi.fn(),
18
+ silent: vi.fn(),
19
+ };
20
+ function emptyCore() {
21
+ const claimDue = vi.fn().mockResolvedValue([]);
22
+ return {
23
+ core: {
24
+ db: { commands: { documents: { publishSchedules: { claimDue } } } },
25
+ logger,
26
+ },
27
+ claimDue,
28
+ };
29
+ }
30
+ function claim(documentId) {
31
+ const now = new Date('2026-08-22T12:00:00.000Z');
32
+ return {
33
+ documentId,
34
+ collectionId: 'unregistered-collection',
35
+ targetVersionId: `version-${documentId}`,
36
+ publishAt: now,
37
+ state: 'armed',
38
+ suspendedAt: null,
39
+ suspendedReason: null,
40
+ scheduledBy: null,
41
+ lastAuthorizedBy: null,
42
+ lastAuthorizedAt: now,
43
+ scheduledAt: now,
44
+ updatedAt: now,
45
+ executionToken: `token-${documentId}`,
46
+ executionExpiresAt: new Date(now.getTime() + 60_000),
47
+ lastAttemptAt: now,
48
+ nextAttemptAt: now,
49
+ attemptCount: 1,
50
+ lastError: null,
51
+ databaseNow: now,
52
+ recoveredExpiredClaim: false,
53
+ };
54
+ }
55
+ describe('runScheduledPublicationSweep', () => {
56
+ it('returns a drained summary when no schedules are due', async () => {
57
+ const { core, claimDue } = emptyCore();
58
+ await expect(runScheduledPublicationSweep(core, { batchSize: 10, budgetMs: 5_000 })).resolves.toEqual({ published: 0, failed: 0, workRemaining: false });
59
+ expect(claimDue).toHaveBeenCalledWith({ batchSize: 10, leaseMs: 300_000 });
60
+ });
61
+ it.each([
62
+ [{ batchSize: 0 }, 'batchSize'],
63
+ [{ batchSize: 1.5 }, 'batchSize'],
64
+ [{ budgetMs: -1 }, 'budgetMs'],
65
+ [{ budgetMs: Number.POSITIVE_INFINITY }, 'budgetMs'],
66
+ ])('rejects invalid operational limits: %o', async (options, expected) => {
67
+ const { core, claimDue } = emptyCore();
68
+ await expect(runScheduledPublicationSweep(core, options)).rejects.toThrow(expected);
69
+ expect(claimDue).not.toHaveBeenCalled();
70
+ });
71
+ it('honours an already-aborted signal before claiming work', async () => {
72
+ const { core, claimDue } = emptyCore();
73
+ const controller = new AbortController();
74
+ controller.abort(new Error('stopping'));
75
+ await expect(runScheduledPublicationSweep(core, { signal: controller.signal })).rejects.toThrow('stopping');
76
+ expect(claimDue).not.toHaveBeenCalled();
77
+ });
78
+ it('continues the batch when both cleanup and claim release fail for one item', async () => {
79
+ const first = claim('first');
80
+ const second = claim('second');
81
+ const claimDue = vi.fn().mockResolvedValue([first, second]);
82
+ const lockClaim = vi.fn(async ({ documentId }) => {
83
+ if (documentId === first.documentId)
84
+ throw new Error('cleanup unavailable');
85
+ return second;
86
+ });
87
+ const deleteClaim = vi.fn().mockResolvedValue(true);
88
+ const releaseClaim = vi.fn(async () => {
89
+ throw new Error('release unavailable');
90
+ });
91
+ const core = {
92
+ collections: [],
93
+ collectionRecords: new Map(),
94
+ db: {
95
+ withTransaction: async (fn) => fn(),
96
+ commands: {
97
+ audit: { append: vi.fn().mockResolvedValue({ id: 'audit-1' }) },
98
+ documents: {
99
+ publishSchedules: { claimDue, lockClaim, deleteClaim, releaseClaim },
100
+ },
101
+ },
102
+ },
103
+ logger,
104
+ };
105
+ await expect(runScheduledPublicationSweep(core, { batchSize: 10, budgetMs: 5_000 })).resolves.toEqual({ published: 0, failed: 1, workRemaining: false });
106
+ expect(releaseClaim).toHaveBeenCalledWith(expect.objectContaining({ documentId: first.documentId }));
107
+ expect(deleteClaim).toHaveBeenCalledWith(expect.objectContaining({ documentId: second.documentId }));
108
+ });
109
+ });
@@ -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 {};
@@ -0,0 +1,103 @@
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 { initBylineCore } from '../core.js';
10
+ import { defineRecurringTask } from './define-recurring-task.js';
11
+ // Boot-level coverage for the scheduler wiring inside `initBylineCore()`,
12
+ // complementing the pure-function coverage in `validate-scheduler-config.test.node.ts`.
13
+ // Collections are kept empty and `db` a minimal stub so init reaches the
14
+ // scheduler gate without needing a real database — see `core.test.node.ts`
15
+ // for the same pattern.
16
+ function serverConfig(db) {
17
+ return {
18
+ routes: { admin: '/admin' },
19
+ collections: [],
20
+ db,
21
+ i18n: {
22
+ admin: { defaultLocale: 'en', locales: [] },
23
+ content: { defaultLocale: 'en', locales: [] },
24
+ },
25
+ };
26
+ }
27
+ const task = defineRecurringTask({
28
+ name: 'analytics.rollup',
29
+ intervalMs: 3_600_000,
30
+ leaseMs: 300_000,
31
+ run: async () => { },
32
+ });
33
+ describe('initBylineCore scheduler wiring', () => {
34
+ it('rejects recurring tasks registered against an adapter without the scheduler capability', async () => {
35
+ const config = serverConfig({});
36
+ config.recurringTasks = [task];
37
+ await expect(initBylineCore(config, {})).rejects.toThrow(/analytics\.rollup/);
38
+ });
39
+ it('gates scheduled publication on the scheduler capability and contributes its built-in task', async () => {
40
+ const unsupported = serverConfig({});
41
+ unsupported.scheduledPublication = { enabled: true };
42
+ await expect(initBylineCore(unsupported, {})).rejects.toThrow(/documents\.publish-scheduled/);
43
+ const capable = serverConfig({ scheduler: {} });
44
+ capable.scheduledPublication = { enabled: true };
45
+ capable.recurringTasks = [task];
46
+ const core = await initBylineCore(capable, {});
47
+ expect(core.recurringTasks.map((definition) => definition.name)).toEqual([
48
+ 'analytics.rollup',
49
+ 'documents.publish-scheduled',
50
+ ]);
51
+ expect(core.recurringTasks[1]).toMatchObject({
52
+ intervalMs: 60_000,
53
+ leaseMs: 300_000,
54
+ });
55
+ });
56
+ it('populates core.recurringTasks with the registered set when the adapter is capable', async () => {
57
+ const db = { scheduler: {} };
58
+ const config = serverConfig(db);
59
+ config.recurringTasks = [task];
60
+ const core = await initBylineCore(config, {});
61
+ expect(core.recurringTasks).toHaveLength(1);
62
+ const [registered] = core.recurringTasks;
63
+ expect(registered).toMatchObject({ name: 'analytics.rollup', intervalMs: 3_600_000 });
64
+ });
65
+ it('freezes the validated snapshot so post-init mutation of the caller input cannot alter it', async () => {
66
+ const db = { scheduler: {} };
67
+ const localTask = defineRecurringTask({
68
+ name: 'analytics.local',
69
+ intervalMs: 3_600_000,
70
+ leaseMs: 300_000,
71
+ run: async () => { },
72
+ });
73
+ const originalTasks = [localTask];
74
+ const config = serverConfig(db);
75
+ config.recurringTasks = originalTasks;
76
+ const core = await initBylineCore(config, {});
77
+ // Mutate the caller's own array and one of its definition objects after
78
+ // init has returned.
79
+ originalTasks.push(defineRecurringTask({
80
+ name: 'analytics.extra',
81
+ intervalMs: 3_600_000,
82
+ leaseMs: 300_000,
83
+ run: async () => { },
84
+ }));
85
+ localTask.intervalMs = 999;
86
+ // The snapshot on `core` is unaffected by either mutation.
87
+ expect(core.recurringTasks).toHaveLength(1);
88
+ const [snapshotTask] = core.recurringTasks;
89
+ expect(snapshotTask?.intervalMs).toBe(3_600_000);
90
+ expect(snapshotTask?.name).toBe('analytics.local');
91
+ // The snapshot array and its definition objects are themselves frozen.
92
+ // ES modules are always strict mode, so mutating a frozen array or
93
+ // object throws rather than silently no-op'ing.
94
+ expect(() => {
95
+ ;
96
+ core.recurringTasks.push('nope');
97
+ }).toThrow(TypeError);
98
+ expect(() => {
99
+ ;
100
+ snapshotTask.intervalMs = 1;
101
+ }).toThrow(TypeError);
102
+ });
103
+ });
@@ -0,0 +1,34 @@
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 '../logger/index.js';
10
+ import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
11
+ export interface SchedulerOptions {
12
+ tickIntervalMs?: number;
13
+ startupJitterMs?: number;
14
+ concurrency?: number;
15
+ owner?: string;
16
+ shutdownGraceMs?: number;
17
+ }
18
+ export interface SchedulerController {
19
+ stop(): Promise<void>;
20
+ }
21
+ interface SchedulerDeps extends SchedulerOptions {
22
+ store: ISchedulerStore;
23
+ tasks: readonly RecurringTaskDefinition[];
24
+ owner: string;
25
+ logger: BylineLogger;
26
+ }
27
+ /**
28
+ * Dependency-injected ticker used by unit tests. It is intentionally absent
29
+ * from the `@byline/core/scheduler` barrel.
30
+ */
31
+ export declare function startSchedulerWithDeps(params: SchedulerDeps): SchedulerController;
32
+ /** Start the opt-in, in-process ticker over the definitions vetted at boot. */
33
+ export declare function startBylineScheduler(core: BylineCore, options?: SchedulerOptions): SchedulerController;
34
+ export {};
@@ -0,0 +1,144 @@
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 { defaultOwner, runDueTasksWithDeps } from './run-due-tasks.js';
9
+ const DEFAULT_TICK_INTERVAL_MS = 60_000;
10
+ const DEFAULT_STARTUP_JITTER_MS = 30_000;
11
+ const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
12
+ function assertWholeNumber(params) {
13
+ const minimum = params.allowZero === true ? 0 : 1;
14
+ if (!Number.isSafeInteger(params.value) || params.value < minimum) {
15
+ throw new Error(`${params.name} must be a ${params.allowZero === true ? 'non-negative' : 'positive'} ` +
16
+ `whole number${params.unit ?? ''} (received ${params.value})`);
17
+ }
18
+ }
19
+ function unrefTimer(handle) {
20
+ handle.unref?.();
21
+ }
22
+ function startupDelay(maximumMs) {
23
+ if (maximumMs === 0)
24
+ return 0;
25
+ return Math.min(maximumMs, Math.floor(Math.random() * (maximumMs + 1)));
26
+ }
27
+ function waitForTickOrGrace(activeTick, graceMs) {
28
+ return new Promise((resolve) => {
29
+ let settled = false;
30
+ const finish = () => {
31
+ if (settled)
32
+ return;
33
+ settled = true;
34
+ clearTimeout(graceTimer);
35
+ resolve();
36
+ };
37
+ const graceTimer = setTimeout(finish, graceMs);
38
+ unrefTimer(graceTimer);
39
+ void activeTick.then(finish, finish);
40
+ });
41
+ }
42
+ /**
43
+ * Dependency-injected ticker used by unit tests. It is intentionally absent
44
+ * from the `@byline/core/scheduler` barrel.
45
+ */
46
+ export function startSchedulerWithDeps(params) {
47
+ const tickIntervalMs = params.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
48
+ const startupJitterMs = params.startupJitterMs ?? DEFAULT_STARTUP_JITTER_MS;
49
+ const shutdownGraceMs = params.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
50
+ assertWholeNumber({ value: tickIntervalMs, name: 'tickIntervalMs', unit: ' of milliseconds' });
51
+ assertWholeNumber({
52
+ value: startupJitterMs,
53
+ name: 'startupJitterMs',
54
+ allowZero: true,
55
+ unit: ' of milliseconds',
56
+ });
57
+ assertWholeNumber({
58
+ value: shutdownGraceMs,
59
+ name: 'shutdownGraceMs',
60
+ allowZero: true,
61
+ unit: ' of milliseconds',
62
+ });
63
+ if (params.concurrency !== undefined) {
64
+ assertWholeNumber({ value: params.concurrency, name: 'concurrency' });
65
+ }
66
+ const owner = params.owner.slice(0, 255);
67
+ const controller = new AbortController();
68
+ let stopped = false;
69
+ let nextTimeout;
70
+ let activeTick;
71
+ const schedule = (delayMs) => {
72
+ if (stopped)
73
+ return;
74
+ nextTimeout = setTimeout(() => {
75
+ nextTimeout = undefined;
76
+ if (stopped)
77
+ return;
78
+ const currentTick = (async () => {
79
+ try {
80
+ await runDueTasksWithDeps({
81
+ store: params.store,
82
+ tasks: params.tasks,
83
+ owner,
84
+ logger: params.logger,
85
+ signal: controller.signal,
86
+ concurrency: params.concurrency,
87
+ });
88
+ }
89
+ catch (error) {
90
+ params.logger.error({
91
+ event: 'scheduler.tick-error',
92
+ owner,
93
+ durationMs: 0,
94
+ err: error,
95
+ }, 'Recurring task scheduler tick failed');
96
+ }
97
+ })();
98
+ activeTick = currentTick;
99
+ const finishTick = () => {
100
+ if (activeTick === currentTick)
101
+ activeTick = undefined;
102
+ if (!stopped)
103
+ schedule(tickIntervalMs);
104
+ };
105
+ void currentTick.then(finishTick, finishTick);
106
+ }, delayMs);
107
+ unrefTimer(nextTimeout);
108
+ };
109
+ schedule(startupDelay(startupJitterMs));
110
+ return {
111
+ stop: () => {
112
+ if (stopped)
113
+ return Promise.resolve();
114
+ stopped = true;
115
+ if (nextTimeout !== undefined) {
116
+ clearTimeout(nextTimeout);
117
+ nextTimeout = undefined;
118
+ }
119
+ controller.abort(new Error('Recurring task scheduler stopped'));
120
+ const tickAtStop = activeTick;
121
+ if (tickAtStop === undefined)
122
+ return Promise.resolve();
123
+ return waitForTickOrGrace(tickAtStop, shutdownGraceMs);
124
+ },
125
+ };
126
+ }
127
+ /** Start the opt-in, in-process ticker over the definitions vetted at boot. */
128
+ export function startBylineScheduler(core, options = {}) {
129
+ const store = core.db.scheduler;
130
+ if (store === undefined) {
131
+ throw new Error('startBylineScheduler() requires a database adapter with the scheduler capability. ' +
132
+ 'Use a canonical adapter (@byline/db-postgres or @byline/db-mysql).');
133
+ }
134
+ return startSchedulerWithDeps({
135
+ store,
136
+ tasks: core.recurringTasks,
137
+ owner: options.owner ?? defaultOwner(),
138
+ logger: core.logger,
139
+ tickIntervalMs: options.tickIntervalMs,
140
+ startupJitterMs: options.startupJitterMs,
141
+ concurrency: options.concurrency,
142
+ shutdownGraceMs: options.shutdownGraceMs,
143
+ });
144
+ }
@@ -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 {};
@@ -0,0 +1,249 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { defineRecurringTask } from './define-recurring-task.js';
10
+ import { startBylineScheduler, startSchedulerWithDeps } from './ticker.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 fakeStore(overrides = {}) {
22
+ return {
23
+ reconcile: vi.fn(async () => { }),
24
+ claim: vi.fn(async () => null),
25
+ renew: vi.fn(async () => true),
26
+ complete: vi.fn(async () => true),
27
+ fail: vi.fn(async () => true),
28
+ health: vi.fn(async () => []),
29
+ ...overrides,
30
+ };
31
+ }
32
+ function claimed(name) {
33
+ return {
34
+ name,
35
+ leaseToken: `token-${name}`,
36
+ scheduledFor: new Date('2026-08-22T00:00:00Z'),
37
+ databaseNow: new Date('2026-08-22T00:00:01Z'),
38
+ recoveredExpiredLease: false,
39
+ };
40
+ }
41
+ function task(name, run = async () => { }) {
42
+ return defineRecurringTask({ name, intervalMs: 60_000, leaseMs: 60_000, run });
43
+ }
44
+ describe('startBylineScheduler', () => {
45
+ beforeEach(() => {
46
+ vi.useFakeTimers();
47
+ vi.spyOn(Math, 'random').mockReturnValue(0);
48
+ });
49
+ afterEach(() => {
50
+ vi.restoreAllMocks();
51
+ vi.useRealTimers();
52
+ });
53
+ it('runs nothing before the startup jitter elapses', async () => {
54
+ vi.mocked(Math.random).mockReturnValue(1);
55
+ const store = fakeStore();
56
+ const controller = startSchedulerWithDeps({
57
+ store,
58
+ tasks: [task('a')],
59
+ owner: 'test',
60
+ logger: silentLogger,
61
+ startupJitterMs: 30_000,
62
+ });
63
+ await vi.advanceTimersByTimeAsync(29_999);
64
+ expect(store.reconcile).not.toHaveBeenCalled();
65
+ expect(store.claim).not.toHaveBeenCalled();
66
+ await vi.advanceTimersByTimeAsync(1);
67
+ expect(store.reconcile).toHaveBeenCalledTimes(1);
68
+ expect(store.claim).toHaveBeenCalledTimes(1);
69
+ await controller.stop();
70
+ });
71
+ it('unrefs the pending timeout so the ticker alone cannot keep Node alive', async () => {
72
+ vi.useRealTimers();
73
+ const probe = setTimeout(() => { }, 60_000);
74
+ const timerPrototype = Object.getPrototypeOf(probe);
75
+ const unref = vi.spyOn(timerPrototype, 'unref');
76
+ clearTimeout(probe);
77
+ const controller = startSchedulerWithDeps({
78
+ store: fakeStore(),
79
+ tasks: [task('a')],
80
+ owner: 'test',
81
+ logger: silentLogger,
82
+ startupJitterMs: 30_000,
83
+ });
84
+ expect(unref).toHaveBeenCalled();
85
+ await controller.stop();
86
+ });
87
+ it('reconciles before claims on every pass', async () => {
88
+ const operations = [];
89
+ const store = fakeStore({
90
+ reconcile: vi.fn(async () => {
91
+ operations.push('reconcile');
92
+ }),
93
+ claim: vi.fn(async () => {
94
+ operations.push('claim');
95
+ return null;
96
+ }),
97
+ });
98
+ const controller = startSchedulerWithDeps({
99
+ store,
100
+ tasks: [task('a')],
101
+ owner: 'test',
102
+ logger: silentLogger,
103
+ startupJitterMs: 0,
104
+ tickIntervalMs: 1_000,
105
+ });
106
+ await vi.advanceTimersByTimeAsync(0);
107
+ await vi.advanceTimersByTimeAsync(1_000);
108
+ expect(operations).toEqual(['reconcile', 'claim', 'reconcile', 'claim']);
109
+ await controller.stop();
110
+ });
111
+ it('does not overlap local ticks', async () => {
112
+ let releaseHandler;
113
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a')) });
114
+ const recurringTask = task('a', () => new Promise((resolve) => {
115
+ releaseHandler = resolve;
116
+ }));
117
+ const controller = startSchedulerWithDeps({
118
+ store,
119
+ tasks: [recurringTask],
120
+ owner: 'test',
121
+ logger: silentLogger,
122
+ startupJitterMs: 0,
123
+ tickIntervalMs: 1_000,
124
+ });
125
+ await vi.advanceTimersByTimeAsync(0);
126
+ await vi.advanceTimersByTimeAsync(5_000);
127
+ expect(store.reconcile).toHaveBeenCalledTimes(1);
128
+ expect(store.claim).toHaveBeenCalledTimes(1);
129
+ releaseHandler?.();
130
+ await vi.advanceTimersByTimeAsync(0);
131
+ await controller.stop();
132
+ });
133
+ it('stop prevents another tick and is idempotent', async () => {
134
+ const store = fakeStore();
135
+ const controller = startSchedulerWithDeps({
136
+ store,
137
+ tasks: [task('a')],
138
+ owner: 'test',
139
+ logger: silentLogger,
140
+ startupJitterMs: 0,
141
+ tickIntervalMs: 1_000,
142
+ });
143
+ await vi.advanceTimersByTimeAsync(0);
144
+ expect(store.claim).toHaveBeenCalledTimes(1);
145
+ await controller.stop();
146
+ await expect(controller.stop()).resolves.toBeUndefined();
147
+ await vi.advanceTimersByTimeAsync(5_000);
148
+ expect(store.claim).toHaveBeenCalledTimes(1);
149
+ });
150
+ it('stop aborts an in-flight handler without recording failure', async () => {
151
+ let handlerSignal;
152
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a')) });
153
+ const recurringTask = defineRecurringTask({
154
+ name: 'a',
155
+ intervalMs: 60_000,
156
+ leaseMs: 60_000,
157
+ run: async (context) => {
158
+ handlerSignal = context.signal;
159
+ await new Promise((resolve) => {
160
+ context.signal.addEventListener('abort', () => resolve(), { once: true });
161
+ });
162
+ },
163
+ });
164
+ const controller = startSchedulerWithDeps({
165
+ store,
166
+ tasks: [recurringTask],
167
+ owner: 'test',
168
+ logger: silentLogger,
169
+ startupJitterMs: 0,
170
+ });
171
+ await vi.advanceTimersByTimeAsync(0);
172
+ expect(handlerSignal?.aborted).toBe(false);
173
+ await controller.stop();
174
+ expect(handlerSignal?.aborted).toBe(true);
175
+ expect(store.complete).not.toHaveBeenCalled();
176
+ expect(store.fail).not.toHaveBeenCalled();
177
+ });
178
+ it('stop resolves after its grace period when a handler ignores abort', async () => {
179
+ const store = fakeStore({ claim: vi.fn(async () => claimed('a')) });
180
+ const controller = startSchedulerWithDeps({
181
+ store,
182
+ tasks: [task('a', () => new Promise(() => { }))],
183
+ owner: 'test',
184
+ logger: silentLogger,
185
+ startupJitterMs: 0,
186
+ shutdownGraceMs: 500,
187
+ });
188
+ await vi.advanceTimersByTimeAsync(0);
189
+ let stopped = false;
190
+ const stopping = controller.stop().then(() => {
191
+ stopped = true;
192
+ });
193
+ await vi.advanceTimersByTimeAsync(499);
194
+ expect(stopped).toBe(false);
195
+ await vi.advanceTimersByTimeAsync(1);
196
+ await stopping;
197
+ expect(stopped).toBe(true);
198
+ expect(store.complete).not.toHaveBeenCalled();
199
+ expect(store.fail).not.toHaveBeenCalled();
200
+ });
201
+ it('logs a rejected pass and retries on the next tick', async () => {
202
+ const error = new Error('database unavailable');
203
+ const logger = { ...silentLogger, error: vi.fn() };
204
+ const store = fakeStore({
205
+ reconcile: vi
206
+ .fn()
207
+ .mockRejectedValueOnce(error)
208
+ .mockResolvedValue(undefined),
209
+ });
210
+ const controller = startSchedulerWithDeps({
211
+ store,
212
+ tasks: [task('a')],
213
+ owner: 'test',
214
+ logger,
215
+ startupJitterMs: 0,
216
+ tickIntervalMs: 1_000,
217
+ });
218
+ await vi.advanceTimersByTimeAsync(0);
219
+ expect(store.reconcile).toHaveBeenCalledTimes(1);
220
+ expect(store.claim).not.toHaveBeenCalled();
221
+ await vi.advanceTimersByTimeAsync(1_000);
222
+ expect(store.reconcile).toHaveBeenCalledTimes(2);
223
+ expect(store.claim).toHaveBeenCalledTimes(1);
224
+ expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: 'scheduler.tick-error', err: error }), expect.any(String));
225
+ await controller.stop();
226
+ });
227
+ it('uses core.recurringTasks and fails fast without a scheduler store', async () => {
228
+ const coreRun = vi.fn(async () => { });
229
+ const injectedRun = vi.fn(async () => { });
230
+ const store = fakeStore({ claim: vi.fn(async ({ name }) => claimed(name)) });
231
+ const core = {
232
+ db: { scheduler: store },
233
+ recurringTasks: [task('core-task', coreRun)],
234
+ logger: silentLogger,
235
+ };
236
+ const controller = startBylineScheduler(core, {
237
+ startupJitterMs: 0,
238
+ owner: 'test',
239
+ tasks: [task('injected-task', injectedRun)],
240
+ });
241
+ await vi.advanceTimersByTimeAsync(0);
242
+ expect(coreRun).toHaveBeenCalledTimes(1);
243
+ expect(injectedRun).not.toHaveBeenCalled();
244
+ await controller.stop();
245
+ expect(() => startBylineScheduler({ ...core, db: {} }, {
246
+ startupJitterMs: 0,
247
+ })).toThrow(/scheduler capability/i);
248
+ });
249
+ });