@warlock.js/scheduler 5.19.1 → 5.20.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/llms-full.txt CHANGED
@@ -6,300 +6,315 @@
6
6
 
7
7
  ## configure-retry-and-overlap `@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`
8
8
 
9
- ---
10
- name: configure-retry-and-overlap
11
- description: "Retry, backoff, and overlap in @warlock.js/scheduler; use when you need to configure retry and overlap."
12
- ---
13
-
14
- # Retry, backoff, and overlap
15
-
16
- Two independent execution-control concerns on a single `Job`. They compose cleanly.
17
-
18
- ## `.retry(maxRetries, delay?, backoffMultiplier?)`
19
-
20
- ```ts
21
- job("send-report", sendReport)
22
- .daily()
23
- .at("08:00")
24
- .retry(3); // 3 retries, 1000ms apart (default delay)
25
-
26
- job("sync", syncInventory)
27
- .everyHour()
28
- .retry(5, 2000); // 5 retries, 2000ms each
29
-
30
- job("queue", processQueue)
31
- .everyMinutes(10)
32
- .retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
33
- ```
34
-
35
- **Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
36
-
37
- **Formula:** delay before attempt `N+1` = `delay × backoffMultiplier^(N-1)`. Without a multiplier, all retries wait `delay` ms.
38
-
39
- **Validation.** Throws at definition time on negative `maxRetries`, negative `delay`, or zero/negative `backoffMultiplier`. `retry(0)` is valid — means "no retries, single attempt."
40
-
41
- ## Retry count surfaces in `JobResult`
42
-
43
- ```ts
44
- scheduler.on("job:complete", (name, result) => {
45
- if (result.retries && result.retries > 0) {
46
- log.warn({ name, retries: result.retries }, "succeeded after retries");
47
- }
48
- });
49
-
50
- scheduler.on("job:error", (name, error) => {
51
- // Fires once, AFTER all retries are exhausted.
52
- log.error({ name, error }, "failed permanently");
53
- });
54
- ```
55
-
56
- `JobResult.retries`:
57
- - On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
58
- - On **failure**: equals the configured `maxRetries` (all of them were used).
59
-
60
- ## After permanent failure — `nextRun` advances normally
61
-
62
- This is load-bearing: a job that exhausts every retry and still throws does **not** re-fire on the next tick. The scheduler:
63
-
64
- 1. Emits `job:error` once with the final error.
65
- 2. Advances `nextRun` by the job's interval, same as success.
66
-
67
- Both branches go through the same `finally`-block path in `Job.run()`. There is no "stuck on retry" mode.
68
-
69
- ```ts
70
- // Fires every 10 minutes, retries 3 times per fire.
71
- // If the 10:00 run exhausts all retries, next run is at 10:10. NOT at 10:00:00.001.
72
- job("flaky", fn).everyMinutes(10).retry(3, 1000);
73
- ```
74
-
75
- To **stop** firing after N consecutive failures, do it in user code:
76
-
77
- ```ts
78
- let consecutiveFailures = 0;
79
- scheduler.on("job:error", name => {
80
- if (name !== "flaky") return;
81
- consecutiveFailures++;
82
- if (consecutiveFailures >= 5) {
83
- scheduler.removeJob("flaky");
84
- alert.critical("flaky disabled after 5 failures");
85
- }
86
- });
87
- scheduler.on("job:complete", name => {
88
- if (name === "flaky") consecutiveFailures = 0;
89
- });
90
- ```
91
-
92
- ## `.preventOverlap(skip = true)`
93
-
94
- Tells the scheduler to skip a tick if the job is already running.
95
-
96
- ```ts
97
- job("queue", processQueue)
98
- .everyMinutes(5)
99
- .preventOverlap();
100
- ```
101
-
102
- **Important nuance.** The scheduler awaits each tick's jobs before scheduling the next tick — so **concurrent same-job runs from the scheduler's own loop are structurally impossible**, regardless of `preventOverlap()`. Where it actually matters: jobs whose callbacks ALSO get invoked outside the scheduler.
103
-
104
- Real-world shapes that benefit:
105
-
106
- ```ts
107
- // 1) Boot-time recovery sweep before normal scheduling.
108
- const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
109
- scheduler.addJob(queueJob);
110
- queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
111
- scheduler.start();
112
-
113
- // 2) Admin-triggered manual re-run from a dashboard endpoint.
114
- app.post("/admin/jobs/:name/run", async (req, res) => {
115
- const j = scheduler.getJob(req.params.name);
116
- await j?.run();
117
- res.sendStatus(204);
118
- });
119
-
120
- // 3) Multiple scheduler instances pointed at the same Job (rare).
121
- ```
122
-
123
- If a tick lands while one of these external runs is mid-flight:
124
- - Scheduler emits `job:skip` with reason `"Job is already running"`
125
- - The tick proceeds without re-entering the job
126
-
127
- For a single-process app where the callback is only ever invoked via the scheduler, `preventOverlap()` is a no-op — but it's free, defensive documentation. Keep it on for any job that touches shared state.
128
-
129
- ## Combining retry + preventOverlap
130
-
131
- They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
132
-
133
- ```ts
134
- job("long-flaky", processQueue)
135
- .everyMinutes(5)
136
- .preventOverlap()
137
- .retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
138
- ```
139
-
140
- If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
141
-
142
- ## See also
143
-
144
- - [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — full event reference, `JobResult` shape, lifecycle
145
- - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the scheduling methods these compose with
9
+ ---
10
+ name: configure-retry-and-overlap
11
+ description: 'Configure `.retry(maxRetries, delay?, backoffMultiplier?)` for fixed / exponential backoff and `.preventOverlap()` for external-invocation safety. Triggers: `.retry`, `.preventOverlap`, `maxRetries`, `backoffMultiplier`, `JobResult.retries`, `job:error`, `job:skip`; "how do I retry a failed job", "exponential backoff for scheduled job", "prevent concurrent job runs", "stop firing after N consecutive failures"; typical import `import { job, scheduler } from "@warlock.js/scheduler"`. Skip: events / shutdown — `@warlock.js/scheduler/observe-scheduler/SKILL.md`; building the schedule itself — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; competing libs `p-retry`, `async-retry`, `bullmq`.'
12
+ ---
13
+
14
+ # Retry, backoff, and overlap
15
+
16
+ Two independent execution-control concerns on a single `Job`. They compose cleanly.
17
+
18
+ ## `.retry(maxRetries, delay?, backoffMultiplier?)`
19
+
20
+ ```ts
21
+ job("send-report", sendReport)
22
+ .daily()
23
+ .at("08:00")
24
+ .retry(3); // 3 retries, 1000ms apart (default delay)
25
+
26
+ job("sync", syncInventory)
27
+ .everyHour()
28
+ .retry(5, 2000); // 5 retries, 2000ms each
29
+
30
+ job("queue", processQueue)
31
+ .everyMinutes(10)
32
+ .retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
33
+ ```
34
+
35
+ **Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
36
+
37
+ **Formula:** delay before attempt `N+1` = `delay × backoffMultiplier^(N-1)`. Without a multiplier, all retries wait `delay` ms.
38
+
39
+ **Validation.** Throws at definition time on negative `maxRetries`, negative `delay`, or zero/negative `backoffMultiplier`. `retry(0)` is valid — means "no retries, single attempt."
40
+
41
+ ## Retry count surfaces in `JobResult`
42
+
43
+ ```ts
44
+ scheduler.on("job:complete", (name, result) => {
45
+ if (result.retries && result.retries > 0) {
46
+ log.warn({ name, retries: result.retries }, "succeeded after retries");
47
+ }
48
+ });
49
+
50
+ scheduler.on("job:error", (name, error) => {
51
+ // Fires once, AFTER all retries are exhausted.
52
+ log.error({ name, error }, "failed permanently");
53
+ });
54
+ ```
55
+
56
+ `JobResult.retries`:
57
+ - On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
58
+ - On **failure**: equals the configured `maxRetries` (all of them were used).
59
+
60
+ ## After permanent failure — `nextRun` advances normally
61
+
62
+ This is load-bearing: a job that exhausts every retry and still throws does **not** re-fire on the next tick. The scheduler:
63
+
64
+ 1. Emits `job:error` once with the final error.
65
+ 2. Advances `nextRun` by the job's interval, same as success.
66
+
67
+ Both branches go through the same `finally`-block path in `Job.run()`. There is no "stuck on retry" mode.
68
+
69
+ ```ts
70
+ // Fires every 10 minutes, retries 3 times per fire.
71
+ // If the 10:00 run exhausts all retries, next run is at 10:10. NOT at 10:00:00.001.
72
+ job("flaky", fn).everyMinutes(10).retry(3, 1000);
73
+ ```
74
+
75
+ To **stop** firing after N consecutive failures, do it in user code:
76
+
77
+ ```ts
78
+ let consecutiveFailures = 0;
79
+ scheduler.on("job:error", name => {
80
+ if (name !== "flaky") return;
81
+ consecutiveFailures++;
82
+ if (consecutiveFailures >= 5) {
83
+ scheduler.removeJob("flaky");
84
+ alert.critical("flaky disabled after 5 failures");
85
+ }
86
+ });
87
+ scheduler.on("job:complete", name => {
88
+ if (name === "flaky") consecutiveFailures = 0;
89
+ });
90
+ ```
91
+
92
+ ## `.preventOverlap(skip = true)`
93
+
94
+ Tells the scheduler to skip a tick if the job is already running.
95
+
96
+ ```ts
97
+ job("queue", processQueue)
98
+ .everyMinutes(5)
99
+ .preventOverlap();
100
+ ```
101
+
102
+ **Important nuance.** The scheduler awaits each tick's jobs before scheduling the next tick — so **concurrent same-job runs from the scheduler's own loop are structurally impossible**, regardless of `preventOverlap()`. Where it actually matters: jobs whose callbacks ALSO get invoked outside the scheduler.
103
+
104
+ Real-world shapes that benefit:
105
+
106
+ ```ts
107
+ // 1) Boot-time recovery sweep before normal scheduling.
108
+ const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
109
+ scheduler.addJob(queueJob);
110
+ queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
111
+ scheduler.start();
112
+
113
+ // 2) Admin-triggered manual re-run from a dashboard endpoint.
114
+ app.post("/admin/jobs/:name/run", async (req, res) => {
115
+ const j = scheduler.getJob(req.params.name);
116
+ await j?.run();
117
+ res.sendStatus(204);
118
+ });
119
+
120
+ // 3) Multiple scheduler instances pointed at the same Job (rare).
121
+ ```
122
+
123
+ If a tick lands while one of these external runs is mid-flight:
124
+ - Scheduler emits `job:skip` with reason `"Job is already running"`
125
+ - The tick proceeds without re-entering the job
126
+
127
+ For a single-process app where the callback is only ever invoked via the scheduler, `preventOverlap()` is a no-op — but it's free, defensive documentation. Keep it on for any job that touches shared state.
128
+
129
+ ## Combining retry + preventOverlap
130
+
131
+ They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
132
+
133
+ ```ts
134
+ job("long-flaky", processQueue)
135
+ .everyMinutes(5)
136
+ .preventOverlap()
137
+ .retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
138
+ ```
139
+
140
+ If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
141
+
142
+ ## See also
143
+
144
+ - [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — full event reference, `JobResult` shape, lifecycle
145
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the scheduling methods these compose with
146
+
147
+ ## Running on one server only: `onOneServer()`
148
+
149
+ `preventOverlap()` is in-process. With several app instances, add `onOneServer()` so only one instance runs each tick:
150
+
151
+ ```ts
152
+ job("nightly-report", buildReport).daily().at("02:00").onOneServer().preventOverlap();
153
+ job("sync", sync).everyMinutes(5).onOneServer({ lockTtl: "10m", key: "sync-v2" });
154
+ ```
155
+
156
+ - Claim key: `scheduler.<key ?? name>.<scheduledTickEpochMs>` (scheduled tick time truncated to the second, so every server derives the same key). Losers skip the tick and the scheduler emits `job:skip`.
157
+ - It is a **claim, not a lock**: a create-only, TTL-bounded cache entry that is never released, only expires. That guarantees one run per tick even if another server's timer fires late, after the winner finished.
158
+ - Default claim TTL: `min(interval, 1h)` with a 60s floor (1h for cron jobs). Override with `lockTtl`.
159
+ - Needs the optional `@warlock.js/cache` peer and a shared driver (redis/pg). On the memory driver it only dedupes within one process.
160
+ - Throws at call time if the job has no name/key.
146
161
 
147
162
 
148
163
  ## observe-scheduler `@warlock.js/scheduler/observe-scheduler/SKILL.md`
149
164
 
150
- ---
151
- name: observe-scheduler
152
- description: "Observability and lifecycle in @warlock.js/scheduler; use when you need to observe scheduler."
153
- ---
154
-
155
- # Observability and lifecycle
156
-
157
- The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire listeners before calling `.start()`.
158
-
159
- ## All seven events
160
-
161
- ```ts
162
- type SchedulerEvents = {
163
- "job:start": [jobName: string];
164
- "job:complete": [jobName: string, result: JobResult];
165
- "job:error": [jobName: string, error: unknown];
166
- "job:skip": [jobName: string, reason: string];
167
- "scheduler:started": [];
168
- "scheduler:stopped": [];
169
- "scheduler:tick": [timestamp: Date];
170
- };
171
- ```
172
-
173
- | Event | When it fires |
174
- | -------------------- | -------------------------------------------------------------------------------------- |
175
- | `scheduler:started` | Once, after `.start()` enters the tick loop |
176
- | `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
177
- | `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
178
- | `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
179
- | `job:complete` | When a job finishes successfully (possibly after retries) |
180
- | `job:error` | When a job exhausts all retries and the final attempt still throws |
181
- | `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
182
-
183
- **Contract for retries.** `job:start` and `job:complete`/`job:error` fire **once per fire**, not once per retry attempt. The retry count surfaces inside the `JobResult` passed to `job:complete`, or as `result.retries === maxRetries` on the failure path.
184
-
185
- ## `JobResult` shape
186
-
187
- ```ts
188
- type JobResult = {
189
- success: boolean;
190
- duration: number; // milliseconds, end-to-end including retry waits
191
- error?: unknown; // present when success === false
192
- retries?: number; // attempts that failed before the final outcome
193
- };
194
- ```
195
-
196
- `duration` is wall-clock from the first attempt's start to the final settlement — useful for both p50 metrics and capacity planning.
197
-
198
- ## Subscribing
199
-
200
- ```ts
201
- import type { JobResult } from "@warlock.js/scheduler";
202
- import { scheduler } from "@warlock.js/scheduler";
203
-
204
- scheduler.on("job:start", name => log.debug({ name }, "job start"));
205
- scheduler.on("job:complete", (name, result: JobResult) => {
206
- log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
207
- });
208
- scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
209
- scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
210
- ```
211
-
212
- `.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
213
-
214
- ## Lifecycle methods
215
-
216
- ### `.start()`
217
-
218
- Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
219
-
220
- Throws if:
221
- - already running (`"Scheduler is already running."`)
222
- - zero jobs registered (`"Cannot start scheduler with no jobs."`)
223
-
224
- ### `.stop()`
225
-
226
- Immediately clears the next-tick timer. Does NOT wait for jobs currently mid-execution. Emits `scheduler:stopped`. **No-op if not running** — calling `.stop()` on a never-started or already-stopped scheduler is safe and silent.
227
-
228
- ### `.shutdown(timeout = 30000)`
229
-
230
- Graceful equivalent of `.stop()`:
231
-
232
- 1. Marks the scheduler as shutting down (no new ticks scheduled).
233
- 2. Calls `.stop()` internally (so `scheduler:stopped` fires).
234
- 3. Awaits every currently-running job's `waitForCompletion()`, capped by `timeout`.
235
- 4. Resolves either when all jobs finish or when the timeout elapses.
236
-
237
- ```ts
238
- process.on("SIGTERM", async () => {
239
- await scheduler.shutdown(30_000);
240
- process.exit(0);
241
- });
242
- ```
243
-
244
- The timeout is a HARD cap — jobs still running after it are abandoned (their promises keep resolving in the background, but the scheduler doesn't await them). The package does not currently support per-job timeouts or `AbortSignal` cancellation.
245
-
246
- ## Production observer pattern
247
-
248
- Wire all observers before `start()`, in one place:
249
-
250
- ```ts
251
- import { scheduler } from "@warlock.js/scheduler";
252
- import { logger } from "./logger";
253
- import { metrics } from "./metrics";
254
- import { alerts } from "./alerts";
255
-
256
- scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
257
-
258
- scheduler.on("job:complete", (name, result) => {
259
- metrics.increment(`job.success.${name}`);
260
- metrics.timing(`job.duration.${name}`, result.duration);
261
- if (result.retries) metrics.increment(`job.retries.${name}`, result.retries);
262
- });
263
-
264
- scheduler.on("job:error", (name, error) => {
265
- logger.error({ name, error }, "Job failed permanently");
266
- alerts.critical(`Scheduler job "${name}" failed`, error);
267
- });
268
-
269
- scheduler.on("job:skip", (name, reason) => {
270
- logger.warn({ name, reason }, "Job skipped — likely external invocation in flight");
271
- });
272
-
273
- scheduler.on("scheduler:started", () => logger.info("scheduler up"));
274
- scheduler.on("scheduler:stopped", () => logger.info("scheduler down"));
275
-
276
- // Register jobs, then:
277
- scheduler.start();
278
- ```
279
-
280
- ## Inspection at runtime
281
-
282
- ```ts
283
- scheduler.isRunning; // boolean
284
- scheduler.jobCount; // number of registered jobs
285
- scheduler.list(); // readonly Job[]
286
- scheduler.getJob("name"); // Job | undefined
287
- ```
288
-
289
- And on each `Job`:
290
-
291
- ```ts
292
- job.nextRun; // Dayjs | null
293
- job.lastRun; // Dayjs | null — success OR failure
294
- job.isRunning; // boolean
295
- job.intervals; // readonly schedule config
296
- job.cronExpression; // string | null
297
- ```
298
-
299
- ## See also
300
-
301
- - [`@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md) — what triggers each event in detail
302
- - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the methods that mutate the state these getters expose
165
+ ---
166
+ name: observe-scheduler
167
+ description: 'Subscribe to scheduler lifecycle and job events for logging / metrics / alerting / graceful shutdown — seven typed `SchedulerEvents`, `JobResult` payload, `start()` / `stop()` / `shutdown()`. Triggers: `scheduler.on`, `scheduler.shutdown`, `JobResult`, `job:complete`, `job:error`, `scheduler:started`, `scheduler.getJob`; "graceful SIGTERM shutdown", "did this job run", "scheduler metrics and alerts", "subscribe to job events"; typical import `import { scheduler, type JobResult } from "@warlock.js/scheduler"`. Skip: retry / overlap — `@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`; building schedules — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; native `EventEmitter`, `process.on`.'
168
+ ---
169
+
170
+ # Observability and lifecycle
171
+
172
+ The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire listeners before calling `.start()`.
173
+
174
+ ## All seven events
175
+
176
+ ```ts
177
+ type SchedulerEvents = {
178
+ "job:start": [jobName: string];
179
+ "job:complete": [jobName: string, result: JobResult];
180
+ "job:error": [jobName: string, error: unknown];
181
+ "job:skip": [jobName: string, reason: string];
182
+ "scheduler:started": [];
183
+ "scheduler:stopped": [];
184
+ "scheduler:tick": [timestamp: Date];
185
+ };
186
+ ```
187
+
188
+ | Event | When it fires |
189
+ | -------------------- | -------------------------------------------------------------------------------------- |
190
+ | `scheduler:started` | Once, after `.start()` enters the tick loop |
191
+ | `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
192
+ | `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
193
+ | `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
194
+ | `job:complete` | When a job finishes successfully (possibly after retries) |
195
+ | `job:error` | When a job exhausts all retries and the final attempt still throws |
196
+ | `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
197
+
198
+ **Contract for retries.** `job:start` and `job:complete`/`job:error` fire **once per fire**, not once per retry attempt. The retry count surfaces inside the `JobResult` passed to `job:complete`, or as `result.retries === maxRetries` on the failure path.
199
+
200
+ ## `JobResult` shape
201
+
202
+ ```ts
203
+ type JobResult = {
204
+ success: boolean;
205
+ duration: number; // milliseconds, end-to-end including retry waits
206
+ error?: unknown; // present when success === false
207
+ retries?: number; // attempts that failed before the final outcome
208
+ };
209
+ ```
210
+
211
+ `duration` is wall-clock from the first attempt's start to the final settlement — useful for both p50 metrics and capacity planning.
212
+
213
+ ## Subscribing
214
+
215
+ ```ts
216
+ import type { JobResult } from "@warlock.js/scheduler";
217
+ import { scheduler } from "@warlock.js/scheduler";
218
+
219
+ scheduler.on("job:start", name => log.debug({ name }, "job start"));
220
+ scheduler.on("job:complete", (name, result: JobResult) => {
221
+ log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
222
+ });
223
+ scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
224
+ scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
225
+ ```
226
+
227
+ `.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
228
+
229
+ ## Lifecycle methods
230
+
231
+ ### `.start()`
232
+
233
+ Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
234
+
235
+ Throws if:
236
+ - already running (`"Scheduler is already running."`)
237
+ - zero jobs registered (`"Cannot start scheduler with no jobs."`)
238
+
239
+ ### `.stop()`
240
+
241
+ Immediately clears the next-tick timer. Does NOT wait for jobs currently mid-execution. Emits `scheduler:stopped`. **No-op if not running** — calling `.stop()` on a never-started or already-stopped scheduler is safe and silent.
242
+
243
+ ### `.shutdown(timeout = 30000)`
244
+
245
+ Graceful equivalent of `.stop()`:
246
+
247
+ 1. Marks the scheduler as shutting down (no new ticks scheduled).
248
+ 2. Calls `.stop()` internally (so `scheduler:stopped` fires).
249
+ 3. Awaits every currently-running job's `waitForCompletion()`, capped by `timeout`.
250
+ 4. Resolves either when all jobs finish or when the timeout elapses.
251
+
252
+ ```ts
253
+ process.on("SIGTERM", async () => {
254
+ await scheduler.shutdown(30_000);
255
+ process.exit(0);
256
+ });
257
+ ```
258
+
259
+ The timeout is a HARD cap — jobs still running after it are abandoned (their promises keep resolving in the background, but the scheduler doesn't await them). The package does not currently support per-job timeouts or `AbortSignal` cancellation.
260
+
261
+ ## Production observer pattern
262
+
263
+ Wire all observers before `start()`, in one place:
264
+
265
+ ```ts
266
+ import { scheduler } from "@warlock.js/scheduler";
267
+ import { logger } from "./logger";
268
+ import { metrics } from "./metrics";
269
+ import { alerts } from "./alerts";
270
+
271
+ scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
272
+
273
+ scheduler.on("job:complete", (name, result) => {
274
+ metrics.increment(`job.success.${name}`);
275
+ metrics.timing(`job.duration.${name}`, result.duration);
276
+ if (result.retries) metrics.increment(`job.retries.${name}`, result.retries);
277
+ });
278
+
279
+ scheduler.on("job:error", (name, error) => {
280
+ logger.error({ name, error }, "Job failed permanently");
281
+ alerts.critical(`Scheduler job "${name}" failed`, error);
282
+ });
283
+
284
+ scheduler.on("job:skip", (name, reason) => {
285
+ logger.warn({ name, reason }, "Job skipped — likely external invocation in flight");
286
+ });
287
+
288
+ scheduler.on("scheduler:started", () => logger.info("scheduler up"));
289
+ scheduler.on("scheduler:stopped", () => logger.info("scheduler down"));
290
+
291
+ // Register jobs, then:
292
+ scheduler.start();
293
+ ```
294
+
295
+ ## Inspection at runtime
296
+
297
+ ```ts
298
+ scheduler.isRunning; // boolean
299
+ scheduler.jobCount; // number of registered jobs
300
+ scheduler.list(); // readonly Job[]
301
+ scheduler.getJob("name"); // Job | undefined
302
+ ```
303
+
304
+ And on each `Job`:
305
+
306
+ ```ts
307
+ job.nextRun; // Dayjs | null
308
+ job.lastRun; // Dayjs | null — success OR failure
309
+ job.isRunning; // boolean
310
+ job.intervals; // readonly schedule config
311
+ job.cronExpression; // string | null
312
+ ```
313
+
314
+ ## See also
315
+
316
+ - [`@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md) — what triggers each event in detail
317
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the methods that mutate the state these getters expose
303
318
 
