@warlock.js/scheduler 4.0.174 → 4.1.2

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.
Files changed (69) hide show
  1. package/README.md +60 -1
  2. package/cjs/index.cjs +1132 -0
  3. package/cjs/index.cjs.map +1 -0
  4. package/esm/cron-parser.d.mts +129 -0
  5. package/esm/cron-parser.d.mts.map +1 -0
  6. package/esm/cron-parser.mjs +210 -0
  7. package/esm/cron-parser.mjs.map +1 -0
  8. package/esm/index.d.mts +5 -0
  9. package/esm/index.mjs +5 -0
  10. package/esm/job.d.mts +386 -0
  11. package/esm/job.d.mts.map +1 -0
  12. package/esm/job.mjs +633 -0
  13. package/esm/job.mjs.map +1 -0
  14. package/esm/scheduler.d.mts +193 -0
  15. package/esm/scheduler.d.mts.map +1 -0
  16. package/esm/scheduler.mjs +261 -0
  17. package/esm/scheduler.mjs.map +1 -0
  18. package/esm/types.d.mts +70 -0
  19. package/esm/types.d.mts.map +1 -0
  20. package/llms-full.txt +888 -0
  21. package/llms.txt +15 -0
  22. package/package.json +40 -28
  23. package/skills/configure-retry-and-overlap/SKILL.md +137 -0
  24. package/skills/observe-scheduler/SKILL.md +153 -0
  25. package/skills/overview/SKILL.md +92 -0
  26. package/skills/pin-schedule-timezone/SKILL.md +114 -0
  27. package/skills/schedule-fluently/SKILL.md +141 -0
  28. package/skills/schedule-with-cron/SKILL.md +123 -0
  29. package/skills/scheduler-basics/SKILL.md +94 -0
  30. package/cjs/cron-parser.d.ts +0 -98
  31. package/cjs/cron-parser.d.ts.map +0 -1
  32. package/cjs/cron-parser.js +0 -193
  33. package/cjs/cron-parser.js.map +0 -1
  34. package/cjs/index.d.ts +0 -44
  35. package/cjs/index.d.ts.map +0 -1
  36. package/cjs/index.js +0 -1
  37. package/cjs/index.js.map +0 -1
  38. package/cjs/job.d.ts +0 -332
  39. package/cjs/job.d.ts.map +0 -1
  40. package/cjs/job.js +0 -616
  41. package/cjs/job.js.map +0 -1
  42. package/cjs/scheduler.d.ts +0 -182
  43. package/cjs/scheduler.d.ts.map +0 -1
  44. package/cjs/scheduler.js +0 -316
  45. package/cjs/scheduler.js.map +0 -1
  46. package/cjs/types.d.ts +0 -63
  47. package/cjs/types.d.ts.map +0 -1
  48. package/cjs/utils.d.ts +0 -3
  49. package/cjs/utils.d.ts.map +0 -1
  50. package/esm/cron-parser.d.ts +0 -98
  51. package/esm/cron-parser.d.ts.map +0 -1
  52. package/esm/cron-parser.js +0 -193
  53. package/esm/cron-parser.js.map +0 -1
  54. package/esm/index.d.ts +0 -44
  55. package/esm/index.d.ts.map +0 -1
  56. package/esm/index.js +0 -1
  57. package/esm/index.js.map +0 -1
  58. package/esm/job.d.ts +0 -332
  59. package/esm/job.d.ts.map +0 -1
  60. package/esm/job.js +0 -616
  61. package/esm/job.js.map +0 -1
  62. package/esm/scheduler.d.ts +0 -182
  63. package/esm/scheduler.d.ts.map +0 -1
  64. package/esm/scheduler.js +0 -316
  65. package/esm/scheduler.js.map +0 -1
  66. package/esm/types.d.ts +0 -63
  67. package/esm/types.d.ts.map +0 -1
  68. package/esm/utils.d.ts +0 -3
  69. package/esm/utils.d.ts.map +0 -1
