@warlock.js/scheduler 5.30.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/skills/index.md CHANGED
@@ -1,11 +1,13 @@
1
1
  ---
2
- description: "In-process job scheduler for Warlock.js with fluent and cron scheduling, retries, overlap prevention and timezones. Exports `Scheduler`, `scheduler`, `Job`, `job`, `CronParser`, `parseCron`, plus types `RetryConfig`, `JobResult`, `SchedulerEvents`. Use for: \"run this every day at 3am\", \"run a cron expression on weekdays\", \"retry a failing job 3 times\", \"stop a job overlapping itself\", \"run in New York time\", \"log job failures\". Not this package: durable queues and background workers are not scheduling; per-run locking across instances uses @warlock.js/cache."
2
+ description: 'In-process job scheduler for Warlock.js with fluent and cron scheduling, retries, overlap prevention and timezones. Exports `Scheduler`, `scheduler`, `Job`, `job`, `CronParser`, `parseCron`, plus types `RetryConfig`, `JobResult`, `SchedulerEvents`. Use for: "run this every day at 3am", "run a cron expression on weekdays", "retry a failing job 3 times", "stop a job overlapping itself", "run in New York time", "log job failures". Not this package: durable queues and background workers are not scheduling; per-run locking across instances uses @warlock.js/cache.'
3
3
  ---
4
+
4
5
  # @warlock.js/scheduler
5
6
 
6
7
  Build a `job(name, fn)`, describe when it runs with the fluent API (`.daily().at("03:00")`) or `.cron("...")`, add it to a `Scheduler`, then `start()`. Events (`job:error` and others) provide observability; `shutdown()` stops gracefully.
7
8
 
8
9
  ## The 80% path
10
+
9
11
  1. Orient: `overview.md`, `scheduler-basics.md`.
10
12
  2. Define the schedule fluently: `schedule-fluently.md`, or by cron: `schedule-with-cron.md`.
11
13
  3. Make it resilient: `configure-retry-and-overlap.md`.
