@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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -2
  3. package/dist/alterations.d.ts +6 -0
  4. package/dist/alterations.d.ts.map +1 -0
  5. package/dist/alterations.js +61 -0
  6. package/dist/alterations.js.map +1 -0
  7. package/dist/cjs/alterations.d.ts +5 -0
  8. package/dist/cjs/alterations.js +96 -0
  9. package/dist/cjs/dispatcher.d.ts +38 -0
  10. package/dist/cjs/dispatcher.js +165 -0
  11. package/dist/cjs/dispatcher_store.d.ts +9 -0
  12. package/dist/cjs/dispatcher_store.js +15 -0
  13. package/dist/cjs/generators/anchored.d.ts +3 -0
  14. package/dist/cjs/generators/anchored.js +57 -0
  15. package/dist/cjs/generators/days_of_month.d.ts +3 -0
  16. package/dist/cjs/generators/days_of_month.js +76 -0
  17. package/dist/cjs/generators/days_of_week.d.ts +5 -0
  18. package/dist/cjs/generators/days_of_week.js +65 -0
  19. package/dist/cjs/generators/days_of_year.d.ts +3 -0
  20. package/dist/cjs/generators/days_of_year.js +70 -0
  21. package/dist/cjs/generators/instant.d.ts +3 -0
  22. package/dist/cjs/generators/instant.js +15 -0
  23. package/dist/cjs/generators/local_date_time.d.ts +3 -0
  24. package/dist/cjs/generators/local_date_time.js +31 -0
  25. package/dist/cjs/generators/months_of_year.d.ts +3 -0
  26. package/dist/cjs/generators/months_of_year.js +109 -0
  27. package/dist/cjs/generators/types.d.ts +9 -0
  28. package/dist/cjs/generators/types.js +19 -0
  29. package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
  30. package/dist/cjs/generators/weeks_of_month.js +99 -0
  31. package/dist/cjs/generators/yearly.d.ts +3 -0
  32. package/dist/cjs/generators/yearly.js +52 -0
  33. package/dist/cjs/in_memory_port.d.ts +18 -0
  34. package/dist/cjs/in_memory_port.js +59 -0
  35. package/dist/cjs/index.d.ts +6 -0
  36. package/dist/cjs/index.js +14 -0
  37. package/dist/cjs/occurrence_key.d.ts +5 -0
  38. package/dist/cjs/occurrence_key.js +21 -0
  39. package/dist/cjs/package.json +3 -0
  40. package/dist/cjs/port.d.ts +15 -0
  41. package/dist/cjs/port.js +2 -0
  42. package/dist/cjs/scheduler.d.ts +24 -0
  43. package/dist/cjs/scheduler.js +460 -0
  44. package/dist/cjs/types.d.ts +149 -0
  45. package/dist/cjs/types.js +58 -0
  46. package/dist/cjs/tz.d.ts +22 -0
  47. package/dist/cjs/tz.js +197 -0
  48. package/dist/dispatcher.d.ts +39 -0
  49. package/dist/dispatcher.d.ts.map +1 -0
  50. package/dist/dispatcher.js +111 -0
  51. package/dist/dispatcher.js.map +1 -0
  52. package/dist/dispatcher_store.d.ts +10 -0
  53. package/dist/dispatcher_store.d.ts.map +1 -0
  54. package/dist/dispatcher_store.js +13 -0
  55. package/dist/dispatcher_store.js.map +1 -0
  56. package/dist/generators/anchored.d.ts +4 -0
  57. package/dist/generators/anchored.d.ts.map +1 -0
  58. package/dist/generators/anchored.js +42 -0
  59. package/dist/generators/anchored.js.map +1 -0
  60. package/dist/generators/days_of_month.d.ts +4 -0
  61. package/dist/generators/days_of_month.d.ts.map +1 -0
  62. package/dist/generators/days_of_month.js +53 -0
  63. package/dist/generators/days_of_month.js.map +1 -0
  64. package/dist/generators/days_of_week.d.ts +6 -0
  65. package/dist/generators/days_of_week.d.ts.map +1 -0
  66. package/dist/generators/days_of_week.js +43 -0
  67. package/dist/generators/days_of_week.js.map +1 -0
  68. package/dist/generators/days_of_year.d.ts +4 -0
  69. package/dist/generators/days_of_year.d.ts.map +1 -0
  70. package/dist/generators/days_of_year.js +50 -0
  71. package/dist/generators/days_of_year.js.map +1 -0
  72. package/dist/generators/instant.d.ts +4 -0
  73. package/dist/generators/instant.d.ts.map +1 -0
  74. package/dist/generators/instant.js +15 -0
  75. package/dist/generators/instant.js.map +1 -0
  76. package/dist/generators/local_date_time.d.ts +4 -0
  77. package/dist/generators/local_date_time.d.ts.map +1 -0
  78. package/dist/generators/local_date_time.js +20 -0
  79. package/dist/generators/local_date_time.js.map +1 -0
  80. package/dist/generators/months_of_year.d.ts +4 -0
  81. package/dist/generators/months_of_year.d.ts.map +1 -0
  82. package/dist/generators/months_of_year.js +64 -0
  83. package/dist/generators/months_of_year.js.map +1 -0
  84. package/dist/generators/types.d.ts +10 -0
  85. package/dist/generators/types.d.ts.map +1 -0
  86. package/dist/generators/types.js +14 -0
  87. package/dist/generators/types.js.map +1 -0
  88. package/dist/generators/weeks_of_month.d.ts +4 -0
  89. package/dist/generators/weeks_of_month.d.ts.map +1 -0
  90. package/dist/generators/weeks_of_month.js +61 -0
  91. package/dist/generators/weeks_of_month.js.map +1 -0
  92. package/dist/generators/yearly.d.ts +4 -0
  93. package/dist/generators/yearly.d.ts.map +1 -0
  94. package/dist/generators/yearly.js +37 -0
  95. package/dist/generators/yearly.js.map +1 -0
  96. package/dist/in_memory_port.d.ts +19 -0
  97. package/dist/in_memory_port.d.ts.map +1 -0
  98. package/dist/in_memory_port.js +45 -0
  99. package/dist/in_memory_port.js.map +1 -0
  100. package/dist/index.d.ts +7 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +6 -0
  103. package/dist/occurrence_key.d.ts +6 -0
  104. package/dist/occurrence_key.d.ts.map +1 -0
  105. package/dist/occurrence_key.js +25 -0
  106. package/dist/occurrence_key.js.map +1 -0
  107. package/dist/port.d.ts +16 -0
  108. package/dist/port.d.ts.map +1 -0
  109. package/dist/scheduler.d.ts +25 -0
  110. package/dist/scheduler.d.ts.map +1 -0
  111. package/dist/scheduler.js +332 -0
  112. package/dist/scheduler.js.map +1 -0
  113. package/dist/types.d.ts +150 -0
  114. package/dist/types.d.ts.map +1 -0
  115. package/dist/types.js +29 -0
  116. package/dist/types.js.map +1 -0
  117. package/dist/tz.d.ts +23 -0
  118. package/dist/tz.d.ts.map +1 -0
  119. package/dist/tz.js +131 -0
  120. package/dist/tz.js.map +1 -0
  121. package/package.json +70 -4
  122. package/src/__tests__/alterations.test.ts +177 -0
  123. package/src/__tests__/anchored.test.ts +107 -0
  124. package/src/__tests__/days_of_month.test.ts +162 -0
  125. package/src/__tests__/days_of_week.test.ts +134 -0
  126. package/src/__tests__/days_of_year.test.ts +158 -0
  127. package/src/__tests__/dispatcher.test.ts +317 -0
  128. package/src/__tests__/instant.test.ts +50 -0
  129. package/src/__tests__/local_date_time.test.ts +139 -0
  130. package/src/__tests__/months_of_year.test.ts +229 -0
  131. package/src/__tests__/scheduler.test.ts +888 -0
  132. package/src/__tests__/tz.test.ts +201 -0
  133. package/src/__tests__/weeks_of_month.test.ts +195 -0
  134. package/src/__tests__/yearly.test.ts +108 -0
  135. package/src/alterations.ts +108 -0
  136. package/src/dispatcher.ts +184 -0
  137. package/src/dispatcher_store.ts +26 -0
  138. package/src/generators/anchored.ts +69 -0
  139. package/src/generators/days_of_month.ts +100 -0
  140. package/src/generators/days_of_week.ts +101 -0
  141. package/src/generators/days_of_year.ts +88 -0
  142. package/src/generators/instant.ts +18 -0
  143. package/src/generators/local_date_time.ts +38 -0
  144. package/src/generators/months_of_year.ts +143 -0
  145. package/src/generators/types.ts +26 -0
  146. package/src/generators/weeks_of_month.ts +133 -0
  147. package/src/generators/yearly.ts +73 -0
  148. package/src/in_memory_port.ts +70 -0
  149. package/src/index.ts +24 -0
  150. package/src/occurrence_key.ts +42 -0
  151. package/src/port.ts +31 -0
  152. package/src/scheduler.ts +499 -0
  153. package/src/types.ts +369 -0
  154. package/src/tz.ts +246 -0
  155. package/tsconfig.json +25 -0
