@sentry/junior-scheduler 0.107.1 → 0.109.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/src/cadence.ts CHANGED
@@ -1,52 +1,9 @@
1
1
  import type {
2
- ScheduledCalendarFrequency,
3
2
  ScheduledLocalTime,
4
3
  ScheduledTask,
5
4
  ScheduledTaskRecurrence,
6
5
  } from "./types";
7
6
 
8
- /** Parse an ISO timestamp into a finite Unix timestamp in milliseconds. */
9
- export function parseScheduleTimestamp(value: string): number | undefined {
10
- const trimmed = value.trim();
11
- const match =
12
- /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/.exec(
13
- trimmed,
14
- );
15
- if (!match) {
16
- return undefined;
17
- }
18
-
19
- const year = Number(match[1]);
20
- const month = Number(match[2]);
21
- const day = Number(match[3]);
22
- const hour = Number(match[4]);
23
- const minute = Number(match[5]);
24
- const second = match[6] ? Number(match[6]) : 0;
25
- if (
26
- !Number.isInteger(year) ||
27
- !Number.isInteger(month) ||
28
- !Number.isInteger(day) ||
29
- !Number.isInteger(hour) ||
30
- !Number.isInteger(minute) ||
31
- !Number.isInteger(second) ||
32
- month < 1 ||
33
- month > 12 ||
34
- day < 1 ||
35
- day > daysInMonth(year, month) ||
36
- hour < 0 ||
37
- hour > 23 ||
38
- minute < 0 ||
39
- minute > 59 ||
40
- second < 0 ||
41
- second > 59
42
- ) {
43
- return undefined;
44
- }
45
-
46
- const parsed = Date.parse(trimmed);
47
- return Number.isFinite(parsed) ? parsed : undefined;
48
- }
49
-
50
7
  export interface ZonedDateTimeParts {
51
8
  day: number;
52
9
  hour: number;
@@ -93,6 +50,10 @@ function getLocalDateWeekday(date: LocalDate): number {
93
50
  return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay();
94
51
  }
95
52
 
53
+ function getWeekStart(date: LocalDate): LocalDate {
54
+ return addDays(date, -((getLocalDateWeekday(date) + 6) % 7));
55
+ }
56
+
96
57
  /** Resolve a UTC timestamp into calendar parts for a named time zone. */
97
58
  export function getZonedDateTimeParts(
98
59
  timestampMs: number,
@@ -136,7 +97,7 @@ function localDateTimeToTimestampMs(args: {
136
97
  date: LocalDate;
137
98
  time: ScheduledLocalTime;
138
99
  timezone: string;
139
- }): number {
100
+ }): number | undefined {
140
101
  const localAsUtcMs = Date.UTC(
141
102
  args.date.year,
142
103
  args.date.month - 1,
@@ -145,18 +106,222 @@ function localDateTimeToTimestampMs(args: {
145
106
  args.time.minute,
146
107
  0,
147
108
  );
148
- let timestampMs =
149
- localAsUtcMs - getTimeZoneOffsetMs(localAsUtcMs, args.timezone);
109
+ const offsets = new Set<number>();
110
+ for (const probeDeltaMs of [
111
+ -36 * 60 * 60 * 1000,
112
+ -12 * 60 * 60 * 1000,
113
+ 0,
114
+ 12 * 60 * 60 * 1000,
115
+ 36 * 60 * 60 * 1000,
116
+ ]) {
117
+ offsets.add(
118
+ getTimeZoneOffsetMs(localAsUtcMs + probeDeltaMs, args.timezone),
119
+ );
120
+ }
121
+
122
+ const matches = [...offsets]
123
+ .map((offsetMs) => localAsUtcMs - offsetMs)
124
+ .filter((timestampMs) => {
125
+ const parts = getZonedDateTimeParts(timestampMs, args.timezone);
126
+ return (
127
+ parts.year === args.date.year &&
128
+ parts.month === args.date.month &&
129
+ parts.day === args.date.day &&
130
+ parts.hour === args.time.hour &&
131
+ parts.minute === args.time.minute
132
+ );
133
+ });
134
+
135
+ if (matches.length === 0) {
136
+ return undefined;
137
+ }
138
+ return Math.min(...matches);
139
+ }
140
+
141
+ /** Resolve local time, rejecting DST gaps and choosing the earlier instant in a fold. */
142
+ export function resolveLocalScheduleAtMs(args: {
143
+ date: string;
144
+ time: ScheduledLocalTime;
145
+ timezone: string;
146
+ }): number | undefined {
147
+ const date = parseLocalDate(args.date);
148
+ if (!date) {
149
+ return undefined;
150
+ }
151
+ return localDateTimeToTimestampMs({
152
+ date,
153
+ time: args.time,
154
+ timezone: args.timezone,
155
+ });
156
+ }
157
+
158
+ function daysBetween(left: LocalDate, right: LocalDate): number {
159
+ return Math.floor(
160
+ (Date.UTC(right.year, right.month - 1, right.day) -
161
+ Date.UTC(left.year, left.month - 1, left.day)) /
162
+ (24 * 60 * 60 * 1000),
163
+ );
164
+ }
165
+
166
+ function monthsBetween(left: LocalDate, right: LocalDate): number {
167
+ return right.year * 12 + right.month - (left.year * 12 + left.month);
168
+ }
169
+
170
+ function weeklyRecurrenceMatchesDate(
171
+ date: LocalDate,
172
+ start: LocalDate,
173
+ recurrence: ScheduledTaskRecurrence,
174
+ ): boolean {
175
+ if (compareDate(date, start) < 0) {
176
+ return false;
177
+ }
178
+ return (
179
+ normalizeWeekdays(recurrence.weekdays).includes(
180
+ getLocalDateWeekday(date),
181
+ ) &&
182
+ Math.floor(daysBetween(getWeekStart(start), getWeekStart(date)) / 7) %
183
+ recurrence.interval ===
184
+ 0
185
+ );
186
+ }
150
187
 
151
- for (let index = 0; index < 3; index += 1) {
152
- const next = localAsUtcMs - getTimeZoneOffsetMs(timestampMs, args.timezone);
153
- if (next === timestampMs) {
154
- break;
188
+ function greatestCommonDivisor(left: number, right: number): number {
189
+ let a = Math.abs(left);
190
+ let b = Math.abs(right);
191
+ while (b !== 0) {
192
+ [a, b] = [b, a % b];
193
+ }
194
+ return a;
195
+ }
196
+
197
+ function findNextRunAtMs(args: {
198
+ afterMs: number;
199
+ recurrence: ScheduledTaskRecurrence;
200
+ searchFrom: LocalDate;
201
+ timezone: string;
202
+ }): number | undefined {
203
+ const start = parseLocalDate(args.recurrence.startDate);
204
+ const interval = args.recurrence.interval;
205
+ if (!start || !Number.isInteger(interval) || interval <= 0) {
206
+ return undefined;
207
+ }
208
+
209
+ const searchFrom =
210
+ compareDate(args.searchFrom, start) < 0 ? start : args.searchFrom;
211
+ if (args.recurrence.frequency === "daily") {
212
+ const offsetDays = daysBetween(start, searchFrom);
213
+ let candidateDate = addDays(
214
+ start,
215
+ Math.ceil(offsetDays / interval) * interval,
216
+ );
217
+ const gregorianCycleDays = 146_097;
218
+ const candidateCount =
219
+ gregorianCycleDays / greatestCommonDivisor(gregorianCycleDays, interval);
220
+ for (let attempts = 0; attempts < candidateCount; attempts += 1) {
221
+ const candidate = buildCandidate({
222
+ date: candidateDate,
223
+ recurrence: args.recurrence,
224
+ timezone: args.timezone,
225
+ });
226
+ if (candidate !== undefined && candidate > args.afterMs) {
227
+ return candidate;
228
+ }
229
+ candidateDate = addDays(candidateDate, interval);
230
+ }
231
+ return undefined;
232
+ }
233
+
234
+ if (args.recurrence.frequency === "weekly") {
235
+ if (normalizeWeekdays(args.recurrence.weekdays).length === 0) {
236
+ return undefined;
237
+ }
238
+ let candidateDate = searchFrom;
239
+ const gregorianCycleDays = 146_097;
240
+ const cadenceDays = interval * 7;
241
+ const searchDays =
242
+ (gregorianCycleDays * cadenceDays) /
243
+ greatestCommonDivisor(gregorianCycleDays, cadenceDays);
244
+ for (let attempts = 0; attempts < searchDays; attempts += 1) {
245
+ if (weeklyRecurrenceMatchesDate(candidateDate, start, args.recurrence)) {
246
+ const candidate = buildCandidate({
247
+ date: candidateDate,
248
+ recurrence: args.recurrence,
249
+ timezone: args.timezone,
250
+ });
251
+ if (candidate !== undefined && candidate > args.afterMs) {
252
+ return candidate;
253
+ }
254
+ }
255
+ candidateDate = addDays(candidateDate, 1);
256
+ }
257
+ return undefined;
258
+ }
259
+
260
+ if (args.recurrence.frequency === "monthly") {
261
+ const startMonth = start.year * 12 + start.month - 1;
262
+ const searchMonth = searchFrom.year * 12 + searchFrom.month - 1;
263
+ let candidateMonth =
264
+ startMonth + Math.ceil((searchMonth - startMonth) / interval) * interval;
265
+ const gregorianCycleMonths = 4_800;
266
+ const candidateCount =
267
+ gregorianCycleMonths /
268
+ greatestCommonDivisor(gregorianCycleMonths, interval);
269
+ for (let attempts = 0; attempts < candidateCount; attempts += 1) {
270
+ const candidateDate = {
271
+ year: Math.floor(candidateMonth / 12),
272
+ month: (candidateMonth % 12) + 1,
273
+ day: args.recurrence.dayOfMonth ?? 0,
274
+ };
275
+ if (compareDate(candidateDate, searchFrom) >= 0) {
276
+ const candidate = buildCandidate({
277
+ date: candidateDate,
278
+ recurrence: args.recurrence,
279
+ timezone: args.timezone,
280
+ });
281
+ if (candidate !== undefined && candidate > args.afterMs) {
282
+ return candidate;
283
+ }
284
+ }
285
+ candidateMonth += interval;
286
+ }
287
+ return undefined;
288
+ }
289
+
290
+ let candidateYear =
291
+ start.year +
292
+ Math.ceil((searchFrom.year - start.year) / interval) * interval;
293
+ const candidateCount = 400 / greatestCommonDivisor(400, interval);
294
+ for (let attempts = 0; attempts < candidateCount; attempts += 1) {
295
+ const candidateDate = {
296
+ year: candidateYear,
297
+ month: args.recurrence.month ?? 0,
298
+ day: args.recurrence.dayOfMonth ?? 0,
299
+ };
300
+ if (compareDate(candidateDate, searchFrom) >= 0) {
301
+ const candidate = buildCandidate({
302
+ date: candidateDate,
303
+ recurrence: args.recurrence,
304
+ timezone: args.timezone,
305
+ });
306
+ if (candidate !== undefined && candidate > args.afterMs) {
307
+ return candidate;
308
+ }
155
309
  }
156
- timestampMs = next;
310
+ candidateYear += interval;
157
311
  }
312
+ return undefined;
313
+ }
158
314
 
159
- return timestampMs;
315
+ /** Compute the first recurring calendar occurrence strictly after a timestamp. */
316
+ export function getFirstRunAtMs(args: {
317
+ afterMs: number;
318
+ recurrence: ScheduledTaskRecurrence;
319
+ timezone: string;
320
+ }): number | undefined {
321
+ return findNextRunAtMs({
322
+ ...args,
323
+ searchFrom: getLocalDate(args.afterMs, args.timezone),
324
+ });
160
325
  }
161
326
 
162
327
  function compareDate(left: LocalDate, right: LocalDate): number {
@@ -203,14 +368,6 @@ function parseLocalDate(value: string): LocalDate | undefined {
203
368
  return { year, month, day };
204
369
  }
205
370
 
206
- function formatLocalDate(date: LocalDate): string {
207
- return [
208
- String(date.year).padStart(4, "0"),
209
- String(date.month).padStart(2, "0"),
210
- String(date.day).padStart(2, "0"),
211
- ].join("-");
212
- }
213
-
214
371
  function getLocalDate(timestampMs: number, timezone: string): LocalDate {
215
372
  const parts = getZonedDateTimeParts(timestampMs, timezone);
216
373
  return { year: parts.year, month: parts.month, day: parts.day };
@@ -226,7 +383,7 @@ function buildCandidate(args: {
226
383
  date: LocalDate;
227
384
  recurrence: ScheduledTaskRecurrence;
228
385
  timezone: string;
229
- }): number {
386
+ }): number | undefined {
230
387
  return localDateTimeToTimestampMs({
231
388
  date: args.date,
232
389
  time: args.recurrence.time,
@@ -234,218 +391,6 @@ function buildCandidate(args: {
234
391
  });
235
392
  }
236
393
 
237
- function getDailyNextRunAtMs(args: {
238
- afterMs: number;
239
- recurrence: ScheduledTaskRecurrence;
240
- scheduledForMs: number;
241
- timezone: string;
242
- }): number | undefined {
243
- const start = parseLocalDate(args.recurrence.startDate);
244
- if (!start) {
245
- return undefined;
246
- }
247
-
248
- let candidateDate = addDays(
249
- getLocalDate(args.scheduledForMs, args.timezone),
250
- args.recurrence.interval,
251
- );
252
- if (compareDate(candidateDate, start) < 0) {
253
- candidateDate = start;
254
- }
255
-
256
- let candidate = buildCandidate({
257
- date: candidateDate,
258
- recurrence: args.recurrence,
259
- timezone: args.timezone,
260
- });
261
- while (candidate <= args.afterMs) {
262
- candidateDate = addDays(candidateDate, args.recurrence.interval);
263
- candidate = buildCandidate({
264
- date: candidateDate,
265
- recurrence: args.recurrence,
266
- timezone: args.timezone,
267
- });
268
- }
269
- return candidate;
270
- }
271
-
272
- function getWeeklyNextRunAtMs(args: {
273
- afterMs: number;
274
- recurrence: ScheduledTaskRecurrence;
275
- scheduledForMs: number;
276
- timezone: string;
277
- }): number | undefined {
278
- const start = parseLocalDate(args.recurrence.startDate);
279
- if (!start) {
280
- return undefined;
281
- }
282
-
283
- const weekdays = normalizeWeekdays(args.recurrence.weekdays);
284
- if (weekdays.length === 0) {
285
- return undefined;
286
- }
287
-
288
- let candidateDate = addDays(
289
- getLocalDate(args.scheduledForMs, args.timezone),
290
- 1,
291
- );
292
- for (let attempts = 0; attempts < 3660; attempts += 1) {
293
- const weeksSinceStart = Math.floor(
294
- (Date.UTC(
295
- candidateDate.year,
296
- candidateDate.month - 1,
297
- candidateDate.day,
298
- ) -
299
- Date.UTC(start.year, start.month - 1, start.day)) /
300
- (7 * 24 * 60 * 60 * 1000),
301
- );
302
- const isInCycle =
303
- weeksSinceStart >= 0 && weeksSinceStart % args.recurrence.interval === 0;
304
- if (isInCycle && weekdays.includes(getLocalDateWeekday(candidateDate))) {
305
- const candidate = buildCandidate({
306
- date: candidateDate,
307
- recurrence: args.recurrence,
308
- timezone: args.timezone,
309
- });
310
- if (candidate > args.afterMs) {
311
- return candidate;
312
- }
313
- }
314
- candidateDate = addDays(candidateDate, 1);
315
- }
316
-
317
- return undefined;
318
- }
319
-
320
- function getMonthlyNextRunAtMs(args: {
321
- afterMs: number;
322
- recurrence: ScheduledTaskRecurrence;
323
- scheduledForMs: number;
324
- timezone: string;
325
- }): number | undefined {
326
- const start = parseLocalDate(args.recurrence.startDate);
327
- const dayOfMonth = args.recurrence.dayOfMonth;
328
- if (!start || !dayOfMonth) {
329
- return undefined;
330
- }
331
-
332
- const scheduledDate = getLocalDate(args.scheduledForMs, args.timezone);
333
- let monthIndex = scheduledDate.year * 12 + scheduledDate.month - 1;
334
- const startMonthIndex = start.year * 12 + start.month - 1;
335
-
336
- for (let attempts = 0; attempts < 1200; attempts += 1) {
337
- monthIndex += args.recurrence.interval;
338
- if (monthIndex < startMonthIndex) {
339
- monthIndex = startMonthIndex;
340
- }
341
- const year = Math.floor(monthIndex / 12);
342
- const month = (monthIndex % 12) + 1;
343
- if (dayOfMonth > daysInMonth(year, month)) {
344
- continue;
345
- }
346
- const candidate = buildCandidate({
347
- date: { year, month, day: dayOfMonth },
348
- recurrence: args.recurrence,
349
- timezone: args.timezone,
350
- });
351
- if (candidate > args.afterMs) {
352
- return candidate;
353
- }
354
- }
355
-
356
- return undefined;
357
- }
358
-
359
- function getYearlyNextRunAtMs(args: {
360
- afterMs: number;
361
- recurrence: ScheduledTaskRecurrence;
362
- scheduledForMs: number;
363
- timezone: string;
364
- }): number | undefined {
365
- const start = parseLocalDate(args.recurrence.startDate);
366
- const month = args.recurrence.month;
367
- const dayOfMonth = args.recurrence.dayOfMonth;
368
- if (!start || !month || !dayOfMonth) {
369
- return undefined;
370
- }
371
-
372
- const scheduledDate = getLocalDate(args.scheduledForMs, args.timezone);
373
- let year = scheduledDate.year;
374
-
375
- for (let attempts = 0; attempts < 100; attempts += 1) {
376
- year += args.recurrence.interval;
377
- if (year < start.year) {
378
- year = start.year;
379
- }
380
- if (dayOfMonth > daysInMonth(year, month)) {
381
- continue;
382
- }
383
- const candidate = buildCandidate({
384
- date: { year, month, day: dayOfMonth },
385
- recurrence: args.recurrence,
386
- timezone: args.timezone,
387
- });
388
- if (candidate > args.afterMs) {
389
- return candidate;
390
- }
391
- }
392
-
393
- return undefined;
394
- }
395
-
396
- /** Build a calendar recurrence anchored to an exact first run timestamp. */
397
- export function buildCalendarRecurrence(args: {
398
- frequency: ScheduledCalendarFrequency;
399
- interval?: number;
400
- nextRunAtMs: number;
401
- timezone: string;
402
- weekdays?: number[];
403
- }): ScheduledTaskRecurrence {
404
- const interval = args.interval && args.interval > 0 ? args.interval : 1;
405
- const parts = getZonedDateTimeParts(args.nextRunAtMs, args.timezone);
406
- const time = { hour: parts.hour, minute: parts.minute };
407
- const startDate = formatLocalDate(parts);
408
-
409
- if (args.frequency === "weekly") {
410
- const weekdays = normalizeWeekdays(args.weekdays);
411
- return {
412
- frequency: args.frequency,
413
- interval,
414
- startDate,
415
- time,
416
- weekdays: weekdays.length > 0 ? weekdays : [parts.weekday],
417
- };
418
- }
419
-
420
- if (args.frequency === "monthly") {
421
- return {
422
- dayOfMonth: parts.day,
423
- frequency: args.frequency,
424
- interval,
425
- startDate,
426
- time,
427
- };
428
- }
429
-
430
- if (args.frequency === "yearly") {
431
- return {
432
- dayOfMonth: parts.day,
433
- frequency: args.frequency,
434
- interval,
435
- month: parts.month,
436
- startDate,
437
- time,
438
- };
439
- }
440
-
441
- return {
442
- frequency: args.frequency,
443
- interval,
444
- startDate,
445
- time,
446
- };
447
- }
448
-
449
394
  /** Return the next fire time after a completed run, when the task recurs. */
450
395
  export function getNextRunAtMs(
451
396
  task: ScheduledTask,
@@ -465,37 +410,16 @@ export function getNextRunAtMs(
465
410
  return undefined;
466
411
  }
467
412
 
468
- if (recurrence.frequency === "daily") {
469
- return getDailyNextRunAtMs({
470
- recurrence,
471
- timezone: task.schedule.timezone,
472
- scheduledForMs,
473
- afterMs,
474
- });
475
- }
476
-
477
- if (recurrence.frequency === "weekly") {
478
- return getWeeklyNextRunAtMs({
479
- recurrence,
480
- timezone: task.schedule.timezone,
481
- scheduledForMs,
482
- afterMs,
483
- });
484
- }
485
-
486
- if (recurrence.frequency === "monthly") {
487
- return getMonthlyNextRunAtMs({
488
- recurrence,
489
- timezone: task.schedule.timezone,
490
- scheduledForMs,
491
- afterMs,
492
- });
493
- }
494
-
495
- return getYearlyNextRunAtMs({
496
- recurrence,
497
- timezone: task.schedule.timezone,
498
- scheduledForMs,
413
+ const timezone = task.schedule.timezone;
414
+ const afterDate = getLocalDate(afterMs, timezone);
415
+ const nextScheduledDate = addDays(getLocalDate(scheduledForMs, timezone), 1);
416
+ return findNextRunAtMs({
499
417
  afterMs,
418
+ recurrence,
419
+ searchFrom:
420
+ compareDate(afterDate, nextScheduledDate) < 0
421
+ ? nextScheduledDate
422
+ : afterDate,
423
+ timezone,
500
424
  });
501
425
  }
package/src/index.ts CHANGED
@@ -10,7 +10,6 @@ export {
10
10
  export {
11
11
  createSchedulerOperationalSqlStore,
12
12
  createSchedulerSqlStore,
13
- migrateSchedulerStateToSql,
14
13
  type SchedulerDb,
15
14
  } from "./store";
16
15
  export type {
package/src/plugin.ts CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  import {
14
14
  createSchedulerOperationalSqlStore,
15
15
  createSchedulerSqlStore,
16
- migrateSchedulerStateToSql,
17
16
  type SchedulerDb,
18
17
  type SchedulerOperationalStore,
19
18
  type SchedulerStore,
@@ -582,12 +581,6 @@ export function createSchedulerPlugin() {
582
581
  store: schedulerOperationalStore(ctx),
583
582
  });
584
583
  },
585
- async migrateStorage(ctx) {
586
- return await migrateSchedulerStateToSql({
587
- db: ctx.db as SchedulerDb,
588
- state: ctx.state,
589
- });
590
- },
591
584
  },
592
585
  });
593
586
  }