304
319
 
305
320
  ## overview `@warlock.js/scheduler/overview/SKILL.md`
@@ -404,392 +419,392 @@ process.on("SIGTERM", () => scheduler.shutdown());
404
419
 
405
420
  ## pin-schedule-timezone `@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`
406
421
 
407
- ---
408
- name: pin-schedule-timezone
409
- description: "Per-job timezones in @warlock.js/scheduler; use when you need to pin schedule timezone."
410
- ---
411
-
412
- # Per-job timezones
413
-
414
- Every `Job` has its own timezone. The default is **UTC** — `.daily().at("09:00")` fires at 09:00 UTC, regardless of the server's locale or system timezone. Pin to wall-clock time with `.inTimezone(IANA string)`.
415
-
416
- ## Basic usage
417
-
418
- ```ts
419
- job("morning-digest", sendDailyDigest)
420
- .daily()
421
- .at("08:00")
422
- .inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
423
- ```
424
-
425
- `.inTimezone()` is chainable — typically placed after the time/day methods.
426
-
427
- ## Why the default is UTC
428
-
429
- A server runs wherever ops put it (Frankfurt, Virginia, GCP us-central1, …). System-local time is unpredictable; UTC is stable. The framework picks the stable default so that `daily().at("09:00")` without further config is reproducible across deployments. Pin to wall-clock time when business hours actually matter.
430
-
431
- ## Multi-region fan-out
432
-
433
- Same logical task, three regions:
434
-
435
- ```ts
436
- import { scheduler, job } from "@warlock.js/scheduler";
437
-
438
- const regions = [
439
- { name: "us-east", tz: "America/New_York" },
440
- { name: "eu-west", tz: "Europe/Berlin" },
441
- { name: "asia", tz: "Asia/Tokyo" },
442
- ];
443
-
444
- for (const { name, tz } of regions) {
445
- scheduler.addJob(
446
- job(`morning-report-${name}`, () => sendReport(name))
447
- .daily()
448
- .at("09:00")
449
- .inTimezone(tz)
450
- );
451
- }
452
-
453
- scheduler.start();
454
- ```
455
-
456
- Three separate `Job` instances, three separate `nextRun` calculations. Each fires once per day at its region's 09:00.
457
-
458
- ## DST is automatic
459
-
460
- dayjs handles transitions correctly. A daily 09:00 job in `America/New_York` shifts between 13:00 UTC (EST winter) and 14:00 UTC (EDT summer) without intervention. The same job in a fixed-offset region (`Asia/Tokyo`, `Africa/Algiers`) stays at a constant UTC offset.
461
-
462
- ## Common IANA strings
463
-
464
- | Region | TZ string |
465
- | ------------------- | --------------------------- |
466
- | UTC | `UTC` |
467
- | US Eastern | `America/New_York` |
468
- | US Central | `America/Chicago` |
469
- | US Mountain | `America/Denver` |
470
- | US Pacific | `America/Los_Angeles` |
471
- | UK / Ireland | `Europe/London` |
472
- | Central Europe | `Europe/Berlin` |
473
- | Eastern Europe | `Europe/Kyiv` |
474
- | India | `Asia/Kolkata` |
475
- | Japan | `Asia/Tokyo` |
476
- | Australia (Sydney) | `Australia/Sydney` |
477
- | Egypt | `Africa/Cairo` |
478
-
479
- Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
480
-
481
- ## Interaction with `at()`, `on()`, cron
482
-
483
- The timezone applies to every interpretation of "now" and every constraint:
484
-
485
- ```ts
486
- job("monday-standup", task)
487
- .weekly()
488
- .on("monday")
489
- .at("09:00")
490
- .inTimezone("Europe/Berlin");
491
- // "Monday in Berlin local time" + "09:00 Berlin local time"
492
- // — automatically shifts CET ↔ CEST as DST changes.
493
-
494
- job("nightly-cron", task)
495
- .cron("0 3 * * *")
496
- .inTimezone("Asia/Tokyo");
497
- // "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
498
- ```
499
-
500
- ## Validation
501
-
502
- `.inTimezone()` stores the string as-is, then immediately recomputes `nextRun` — and that recompute calls dayjs with the zone. An invalid IANA string makes dayjs throw a `RangeError: Invalid time zone specified` **synchronously, at that point in the chain**:
503
-
504
- ```ts
505
- // Throws right here — the .inTimezone() call recomputes nextRun, which
506
- // hits dayjs().tz("Asia/Whatever") and raises RangeError immediately.
507
- const j = job("t", task).daily().at("09:00").inTimezone("Asia/Whatever");
508
- ```
509
-
510
- A valid zone computes `nextRun` on the spot (non-null after the chain). So a typo fails fast at definition time, not at the first tick — no special unit test needed to surface it.
511
-
512
- ## Server-side caveats
513
-
514
- Node ships full ICU on most platforms, so all IANA zones work out of the box. If you're running a small-ICU build (alpine without full ICU, or old Node versions with `--icu=small`), only a limited set of zones is recognized. Set the `NODE_ICU_DATA` env var or use the `full-icu` package in that case.
515
-
516
- ## See also
517
-
518
- - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — `at()`, `on()`, `daily()`, etc.
519
- - [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — cron schedules are timezone-aware too
520
- - [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — `job.nextRun.toISOString()` always renders in UTC; format with `.tz(zone)` to see local time
422
+ ---
423
+ name: pin-schedule-timezone
424
+ description: 'Pin a job to a specific IANA timezone via `.inTimezone(zone)` so its wall-clock fire time stays correct regardless of server location / DST. Triggers: `.inTimezone`, `America/New_York`, `Europe/Berlin`, `Asia/Tokyo`, `NODE_ICU_DATA`; "schedule job in a timezone", "DST drift on non-UTC server", "multi-region fan-out scheduling", "fire at 9am ET regardless of server"; typical import `import { job, scheduler } from "@warlock.js/scheduler"`. Skip: fluent interval methods — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; cron syntax — `@warlock.js/scheduler/schedule-with-cron/SKILL.md`; competing libs `dayjs-timezone`, `luxon`, `date-fns-tz`.'
425
+ ---
426
+
427
+ # Per-job timezones
428
+
429
+ Every `Job` has its own timezone. The default is **UTC** — `.daily().at("09:00")` fires at 09:00 UTC, regardless of the server's locale or system timezone. Pin to wall-clock time with `.inTimezone(IANA string)`.
430
+
431
+ ## Basic usage
432
+
433
+ ```ts
434
+ job("morning-digest", sendDailyDigest)
435
+ .daily()
436
+ .at("08:00")
437
+ .inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
438
+ ```
439
+
440
+ `.inTimezone()` is chainable — typically placed after the time/day methods.
441
+
442
+ ## Why the default is UTC
443
+
444
+ A server runs wherever ops put it (Frankfurt, Virginia, GCP us-central1, …). System-local time is unpredictable; UTC is stable. The framework picks the stable default so that `daily().at("09:00")` without further config is reproducible across deployments. Pin to wall-clock time when business hours actually matter.
445
+
446
+ ## Multi-region fan-out
447
+
448
+ Same logical task, three regions:
449
+
450
+ ```ts
451
+ import { scheduler, job } from "@warlock.js/scheduler";
452
+
453
+ const regions = [
454
+ { name: "us-east", tz: "America/New_York" },
455
+ { name: "eu-west", tz: "Europe/Berlin" },
456
+ { name: "asia", tz: "Asia/Tokyo" },
457
+ ];
458
+
459
+ for (const { name, tz } of regions) {
460
+ scheduler.addJob(
461
+ job(`morning-report-${name}`, () => sendReport(name))
462
+ .daily()
463
+ .at("09:00")
464
+ .inTimezone(tz)
465
+ );
466
+ }
467
+
468
+ scheduler.start();
469
+ ```
470
+
471
+ Three separate `Job` instances, three separate `nextRun` calculations. Each fires once per day at its region's 09:00.
472
+
473
+ ## DST is automatic
474
+
475
+ dayjs handles transitions correctly. A daily 09:00 job in `America/New_York` shifts between 13:00 UTC (EST winter) and 14:00 UTC (EDT summer) without intervention. The same job in a fixed-offset region (`Asia/Tokyo`, `Africa/Algiers`) stays at a constant UTC offset.
476
+
477
+ ## Common IANA strings
478
+
479
+ | Region | TZ string |
480
+ | ------------------- | --------------------------- |
481
+ | UTC | `UTC` |
482
+ | US Eastern | `America/New_York` |
483
+ | US Central | `America/Chicago` |
484
+ | US Mountain | `America/Denver` |
485
+ | US Pacific | `America/Los_Angeles` |
486
+ | UK / Ireland | `Europe/London` |
487
+ | Central Europe | `Europe/Berlin` |
488
+ | Eastern Europe | `Europe/Kyiv` |
489
+ | India | `Asia/Kolkata` |
490
+ | Japan | `Asia/Tokyo` |
491
+ | Australia (Sydney) | `Australia/Sydney` |
492
+ | Egypt | `Africa/Cairo` |
493
+
494
+ Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
495
+
496
+ ## Interaction with `at()`, `on()`, cron
497
+
498
+ The timezone applies to every interpretation of "now" and every constraint:
499
+
500
+ ```ts
501
+ job("monday-standup", task)
502
+ .weekly()
503
+ .on("monday")
504
+ .at("09:00")
505
+ .inTimezone("Europe/Berlin");
506
+ // "Monday in Berlin local time" + "09:00 Berlin local time"
507
+ // — automatically shifts CET ↔ CEST as DST changes.
508
+
509
+ job("nightly-cron", task)
510
+ .cron("0 3 * * *")
511
+ .inTimezone("Asia/Tokyo");
512
+ // "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
513
+ ```
514
+
515
+ ## Validation
516
+
517
+ `.inTimezone()` stores the string as-is, then immediately recomputes `nextRun` — and that recompute calls dayjs with the zone. An invalid IANA string makes dayjs throw a `RangeError: Invalid time zone specified` **synchronously, at that point in the chain**:
518
+
519
+ ```ts
520
+ // Throws right here — the .inTimezone() call recomputes nextRun, which
521
+ // hits dayjs().tz("Asia/Whatever") and raises RangeError immediately.
522
+ const j = job("t", task).daily().at("09:00").inTimezone("Asia/Whatever");
523
+ ```
524
+
525
+ A valid zone computes `nextRun` on the spot (non-null after the chain). So a typo fails fast at definition time, not at the first tick — no special unit test needed to surface it.
526
+
527
+ ## Server-side caveats
528
+
529
+ Node ships full ICU on most platforms, so all IANA zones work out of the box. If you're running a small-ICU build (alpine without full ICU, or old Node versions with `--icu=small`), only a limited set of zones is recognized. Set the `NODE_ICU_DATA` env var or use the `full-icu` package in that case.
530
+
531
+ ## See also
532
+
533
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — `at()`, `on()`, `daily()`, etc.
534
+ - [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — cron schedules are timezone-aware too
535
+ - [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — `job.nextRun.toISOString()` always renders in UTC; format with `.tz(zone)` to see local time
521
536
 
522
537
 
523
538
  ## schedule-fluently `@warlock.js/scheduler/schedule-fluently/SKILL.md`
524
539
 
525
- ---
526
- name: schedule-fluently
527
- description: "Fluent scheduling API in @warlock.js/scheduler; use when you need to schedule fluently."
528
- ---
529
-
530
- # Fluent scheduling API
531
-
532
- Chainable methods on a `Job` instance. Each method returns `this` for chaining; each call recomputes `job.nextRun` on the spot.
533
-
534
- ## Preset intervals
535
-
536
- | Method | Equivalent of | Notes |
537
- | -------------------- | ------------------------ | ------------------------------ |
538
- | `.everySecond()` | `.every(1, "second")` | High frequency — use sparingly |
539
- | `.everySeconds(N)` | `.every(N, "second")` | |
540
- | `.everyMinute()` | `.every(1, "minute")` | |
541
- | `.everyMinutes(N)` | `.every(N, "minute")` | |
542
- | `.everyHour()` | `.every(1, "hour")` | |
543
- | `.everyHours(N)` | `.every(N, "hour")` | |
544
- | `.everyDay()` | `.every(1, "day")` | Same as `.daily()` |
545
- | `.daily()` | `.every(1, "day")` | |
546
- | `.twiceDaily()` | `.every(12, "hour")` | Every 12 hours |
547
- | `.everyWeek()` | `.every(1, "week")` | Same as `.weekly()` |
548
- | `.weekly()` | `.every(1, "week")` | |
549
- | `.everyMonth()` | `.every(1, "month")` | Same as `.monthly()` |
550
- | `.monthly()` | `.every(1, "month")` | |
551
- | `.everyYear()` | `.every(1, "year")` | Same as `.yearly()` |
552
- | `.yearly()` | `.every(1, "year")` | |
553
- | `.always()` | `.every(1, "minute")` | Continuous "tick" jobs |
554
-
555
- ## Custom intervals — `.every(value, unit)`
556
-
557
- ```ts
558
- job("t", task).every(5, "minute");
559
- job("t", task).every(2, "hour");
560
- job("t", task).every(3, "day");
561
- ```
562
-
563
- Units: `"second" | "minute" | "hour" | "day" | "week" | "month" | "year"`.
564
-
565
- **Validation.** `every(value, unit)` throws at definition time if `value` is `0`, negative, `NaN`, or `Infinity`. This guards against the misconfigured-interval class of bugs that would otherwise spin the scheduler.
566
-
567
- ## Target a specific time — `.at("HH:mm" | "HH:mm:ss")`
568
-
569
- ```ts
570
- job("nightly", fn).daily().at("03:00");
571
- job("midday", fn).daily().at("12:30:15");
572
- ```
573
-
574
- **Validation.** `.at()` throws if the format is malformed (`"foo"`, `"9-30"`), or any component is out of range (hour > 23, minute > 59, second > 59).
575
-
576
- ## Target a specific day — `.on(day)`
577
-
578
- Day-of-week (string) or day-of-month (number 1–31):
579
-
580
- ```ts
581
- job("monday-standup", task).weekly().on("monday");
582
- job("mid-month-sync", task).monthly().on(15);
583
- job("end-of-month", task).monthly().on(31); // see beginOf/endOf for the right way
584
- ```
585
-
586
- Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`.
587
-
588
- **Validation.** Numeric `on(N)` throws if `N < 1 || N > 31`.
589
-
590
- **Gotcha.** `monthly().on(31)` clamps to the actual month length in dayjs — February runs are unpredictable. Use `endOf("month")` for "last day" semantics instead.
591
-
592
- ## Boundary shortcuts — `.beginOf(type)` / `.endOf(type)`
593
-
594
- Both accept `"day" | "month" | "year"`.
595
-
596
- | Method | Fires at |
597
- | -------------------- | ------------------------------------------------- |
598
- | `beginOf("day")` | 00:00 every day |
599
- | `endOf("day")` | 23:59 every day |
600
- | `beginOf("month")` | 1st of every month at 00:00 |
601
- | `endOf("month")` | Last day of every month at 23:59 (**dynamic**) |
602
- | `beginOf("year")` | January 1 at 00:00 every year |
603
- | `endOf("year")` | December 31 at 23:59 every year |
604
-
605
- **`endOf("month")` is dynamic** — recomputes per cycle, so a job defined in February (28 days) still fires on March 31, April 30, etc. Leap years pick Feb 29 correctly.
606
-
607
- **`beginOf("year")` / `endOf("year")` lock the month** — always Jan 1 / Dec 31, never "1st/31st of whatever month the job was defined in."
608
-
609
- ## Cron is an alternative, not an addition
610
-
611
- `.cron("…")` clears any prior interval/`at`/`on`/`beginOf`/`endOf` config — and vice versa. They're mutually exclusive. See [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md).
612
-
613
- ## Chaining order doesn't matter (for the most part)
614
-
615
- Every fluent method recomputes `nextRun` at the end. Putting `.at()` before `.daily()` produces the same schedule as putting `.daily()` before `.at()` — but for readability, the conventional order is **interval → day → time → timezone → execution options**:
616
-
617
- ```ts
618
- job("good", fn)
619
- .weekly() // interval
620
- .on("monday") // day
621
- .at("09:00") // time
622
- .inTimezone("UTC") // timezone
623
- .preventOverlap() // execution
624
- .retry(3, 1000); // execution
625
- ```
626
-
627
- ## Inline registration — `scheduler.newJob()`
628
-
629
- For one-liners, the scheduler exposes a `newJob()` shortcut that creates, registers, and returns the job in one call:
630
-
631
- ```ts
632
- scheduler
633
- .newJob("cleanup", cleanupFn)
634
- .daily()
635
- .at("03:00");
636
- ```
637
-
638
- To register several pre-built jobs at once, `scheduler.addJobs([...])` is the batch counterpart to `addJob` (both are chainable and preserve insertion order):
639
-
640
- ```ts
641
- scheduler.addJobs([
642
- job("cleanup", cleanupFn).daily().at("03:00"),
643
- job("reports", sendReports).weekly().on("monday").at("09:00"),
644
- ]);
645
- ```
646
-
647
- If the scheduler is already running, every job added via `addJob` / `addJobs` is prepared on the spot (its `nextRun` is computed) so it fires on the next tick.
648
-
649
- ## Reading state at runtime
650
-
651
- ```ts
652
- const j = scheduler.getJob("nightly-cleanup");
653
-
654
- j?.nextRun?.toISOString(); // next scheduled run (Dayjs)
655
- j?.lastRun?.toISOString(); // last attempt — success OR failure
656
- j?.isRunning; // currently executing?
657
- j?.intervals; // { every?, day?, dayOfMonthMode?, month?, time? } (readonly)
658
- j?.cronExpression; // null if using fluent API
659
- ```
660
-
661
- ## See also
662
-
663
- - [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — when the fluent API isn't enough
664
- - [`@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md) — `.retry()` and `.preventOverlap()` on the same job
665
- - [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md) — `.inTimezone()`
540
+ ---
541
+ name: schedule-fluently
542
+ description: 'Build a job schedule via `every*` / `daily` / `weekly` / `monthly` / `at` / `on` / `beginOf` / `endOf`. Triggers: `.everyMinutes`, `.daily`, `.weekly`, `.monthly`, `.at`, `.on`, `.every`, `.beginOf`, `.endOf`, `.twiceDaily`, `scheduler.newJob`; "run every 5 minutes", "daily at 3am", "monday standup", "first of every month", "last day of month"; typical import `import { job, scheduler } from "@warlock.js/scheduler"`. Skip: cron — `@warlock.js/scheduler/schedule-with-cron/SKILL.md`; timezone — `@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`; retry — `@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`; competing `node-cron`, `node-schedule`, `agenda`; native `setInterval`.'
543
+ ---
544
+
545
+ # Fluent scheduling API
546
+
547
+ Chainable methods on a `Job` instance. Each method returns `this` for chaining; each call recomputes `job.nextRun` on the spot.
548
+
549
+ ## Preset intervals
550
+
551
+ | Method | Equivalent of | Notes |
552
+ | -------------------- | ------------------------ | ------------------------------ |
553
+ | `.everySecond()` | `.every(1, "second")` | High frequency — use sparingly |
554
+ | `.everySeconds(N)` | `.every(N, "second")` | |
555
+ | `.everyMinute()` | `.every(1, "minute")` | |
556
+ | `.everyMinutes(N)` | `.every(N, "minute")` | |
557
+ | `.everyHour()` | `.every(1, "hour")` | |
558
+ | `.everyHours(N)` | `.every(N, "hour")` | |
559
+ | `.everyDay()` | `.every(1, "day")` | Same as `.daily()` |
560
+ | `.daily()` | `.every(1, "day")` | |
561
+ | `.twiceDaily()` | `.every(12, "hour")` | Every 12 hours |
562
+ | `.everyWeek()` | `.every(1, "week")` | Same as `.weekly()` |
563
+ | `.weekly()` | `.every(1, "week")` | |
564
+ | `.everyMonth()` | `.every(1, "month")` | Same as `.monthly()` |
565
+ | `.monthly()` | `.every(1, "month")` | |
566
+ | `.everyYear()` | `.every(1, "year")` | Same as `.yearly()` |
567
+ | `.yearly()` | `.every(1, "year")` | |
568
+ | `.always()` | `.every(1, "minute")` | Continuous "tick" jobs |
569
+
570
+ ## Custom intervals — `.every(value, unit)`
571
+
572
+ ```ts
573
+ job("t", task).every(5, "minute");
574
+ job("t", task).every(2, "hour");
575
+ job("t", task).every(3, "day");
576
+ ```
577
+
578
+ Units: `"second" | "minute" | "hour" | "day" | "week" | "month" | "year"`.
579
+
580
+ **Validation.** `every(value, unit)` throws at definition time if `value` is `0`, negative, `NaN`, or `Infinity`. This guards against the misconfigured-interval class of bugs that would otherwise spin the scheduler.
581
+
582
+ ## Target a specific time — `.at("HH:mm" | "HH:mm:ss")`
583
+
584
+ ```ts
585
+ job("nightly", fn).daily().at("03:00");
586
+ job("midday", fn).daily().at("12:30:15");
587
+ ```
588
+
589
+ **Validation.** `.at()` throws if the format is malformed (`"foo"`, `"9-30"`), or any component is out of range (hour > 23, minute > 59, second > 59).
590
+
591
+ ## Target a specific day — `.on(day)`
592
+
593
+ Day-of-week (string) or day-of-month (number 1–31):
594
+
595
+ ```ts
596
+ job("monday-standup", task).weekly().on("monday");
597
+ job("mid-month-sync", task).monthly().on(15);
598
+ job("end-of-month", task).monthly().on(31); // see beginOf/endOf for the right way
599
+ ```
600
+
601
+ Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`.
602
+
603
+ **Validation.** Numeric `on(N)` throws if `N < 1 || N > 31`.
604
+
605
+ **Gotcha.** `monthly().on(31)` clamps to the actual month length in dayjs — February runs are unpredictable. Use `endOf("month")` for "last day" semantics instead.
606
+
607
+ ## Boundary shortcuts — `.beginOf(type)` / `.endOf(type)`
608
+
609
+ Both accept `"day" | "month" | "year"`.
610
+
611
+ | Method | Fires at |
612
+ | -------------------- | ------------------------------------------------- |
613
+ | `beginOf("day")` | 00:00 every day |
614
+ | `endOf("day")` | 23:59 every day |
615
+ | `beginOf("month")` | 1st of every month at 00:00 |
616
+ | `endOf("month")` | Last day of every month at 23:59 (**dynamic**) |
617
+ | `beginOf("year")` | January 1 at 00:00 every year |
618
+ | `endOf("year")` | December 31 at 23:59 every year |
619
+
620
+ **`endOf("month")` is dynamic** — recomputes per cycle, so a job defined in February (28 days) still fires on March 31, April 30, etc. Leap years pick Feb 29 correctly.
621
+
622
+ **`beginOf("year")` / `endOf("year")` lock the month** — always Jan 1 / Dec 31, never "1st/31st of whatever month the job was defined in."
623
+
624
+ ## Cron is an alternative, not an addition
625
+
626
+ `.cron("…")` clears any prior interval/`at`/`on`/`beginOf`/`endOf` config — and vice versa. They're mutually exclusive. See [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md).
627
+
628
+ ## Chaining order doesn't matter (for the most part)
629
+
630
+ Every fluent method recomputes `nextRun` at the end. Putting `.at()` before `.daily()` produces the same schedule as putting `.daily()` before `.at()` — but for readability, the conventional order is **interval → day → time → timezone → execution options**:
631
+
632
+ ```ts
633
+ job("good", fn)
634
+ .weekly() // interval
635
+ .on("monday") // day
636
+ .at("09:00") // time
637
+ .inTimezone("UTC") // timezone
638
+ .preventOverlap() // execution
639
+ .retry(3, 1000); // execution
640
+ ```
641
+
642
+ ## Inline registration — `scheduler.newJob()`
643
+
644
+ For one-liners, the scheduler exposes a `newJob()` shortcut that creates, registers, and returns the job in one call:
645
+
646
+ ```ts
647
+ scheduler
648
+ .newJob("cleanup", cleanupFn)
649
+ .daily()
650
+ .at("03:00");
651
+ ```
652
+
653
+ To register several pre-built jobs at once, `scheduler.addJobs([...])` is the batch counterpart to `addJob` (both are chainable and preserve insertion order):
654
+
655
+ ```ts
656
+ scheduler.addJobs([
657
+ job("cleanup", cleanupFn).daily().at("03:00"),
658
+ job("reports", sendReports).weekly().on("monday").at("09:00"),
659
+ ]);
660
+ ```
661
+
662
+ If the scheduler is already running, every job added via `addJob` / `addJobs` is prepared on the spot (its `nextRun` is computed) so it fires on the next tick.
663
+
664
+ ## Reading state at runtime
665
+
666
+ ```ts
667
+ const j = scheduler.getJob("nightly-cleanup");
668
+
669
+ j?.nextRun?.toISOString(); // next scheduled run (Dayjs)
670
+ j?.lastRun?.toISOString(); // last attempt — success OR failure
671
+ j?.isRunning; // currently executing?
672
+ j?.intervals; // { every?, day?, dayOfMonthMode?, month?, time? } (readonly)
673
+ j?.cronExpression; // null if using fluent API
674
+ ```
675
+
676
+ ## See also
677
+
678
+ - [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — when the fluent API isn't enough
679
+ - [`@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md) — `.retry()` and `.preventOverlap()` on the same job
680
+ - [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md) — `.inTimezone()`
666
681
 
667
682
 
668
683
  ## schedule-with-cron `@warlock.js/scheduler/schedule-with-cron/SKILL.md`
669
684
 
670
- ---
671
- name: schedule-with-cron
672
- description: "Cron expressions in @warlock.js/scheduler; use when you need to schedule with cron."
673
- ---
674
-
675
- # Cron expressions
676
-
677
- The escape hatch when the fluent API can't express a schedule. Activating `.cron()` clears any prior fluent config — the two are mutually exclusive.
678
-
679
- ## 5-field syntax
680
-
681
- ```
682
- ┌───────────── minute (0-59)
683
- │ ┌───────────── hour (0-23)
684
- │ │ ┌───────────── day-of-month (1-31)
685
- │ │ │ ┌───────────── month (1-12)
686
- │ │ │ │ ┌───────────── day-of-week (0-6, Sunday = 0)
687
- │ │ │ │ │
688
- * * * * *
689
- ```
690
-
691
- **Note:** Sunday is `0` only. Some cron dialects also accept `7` for Sunday — this parser does NOT.
692
-
693
- ## Operators
694
-
695
- | Syntax | Meaning | Example |
696
- | --------- | ----------------------------- | ---------------- |
697
- | `*` | Any value (full range) | `* * * * *` |
698
- | `5` | Single value | `30 14 * * *` |
699
- | `1,3,5` | List | `0,30 * * * *` |
700
- | `1-5` | Inclusive range | `0 9-17 * * *` |
701
- | `*/N` | Step over the wildcard | `*/5 * * * *` |
702
- | `A-B/N` | Step over a range | `0 1-10/2 * * *` |
703
-
704
- **Not supported (yet):** named tokens (`MON-FRI`, `JAN-DEC`), special strings (`@daily`, `@hourly`, `@reboot`), seconds field (6-field), Quartz modifiers (`L`, `W`, `#`).
705
-
706
- ## Day-of-month + day-of-week — Vixie OR semantics
707
-
708
- When **both** `dayOfMonth` AND `dayOfWeek` are restricted (neither is `*`), the date matches if **either** constraint matches. When one is `*` and the other is restricted, only the restricted one matters.
709
-
710
- ```ts
711
- // "1st of month OR any Monday" — fires on the 1st even if not a Monday,
712
- // AND fires on any Monday even if not the 1st.
713
- job("digest", sendDigest).cron("0 0 1 * 1");
714
-
715
- // "15th of month" — DOW is *, so only DOM applies. Fires only on the 15th.
716
- job("midmonth", task).cron("0 0 15 * *");
717
-
718
- // "Every Monday" — DOM is *, so only DOW applies. Fires only on Mondays.
719
- job("monday-only", task).cron("0 0 * * 1");
720
- ```
721
-
722
- This matches Vixie cron (the de facto standard on Unix). Migrating an existing cron table from `crontab(5)` should "just work" semantically.
723
-
724
- ## Common recipes
725
-
726
- ```ts
727
- // Every 5 minutes
728
- .cron("*/5 * * * *")
729
-
730
- // Top of every hour
731
- .cron("0 * * * *")
732
-
733
- // Every weekday at 9 AM
734
- .cron("0 9 * * 1-5")
735
-
736
- // 2:30 PM on the 15th of every month
737
- .cron("30 14 15 * *")
738
-
739
- // Every 2 hours, on the hour
740
- .cron("0 */2 * * *")
741
-
742
- // First day of each month at midnight
743
- .cron("0 0 1 * *")
744
- ```
745
-
746
- ## Validation
747
-
748
- `new CronParser(expr)` and `.cron(expr)` both throw at definition time on:
749
-
750
- - Wrong field count (must be exactly 5)
751
- - Non-numeric values, out-of-range values, inverted ranges (`5-1`)
752
- - Step `<= 0`, non-numeric step
753
- - Impossible day-of-month / month combinations — a date that can never occur, e.g. `0 0 30 2 *` (Feb 30) or `0 0 31 4 *` (April has 30 days). Rejected with an `Impossible cron expression` error.
754
-
755
- ```ts
756
- new CronParser("0 0 30 2 *"); // throws: Impossible cron expression — Feb never has a 30th
757
- new CronParser("0 0 31 4 *"); // throws: April has only 30 days
758
- ```
759
-
760
- **Leap years and the OR escape hatch.** `0 0 29 2 *` (Feb 29) is **accepted** — it occurs in leap years. And when day-of-week is also restricted, Vixie OR semantics keep the schedule alive via the weekday path, so the combo is never rejected: `0 0 30 2 1` (Feb 30 OR any Monday) parses fine and fires on Mondays.
761
-
762
- Throws are eager — bad expressions fail fast at construction, not at first tick.
763
-
764
- ## Standalone preview — `parseCron()`
765
-
766
- The `CronParser` class is exported as a utility for ad-hoc next-run calculation, separate from any job:
767
-
768
- ```ts
769
- import { parseCron } from "@warlock.js/scheduler";
770
- import dayjs from "dayjs";
771
-
772
- const parser = parseCron("0 9 * * 1-5");
773
-
774
- parser.nextRun().toISOString(); // next weekday at 9 AM
775
- parser.nextRun(dayjs("2026-12-31")).format(); // from a specific anchor
776
-
777
- parser.matches(dayjs()); // is "now" a fire moment?
778
-
779
- parser.fields; // parsed numeric arrays
780
- parser.expression; // original string
781
- ```
782
-
783
- Impossible expressions are rejected eagerly at construction (see Validation), so `nextRun(from?)` always resolves a satisfiable expression within a year — its one-year scan bound is just a defensive backstop, never the path that catches a bad expression.
784
-
785
- ## Timezone interaction
786
-
787
- If the job has `.inTimezone(tz)`, the cron parser receives a timezone-aware `dayjs` object — so `0 9 * * *` with `.inTimezone("Asia/Tokyo")` fires at 09:00 JST. See [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md).
788
-
789
- ## See also
790
-
791
- - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the higher-level alternative for human-readable schedules
792
- - [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md) — pinning a cron schedule to a wall-clock timezone
685
+ ---
686
+ name: schedule-with-cron
687
+ description: 'Write `.cron(''…'')` expressions — 5-field syntax, operators (`*` `,` `-` `/`), Vixie OR semantics for DOM / DOW, `parseCron()` preview utility. Triggers: `.cron`, `parseCron`, `CronParser`, `*/5 * * * *`, `0 9 * * 1-5`; "write a cron expression", "every weekday at 9am cron", "preview next cron run", "migrate crontab(5) entry"; typical import `import { parseCron } from "@warlock.js/scheduler"`. Skip: human-readable schedules — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; timezone — `@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`; competing libs `node-cron`, `cron`, `croner`, `cronstrue`.'
688
+ ---
689
+
690
+ # Cron expressions
691
+
692
+ The escape hatch when the fluent API can't express a schedule. Activating `.cron()` clears any prior fluent config — the two are mutually exclusive.
693
+
694
+ ## 5-field syntax
695
+
696
+ ```
697
+ ┌───────────── minute (0-59)
698
+ │ ┌───────────── hour (0-23)
699
+ │ │ ┌───────────── day-of-month (1-31)
700
+ │ │ │ ┌───────────── month (1-12)
701
+ │ │ │ │ ┌───────────── day-of-week (0-6, Sunday = 0)
702
+ │ │ │ │ │
703
+ * * * * *
704
+ ```
705
+
706
+ **Note:** Sunday is `0` only. Some cron dialects also accept `7` for Sunday — this parser does NOT.
707
+
708
+ ## Operators
709
+
710
+ | Syntax | Meaning | Example |
711
+ | --------- | ----------------------------- | ---------------- |
712
+ | `*` | Any value (full range) | `* * * * *` |
713
+ | `5` | Single value | `30 14 * * *` |
714
+ | `1,3,5` | List | `0,30 * * * *` |
715
+ | `1-5` | Inclusive range | `0 9-17 * * *` |
716
+ | `*/N` | Step over the wildcard | `*/5 * * * *` |
717
+ | `A-B/N` | Step over a range | `0 1-10/2 * * *` |
718
+
719
+ **Not supported (yet):** named tokens (`MON-FRI`, `JAN-DEC`), special strings (`@daily`, `@hourly`, `@reboot`), seconds field (6-field), Quartz modifiers (`L`, `W`, `#`).
720
+
721
+ ## Day-of-month + day-of-week — Vixie OR semantics
722
+
723
+ When **both** `dayOfMonth` AND `dayOfWeek` are restricted (neither is `*`), the date matches if **either** constraint matches. When one is `*` and the other is restricted, only the restricted one matters.
724
+
725
+ ```ts
726
+ // "1st of month OR any Monday" — fires on the 1st even if not a Monday,
727
+ // AND fires on any Monday even if not the 1st.
728
+ job("digest", sendDigest).cron("0 0 1 * 1");
729
+
730
+ // "15th of month" — DOW is *, so only DOM applies. Fires only on the 15th.
731
+ job("midmonth", task).cron("0 0 15 * *");
732
+
733
+ // "Every Monday" — DOM is *, so only DOW applies. Fires only on Mondays.
734
+ job("monday-only", task).cron("0 0 * * 1");
735
+ ```
736
+
737
+ This matches Vixie cron (the de facto standard on Unix). Migrating an existing cron table from `crontab(5)` should "just work" semantically.
738
+
739
+ ## Common recipes
740
+
741
+ ```ts
742
+ // Every 5 minutes
743
+ .cron("*/5 * * * *")
744
+
745
+ // Top of every hour
746
+ .cron("0 * * * *")
747
+
748
+ // Every weekday at 9 AM
749
+ .cron("0 9 * * 1-5")
750
+
751
+ // 2:30 PM on the 15th of every month
752
+ .cron("30 14 15 * *")
753
+
754
+ // Every 2 hours, on the hour
755
+ .cron("0 */2 * * *")
756
+
757
+ // First day of each month at midnight
758
+ .cron("0 0 1 * *")
759
+ ```
760
+
761
+ ## Validation
762
+
763
+ `new CronParser(expr)` and `.cron(expr)` both throw at definition time on:
764
+
765
+ - Wrong field count (must be exactly 5)
766
+ - Non-numeric values, out-of-range values, inverted ranges (`5-1`)
767
+ - Step `<= 0`, non-numeric step
768
+ - Impossible day-of-month / month combinations — a date that can never occur, e.g. `0 0 30 2 *` (Feb 30) or `0 0 31 4 *` (April has 30 days). Rejected with an `Impossible cron expression` error.
769
+
770
+ ```ts
771
+ new CronParser("0 0 30 2 *"); // throws: Impossible cron expression — Feb never has a 30th
772
+ new CronParser("0 0 31 4 *"); // throws: April has only 30 days
773
+ ```
774
+
775
+ **Leap years and the OR escape hatch.** `0 0 29 2 *` (Feb 29) is **accepted** — it occurs in leap years. And when day-of-week is also restricted, Vixie OR semantics keep the schedule alive via the weekday path, so the combo is never rejected: `0 0 30 2 1` (Feb 30 OR any Monday) parses fine and fires on Mondays.
776
+
777
+ Throws are eager — bad expressions fail fast at construction, not at first tick.
778
+
779
+ ## Standalone preview — `parseCron()`
780
+
781
+ The `CronParser` class is exported as a utility for ad-hoc next-run calculation, separate from any job:
782
+
783
+ ```ts
784
+ import { parseCron } from "@warlock.js/scheduler";
785
+ import dayjs from "dayjs";
786
+
787
+ const parser = parseCron("0 9 * * 1-5");
788
+
789
+ parser.nextRun().toISOString(); // next weekday at 9 AM
790
+ parser.nextRun(dayjs("2026-12-31")).format(); // from a specific anchor
791
+
792
+ parser.matches(dayjs()); // is "now" a fire moment?
793
+
794
+ parser.fields; // parsed numeric arrays
795
+ parser.expression; // original string
796
+ ```
797
+
798
+ Impossible expressions are rejected eagerly at construction (see Validation), so `nextRun(from?)` always resolves a satisfiable expression within a year — its one-year scan bound is just a defensive backstop, never the path that catches a bad expression.
799
+
800
+ ## Timezone interaction
801
+
802
+ If the job has `.inTimezone(tz)`, the cron parser receives a timezone-aware `dayjs` object — so `0 9 * * *` with `.inTimezone("Asia/Tokyo")` fires at 09:00 JST. See [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md).
803
+
804
+ ## See also
805
+
806
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the higher-level alternative for human-readable schedules
807
+ - [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md) — pinning a cron schedule to a wall-clock timezone
793
808
 
794
809
 
795
810
  ## scheduler-basics `@warlock.js/scheduler/scheduler-basics/SKILL.md`