package/src/types.ts ADDED
@@ -0,0 +1,369 @@
1
+ // All types are plain data — safe to JSON-serialize and store in a JSON column.
2
+ // Generated occurrences carry a TPayload so consumers can attach whatever
3
+ // domain data they need (cron action, calendar event body, etc.).
4
+ //
5
+ // ─── DST gotchas for wall-clock rules ───────────────────────────────────────
6
+ //
7
+ // daysOfWeek / weekOfMonth / yearly rules use wall-clock fields (timeOfDay,
8
+ // day-of-week, month/day) interpreted in the template's `timezone`. In a tz
9
+ // that observes DST, two edge cases exist:
10
+ //
11
+ // 1. Spring-forward gap. On the spring-forward day, an hour is SKIPPED — in
12
+ // America/Denver, 02:00 MST jumps to 03:00 MDT, so wall times 02:00–02:59
13
+ // don't exist that day. If `timeOfDay` lands in this gap, we resolve to
14
+ // the post-transition offset (the default in Luxon and date-fns-tz),
15
+ // which means the event fires at a *different* wall-clock time than you
16
+ // asked for — typically one hour earlier in real time. RECOMMENDATION:
17
+ // don't pick `timeOfDay` between 02:00 and 02:59 in DST-observing zones.
18
+ // 03:00 is always safe.
19
+ //
20
+ // 2. TimeSpan spanning the gap. The TimeSpan type has two flavors:
21
+ // `{type:"duration",ms}` — exact UTC milliseconds, no DST adjustment.
22
+ // An 8h duration shift starting 23:00 spring-forward eve runs for
23
+ // exactly 8h real time; the worker's wall display reads "9 hours
24
+ // elapsed" because clocks jumped. Payroll-correct.
25
+ // `{type:"hourSpan",hours}` — wall-clock hours, flexes with DST.
26
+ // An 8h hourSpan shift starting 23:00 spring-forward eve ends at
27
+ // 07:00 next wall day regardless; real elapsed time is 7 actual
28
+ // hours. Calendar-display correct.
29
+ // Both are valid; pick based on whether you care about real elapsed
30
+ // time or wall-clock display.
31
+
32
+ export enum Day {
33
+ Sunday = 0,
34
+ Monday = 1,
35
+ Tuesday = 2,
36
+ Wednesday = 3,
37
+ Thursday = 4,
38
+ Friday = 5,
39
+ Saturday = 6,
40
+ }
41
+
42
+ export enum Month {
43
+ January = 0,
44
+ February = 1,
45
+ March = 2,
46
+ April = 3,
47
+ May = 4,
48
+ June = 5,
49
+ July = 6,
50
+ August = 7,
51
+ September = 8,
52
+ October = 9,
53
+ November = 10,
54
+ December = 11,
55
+ }
56
+
57
+ // Discriminated union over recurrence kinds. The union is exhaustively
58
+ // switched in the generators dispatcher; adding a new kind without wiring
59
+ // it in is a TS error, not a silent miss (this is the bug we removed from
60
+ // activity-board's IEventType-style hierarchy).
61
+ // Two conceptual modes:
62
+ // Positional cascade — DaysOfWeek → WeeksOfMonth → MonthsOfYear
63
+ // "Which slot in the calendar?" (weekday-anchored)
64
+ // Numerical / date-indexed — Yearly, DaysOfMonth, DaysOfYear
65
+ // "Which numbered day?" (date-anchored)
66
+ //
67
+ // Plus two single-occurrence kinds — Instant (one absolute UTC point) and
68
+ // LocalDateTime (one wall-clock point, DST-resolved in the template tz) — and
69
+ // Anchored (fixed-interval stepping from an absolute anchor).
70
+
71
+ // How long an occurrence lasts. Two flavors:
72
+ // { type: "duration", ms } — fixed real-elapsed time (UTC ms). An 8h
73
+ // duration is always 8h of wall-clock time
74
+ // EXCEPT when the wall clock jumps mid-shift
75
+ // — then the wall display shows ±1h.
76
+ // Payroll-correct.
77
+ // { type: "hourSpan", hours } — wall-clock hours. An 8h hourSpan starting
78
+ // at 23:00 wall ends at 07:00 wall the next
79
+ // day, regardless of DST. Real elapsed time
80
+ // shrinks/expands by ±1h on DST nights.
81
+ // Calendar-display correct.
82
+ //
83
+ // hourSpan accepts fractional hours (e.g. 7.5 = 7h30m).
84
+ //
85
+ // When the template has no timezone, hourSpan collapses to
86
+ // `hours * 3_600_000` ms (no wall clock to flex against); with a timezone it
87
+ // flexes across DST. This holds for EVERY rule kind, including Instant and
88
+ // Anchored — their starts are absolute, but their spans still flex if a tz is
89
+ // set. When omitted from any rule, timeSpan defaults to
90
+ // { type: "duration", ms: 1 } so half-open window intersection still emits
91
+ // cron-fire events.
92
+ export type TimeSpan = { type: "duration"; ms: number } | { type: "hourSpan"; hours: number };
93
+
94
+ export type RecurrenceRule =
95
+ | InstantRule
96
+ | LocalDateTimeRule
97
+ | AnchoredRule
98
+ | DaysOfWeekRule
99
+ | WeeksOfMonthRule
100
+ | MonthsOfYearRule
101
+ | YearlyRule
102
+ | DaysOfMonthRule
103
+ | DaysOfYearRule;
104
+
105
+ export interface InstantRule {
106
+ kind: "instant";
107
+ // Absolute UTC epoch ms — a single fixed point on the timeline. The template
108
+ // timezone does NOT affect this start: an instant has no wall-clock
109
+ // ambiguity to resolve, so there is no DST math to do on it. For a one-off
110
+ // expressed in wall-clock terms ("09:00 on 8 Mar 2026 in America/Denver"),
111
+ // use LocalDateTimeRule instead.
112
+ startOn: number;
113
+ // How long the occurrence lasts. Defaults to { type: "duration", ms: 1 }.
114
+ // (Half-open intersection means a zero-length event wouldn't match any
115
+ // window — we forbid that so cron-fire events still emit correctly.)
116
+ // An hourSpan still flexes across DST when the template carries a timezone;
117
+ // with no timezone it collapses to hours*3_600_000.
118
+ timeSpan?: TimeSpan;
119
+ }
120
+
121
+ // A single occurrence expressed in WALL-CLOCK terms, resolved through the
122
+ // template timezone (DST-aware). Use this when a human picked a local date and
123
+ // time and you want the engine to resolve it to an instant — the inverse of
124
+ // InstantRule, which takes an already-absolute instant.
125
+ //
126
+ // Spring-forward gap / fall-back ambiguity on the start resolve to the earlier
127
+ // UTC instant, identical to every other wall-clock rule (see tz.ts). When the
128
+ // template has no timezone, the fields are interpreted as UTC.
129
+ //
130
+ // `day` is clamped to the month length (Feb 30 → Feb 28/29, Apr 31 → Apr 30),
131
+ // matching YearlyRule.
132
+ export interface LocalDateTimeRule {
133
+ kind: "localDateTime";
134
+ year: number;
135
+ month: Month; // 0-indexed: 0 = January
136
+ day: number; // 1..31, clamped to month length
137
+ // Time-of-day in ms since wall-clock midnight (0 .. 86_400_000), interpreted
138
+ // in the template's timezone.
139
+ timeOfDay: number;
140
+ // How long the occurrence lasts. Defaults to { type: "duration", ms: 1 }.
141
+ // hourSpan flexes across DST (template tz); collapses to hours*3_600_000
142
+ // when the template has no timezone.
143
+ timeSpan?: TimeSpan;
144
+ }
145
+
146
+ export interface AnchoredRule {
147
+ kind: "anchored";
148
+ // Anchor moment in UTC epoch ms; recurrence steps from here.
149
+ anchorDate: number;
150
+ // Step in ms. Must be > 0 (validated at write time).
151
+ intervalMs: number;
152
+ // How long each occurrence lasts. Defaults to { type: "duration", ms: 1 }.
153
+ // hourSpan is treated as hours*3_600_000 (no tz on Anchored).
154
+ timeSpan?: TimeSpan;
155
+ // Series window in UTC epoch ms. seriesStart bounds where occurrences
156
+ // can start; seriesEnd === null means open-ended.
157
+ seriesStart: number;
158
+ seriesEnd: number | null;
159
+ // Optional cap on number of occurrences emitted (counted from anchor).
160
+ // null/undefined means unbounded (subject to seriesEnd).
161
+ count?: number | null;
162
+ }
163
+
164
+ export interface DaysOfWeekRule {
165
+ kind: "daysOfWeek";
166
+ // Days the event repeats on. Non-empty.
167
+ days: Day[];
168
+ // 1 = every week, 2 = every other week, etc. Must be > 0.
169
+ weekInterval: number;
170
+ // Time-of-day in ms since wall-clock midnight (0 .. 86_400_000). Interpreted
171
+ // in the template's timezone.
172
+ timeOfDay: number;
173
+ // How long each occurrence lasts. Defaults to { type: "duration", ms: 1 }.
174
+ timeSpan?: TimeSpan;
175
+ // Series bounds in UTC epoch ms.
176
+ seriesStart: number;
177
+ seriesEnd: number | null;
178
+ count?: number | null;
179
+ }
180
+
181
+ export interface WeeksOfMonthRule {
182
+ kind: "weeksOfMonth";
183
+ // Days within the chosen weeks. Non-empty.
184
+ days: Day[];
185
+ // Which weeks of the month to fire on. Each entry: -5..-1 (nth from end)
186
+ // or 1..5 (nth from start). 0 is invalid. Non-empty. Examples:
187
+ // [2] → 2nd week
188
+ // [-1] → last week
189
+ // [1, -1] → first and last week
190
+ // [1, 3, 5]→ 1st, 3rd, 5th weeks (5th may not exist in some months)
191
+ weeks: number[];
192
+ // 1 = every month, 2 = every other month, etc. Must be > 0.
193
+ monthInterval: number;
194
+ timeOfDay: number;
195
+ // How long each occurrence lasts. Defaults to { type: "duration", ms: 1 }.
196
+ timeSpan?: TimeSpan;
197
+ seriesStart: number;
198
+ seriesEnd: number | null;
199
+ count?: number | null;
200
+ }
201
+
202
+ // Positional yearly: months × weeks-of-month × weekdays.
203
+ // Top of the positional cascade. The fields parallel WeeksOfMonthRule
204
+ // (weeks + days), with `months` choosing which months within a year and
205
+ // `yearInterval` providing the cadence.
206
+ //
207
+ // Examples:
208
+ // "1st Monday of Jan and Jul, every year"
209
+ // months: [January, July], weeks: [1], days: [Monday], yearInterval: 1
210
+ // "Last Friday of every quarter"
211
+ // months: [March, June, September, December],
212
+ // weeks: [-1], days: [Friday], yearInterval: 1
213
+ // "2nd Sunday of May (Mother's Day) every year"
214
+ // months: [May], weeks: [2], days: [Sunday], yearInterval: 1
215
+ // "4th Thursday of November (Thanksgiving)"
216
+ // months: [November], weeks: [4], days: [Thursday], yearInterval: 1
217
+ //
218
+ // If an Nth weekday doesn't exist in a given month (e.g. asking for the
219
+ // 5th Sunday in a month that has only 4), that occurrence is silently
220
+ // skipped — same behavior as WeeksOfMonthRule.
221
+ export interface MonthsOfYearRule {
222
+ kind: "monthsOfYear";
223
+ // Months to fire on. Non-empty.
224
+ months: Month[];
225
+ // Which weeks within the chosen months (1..5 or -5..-1, 0 invalid).
226
+ weeks: number[];
227
+ // Which weekdays. Non-empty. (Day enum: Sunday=0..Saturday=6.)
228
+ days: Day[];
229
+ // 1 = every year, 2 = every other year, etc. Must be > 0.
230
+ yearInterval: number;
231
+ timeOfDay: number;
232
+ timeSpan?: TimeSpan;
233
+ seriesStart: number;
234
+ seriesEnd: number | null;
235
+ count?: number | null;
236
+ }
237
+
238
+ // Simplest yearly recurrence: a single month and day-of-month. Reads
239
+ // cleanly for the overwhelmingly common case ("every June 11", "every
240
+ // other Jan 1"). For multi-date cases use DaysOfMonthRule.
241
+ //
242
+ // Feb 29 in non-leap years falls back to Feb 28. Day 30/31 in shorter
243
+ // months falls back to that month's last day.
244
+ export interface YearlyRule {
245
+ kind: "yearly";
246
+ month: Month;
247
+ day: number; // 1..31, clamped to month length
248
+ yearInterval: number;
249
+ timeOfDay: number;
250
+ timeSpan?: TimeSpan;
251
+ seriesStart: number;
252
+ seriesEnd: number | null;
253
+ count?: number | null;
254
+ }
255
+
256
+ // Multiple day-of-month dates across multiple months.
257
+ //
258
+ // Examples:
259
+ // "15th of Jan and Mar, every other year"
260
+ // months: [January, March], daysOfMonth: [15], yearInterval: 2
261
+ // "1st and 15th of every month (payroll)"
262
+ // months: [Jan..Dec], daysOfMonth: [1, 15], yearInterval: 1
263
+ // "Quarterly: 15th of Jan/Apr/Jul/Oct"
264
+ // months: [Jan, Apr, Jul, Oct], daysOfMonth: [15], yearInterval: 1
265
+ //
266
+ // Day-of-month clamp: if a day doesn't exist in a given month
267
+ // (e.g. day 31 in April), falls back to the last day of that month.
268
+ // Sorting within a year is ascending by startTimestamp.
269
+ export interface DaysOfMonthRule {
270
+ kind: "daysOfMonth";
271
+ months: Month[]; // non-empty
272
+ daysOfMonth: number[]; // each 1..31, non-empty
273
+ yearInterval: number;
274
+ timeOfDay: number;
275
+ timeSpan?: TimeSpan;
276
+ seriesStart: number;
277
+ seriesEnd: number | null;
278
+ count?: number | null;
279
+ }
280
+
281
+ // Day-of-year recurrence: "the Nth day(s) of the year".
282
+ //
283
+ // Day 1 = Jan 1. Day 60 = Mar 1 (in non-leap), Feb 29 (in leap).
284
+ // Day 366 only exists in leap years and is SKIPPED in non-leap years
285
+ // (unlike daysOfMonth, we don't clamp — "366th day" means literally
286
+ // that, and there's no obvious "last day of year" semantics worth the
287
+ // surprise).
288
+ //
289
+ // Examples:
290
+ // "50th and 70th day of year (Feb 19 and Mar 11)"
291
+ // daysOfYear: [50, 70], yearInterval: 1
292
+ // "Day 1 (New Year's Day) and day 366 (last day of leap years)"
293
+ // daysOfYear: [1, 366], yearInterval: 1 // day 366 emits only in leap years
294
+ export interface DaysOfYearRule {
295
+ kind: "daysOfYear";
296
+ daysOfYear: number[]; // each 1..366, non-empty
297
+ yearInterval: number;
298
+ timeOfDay: number;
299
+ timeSpan?: TimeSpan;
300
+ seriesStart: number;
301
+ seriesEnd: number | null;
302
+ count?: number | null;
303
+ }
304
+
305
+ // How to resolve a wall-clock time that is nonexistent (spring-forward gap)
306
+ // or ambiguous (fall-back overlap) in the template timezone. Mirrors the
307
+ // Temporal proposal's `disambiguation` option, plus a recurrence-friendly
308
+ // "skip".
309
+ //
310
+ // "compatible" (default) — gap → the LATER instant (clock moves forward past
311
+ // the gap); overlap → the EARLIER instant. Matches
312
+ // Temporal, the JS Date constructor, and most
313
+ // operating systems.
314
+ // "earlier" — gap and overlap both resolve to the earlier
315
+ // instant. (This was the engine's behavior before
316
+ // disambiguation was configurable.)
317
+ // "later" — gap and overlap both resolve to the later instant.
318
+ // "reject" — throw on a nonexistent or ambiguous wall time.
319
+ // "skip" — drop occurrences whose wall time is nonexistent (in
320
+ // a gap), keeping the rest of the series. Overlaps
321
+ // still resolve (to the earlier instant); only gaps
322
+ // are skipped.
323
+ export type Disambiguation = "compatible" | "earlier" | "later" | "reject" | "skip";
324
+
325
+ // A scheduled item the consumer cares about. The payload is opaque to the
326
+ // scheduler; the only contract is that it carries a `type` string so a
327
+ // dispatcher (or downstream consumer) can route on it.
328
+ export interface ScheduleTemplate<TPayload extends { type: string }> {
329
+ id: string;
330
+ rule: RecurrenceRule;
331
+ // IANA timezone, e.g. "America/Denver". REQUIRED and validated at write time
332
+ // (Scheduler.add/update reject unknown zones). Wall-clock rule fields
333
+ // (timeOfDay, day-of-month, ...) are interpreted in this zone; use "UTC" for
334
+ // zone-free scheduling. The absolute rules (instant, anchored) ignore it —
335
+ // pass "UTC" there by convention.
336
+ timezone: string;
337
+ // Policy for resolving wall-clock times that fall in a DST gap or overlap.
338
+ // Defaults to "compatible". See Disambiguation. Has no effect without a
339
+ // timezone (UTC has no DST) or on absolute rules (instant, anchored), whose
340
+ // starts carry no wall-clock ambiguity.
341
+ disambiguation?: Disambiguation;
342
+ payload: TPayload;
343
+ }
344
+
345
+ // Per-instance overrides. Identified by occurrenceKey rather than raw
346
+ // timestamp so editing timeOfDay on the template doesn't orphan alterations.
347
+ export interface Alteration<TPayload extends { type: string }> {
348
+ id: string;
349
+ templateId: string;
350
+ occurrenceKey: string;
351
+ kind: "reschedule" | "cancel";
352
+ // For reschedule:
353
+ startTimestamp?: number;
354
+ timeSpan?: TimeSpan;
355
+ // Optional payload patch merged onto the template payload at emit time.
356
+ payloadOverride?: Partial<TPayload>;
357
+ }
358
+
359
+ // What the scheduler emits. References the template by id (not by value)
360
+ // so result lists stay light even for large windows.
361
+ export interface ScheduledEvent<TPayload extends { type: string }> {
362
+ templateId: string;
363
+ occurrenceKey: string;
364
+ startTimestamp: number;
365
+ endTimestamp: number;
366
+ payload: TPayload;
367
+ // Present iff this event came from (or was modified by) an alteration.
368
+ alterationId?: string;
369
+ }
package/src/tz.ts ADDED
@@ -0,0 +1,246 @@
1
+ import type { Day, Disambiguation, TimeSpan } from "./types.js";
2
+
3
+ const DAY_MS = 86_400_000;
4
+
5
+ // Timezone math built on Intl.DateTimeFormat (zero deps). All public functions
6
+ // take and return UTC epoch ms; `tz` is an IANA name (or "UTC", or omitted).
7
+ //
8
+ // The hard direction is wall-time → UTC, because Intl only gives us UTC →
9
+ // wall. We enumerate the candidate instants for a wall time by probing the
10
+ // zone offset one day before and one day after (which brackets any single DST
11
+ // transition), then keep the candidates that round-trip back to the input
12
+ // wall time. That yields 1 instant normally, 0 in a spring-forward gap, or 2
13
+ // in a fall-back overlap — the same approach Temporal uses.
14
+ //
15
+ // A `disambiguation` policy then picks among them (see the Disambiguation type
16
+ // in types.ts). The default, "compatible", sends a gap to the LATER instant
17
+ // and an overlap to the EARLIER instant — matching Temporal / the JS Date
18
+ // constructor / most operating systems. "skip" is handled by callers via
19
+ // isNonexistentWallTime(); wallTimeToUtc treats it as "compatible".
20
+
21
+ export interface WallParts {
22
+ year: number;
23
+ month: number; // 0-indexed: 0 = January
24
+ day: number; // 1..31
25
+ hour: number; // 0..23
26
+ minute: number; // 0..59
27
+ second: number; // 0..59
28
+ ms: number; // 0..999
29
+ dayOfWeek: Day;
30
+ }
31
+
32
+ const _formatterCache = new Map<string, Intl.DateTimeFormat>();
33
+
34
+ function _formatter(tz: string): Intl.DateTimeFormat {
35
+ let f = _formatterCache.get(tz);
36
+ if (f == null) {
37
+ f = new Intl.DateTimeFormat("en-US", {
38
+ timeZone: tz,
39
+ year: "numeric",
40
+ month: "2-digit",
41
+ day: "2-digit",
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ second: "2-digit",
45
+ hour12: false,
46
+ });
47
+ _formatterCache.set(tz, f);
48
+ }
49
+ return f;
50
+ }
51
+
52
+ // Is `tz` an IANA zone name (or "UTC") the runtime recognizes? Constructing an
53
+ // Intl.DateTimeFormat with an unknown zone throws RangeError; we probe for that.
54
+ export function isValidTimeZone(tz: string): boolean {
55
+ try {
56
+ new Intl.DateTimeFormat("en-US", { timeZone: tz }).format();
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export function utcToWallParts(utcEpochMs: number, tz = "UTC"): WallParts {
64
+ if (tz === "UTC") {
65
+ const d = new Date(utcEpochMs);
66
+ return {
67
+ year: d.getUTCFullYear(),
68
+ month: d.getUTCMonth(),
69
+ day: d.getUTCDate(),
70
+ hour: d.getUTCHours(),
71
+ minute: d.getUTCMinutes(),
72
+ second: d.getUTCSeconds(),
73
+ ms: d.getUTCMilliseconds(),
74
+ dayOfWeek: d.getUTCDay() as Day,
75
+ };
76
+ }
77
+
78
+ const parts = _formatter(tz).formatToParts(new Date(utcEpochMs));
79
+ const get = (type: string): number => {
80
+ const p = parts.find((x) => x.type === type);
81
+ if (p == null) throw new Error(`Missing date-time part '${type}' for tz '${tz}'`);
82
+ return Number(p.value);
83
+ };
84
+ let hour = get("hour");
85
+ if (hour === 24) hour = 0; // some Node versions report midnight as "24"
86
+ const year = get("year");
87
+ const month = get("month") - 1;
88
+ const day = get("day");
89
+ // Day-of-week derived from the wall date via UTC math (the math itself
90
+ // is tz-independent — Date.UTC just gives us a zero-anchored instant).
91
+ const dayOfWeek = new Date(Date.UTC(year, month, day)).getUTCDay() as Day;
92
+ // Intl doesn't give us ms — preserve the original ms from utcEpochMs.
93
+ // Sub-second precision is the same in any tz, since offsets are minute-
94
+ // aligned (or, for a few historical zones, second-aligned).
95
+ const ms = ((utcEpochMs % 1000) + 1000) % 1000;
96
+ return {
97
+ year,
98
+ month,
99
+ day,
100
+ hour,
101
+ minute: get("minute"),
102
+ second: get("second"),
103
+ ms,
104
+ dayOfWeek,
105
+ };
106
+ }
107
+
108
+ // Offset from UTC to local wall time at the given UTC instant, in ms.
109
+ // Positive for zones east of UTC. (wallAsUtc - utcInstant)
110
+ function _offsetMsAt(utcEpochMs: number, tz: string): number {
111
+ if (tz === "UTC") return 0;
112
+ const w = utcToWallParts(utcEpochMs, tz);
113
+ const wallAsUtc = Date.UTC(w.year, w.month, w.day, w.hour, w.minute, w.second) + w.ms;
114
+ return wallAsUtc - utcEpochMs;
115
+ }
116
+
117
+ // The instants that a wall time could map to in `tz`, plus the bracketing
118
+ // offsets. `valid` has length 1 normally, 0 in a spring-forward gap, or 2 in a
119
+ // fall-back overlap (sorted ascending).
120
+ function _possibleInstants(
121
+ localUtc: number,
122
+ tz: string
123
+ ): { valid: number[]; offBefore: number; offAfter: number } {
124
+ const offBefore = _offsetMsAt(localUtc - DAY_MS, tz);
125
+ const offAfter = _offsetMsAt(localUtc + DAY_MS, tz);
126
+ const valid: number[] = [];
127
+ const offsets = offBefore === offAfter ? [offBefore] : [offBefore, offAfter];
128
+ for (const off of offsets) {
129
+ const epoch = localUtc - off;
130
+ // Keep this candidate only if it reads back as the wall time we asked for
131
+ // (a gap's candidates won't; an overlap's both will).
132
+ const w = utcToWallParts(epoch, tz);
133
+ const back = Date.UTC(w.year, w.month, w.day, w.hour, w.minute, w.second) + w.ms;
134
+ if (back === localUtc && !valid.includes(epoch)) valid.push(epoch);
135
+ }
136
+ valid.sort((a, b) => a - b);
137
+ return { valid, offBefore, offAfter };
138
+ }
139
+
140
+ // Is this wall time nonexistent (in a spring-forward gap) in `tz`? Used by the
141
+ // "skip" disambiguation to drop occurrences whose wall time doesn't exist.
142
+ export function isNonexistentWallTime(
143
+ year: number,
144
+ month: number,
145
+ day: number,
146
+ msOfDay: number,
147
+ tz: string | undefined
148
+ ): boolean {
149
+ if (tz == null || tz === "UTC") return false;
150
+ const localUtc = Date.UTC(year, month, day) + msOfDay;
151
+ return _possibleInstants(localUtc, tz).valid.length === 0;
152
+ }
153
+
154
+ export function wallTimeToUtc(
155
+ year: number,
156
+ month: number, // 0-indexed
157
+ day: number,
158
+ msOfDay: number, // 0..86_399_999, ms since wall-clock midnight
159
+ tz = "UTC",
160
+ disambiguation: Disambiguation = "compatible"
161
+ ): number {
162
+ if (tz === "UTC") {
163
+ return Date.UTC(year, month, day) + msOfDay;
164
+ }
165
+ const localUtc = Date.UTC(year, month, day) + msOfDay;
166
+ const { valid, offBefore, offAfter } = _possibleInstants(localUtc, tz);
167
+
168
+ if (valid.length === 1) return valid[0];
169
+
170
+ if (valid.length === 2) {
171
+ // Fall-back overlap. compatible/earlier/skip → earlier; later → later.
172
+ if (disambiguation === "reject") {
173
+ throw new Error(
174
+ `Ambiguous wall time ${new Date(localUtc).toISOString().slice(0, 19)} in '${tz}' (fall-back overlap) with disambiguation "reject"`
175
+ );
176
+ }
177
+ return disambiguation === "later" ? valid[1] : valid[0];
178
+ }
179
+
180
+ // Spring-forward gap (valid.length === 0). earlier → the earlier instant
181
+ // (post-transition offset); compatible/later/skip → the later instant.
182
+ // ("skip" only reaches here defensively — callers pre-check via
183
+ // isNonexistentWallTime; we resolve it like "compatible".)
184
+ if (disambiguation === "reject") {
185
+ throw new Error(
186
+ `Nonexistent wall time ${new Date(localUtc).toISOString().slice(0, 19)} in '${tz}' (spring-forward gap) with disambiguation "reject"`
187
+ );
188
+ }
189
+ return disambiguation === "earlier" ? localUtc - offAfter : localUtc - offBefore;
190
+ }
191
+
192
+ export function dayOfWeekInTz(utcEpochMs: number, tz = "UTC"): Day {
193
+ return utcToWallParts(utcEpochMs, tz).dayOfWeek;
194
+ }
195
+
196
+ // Add N calendar days in the wall-clock sense — important across DST
197
+ // boundaries, where one civil day is 23 or 25 hours, not 24.
198
+ export function addDaysInTz(utcEpochMs: number, days: number, tz = "UTC"): number {
199
+ const w = utcToWallParts(utcEpochMs, tz);
200
+ return wallTimeToUtc(w.year, w.month, w.day + days, _hmsToMs(w), tz);
201
+ }
202
+
203
+ export function addMonthsInTz(utcEpochMs: number, months: number, tz = "UTC"): number {
204
+ const w = utcToWallParts(utcEpochMs, tz);
205
+ return wallTimeToUtc(w.year, w.month + months, w.day, _hmsToMs(w), tz);
206
+ }
207
+
208
+ // Civil-calendar month length — tz-independent.
209
+ export function monthLength(year: number, month: number): number {
210
+ // Date.UTC(y, m+1, 0) gives the last day of month `m` (0-indexed).
211
+ return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
212
+ }
213
+
214
+ // Returns endTimestamp for an occurrence given its start and a TimeSpan.
215
+ //
216
+ // For `duration`: pure UTC ms addition. The wall clock may show ±1h different
217
+ // elapsed time on DST nights, but real elapsed time is exactly `ms`.
218
+ //
219
+ // For `hourSpan`: add hours in wall-clock space (handles midnight crossing
220
+ // and DST flex). On a tz-less template, hourSpan collapses to hours*3_600_000
221
+ // since there's no wall clock to flex against.
222
+ //
223
+ // When span is undefined, defaults to 1ms (so the half-open intersection
224
+ // emits cron-fire occurrences correctly).
225
+ export function computeSpanEnd(
226
+ startUtc: number,
227
+ span: TimeSpan | undefined,
228
+ tz: string | undefined
229
+ ): number {
230
+ if (span == null) return startUtc + 1;
231
+ if (span.type === "duration") return startUtc + span.ms;
232
+ // hourSpan
233
+ if (tz == null) {
234
+ // No tz → treat hours as a fixed real-time interval.
235
+ return startUtc + span.hours * 3_600_000;
236
+ }
237
+ const w = utcToWallParts(startUtc, tz);
238
+ const totalMs = _hmsToMs(w) + span.hours * 3_600_000;
239
+ const daysAhead = Math.floor(totalMs / 86_400_000);
240
+ const endMsInDay = totalMs - daysAhead * 86_400_000;
241
+ return wallTimeToUtc(w.year, w.month, w.day + daysAhead, endMsInDay, tz);
242
+ }
243
+
244
+ function _hmsToMs(w: WallParts): number {
245
+ return w.hour * 3_600_000 + w.minute * 60_000 + w.second * 1000 + w.ms;
246
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUnusedLocals": true,
10
+ "noUnusedParameters": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "isolatedModules": true,
16
+ "resolveJsonModule": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "outDir": "./dist",
21
+ "rootDir": "./src"
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/__tests__/**"]
25
+ }