@warlock.js/scheduler 5.19.1 → 5.21.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.
@@ -1,141 +1,141 @@
1
- ---
2
- name: schedule-fluently
3
- description: "Fluent scheduling API in @warlock.js/scheduler; use when you need to schedule fluently."
4
- ---
5
-
6
- # Fluent scheduling API
7
-
8
- Chainable methods on a `Job` instance. Each method returns `this` for chaining; each call recomputes `job.nextRun` on the spot.
9
-
10
- ## Preset intervals
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 |
30
-
31
- ## Custom intervals — `.every(value, unit)`
32
-
33
- ```ts
34
- job("t", task).every(5, "minute");
35
- job("t", task).every(2, "hour");
36
- job("t", task).every(3, "day");
37
- ```
38
-
39
- Units: `"second" | "minute" | "hour" | "day" | "week" | "month" | "year"`.
40
-
41
- **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.
42
-
43
- ## Target a specific time — `.at("HH:mm" | "HH:mm:ss")`
44
-
45
- ```ts
46
- job("nightly", fn).daily().at("03:00");
47
- job("midday", fn).daily().at("12:30:15");
48
- ```
49
-
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).
51
-
52
- ## Target a specific day — `.on(day)`
53
-
54
- Day-of-week (string) or day-of-month (number 1–31):
55
-
56
- ```ts
57
- job("monday-standup", task).weekly().on("monday");
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
60
- ```
61
-
62
- Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`.
63
-
64
- **Validation.** Numeric `on(N)` throws if `N < 1 || N > 31`.
65
-
66
- **Gotcha.** `monthly().on(31)` clamps to the actual month length in dayjs — February runs are unpredictable. Use `endOf("month")` for "last day" semantics instead.
67
-
68
- ## Boundary shortcuts — `.beginOf(type)` / `.endOf(type)`
69
-
70
- Both accept `"day" | "month" | "year"`.
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 |
80
-
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
-
83
- **`beginOf("year")` / `endOf("year")` lock the month** — always Jan 1 / Dec 31, never "1st/31st of whatever month the job was defined in."
84
-
85
- ## Cron is an alternative, not an addition
86
-
87
- `.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).
88
-
89
- ## Chaining order doesn't matter (for the most part)
90
-
91
- 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**:
92
-
93
- ```ts
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
101
- ```
102
-
103
- ## Inline registration — `scheduler.newJob()`
104
-
105
- For one-liners, the scheduler exposes a `newJob()` shortcut that creates, registers, and returns the job in one call:
106
-
107
- ```ts
108
- scheduler
109
- .newJob("cleanup", cleanupFn)
110
- .daily()
111
- .at("03:00");
112
- ```
113
-
114
- To register several pre-built jobs at once, `scheduler.addJobs([...])` is the batch counterpart to `addJob` (both are chainable and preserve insertion order):
115
-
116
- ```ts
117
- scheduler.addJobs([
118
- job("cleanup", cleanupFn).daily().at("03:00"),
119
- job("reports", sendReports).weekly().on("monday").at("09:00"),
120
- ]);
121
- ```
122
-
123
- 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.
124
-
125
- ## Reading state at runtime
126
-
127
- ```ts
128
- const j = scheduler.getJob("nightly-cleanup");
129
-
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
135
- ```
136
-
137
- ## See also
138
-
139
- - [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — when the fluent API isn't enough
140
- - [`@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
141
- - [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md) — `.inTimezone()`
1
+ ---
2
+ name: schedule-fluently
3
+ 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`.'
4
+ ---
5
+
6
+ # Fluent scheduling API
7
+
8
+ Chainable methods on a `Job` instance. Each method returns `this` for chaining; each call recomputes `job.nextRun` on the spot.
9
+
10
+ ## Preset intervals
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 |
30
+
31
+ ## Custom intervals — `.every(value, unit)`
32
+
33
+ ```ts
34
+ job("t", task).every(5, "minute");
35
+ job("t", task).every(2, "hour");
36
+ job("t", task).every(3, "day");
37
+ ```
38
+
39
+ Units: `"second" | "minute" | "hour" | "day" | "week" | "month" | "year"`.
40
+
41
+ **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.
42
+
43
+ ## Target a specific time — `.at("HH:mm" | "HH:mm:ss")`
44
+
45
+ ```ts
46
+ job("nightly", fn).daily().at("03:00");
47
+ job("midday", fn).daily().at("12:30:15");
48
+ ```
49
+
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).
51
+
52
+ ## Target a specific day — `.on(day)`
53
+
54
+ Day-of-week (string) or day-of-month (number 1–31):
55
+
56
+ ```ts
57
+ job("monday-standup", task).weekly().on("monday");
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
60
+ ```
61
+
62
+ Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`.
63
+
64
+ **Validation.** Numeric `on(N)` throws if `N < 1 || N > 31`.
65
+
66
+ **Gotcha.** `monthly().on(31)` clamps to the actual month length in dayjs — February runs are unpredictable. Use `endOf("month")` for "last day" semantics instead.
67
+
68
+ ## Boundary shortcuts — `.beginOf(type)` / `.endOf(type)`
69
+
70
+ Both accept `"day" | "month" | "year"`.
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 |
80
+
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
+
83
+ **`beginOf("year")` / `endOf("year")` lock the month** — always Jan 1 / Dec 31, never "1st/31st of whatever month the job was defined in."
84
+
85
+ ## Cron is an alternative, not an addition
86
+
87
+ `.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).
88
+
89
+ ## Chaining order doesn't matter (for the most part)
90
+
91
+ 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**:
92
+
93
+ ```ts
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
101
+ ```
102
+
103
+ ## Inline registration — `scheduler.newJob()`
104
+
105
+ For one-liners, the scheduler exposes a `newJob()` shortcut that creates, registers, and returns the job in one call:
106
+
107
+ ```ts
108
+ scheduler
109
+ .newJob("cleanup", cleanupFn)
110
+ .daily()
111
+ .at("03:00");
112
+ ```
113
+
114
+ To register several pre-built jobs at once, `scheduler.addJobs([...])` is the batch counterpart to `addJob` (both are chainable and preserve insertion order):
115
+
116
+ ```ts
117
+ scheduler.addJobs([
118
+ job("cleanup", cleanupFn).daily().at("03:00"),
119
+ job("reports", sendReports).weekly().on("monday").at("09:00"),
120
+ ]);
121
+ ```
122
+
123
+ 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.
124
+
125
+ ## Reading state at runtime
126
+
127
+ ```ts
128
+ const j = scheduler.getJob("nightly-cleanup");
129
+
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
135
+ ```
136
+
137
+ ## See also
138
+
139
+ - [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — when the fluent API isn't enough
140
+ - [`@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
141
+ - [`@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md) — `.inTimezone()`
@@ -1,123 +1,123 @@
1
- ---
2
- name: schedule-with-cron
3
- description: "Cron expressions in @warlock.js/scheduler; use when you need to schedule with cron."
4
- ---
5
-
6
- # Cron expressions
7
-
8
- The escape hatch when the fluent API can't express a schedule. Activating `.cron()` clears any prior fluent config — the two are mutually exclusive.
9
-
10
- ## 5-field syntax
11
-
12
- ```
13
- ┌───────────── minute (0-59)
14
- │ ┌───────────── hour (0-23)
15
- │ │ ┌───────────── day-of-month (1-31)
16
- │ │ │ ┌───────────── month (1-12)
17
- │ │ │ │ ┌───────────── day-of-week (0-6, Sunday = 0)
18
- │ │ │ │ │
19
- * * * * *
20
- ```
21
-
22
- **Note:** Sunday is `0` only. Some cron dialects also accept `7` for Sunday — this parser does NOT.
23
-
24
- ## Operators
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 * * *` |
34
-
35
- **Not supported (yet):** named tokens (`MON-FRI`, `JAN-DEC`), special strings (`@daily`, `@hourly`, `@reboot`), seconds field (6-field), Quartz modifiers (`L`, `W`, `#`).
36
-
37
- ## Day-of-month + day-of-week — Vixie OR semantics
38
-
39
- 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.
40
-
41
- ```ts
42
- // "1st of month OR any Monday" — fires on the 1st even if not a Monday,
43
- // AND fires on any Monday even if not the 1st.
44
- job("digest", sendDigest).cron("0 0 1 * 1");
45
-
46
- // "15th of month" — DOW is *, so only DOM applies. Fires only on the 15th.
47
- job("midmonth", task).cron("0 0 15 * *");
48
-
49
- // "Every Monday" — DOM is *, so only DOW applies. Fires only on Mondays.
50
- job("monday-only", task).cron("0 0 * * 1");
51
- ```
52
-
53
- This matches Vixie cron (the de facto standard on Unix). Migrating an existing cron table from `crontab(5)` should "just work" semantically.
54
-
55
- ## Common recipes
56
-
57
- ```ts
58
- // Every 5 minutes
59
- .cron("*/5 * * * *")
60
-
61
- // Top of every hour
62
- .cron("0 * * * *")
63
-
64
- // Every weekday at 9 AM
65
- .cron("0 9 * * 1-5")
66
-
67
- // 2:30 PM on the 15th of every month
68
- .cron("30 14 15 * *")
69
-
70
- // Every 2 hours, on the hour
71
- .cron("0 */2 * * *")
72
-
73
- // First day of each month at midnight
74
- .cron("0 0 1 * *")
75
- ```
76
-
77
- ## Validation
78
-
79
- `new CronParser(expr)` and `.cron(expr)` both throw at definition time on:
80
-
81
- - Wrong field count (must be exactly 5)
82
- - Non-numeric values, out-of-range values, inverted ranges (`5-1`)
83
- - Step `<= 0`, non-numeric step
84
- - 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.
85
-
86
- ```ts
87
- new CronParser("0 0 30 2 *"); // throws: Impossible cron expression — Feb never has a 30th
88
- new CronParser("0 0 31 4 *"); // throws: April has only 30 days
89
- ```
90
-
91
- **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.
92
-
93
- Throws are eager — bad expressions fail fast at construction, not at first tick.
94
-
95
- ## Standalone preview — `parseCron()`
96
-
97
- The `CronParser` class is exported as a utility for ad-hoc next-run calculation, separate from any job:
98
-
99
- ```ts
100
- import { parseCron } from "@warlock.js/scheduler";
101
- import dayjs from "dayjs";
102
-
103
- const parser = parseCron("0 9 * * 1-5");
104
-
105
- parser.nextRun().toISOString(); // next weekday at 9 AM
106
- parser.nextRun(dayjs("2026-12-31")).format(); // from a specific anchor
107
-
108
- parser.matches(dayjs()); // is "now" a fire moment?
109
-
110
- parser.fields; // parsed numeric arrays
111
- parser.expression; // original string
112
- ```
113
-
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.
115
-
116
- ## Timezone interaction
117
-
118
- 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).
119
-
120
- ## See also
121
-
122
- - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the higher-level alternative for human-readable schedules
123
- - [`@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
1
+ ---
2
+ name: schedule-with-cron
3
+ 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`.'
4
+ ---
5
+
6
+ # Cron expressions
7
+
8
+ The escape hatch when the fluent API can't express a schedule. Activating `.cron()` clears any prior fluent config — the two are mutually exclusive.
9
+
10
+ ## 5-field syntax
11
+
12
+ ```
13
+ ┌───────────── minute (0-59)
14
+ │ ┌───────────── hour (0-23)
15
+ │ │ ┌───────────── day-of-month (1-31)
16
+ │ │ │ ┌───────────── month (1-12)
17
+ │ │ │ │ ┌───────────── day-of-week (0-6, Sunday = 0)
18
+ │ │ │ │ │
19
+ * * * * *
20
+ ```
21
+
22
+ **Note:** Sunday is `0` only. Some cron dialects also accept `7` for Sunday — this parser does NOT.
23
+
24
+ ## Operators
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 * * *` |
34
+
35
+ **Not supported (yet):** named tokens (`MON-FRI`, `JAN-DEC`), special strings (`@daily`, `@hourly`, `@reboot`), seconds field (6-field), Quartz modifiers (`L`, `W`, `#`).
36
+
37
+ ## Day-of-month + day-of-week — Vixie OR semantics
38
+
39
+ 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.
40
+
41
+ ```ts
42
+ // "1st of month OR any Monday" — fires on the 1st even if not a Monday,
43
+ // AND fires on any Monday even if not the 1st.
44
+ job("digest", sendDigest).cron("0 0 1 * 1");
45
+
46
+ // "15th of month" — DOW is *, so only DOM applies. Fires only on the 15th.
47
+ job("midmonth", task).cron("0 0 15 * *");
48
+
49
+ // "Every Monday" — DOM is *, so only DOW applies. Fires only on Mondays.
50
+ job("monday-only", task).cron("0 0 * * 1");
51
+ ```
52
+
53
+ This matches Vixie cron (the de facto standard on Unix). Migrating an existing cron table from `crontab(5)` should "just work" semantically.
54
+
55
+ ## Common recipes
56
+
57
+ ```ts
58
+ // Every 5 minutes
59
+ .cron("*/5 * * * *")
60
+
61
+ // Top of every hour
62
+ .cron("0 * * * *")
63
+
64
+ // Every weekday at 9 AM
65
+ .cron("0 9 * * 1-5")
66
+
67
+ // 2:30 PM on the 15th of every month
68
+ .cron("30 14 15 * *")
69
+
70
+ // Every 2 hours, on the hour
71
+ .cron("0 */2 * * *")
72
+
73
+ // First day of each month at midnight
74
+ .cron("0 0 1 * *")
75
+ ```
76
+
77
+ ## Validation
78
+
79
+ `new CronParser(expr)` and `.cron(expr)` both throw at definition time on:
80
+
81
+ - Wrong field count (must be exactly 5)
82
+ - Non-numeric values, out-of-range values, inverted ranges (`5-1`)
83
+ - Step `<= 0`, non-numeric step
84
+ - 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.
85
+
86
+ ```ts
87
+ new CronParser("0 0 30 2 *"); // throws: Impossible cron expression — Feb never has a 30th
88
+ new CronParser("0 0 31 4 *"); // throws: April has only 30 days
89
+ ```
90
+
91
+ **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.
92
+
93
+ Throws are eager — bad expressions fail fast at construction, not at first tick.
94
+
95
+ ## Standalone preview — `parseCron()`
96
+
97
+ The `CronParser` class is exported as a utility for ad-hoc next-run calculation, separate from any job:
98
+
99
+ ```ts
100
+ import { parseCron } from "@warlock.js/scheduler";
101
+ import dayjs from "dayjs";
102
+
103
+ const parser = parseCron("0 9 * * 1-5");
104
+
105
+ parser.nextRun().toISOString(); // next weekday at 9 AM
106
+ parser.nextRun(dayjs("2026-12-31")).format(); // from a specific anchor
107
+
108
+ parser.matches(dayjs()); // is "now" a fire moment?
109
+
110
+ parser.fields; // parsed numeric arrays
111
+ parser.expression; // original string
112
+ ```
113
+
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.
115
+
116
+ ## Timezone interaction
117
+
118
+ 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).
119
+
120
+ ## See also
121
+
122
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the higher-level alternative for human-readable schedules
123
+ - [`@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