@@ -13,6 +15,7 @@ Build a `job(name, fn)`, describe when it runs with the fluent API (`.daily().at
13
15
  5. Watch it run: `observe-scheduler.md`.
14
16
 
15
17
  ## Conventions and pitfalls
18
+
16
19
  - It is in-memory and per-process: each app instance runs its own copy. With multiple instances, guard jobs with a lock (for example `@warlock.js/cache`) or run the scheduler in one instance only.
17
20
  - Missed runs while the process was down are not replayed.
18
21
  - Set a timezone explicitly for jobs tied to business hours; otherwise the host's applies.
@@ -11,25 +11,25 @@ The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire l
11
11
 
12
12
  ```ts
13
13
  type SchedulerEvents = {
14
- "job:start": [jobName: string];
15
- "job:complete": [jobName: string, result: JobResult];
16
- "job:error": [jobName: string, error: unknown];
17
- "job:skip": [jobName: string, reason: string];
14
+ "job:start": [jobName: string];
15
+ "job:complete": [jobName: string, result: JobResult];
16
+ "job:error": [jobName: string, error: unknown];
17
+ "job:skip": [jobName: string, reason: string];
18
18
  "scheduler:started": [];
19
19
  "scheduler:stopped": [];
20
- "scheduler:tick": [timestamp: Date];
20
+ "scheduler:tick": [timestamp: Date];
21
21
  };
22
22
  ```
23
23
 
24
- | Event | When it fires |
25
- | -------------------- | -------------------------------------------------------------------------------------- |
26
- | `scheduler:started` | Once, after `.start()` enters the tick loop |
27
- | `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
28
- | `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
29
- | `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
30
- | `job:complete` | When a job finishes successfully (possibly after retries) |
31
- | `job:error` | When a job exhausts all retries and the final attempt still throws |
32
- | `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
24
+ | Event | When it fires |
25
+ | ------------------- | -------------------------------------------------------------------------------------- |
26
+ | `scheduler:started` | Once, after `.start()` enters the tick loop |
27
+ | `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
28
+ | `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
29
+ | `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
30
+ | `job:complete` | When a job finishes successfully (possibly after retries) |
31
+ | `job:error` | When a job exhausts all retries and the final attempt still throws |
32
+ | `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
33
33
 
34
34
  **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.
35
35
 
@@ -38,9 +38,9 @@ type SchedulerEvents = {
38
38
  ```ts
39
39
  type JobResult = {
40
40
  success: boolean;
41
- duration: number; // milliseconds, end-to-end including retry waits
42
- error?: unknown; // present when success === false
43
- retries?: number; // attempts that failed before the final outcome
41
+ duration: number; // milliseconds, end-to-end including retry waits
42
+ error?: unknown; // present when success === false
43
+ retries?: number; // attempts that failed before the final outcome
44
44
  };
45
45
  ```
46
46
 
@@ -52,12 +52,12 @@ type JobResult = {
52
52
  import type { JobResult } from "@warlock.js/scheduler";
53
53
  import { scheduler } from "@warlock.js/scheduler";
54
54
 
55
- scheduler.on("job:start", name => log.debug({ name }, "job start"));
55
+ scheduler.on("job:start", (name) => log.debug({ name }, "job start"));
56
56
  scheduler.on("job:complete", (name, result: JobResult) => {
57
57
  log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
58
58
  });
59
- scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
60
- scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
59
+ scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
60
+ scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
61
61
  ```
62
62
 
63
63
  `.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
@@ -69,6 +69,7 @@ scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job s
69
69
  Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
70
70
 
71
71
  Throws if:
72
+
72
73
  - already running (`"Scheduler is already running."`)
73
74
  - zero jobs registered (`"Cannot start scheduler with no jobs."`)
74
75
 
@@ -104,7 +105,7 @@ import { logger } from "./logger";
104
105
  import { metrics } from "./metrics";
105
106
  import { alerts } from "./alerts";
106
107
 
107
- scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
108
+ scheduler.on("job:start", (name) => metrics.increment(`job.start.${name}`));
108
109
 
109
110
  scheduler.on("job:complete", (name, result) => {
110
111
  metrics.increment(`job.success.${name}`);
@@ -131,20 +132,20 @@ scheduler.start();
131
132
  ## Inspection at runtime
132
133
 
133
134
  ```ts
134
- scheduler.isRunning; // boolean
135
- scheduler.jobCount; // number of registered jobs
136
- scheduler.list(); // readonly Job[]
137
- scheduler.getJob("name"); // Job | undefined
135
+ scheduler.isRunning; // boolean
136
+ scheduler.jobCount; // number of registered jobs
137
+ scheduler.list(); // readonly Job[]
138
+ scheduler.getJob("name"); // Job | undefined
138
139
  ```
139
140
 
140
141
  And on each `Job`:
141
142
 
142
143
  ```ts
143
- job.nextRun; // Dayjs | null
144
- job.lastRun; // Dayjs | null — success OR failure
145
- job.isRunning; // boolean
146
- job.intervals; // readonly schedule config
147
- job.cronExpression; // string | null
144
+ job.nextRun; // Dayjs | null
145
+ job.lastRun; // Dayjs | null — success OR failure
146
+ job.isRunning; // boolean
147
+ job.intervals; // readonly schedule config
148
+ job.cronExpression; // string | null
148
149
  ```
149
150
 
150
151
  ## See also
@@ -30,27 +30,33 @@ Six task skills cover the full surface. Most callers only need `scheduler-basics
30
30
  ### Foundations
31
31
 
32
32
  #### `scheduler-basics`
33
+
33
34
  Start here. The `job(name, fn)` factory, the `scheduler` singleton, the 2-primitive surface, UTC default, why the API is factory-first.
34
35
 
35
36
  ### Scheduling
36
37
 
37
38
  #### `schedule-fluently`
39
+
38
40
  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.
39
41
 
40
42
  #### `schedule-with-cron`
43
+
41
44
  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.
42
45
 
43
46
  ### Production concerns
44
47
 
45
48
  #### `configure-retry-and-overlap`
49
+
46
50
  `.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.
47
51
 
48
52
  #### `pin-schedule-timezone`
53
+
49
54
  `.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.
50
55
 
51
56
  ### Observability + shutdown
52
57
 
53
58
  #### `observe-scheduler`
59
+
54
60
  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.
55
61
 
56
62
  ## Quick taste
@@ -10,10 +10,7 @@ Every `Job` has its own timezone. The default is **UTC** — `.daily().at("09:00
10
10
  ## Basic usage
11
11
 
12
12
  ```ts
13
- job("morning-digest", sendDailyDigest)
14
- .daily()
15
- .at("08:00")
16
- .inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
13
+ job("morning-digest", sendDailyDigest).daily().at("08:00").inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
17
14
  ```
18
15
 
19
16
  `.inTimezone()` is chainable — typically placed after the time/day methods.
@@ -30,9 +27,9 @@ Same logical task, three regions:
30
27
  import { scheduler, job } from "@warlock.js/scheduler";
31
28
 
32
29
  const regions = [
33
- { name: "us-east", tz: "America/New_York" },
34
- { name: "eu-west", tz: "Europe/Berlin" },
35
- { name: "asia", tz: "Asia/Tokyo" },
30
+ { name: "us-east", tz: "America/New_York" },
31
+ { name: "eu-west", tz: "Europe/Berlin" },
32
+ { name: "asia", tz: "Asia/Tokyo" },
36
33
  ];
37
34
 
38
35
  for (const { name, tz } of regions) {
@@ -40,7 +37,7 @@ for (const { name, tz } of regions) {
40
37
  job(`morning-report-${name}`, () => sendReport(name))
41
38
  .daily()
42
39
  .at("09:00")
43
- .inTimezone(tz)
40
+ .inTimezone(tz),
44
41
  );
45
42
  }
46
43
 
@@ -55,20 +52,20 @@ dayjs handles transitions correctly. A daily 09:00 job in `America/New_York` shi
55
52
 
56
53
  ## Common IANA strings
57
54
 
58
- | Region | TZ string |
59
- | ------------------- | --------------------------- |
60
- | UTC | `UTC` |
61
- | US Eastern | `America/New_York` |
62
- | US Central | `America/Chicago` |
63
- | US Mountain | `America/Denver` |
64
- | US Pacific | `America/Los_Angeles` |
65
- | UK / Ireland | `Europe/London` |
66
- | Central Europe | `Europe/Berlin` |
67
- | Eastern Europe | `Europe/Kyiv` |
68
- | India | `Asia/Kolkata` |
69
- | Japan | `Asia/Tokyo` |
70
- | Australia (Sydney) | `Australia/Sydney` |
71
- | Egypt | `Africa/Cairo` |
55
+ | Region | TZ string |
56
+ | ------------------ | --------------------- |
57
+ | UTC | `UTC` |
58
+ | US Eastern | `America/New_York` |
59
+ | US Central | `America/Chicago` |
60
+ | US Mountain | `America/Denver` |
61
+ | US Pacific | `America/Los_Angeles` |
62
+ | UK / Ireland | `Europe/London` |
63
+ | Central Europe | `Europe/Berlin` |
64
+ | Eastern Europe | `Europe/Kyiv` |
65
+ | India | `Asia/Kolkata` |
66
+ | Japan | `Asia/Tokyo` |
67
+ | Australia (Sydney) | `Australia/Sydney` |
68
+ | Egypt | `Africa/Cairo` |
72
69
 
73
70
  Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
74
71
 
@@ -77,17 +74,11 @@ Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
77
74
  The timezone applies to every interpretation of "now" and every constraint:
78
75
 
79
76
  ```ts
80
- job("monday-standup", task)
81
- .weekly()
82
- .on("monday")
83
- .at("09:00")
84
- .inTimezone("Europe/Berlin");
77
+ job("monday-standup", task).weekly().on("monday").at("09:00").inTimezone("Europe/Berlin");
85
78
  // "Monday in Berlin local time" + "09:00 Berlin local time"
86
79
  // — automatically shifts CET ↔ CEST as DST changes.
87
80
 
88
- job("nightly-cron", task)
89
- .cron("0 3 * * *")
90
- .inTimezone("Asia/Tokyo");
81
+ job("nightly-cron", task).cron("0 3 * * *").inTimezone("Asia/Tokyo");
91
82
  // "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
92
83
  ```
93
84
 
@@ -9,24 +9,24 @@ Chainable methods on a `Job` instance. Each method returns `this` for chaining;
9
9
 
10
10
  ## Preset intervals
11
11
 
12
- | Method | Equivalent of | Notes |
13
- | -------------------- | ------------------------ | ------------------------------ |
14
- | `.everySecond()` | `.every(1, "second")` | High frequency — use sparingly |
15
- | `.everySeconds(N)` | `.every(N, "second")` | |
16
- | `.everyMinute()` | `.every(1, "minute")` | |
17
- | `.everyMinutes(N)` | `.every(N, "minute")` | |
18
- | `.everyHour()` | `.every(1, "hour")` | |
19
- | `.everyHours(N)` | `.every(N, "hour")` | |
20
- | `.everyDay()` | `.every(1, "day")` | Same as `.daily()` |
21
- | `.daily()` | `.every(1, "day")` | |
22
- | `.twiceDaily()` | `.every(12, "hour")` | Every 12 hours |
23
- | `.everyWeek()` | `.every(1, "week")` | Same as `.weekly()` |
24
- | `.weekly()` | `.every(1, "week")` | |
25
- | `.everyMonth()` | `.every(1, "month")` | Same as `.monthly()` |
26
- | `.monthly()` | `.every(1, "month")` | |
27
- | `.everyYear()` | `.every(1, "year")` | Same as `.yearly()` |
28
- | `.yearly()` | `.every(1, "year")` | |
29
- | `.always()` | `.every(1, "minute")` | Continuous "tick" jobs |
12
+ | Method | Equivalent of | Notes |
13
+ | ------------------ | --------------------- | ------------------------------ |
14
+ | `.everySecond()` | `.every(1, "second")` | High frequency — use sparingly |
15
+ | `.everySeconds(N)` | `.every(N, "second")` | |
16
+ | `.everyMinute()` | `.every(1, "minute")` | |
17
+ | `.everyMinutes(N)` | `.every(N, "minute")` | |
18
+ | `.everyHour()` | `.every(1, "hour")` | |
19
+ | `.everyHours(N)` | `.every(N, "hour")` | |
20
+ | `.everyDay()` | `.every(1, "day")` | Same as `.daily()` |
21
+ | `.daily()` | `.every(1, "day")` | |
22
+ | `.twiceDaily()` | `.every(12, "hour")` | Every 12 hours |
23
+ | `.everyWeek()` | `.every(1, "week")` | Same as `.weekly()` |
24
+ | `.weekly()` | `.every(1, "week")` | |
25
+ | `.everyMonth()` | `.every(1, "month")` | Same as `.monthly()` |
26
+ | `.monthly()` | `.every(1, "month")` | |
27
+ | `.everyYear()` | `.every(1, "year")` | Same as `.yearly()` |
28
+ | `.yearly()` | `.every(1, "year")` | |
29
+ | `.always()` | `.every(1, "minute")` | Continuous "tick" jobs |
30
30
 
31
31
  ## Custom intervals — `.every(value, unit)`
32
32
 
@@ -44,7 +44,7 @@ Units: `"second" | "minute" | "hour" | "day" | "week" | "month" | "year"`.
44
44
 
45
45
  ```ts
46
46
  job("nightly", fn).daily().at("03:00");
47
- job("midday", fn).daily().at("12:30:15");
47
+ job("midday", fn).daily().at("12:30:15");
48
48
  ```
49
49
 
50
50
  **Validation.** `.at()` throws if the format is malformed (`"foo"`, `"9-30"`), or any component is out of range (hour > 23, minute > 59, second > 59).
@@ -56,7 +56,7 @@ Day-of-week (string) or day-of-month (number 1–31):
56
56
  ```ts
57
57
  job("monday-standup", task).weekly().on("monday");
58
58
  job("mid-month-sync", task).monthly().on(15);
59
- job("end-of-month", task).monthly().on(31); // see beginOf/endOf for the right way
59
+ job("end-of-month", task).monthly().on(31); // see beginOf/endOf for the right way
60
60
  ```
61
61
 
62
62
  Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`.
@@ -69,14 +69,14 @@ Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `
69
69
 
70
70
  Both accept `"day" | "month" | "year"`.
71
71
 
72
- | Method | Fires at |
73
- | -------------------- | ------------------------------------------------- |
74
- | `beginOf("day")` | 00:00 every day |
75
- | `endOf("day")` | 23:59 every day |
76
- | `beginOf("month")` | 1st of every month at 00:00 |
77
- | `endOf("month")` | Last day of every month at 23:59 (**dynamic**) |
78
- | `beginOf("year")` | January 1 at 00:00 every year |
79
- | `endOf("year")` | December 31 at 23:59 every year |
72
+ | Method | Fires at |
73
+ | ------------------ | ---------------------------------------------- |
74
+ | `beginOf("day")` | 00:00 every day |
75
+ | `endOf("day")` | 23:59 every day |
76
+ | `beginOf("month")` | 1st of every month at 00:00 |
77
+ | `endOf("month")` | Last day of every month at 23:59 (**dynamic**) |
78
+ | `beginOf("year")` | January 1 at 00:00 every year |
79
+ | `endOf("year")` | December 31 at 23:59 every year |
80
80
 
81
81
  **`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.
82
82
 
@@ -92,12 +92,12 @@ Every fluent method recomputes `nextRun` at the end. Putting `.at()` before `.da
92
92
 
93
93
  ```ts
94
94
  job("good", fn)
95
- .weekly() // interval
96
- .on("monday") // day
97
- .at("09:00") // time
98
- .inTimezone("UTC") // timezone
99
- .preventOverlap() // execution
100
- .retry(3, 1000); // execution
95
+ .weekly() // interval
96
+ .on("monday") // day
97
+ .at("09:00") // time
98
+ .inTimezone("UTC") // timezone
99
+ .preventOverlap() // execution
100
+ .retry(3, 1000); // execution
101
101
  ```
102
102
 
103
103
  ## Inline registration — `scheduler.newJob()`
@@ -105,10 +105,7 @@ job("good", fn)
105
105
  For one-liners, the scheduler exposes a `newJob()` shortcut that creates, registers, and returns the job in one call:
106
106
 
107
107
  ```ts
108
- scheduler
109
- .newJob("cleanup", cleanupFn)
110
- .daily()
111
- .at("03:00");
108
+ scheduler.newJob("cleanup", cleanupFn).daily().at("03:00");
112
109
  ```
113
110
 
114
111
  To register several pre-built jobs at once, `scheduler.addJobs([...])` is the batch counterpart to `addJob` (both are chainable and preserve insertion order):
@@ -127,11 +124,11 @@ If the scheduler is already running, every job added via `addJob` / `addJobs` is
127
124
  ```ts
128
125
  const j = scheduler.getJob("nightly-cleanup");
129
126
 
130
- j?.nextRun?.toISOString(); // next scheduled run (Dayjs)
131
- j?.lastRun?.toISOString(); // last attempt — success OR failure
132
- j?.isRunning; // currently executing?
133
- j?.intervals; // { every?, day?, dayOfMonthMode?, month?, time? } (readonly)
134
- j?.cronExpression; // null if using fluent API
127
+ j?.nextRun?.toISOString(); // next scheduled run (Dayjs)
128
+ j?.lastRun?.toISOString(); // last attempt — success OR failure
129
+ j?.isRunning; // currently executing?
130
+ j?.intervals; // { every?, day?, dayOfMonthMode?, month?, time? } (readonly)
131
+ j?.cronExpression; // null if using fluent API
135
132
  ```
136
133
 
137
134
  ## See also
@@ -23,14 +23,14 @@ The escape hatch when the fluent API can't express a schedule. Activating `.cron
23
23
 
24
24
  ## Operators
25
25
 
26
- | Syntax | Meaning | Example |
27
- | --------- | ----------------------------- | ---------------- |
28
- | `*` | Any value (full range) | `* * * * *` |
29
- | `5` | Single value | `30 14 * * *` |
30
- | `1,3,5` | List | `0,30 * * * *` |
31
- | `1-5` | Inclusive range | `0 9-17 * * *` |
32
- | `*/N` | Step over the wildcard | `*/5 * * * *` |
33
- | `A-B/N` | Step over a range | `0 1-10/2 * * *` |
26
+ | Syntax | Meaning | Example |
27
+ | ------- | ---------------------- | ---------------- |
28
+ | `*` | Any value (full range) | `* * * * *` |
29
+ | `5` | Single value | `30 14 * * *` |
30
+ | `1,3,5` | List | `0,30 * * * *` |
31
+ | `1-5` | Inclusive range | `0 9-17 * * *` |
32
+ | `*/N` | Step over the wildcard | `*/5 * * * *` |
33
+ | `A-B/N` | Step over a range | `0 1-10/2 * * *` |
34
34
 
35
35
  **Not supported (yet):** named tokens (`MON-FRI`, `JAN-DEC`), special strings (`@daily`, `@hourly`, `@reboot`), seconds field (6-field), Quartz modifiers (`L`, `W`, `#`).
36
36
 
@@ -102,13 +102,13 @@ import dayjs from "dayjs";
102
102
 
103
103
  const parser = parseCron("0 9 * * 1-5");
104
104
 
105
- parser.nextRun().toISOString(); // next weekday at 9 AM
105
+ parser.nextRun().toISOString(); // next weekday at 9 AM
106
106
  parser.nextRun(dayjs("2026-12-31")).format(); // from a specific anchor
107
107
 
108
- parser.matches(dayjs()); // is "now" a fire moment?
108
+ parser.matches(dayjs()); // is "now" a fire moment?
109
109
 
110
- parser.fields; // parsed numeric arrays
111
- parser.expression; // original string
110
+ parser.fields; // parsed numeric arrays
111
+ parser.expression; // original string
112
112
  ```
113
113
 
114
114
  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.
@@ -36,7 +36,7 @@ The 9 things that are true in every scheduler use:
36
36
  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.
37
37
  7. **`Job.run()` returns a `JobResult`, never throws.** Errors funnel into `result.error`. The scheduler emits `job:error` after all retries are exhausted.
38
38
  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.
39
- 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`.
39
+ 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`.
40
40
 
41
41
  ## 30-second example
42
42
 
@@ -53,7 +53,7 @@ scheduler.addJob(
53
53
  .at("03:00")
54
54
  .inTimezone("America/New_York")
55
55
  .preventOverlap()
56
- .retry(3, 1000)
56
+ .retry(3, 1000),
57
57
  );
58
58
 
59
59
  scheduler.start();
@@ -79,13 +79,13 @@ scheduler.around((job, run) => storage.run({ job: job.name }, run));
79
79
 
80
80
  ## Pick a skill
81
81
 
82
- | If the task is about… | Load |
83
- | --- | --- |
84
- | Building schedules via `every*`/`daily`/`weekly`/`monthly`/`at`/`on`/`beginOf`/`endOf` | the `schedule-fluently` topic |
85
- | Writing `.cron("…")` expressions, debugging DOM/DOW behavior, using `parseCron()` for preview | the `schedule-with-cron` topic |
82
+ | If the task is about… | Load |
83
+ | ----------------------------------------------------------------------------------------------------- | --------------------------------------- |
84
+ | Building schedules via `every*`/`daily`/`weekly`/`monthly`/`at`/`on`/`beginOf`/`endOf` | the `schedule-fluently` topic |
85
+ | Writing `.cron("…")` expressions, debugging DOM/DOW behavior, using `parseCron()` for preview | the `schedule-with-cron` topic |
86
86
  | Configuring `.retry()` / exponential backoff, `.preventOverlap()`, understanding failure rescheduling | the `configure-retry-and-overlap` topic |
87
- | Subscribing to scheduler events, reading `JobResult`, lifecycle, graceful shutdown | the `observe-scheduler` topic |
88
- | Per-job `.inTimezone()`, multi-region patterns, DST handling | the `pin-schedule-timezone` topic |
87
+ | Subscribing to scheduler events, reading `JobResult`, lifecycle, graceful shutdown | the `observe-scheduler` topic |
88
+ | Per-job `.inTimezone()`, multi-region patterns, DST handling | the `pin-schedule-timezone` topic |
89
89
 
90
90
  ## When NOT to use this skill
91
91