@j13b/scheduler 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/alterations.d.ts +6 -0
- package/dist/alterations.d.ts.map +1 -0
- package/dist/alterations.js +61 -0
- package/dist/alterations.js.map +1 -0
- package/dist/cjs/alterations.d.ts +5 -0
- package/dist/cjs/alterations.js +96 -0
- package/dist/cjs/dispatcher.d.ts +38 -0
- package/dist/cjs/dispatcher.js +165 -0
- package/dist/cjs/dispatcher_store.d.ts +9 -0
- package/dist/cjs/dispatcher_store.js +15 -0
- package/dist/cjs/generators/anchored.d.ts +3 -0
- package/dist/cjs/generators/anchored.js +57 -0
- package/dist/cjs/generators/days_of_month.d.ts +3 -0
- package/dist/cjs/generators/days_of_month.js +76 -0
- package/dist/cjs/generators/days_of_week.d.ts +5 -0
- package/dist/cjs/generators/days_of_week.js +65 -0
- package/dist/cjs/generators/days_of_year.d.ts +3 -0
- package/dist/cjs/generators/days_of_year.js +70 -0
- package/dist/cjs/generators/instant.d.ts +3 -0
- package/dist/cjs/generators/instant.js +15 -0
- package/dist/cjs/generators/local_date_time.d.ts +3 -0
- package/dist/cjs/generators/local_date_time.js +31 -0
- package/dist/cjs/generators/months_of_year.d.ts +3 -0
- package/dist/cjs/generators/months_of_year.js +109 -0
- package/dist/cjs/generators/types.d.ts +9 -0
- package/dist/cjs/generators/types.js +19 -0
- package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
- package/dist/cjs/generators/weeks_of_month.js +99 -0
- package/dist/cjs/generators/yearly.d.ts +3 -0
- package/dist/cjs/generators/yearly.js +52 -0
- package/dist/cjs/in_memory_port.d.ts +18 -0
- package/dist/cjs/in_memory_port.js +59 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.js +14 -0
- package/dist/cjs/occurrence_key.d.ts +5 -0
- package/dist/cjs/occurrence_key.js +21 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/port.d.ts +15 -0
- package/dist/cjs/port.js +2 -0
- package/dist/cjs/scheduler.d.ts +24 -0
- package/dist/cjs/scheduler.js +460 -0
- package/dist/cjs/types.d.ts +149 -0
- package/dist/cjs/types.js +58 -0
- package/dist/cjs/tz.d.ts +22 -0
- package/dist/cjs/tz.js +197 -0
- package/dist/dispatcher.d.ts +39 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +111 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/dispatcher_store.d.ts +10 -0
- package/dist/dispatcher_store.d.ts.map +1 -0
- package/dist/dispatcher_store.js +13 -0
- package/dist/dispatcher_store.js.map +1 -0
- package/dist/generators/anchored.d.ts +4 -0
- package/dist/generators/anchored.d.ts.map +1 -0
- package/dist/generators/anchored.js +42 -0
- package/dist/generators/anchored.js.map +1 -0
- package/dist/generators/days_of_month.d.ts +4 -0
- package/dist/generators/days_of_month.d.ts.map +1 -0
- package/dist/generators/days_of_month.js +53 -0
- package/dist/generators/days_of_month.js.map +1 -0
- package/dist/generators/days_of_week.d.ts +6 -0
- package/dist/generators/days_of_week.d.ts.map +1 -0
- package/dist/generators/days_of_week.js +43 -0
- package/dist/generators/days_of_week.js.map +1 -0
- package/dist/generators/days_of_year.d.ts +4 -0
- package/dist/generators/days_of_year.d.ts.map +1 -0
- package/dist/generators/days_of_year.js +50 -0
- package/dist/generators/days_of_year.js.map +1 -0
- package/dist/generators/instant.d.ts +4 -0
- package/dist/generators/instant.d.ts.map +1 -0
- package/dist/generators/instant.js +15 -0
- package/dist/generators/instant.js.map +1 -0
- package/dist/generators/local_date_time.d.ts +4 -0
- package/dist/generators/local_date_time.d.ts.map +1 -0
- package/dist/generators/local_date_time.js +20 -0
- package/dist/generators/local_date_time.js.map +1 -0
- package/dist/generators/months_of_year.d.ts +4 -0
- package/dist/generators/months_of_year.d.ts.map +1 -0
- package/dist/generators/months_of_year.js +64 -0
- package/dist/generators/months_of_year.js.map +1 -0
- package/dist/generators/types.d.ts +10 -0
- package/dist/generators/types.d.ts.map +1 -0
- package/dist/generators/types.js +14 -0
- package/dist/generators/types.js.map +1 -0
- package/dist/generators/weeks_of_month.d.ts +4 -0
- package/dist/generators/weeks_of_month.d.ts.map +1 -0
- package/dist/generators/weeks_of_month.js +61 -0
- package/dist/generators/weeks_of_month.js.map +1 -0
- package/dist/generators/yearly.d.ts +4 -0
- package/dist/generators/yearly.d.ts.map +1 -0
- package/dist/generators/yearly.js +37 -0
- package/dist/generators/yearly.js.map +1 -0
- package/dist/in_memory_port.d.ts +19 -0
- package/dist/in_memory_port.d.ts.map +1 -0
- package/dist/in_memory_port.js +45 -0
- package/dist/in_memory_port.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/occurrence_key.d.ts +6 -0
- package/dist/occurrence_key.d.ts.map +1 -0
- package/dist/occurrence_key.js +25 -0
- package/dist/occurrence_key.js.map +1 -0
- package/dist/port.d.ts +16 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/scheduler.d.ts +25 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +332 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/dist/tz.d.ts +23 -0
- package/dist/tz.d.ts.map +1 -0
- package/dist/tz.js +131 -0
- package/dist/tz.js.map +1 -0
- package/package.json +70 -4
- package/src/__tests__/alterations.test.ts +177 -0
- package/src/__tests__/anchored.test.ts +107 -0
- package/src/__tests__/days_of_month.test.ts +162 -0
- package/src/__tests__/days_of_week.test.ts +134 -0
- package/src/__tests__/days_of_year.test.ts +158 -0
- package/src/__tests__/dispatcher.test.ts +317 -0
- package/src/__tests__/instant.test.ts +50 -0
- package/src/__tests__/local_date_time.test.ts +139 -0
- package/src/__tests__/months_of_year.test.ts +229 -0
- package/src/__tests__/scheduler.test.ts +888 -0
- package/src/__tests__/tz.test.ts +201 -0
- package/src/__tests__/weeks_of_month.test.ts +195 -0
- package/src/__tests__/yearly.test.ts +108 -0
- package/src/alterations.ts +108 -0
- package/src/dispatcher.ts +184 -0
- package/src/dispatcher_store.ts +26 -0
- package/src/generators/anchored.ts +69 -0
- package/src/generators/days_of_month.ts +100 -0
- package/src/generators/days_of_week.ts +101 -0
- package/src/generators/days_of_year.ts +88 -0
- package/src/generators/instant.ts +18 -0
- package/src/generators/local_date_time.ts +38 -0
- package/src/generators/months_of_year.ts +143 -0
- package/src/generators/types.ts +26 -0
- package/src/generators/weeks_of_month.ts +133 -0
- package/src/generators/yearly.ts +73 -0
- package/src/in_memory_port.ts +70 -0
- package/src/index.ts +24 -0
- package/src/occurrence_key.ts +42 -0
- package/src/port.ts +31 -0
- package/src/scheduler.ts +499 -0
- package/src/types.ts +369 -0
- package/src/tz.ts +246 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { InMemorySchedulerPort } from "../in_memory_port.js";
|
|
3
|
+
import { Scheduler } from "../scheduler.js";
|
|
4
|
+
import { Day, type ScheduleTemplate } from "../types.js";
|
|
5
|
+
|
|
6
|
+
interface CronPayload {
|
|
7
|
+
type: "cron";
|
|
8
|
+
command: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const HOUR = 3_600_000;
|
|
12
|
+
|
|
13
|
+
function makeScheduler() {
|
|
14
|
+
const port = new InMemorySchedulerPort<CronPayload>();
|
|
15
|
+
const scheduler = new Scheduler(port);
|
|
16
|
+
return { port, scheduler };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("Scheduler — list across mixed rule kinds", () => {
|
|
20
|
+
test("emits events from once + daysOfWeek + yearly templates in one window", async () => {
|
|
21
|
+
const { scheduler } = makeScheduler();
|
|
22
|
+
await scheduler.add({
|
|
23
|
+
id: "once-1",
|
|
24
|
+
timezone: "UTC",
|
|
25
|
+
rule: {
|
|
26
|
+
kind: "instant",
|
|
27
|
+
startOn: Date.UTC(2026, 4, 11, 14),
|
|
28
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
29
|
+
},
|
|
30
|
+
payload: { type: "cron", command: "ping" },
|
|
31
|
+
});
|
|
32
|
+
await scheduler.add({
|
|
33
|
+
id: "weekly-1",
|
|
34
|
+
timezone: "UTC",
|
|
35
|
+
rule: {
|
|
36
|
+
kind: "daysOfWeek",
|
|
37
|
+
days: [Day.Monday, Day.Wednesday, Day.Friday],
|
|
38
|
+
weekInterval: 1,
|
|
39
|
+
timeOfDay: 9 * HOUR,
|
|
40
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
41
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
42
|
+
seriesEnd: null,
|
|
43
|
+
},
|
|
44
|
+
payload: { type: "cron", command: "rsync" },
|
|
45
|
+
});
|
|
46
|
+
await scheduler.add({
|
|
47
|
+
id: "yearly-1",
|
|
48
|
+
timezone: "UTC",
|
|
49
|
+
rule: {
|
|
50
|
+
kind: "yearly",
|
|
51
|
+
month: 4, // May
|
|
52
|
+
day: 11,
|
|
53
|
+
yearInterval: 1,
|
|
54
|
+
timeOfDay: 0,
|
|
55
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
56
|
+
seriesStart: Date.UTC(2026, 0, 1),
|
|
57
|
+
seriesEnd: null,
|
|
58
|
+
},
|
|
59
|
+
payload: { type: "cron", command: "yearly-task" },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const events = await scheduler.list(Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 12));
|
|
63
|
+
// Expect: yearly @ 00:00, weekly Mon @ 09:00, once @ 14:00
|
|
64
|
+
expect(
|
|
65
|
+
events.map((e) => `${e.payload.command}@${new Date(e.startTimestamp).toISOString()}`)
|
|
66
|
+
).toEqual([
|
|
67
|
+
"yearly-task@2026-05-11T00:00:00.000Z",
|
|
68
|
+
"rsync@2026-05-11T09:00:00.000Z",
|
|
69
|
+
"ping@2026-05-11T14:00:00.000Z",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("Scheduler — alterations end-to-end", () => {
|
|
75
|
+
test("cancel and reschedule on a daysOfWeek template", async () => {
|
|
76
|
+
const { scheduler } = makeScheduler();
|
|
77
|
+
const tpl: ScheduleTemplate<CronPayload> = {
|
|
78
|
+
id: "weekly-1",
|
|
79
|
+
timezone: "UTC",
|
|
80
|
+
rule: {
|
|
81
|
+
kind: "daysOfWeek",
|
|
82
|
+
days: [Day.Monday, Day.Wednesday, Day.Friday],
|
|
83
|
+
weekInterval: 1,
|
|
84
|
+
timeOfDay: 9 * HOUR,
|
|
85
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
86
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
87
|
+
seriesEnd: null,
|
|
88
|
+
},
|
|
89
|
+
payload: { type: "cron", command: "rsync" },
|
|
90
|
+
};
|
|
91
|
+
await scheduler.add(tpl);
|
|
92
|
+
|
|
93
|
+
// Cancel Wednesday, reschedule Friday to 14:00 with override command.
|
|
94
|
+
await scheduler.cancel("weekly-1", "2026-05-06");
|
|
95
|
+
await scheduler.reschedule(
|
|
96
|
+
"weekly-1",
|
|
97
|
+
"2026-05-08",
|
|
98
|
+
Date.UTC(2026, 4, 8, 14),
|
|
99
|
+
{ type: "duration", ms: 2 * HOUR },
|
|
100
|
+
{ command: "rsync --verbose" }
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const events = await scheduler.list(Date.UTC(2026, 4, 4), Date.UTC(2026, 4, 11));
|
|
104
|
+
expect(events.map((e) => new Date(e.startTimestamp).toISOString())).toEqual([
|
|
105
|
+
"2026-05-04T09:00:00.000Z", // Mon (untouched)
|
|
106
|
+
"2026-05-08T14:00:00.000Z", // Fri (rescheduled)
|
|
107
|
+
]);
|
|
108
|
+
const fri = events[1];
|
|
109
|
+
expect(fri.payload.command).toBe("rsync --verbose");
|
|
110
|
+
expect(fri.endTimestamp - fri.startTimestamp).toBe(2 * HOUR);
|
|
111
|
+
expect(fri.alterationId).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("re-rescheduling the same occurrence replaces the prior alteration", async () => {
|
|
115
|
+
const { scheduler, port } = makeScheduler();
|
|
116
|
+
await scheduler.add({
|
|
117
|
+
id: "tpl-1",
|
|
118
|
+
timezone: "UTC",
|
|
119
|
+
rule: {
|
|
120
|
+
kind: "daysOfWeek",
|
|
121
|
+
days: [Day.Monday],
|
|
122
|
+
weekInterval: 1,
|
|
123
|
+
timeOfDay: 9 * HOUR,
|
|
124
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
125
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
126
|
+
seriesEnd: null,
|
|
127
|
+
},
|
|
128
|
+
payload: { type: "cron", command: "x" },
|
|
129
|
+
});
|
|
130
|
+
await scheduler.reschedule("tpl-1", "2026-05-04", Date.UTC(2026, 4, 4, 12));
|
|
131
|
+
await scheduler.reschedule("tpl-1", "2026-05-04", Date.UTC(2026, 4, 4, 16));
|
|
132
|
+
|
|
133
|
+
const all = await port.listAlterationsByTemplate("tpl-1");
|
|
134
|
+
expect(all).toHaveLength(1);
|
|
135
|
+
expect(all[0].startTimestamp).toBe(Date.UTC(2026, 4, 4, 16));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("Scheduler — update wipes alterations on rule change", () => {
|
|
140
|
+
test("changing the rule clears existing alterations", async () => {
|
|
141
|
+
const { scheduler, port } = makeScheduler();
|
|
142
|
+
await scheduler.add({
|
|
143
|
+
id: "tpl-1",
|
|
144
|
+
timezone: "UTC",
|
|
145
|
+
rule: {
|
|
146
|
+
kind: "daysOfWeek",
|
|
147
|
+
days: [Day.Monday],
|
|
148
|
+
weekInterval: 1,
|
|
149
|
+
timeOfDay: 9 * HOUR,
|
|
150
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
151
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
152
|
+
seriesEnd: null,
|
|
153
|
+
},
|
|
154
|
+
payload: { type: "cron", command: "x" },
|
|
155
|
+
});
|
|
156
|
+
await scheduler.cancel("tpl-1", "2026-05-04");
|
|
157
|
+
expect(await port.listAlterationsByTemplate("tpl-1")).toHaveLength(1);
|
|
158
|
+
|
|
159
|
+
await scheduler.update({
|
|
160
|
+
id: "tpl-1",
|
|
161
|
+
rule: {
|
|
162
|
+
kind: "daysOfWeek",
|
|
163
|
+
days: [Day.Tuesday], // changed
|
|
164
|
+
weekInterval: 1,
|
|
165
|
+
timeOfDay: 9 * HOUR,
|
|
166
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
167
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
168
|
+
seriesEnd: null,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(await port.listAlterationsByTemplate("tpl-1")).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("payload-only update keeps alterations", async () => {
|
|
175
|
+
const { scheduler, port } = makeScheduler();
|
|
176
|
+
await scheduler.add({
|
|
177
|
+
id: "tpl-1",
|
|
178
|
+
timezone: "UTC",
|
|
179
|
+
rule: {
|
|
180
|
+
kind: "daysOfWeek",
|
|
181
|
+
days: [Day.Monday],
|
|
182
|
+
weekInterval: 1,
|
|
183
|
+
timeOfDay: 9 * HOUR,
|
|
184
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
185
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
186
|
+
seriesEnd: null,
|
|
187
|
+
},
|
|
188
|
+
payload: { type: "cron", command: "old" },
|
|
189
|
+
});
|
|
190
|
+
await scheduler.cancel("tpl-1", "2026-05-04");
|
|
191
|
+
await scheduler.update({
|
|
192
|
+
id: "tpl-1",
|
|
193
|
+
payload: { type: "cron", command: "new" },
|
|
194
|
+
});
|
|
195
|
+
expect(await port.listAlterationsByTemplate("tpl-1")).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("days array order doesn't trigger wipe", async () => {
|
|
199
|
+
const { scheduler, port } = makeScheduler();
|
|
200
|
+
await scheduler.add({
|
|
201
|
+
id: "tpl-1",
|
|
202
|
+
timezone: "UTC",
|
|
203
|
+
rule: {
|
|
204
|
+
kind: "daysOfWeek",
|
|
205
|
+
days: [Day.Monday, Day.Wednesday],
|
|
206
|
+
weekInterval: 1,
|
|
207
|
+
timeOfDay: 9 * HOUR,
|
|
208
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
209
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
210
|
+
seriesEnd: null,
|
|
211
|
+
},
|
|
212
|
+
payload: { type: "cron", command: "x" },
|
|
213
|
+
});
|
|
214
|
+
await scheduler.cancel("tpl-1", "2026-05-04");
|
|
215
|
+
await scheduler.update({
|
|
216
|
+
id: "tpl-1",
|
|
217
|
+
rule: {
|
|
218
|
+
kind: "daysOfWeek",
|
|
219
|
+
days: [Day.Wednesday, Day.Monday], // same set, different order
|
|
220
|
+
weekInterval: 1,
|
|
221
|
+
timeOfDay: 9 * HOUR,
|
|
222
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
223
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
224
|
+
seriesEnd: null,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
expect(await port.listAlterationsByTemplate("tpl-1")).toHaveLength(1);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("Scheduler — next() and nextDue()", () => {
|
|
232
|
+
test("next() finds the upcoming occurrence within horizon", async () => {
|
|
233
|
+
const { scheduler } = makeScheduler();
|
|
234
|
+
await scheduler.add({
|
|
235
|
+
id: "weekly",
|
|
236
|
+
timezone: "UTC",
|
|
237
|
+
rule: {
|
|
238
|
+
kind: "daysOfWeek",
|
|
239
|
+
days: [Day.Monday],
|
|
240
|
+
weekInterval: 1,
|
|
241
|
+
timeOfDay: 9 * HOUR,
|
|
242
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
243
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
244
|
+
seriesEnd: null,
|
|
245
|
+
},
|
|
246
|
+
payload: { type: "cron", command: "x" },
|
|
247
|
+
});
|
|
248
|
+
const nxt = await scheduler.next("weekly", Date.UTC(2026, 4, 11, 12));
|
|
249
|
+
// Next Monday after 2026-05-11 12:00 (which is Monday) is May 18 09:00.
|
|
250
|
+
expect(nxt?.startTimestamp).toBe(Date.UTC(2026, 4, 18, 9));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("next() returns null when no occurrence within horizon", async () => {
|
|
254
|
+
const { scheduler } = makeScheduler();
|
|
255
|
+
await scheduler.add({
|
|
256
|
+
id: "once",
|
|
257
|
+
timezone: "UTC",
|
|
258
|
+
rule: {
|
|
259
|
+
kind: "instant",
|
|
260
|
+
startOn: Date.UTC(2026, 4, 11),
|
|
261
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
262
|
+
},
|
|
263
|
+
payload: { type: "cron", command: "x" },
|
|
264
|
+
});
|
|
265
|
+
// Looking AFTER the once event.
|
|
266
|
+
const nxt = await scheduler.next("once", Date.UTC(2026, 4, 12));
|
|
267
|
+
expect(nxt).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("nextDue() returns events from multiple templates sorted ascending", async () => {
|
|
271
|
+
const { scheduler } = makeScheduler();
|
|
272
|
+
await scheduler.add({
|
|
273
|
+
id: "weekly",
|
|
274
|
+
timezone: "UTC",
|
|
275
|
+
rule: {
|
|
276
|
+
kind: "daysOfWeek",
|
|
277
|
+
days: [Day.Monday],
|
|
278
|
+
weekInterval: 1,
|
|
279
|
+
timeOfDay: 9 * HOUR,
|
|
280
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
281
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
282
|
+
seriesEnd: null,
|
|
283
|
+
},
|
|
284
|
+
payload: { type: "cron", command: "weekly" },
|
|
285
|
+
});
|
|
286
|
+
await scheduler.add({
|
|
287
|
+
id: "once",
|
|
288
|
+
timezone: "UTC",
|
|
289
|
+
rule: {
|
|
290
|
+
kind: "instant",
|
|
291
|
+
startOn: Date.UTC(2026, 4, 13, 12),
|
|
292
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
293
|
+
},
|
|
294
|
+
payload: { type: "cron", command: "once" },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const events = await scheduler.nextDue(Date.UTC(2026, 4, 11), 3);
|
|
298
|
+
// From May 11 (after midnight): May 11 09 (weekly), May 13 12 (once),
|
|
299
|
+
// May 18 09 (weekly). Note May 11 is Monday so 09:00 is included.
|
|
300
|
+
expect(events.map((e) => e.payload.command)).toEqual(["weekly", "once", "weekly"]);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("Scheduler — timeSpan normalization", () => {
|
|
305
|
+
test("omitted timeSpan defaults to { type: 'duration', ms: 1 } (cron-fire)", async () => {
|
|
306
|
+
const { scheduler, port } = makeScheduler();
|
|
307
|
+
await scheduler.add({
|
|
308
|
+
id: "fire",
|
|
309
|
+
timezone: "UTC",
|
|
310
|
+
rule: { kind: "instant", startOn: Date.UTC(2026, 4, 11, 14) }, // no timeSpan
|
|
311
|
+
payload: { type: "cron", command: "ping" },
|
|
312
|
+
});
|
|
313
|
+
// Stored template's rule should now have timeSpan: { type: "duration", ms: 1 }.
|
|
314
|
+
const stored = await port.getTemplate("fire");
|
|
315
|
+
expect(stored?.rule.kind === "instant" && stored.rule.timeSpan).toEqual({
|
|
316
|
+
type: "duration",
|
|
317
|
+
ms: 1,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// list() should emit it even with a tight 1ms window starting AT the event.
|
|
321
|
+
const events = await scheduler.list(Date.UTC(2026, 4, 11, 14), Date.UTC(2026, 4, 11, 14) + 1);
|
|
322
|
+
expect(events).toHaveLength(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("supplied timeSpan.ms < 1 throws", async () => {
|
|
326
|
+
const { scheduler } = makeScheduler();
|
|
327
|
+
await expect(
|
|
328
|
+
scheduler.add({
|
|
329
|
+
id: "bad",
|
|
330
|
+
timezone: "UTC",
|
|
331
|
+
rule: { kind: "instant", startOn: 0, timeSpan: { type: "duration", ms: 0 } },
|
|
332
|
+
payload: { type: "cron", command: "x" },
|
|
333
|
+
})
|
|
334
|
+
).rejects.toThrow(/timeSpan\.ms must be >= 1/);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("Scheduler — write-side validation", () => {
|
|
339
|
+
test("add throws on invalid rule", async () => {
|
|
340
|
+
const { scheduler } = makeScheduler();
|
|
341
|
+
await expect(
|
|
342
|
+
scheduler.add({
|
|
343
|
+
id: "bad",
|
|
344
|
+
timezone: "UTC",
|
|
345
|
+
rule: {
|
|
346
|
+
kind: "anchored",
|
|
347
|
+
anchorDate: 0,
|
|
348
|
+
intervalMs: 0, // invalid
|
|
349
|
+
seriesStart: 0,
|
|
350
|
+
seriesEnd: null,
|
|
351
|
+
},
|
|
352
|
+
payload: { type: "cron", command: "x" },
|
|
353
|
+
})
|
|
354
|
+
).rejects.toThrow(/intervalMs/);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("update throws on invalid rule", async () => {
|
|
358
|
+
const { scheduler } = makeScheduler();
|
|
359
|
+
await scheduler.add({
|
|
360
|
+
id: "tpl-1",
|
|
361
|
+
timezone: "UTC",
|
|
362
|
+
rule: {
|
|
363
|
+
kind: "anchored",
|
|
364
|
+
anchorDate: 0,
|
|
365
|
+
intervalMs: 1000,
|
|
366
|
+
seriesStart: 0,
|
|
367
|
+
seriesEnd: null,
|
|
368
|
+
},
|
|
369
|
+
payload: { type: "cron", command: "x" },
|
|
370
|
+
});
|
|
371
|
+
await expect(
|
|
372
|
+
scheduler.update({
|
|
373
|
+
id: "tpl-1",
|
|
374
|
+
rule: {
|
|
375
|
+
kind: "weeksOfMonth",
|
|
376
|
+
days: [Day.Monday],
|
|
377
|
+
weeks: [0], // invalid
|
|
378
|
+
monthInterval: 1,
|
|
379
|
+
timeOfDay: 0,
|
|
380
|
+
seriesStart: 0,
|
|
381
|
+
seriesEnd: null,
|
|
382
|
+
},
|
|
383
|
+
})
|
|
384
|
+
).rejects.toThrow(/week/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("add throws on unknown disambiguation", async () => {
|
|
388
|
+
const { scheduler } = makeScheduler();
|
|
389
|
+
await expect(
|
|
390
|
+
scheduler.add({
|
|
391
|
+
id: "bad-dis",
|
|
392
|
+
timezone: "America/Denver",
|
|
393
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing a runtime-invalid value
|
|
394
|
+
disambiguation: "nonsense" as any,
|
|
395
|
+
rule: { kind: "instant", startOn: 0 },
|
|
396
|
+
payload: { type: "cron", command: "x" },
|
|
397
|
+
})
|
|
398
|
+
).rejects.toThrow(/disambiguation/);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("add throws on unknown timezone", async () => {
|
|
402
|
+
const { scheduler } = makeScheduler();
|
|
403
|
+
await expect(
|
|
404
|
+
scheduler.add({
|
|
405
|
+
id: "bad-tz",
|
|
406
|
+
timezone: "America/Denvr", // typo
|
|
407
|
+
rule: { kind: "instant", startOn: 0 },
|
|
408
|
+
payload: { type: "cron", command: "x" },
|
|
409
|
+
})
|
|
410
|
+
).rejects.toThrow(/timezone/i);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("add throws on missing/empty timezone", async () => {
|
|
414
|
+
const { scheduler } = makeScheduler();
|
|
415
|
+
await expect(
|
|
416
|
+
scheduler.add({
|
|
417
|
+
id: "no-tz",
|
|
418
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing a runtime-invalid value
|
|
419
|
+
timezone: undefined as any,
|
|
420
|
+
rule: { kind: "instant", startOn: 0 },
|
|
421
|
+
payload: { type: "cron", command: "x" },
|
|
422
|
+
})
|
|
423
|
+
).rejects.toThrow(/timezone is required/);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("update throws on unknown timezone", async () => {
|
|
427
|
+
const { scheduler } = makeScheduler();
|
|
428
|
+
await scheduler.add({
|
|
429
|
+
id: "tz-1",
|
|
430
|
+
timezone: "UTC",
|
|
431
|
+
rule: { kind: "instant", startOn: 0 },
|
|
432
|
+
payload: { type: "cron", command: "x" },
|
|
433
|
+
});
|
|
434
|
+
await expect(scheduler.update({ id: "tz-1", timezone: "Not/AZone" })).rejects.toThrow(
|
|
435
|
+
/timezone/i
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe("Scheduler — disambiguation threads through to generators", () => {
|
|
441
|
+
// A daily 02:30 event across the 2026-03-08 Denver spring-forward gap, where
|
|
442
|
+
// 02:30 doesn't exist. The template-level disambiguation should drive how
|
|
443
|
+
// that day resolves. Window: just Mar 8 in UTC terms.
|
|
444
|
+
const dayWindowStart = Date.UTC(2026, 2, 8, 6); // 06:00Z (Mar 7 23:00 MST)
|
|
445
|
+
const dayWindowEnd = Date.UTC(2026, 2, 9, 6);
|
|
446
|
+
|
|
447
|
+
function dailyAt0230(): ScheduleTemplate<CronPayload> {
|
|
448
|
+
return {
|
|
449
|
+
id: "daily",
|
|
450
|
+
timezone: "America/Denver",
|
|
451
|
+
rule: {
|
|
452
|
+
kind: "daysOfWeek",
|
|
453
|
+
days: [
|
|
454
|
+
Day.Sunday,
|
|
455
|
+
Day.Monday,
|
|
456
|
+
Day.Tuesday,
|
|
457
|
+
Day.Wednesday,
|
|
458
|
+
Day.Thursday,
|
|
459
|
+
Day.Friday,
|
|
460
|
+
Day.Saturday,
|
|
461
|
+
],
|
|
462
|
+
weekInterval: 1,
|
|
463
|
+
timeOfDay: 2 * HOUR + 30 * 60_000,
|
|
464
|
+
seriesStart: Date.UTC(2026, 2, 1),
|
|
465
|
+
seriesEnd: null,
|
|
466
|
+
},
|
|
467
|
+
payload: { type: "cron", command: "x" },
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
test("default (compatible) moves the gap occurrence forward to 09:30Z", async () => {
|
|
472
|
+
const { scheduler } = makeScheduler();
|
|
473
|
+
await scheduler.add(dailyAt0230());
|
|
474
|
+
const events = await scheduler.list(dayWindowStart, dayWindowEnd);
|
|
475
|
+
expect(events).toHaveLength(1);
|
|
476
|
+
expect(new Date(events[0].startTimestamp).toISOString()).toBe("2026-03-08T09:30:00.000Z");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("skip drops the nonexistent gap occurrence", async () => {
|
|
480
|
+
const { scheduler } = makeScheduler();
|
|
481
|
+
await scheduler.add({ ...dailyAt0230(), disambiguation: "skip" });
|
|
482
|
+
const events = await scheduler.list(dayWindowStart, dayWindowEnd);
|
|
483
|
+
expect(events).toHaveLength(0);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("Scheduler — remove cleans up alterations", () => {
|
|
488
|
+
test("remove deletes the template and its alterations", async () => {
|
|
489
|
+
const { scheduler, port } = makeScheduler();
|
|
490
|
+
await scheduler.add({
|
|
491
|
+
id: "tpl-1",
|
|
492
|
+
timezone: "UTC",
|
|
493
|
+
rule: {
|
|
494
|
+
kind: "daysOfWeek",
|
|
495
|
+
days: [Day.Monday],
|
|
496
|
+
weekInterval: 1,
|
|
497
|
+
timeOfDay: 9 * HOUR,
|
|
498
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
499
|
+
seriesStart: Date.UTC(2026, 4, 4),
|
|
500
|
+
seriesEnd: null,
|
|
501
|
+
},
|
|
502
|
+
payload: { type: "cron", command: "x" },
|
|
503
|
+
});
|
|
504
|
+
await scheduler.cancel("tpl-1", "2026-05-04");
|
|
505
|
+
await scheduler.remove("tpl-1");
|
|
506
|
+
|
|
507
|
+
expect(await port.getTemplate("tpl-1")).toBeNull();
|
|
508
|
+
expect(await port.listAlterationsByTemplate("tpl-1")).toHaveLength(0);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe("Scheduler — list with empty window", () => {
|
|
513
|
+
test("returns empty when windowEnd <= windowStart", async () => {
|
|
514
|
+
const { scheduler } = makeScheduler();
|
|
515
|
+
await scheduler.add({
|
|
516
|
+
id: "once",
|
|
517
|
+
timezone: "UTC",
|
|
518
|
+
rule: {
|
|
519
|
+
kind: "instant",
|
|
520
|
+
startOn: Date.UTC(2026, 4, 11),
|
|
521
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
522
|
+
},
|
|
523
|
+
payload: { type: "cron", command: "x" },
|
|
524
|
+
});
|
|
525
|
+
const events = await scheduler.list(Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 11));
|
|
526
|
+
expect(events).toEqual([]);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("Scheduler — DST-aware recurrence", () => {
|
|
531
|
+
test("Mon 9am Denver weekly stays at 9am wall across spring forward", async () => {
|
|
532
|
+
const { scheduler } = makeScheduler();
|
|
533
|
+
await scheduler.add({
|
|
534
|
+
id: "denver-weekly",
|
|
535
|
+
timezone: "America/Denver",
|
|
536
|
+
rule: {
|
|
537
|
+
kind: "daysOfWeek",
|
|
538
|
+
days: [Day.Monday],
|
|
539
|
+
weekInterval: 1,
|
|
540
|
+
timeOfDay: 9 * HOUR,
|
|
541
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
542
|
+
seriesStart: Date.UTC(2025, 2, 1),
|
|
543
|
+
seriesEnd: null,
|
|
544
|
+
},
|
|
545
|
+
payload: { type: "cron", command: "morning" },
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// Window: Mar 1 .. Mar 24, 2025. Spring forward is Sun Mar 9.
|
|
549
|
+
// Mondays: Mar 3 (MST → 16 UTC), Mar 10 (MDT → 15 UTC), Mar 17 (MDT → 15 UTC).
|
|
550
|
+
const events = await scheduler.list(Date.UTC(2025, 2, 1), Date.UTC(2025, 2, 24));
|
|
551
|
+
expect(events.map((e) => new Date(e.startTimestamp).toISOString())).toEqual([
|
|
552
|
+
"2025-03-03T16:00:00.000Z",
|
|
553
|
+
"2025-03-10T15:00:00.000Z",
|
|
554
|
+
"2025-03-17T15:00:00.000Z",
|
|
555
|
+
]);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe("Scheduler — alterations across DST", () => {
|
|
560
|
+
// The hypothesis: occurrenceKey is the wall-clock date in the template's
|
|
561
|
+
// tz, so it stays stable across DST. A cancel/reschedule applied to a
|
|
562
|
+
// post-transition occurrence should match correctly even though the UTC
|
|
563
|
+
// instant of that occurrence differs from a hypothetical no-DST world.
|
|
564
|
+
|
|
565
|
+
test("cancel a Monday after spring forward suppresses only that occurrence", async () => {
|
|
566
|
+
const { scheduler } = makeScheduler();
|
|
567
|
+
await scheduler.add({
|
|
568
|
+
id: "denver-weekly",
|
|
569
|
+
timezone: "America/Denver",
|
|
570
|
+
rule: {
|
|
571
|
+
kind: "daysOfWeek",
|
|
572
|
+
days: [Day.Monday],
|
|
573
|
+
weekInterval: 1,
|
|
574
|
+
timeOfDay: 9 * HOUR,
|
|
575
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
576
|
+
seriesStart: Date.UTC(2025, 2, 1),
|
|
577
|
+
seriesEnd: null,
|
|
578
|
+
},
|
|
579
|
+
payload: { type: "cron", command: "morning" },
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// Cancel the post-spring-forward Monday. Key is the wall-clock date in
|
|
583
|
+
// Denver tz, which is "2025-03-10" regardless of DST.
|
|
584
|
+
await scheduler.cancel("denver-weekly", "2025-03-10");
|
|
585
|
+
|
|
586
|
+
const events = await scheduler.list(Date.UTC(2025, 2, 1), Date.UTC(2025, 2, 24));
|
|
587
|
+
expect(events.map((e) => new Date(e.startTimestamp).toISOString())).toEqual([
|
|
588
|
+
"2025-03-03T16:00:00.000Z", // pre-DST Mon kept
|
|
589
|
+
// Mar 10 cancelled
|
|
590
|
+
"2025-03-17T15:00:00.000Z", // post-DST Mon kept
|
|
591
|
+
]);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("reschedule a fall-back-week Monday lands on the new wall time correctly", async () => {
|
|
595
|
+
const { scheduler } = makeScheduler();
|
|
596
|
+
await scheduler.add({
|
|
597
|
+
id: "denver-weekly",
|
|
598
|
+
timezone: "America/Denver",
|
|
599
|
+
rule: {
|
|
600
|
+
kind: "daysOfWeek",
|
|
601
|
+
days: [Day.Monday],
|
|
602
|
+
weekInterval: 1,
|
|
603
|
+
timeOfDay: 9 * HOUR,
|
|
604
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
605
|
+
seriesStart: Date.UTC(2025, 9, 1), // Oct 1 2025
|
|
606
|
+
seriesEnd: null,
|
|
607
|
+
},
|
|
608
|
+
payload: { type: "cron", command: "morning" },
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// Fall back is Sun 2025-11-02. The Monday after fall-back is Nov 3,
|
|
612
|
+
// which is the first MST Monday (offset -7). Reschedule that Monday
|
|
613
|
+
// to 14:00 wall-clock. Wall time interpreted in Denver tz (MST) →
|
|
614
|
+
// 14:00 - (-7h) = 21:00 UTC.
|
|
615
|
+
const newStartUtc = Date.UTC(2025, 10, 3, 21); // 14:00 MST = 21:00 UTC
|
|
616
|
+
await scheduler.reschedule("denver-weekly", "2025-11-03", newStartUtc, {
|
|
617
|
+
type: "duration",
|
|
618
|
+
ms: HOUR,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const events = await scheduler.list(Date.UTC(2025, 9, 27), Date.UTC(2025, 10, 10));
|
|
622
|
+
// Mondays: Oct 27 (MDT → 15 UTC), Nov 3 (rescheduled → 21 UTC).
|
|
623
|
+
expect(events.map((e) => new Date(e.startTimestamp).toISOString())).toEqual([
|
|
624
|
+
"2025-10-27T15:00:00.000Z",
|
|
625
|
+
"2025-11-03T21:00:00.000Z", // rescheduled
|
|
626
|
+
]);
|
|
627
|
+
const moved = events[1];
|
|
628
|
+
expect(moved.alterationId).toBeDefined();
|
|
629
|
+
expect(moved.occurrenceKey).toBe("2025-11-03");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("cancel of an occurrence whose UTC start differs across DST still matches by wall-date", async () => {
|
|
633
|
+
// Same template recurring across spring-forward in Denver. We cancel
|
|
634
|
+
// by the wall-date key — the matching must NOT depend on the UTC
|
|
635
|
+
// timestamp (which shifts by 1 hour across the DST boundary).
|
|
636
|
+
const { scheduler, port } = makeScheduler();
|
|
637
|
+
await scheduler.add({
|
|
638
|
+
id: "denver-weekly",
|
|
639
|
+
timezone: "America/Denver",
|
|
640
|
+
rule: {
|
|
641
|
+
kind: "daysOfWeek",
|
|
642
|
+
days: [Day.Monday, Day.Friday],
|
|
643
|
+
weekInterval: 1,
|
|
644
|
+
timeOfDay: 9 * HOUR,
|
|
645
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
646
|
+
seriesStart: Date.UTC(2025, 2, 1),
|
|
647
|
+
seriesEnd: null,
|
|
648
|
+
},
|
|
649
|
+
payload: { type: "cron", command: "x" },
|
|
650
|
+
});
|
|
651
|
+
// Cancel Friday Mar 7 (MST, pre-DST) AND Friday Mar 14 (MDT, post-DST).
|
|
652
|
+
await scheduler.cancel("denver-weekly", "2025-03-07");
|
|
653
|
+
await scheduler.cancel("denver-weekly", "2025-03-14");
|
|
654
|
+
|
|
655
|
+
const events = await scheduler.list(Date.UTC(2025, 2, 1), Date.UTC(2025, 2, 17));
|
|
656
|
+
// Expected: Mon Mar 3 (16 UTC), Mon Mar 10 (15 UTC). Both Fridays are
|
|
657
|
+
// cancelled; the cancel keys are the wall-clock dates.
|
|
658
|
+
expect(events.map((e) => new Date(e.startTimestamp).toISOString())).toEqual([
|
|
659
|
+
"2025-03-03T16:00:00.000Z",
|
|
660
|
+
"2025-03-10T15:00:00.000Z",
|
|
661
|
+
]);
|
|
662
|
+
// Sanity: both alterations stored.
|
|
663
|
+
expect(await port.listAlterationsByTemplate("denver-weekly")).toHaveLength(2);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("nth weekday of month: cancel on the post-DST occurrence works", async () => {
|
|
667
|
+
// 2nd Monday of every month in Denver. Mar 2025 2nd Mon = Mar 10
|
|
668
|
+
// (post-DST). Cancel it.
|
|
669
|
+
const { scheduler } = makeScheduler();
|
|
670
|
+
await scheduler.add({
|
|
671
|
+
id: "monthly",
|
|
672
|
+
timezone: "America/Denver",
|
|
673
|
+
rule: {
|
|
674
|
+
kind: "weeksOfMonth",
|
|
675
|
+
days: [Day.Monday],
|
|
676
|
+
weeks: [2],
|
|
677
|
+
monthInterval: 1,
|
|
678
|
+
timeOfDay: 10 * HOUR,
|
|
679
|
+
timeSpan: { type: "duration", ms: HOUR },
|
|
680
|
+
seriesStart: Date.UTC(2025, 0, 1),
|
|
681
|
+
seriesEnd: null,
|
|
682
|
+
},
|
|
683
|
+
payload: { type: "cron", command: "monthly" },
|
|
684
|
+
});
|
|
685
|
+
await scheduler.cancel("monthly", "2025-03-10");
|
|
686
|
+
|
|
687
|
+
const events = await scheduler.list(Date.UTC(2025, 0, 1), Date.UTC(2025, 5, 1));
|
|
688
|
+
// 2nd Mondays: Jan 13 (MST → 17 UTC), Feb 10 (MST → 17 UTC),
|
|
689
|
+
// Mar 10 (MDT → 16 UTC, CANCELLED), Apr 14 (MDT → 16 UTC),
|
|
690
|
+
// May 12 (MDT → 16 UTC).
|
|
691
|
+
expect(events.map((e) => new Date(e.startTimestamp).toISOString())).toEqual([
|
|
692
|
+
"2025-01-13T17:00:00.000Z",
|
|
693
|
+
"2025-02-10T17:00:00.000Z",
|
|
694
|
+
"2025-04-14T16:00:00.000Z",
|
|
695
|
+
"2025-05-12T16:00:00.000Z",
|
|
696
|
+
]);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe("Scheduler — duration spans DST", () => {
|
|
701
|
+
// duration is UTC ms. A shift that crosses a DST transition still has
|
|
702
|
+
// exactly its specified duration in real-world elapsed time — only the
|
|
703
|
+
// wall-clock display is "wrong" by ±1 hour.
|
|
704
|
+
|
|
705
|
+
test("8h shift starting 23:00 the night before spring forward = 8h UTC", async () => {
|
|
706
|
+
const { scheduler } = makeScheduler();
|
|
707
|
+
await scheduler.add({
|
|
708
|
+
id: "night-shift",
|
|
709
|
+
timezone: "America/Denver",
|
|
710
|
+
rule: {
|
|
711
|
+
kind: "daysOfWeek",
|
|
712
|
+
// Saturday Mar 8 night → Sunday Mar 9 morning. The DST flip is at
|
|
713
|
+
// 02:00 MST on Sun Mar 9, mid-shift.
|
|
714
|
+
days: [Day.Saturday],
|
|
715
|
+
weekInterval: 1,
|
|
716
|
+
timeOfDay: 23 * HOUR, // 11 PM
|
|
717
|
+
timeSpan: { type: "duration", ms: 8 * HOUR },
|
|
718
|
+
seriesStart: Date.UTC(2025, 2, 1),
|
|
719
|
+
seriesEnd: null,
|
|
720
|
+
},
|
|
721
|
+
payload: { type: "cron", command: "shift" },
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const events = await scheduler.list(Date.UTC(2025, 2, 8), Date.UTC(2025, 2, 10));
|
|
725
|
+
// Sat Mar 8 23:00 MST = 06:00 UTC Mar 9.
|
|
726
|
+
// Mid-shift, DST flip at 09:00 UTC. Shift continues, ends 8h after start.
|
|
727
|
+
// 06:00 UTC + 8h = 14:00 UTC = 08:00 MDT Mar 9.
|
|
728
|
+
expect(events).toHaveLength(1);
|
|
729
|
+
const shift = events[0];
|
|
730
|
+
expect(new Date(shift.startTimestamp).toISOString()).toBe("2025-03-09T06:00:00.000Z");
|
|
731
|
+
expect(new Date(shift.endTimestamp).toISOString()).toBe("2025-03-09T14:00:00.000Z");
|
|
732
|
+
// Most important: actual elapsed UTC duration is exactly 8 hours.
|
|
733
|
+
expect(shift.endTimestamp - shift.startTimestamp).toBe(8 * HOUR);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("8h shift starting 23:00 the night before fall back = 8h UTC", async () => {
|
|
737
|
+
const { scheduler } = makeScheduler();
|
|
738
|
+
await scheduler.add({
|
|
739
|
+
id: "night-shift",
|
|
740
|
+
timezone: "America/Denver",
|
|
741
|
+
rule: {
|
|
742
|
+
kind: "daysOfWeek",
|
|
743
|
+
// Sat Nov 1 23:00 MDT → Sun Nov 2 morning. Fall back is 02:00 MDT
|
|
744
|
+
// (= 08:00 UTC), so clocks repeat 01:00–01:59. Shift sees a wall
|
|
745
|
+
// clock that goes 23:00 MDT → 00:00 → 01:00 MDT → 01:59 MDT →
|
|
746
|
+
// 01:00 MST → 01:59 MST → 02:00 MST → ... → 06:00 MST. That's 7
|
|
747
|
+
// wall-hours but 8 actual hours.
|
|
748
|
+
days: [Day.Saturday],
|
|
749
|
+
weekInterval: 1,
|
|
750
|
+
timeOfDay: 23 * HOUR,
|
|
751
|
+
timeSpan: { type: "duration", ms: 8 * HOUR },
|
|
752
|
+
seriesStart: Date.UTC(2025, 9, 1),
|
|
753
|
+
seriesEnd: null,
|
|
754
|
+
},
|
|
755
|
+
payload: { type: "cron", command: "shift" },
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
const events = await scheduler.list(Date.UTC(2025, 10, 1), Date.UTC(2025, 10, 3));
|
|
759
|
+
// Sat Nov 1 23:00 MDT = 05:00 UTC Nov 2 (offset -6).
|
|
760
|
+
// 05:00 UTC + 8h = 13:00 UTC = 06:00 MST Nov 2 (offset -7 post-fallback).
|
|
761
|
+
expect(events).toHaveLength(1);
|
|
762
|
+
const shift = events[0];
|
|
763
|
+
expect(new Date(shift.startTimestamp).toISOString()).toBe("2025-11-02T05:00:00.000Z");
|
|
764
|
+
expect(new Date(shift.endTimestamp).toISOString()).toBe("2025-11-02T13:00:00.000Z");
|
|
765
|
+
expect(shift.endTimestamp - shift.startTimestamp).toBe(8 * HOUR);
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
describe("Scheduler — hourSpan across DST", () => {
|
|
770
|
+
// hourSpan tracks wall-clock hours, not UTC ms. The real-elapsed UTC time
|
|
771
|
+
// of the same hourSpan changes by ±1 hour across DST transitions.
|
|
772
|
+
|
|
773
|
+
test("8h hourSpan starting 23:00 spring-forward eve = 7h UTC (one wall-hour eaten)", async () => {
|
|
774
|
+
const { scheduler } = makeScheduler();
|
|
775
|
+
await scheduler.add({
|
|
776
|
+
id: "night-shift",
|
|
777
|
+
timezone: "America/Denver",
|
|
778
|
+
rule: {
|
|
779
|
+
kind: "daysOfWeek",
|
|
780
|
+
days: [Day.Saturday],
|
|
781
|
+
weekInterval: 1,
|
|
782
|
+
timeOfDay: 23 * HOUR,
|
|
783
|
+
timeSpan: { type: "hourSpan", hours: 8 },
|
|
784
|
+
seriesStart: Date.UTC(2025, 2, 1),
|
|
785
|
+
seriesEnd: null,
|
|
786
|
+
},
|
|
787
|
+
payload: { type: "cron", command: "shift" },
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
const events = await scheduler.list(Date.UTC(2025, 2, 8), Date.UTC(2025, 2, 10));
|
|
791
|
+
expect(events).toHaveLength(1);
|
|
792
|
+
const shift = events[0];
|
|
793
|
+
// Start: Sat Mar 8 23:00 MST = 06:00 UTC Mar 9.
|
|
794
|
+
expect(new Date(shift.startTimestamp).toISOString()).toBe("2025-03-09T06:00:00.000Z");
|
|
795
|
+
// End: 23:00 + 8 WALL hours = 07:00 next wall day in Denver, which is
|
|
796
|
+
// 07:00 MDT (post-DST) = 13:00 UTC. Real elapsed = 7 actual hours.
|
|
797
|
+
expect(new Date(shift.endTimestamp).toISOString()).toBe("2025-03-09T13:00:00.000Z");
|
|
798
|
+
expect(shift.endTimestamp - shift.startTimestamp).toBe(7 * HOUR);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("8h hourSpan starting 23:00 fall-back eve = 9h UTC (extra wall-hour)", async () => {
|
|
802
|
+
const { scheduler } = makeScheduler();
|
|
803
|
+
await scheduler.add({
|
|
804
|
+
id: "night-shift",
|
|
805
|
+
timezone: "America/Denver",
|
|
806
|
+
rule: {
|
|
807
|
+
kind: "daysOfWeek",
|
|
808
|
+
days: [Day.Saturday],
|
|
809
|
+
weekInterval: 1,
|
|
810
|
+
timeOfDay: 23 * HOUR,
|
|
811
|
+
timeSpan: { type: "hourSpan", hours: 8 },
|
|
812
|
+
seriesStart: Date.UTC(2025, 9, 1),
|
|
813
|
+
seriesEnd: null,
|
|
814
|
+
},
|
|
815
|
+
payload: { type: "cron", command: "shift" },
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const events = await scheduler.list(Date.UTC(2025, 10, 1), Date.UTC(2025, 10, 3));
|
|
819
|
+
expect(events).toHaveLength(1);
|
|
820
|
+
const shift = events[0];
|
|
821
|
+
// Start: Sat Nov 1 23:00 MDT = 05:00 UTC Nov 2.
|
|
822
|
+
expect(new Date(shift.startTimestamp).toISOString()).toBe("2025-11-02T05:00:00.000Z");
|
|
823
|
+
// End: 23:00 + 8 WALL hours = 07:00 next wall day in Denver, which is
|
|
824
|
+
// 07:00 MST (post-fall-back) = 14:00 UTC. Real elapsed = 9 actual hours.
|
|
825
|
+
expect(new Date(shift.endTimestamp).toISOString()).toBe("2025-11-02T14:00:00.000Z");
|
|
826
|
+
expect(shift.endTimestamp - shift.startTimestamp).toBe(9 * HOUR);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("fractional hourSpan: 7.5 wall-hours", async () => {
|
|
830
|
+
const { scheduler } = makeScheduler();
|
|
831
|
+
await scheduler.add({
|
|
832
|
+
id: "shift",
|
|
833
|
+
timezone: "UTC",
|
|
834
|
+
rule: {
|
|
835
|
+
kind: "daysOfWeek",
|
|
836
|
+
days: [Day.Monday],
|
|
837
|
+
weekInterval: 1,
|
|
838
|
+
timeOfDay: 9 * HOUR,
|
|
839
|
+
timeSpan: { type: "hourSpan", hours: 7.5 },
|
|
840
|
+
seriesStart: Date.UTC(2026, 4, 1),
|
|
841
|
+
seriesEnd: null,
|
|
842
|
+
},
|
|
843
|
+
payload: { type: "cron", command: "x" },
|
|
844
|
+
});
|
|
845
|
+
const events = await scheduler.list(Date.UTC(2026, 4, 4), Date.UTC(2026, 4, 5));
|
|
846
|
+
// Mon May 4 2026, 9:00 UTC + 7.5h = 16:30 UTC. No DST in UTC.
|
|
847
|
+
expect(new Date(events[0].endTimestamp).toISOString()).toBe("2026-05-04T16:30:00.000Z");
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("hourSpan crosses wall midnight: 12h span starting at 18:00", async () => {
|
|
851
|
+
const { scheduler } = makeScheduler();
|
|
852
|
+
await scheduler.add({
|
|
853
|
+
id: "long",
|
|
854
|
+
timezone: "UTC",
|
|
855
|
+
rule: {
|
|
856
|
+
kind: "daysOfWeek",
|
|
857
|
+
days: [Day.Monday],
|
|
858
|
+
weekInterval: 1,
|
|
859
|
+
timeOfDay: 18 * HOUR,
|
|
860
|
+
timeSpan: { type: "hourSpan", hours: 12 },
|
|
861
|
+
seriesStart: Date.UTC(2026, 4, 1),
|
|
862
|
+
seriesEnd: null,
|
|
863
|
+
},
|
|
864
|
+
payload: { type: "cron", command: "x" },
|
|
865
|
+
});
|
|
866
|
+
const events = await scheduler.list(Date.UTC(2026, 4, 4), Date.UTC(2026, 4, 6));
|
|
867
|
+
// Mon May 4 18:00 UTC + 12h = Tue May 5 06:00 UTC
|
|
868
|
+
expect(new Date(events[0].startTimestamp).toISOString()).toBe("2026-05-04T18:00:00.000Z");
|
|
869
|
+
expect(new Date(events[0].endTimestamp).toISOString()).toBe("2026-05-05T06:00:00.000Z");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("hourSpan on Instant in UTC = hours * 3_600_000 (no DST to flex)", async () => {
|
|
873
|
+
const { scheduler } = makeScheduler();
|
|
874
|
+
await scheduler.add({
|
|
875
|
+
id: "once",
|
|
876
|
+
timezone: "UTC",
|
|
877
|
+
rule: {
|
|
878
|
+
kind: "instant",
|
|
879
|
+
startOn: Date.UTC(2026, 4, 11, 12),
|
|
880
|
+
timeSpan: { type: "hourSpan", hours: 2 },
|
|
881
|
+
},
|
|
882
|
+
payload: { type: "cron", command: "x" },
|
|
883
|
+
});
|
|
884
|
+
const events = await scheduler.list(Date.UTC(2026, 4, 11), Date.UTC(2026, 4, 12));
|
|
885
|
+
expect(events).toHaveLength(1);
|
|
886
|
+
expect(events[0].endTimestamp - events[0].startTimestamp).toBe(2 * HOUR);
|
|
887
|
+
});
|
|
888
|
+
});
|