@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,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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { InstantRule } from "../types.js";
|
|
2
|
+
import { computeSpanEnd } from "../tz.js";
|
|
3
|
+
import { type Occurrence, intersects } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// A single occurrence at an absolute UTC instant. The template timezone does
|
|
6
|
+
// not affect the start (the instant is already a fixed point on the timeline);
|
|
7
|
+
// it only flexes an hourSpan end, via computeSpanEnd.
|
|
8
|
+
export function generateInstant(
|
|
9
|
+
rule: InstantRule,
|
|
10
|
+
tz: string | undefined,
|
|
11
|
+
windowStart: number,
|
|
12
|
+
windowEnd: number
|
|
13
|
+
): Occurrence[] {
|
|
14
|
+
const start = rule.startOn;
|
|
15
|
+
const end = computeSpanEnd(start, rule.timeSpan, tz);
|
|
16
|
+
if (!intersects(start, end, windowStart, windowEnd)) return [];
|
|
17
|
+
return [{ startTimestamp: start, endTimestamp: end }];
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Disambiguation, LocalDateTimeRule } from "../types.js";
|
|
2
|
+
import { computeSpanEnd, isNonexistentWallTime, monthLength, wallTimeToUtc } from "../tz.js";
|
|
3
|
+
import { type Occurrence, intersects } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// A single occurrence expressed in wall-clock terms, resolved through the
|
|
6
|
+
// template timezone (DST-aware). `day` clamps to the month length. When tz is
|
|
7
|
+
// undefined the wall-clock fields are interpreted as UTC. A spring-forward gap
|
|
8
|
+
// or fall-back overlap on the start is resolved per `disambiguation` (see
|
|
9
|
+
// tz.ts) — "skip" drops the occurrence entirely if its wall time doesn't exist.
|
|
10
|
+
export function generateLocalDateTime(
|
|
11
|
+
rule: LocalDateTimeRule,
|
|
12
|
+
tz: string | undefined,
|
|
13
|
+
windowStart: number,
|
|
14
|
+
windowEnd: number,
|
|
15
|
+
disambiguation: Disambiguation = "compatible"
|
|
16
|
+
): Occurrence[] {
|
|
17
|
+
if (!Number.isInteger(rule.year)) {
|
|
18
|
+
throw new Error(`LocalDateTimeRule.year must be an integer (got ${rule.year})`);
|
|
19
|
+
}
|
|
20
|
+
if (rule.month < 0 || rule.month > 11) {
|
|
21
|
+
throw new Error(`LocalDateTimeRule.month must be in 0..11 (got ${rule.month})`);
|
|
22
|
+
}
|
|
23
|
+
if (rule.day < 1 || rule.day > 31) {
|
|
24
|
+
throw new Error(`LocalDateTimeRule.day must be in 1..31 (got ${rule.day})`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const day = Math.min(rule.day, monthLength(rule.year, rule.month));
|
|
28
|
+
if (
|
|
29
|
+
disambiguation === "skip" &&
|
|
30
|
+
isNonexistentWallTime(rule.year, rule.month, day, rule.timeOfDay, tz)
|
|
31
|
+
) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const start = wallTimeToUtc(rule.year, rule.month, day, rule.timeOfDay, tz, disambiguation);
|
|
35
|
+
const end = computeSpanEnd(start, rule.timeSpan, tz);
|
|
36
|
+
if (!intersects(start, end, windowStart, windowEnd)) return [];
|
|
37
|
+
return [{ startTimestamp: start, endTimestamp: end }];
|
|
38
|
+
}
|