@warlock.js/scheduler 5.29.0 → 5.31.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
@@ -18,18 +18,11 @@ Two independent execution-control concerns on a single `Job`. They compose clean
18
18
  ## `.retry(maxRetries, delay?, backoffMultiplier?)`
19
19
 
20
20
  ```ts
21
- job("send-report", sendReport)
22
- .daily()
23
- .at("08:00")
24
- .retry(3); // 3 retries, 1000ms apart (default delay)
21
+ job("send-report", sendReport).daily().at("08:00").retry(3); // 3 retries, 1000ms apart (default delay)
25
22
 
26
- job("sync", syncInventory)
27
- .everyHour()
28
- .retry(5, 2000); // 5 retries, 2000ms each
23
+ job("sync", syncInventory).everyHour().retry(5, 2000); // 5 retries, 2000ms each
29
24
 
30
- job("queue", processQueue)
31
- .everyMinutes(10)
32
- .retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
25
+ job("queue", processQueue).everyMinutes(10).retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
33
26
  ```
34
27
 
35
28
  **Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
@@ -54,6 +47,7 @@ scheduler.on("job:error", (name, error) => {
54
47
  ```
55
48
 
56
49
  `JobResult.retries`:
50
+
57
51
  - On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
58
52
  - On **failure**: equals the configured `maxRetries` (all of them were used).
59
53
 
@@ -76,7 +70,7 @@ To **stop** firing after N consecutive failures, do it in user code:
76
70
 
77
71
  ```ts
78
72
  let consecutiveFailures = 0;
79
- scheduler.on("job:error", name => {
73
+ scheduler.on("job:error", (name) => {
80
74
  if (name !== "flaky") return;
81
75
  consecutiveFailures++;
82
76
  if (consecutiveFailures >= 5) {
@@ -84,7 +78,7 @@ scheduler.on("job:error", name => {
84
78
  alert.critical("flaky disabled after 5 failures");
85
79
  }
86
80
  });
87
- scheduler.on("job:complete", name => {
81
+ scheduler.on("job:complete", (name) => {
88
82
  if (name === "flaky") consecutiveFailures = 0;
89
83
  });
90
84
  ```
@@ -94,9 +88,7 @@ scheduler.on("job:complete", name => {
94
88
  Tells the scheduler to skip a tick if the job is already running.
95
89
 
96
90
  ```ts
97
- job("queue", processQueue)
98
- .everyMinutes(5)
99
- .preventOverlap();
91
+ job("queue", processQueue).everyMinutes(5).preventOverlap();
100
92
  ```
101
93
 
102
94
  **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.
@@ -107,7 +99,7 @@ Real-world shapes that benefit:
107
99
  // 1) Boot-time recovery sweep before normal scheduling.
108
100
  const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
109
101
  scheduler.addJob(queueJob);
110
- queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
102
+ queueJob.run().catch((err) => log.error({ err }, "boot sweep failed"));
111
103
  scheduler.start();
112
104
 
113
105
  // 2) Admin-triggered manual re-run from a dashboard endpoint.
@@ -121,6 +113,7 @@ app.post("/admin/jobs/:name/run", async (req, res) => {
121
113
  ```
122
114
 
123
115
  If a tick lands while one of these external runs is mid-flight:
116
+
124
117
  - Scheduler emits `job:skip` with reason `"Job is already running"`
125
118
  - The tick proceeds without re-entering the job
126
119
 
@@ -131,10 +124,7 @@ For a single-process app where the callback is only ever invoked via the schedul
131
124
  They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
132
125
 
133
126
  ```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
127
+ job("long-flaky", processQueue).everyMinutes(5).preventOverlap().retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
138
128
  ```
139
129
 
140
130
  If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
@@ -175,25 +165,25 @@ The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire l
175
165
 
176
166
  ```ts
177
167
  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];
168
+ "job:start": [jobName: string];
169
+ "job:complete": [jobName: string, result: JobResult];
170
+ "job:error": [jobName: string, error: unknown];
171
+ "job:skip": [jobName: string, reason: string];
182
172
  "scheduler:started": [];
183
173
  "scheduler:stopped": [];
184
- "scheduler:tick": [timestamp: Date];
174
+ "scheduler:tick": [timestamp: Date];
185
175
  };
186
176
  ```
187
177
 
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) |
178
+ | Event | When it fires |
179
+ | ------------------- | -------------------------------------------------------------------------------------- |
180
+ | `scheduler:started` | Once, after `.start()` enters the tick loop |
181
+ | `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
182
+ | `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
183
+ | `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
184
+ | `job:complete` | When a job finishes successfully (possibly after retries) |
185
+ | `job:error` | When a job exhausts all retries and the final attempt still throws |
186
+ | `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
197
187
 
198
188
  **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
189
 
@@ -202,9 +192,9 @@ type SchedulerEvents = {
202
192
  ```ts
203
193
  type JobResult = {
204
194
  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
195
+ duration: number; // milliseconds, end-to-end including retry waits
196
+ error?: unknown; // present when success === false
197
+ retries?: number; // attempts that failed before the final outcome
208
198
  };
209
199
  ```
210
200
 
@@ -216,12 +206,12 @@ type JobResult = {
216
206
  import type { JobResult } from "@warlock.js/scheduler";
217
207
  import { scheduler } from "@warlock.js/scheduler";
218
208
 
219
- scheduler.on("job:start", name => log.debug({ name }, "job start"));
209
+ scheduler.on("job:start", (name) => log.debug({ name }, "job start"));
220
210
  scheduler.on("job:complete", (name, result: JobResult) => {
221
211
  log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
222
212
  });
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"));
213
+ scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
214
+ scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
225
215
  ```
226
216
 
227
217
  `.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
@@ -233,6 +223,7 @@ scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job s
233
223
  Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
234
224
 
235
225
  Throws if:
226
+
236
227
  - already running (`"Scheduler is already running."`)
237
228
  - zero jobs registered (`"Cannot start scheduler with no jobs."`)
238
229
 
@@ -268,7 +259,7 @@ import { logger } from "./logger";
268
259
  import { metrics } from "./metrics";
269
260
  import { alerts } from "./alerts";
270
261
 
271
- scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
262
+ scheduler.on("job:start", (name) => metrics.increment(`job.start.${name}`));
272
263
 
273
264
  scheduler.on("job:complete", (name, result) => {
274
265
  metrics.increment(`job.success.${name}`);
@@ -295,20 +286,20 @@ scheduler.start();
295
286
  ## Inspection at runtime
296
287
 
297
288
  ```ts
298
- scheduler.isRunning; // boolean
299
- scheduler.jobCount; // number of registered jobs
300
- scheduler.list(); // readonly Job[]
301
- scheduler.getJob("name"); // Job | undefined
289
+ scheduler.isRunning; // boolean
290
+ scheduler.jobCount; // number of registered jobs
291
+ scheduler.list(); // readonly Job[]
292
+ scheduler.getJob("name"); // Job | undefined
302
293
  ```
303
294
 
304
295
  And on each `Job`:
305
296
 
306
297
  ```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
298
+ job.nextRun; // Dayjs | null
299
+ job.lastRun; // Dayjs | null — success OR failure
300
+ job.isRunning; // boolean
301
+ job.intervals; // readonly schedule config
302
+ job.cronExpression; // string | null
312
303
  ```
313
304
 
314
305
  ## See also
@@ -351,27 +342,33 @@ Six task skills cover the full surface. Most callers only need `scheduler-basics
351
342
  ### Foundations
352
343
 
353
344
  #### `scheduler-basics`
345
+
354
346
  Start here. The `job(name, fn)` factory, the `scheduler` singleton, the 2-primitive surface, UTC default, why the API is factory-first.
355
347
 
356
348
  ### Scheduling
357
349
 
358
350
  #### `schedule-fluently`
351
+
359
352
  Build schedules without writing cron: `.everyMinutes(N)`, `.daily().at("03:00")`, `.weekly().on("monday")`, `.monthly()`, `.beginOf("month")`, `.endOf("year")`, and friends. Reach for this 80% of the time.
360
353
 
361
354
  #### `schedule-with-cron`
355
+
362
356
  Drop down to `.cron("0 9 * * 1-5")` when the fluent API can't express it (Vixie OR semantics for DOM/DOW, complex multi-value lists). Includes `parseCron()` for previewing next-run times before deploy.
363
357
 
364
358
  ### Production concerns
365
359
 
366
360
  #### `configure-retry-and-overlap`
361
+
367
362
  `.retry(maxRetries, delay?, backoffMultiplier?)` for fixed or exponential backoff; `.preventOverlap()` so a slow run never collides with the next tick. Reach for both anytime a job calls external services.
368
363
 
369
364
  #### `pin-schedule-timezone`
365
+
370
366
  `.inTimezone("America/New_York")` pins wall-clock fire times across DST and across servers. The default is UTC — if your job description includes "9 AM," you almost certainly want this skill.
371
367
 
372
368
  ### Observability + shutdown
373
369
 
374
370
  #### `observe-scheduler`
371
+
375
372
  The seven typed `SchedulerEvents` (`job:start` / `complete` / `error` / `skip`, `scheduler:started` / `stopped` / `tick`), the `JobResult` payload, `start()` / `stop()` / `shutdown()`. Wire this for logging, metrics, alerting, and graceful SIGTERM handling.
376
373
 
377
374
  ## Quick taste
@@ -431,10 +428,7 @@ Every `Job` has its own timezone. The default is **UTC** — `.daily().at("09:00
431
428
  ## Basic usage
432
429
 
433
430
  ```ts
434
- job("morning-digest", sendDailyDigest)
435
- .daily()
436
- .at("08:00")
437
- .inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
431
+ job("morning-digest", sendDailyDigest).daily().at("08:00").inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
438
432
  ```
439
433
 
440
434
  `.inTimezone()` is chainable — typically placed after the time/day methods.
@@ -451,9 +445,9 @@ Same logical task, three regions:
451
445
  import { scheduler, job } from "@warlock.js/scheduler";
452
446
 
453
447
  const regions = [
454
- { name: "us-east", tz: "America/New_York" },
455
- { name: "eu-west", tz: "Europe/Berlin" },
456
- { name: "asia", tz: "Asia/Tokyo" },
448
+ { name: "us-east", tz: "America/New_York" },
449
+ { name: "eu-west", tz: "Europe/Berlin" },
450
+ { name: "asia", tz: "Asia/Tokyo" },
457
451
  ];
458
452
 
459
453
  for (const { name, tz } of regions) {
@@ -461,7 +455,7 @@ for (const { name, tz } of regions) {
461
455
  job(`morning-report-${name}`, () => sendReport(name))
462
456
  .daily()
463
457
  .at("09:00")
464
- .inTimezone(tz)
458
+ .inTimezone(tz),
465
459
  );
466
460
  }
467
461
 
@@ -476,20 +470,20 @@ dayjs handles transitions correctly. A daily 09:00 job in `America/New_York` shi
476
470
 
477
471
  ## Common IANA strings
478
472
 
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` |
473
+ | Region | TZ string |
474
+ | ------------------ | --------------------- |
475
+ | UTC | `UTC` |
476
+ | US Eastern | `America/New_York` |
477
+ | US Central | `America/Chicago` |
478
+ | US Mountain | `America/Denver` |
479
+ | US Pacific | `America/Los_Angeles` |
480
+ | UK / Ireland | `Europe/London` |
481
+ | Central Europe | `Europe/Berlin` |
482
+ | Eastern Europe | `Europe/Kyiv` |
483
+ | India | `Asia/Kolkata` |
484
+ | Japan | `Asia/Tokyo` |
485
+ | Australia (Sydney) | `Australia/Sydney` |
486
+ | Egypt | `Africa/Cairo` |
493
487
 
494
488
  Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
495
489
 
@@ -498,17 +492,11 @@ Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
498
492
  The timezone applies to every interpretation of "now" and every constraint:
499
493
 
500
494
  ```ts
501
- job("monday-standup", task)
502
- .weekly()
503
- .on("monday")
504
- .at("09:00")
505
- .inTimezone("Europe/Berlin");
495
+ job("monday-standup", task).weekly().on("monday").at("09:00").inTimezone("Europe/Berlin");
506
496
  // "Monday in Berlin local time" + "09:00 Berlin local time"
507
497
  // — automatically shifts CET ↔ CEST as DST changes.
508
498
 
509
- job("nightly-cron", task)
510
- .cron("0 3 * * *")
511
- .inTimezone("Asia/Tokyo");
499
+ job("nightly-cron", task).cron("0 3 * * *").inTimezone("Asia/Tokyo");
512
500
  // "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
513
501
  ```
514
502
 
@@ -548,24 +536,24 @@ Chainable methods on a `Job` instance. Each method returns `this` for chaining;
548
536
 
549
537
  ## Preset intervals
550
538
 
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 |
539
+ | Method | Equivalent of | Notes |
540
+ | ------------------ | --------------------- | ------------------------------ |
541
+ | `.everySecond()` | `.every(1, "second")` | High frequency — use sparingly |
542
+ | `.everySeconds(N)` | `.every(N, "second")` | |
543
+ | `.everyMinute()` | `.every(1, "minute")` | |
544
+ | `.everyMinutes(N)` | `.every(N, "minute")` | |
545
+ | `.everyHour()` | `.every(1, "hour")` | |
546
+ | `.everyHours(N)` | `.every(N, "hour")` | |
547
+ | `.everyDay()` | `.every(1, "day")` | Same as `.daily()` |
548
+ | `.daily()` | `.every(1, "day")` | |
549
+ | `.twiceDaily()` | `.every(12, "hour")` | Every 12 hours |
550
+ | `.everyWeek()` | `.every(1, "week")` | Same as `.weekly()` |
551
+ | `.weekly()` | `.every(1, "week")` | |
552
+ | `.everyMonth()` | `.every(1, "month")` | Same as `.monthly()` |
553
+ | `.monthly()` | `.every(1, "month")` | |
554
+ | `.everyYear()` | `.every(1, "year")` | Same as `.yearly()` |
555
+ | `.yearly()` | `.every(1, "year")` | |
556
+ | `.always()` | `.every(1, "minute")` | Continuous "tick" jobs |
569
557
 
570
558
  ## Custom intervals — `.every(value, unit)`
571
559
 
@@ -583,7 +571,7 @@ Units: `"second" | "minute" | "hour" | "day" | "week" | "month" | "year"`.
583
571
 
584
572
  ```ts
585
573
  job("nightly", fn).daily().at("03:00");
586
- job("midday", fn).daily().at("12:30:15");
574
+ job("midday", fn).daily().at("12:30:15");
587
575
  ```
588
576
 
589
577
  **Validation.** `.at()` throws if the format is malformed (`"foo"`, `"9-30"`), or any component is out of range (hour > 23, minute > 59, second > 59).
@@ -595,7 +583,7 @@ Day-of-week (string) or day-of-month (number 1–31):
595
583
  ```ts
596
584
  job("monday-standup", task).weekly().on("monday");
597
585
  job("mid-month-sync", task).monthly().on(15);
598
- job("end-of-month", task).monthly().on(31); // see beginOf/endOf for the right way
586
+ job("end-of-month", task).monthly().on(31); // see beginOf/endOf for the right way
599
587
  ```
600
588
 
601
589
  Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`.
@@ -608,14 +596,14 @@ Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `
608
596
 
609
597
  Both accept `"day" | "month" | "year"`.
610
598
 
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 |
599
+ | Method | Fires at |
600
+ | ------------------ | ---------------------------------------------- |
601
+ | `beginOf("day")` | 00:00 every day |
602
+ | `endOf("day")` | 23:59 every day |
603
+ | `beginOf("month")` | 1st of every month at 00:00 |
604
+ | `endOf("month")` | Last day of every month at 23:59 (**dynamic**) |
605
+ | `beginOf("year")` | January 1 at 00:00 every year |
606
+ | `endOf("year")` | December 31 at 23:59 every year |
619
607
 
620
608
  **`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
609
 
@@ -631,12 +619,12 @@ Every fluent method recomputes `nextRun` at the end. Putting `.at()` before `.da
631
619
 
632
620
  ```ts
633
621
  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
622
+ .weekly() // interval
623
+ .on("monday") // day
624
+ .at("09:00") // time
625
+ .inTimezone("UTC") // timezone
626
+ .preventOverlap() // execution
627
+ .retry(3, 1000); // execution
640
628
  ```
641
629
 
642
630
  ## Inline registration — `scheduler.newJob()`
@@ -644,10 +632,7 @@ job("good", fn)
644
632
  For one-liners, the scheduler exposes a `newJob()` shortcut that creates, registers, and returns the job in one call:
645
633
 
646
634
  ```ts
647
- scheduler
648
- .newJob("cleanup", cleanupFn)
649
- .daily()
650
- .at("03:00");
635
+ scheduler.newJob("cleanup", cleanupFn).daily().at("03:00");
651
636
  ```
652
637
 
653
638
  To register several pre-built jobs at once, `scheduler.addJobs([...])` is the batch counterpart to `addJob` (both are chainable and preserve insertion order):
@@ -666,11 +651,11 @@ If the scheduler is already running, every job added via `addJob` / `addJobs` is
666
651
  ```ts
667
652
  const j = scheduler.getJob("nightly-cleanup");
668
653
 
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
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
674
659
  ```
675
660
 
676
661
  ## See also
@@ -707,14 +692,14 @@ The escape hatch when the fluent API can't express a schedule. Activating `.cron
707
692
 
708
693
  ## Operators
709
694
 
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 * * *` |
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 * * *` |
718
703
 
719
704
  **Not supported (yet):** named tokens (`MON-FRI`, `JAN-DEC`), special strings (`@daily`, `@hourly`, `@reboot`), seconds field (6-field), Quartz modifiers (`L`, `W`, `#`).
720
705
 
@@ -786,13 +771,13 @@ import dayjs from "dayjs";
786
771
 
787
772
  const parser = parseCron("0 9 * * 1-5");
788
773
 
789
- parser.nextRun().toISOString(); // next weekday at 9 AM
774
+ parser.nextRun().toISOString(); // next weekday at 9 AM
790
775
  parser.nextRun(dayjs("2026-12-31")).format(); // from a specific anchor
791
776
 
792
- parser.matches(dayjs()); // is "now" a fire moment?
777
+ parser.matches(dayjs()); // is "now" a fire moment?
793
778
 
794
- parser.fields; // parsed numeric arrays
795
- parser.expression; // original string
779
+ parser.fields; // parsed numeric arrays
780
+ parser.expression; // original string
796
781
  ```
797
782
 
798
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.
@@ -847,7 +832,7 @@ The 9 things that are true in every scheduler use:
847
832
  6. **Validation throws at definition time.** `every(0)`, `at("24:00")`, `on(32)`, `retry(-1)`, malformed cron — all throw immediately when you wire the job, not at runtime.
848
833
  7. **`Job.run()` returns a `JobResult`, never throws.** Errors funnel into `result.error`. The scheduler emits `job:error` after all retries are exhausted.
849
834
  8. **Parallel mode is within a tick, not across.** Even with `runInParallel(true)`, the NEXT tick waits for the current tick's jobs to all settle.
850
- 9. **Tick interval is drift-compensated.** A tick that takes 200 ms is followed by an 800 ms delay so the cadence between tick *starts* averages `tickInterval`, not `tickInterval + work-time`.
835
+ 9. **Tick interval is drift-compensated.** A tick that takes 200 ms is followed by an 800 ms delay so the cadence between tick _starts_ averages `tickInterval`, not `tickInterval + work-time`.
851
836
 
852
837
  ## 30-second example
853
838
 
@@ -864,7 +849,7 @@ scheduler.addJob(
864
849
  .at("03:00")
865
850
  .inTimezone("America/New_York")
866
851
  .preventOverlap()
867
- .retry(3, 1000)
852
+ .retry(3, 1000),
868
853
  );
869
854
 
870
855
  scheduler.start();
@@ -890,13 +875,13 @@ scheduler.around((job, run) => storage.run({ job: job.name }, run));
890
875
 
891
876
  ## Pick a skill
892
877
 
893
- | If the task is about… | Load |
894
- | --- | --- |
895
- | Building schedules via `every*`/`daily`/`weekly`/`monthly`/`at`/`on`/`beginOf`/`endOf` | the `schedule-fluently` topic |
896
- | Writing `.cron("…")` expressions, debugging DOM/DOW behavior, using `parseCron()` for preview | the `schedule-with-cron` topic |
878
+ | If the task is about… | Load |
879
+ | ----------------------------------------------------------------------------------------------------- | --------------------------------------- |
880
+ | Building schedules via `every*`/`daily`/`weekly`/`monthly`/`at`/`on`/`beginOf`/`endOf` | the `schedule-fluently` topic |
881
+ | Writing `.cron("…")` expressions, debugging DOM/DOW behavior, using `parseCron()` for preview | the `schedule-with-cron` topic |
897
882
  | Configuring `.retry()` / exponential backoff, `.preventOverlap()`, understanding failure rescheduling | the `configure-retry-and-overlap` topic |
898
- | Subscribing to scheduler events, reading `JobResult`, lifecycle, graceful shutdown | the `observe-scheduler` topic |
899
- | Per-job `.inTimezone()`, multi-region patterns, DST handling | the `pin-schedule-timezone` topic |
883
+ | Subscribing to scheduler events, reading `JobResult`, lifecycle, graceful shutdown | the `observe-scheduler` topic |
884
+ | Per-job `.inTimezone()`, multi-region patterns, DST handling | the `pin-schedule-timezone` topic |
900
885
 
901
886
  ## When NOT to use this skill
902
887
 
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "dayjs": "^1.11.19"
9
9
  },
10
10
  "peerDependencies": {
11
- "@warlock.js/cache": "5.29.0"
11
+ "@warlock.js/cache": "5.31.0"
12
12
  },
13
13
  "peerDependenciesMeta": {
14
14
  "@warlock.js/cache": {
@@ -17,7 +17,8 @@
17
17
  },
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "https://github.com/warlockjs/scheduler"
20
+ "url": "git+https://github.com/warlockjs/framework.git",
21
+ "directory": "packages/scheduler"
21
22
  },
22
23
  "keywords": [
23
24
  "warlock.js",
@@ -33,7 +34,7 @@
33
34
  ],
34
35
  "author": "hassanzohdy",
35
36
  "license": "MIT",
36
- "version": "5.29.0",
37
+ "version": "5.31.0",
37
38
  "main": "./cjs/index.cjs",
38
39
  "module": "./esm/index.mjs",
39
40
  "types": "./esm/index.d.mts",
@@ -10,18 +10,11 @@ Two independent execution-control concerns on a single `Job`. They compose clean
10
10
  ## `.retry(maxRetries, delay?, backoffMultiplier?)`
11
11
 
12
12
  ```ts
13
- job("send-report", sendReport)
14
- .daily()
15
- .at("08:00")
16
- .retry(3); // 3 retries, 1000ms apart (default delay)
17
-
18
- job("sync", syncInventory)
19
- .everyHour()
20
- .retry(5, 2000); // 5 retries, 2000ms each
21
-
22
- job("queue", processQueue)
23
- .everyMinutes(10)
24
- .retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
13
+ job("send-report", sendReport).daily().at("08:00").retry(3); // 3 retries, 1000ms apart (default delay)
14
+
15
+ job("sync", syncInventory).everyHour().retry(5, 2000); // 5 retries, 2000ms each
16
+
17
+ job("queue", processQueue).everyMinutes(10).retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
25
18
  ```
26
19
 
27
20
  **Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
@@ -46,6 +39,7 @@ scheduler.on("job:error", (name, error) => {
46
39
  ```
47
40
 
48
41
  `JobResult.retries`:
42
+
49
43
  - On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
50
44
  - On **failure**: equals the configured `maxRetries` (all of them were used).
51
45
 
@@ -68,7 +62,7 @@ To **stop** firing after N consecutive failures, do it in user code:
68
62
 
69
63
  ```ts
70
64
  let consecutiveFailures = 0;
71
- scheduler.on("job:error", name => {
65
+ scheduler.on("job:error", (name) => {
72
66
  if (name !== "flaky") return;
73
67
  consecutiveFailures++;
74
68
  if (consecutiveFailures >= 5) {
@@ -76,7 +70,7 @@ scheduler.on("job:error", name => {
76
70
  alert.critical("flaky disabled after 5 failures");
77
71
  }
78
72
  });
79
- scheduler.on("job:complete", name => {
73
+ scheduler.on("job:complete", (name) => {
80
74
  if (name === "flaky") consecutiveFailures = 0;
81
75
  });
82
76
  ```
@@ -86,9 +80,7 @@ scheduler.on("job:complete", name => {
86
80
  Tells the scheduler to skip a tick if the job is already running.
87
81
 
88
82
  ```ts
89
- job("queue", processQueue)
90
- .everyMinutes(5)
91
- .preventOverlap();
83
+ job("queue", processQueue).everyMinutes(5).preventOverlap();
92
84
  ```
93
85
 
94
86
  **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.
@@ -99,7 +91,7 @@ Real-world shapes that benefit:
99
91
  // 1) Boot-time recovery sweep before normal scheduling.
100
92
  const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
101
93
  scheduler.addJob(queueJob);
102
- queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
94
+ queueJob.run().catch((err) => log.error({ err }, "boot sweep failed"));
103
95
  scheduler.start();
104
96
 
105
97
  // 2) Admin-triggered manual re-run from a dashboard endpoint.
@@ -113,6 +105,7 @@ app.post("/admin/jobs/:name/run", async (req, res) => {
113
105
  ```
114
106
 
115
107
  If a tick lands while one of these external runs is mid-flight:
108
+
116
109
  - Scheduler emits `job:skip` with reason `"Job is already running"`
117
110
  - The tick proceeds without re-entering the job
118
111
 
@@ -123,10 +116,7 @@ For a single-process app where the callback is only ever invoked via the schedul
123
116
  They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
124
117
 
125
118
  ```ts
126
- job("long-flaky", processQueue)
127
- .everyMinutes(5)
128
- .preventOverlap()
129
- .retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
119
+ job("long-flaky", processQueue).everyMinutes(5).preventOverlap().retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
130
120
  ```
131
121
 
132
122
  If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.