@basaltkit/scheduler 1.0.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/LICENSE +21 -0
- package/README.md +267 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +286 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Machize Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# @basaltkit/scheduler
|
|
2
|
+
|
|
3
|
+
Task scheduler for Basalt applications: define, with a fluent, readable API (`daily().at('03:00')`), tasks that run automatically at set times — backups, reports, cleanups, billing.
|
|
4
|
+
|
|
5
|
+
You need this module whenever you want something to happen **on a schedule**, rather than in response to a user request.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What this module solves
|
|
10
|
+
|
|
11
|
+
Many applications need periodic work: deleting expired sessions every night, sending a weekly summary on Sunday, closing billing on the 1st of each month. The traditional way to do this is **cron** — a 5-field format (`minute hour day-of-month month day-of-week`, e.g. `0 3 * * *` = "every day at 03:00") that is powerful but easy to get wrong.
|
|
12
|
+
|
|
13
|
+
This module lets you declare schedules in readable code — `schedule.call('backup', doBackup).daily().at('03:00')` — without memorizing cron syntax (though it also accepts it as an "escape hatch"). The scheduler wakes up **once a minute**, checks which entries are "due" that minute, and runs them.
|
|
14
|
+
|
|
15
|
+
It also handles the annoying problems: **timezones** (schedule at 03:00 in Lisbon or São Paulo, not the server's), **overlap** (if the previous run is still in progress, skip the new one with `withoutOverlapping()`), **failures** (`onFailure` handler per entry; without it, the error is aggregated without crashing the process) and **testability** (the `tick(data)` method is deterministic — in tests you call it with a fixed date, with no need to wait on real clocks).
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add @basaltkit/scheduler
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Depends on `@basaltkit/core` and integrates (optionally) with `@basaltkit/queue`.
|
|
24
|
+
|
|
25
|
+
## Getting started in 5 minutes
|
|
26
|
+
|
|
27
|
+
**1. Register the plugin and define the schedules** (e.g. `src/app.ts`):
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createApp } from '@basaltkit/core'
|
|
31
|
+
import { schedulerPlugin } from '@basaltkit/scheduler'
|
|
32
|
+
|
|
33
|
+
const app = await createApp({
|
|
34
|
+
plugins: [
|
|
35
|
+
schedulerPlugin({
|
|
36
|
+
define: (schedule) => {
|
|
37
|
+
// every minute
|
|
38
|
+
schedule.call('heartbeat', () => console.log('still alive'))
|
|
39
|
+
|
|
40
|
+
// every day at 03:00 UTC
|
|
41
|
+
schedule
|
|
42
|
+
.call('backup', async () => {
|
|
43
|
+
// do the backup…
|
|
44
|
+
})
|
|
45
|
+
.daily()
|
|
46
|
+
.at('03:00')
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
}).boot()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**2. There's no step 2.** On `boot`, the plugin calls your `define`, and the internal timer starts on its own (it aligns to the next minute and then checks every 60 seconds). On application `shutdown`, the timer stops.
|
|
54
|
+
|
|
55
|
+
To see what's scheduled:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { SCHEDULER } from '@basaltkit/scheduler'
|
|
59
|
+
|
|
60
|
+
console.log(app.container.get(SCHEDULER).list())
|
|
61
|
+
// [{ name: 'heartbeat', cron: '* * * * *', timezone: 'UTC' },
|
|
62
|
+
// { name: 'backup', cron: '0 3 * * *', timezone: 'UTC' }]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage guide
|
|
66
|
+
|
|
67
|
+
### Fluent frequencies
|
|
68
|
+
|
|
69
|
+
Each method returns the entry itself, so they can be chained:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { Scheduler } from '@basaltkit/scheduler'
|
|
73
|
+
|
|
74
|
+
const schedule = new Scheduler()
|
|
75
|
+
|
|
76
|
+
schedule.call('a', task).everyMinute() // * * * * *
|
|
77
|
+
schedule.call('b', task).everyMinutes(15) // */15 * * * *
|
|
78
|
+
schedule.call('c', task).hourly() // 0 * * * * (at minute 0)
|
|
79
|
+
schedule.call('d', task).daily() // 0 0 * * * (midnight)
|
|
80
|
+
schedule.call('e', task).daily().at('03:30') // 30 3 * * *
|
|
81
|
+
schedule.call('f', task).weekly() // 0 0 * * 0 (Sunday at midnight)
|
|
82
|
+
schedule.call('g', task).weekly().sundays().at('08:00') // 0 8 * * 0
|
|
83
|
+
schedule.call('h', task).monthly().at('00:30') // 30 0 1 * * (the 1st of the month)
|
|
84
|
+
schedule.call('i', task).mondays().at('09:00') // days of the week: sundays()…saturdays()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`at('HH:mm')` combines with `daily()`/`weekly()`/`monthly()` — it sets the hour and minute.
|
|
88
|
+
|
|
89
|
+
### Direct cron expression
|
|
90
|
+
|
|
91
|
+
When the fluent API isn't enough, pass raw cron (5 fields; supports `*`, `*/n` steps, `a-b` ranges and `a,b,c` lists):
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// every 15 minutes, from 9am to 5pm, Monday to Friday
|
|
95
|
+
schedule.call('sync', sync).cron('*/15 9-17 * * 1-5')
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
An invalid expression throws `CronParseError` (code `CRON_INVALID`).
|
|
99
|
+
|
|
100
|
+
### Timezones
|
|
101
|
+
|
|
102
|
+
By default, times are interpreted in **UTC**. Use `timezone()` with an IANA name:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
schedule
|
|
106
|
+
.call('report', generateReport)
|
|
107
|
+
.daily()
|
|
108
|
+
.at('03:00')
|
|
109
|
+
.timezone('America/Sao_Paulo') // 03:00 in São Paulo = 06:00 UTC
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Avoiding overlap: `withoutOverlapping()`
|
|
113
|
+
|
|
114
|
+
If a task takes longer than the interval between runs, the new run is **skipped** while the previous one is in progress:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const entry = schedule
|
|
118
|
+
.call('slow-import', importEverything)
|
|
119
|
+
.everyMinute()
|
|
120
|
+
.withoutOverlapping()
|
|
121
|
+
|
|
122
|
+
// entry.skippedOverlaps counts the skipped runs (observability/tests)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Handling failures: `onFailure()`
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
schedule
|
|
129
|
+
.call('fragile', taskThatMightFail)
|
|
130
|
+
.hourly()
|
|
131
|
+
.onFailure((error) => {
|
|
132
|
+
console.error('task failed', error)
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- **With** `onFailure`: the error is delivered to the handler and doesn't propagate.
|
|
137
|
+
- **Without** `onFailure`: on manual `tick()`, errors from due entries are aggregated into an `AggregateError` (all due entries run in the same call). In automatic mode (timer), the failure is swallowed so as not to crash the process — so in production, always set `onFailure` (or use `schedule.job(...)`, see below, and leave retries to the queue).
|
|
138
|
+
|
|
139
|
+
### Scheduling queue jobs: `schedule.job()`
|
|
140
|
+
|
|
141
|
+
Instead of running the task in the scheduler's own process, you can schedule the **dispatch** of an `@basaltkit/queue` job — the heavy lifting runs on the worker, with retries and context:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { createApp } from '@basaltkit/core'
|
|
145
|
+
import { defineJob, queuePlugin } from '@basaltkit/queue'
|
|
146
|
+
import { schedulerPlugin } from '@basaltkit/scheduler'
|
|
147
|
+
import { z } from 'zod'
|
|
148
|
+
|
|
149
|
+
const ReconcileBilling = defineJob({
|
|
150
|
+
name: 'billing.reconcile',
|
|
151
|
+
schema: z.object({ mode: z.string() }),
|
|
152
|
+
async handle({ mode }) { /* reconcile… */ },
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const app = await createApp({
|
|
156
|
+
plugins: [
|
|
157
|
+
queuePlugin({ jobs: [ReconcileBilling] }),
|
|
158
|
+
schedulerPlugin({
|
|
159
|
+
define: (schedule) => {
|
|
160
|
+
schedule.job(ReconcileBilling, { mode: 'full' }).daily().at('03:00')
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
],
|
|
164
|
+
}).boot()
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The entry is named after the job (`billing.reconcile`). If the job takes no payload (`T = void`), just call `schedule.job(MyJob)`.
|
|
168
|
+
|
|
169
|
+
### Testing deterministically
|
|
170
|
+
|
|
171
|
+
`tick(date)` runs everything due at that exact instant — no real timers involved:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { Scheduler } from '@basaltkit/scheduler'
|
|
175
|
+
|
|
176
|
+
const scheduler = new Scheduler()
|
|
177
|
+
let runs = 0
|
|
178
|
+
scheduler.call('backup', () => void runs++).daily().at('03:00')
|
|
179
|
+
|
|
180
|
+
await scheduler.tick(new Date('2026-08-05T10:15:00Z')) // not 03:00 → doesn't run
|
|
181
|
+
await scheduler.tick(new Date('2026-08-05T03:00:00Z')) // runs
|
|
182
|
+
console.log(runs) // 1
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
In tests with the plugin, pass `autostart: false` so the timer doesn't start.
|
|
186
|
+
|
|
187
|
+
## API reference
|
|
188
|
+
|
|
189
|
+
### `schedulerPlugin(options?: SchedulerPluginOptions)`
|
|
190
|
+
|
|
191
|
+
Registers a `Scheduler` (singleton) under the `SCHEDULER` token; on `boot` it calls `define`, publishes the entries to the container's metadata (key `schedule:entries`, consumed by the `basalt schedule:list` CLI) and starts the timer; on `shutdown` it calls `stop()`.
|
|
192
|
+
|
|
193
|
+
| Option | Type | Required? | Default | Description |
|
|
194
|
+
|---|---|---|---|---|
|
|
195
|
+
| `define` | `(schedule: Scheduler) => void` | No | — | Callback where you declare the schedules (receives the Scheduler at boot). |
|
|
196
|
+
| `autostart` | `boolean` | No | `true` | Starts the timer at boot. Turn off in tests. |
|
|
197
|
+
|
|
198
|
+
### `class Scheduler`
|
|
199
|
+
|
|
200
|
+
| Method | Signature | Description |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| `call` | `(name: string, task: () => void \| Promise<void>) => ScheduleEntry` | Schedules a function with a name. |
|
|
203
|
+
| `job` | `<T>(job: JobDefinition<T>, payload?) => ScheduleEntry` | Schedules the `dispatch` of an `@basaltkit/queue` job (payload required if the job needs one). |
|
|
204
|
+
| `list` | `() => { name, cron, timezone }[]` | Describes all entries. |
|
|
205
|
+
| `tick` | `(date?: Date) => Promise<void>` | Runs the entries due at that instant (default: now). Aggregates failures without `onFailure` into an `AggregateError`. |
|
|
206
|
+
| `start` | `() => void` | Aligns to the next minute and then `tick()`s every 60s. Idempotent. |
|
|
207
|
+
| `stop` | `() => void` | Stops the timers. |
|
|
208
|
+
|
|
209
|
+
### `class ScheduleEntry` (returned by `call`/`job`)
|
|
210
|
+
|
|
211
|
+
Frequency methods (all return `this`): `everyMinute()`, `everyMinutes(n)`, `hourly()`, `daily()`, `weekly()`, `monthly()`, `at('HH:mm')`, `cron(expression)`, `sundays()`, `mondays()`, `tuesdays()`, `wednesdays()`, `thursdays()`, `fridays()`, `saturdays()`.
|
|
212
|
+
|
|
213
|
+
| Method/property | Type | Default | Description |
|
|
214
|
+
|---|---|---|---|
|
|
215
|
+
| `timezone(tz)` | `(tz: string) => this` | `'UTC'` | IANA timezone in which the time is interpreted. |
|
|
216
|
+
| `withoutOverlapping()` | `() => this` | off | Skips the run if the previous one is still in progress. |
|
|
217
|
+
| `onFailure(handler)` | `((error: unknown) => void) => this` | — | Receives the error instead of propagating it. |
|
|
218
|
+
| `describe()` | `() => { name, cron, timezone }` | — | Description of the entry. |
|
|
219
|
+
| `isDue(date)` | `(date: Date) => boolean` | — | Is the entry due at this instant? |
|
|
220
|
+
| `skippedOverlaps` | `number` | `0` | Counter of runs skipped due to overlap. |
|
|
221
|
+
| `run()` | `() => Promise<void>` | — | **Advanced/internal**: runs with overlap guard and failure handling. |
|
|
222
|
+
|
|
223
|
+
Without any frequency method, the entry runs **every minute** (initial cron fields are `* * * * *`).
|
|
224
|
+
|
|
225
|
+
### Cron utilities (Advanced)
|
|
226
|
+
|
|
227
|
+
Exported for tooling and tests; you don't usually need them:
|
|
228
|
+
|
|
229
|
+
| Export | Signature | Description |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `parseCron` | `(expression: string) => CronFields` | Splits a 5-field expression; throws `CronParseError` if invalid. |
|
|
232
|
+
| `cronMatches` | `(fields: CronFields, date: Date, timeZone?: string) => boolean` | Does the instant match the expression (in the given timezone)? |
|
|
233
|
+
| `fieldMatches` | `(field: string, value: number) => boolean` | Does a field (`*`, `*/n`, `a-b`, `a,b,c`, value) accept the number? |
|
|
234
|
+
| `zonedParts` | `(date: Date, timeZone = 'UTC') => ZonedParts` | Breaks the instant down into minute/hour/day/month/day-of-week in the timezone. |
|
|
235
|
+
| `CronParseError` | class (`BasaltError`, code `CRON_INVALID`) | Invalid cron expression. |
|
|
236
|
+
| `CronFields`, `ZonedParts` | types | Cron fields as strings; numeric parts of the instant. |
|
|
237
|
+
|
|
238
|
+
### Token
|
|
239
|
+
|
|
240
|
+
- `SCHEDULER: Token<Scheduler>` — to get the Scheduler from the container: `app.container.get(SCHEDULER)`.
|
|
241
|
+
|
|
242
|
+
## Common errors and solutions (FAQ)
|
|
243
|
+
|
|
244
|
+
**My `daily().at('03:00')` task runs at the wrong time.**
|
|
245
|
+
Times are UTC by default. Add `.timezone('Europe/Lisbon')` (or your IANA timezone).
|
|
246
|
+
|
|
247
|
+
**`CronParseError: expected 5 fields`.**
|
|
248
|
+
`cron()` only accepts the classic 5-field format (`min hour day month day-of-week`). 6-field formats (with seconds) are not supported.
|
|
249
|
+
|
|
250
|
+
**The task runs twice (two servers).**
|
|
251
|
+
The scheduler runs in every process where the plugin starts. If you have multiple replicas, enable the scheduler on only one (e.g. via an environment variable) or schedule `schedule.job(...)` with idempotent jobs.
|
|
252
|
+
|
|
253
|
+
**A task failed and I didn't see anything.**
|
|
254
|
+
In automatic mode, failures without `onFailure` are silenced so as not to crash the process. Set `onFailure` on each entry (or log inside it).
|
|
255
|
+
|
|
256
|
+
**I need second-level precision.**
|
|
257
|
+
Not possible — the resolution is the minute (the `tick` runs every 60s), just like classic cron.
|
|
258
|
+
|
|
259
|
+
**In tests, the process doesn't exit.**
|
|
260
|
+
Pass `autostart: false` to the plugin, or call `scheduler.stop()`. (The timers use `unref()`, so they usually don't hold the process open, but in tests it's best not to start them.)
|
|
261
|
+
|
|
262
|
+
## How it connects to other modules
|
|
263
|
+
|
|
264
|
+
- **`@basaltkit/core`** — `schedulerPlugin` is a core plugin (register/boot/shutdown); entries are published to the container's metadata registry (`ensureMetadata` → key `schedule:entries`) for the `basalt schedule:list` CLI; `CronParseError` extends `BasaltError`.
|
|
265
|
+
- **`@basaltkit/queue`** — `schedule.job(MyJob, payload)` schedules a job's *dispatch*: the scheduler only enqueues it; execution, retries and context are handled by the queue and its workers. This is the recommended pattern for heavy or critical tasks.
|
|
266
|
+
- **`@basaltkit/logger`** — use the logger inside tasks/`onFailure` to get structured traces of the runs.
|
|
267
|
+
- **`@basaltkit/audit` / `@basaltkit/activity`** — scheduled tasks can record audit or activity entries (e.g. `audit.record('maintenance.run')`) to leave a trail of the automated work.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as _basaltkit_core from '@basaltkit/core';
|
|
2
|
+
import { BasaltError } from '@basaltkit/core';
|
|
3
|
+
import { JobDefinition } from '@basaltkit/queue';
|
|
4
|
+
|
|
5
|
+
declare class CronParseError extends BasaltError {
|
|
6
|
+
constructor(expression: string, detail: string);
|
|
7
|
+
}
|
|
8
|
+
interface CronFields {
|
|
9
|
+
minute: string;
|
|
10
|
+
hour: string;
|
|
11
|
+
dayOfMonth: string;
|
|
12
|
+
month: string;
|
|
13
|
+
dayOfWeek: string;
|
|
14
|
+
}
|
|
15
|
+
declare function parseCron(expression: string): CronFields;
|
|
16
|
+
/** Supports: asterisk, steps (asterisk/n), single value, a-b ranges and a,b,c lists. */
|
|
17
|
+
declare function fieldMatches(field: string, value: number): boolean;
|
|
18
|
+
interface ZonedParts {
|
|
19
|
+
minute: number;
|
|
20
|
+
hour: number;
|
|
21
|
+
dayOfMonth: number;
|
|
22
|
+
month: number;
|
|
23
|
+
dayOfWeek: number;
|
|
24
|
+
}
|
|
25
|
+
/** Decomposes an instant into the cron fields, in the requested time zone (default UTC). */
|
|
26
|
+
declare function zonedParts(date: Date, timeZone?: string): ZonedParts;
|
|
27
|
+
declare function cronMatches(fields: CronFields, date: Date, timeZone?: string): boolean;
|
|
28
|
+
|
|
29
|
+
type Task = () => void | Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* A scheduled entry, built fluently:
|
|
32
|
+
*
|
|
33
|
+
* schedule.job(ReconcileBilling).daily().at('03:00').timezone('UTC')
|
|
34
|
+
* schedule.call('purge-cache', () => cache.flush()).everyMinute().withoutOverlapping()
|
|
35
|
+
*/
|
|
36
|
+
declare class ScheduleEntry {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
private readonly task;
|
|
39
|
+
private fields;
|
|
40
|
+
private tz;
|
|
41
|
+
private noOverlap;
|
|
42
|
+
private failureHandler;
|
|
43
|
+
private running;
|
|
44
|
+
/** count of executions skipped due to overlap — visible for observability/tests */
|
|
45
|
+
skippedOverlaps: number;
|
|
46
|
+
constructor(name: string, task: Task);
|
|
47
|
+
everyMinute(): this;
|
|
48
|
+
everyMinutes(n: number): this;
|
|
49
|
+
hourly(): this;
|
|
50
|
+
daily(): this;
|
|
51
|
+
weekly(): this;
|
|
52
|
+
monthly(): this;
|
|
53
|
+
/** 'HH:mm' time — combines with daily/weekly/monthly. */
|
|
54
|
+
at(time: string): this;
|
|
55
|
+
/** Raw cron expression (5 fields) — escape hatch. */
|
|
56
|
+
cron(expression: string): this;
|
|
57
|
+
sundays(): this;
|
|
58
|
+
mondays(): this;
|
|
59
|
+
tuesdays(): this;
|
|
60
|
+
wednesdays(): this;
|
|
61
|
+
thursdays(): this;
|
|
62
|
+
fridays(): this;
|
|
63
|
+
saturdays(): this;
|
|
64
|
+
timezone(tz: string): this;
|
|
65
|
+
/** If the previous execution is still running, the new one is skipped. */
|
|
66
|
+
withoutOverlapping(): this;
|
|
67
|
+
onFailure(handler: (error: unknown) => void): this;
|
|
68
|
+
/** Entry description — consumed by `basalt schedule list`. */
|
|
69
|
+
describe(): {
|
|
70
|
+
name: string;
|
|
71
|
+
cron: string;
|
|
72
|
+
timezone: string;
|
|
73
|
+
};
|
|
74
|
+
isDue(date: Date): boolean;
|
|
75
|
+
/** @internal runs the task with the overlap guard and failure handling. */
|
|
76
|
+
run(): Promise<void>;
|
|
77
|
+
private onDayOfWeek;
|
|
78
|
+
}
|
|
79
|
+
declare class Scheduler {
|
|
80
|
+
private readonly entries;
|
|
81
|
+
private timer;
|
|
82
|
+
private interval;
|
|
83
|
+
/** Schedules the dispatch of a @basaltkit/queue job. */
|
|
84
|
+
job<T>(job: JobDefinition<T>, ...payload: T extends void ? [] : [T]): ScheduleEntry;
|
|
85
|
+
/** Schedules a named function. */
|
|
86
|
+
call(name: string, task: Task): ScheduleEntry;
|
|
87
|
+
list(): {
|
|
88
|
+
name: string;
|
|
89
|
+
cron: string;
|
|
90
|
+
timezone: string;
|
|
91
|
+
}[];
|
|
92
|
+
/**
|
|
93
|
+
* Runs the entries due at the given instant. Deterministic — this is what
|
|
94
|
+
* the tests call directly and what the timer calls every minute.
|
|
95
|
+
* Failures (without onFailure) are aggregated; all due entries run.
|
|
96
|
+
*/
|
|
97
|
+
tick(date?: Date): Promise<void>;
|
|
98
|
+
/** Aligns to the next minute and then runs tick() every 60s. */
|
|
99
|
+
start(): void;
|
|
100
|
+
stop(): void;
|
|
101
|
+
private safeTick;
|
|
102
|
+
private add;
|
|
103
|
+
}
|
|
104
|
+
declare const SCHEDULER: _basaltkit_core.Token<Scheduler>;
|
|
105
|
+
interface SchedulerPluginOptions {
|
|
106
|
+
/** Callback that defines the schedules — receives the Scheduler at boot. */
|
|
107
|
+
define?: (schedule: Scheduler) => void;
|
|
108
|
+
/** Starts the timer at boot. Default: true (turn off in tests). */
|
|
109
|
+
autostart?: boolean;
|
|
110
|
+
}
|
|
111
|
+
declare function schedulerPlugin(options?: SchedulerPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
|
|
112
|
+
|
|
113
|
+
export { type CronFields, CronParseError, SCHEDULER, ScheduleEntry, Scheduler, type SchedulerPluginOptions, type ZonedParts, cronMatches, fieldMatches, parseCron, schedulerPlugin, zonedParts };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createToken, definePlugin, ensureMetadata } from "@basaltkit/core";
|
|
3
|
+
|
|
4
|
+
// src/cron.ts
|
|
5
|
+
import { BasaltError } from "@basaltkit/core";
|
|
6
|
+
var CronParseError = class extends BasaltError {
|
|
7
|
+
constructor(expression, detail) {
|
|
8
|
+
super("CRON_INVALID", `Invalid cron expression "${expression}": ${detail}`);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
function parseCron(expression) {
|
|
12
|
+
const parts = expression.trim().split(/\s+/);
|
|
13
|
+
if (parts.length !== 5) {
|
|
14
|
+
throw new CronParseError(expression, `expected 5 fields, received ${parts.length}`);
|
|
15
|
+
}
|
|
16
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
17
|
+
return { minute, hour, dayOfMonth, month, dayOfWeek };
|
|
18
|
+
}
|
|
19
|
+
function cronToString(fields) {
|
|
20
|
+
return [fields.minute, fields.hour, fields.dayOfMonth, fields.month, fields.dayOfWeek].join(" ");
|
|
21
|
+
}
|
|
22
|
+
function fieldMatches(field, value) {
|
|
23
|
+
if (field === "*") return true;
|
|
24
|
+
return field.split(",").some((part) => {
|
|
25
|
+
const step = /^\*\/(\d+)$/.exec(part);
|
|
26
|
+
if (step) return value % Number(step[1]) === 0;
|
|
27
|
+
const range = /^(\d+)-(\d+)$/.exec(part);
|
|
28
|
+
if (range) return value >= Number(range[1]) && value <= Number(range[2]);
|
|
29
|
+
return Number(part) === value;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
var WEEKDAYS = {
|
|
33
|
+
Sun: 0,
|
|
34
|
+
Mon: 1,
|
|
35
|
+
Tue: 2,
|
|
36
|
+
Wed: 3,
|
|
37
|
+
Thu: 4,
|
|
38
|
+
Fri: 5,
|
|
39
|
+
Sat: 6
|
|
40
|
+
};
|
|
41
|
+
function zonedParts(date, timeZone = "UTC") {
|
|
42
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
43
|
+
timeZone,
|
|
44
|
+
minute: "numeric",
|
|
45
|
+
hour: "numeric",
|
|
46
|
+
day: "numeric",
|
|
47
|
+
month: "numeric",
|
|
48
|
+
weekday: "short",
|
|
49
|
+
hour12: false
|
|
50
|
+
});
|
|
51
|
+
const parts = {};
|
|
52
|
+
for (const part of formatter.formatToParts(date)) {
|
|
53
|
+
parts[part.type] = part.value;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
minute: Number(parts["minute"]),
|
|
57
|
+
// Intl with hour12:false may emit 24 for midnight
|
|
58
|
+
hour: Number(parts["hour"]) % 24,
|
|
59
|
+
dayOfMonth: Number(parts["day"]),
|
|
60
|
+
month: Number(parts["month"]),
|
|
61
|
+
dayOfWeek: WEEKDAYS[parts["weekday"]]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function cronMatches(fields, date, timeZone) {
|
|
65
|
+
const parts = zonedParts(date, timeZone);
|
|
66
|
+
return fieldMatches(fields.minute, parts.minute) && fieldMatches(fields.hour, parts.hour) && fieldMatches(fields.dayOfMonth, parts.dayOfMonth) && fieldMatches(fields.month, parts.month) && fieldMatches(fields.dayOfWeek, parts.dayOfWeek);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/index.ts
|
|
70
|
+
var ScheduleEntry = class {
|
|
71
|
+
constructor(name, task) {
|
|
72
|
+
this.name = name;
|
|
73
|
+
this.task = task;
|
|
74
|
+
}
|
|
75
|
+
name;
|
|
76
|
+
task;
|
|
77
|
+
fields = {
|
|
78
|
+
minute: "*",
|
|
79
|
+
hour: "*",
|
|
80
|
+
dayOfMonth: "*",
|
|
81
|
+
month: "*",
|
|
82
|
+
dayOfWeek: "*"
|
|
83
|
+
};
|
|
84
|
+
tz = "UTC";
|
|
85
|
+
noOverlap = false;
|
|
86
|
+
failureHandler;
|
|
87
|
+
running = false;
|
|
88
|
+
/** count of executions skipped due to overlap — visible for observability/tests */
|
|
89
|
+
skippedOverlaps = 0;
|
|
90
|
+
everyMinute() {
|
|
91
|
+
this.fields = { minute: "*", hour: "*", dayOfMonth: "*", month: "*", dayOfWeek: "*" };
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
everyMinutes(n) {
|
|
95
|
+
this.everyMinute();
|
|
96
|
+
this.fields.minute = `*/${n}`;
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
hourly() {
|
|
100
|
+
this.everyMinute();
|
|
101
|
+
this.fields.minute = "0";
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
daily() {
|
|
105
|
+
this.hourly();
|
|
106
|
+
this.fields.hour = "0";
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
weekly() {
|
|
110
|
+
this.daily();
|
|
111
|
+
this.fields.dayOfWeek = "0";
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
monthly() {
|
|
115
|
+
this.daily();
|
|
116
|
+
this.fields.dayOfMonth = "1";
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
/** 'HH:mm' time — combines with daily/weekly/monthly. */
|
|
120
|
+
at(time) {
|
|
121
|
+
const [hour, minute] = time.split(":");
|
|
122
|
+
this.fields.hour = String(Number(hour));
|
|
123
|
+
this.fields.minute = String(Number(minute ?? 0));
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
/** Raw cron expression (5 fields) — escape hatch. */
|
|
127
|
+
cron(expression) {
|
|
128
|
+
this.fields = parseCron(expression);
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
sundays() {
|
|
132
|
+
return this.onDayOfWeek(0);
|
|
133
|
+
}
|
|
134
|
+
mondays() {
|
|
135
|
+
return this.onDayOfWeek(1);
|
|
136
|
+
}
|
|
137
|
+
tuesdays() {
|
|
138
|
+
return this.onDayOfWeek(2);
|
|
139
|
+
}
|
|
140
|
+
wednesdays() {
|
|
141
|
+
return this.onDayOfWeek(3);
|
|
142
|
+
}
|
|
143
|
+
thursdays() {
|
|
144
|
+
return this.onDayOfWeek(4);
|
|
145
|
+
}
|
|
146
|
+
fridays() {
|
|
147
|
+
return this.onDayOfWeek(5);
|
|
148
|
+
}
|
|
149
|
+
saturdays() {
|
|
150
|
+
return this.onDayOfWeek(6);
|
|
151
|
+
}
|
|
152
|
+
timezone(tz) {
|
|
153
|
+
this.tz = tz;
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
/** If the previous execution is still running, the new one is skipped. */
|
|
157
|
+
withoutOverlapping() {
|
|
158
|
+
this.noOverlap = true;
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
onFailure(handler) {
|
|
162
|
+
this.failureHandler = handler;
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
/** Entry description — consumed by `basalt schedule list`. */
|
|
166
|
+
describe() {
|
|
167
|
+
return { name: this.name, cron: cronToString(this.fields), timezone: this.tz };
|
|
168
|
+
}
|
|
169
|
+
isDue(date) {
|
|
170
|
+
return cronMatches(this.fields, date, this.tz);
|
|
171
|
+
}
|
|
172
|
+
/** @internal runs the task with the overlap guard and failure handling. */
|
|
173
|
+
async run() {
|
|
174
|
+
if (this.noOverlap && this.running) {
|
|
175
|
+
this.skippedOverlaps++;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.running = true;
|
|
179
|
+
try {
|
|
180
|
+
await this.task();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (!this.failureHandler) throw error;
|
|
183
|
+
this.failureHandler(error);
|
|
184
|
+
} finally {
|
|
185
|
+
this.running = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
onDayOfWeek(day) {
|
|
189
|
+
this.fields.dayOfWeek = String(day);
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var Scheduler = class {
|
|
194
|
+
entries = [];
|
|
195
|
+
timer;
|
|
196
|
+
interval;
|
|
197
|
+
/** Schedules the dispatch of a @basaltkit/queue job. */
|
|
198
|
+
job(job, ...payload) {
|
|
199
|
+
return this.add(new ScheduleEntry(job.name, () => job.dispatch(payload[0])));
|
|
200
|
+
}
|
|
201
|
+
/** Schedules a named function. */
|
|
202
|
+
call(name, task) {
|
|
203
|
+
return this.add(new ScheduleEntry(name, task));
|
|
204
|
+
}
|
|
205
|
+
list() {
|
|
206
|
+
return this.entries.map((entry) => entry.describe());
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Runs the entries due at the given instant. Deterministic — this is what
|
|
210
|
+
* the tests call directly and what the timer calls every minute.
|
|
211
|
+
* Failures (without onFailure) are aggregated; all due entries run.
|
|
212
|
+
*/
|
|
213
|
+
async tick(date = /* @__PURE__ */ new Date()) {
|
|
214
|
+
const due = this.entries.filter((entry) => entry.isDue(date));
|
|
215
|
+
const errors = [];
|
|
216
|
+
await Promise.all(
|
|
217
|
+
due.map(async (entry) => {
|
|
218
|
+
try {
|
|
219
|
+
await entry.run();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
errors.push(error);
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
if (errors.length > 0) {
|
|
226
|
+
throw new AggregateError(errors, `Failure in ${errors.length} scheduled task(s)`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Aligns to the next minute and then runs tick() every 60s. */
|
|
230
|
+
start() {
|
|
231
|
+
if (this.timer || this.interval) return;
|
|
232
|
+
const msToNextMinute = 6e4 - Date.now() % 6e4;
|
|
233
|
+
this.timer = setTimeout(() => {
|
|
234
|
+
void this.safeTick();
|
|
235
|
+
this.interval = setInterval(() => void this.safeTick(), 6e4);
|
|
236
|
+
this.interval.unref?.();
|
|
237
|
+
}, msToNextMinute);
|
|
238
|
+
this.timer.unref?.();
|
|
239
|
+
}
|
|
240
|
+
stop() {
|
|
241
|
+
if (this.timer) clearTimeout(this.timer);
|
|
242
|
+
if (this.interval) clearInterval(this.interval);
|
|
243
|
+
this.timer = void 0;
|
|
244
|
+
this.interval = void 0;
|
|
245
|
+
}
|
|
246
|
+
async safeTick() {
|
|
247
|
+
try {
|
|
248
|
+
await this.tick();
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
add(entry) {
|
|
253
|
+
this.entries.push(entry);
|
|
254
|
+
return entry;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
var SCHEDULER = createToken("scheduler");
|
|
258
|
+
function schedulerPlugin(options = {}) {
|
|
259
|
+
return definePlugin({
|
|
260
|
+
name: "basalt:scheduler",
|
|
261
|
+
register({ container }) {
|
|
262
|
+
container.singleton(SCHEDULER, () => new Scheduler());
|
|
263
|
+
},
|
|
264
|
+
boot({ container }) {
|
|
265
|
+
const scheduler = container.get(SCHEDULER);
|
|
266
|
+
options.define?.(scheduler);
|
|
267
|
+
const metadata = ensureMetadata(container);
|
|
268
|
+
for (const entry of scheduler.list()) metadata.add("schedule:entries", entry);
|
|
269
|
+
if (options.autostart !== false) scheduler.start();
|
|
270
|
+
},
|
|
271
|
+
shutdown({ container }) {
|
|
272
|
+
container.get(SCHEDULER).stop();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
export {
|
|
277
|
+
CronParseError,
|
|
278
|
+
SCHEDULER,
|
|
279
|
+
ScheduleEntry,
|
|
280
|
+
Scheduler,
|
|
281
|
+
cronMatches,
|
|
282
|
+
fieldMatches,
|
|
283
|
+
parseCron,
|
|
284
|
+
schedulerPlugin,
|
|
285
|
+
zonedParts
|
|
286
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@basaltkit/scheduler",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fluent Basalt scheduler: schedule.job(X).daily().at('03:00'), timezones, withoutOverlapping and @basaltkit/queue integration.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@basaltkit/core": "^1.0.0",
|
|
18
|
+
"@basaltkit/queue": "^1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.15.0",
|
|
22
|
+
"tsup": "^8.4.0",
|
|
23
|
+
"typescript": "^5.8.0",
|
|
24
|
+
"vitest": "^3.1.0",
|
|
25
|
+
"zod": "^3.24.0",
|
|
26
|
+
"@basaltkit/tsconfig": "^0.24.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/Zebedeu/basalt.git",
|
|
34
|
+
"directory": "packages/scheduler"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/scheduler#readme",
|
|
37
|
+
"bugs": "https://github.com/Zebedeu/basalt/issues",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"basalt",
|
|
40
|
+
"typescript",
|
|
41
|
+
"scheduler",
|
|
42
|
+
"cron"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|