package/llms.txt ADDED
@@ -0,0 +1,15 @@
1
+ # Warlock Scheduler
2
+
3
+ > Package: `@warlock.js/scheduler`
4
+
5
+ > A simple scheduler package to run jobs in scheduled times
6
+
7
+ ## Skills
8
+
9
+ - [configure-retry-and-overlap](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md): 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`.
10
+ - [observe-scheduler](@warlock.js/scheduler/observe-scheduler/SKILL.md): 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`.
11
+ - [overview](@warlock.js/scheduler/overview/SKILL.md): Front-door orientation for `@warlock.js/scheduler` — cron-like job scheduling with fluent schedule API (`.daily().at()`, `.weekly().on()`, `.cron("...")`), retry with backoff, overlap prevention, IANA timezone pinning, and seven typed lifecycle events. UTC default; opt into local time per job. TRIGGER when: code imports anything from `@warlock.js/scheduler`; user asks "what does @warlock.js/scheduler do", "compare with node-cron / agenda / bull", "schedule a cron job in Node", "how do I prevent overlapping runs", "how do I retry a failed job", "what timezone does the scheduler use"; package.json adds `@warlock.js/scheduler`. Skip: specific task already known — load the matching task skill directly (`scheduler-basics`, `schedule-fluently`, `schedule-with-cron`, `configure-retry-and-overlap`, `pin-schedule-timezone`, `observe-scheduler`); ad-hoc `setTimeout` / `setInterval` for one-off delays (no cron / retry / observability needed).
12
+ - [pin-schedule-timezone](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md): 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`.
13
+ - [schedule-fluently](@warlock.js/scheduler/schedule-fluently/SKILL.md): 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`.
14
+ - [schedule-with-cron](@warlock.js/scheduler/schedule-with-cron/SKILL.md): 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`.
15
+ - [scheduler-basics](@warlock.js/scheduler/scheduler-basics/SKILL.md): Start with `@warlock.js/scheduler` — `job()` factory + `scheduler` singleton, 2-primitive surface, UTC default, factory-first API. Triggers: `job`, `scheduler`, `scheduler.addJob`, `scheduler.start`, `scheduler.newJob`; "schedule a recurring job", "how to use warlock scheduler", "where do I start"; typical import `import { job, scheduler } from "@warlock.js/scheduler"`. Skip: fluent — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; cron — `@warlock.js/scheduler/schedule-with-cron/SKILL.md`; retry — `@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`; events — `@warlock.js/scheduler/observe-scheduler/SKILL.md`; timezone — `@warlock.js/scheduler/pin-schedule-timezone/SKILL.md`; competing `bullmq`, `agenda`, `node-cron`; native `setInterval`.
package/package.json CHANGED
@@ -1,29 +1,41 @@
1
1
  {
2
- "name": "@warlock.js/scheduler",
3
- "version": "4.0.174",
4
- "description": "A simple scheduler package to run jobs in scheduled times",
5
- "main": "./cjs/index.js",
6
- "dependencies": {
7
- "dayjs": "^1.11.19"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "https://github.com/warlockjs/scheduler"
12
- },
13
- "keywords": [
14
- "warlock.js",
15
- "scheduler",
16
- "job",
17
- "cron",
18
- "time",
19
- "date",
20
- "interval",
21
- "timer",
22
- "delay",
23
- "timeout"
24
- ],
25
- "author": "hassanzohdy",
26
- "license": "MIT",
27
- "module": "./esm/index.js",
28
- "typings": "./cjs/index.d.ts"
29
- }
2
+ "name": "@warlock.js/scheduler",
3
+ "description": "A simple scheduler package to run jobs in scheduled times",
4
+ "keywords": [
5
+ "warlock.js",
6
+ "scheduler",
7
+ "job",
8
+ "cron",
9
+ "time",
10
+ "date",
11
+ "interval",
12
+ "timer",
13
+ "delay",
14
+ "timeout"
15
+ ],
16
+ "author": "hassanzohdy",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/warlockjs/scheduler"
21
+ },
22
+ "dependencies": {
23
+ "dayjs": "^1.11.19"
24
+ },
25
+ "version": "4.1.2",
26
+ "main": "./cjs/index.cjs",
27
+ "module": "./esm/index.mjs",
28
+ "types": "./esm/index.d.mts",
29
+ "exports": {
30
+ ".": {
31
+ "import": {
32
+ "types": "./esm/index.d.mts",
33
+ "default": "./esm/index.mjs"
34
+ },
35
+ "require": {
36
+ "types": "./esm/index.d.mts",
37
+ "default": "./cjs/index.cjs"
38
+ }
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: configure-retry-and-overlap
3
+ 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`.'
4
+ ---
5
+
6
+ # Retry, backoff, and overlap
7
+
8
+ Two independent execution-control concerns on a single `Job`. They compose cleanly.
9
+
10
+ ## `.retry(maxRetries, delay?, backoffMultiplier?)`
11
+
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
25
+ ```
26
+
27
+ **Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
28
+
29
+ **Formula:** delay before attempt `N+1` = `delay × backoffMultiplier^(N-1)`. Without a multiplier, all retries wait `delay` ms.
30
+
31
+ **Validation.** Throws at definition time on negative `maxRetries`, negative `delay`, or zero/negative `backoffMultiplier`. `retry(0)` is valid — means "no retries, single attempt."
32
+
33
+ ## Retry count surfaces in `JobResult`
34
+
35
+ ```ts
36
+ scheduler.on("job:complete", (name, result) => {
37
+ if (result.retries && result.retries > 0) {
38
+ log.warn({ name, retries: result.retries }, "succeeded after retries");
39
+ }
40
+ });
41
+
42
+ scheduler.on("job:error", (name, error) => {
43
+ // Fires once, AFTER all retries are exhausted.
44
+ log.error({ name, error }, "failed permanently");
45
+ });
46
+ ```
47
+
48
+ `JobResult.retries`:
49
+ - On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
50
+ - On **failure**: equals the configured `maxRetries` (all of them were used).
51
+
52
+ ## After permanent failure — `nextRun` advances normally
53
+
54
+ This is load-bearing: a job that exhausts every retry and still throws does **not** re-fire on the next tick. The scheduler:
55
+
56
+ 1. Emits `job:error` once with the final error.
57
+ 2. Advances `nextRun` by the job's interval, same as success.
58
+
59
+ Both branches go through the same `finally`-block path in `Job.run()`. There is no "stuck on retry" mode.
60
+
61
+ ```ts
62
+ // Fires every 10 minutes, retries 3 times per fire.
63
+ // If the 10:00 run exhausts all retries, next run is at 10:10. NOT at 10:00:00.001.
64
+ job("flaky", fn).everyMinutes(10).retry(3, 1000);
65
+ ```
66
+
67
+ To **stop** firing after N consecutive failures, do it in user code:
68
+
69
+ ```ts
70
+ let consecutiveFailures = 0;
71
+ scheduler.on("job:error", name => {
72
+ if (name !== "flaky") return;
73
+ consecutiveFailures++;
74
+ if (consecutiveFailures >= 5) {
75
+ scheduler.removeJob("flaky");
76
+ alert.critical("flaky disabled after 5 failures");
77
+ }
78
+ });
79
+ scheduler.on("job:complete", name => {
80
+ if (name === "flaky") consecutiveFailures = 0;
81
+ });
82
+ ```
83
+
84
+ ## `.preventOverlap(skip = true)`
85
+
86
+ Tells the scheduler to skip a tick if the job is already running.
87
+
88
+ ```ts
89
+ job("queue", processQueue)
90
+ .everyMinutes(5)
91
+ .preventOverlap();
92
+ ```
93
+
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.
95
+
96
+ Real-world shapes that benefit:
97
+
98
+ ```ts
99
+ // 1) Boot-time recovery sweep before normal scheduling.
100
+ const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
101
+ scheduler.addJob(queueJob);
102
+ queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
103
+ scheduler.start();
104
+
105
+ // 2) Admin-triggered manual re-run from a dashboard endpoint.
106
+ app.post("/admin/jobs/:name/run", async (req, res) => {
107
+ const j = scheduler.getJob(req.params.name);
108
+ await j?.run();
109
+ res.sendStatus(204);
110
+ });
111
+
112
+ // 3) Multiple scheduler instances pointed at the same Job (rare).
113
+ ```
114
+
115
+ If a tick lands while one of these external runs is mid-flight:
116
+ - Scheduler emits `job:skip` with reason `"Job is already running"`
117
+ - The tick proceeds without re-entering the job
118
+
119
+ 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.
120
+
121
+ ## Combining retry + preventOverlap
122
+
123
+ They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
124
+
125
+ ```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
130
+ ```
131
+
132
+ If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
133
+
134
+ ## See also
135
+
136
+ - [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — full event reference, `JobResult` shape, lifecycle
137
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the scheduling methods these compose with
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: observe-scheduler
3
+ 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`.'
4
+ ---
5
+
6
+ # Observability and lifecycle
7
+
8
+ The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire listeners before calling `.start()`.
9
+
10
+ ## All seven events
11
+
12
+ ```ts
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];
18
+ "scheduler:started": [];
19
+ "scheduler:stopped": [];
20
+ "scheduler:tick": [timestamp: Date];
21
+ };
22
+ ```
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) |
33
+
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
+
36
+ ## `JobResult` shape
37
+
38
+ ```ts
39
+ type JobResult = {
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
44
+ };
45
+ ```
46
+
47
+ `duration` is wall-clock from the first attempt's start to the final settlement — useful for both p50 metrics and capacity planning.
48
+
49
+ ## Subscribing
50
+
51
+ ```ts
52
+ import type { JobResult } from "@warlock.js/scheduler";
53
+ import { scheduler } from "@warlock.js/scheduler";
54
+
55
+ scheduler.on("job:start", name => log.debug({ name }, "job start"));
56
+ scheduler.on("job:complete", (name, result: JobResult) => {
57
+ log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
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"));
61
+ ```
62
+
63
+ `.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
64
+
65
+ ## Lifecycle methods
66
+
67
+ ### `.start()`
68
+
69
+ Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
70
+
71
+ Throws if:
72
+ - already running (`"Scheduler is already running."`)
73
+ - zero jobs registered (`"Cannot start scheduler with no jobs."`)
74
+
75
+ ### `.stop()`
76
+
77
+ 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.
78
+
79
+ ### `.shutdown(timeout = 30000)`
80
+
81
+ Graceful equivalent of `.stop()`:
82
+
83
+ 1. Marks the scheduler as shutting down (no new ticks scheduled).
84
+ 2. Calls `.stop()` internally (so `scheduler:stopped` fires).
85
+ 3. Awaits every currently-running job's `waitForCompletion()`, capped by `timeout`.
86
+ 4. Resolves either when all jobs finish or when the timeout elapses.
87
+
88
+ ```ts
89
+ process.on("SIGTERM", async () => {
90
+ await scheduler.shutdown(30_000);
91
+ process.exit(0);
92
+ });
93
+ ```
94
+
95
+ 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.
96
+
97
+ ## Production observer pattern
98
+
99
+ Wire all observers before `start()`, in one place:
100
+
101
+ ```ts
102
+ import { scheduler } from "@warlock.js/scheduler";
103
+ import { logger } from "./logger";
104
+ import { metrics } from "./metrics";
105
+ import { alerts } from "./alerts";
106
+
107
+ scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
108
+
109
+ scheduler.on("job:complete", (name, result) => {
110
+ metrics.increment(`job.success.${name}`);
111
+ metrics.timing(`job.duration.${name}`, result.duration);
112
+ if (result.retries) metrics.increment(`job.retries.${name}`, result.retries);
113
+ });
114
+
115
+ scheduler.on("job:error", (name, error) => {
116
+ logger.error({ name, error }, "Job failed permanently");
117
+ alerts.critical(`Scheduler job "${name}" failed`, error);
118
+ });
119
+
120
+ scheduler.on("job:skip", (name, reason) => {
121
+ logger.warn({ name, reason }, "Job skipped — likely external invocation in flight");
122
+ });
123
+
124
+ scheduler.on("scheduler:started", () => logger.info("scheduler up"));
125
+ scheduler.on("scheduler:stopped", () => logger.info("scheduler down"));
126
+
127
+ // Register jobs, then:
128
+ scheduler.start();
129
+ ```
130
+
131
+ ## Inspection at runtime
132
+
133
+ ```ts
134
+ scheduler.isRunning; // boolean
135
+ scheduler.jobCount; // number of registered jobs
136
+ scheduler.list(); // readonly Job[]
137
+ scheduler.getJob("name"); // Job | undefined
138
+ ```
139
+
140
+ And on each `Job`:
141
+
142
+ ```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
148
+ ```
149
+
150
+ ## See also
151
+
152
+ - [`@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md) — what triggers each event in detail
153
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the methods that mutate the state these getters expose
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: overview
3
+ description: 'Front-door orientation for `@warlock.js/scheduler` — cron-like job scheduling with fluent schedule API (`.daily().at()`, `.weekly().on()`, `.cron("...")`), retry with backoff, overlap prevention, IANA timezone pinning, and seven typed lifecycle events. UTC default; opt into local time per job. TRIGGER when: code imports anything from `@warlock.js/scheduler`; user asks "what does @warlock.js/scheduler do", "compare with node-cron / agenda / bull", "schedule a cron job in Node", "how do I prevent overlapping runs", "how do I retry a failed job", "what timezone does the scheduler use"; package.json adds `@warlock.js/scheduler`. Skip: specific task already known — load the matching task skill directly (`scheduler-basics`, `schedule-fluently`, `schedule-with-cron`, `configure-retry-and-overlap`, `pin-schedule-timezone`, `observe-scheduler`); ad-hoc `setTimeout` / `setInterval` for one-off delays (no cron / retry / observability needed).'
4
+ ---
5
+
6
+ # `@warlock.js/scheduler` — overview
7
+
8
+ Production-ready job scheduler with a cron engine, fluent schedule builder, retry-with-backoff, overlap prevention, IANA timezone pinning, and seven typed lifecycle events. Two primitives — `job(name, fn)` and the `scheduler` singleton — that compose into everything else.
9
+
10
+ ## When to reach for it
11
+
12
+ - You have **recurring work** in a Node service (cleanups, reports, syncs, polling) and want it scheduled inside the app process — no external cron, no docker-cron sidecar.
13
+ - You'd reach for **node-cron** or **agenda** but want a typed fluent API, retry-with-backoff, overlap prevention, and a working observability story built in — without bringing Redis (agenda) or hand-rolling retry yourself (node-cron).
14
+ - You're inside a `@warlock.js/*` project — the framework already uses this for built-in maintenance jobs (token cleanup, etc.).
15
+
16
+ Skip if you need a **distributed** job queue with persistence, retries across processes, and worker pools — reach for **BullMQ** or **Temporal**. This package runs jobs **in-process**; if the process dies before the next tick, the schedule resumes from now, not from the missed fire time.
17
+
18
+ ## The mental model in one paragraph
19
+
20
+ Define a job with `job("name", async () => { ... })`. Attach a schedule via the fluent API (`.daily().at("03:00")`) or a cron string (`.cron("0 3 * * *")`). Optionally pin a timezone (`.inTimezone("America/New_York")`), prevent concurrent runs (`.preventOverlap()`), and configure retries (`.retry(3, 1000)`). Register the job with `scheduler.addJob(job)`, call `scheduler.start()`, and listen on the seven lifecycle events (`job:start` / `complete` / `error` / `skip`, `scheduler:started` / `stopped` / `tick`) for logging, metrics, and alerting. `scheduler.shutdown()` drains in-flight jobs before exit.
21
+
22
+ ## Skills index
23
+
24
+ Six task skills cover the full surface. Most callers only need `scheduler-basics` + `schedule-fluently` + `observe-scheduler`.
25
+
26
+ ### Foundations
27
+
28
+ #### [`scheduler-basics`](@warlock.js/scheduler/scheduler-basics/SKILL.md)
29
+ Start here. The `job(name, fn)` factory, the `scheduler` singleton, the 2-primitive surface, UTC default, why the API is factory-first.
30
+
31
+ ### Scheduling
32
+
33
+ #### [`schedule-fluently`](@warlock.js/scheduler/schedule-fluently/SKILL.md)
34
+ 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.
35
+
36
+ #### [`schedule-with-cron`](@warlock.js/scheduler/schedule-with-cron/SKILL.md)
37
+ 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.
38
+
39
+ ### Production concerns
40
+
41
+ #### [`configure-retry-and-overlap`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md)
42
+ `.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.
43
+
44
+ #### [`pin-schedule-timezone`](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md)
45
+ `.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.
46
+
47
+ ### Observability + shutdown
48
+
49
+ #### [`observe-scheduler`](@warlock.js/scheduler/observe-scheduler/SKILL.md)
50
+ 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.
51
+
52
+ ## Quick taste
53
+
54
+ ```ts
55
+ import { Scheduler, job } from "@warlock.js/scheduler";
56
+
57
+ const scheduler = new Scheduler();
58
+
59
+ scheduler.on("job:error", (name, error) => {
60
+ console.error(`${name} failed:`, error);
61
+ });
62
+
63
+ scheduler.addJob(
64
+ job("cleanup", async () => {
65
+ await cleanupExpiredTokens();
66
+ })
67
+ .daily()
68
+ .at("03:00")
69
+ .inTimezone("America/New_York")
70
+ .preventOverlap()
71
+ .retry(3, 1000),
72
+ );
73
+
74
+ scheduler.addJob(
75
+ job("reports", sendReports).cron("0 9 * * 1-5"), // 9 AM weekdays
76
+ );
77
+
78
+ scheduler.start();
79
+ process.on("SIGTERM", () => scheduler.shutdown());
80
+ ```
81
+
82
+ ## What this package deliberately doesn't do
83
+
84
+ - **Distributed scheduling across processes.** Jobs run in the calling process. For multi-instance deployments, either run the scheduler on one instance (with a leader election) or reach for BullMQ / Temporal.
85
+ - **Persistent job state across restarts.** A missed fire while the process was down is missed — it doesn't catch up. For exactly-once-eventually semantics, use a queue.
86
+ - **Job priority queues or fan-out.** One job = one function. For fan-out, your job dispatches to a queue.
87
+ - **Cron-syntax extensions** beyond the standard 5-field form (no `@daily` macros, no seconds field). Use the fluent API for human-readable schedules instead.
88
+
89
+ ## See also
90
+
91
+ - [`@warlock.js/core/overview/SKILL.md`](@warlock.js/core/overview/SKILL.md) — the parent framework that scheduled jobs typically run alongside.
92
+ - `mongez-agent-kit-authoring-skills` (load via agent-kit sync) — how this `overview/SKILL.md` becomes the front-door skill in `.claude/skills/warlock-js-scheduler-overview/`. Every cross-link above uses the `@warlock.js/scheduler/<skill>/SKILL.md` name form so it survives that flattening.
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: pin-schedule-timezone
3
+ 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`.'
4
+ ---
5
+
6
+ # Per-job timezones
7
+
8
+ 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)`.
9
+
10
+ ## Basic usage
11
+
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
17
+ ```
18
+
19
+ `.inTimezone()` is chainable — typically placed after the time/day methods.
20
+
21
+ ## Why the default is UTC
22
+
23
+ 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.
24
+
25
+ ## Multi-region fan-out
26
+
27
+ Same logical task, three regions:
28
+
29
+ ```ts
30
+ import { scheduler, job } from "@warlock.js/scheduler";
31
+
32
+ const regions = [
33
+ { name: "us-east", tz: "America/New_York" },
34
+ { name: "eu-west", tz: "Europe/Berlin" },
35
+ { name: "asia", tz: "Asia/Tokyo" },
36
+ ];
37
+
38
+ for (const { name, tz } of regions) {
39
+ scheduler.addJob(
40
+ job(`morning-report-${name}`, () => sendReport(name))
41
+ .daily()
42
+ .at("09:00")
43
+ .inTimezone(tz)
44
+ );
45
+ }
46
+
47
+ scheduler.start();
48
+ ```
49
+
50
+ Three separate `Job` instances, three separate `nextRun` calculations. Each fires once per day at its region's 09:00.
51
+
52
+ ## DST is automatic
53
+
54
+ 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.
55
+
56
+ ## Common IANA strings
57
+
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` |
72
+
73
+ Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
74
+
75
+ ## Interaction with `at()`, `on()`, cron
76
+
77
+ The timezone applies to every interpretation of "now" and every constraint:
78
+
79
+ ```ts
80
+ job("monday-standup", task)
81
+ .weekly()
82
+ .on("monday")
83
+ .at("09:00")
84
+ .inTimezone("Europe/Berlin");
85
+ // "Monday in Berlin local time" + "09:00 Berlin local time"
86
+ // — automatically shifts CET ↔ CEST as DST changes.
87
+
88
+ job("nightly-cron", task)
89
+ .cron("0 3 * * *")
90
+ .inTimezone("Asia/Tokyo");
91
+ // "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
92
+ ```
93
+
94
+ ## Validation
95
+
96
+ `.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**:
97
+
98
+ ```ts
99
+ // Throws right here — the .inTimezone() call recomputes nextRun, which
100
+ // hits dayjs().tz("Asia/Whatever") and raises RangeError immediately.
101
+ const j = job("t", task).daily().at("09:00").inTimezone("Asia/Whatever");
102
+ ```
103
+
104
+ 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.
105
+
106
+ ## Server-side caveats
107
+
108
+ 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.
109
+
110
+ ## See also
111
+
112
+ - [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — `at()`, `on()`, `daily()`, etc.
113
+ - [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — cron schedules are timezone-aware too
114
+ - [`@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