@j13b/scheduler 0.0.0-stage → 0.1.1

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 (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -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 +7 -0
  36. package/dist/cjs/index.js +18 -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 +23 -0
  43. package/dist/cjs/scheduler.js +302 -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/cjs/validate_rule.d.ts +4 -0
  49. package/dist/cjs/validate_rule.js +267 -0
  50. package/dist/dispatcher.d.ts +39 -0
  51. package/dist/dispatcher.d.ts.map +1 -0
  52. package/dist/dispatcher.js +111 -0
  53. package/dist/dispatcher.js.map +1 -0
  54. package/dist/dispatcher_store.d.ts +10 -0
  55. package/dist/dispatcher_store.d.ts.map +1 -0
  56. package/dist/dispatcher_store.js +13 -0
  57. package/dist/dispatcher_store.js.map +1 -0
  58. package/dist/generators/anchored.d.ts +4 -0
  59. package/dist/generators/anchored.d.ts.map +1 -0
  60. package/dist/generators/anchored.js +42 -0
  61. package/dist/generators/anchored.js.map +1 -0
  62. package/dist/generators/days_of_month.d.ts +4 -0
  63. package/dist/generators/days_of_month.d.ts.map +1 -0
  64. package/dist/generators/days_of_month.js +53 -0
  65. package/dist/generators/days_of_month.js.map +1 -0
  66. package/dist/generators/days_of_week.d.ts +6 -0
  67. package/dist/generators/days_of_week.d.ts.map +1 -0
  68. package/dist/generators/days_of_week.js +43 -0
  69. package/dist/generators/days_of_week.js.map +1 -0
  70. package/dist/generators/days_of_year.d.ts +4 -0
  71. package/dist/generators/days_of_year.d.ts.map +1 -0
  72. package/dist/generators/days_of_year.js +50 -0
  73. package/dist/generators/days_of_year.js.map +1 -0
  74. package/dist/generators/instant.d.ts +4 -0
  75. package/dist/generators/instant.d.ts.map +1 -0
  76. package/dist/generators/instant.js +15 -0
  77. package/dist/generators/instant.js.map +1 -0
  78. package/dist/generators/local_date_time.d.ts +4 -0
  79. package/dist/generators/local_date_time.d.ts.map +1 -0
  80. package/dist/generators/local_date_time.js +20 -0
  81. package/dist/generators/local_date_time.js.map +1 -0
  82. package/dist/generators/months_of_year.d.ts +4 -0
  83. package/dist/generators/months_of_year.d.ts.map +1 -0
  84. package/dist/generators/months_of_year.js +64 -0
  85. package/dist/generators/months_of_year.js.map +1 -0
  86. package/dist/generators/types.d.ts +10 -0
  87. package/dist/generators/types.d.ts.map +1 -0
  88. package/dist/generators/types.js +14 -0
  89. package/dist/generators/types.js.map +1 -0
  90. package/dist/generators/weeks_of_month.d.ts +4 -0
  91. package/dist/generators/weeks_of_month.d.ts.map +1 -0
  92. package/dist/generators/weeks_of_month.js +61 -0
  93. package/dist/generators/weeks_of_month.js.map +1 -0
  94. package/dist/generators/yearly.d.ts +4 -0
  95. package/dist/generators/yearly.d.ts.map +1 -0
  96. package/dist/generators/yearly.js +37 -0
  97. package/dist/generators/yearly.js.map +1 -0
  98. package/dist/in_memory_port.d.ts +19 -0
  99. package/dist/in_memory_port.d.ts.map +1 -0
  100. package/dist/in_memory_port.js +45 -0
  101. package/dist/in_memory_port.js.map +1 -0
  102. package/dist/index.d.ts +8 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +7 -0
  105. package/dist/occurrence_key.d.ts +6 -0
  106. package/dist/occurrence_key.d.ts.map +1 -0
  107. package/dist/occurrence_key.js +25 -0
  108. package/dist/occurrence_key.js.map +1 -0
  109. package/dist/port.d.ts +16 -0
  110. package/dist/port.d.ts.map +1 -0
  111. package/dist/scheduler.d.ts +24 -0
  112. package/dist/scheduler.d.ts.map +1 -0
  113. package/dist/scheduler.js +241 -0
  114. package/dist/scheduler.js.map +1 -0
  115. package/dist/types.d.ts +150 -0
  116. package/dist/types.d.ts.map +1 -0
  117. package/dist/types.js +29 -0
  118. package/dist/types.js.map +1 -0
  119. package/dist/tz.d.ts +23 -0
  120. package/dist/tz.d.ts.map +1 -0
  121. package/dist/tz.js +131 -0
  122. package/dist/tz.js.map +1 -0
  123. package/dist/validate_rule.d.ts +5 -0
  124. package/dist/validate_rule.d.ts.map +1 -0
  125. package/dist/validate_rule.js +280 -0
  126. package/dist/validate_rule.js.map +1 -0
  127. package/package.json +76 -4
  128. package/src/__tests__/alterations.test.ts +177 -0
  129. package/src/__tests__/anchored.test.ts +107 -0
  130. package/src/__tests__/days_of_month.test.ts +162 -0
  131. package/src/__tests__/days_of_week.test.ts +134 -0
  132. package/src/__tests__/days_of_year.test.ts +158 -0
  133. package/src/__tests__/dispatcher.test.ts +317 -0
  134. package/src/__tests__/instant.test.ts +50 -0
  135. package/src/__tests__/local_date_time.test.ts +139 -0
  136. package/src/__tests__/months_of_year.test.ts +229 -0
  137. package/src/__tests__/scheduler.test.ts +888 -0
  138. package/src/__tests__/tz.test.ts +201 -0
  139. package/src/__tests__/validate_rule.test.ts +190 -0
  140. package/src/__tests__/weeks_of_month.test.ts +195 -0
  141. package/src/__tests__/yearly.test.ts +108 -0
  142. package/src/alterations.ts +108 -0
  143. package/src/dispatcher.ts +184 -0
  144. package/src/dispatcher_store.ts +26 -0
  145. package/src/generators/anchored.ts +69 -0
  146. package/src/generators/days_of_month.ts +100 -0
  147. package/src/generators/days_of_week.ts +101 -0
  148. package/src/generators/days_of_year.ts +88 -0
  149. package/src/generators/instant.ts +18 -0
  150. package/src/generators/local_date_time.ts +38 -0
  151. package/src/generators/months_of_year.ts +143 -0
  152. package/src/generators/types.ts +26 -0
  153. package/src/generators/weeks_of_month.ts +133 -0
  154. package/src/generators/yearly.ts +73 -0
  155. package/src/in_memory_port.ts +70 -0
  156. package/src/index.ts +25 -0
  157. package/src/occurrence_key.ts +42 -0
  158. package/src/port.ts +31 -0
  159. package/src/scheduler.ts +353 -0
  160. package/src/types.ts +369 -0
  161. package/src/tz.ts +246 -0
  162. package/src/validate_rule.ts +311 -0
  163. package/tsconfig.json +25 -0
@@ -0,0 +1,108 @@
1
+ import { intersects } from "./generators/types.js";
2
+ import type { KeyedOccurrence } from "./occurrence_key.js";
3
+ import type { Alteration, ScheduledEvent } from "./types.js";
4
+ import { computeSpanEnd } from "./tz.js";
5
+
6
+ // Apply a template's alterations to its base occurrences and emit the final
7
+ // ScheduledEvents.
8
+ //
9
+ // Two passes:
10
+ // 1. Walk base occurrences. Cancel suppresses; reschedule replaces (and
11
+ // may move out of window, in which case it's not emitted).
12
+ // 2. Walk reschedules whose original key is NOT in the base list (the
13
+ // original was outside the window) but whose new time IS in the window.
14
+ // Synthesize an event for them. This is what activity-board does in its
15
+ // separate AlterationEventGenerator.
16
+ //
17
+ // `tz` is the template's timezone. Used to interpret hourSpan on the
18
+ // alteration's timeSpan (if any). When the alteration carries no timeSpan,
19
+ // we preserve the original occurrence's duration (end - start in UTC ms).
20
+ //
21
+ // Result is sorted ascending by startTimestamp.
22
+ export function applyAlterations<TPayload extends { type: string }>(
23
+ templateId: string,
24
+ basePayload: TPayload,
25
+ keyedOccurrences: readonly KeyedOccurrence[],
26
+ alterations: readonly Alteration<TPayload>[],
27
+ tz: string | undefined,
28
+ windowStart: number,
29
+ windowEnd: number
30
+ ): ScheduledEvent<TPayload>[] {
31
+ // Index alterations for THIS template by occurrence key. If multiple
32
+ // alterations target the same key, last-write-wins.
33
+ const altByKey = new Map<string, Alteration<TPayload>>();
34
+ for (const alt of alterations) {
35
+ if (alt.templateId === templateId) altByKey.set(alt.occurrenceKey, alt);
36
+ }
37
+
38
+ const out: ScheduledEvent<TPayload>[] = [];
39
+ const seenKeys = new Set<string>();
40
+
41
+ for (const occ of keyedOccurrences) {
42
+ seenKeys.add(occ.occurrenceKey);
43
+ const alt = altByKey.get(occ.occurrenceKey);
44
+
45
+ if (alt == null) {
46
+ out.push({
47
+ templateId,
48
+ occurrenceKey: occ.occurrenceKey,
49
+ startTimestamp: occ.startTimestamp,
50
+ endTimestamp: occ.endTimestamp,
51
+ payload: basePayload,
52
+ });
53
+ continue;
54
+ }
55
+ if (alt.kind === "cancel") continue;
56
+
57
+ // reschedule
58
+ const start = alt.startTimestamp ?? occ.startTimestamp;
59
+ // If the alteration specifies a timeSpan, use it (with tz semantics).
60
+ // Otherwise preserve the original UTC duration.
61
+ const end =
62
+ alt.timeSpan != null
63
+ ? computeSpanEnd(start, alt.timeSpan, tz)
64
+ : start + (occ.endTimestamp - occ.startTimestamp);
65
+ if (!intersects(start, end, windowStart, windowEnd)) continue;
66
+ out.push({
67
+ templateId,
68
+ occurrenceKey: occ.occurrenceKey,
69
+ startTimestamp: start,
70
+ endTimestamp: end,
71
+ payload: _mergePayload(basePayload, alt.payloadOverride),
72
+ alterationId: alt.id,
73
+ });
74
+ }
75
+
76
+ // Orphan reschedules: alteration's occurrenceKey isn't in the base list
77
+ // (because the original fell outside the window) but the rescheduled time
78
+ // is. Cancels for orphans are no-ops — there was nothing to cancel here.
79
+ for (const alt of altByKey.values()) {
80
+ if (seenKeys.has(alt.occurrenceKey)) continue;
81
+ if (alt.kind !== "reschedule") continue;
82
+ if (alt.startTimestamp == null) continue;
83
+ // No original to inherit duration from; default to 1ms if no timeSpan.
84
+ const end =
85
+ alt.timeSpan != null
86
+ ? computeSpanEnd(alt.startTimestamp, alt.timeSpan, tz)
87
+ : alt.startTimestamp + 1;
88
+ if (!intersects(alt.startTimestamp, end, windowStart, windowEnd)) continue;
89
+ out.push({
90
+ templateId,
91
+ occurrenceKey: alt.occurrenceKey,
92
+ startTimestamp: alt.startTimestamp,
93
+ endTimestamp: end,
94
+ payload: _mergePayload(basePayload, alt.payloadOverride),
95
+ alterationId: alt.id,
96
+ });
97
+ }
98
+
99
+ return out.sort((a, b) => a.startTimestamp - b.startTimestamp);
100
+ }
101
+
102
+ function _mergePayload<TPayload extends { type: string }>(
103
+ base: TPayload,
104
+ override: Partial<TPayload> | undefined
105
+ ): TPayload {
106
+ if (override == null) return base;
107
+ return { ...base, ...override } as TPayload;
108
+ }
@@ -0,0 +1,184 @@
1
+ import type { IEventSubscription } from "@j13b/state";
2
+ import { type DispatcherStore, InMemoryDispatcherStore } from "./dispatcher_store.js";
3
+ import type { Scheduler } from "./scheduler.js";
4
+ import type { ScheduledEvent } from "./types.js";
5
+
6
+ // Default safety-net wait when nothing is scheduled. The Scheduler.changed
7
+ // event normally wakes us as soon as something gets added, so this is just
8
+ // a backstop in case a notification is missed.
9
+ const DEFAULT_IDLE_TIMEOUT_MS = 60 * 60 * 1000;
10
+
11
+ export interface DispatcherConfig<TPayload extends { type: string }> {
12
+ scheduler: Scheduler<TPayload>;
13
+ // Called for each due event in cursor → now order. Errors thrown from here
14
+ // are passed to onError; the cursor advances regardless (cron-style: a
15
+ // permanently-failing fire shouldn't block the queue forever).
16
+ fire: (event: ScheduledEvent<TPayload>) => Promise<void> | void;
17
+ // Persists the cursor across restarts. Defaults to in-memory (no recovery).
18
+ store?: DispatcherStore;
19
+ // Called when fire() throws or list() fails. Defaults to console.error.
20
+ onError?: (err: unknown, event?: ScheduledEvent<TPayload>) => void;
21
+ // Backstop wait when no event is scheduled. Default 1 hour. Lower for tests.
22
+ idleTimeoutMs?: number;
23
+ // Clock injection (Date.now) and timer injection (setTimeout/clearTimeout).
24
+ // Defaults to wall clock. Inject for deterministic tests.
25
+ now?: () => number;
26
+ setTimeout?: (fn: () => void, ms: number) => unknown;
27
+ clearTimeout?: (handle: unknown) => void;
28
+ }
29
+
30
+ // Drives a Scheduler in real time. The loop:
31
+ // 1. Catch up: list(cursor, now) → fire each → advance cursor → save.
32
+ // 2. Find next: nextDue(now, 1) → wait until its startTimestamp.
33
+ // 3. Wait may end early via Scheduler.changed (a write happened) or stop().
34
+ // Catch-up handles crashes AND in-process adds we missed. The change event
35
+ // handles in-process adds while we're sleeping — the dispatcher wakes
36
+ // immediately and re-queries instead of firing the stale "next" event late.
37
+ export class Dispatcher<TPayload extends { type: string }> {
38
+ private readonly _scheduler: Scheduler<TPayload>;
39
+ private readonly _fire: (event: ScheduledEvent<TPayload>) => Promise<void> | void;
40
+ private readonly _store: DispatcherStore;
41
+ private readonly _onError: (err: unknown, event?: ScheduledEvent<TPayload>) => void;
42
+ private readonly _idleTimeoutMs: number;
43
+ private readonly _now: () => number;
44
+ private readonly _setTimeout: (fn: () => void, ms: number) => unknown;
45
+ private readonly _clearTimeout: (handle: unknown) => void;
46
+
47
+ private _running = false;
48
+ private _wakeResolve: (() => void) | null = null;
49
+ private _changeSubscription: IEventSubscription<void> | null = null;
50
+
51
+ constructor(config: DispatcherConfig<TPayload>) {
52
+ this._scheduler = config.scheduler;
53
+ this._fire = config.fire;
54
+ this._store = config.store ?? new InMemoryDispatcherStore();
55
+ this._onError = config.onError ?? defaultOnError;
56
+ this._idleTimeoutMs = config.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
57
+ this._now = config.now ?? Date.now;
58
+ this._setTimeout = config.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms));
59
+ this._clearTimeout =
60
+ config.clearTimeout ?? ((h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>));
61
+ }
62
+
63
+ // Starts the loop. Resolves after the first catch-up pass completes (so
64
+ // tests can await an initial sync). The loop keeps running in the
65
+ // background until stop() is called.
66
+ async start(): Promise<void> {
67
+ if (this._running) return;
68
+ this._running = true;
69
+ this._changeSubscription = this._scheduler.changed.subscribe(() => this._wake());
70
+ // First iteration in foreground so callers can await initial catch-up.
71
+ await this._tick();
72
+ if (this._running) void this._loop();
73
+ }
74
+
75
+ // Halts the loop. Any in-flight fire() completes; subsequent fires are
76
+ // skipped. The Scheduler subscription is removed.
77
+ stop(): void {
78
+ if (!this._running) return;
79
+ this._running = false;
80
+ if (this._changeSubscription != null) {
81
+ this._changeSubscription.unsubscribe();
82
+ this._changeSubscription = null;
83
+ }
84
+ this._wake();
85
+ }
86
+
87
+ // ─── Internals ──────────────────────────────────────────────
88
+
89
+ private async _loop(): Promise<void> {
90
+ while (this._running) {
91
+ const target = await this._findNextTarget();
92
+ if (!this._running) return;
93
+ await this._sleepUntil(target);
94
+ if (!this._running) return;
95
+ await this._tick();
96
+ }
97
+ }
98
+
99
+ // Returns the epoch-ms instant the dispatcher should next wake. Either
100
+ // the next due event's startTimestamp, or now + idleTimeoutMs.
101
+ private async _findNextTarget(): Promise<number> {
102
+ try {
103
+ const upcoming = await this._scheduler.nextDue(this._now(), 1);
104
+ const next = upcoming[0];
105
+ return next != null ? next.startTimestamp : this._now() + this._idleTimeoutMs;
106
+ } catch (err) {
107
+ this._onError(err);
108
+ return this._now() + this._idleTimeoutMs;
109
+ }
110
+ }
111
+
112
+ // Catch-up pass: fire everything with start in [cursor, now]. We use
113
+ // `now + 1` as the half-open windowEnd so an event scheduled exactly at
114
+ // the wake instant is included (the wake from setTimeout lands on the
115
+ // event's startTimestamp, and we don't want to miss it). Advancing the
116
+ // cursor to `now + 1` likewise avoids re-listing the same event on the
117
+ // next tick.
118
+ private async _tick(): Promise<void> {
119
+ const cursor = (await this._store.loadCursor()) ?? this._now();
120
+ const now = this._now();
121
+ const windowEnd = now + 1;
122
+ if (windowEnd <= cursor) {
123
+ // Cursor is already at or past `now + 1` — happens on the very first
124
+ // tick when we just-saved-and-rewoke. Nothing to do.
125
+ return;
126
+ }
127
+
128
+ let events: ScheduledEvent<TPayload>[];
129
+ try {
130
+ events = await this._scheduler.list(cursor, windowEnd);
131
+ } catch (err) {
132
+ this._onError(err);
133
+ // Don't advance cursor on a list failure — try again next tick.
134
+ return;
135
+ }
136
+
137
+ for (const event of events) {
138
+ if (!this._running) return;
139
+ try {
140
+ await this._fire(event);
141
+ } catch (err) {
142
+ this._onError(err, event);
143
+ }
144
+ }
145
+ await this._store.saveCursor(windowEnd);
146
+ }
147
+
148
+ // Sleeps until either the target epoch-ms instant, or wake() is called
149
+ // (via Scheduler.changed or stop()). No-op if target is already past.
150
+ private async _sleepUntil(targetMs: number): Promise<void> {
151
+ const remaining = targetMs - this._now();
152
+ if (remaining <= 0) return;
153
+ await new Promise<void>((resolve) => {
154
+ let done = false;
155
+ const timer = this._setTimeout(() => {
156
+ if (done) return;
157
+ done = true;
158
+ this._wakeResolve = null;
159
+ resolve();
160
+ }, remaining);
161
+ this._wakeResolve = () => {
162
+ if (done) return;
163
+ done = true;
164
+ this._clearTimeout(timer);
165
+ this._wakeResolve = null;
166
+ resolve();
167
+ };
168
+ });
169
+ }
170
+
171
+ // Resolves any in-flight _sleepUntil. Called by stop() and by the
172
+ // Scheduler.changed subscription.
173
+ private _wake(): void {
174
+ if (this._wakeResolve != null) this._wakeResolve();
175
+ }
176
+ }
177
+
178
+ function defaultOnError(err: unknown, event?: ScheduledEvent<{ type: string }>): void {
179
+ if (event != null) {
180
+ console.error(`[Dispatcher] fire failed for ${event.templateId}/${event.occurrenceKey}:`, err);
181
+ } else {
182
+ console.error("[Dispatcher] error:", err);
183
+ }
184
+ }
@@ -0,0 +1,26 @@
1
+ // Persistence for the Dispatcher's tick cursor. The cursor is a single
2
+ // epoch-ms value: the last instant for which the Dispatcher has fired all
3
+ // due events. On restart, the Dispatcher catches up by listing events in
4
+ // [cursor, now) and firing them — so a process crash followed by restart
5
+ // won't lose events that were due during downtime.
6
+ //
7
+ // Same shape as ChatStore / MemoryStore in the agent module — implement
8
+ // against whatever backend you want.
9
+ export interface DispatcherStore {
10
+ loadCursor(): Promise<number | null>;
11
+ saveCursor(cursor: number): Promise<void>;
12
+ }
13
+
14
+ // Reference in-memory implementation. Useful for tests and short-lived
15
+ // processes that don't need crash recovery.
16
+ export class InMemoryDispatcherStore implements DispatcherStore {
17
+ private _cursor: number | null = null;
18
+
19
+ async loadCursor(): Promise<number | null> {
20
+ return this._cursor;
21
+ }
22
+
23
+ async saveCursor(cursor: number): Promise<void> {
24
+ this._cursor = cursor;
25
+ }
26
+ }
@@ -0,0 +1,69 @@
1
+ import type { AnchoredRule } from "../types.js";
2
+ import { computeSpanEnd } from "../tz.js";
3
+ import {
4
+ MAX_OCCURRENCES_PER_GENERATE,
5
+ type Occurrence,
6
+ TooManyOccurrencesError,
7
+ intersects,
8
+ } from "./types.js";
9
+
10
+ // Anchored is tz-independent for STEPPING (every occurrence is exactly
11
+ // intervalMs ms from the anchor), but tz can still affect the end-of-
12
+ // occurrence when hourSpan is used. seriesStart/seriesEnd bound in UTC.
13
+ export function generateAnchored(
14
+ rule: AnchoredRule,
15
+ tz: string | undefined,
16
+ windowStart: number,
17
+ windowEnd: number
18
+ ): Occurrence[] {
19
+ if (rule.intervalMs <= 0) {
20
+ throw new Error(`AnchoredRule.intervalMs must be > 0 (got ${rule.intervalMs})`);
21
+ }
22
+ if (rule.count != null && rule.count < 0) {
23
+ throw new Error(`AnchoredRule.count must be >= 0 (got ${rule.count})`);
24
+ }
25
+
26
+ const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
27
+ const effectiveEnd = Math.min(windowEnd, seriesEnd);
28
+ const effectiveStart = Math.max(windowStart, rule.seriesStart);
29
+
30
+ if (effectiveStart >= effectiveEnd) return [];
31
+
32
+ // If `count` is set we must start counting from the anchor; otherwise we
33
+ // can fast-forward to the window start.
34
+ let index: number;
35
+ let occurrenceStart: number;
36
+ if (rule.count != null) {
37
+ index = 0;
38
+ occurrenceStart = rule.anchorDate;
39
+ } else {
40
+ if (effectiveStart <= rule.anchorDate) {
41
+ index = 0;
42
+ occurrenceStart = rule.anchorDate;
43
+ } else {
44
+ // First occurrence at or after effectiveStart.
45
+ const stepsAhead = Math.ceil((effectiveStart - rule.anchorDate) / rule.intervalMs);
46
+ index = stepsAhead;
47
+ occurrenceStart = rule.anchorDate + stepsAhead * rule.intervalMs;
48
+ }
49
+ }
50
+
51
+ const out: Occurrence[] = [];
52
+ while (occurrenceStart < effectiveEnd) {
53
+ if (rule.count != null && index >= rule.count) break;
54
+
55
+ const occEnd = computeSpanEnd(occurrenceStart, rule.timeSpan, tz);
56
+ if (
57
+ intersects(occurrenceStart, occEnd, windowStart, windowEnd) &&
58
+ occurrenceStart >= rule.seriesStart
59
+ ) {
60
+ out.push({ startTimestamp: occurrenceStart, endTimestamp: occEnd });
61
+ if (out.length > MAX_OCCURRENCES_PER_GENERATE) {
62
+ throw new TooManyOccurrencesError("anchored");
63
+ }
64
+ }
65
+ index++;
66
+ occurrenceStart += rule.intervalMs;
67
+ }
68
+ return out;
69
+ }
@@ -0,0 +1,100 @@
1
+ import type { DaysOfMonthRule, Disambiguation } from "../types.js";
2
+ import {
3
+ computeSpanEnd,
4
+ isNonexistentWallTime,
5
+ monthLength,
6
+ utcToWallParts,
7
+ wallTimeToUtc,
8
+ } from "../tz.js";
9
+ import {
10
+ MAX_OCCURRENCES_PER_GENERATE,
11
+ type Occurrence,
12
+ TooManyOccurrencesError,
13
+ intersects,
14
+ } from "./types.js";
15
+
16
+ // Numerical date-based recurrence: months × daysOfMonth. Days clamp per
17
+ // month (day 31 in April → Apr 30, day 29 in Feb non-leap → Feb 28).
18
+ export function generateDaysOfMonth(
19
+ rule: DaysOfMonthRule,
20
+ tz: string | undefined,
21
+ windowStart: number,
22
+ windowEnd: number,
23
+ disambiguation: Disambiguation = "compatible"
24
+ ): Occurrence[] {
25
+ if (rule.yearInterval <= 0) {
26
+ throw new Error(`DaysOfMonthRule.yearInterval must be > 0 (got ${rule.yearInterval})`);
27
+ }
28
+ if (rule.months.length === 0) {
29
+ throw new Error("DaysOfMonthRule.months must be non-empty");
30
+ }
31
+ for (const m of rule.months) {
32
+ if (m < 0 || m > 11) {
33
+ throw new Error(`DaysOfMonthRule.months entries must be in 0..11 (got ${m})`);
34
+ }
35
+ }
36
+ if (rule.daysOfMonth.length === 0) {
37
+ throw new Error("DaysOfMonthRule.daysOfMonth must be non-empty");
38
+ }
39
+ for (const d of rule.daysOfMonth) {
40
+ if (d < 1 || d > 31) {
41
+ throw new Error(`DaysOfMonthRule.daysOfMonth entries must be in 1..31 (got ${d})`);
42
+ }
43
+ }
44
+ if (rule.count != null && rule.count < 0) {
45
+ throw new Error(`DaysOfMonthRule.count must be >= 0 (got ${rule.count})`);
46
+ }
47
+
48
+ const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
49
+ const effectiveEnd = Math.min(windowEnd, seriesEnd);
50
+ if (rule.seriesStart >= effectiveEnd) return [];
51
+
52
+ const months = [...new Set(rule.months)].sort((a, b) => a - b);
53
+ const daysOfMonth = [...new Set(rule.daysOfMonth)].sort((a, b) => a - b);
54
+
55
+ const startYear = utcToWallParts(rule.seriesStart, tz).year;
56
+ const endYear = utcToWallParts(Math.min(effectiveEnd, Number.MAX_SAFE_INTEGER), tz).year;
57
+
58
+ const out: Occurrence[] = [];
59
+ let count = 0;
60
+
61
+ for (let year = startYear; year <= endYear; year += rule.yearInterval) {
62
+ const yearEmits: Occurrence[] = [];
63
+
64
+ for (const month of months) {
65
+ const len = monthLength(year, month);
66
+ // Dedup clamped days so day-31 + day-30 in April don't both emit Apr 30.
67
+ const clampedDays = [...new Set(daysOfMonth.map((d) => Math.min(d, len)))];
68
+ for (const day of clampedDays) {
69
+ if (
70
+ disambiguation === "skip" &&
71
+ isNonexistentWallTime(year, month, day, rule.timeOfDay, tz)
72
+ ) {
73
+ continue;
74
+ }
75
+ const occStart = wallTimeToUtc(year, month, day, rule.timeOfDay, tz, disambiguation);
76
+ const occEnd = computeSpanEnd(occStart, rule.timeSpan, tz);
77
+
78
+ if (occStart < rule.seriesStart) continue;
79
+ if (occStart >= seriesEnd) continue;
80
+
81
+ if (rule.count != null && count >= rule.count) {
82
+ yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
83
+ out.push(...yearEmits);
84
+ return out;
85
+ }
86
+ count++;
87
+
88
+ if (intersects(occStart, occEnd, windowStart, windowEnd)) {
89
+ yearEmits.push({ startTimestamp: occStart, endTimestamp: occEnd });
90
+ if (out.length + yearEmits.length > MAX_OCCURRENCES_PER_GENERATE) {
91
+ throw new TooManyOccurrencesError("daysOfMonth");
92
+ }
93
+ }
94
+ }
95
+ }
96
+ yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
97
+ out.push(...yearEmits);
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,101 @@
1
+ import type { DaysOfWeekRule, Disambiguation } from "../types.js";
2
+ import {
3
+ addDaysInTz,
4
+ computeSpanEnd,
5
+ dayOfWeekInTz,
6
+ isNonexistentWallTime,
7
+ utcToWallParts,
8
+ wallTimeToUtc,
9
+ } from "../tz.js";
10
+ import {
11
+ MAX_OCCURRENCES_PER_GENERATE,
12
+ type Occurrence,
13
+ TooManyOccurrencesError,
14
+ intersects,
15
+ } from "./types.js";
16
+
17
+ // Iterate week-by-week (in wall time, in tz) starting from seriesStart's
18
+ // week. For each scheduled week, emit the occurrences that fall on the
19
+ // rule's days.
20
+ export function generateDaysOfWeek(
21
+ rule: DaysOfWeekRule,
22
+ tz: string | undefined,
23
+ windowStart: number,
24
+ windowEnd: number,
25
+ disambiguation: Disambiguation = "compatible"
26
+ ): Occurrence[] {
27
+ if (rule.weekInterval <= 0) {
28
+ throw new Error(`DaysOfWeekRule.weekInterval must be > 0 (got ${rule.weekInterval})`);
29
+ }
30
+ if (rule.days.length === 0) {
31
+ throw new Error("DaysOfWeekRule.days must be non-empty");
32
+ }
33
+ if (rule.count != null && rule.count < 0) {
34
+ throw new Error(`DaysOfWeekRule.count must be >= 0 (got ${rule.count})`);
35
+ }
36
+
37
+ const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
38
+ const effectiveEnd = Math.min(windowEnd, seriesEnd);
39
+ if (rule.seriesStart >= effectiveEnd) return [];
40
+
41
+ // Sort days for deterministic emit order within a week.
42
+ const days = [...rule.days].sort((a, b) => a - b);
43
+
44
+ // Anchor: snap seriesStart to the start of its week (Sunday) in tz.
45
+ const seriesStartParts = utcToWallParts(rule.seriesStart, tz);
46
+ const daysSinceSunday = seriesStartParts.dayOfWeek;
47
+ const weekAnchor = wallTimeToUtc(
48
+ seriesStartParts.year,
49
+ seriesStartParts.month,
50
+ seriesStartParts.day - daysSinceSunday,
51
+ 0,
52
+ tz
53
+ );
54
+
55
+ const out: Occurrence[] = [];
56
+ let count = 0;
57
+ // Step by `weekInterval` weeks at a time. Use addDaysInTz to handle DST
58
+ // correctly (a week is 7 calendar days, not 7*86400000 ms).
59
+ let weekStart = weekAnchor;
60
+ while (weekStart < effectiveEnd) {
61
+ for (const dow of days) {
62
+ // Compute the day-of-week within this week in tz wall time.
63
+ const dayUtc = addDaysInTz(weekStart, dow, tz);
64
+ const dayParts = utcToWallParts(dayUtc, tz);
65
+ if (
66
+ disambiguation === "skip" &&
67
+ isNonexistentWallTime(dayParts.year, dayParts.month, dayParts.day, rule.timeOfDay, tz)
68
+ ) {
69
+ continue;
70
+ }
71
+ const occStart = wallTimeToUtc(
72
+ dayParts.year,
73
+ dayParts.month,
74
+ dayParts.day,
75
+ rule.timeOfDay,
76
+ tz,
77
+ disambiguation
78
+ );
79
+ const occEnd = computeSpanEnd(occStart, rule.timeSpan, tz);
80
+
81
+ // Must be on/after seriesStart and before seriesEnd.
82
+ if (occStart < rule.seriesStart) continue;
83
+ if (occStart >= seriesEnd) continue;
84
+
85
+ if (rule.count != null && count >= rule.count) return out;
86
+ count++;
87
+
88
+ if (intersects(occStart, occEnd, windowStart, windowEnd)) {
89
+ out.push({ startTimestamp: occStart, endTimestamp: occEnd });
90
+ if (out.length > MAX_OCCURRENCES_PER_GENERATE) {
91
+ throw new TooManyOccurrencesError("daysOfWeek");
92
+ }
93
+ }
94
+ }
95
+ weekStart = addDaysInTz(weekStart, 7 * rule.weekInterval, tz);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Re-export so consumers can sanity-check the rule's first day in tz.
101
+ export { dayOfWeekInTz };
@@ -0,0 +1,88 @@
1
+ import type { DaysOfYearRule, Disambiguation } from "../types.js";
2
+ import { computeSpanEnd, isNonexistentWallTime, utcToWallParts, wallTimeToUtc } from "../tz.js";
3
+ import {
4
+ MAX_OCCURRENCES_PER_GENERATE,
5
+ type Occurrence,
6
+ TooManyOccurrencesError,
7
+ intersects,
8
+ } from "./types.js";
9
+
10
+ // Day-of-year recurrence. Day 1 = Jan 1. Day 366 only exists in leap
11
+ // years and is SKIPPED in non-leap years (no clamp — "366th day" means
12
+ // literally that).
13
+ export function generateDaysOfYear(
14
+ rule: DaysOfYearRule,
15
+ tz: string | undefined,
16
+ windowStart: number,
17
+ windowEnd: number,
18
+ disambiguation: Disambiguation = "compatible"
19
+ ): Occurrence[] {
20
+ if (rule.yearInterval <= 0) {
21
+ throw new Error(`DaysOfYearRule.yearInterval must be > 0 (got ${rule.yearInterval})`);
22
+ }
23
+ if (rule.daysOfYear.length === 0) {
24
+ throw new Error("DaysOfYearRule.daysOfYear must be non-empty");
25
+ }
26
+ for (const d of rule.daysOfYear) {
27
+ if (d < 1 || d > 366) {
28
+ throw new Error(`DaysOfYearRule.daysOfYear entries must be in 1..366 (got ${d})`);
29
+ }
30
+ }
31
+ if (rule.count != null && rule.count < 0) {
32
+ throw new Error(`DaysOfYearRule.count must be >= 0 (got ${rule.count})`);
33
+ }
34
+
35
+ const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
36
+ const effectiveEnd = Math.min(windowEnd, seriesEnd);
37
+ if (rule.seriesStart >= effectiveEnd) return [];
38
+
39
+ const days = [...new Set(rule.daysOfYear)].sort((a, b) => a - b);
40
+
41
+ const startYear = utcToWallParts(rule.seriesStart, tz).year;
42
+ const endYear = utcToWallParts(Math.min(effectiveEnd, Number.MAX_SAFE_INTEGER), tz).year;
43
+
44
+ const out: Occurrence[] = [];
45
+ let count = 0;
46
+
47
+ for (let year = startYear; year <= endYear; year += rule.yearInterval) {
48
+ const yearEmits: Occurrence[] = [];
49
+
50
+ for (const dayOfYear of days) {
51
+ // Date.UTC(year, 0, N) treats N as "Nth day from Jan 1". If N is 366
52
+ // in a non-leap year it rolls into year+1; detect and skip.
53
+ const probe = new Date(Date.UTC(year, 0, dayOfYear));
54
+ if (probe.getUTCFullYear() !== year) continue; // day 366 in non-leap year
55
+
56
+ const month = probe.getUTCMonth();
57
+ const day = probe.getUTCDate();
58
+ if (
59
+ disambiguation === "skip" &&
60
+ isNonexistentWallTime(year, month, day, rule.timeOfDay, tz)
61
+ ) {
62
+ continue;
63
+ }
64
+ const occStart = wallTimeToUtc(year, month, day, rule.timeOfDay, tz, disambiguation);
65
+ const occEnd = computeSpanEnd(occStart, rule.timeSpan, tz);
66
+
67
+ if (occStart < rule.seriesStart) continue;
68
+ if (occStart >= seriesEnd) continue;
69
+
70
+ if (rule.count != null && count >= rule.count) {
71
+ yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
72
+ out.push(...yearEmits);
73
+ return out;
74
+ }
75
+ count++;
76
+
77
+ if (intersects(occStart, occEnd, windowStart, windowEnd)) {
78
+ yearEmits.push({ startTimestamp: occStart, endTimestamp: occEnd });
79
+ if (out.length + yearEmits.length > MAX_OCCURRENCES_PER_GENERATE) {
80
+ throw new TooManyOccurrencesError("daysOfYear");
81
+ }
82
+ }
83
+ }
84
+ yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
85
+ out.push(...yearEmits);
86
+ }
87
+ return out;
88
+ }