@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.
- package/LICENSE +21 -0
- package/README.md +73 -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 +7 -0
- package/dist/cjs/index.js +18 -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 +23 -0
- package/dist/cjs/scheduler.js +302 -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/cjs/validate_rule.d.ts +4 -0
- package/dist/cjs/validate_rule.js +267 -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 +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -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 +24 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +241 -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/dist/validate_rule.d.ts +5 -0
- package/dist/validate_rule.d.ts.map +1 -0
- package/dist/validate_rule.js +280 -0
- package/dist/validate_rule.js.map +1 -0
- package/package.json +76 -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__/validate_rule.test.ts +190 -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 +25 -0
- package/src/occurrence_key.ts +42 -0
- package/src/port.ts +31 -0
- package/src/scheduler.ts +353 -0
- package/src/types.ts +369 -0
- package/src/tz.ts +246 -0
- package/src/validate_rule.ts +311 -0
- 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
|
+
}
|