@zerotal/arch 1.7.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Scheduler
|
|
3
|
+
description: Run recurring tasks on a cron-like schedule from class files or a fluent facade, executed in the worker process.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Scheduler
|
|
7
|
+
|
|
8
|
+
Run tasks on a cron-like schedule without editing the system crontab. Drop a
|
|
9
|
+
`Schedule` subclass in `app/schedules/` and it is auto-registered at boot; a
|
|
10
|
+
fluent `Scheduler` facade is also available for quick inline definitions.
|
|
11
|
+
Schedules fire in the worker process (`bun zt worker`).
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# in your project root
|
|
17
|
+
bun add @zerotal/scheduler
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Register the provider
|
|
21
|
+
|
|
22
|
+
Add `SchedulerProvider` to the providers array in `bootstrap/providers.ts`:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// bootstrap/providers.ts
|
|
26
|
+
import { SchedulerProvider } from "@zerotal/scheduler";
|
|
27
|
+
|
|
28
|
+
const providers = [
|
|
29
|
+
// …your other providers
|
|
30
|
+
SchedulerProvider,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export default providers;
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Registering the provider switches on the following, in lifecycle order:
|
|
37
|
+
|
|
38
|
+
- `onRegister` — registers the `app/schedules/` convention, binds the
|
|
39
|
+
`scheduler` manager and the `scheduler.runs` run store as lazy singletons, and
|
|
40
|
+
contributes the static-config check to `zt doctor`.
|
|
41
|
+
- `onBooting` — resolves the `scheduler` binding so it is ready before boot finishes.
|
|
42
|
+
- `onBooted` — subscribes the run log to task events and lazily registers the
|
|
43
|
+
`schedule:list` and `schedule:runs` commands (when a command runner is present).
|
|
44
|
+
- `onStarted` — calls `scheduler.start()`, arming every registered cron.
|
|
45
|
+
- `onStopped` — calls `scheduler.stop()`, so nothing leaks between boots or test suites.
|
|
46
|
+
|
|
47
|
+
> **Note** — The provider itself loads in `web`, `console`, and `worker`, but the
|
|
48
|
+
> `app/schedules/` discovery convention runs only in `worker` (to execute the
|
|
49
|
+
> tasks) and `console` (so `schedule:list` can enumerate them). It never runs in
|
|
50
|
+
> `web`, so your HTTP instances don't fire cron. See
|
|
51
|
+
> [Conventions](/docs/conventions#schedules-appschedules).
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Create `config/scheduler.ts` with the `SchedulerConfig()` helper so every field
|
|
56
|
+
stays type-checked:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// config/scheduler.ts
|
|
60
|
+
import { SchedulerConfig } from "@zerotal/scheduler";
|
|
61
|
+
import { env } from "zerotal";
|
|
62
|
+
|
|
63
|
+
export default SchedulerConfig({
|
|
64
|
+
timezone: env("APP_TIMEZONE", "UTC"),
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Field | Required | Default | Description |
|
|
69
|
+
| ---------- | -------- | ------- | --------------------------------------------------------------------------------------- |
|
|
70
|
+
| `timezone` | no | `"UTC"` | Informational only — `Bun.cron` uses the system timezone. Set per task with `timezone`. |
|
|
71
|
+
|
|
72
|
+
> **Note** — The config `timezone` is informational. To evaluate a cron in a
|
|
73
|
+
> specific zone, set `timezone` on the `Schedule` subclass or `.timezone(tz)` on a
|
|
74
|
+
> facade task; that value is passed through to `Bun.cron`.
|
|
75
|
+
|
|
76
|
+
## Defining schedules
|
|
77
|
+
|
|
78
|
+
Create a class that extends `Schedule`, put the work in `handle()`, and declare the
|
|
79
|
+
cadence with either a `cron` string or the fluent `frequency()` method. Every
|
|
80
|
+
`Schedule` subclass under `app/schedules/` is discovered and registered
|
|
81
|
+
automatically — no manual wiring, no central list.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// app/schedules/SendDailyReports.ts
|
|
85
|
+
import { Schedule } from "@zerotal/scheduler";
|
|
86
|
+
import { Queue } from "@zerotal/queue";
|
|
87
|
+
import { SendReportsJob } from "../jobs/SendReportsJob.ts";
|
|
88
|
+
|
|
89
|
+
export class SendDailyReports extends Schedule {
|
|
90
|
+
cron = "0 8 * * *"; // every day at 08:00
|
|
91
|
+
timezone = "Africa/Johannesburg";
|
|
92
|
+
withoutOverlapping = true;
|
|
93
|
+
|
|
94
|
+
async handle(): Promise<void> {
|
|
95
|
+
await Queue.dispatch(new SendReportsJob());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Prefer the fluent frequency builder over a raw cron string when it reads better —
|
|
101
|
+
override `frequency()` and return a configured task:
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// app/schedules/WarmCache.ts
|
|
105
|
+
import { Schedule, type SchedulerBuilder } from "@zerotal/scheduler";
|
|
106
|
+
|
|
107
|
+
export class WarmCache extends Schedule {
|
|
108
|
+
override frequency(every: SchedulerBuilder) {
|
|
109
|
+
return every.everyFiveMinutes();
|
|
110
|
+
}
|
|
111
|
+
withoutOverlapping = true;
|
|
112
|
+
|
|
113
|
+
async handle(): Promise<void> {
|
|
114
|
+
await Cache.forget("posts:page:1");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Class-based vs the facade — which should I use?
|
|
120
|
+
|
|
121
|
+
- **Class-based (`Schedule` subclass)** — the default for anything non-trivial.
|
|
122
|
+
It's auto-discovered, testable in isolation, and keeps each task in its own
|
|
123
|
+
file under `app/schedules/`.
|
|
124
|
+
- **The `Scheduler` facade** — reach for it for one-liners or inline definitions
|
|
125
|
+
inside a provider (see [Inline schedules](#inline-schedules)).
|
|
126
|
+
|
|
127
|
+
### Settings reference
|
|
128
|
+
|
|
129
|
+
Every setting is an optional property (or method) on your `Schedule` subclass:
|
|
130
|
+
|
|
131
|
+
| Setting | Type | Description |
|
|
132
|
+
| -------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
133
|
+
| `handle()` | method (required) | The work to perform on each run. |
|
|
134
|
+
| `cron` | `string` | Cron expression (5- or 6-field). Set this **or** override `frequency()`. |
|
|
135
|
+
| `frequency(every)` | method | Build the cadence fluently; return the task (see helpers below). |
|
|
136
|
+
| `name` | `string` | Task name in `schedule:list` and logs. Defaults to the class name. |
|
|
137
|
+
| `timezone` | `string` | IANA timezone the cron is evaluated in. |
|
|
138
|
+
| `withoutOverlapping` | `boolean \| OverlapLockOptions` | Skip a tick while a previous run is active; also takes a cross-process lock when a lock driver is configured. |
|
|
139
|
+
| `environments` | `string[]` | Only run when `APP_ENV` is one of these. |
|
|
140
|
+
| `inBackground` | `boolean` | Run the body without blocking the scheduler tick. |
|
|
141
|
+
| `between` | `[string, string]` | Only run between `"HH:MM"` and `"HH:MM"`. |
|
|
142
|
+
| `unlessBetween` | `[string, string]` | Never run between `"HH:MM"` and `"HH:MM"`. |
|
|
143
|
+
| `pingBefore` / `pingAfter` / `pingOnSuccess` / `pingOnFailure` | `string` | Health-check URLs fetched at each lifecycle point. |
|
|
144
|
+
| `appendOutputTo` | `string` | Append captured console output to a file. |
|
|
145
|
+
| `emailOutputTo` | `string` | Email captured console output (needs an output mailer). |
|
|
146
|
+
| `when()` | method → `boolean` | Dynamic guard — run only when truthy. |
|
|
147
|
+
| `skip()` | method → `boolean` | Dynamic guard — skip when truthy. |
|
|
148
|
+
|
|
149
|
+
> **Warning** — These are **instance** properties. `static cron = "…"`
|
|
150
|
+
> typechecks (it merely declares a new static member) but registers nothing —
|
|
151
|
+
> unlike `static fillable` on a model or `static layout` on a Flow component.
|
|
152
|
+
> Discovery warns at boot when it sees static schedule config, and
|
|
153
|
+
> `bun zt doctor` reports it.
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// app/schedules/NightlyBackup.ts
|
|
157
|
+
import { Schedule, type SchedulerBuilder } from "@zerotal/scheduler";
|
|
158
|
+
|
|
159
|
+
export class NightlyBackup extends Schedule {
|
|
160
|
+
frequency(every: SchedulerBuilder) {
|
|
161
|
+
return every.dailyAt("02:30");
|
|
162
|
+
}
|
|
163
|
+
environments = ["production"];
|
|
164
|
+
between: [string, string] = ["00:00", "05:00"];
|
|
165
|
+
pingOnSuccess = "https://hc-ping.com/abc";
|
|
166
|
+
|
|
167
|
+
async handle(): Promise<void> {
|
|
168
|
+
/* … */
|
|
169
|
+
}
|
|
170
|
+
when() {
|
|
171
|
+
return featureFlags.backupsEnabled;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Frequency helpers
|
|
177
|
+
|
|
178
|
+
The `frequency(every)` builder (and the [`Scheduler` facade](#inline-schedules))
|
|
179
|
+
expose fluent cadence methods. Each returns the configured task.
|
|
180
|
+
|
|
181
|
+
| Method | Cron expression | Description |
|
|
182
|
+
| --------------------------------- | --------------------- | ------------------------------ |
|
|
183
|
+
| `.everySecond()` | `* * * * * *` | Every second (6-field) |
|
|
184
|
+
| `.everyFiveSeconds()` | `*/5 * * * * *` | Every five seconds |
|
|
185
|
+
| `.everyThirtySeconds()` | `*/30 * * * * *` | Every thirty seconds |
|
|
186
|
+
| `.everyMinute()` | `* * * * *` | Every minute |
|
|
187
|
+
| `.everyFiveMinutes()` | `*/5 * * * *` | Every five minutes |
|
|
188
|
+
| `.everyFifteenMinutes()` | `*/15 * * * *` | Every fifteen minutes |
|
|
189
|
+
| `.everyThirtyMinutes()` | `*/30 * * * *` | Every thirty minutes |
|
|
190
|
+
| `.hourly()` | `0 * * * *` | Top of every hour |
|
|
191
|
+
| `.hourlyAt(15)` | `15 * * * *` | A specific minute each hour |
|
|
192
|
+
| `.daily()` | `0 0 * * *` | Midnight every day |
|
|
193
|
+
| `.dailyAt("13:30")` | `30 13 * * *` | A specific time daily |
|
|
194
|
+
| `.twiceDaily(1, 13)` | `0 1,13 * * *` | Two specific hours daily |
|
|
195
|
+
| `.weekly()` | `0 0 * * 0` | Midnight every Sunday |
|
|
196
|
+
| `.mondays()` … `.sundays()` | `0 0 * * N` | A specific weekday at midnight |
|
|
197
|
+
| `.weekdays()` / `.weekends()` | `0 0 * * 1-5` / `6,0` | Mon–Fri / Sat–Sun |
|
|
198
|
+
| `.days([1, 4])` | `0 0 * * 1,4` | Specific weekdays |
|
|
199
|
+
| `.monthly()` | `0 0 1 * *` | Midnight on the 1st |
|
|
200
|
+
| `.twiceMonthly(1, 16)` | `0 0 1,16 * *` | Two days each month |
|
|
201
|
+
| `.lastDayOfMonth("23:00")` | guarded | Last calendar day of the month |
|
|
202
|
+
| `.quarterly()` / `.quarterlyOn()` | `0 0 1 1,4,7,10 *` | First day of each quarter |
|
|
203
|
+
| `.yearly()` / `.yearlyOn()` | `0 0 1 1 *` | Once a year |
|
|
204
|
+
| `.cron("0 9 * * 1")` | custom | Any raw cron expression |
|
|
205
|
+
|
|
206
|
+
> **Warning** — Sub-minute cadences use a 6-field cron (`sec min hour day month weekday`).
|
|
207
|
+
> They only make sense in a long-lived worker process — don't pair them with
|
|
208
|
+
> inline polling. `.lastDayOfMonth()` schedules a daily check (`28-31`) guarded by
|
|
209
|
+
> a `when()` that fires only on the actual last day.
|
|
210
|
+
|
|
211
|
+
## Inline schedules
|
|
212
|
+
|
|
213
|
+
For quick, in-code definitions (e.g. inside a provider) use the `Scheduler` facade,
|
|
214
|
+
which exposes the underlying manager fluently:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// in a provider's onBooted()
|
|
218
|
+
import { Scheduler } from "@zerotal/scheduler";
|
|
219
|
+
|
|
220
|
+
Scheduler.job("cleanup-sessions", () => Session.prune()).daily();
|
|
221
|
+
Scheduler.job("warm-cache", () => Cache.forget("posts:page:1")).cron("*/5 * * * *");
|
|
222
|
+
|
|
223
|
+
// Or register a task directly:
|
|
224
|
+
Scheduler.add("rotate-logs", "0 */6 * * *", () => rotateLogs());
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`job()` returns a `SchedulerBuilder`; each cadence method returns the
|
|
228
|
+
`ScheduledTask`, so you can chain the same fluent tuning the class form exposes
|
|
229
|
+
declaratively:
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
// in a provider's onBooted()
|
|
233
|
+
Scheduler.job("nightly-backup", () => runBackup())
|
|
234
|
+
.dailyAt("02:30")
|
|
235
|
+
.timezone("Africa/Johannesburg")
|
|
236
|
+
.withoutOverlapping({ expiresAfterMinutes: 30 })
|
|
237
|
+
.environments(["production"])
|
|
238
|
+
.between("00:00", "05:00")
|
|
239
|
+
.onSuccess(() => logger.info("backup ok"))
|
|
240
|
+
.onFailure((err) => logger.error("backup failed", err))
|
|
241
|
+
.pingOnSuccess("https://hc-ping.com/abc");
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
| Tuning method | Effect |
|
|
245
|
+
| -------------------------------------------------------- | --------------------------------------------------- |
|
|
246
|
+
| `.timezone(tz)` | Evaluate the cron in an IANA timezone. |
|
|
247
|
+
| `.withoutOverlapping(opts?)` | Skip a tick while a prior run is active. |
|
|
248
|
+
| `.environments([...])` | Only run in the listed `APP_ENV`s. |
|
|
249
|
+
| `.between(s, e)` / `.unlessBetween(s, e)` | Time-window guards (`"HH:MM"`). |
|
|
250
|
+
| `.when(fn)` / `.skip(fn)` | Dynamic run / skip guards. |
|
|
251
|
+
| `.runInBackground()` | Don't block the scheduler tick. |
|
|
252
|
+
| `.onStart/onSuccess/onFailure(fn)` | Lifecycle callbacks (failure receives the `Error`). |
|
|
253
|
+
| `.pingBefore/pingAfter/pingOnSuccess/pingOnFailure(url)` | Health-check pings. |
|
|
254
|
+
| `.appendOutputTo/sendOutputTo/emailOutputTo` | Capture console output (see below). |
|
|
255
|
+
|
|
256
|
+
> **Tip** — Prefer class-based schedules for anything non-trivial — they're
|
|
257
|
+
> discoverable, testable, and keep each task in its own file. Reach for the facade
|
|
258
|
+
> for one-liners.
|
|
259
|
+
|
|
260
|
+
## Listing schedules
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
# in your project root
|
|
264
|
+
bun zt schedule:list
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Prints every registered task with its cron expression, a human-readable
|
|
268
|
+
description of the cadence, and the next computed run time:
|
|
269
|
+
|
|
270
|
+
```text
|
|
271
|
+
Scheduled tasks (2)
|
|
272
|
+
Name SendDailyReports
|
|
273
|
+
Expression 0 8 * * *
|
|
274
|
+
Description At 08:00 every day
|
|
275
|
+
Next run 2026-06-22T06:00:00.000Z
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Run history
|
|
279
|
+
|
|
280
|
+
Every completed execution — success or failure — is recorded to a capped JSONL
|
|
281
|
+
file under `storage/framework/`, so the history survives restarts. "Did the
|
|
282
|
+
retention sweep run last night?" is answered from the record, not from memory:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
# in your project root
|
|
286
|
+
bun zt schedule:runs # recent runs, newest first
|
|
287
|
+
bun zt schedule:runs popia:sweep # one task's runs
|
|
288
|
+
bun zt schedule:runs --limit 50
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
```text
|
|
292
|
+
Recent runs (2)
|
|
293
|
+
Task popia:sweep
|
|
294
|
+
Started 2026-08-10T03:00:00.000Z
|
|
295
|
+
Duration 5210 ms
|
|
296
|
+
Result OK
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Configure it under `runLog` in `config/scheduler.ts` — `enabled` (default: on,
|
|
300
|
+
except under `APP_ENV=test`), `path`, and `keep` (records retained after
|
|
301
|
+
compaction, default 500). The store is bound in the container as
|
|
302
|
+
`scheduler.runs`; rebind it to keep the history somewhere else, such as Redis.
|
|
303
|
+
The [monitoring panel's](monitor.md) scheduled-tasks section reads the same
|
|
304
|
+
record, so a task that last ran before a deploy shows that run — marked
|
|
305
|
+
"(recorded)" — instead of "Never run".
|
|
306
|
+
|
|
307
|
+
Skipped ticks (environment, time window, `when()`/`skip()` guards, overlap) are
|
|
308
|
+
deliberate non-runs and are not recorded.
|
|
309
|
+
|
|
310
|
+
## Preventing overlapping runs
|
|
311
|
+
|
|
312
|
+
A long task can still be running when its next tick fires. `withoutOverlapping`
|
|
313
|
+
skips the new tick while the previous run is active:
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
// app/schedules/RebuildSearchIndex.ts
|
|
317
|
+
import { Schedule } from "@zerotal/scheduler";
|
|
318
|
+
|
|
319
|
+
export class RebuildSearchIndex extends Schedule {
|
|
320
|
+
cron = "*/5 * * * *";
|
|
321
|
+
withoutOverlapping = true; // in-process guard
|
|
322
|
+
|
|
323
|
+
async handle(): Promise<void> {
|
|
324
|
+
/* … */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The `true` form always guards **within a single process**, and — when a lock driver
|
|
330
|
+
is configured (Redis or SQLite via the [lock primitive](/docs/lock)) — also takes a
|
|
331
|
+
**cross-process lock** so only one worker runs the task per tick across all your
|
|
332
|
+
machines. Cross-process locking is **on by default**; pass `{ crossProcess: false }`
|
|
333
|
+
to guard within this process only:
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
// app/schedules/RebuildSearchIndex.ts
|
|
337
|
+
withoutOverlapping = { expiresAfterMinutes: 30 }; // cross-process (default)
|
|
338
|
+
// withoutOverlapping = { crossProcess: false }; // in-process guard only
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
With no lock driver registered it degrades to the in-process guard. A skipped tick
|
|
342
|
+
emits a `TaskSkipped` event with reason `"overlap"` (in-process) or `"lock"`
|
|
343
|
+
(cross-process).
|
|
344
|
+
|
|
345
|
+
**`expiresAfterMinutes` is a recovery time, not a duration budget.** The lock is
|
|
346
|
+
refreshed while the task runs — see
|
|
347
|
+
[Long-running work](/docs/lock#long-running-work) — so it only has to outlive a
|
|
348
|
+
missed heartbeat. It answers "how long after this host dies before another may
|
|
349
|
+
take the task over", and defaults to 5 minutes.
|
|
350
|
+
|
|
351
|
+
That is a change in meaning worth knowing if you set it before: it used to have to
|
|
352
|
+
cover the longest the task might ever run, which is why it defaulted to 24 hours
|
|
353
|
+
and why a crashed scheduler could block a task until the next afternoon. A
|
|
354
|
+
long-running task no longer needs a long value here — set one only if you want a
|
|
355
|
+
crash to take _longer_ to recover from.
|
|
356
|
+
|
|
357
|
+
Pass `{ refresh: false }` for the old behaviour, where the task must finish inside
|
|
358
|
+
`expiresAfterMinutes` or lose its lock.
|
|
359
|
+
|
|
360
|
+
## Capturing output
|
|
361
|
+
|
|
362
|
+
Anything the task writes to `console.log` can be persisted or emailed:
|
|
363
|
+
|
|
364
|
+
| Setting / method | Behaviour |
|
|
365
|
+
| ---------------- | --------------------------------------------------------- |
|
|
366
|
+
| `appendOutputTo` | **Append** captured output to a file (keeps history). |
|
|
367
|
+
| `sendOutputTo` | **Overwrite** a file with the latest run's output. |
|
|
368
|
+
| `emailOutputTo` | Email the output — requires an output mailer (see below). |
|
|
369
|
+
|
|
370
|
+
> **Note** — `sendOutputTo` is a facade-only tuning method; on a `Schedule`
|
|
371
|
+
> subclass, use the `appendOutputTo` or `emailOutputTo` properties.
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// app/schedules/GenerateSitemap.ts
|
|
375
|
+
import { Schedule } from "@zerotal/scheduler";
|
|
376
|
+
|
|
377
|
+
export class GenerateSitemap extends Schedule {
|
|
378
|
+
cron = "0 3 * * *";
|
|
379
|
+
appendOutputTo = "storage/logs/sitemap.log";
|
|
380
|
+
|
|
381
|
+
async handle(): Promise<void> {
|
|
382
|
+
console.log(`Sitemap generated with ${count} URLs`); // captured to the log
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`emailOutputTo` needs a mailer wired once at boot — set `ScheduledTask.outputMailer`,
|
|
388
|
+
a `(email, subject, body) => void | Promise<void>` function, in a provider's
|
|
389
|
+
`onBooted()` (without it, the output is logged with a notice instead of sent):
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
// in a provider's onBooted()
|
|
393
|
+
import { ScheduledTask } from "@zerotal/scheduler";
|
|
394
|
+
import { Notify } from "@zerotal/notifications";
|
|
395
|
+
|
|
396
|
+
ScheduledTask.outputMailer = async (email, subject, body) => {
|
|
397
|
+
// ScheduleOutputNotification implements toMail() from subject/body.
|
|
398
|
+
await Notify.send({ email }, new ScheduleOutputNotification(subject, body));
|
|
399
|
+
};
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## Observability — task events
|
|
403
|
+
|
|
404
|
+
Every run emits a framework event you can listen for to feed metrics, logs, or
|
|
405
|
+
alerts. Subscribe in a provider's `onBooted()`:
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
// in a provider's onBooted()
|
|
409
|
+
import { FrameworkEvents } from "zerotal";
|
|
410
|
+
import { TaskRan, TaskFailed, TaskSkipped } from "@zerotal/scheduler";
|
|
411
|
+
|
|
412
|
+
FrameworkEvents.on(TaskRan, (e) => metrics.timing(`schedule.${e.name}`, e.durationMs));
|
|
413
|
+
FrameworkEvents.on(TaskFailed, (e) => logger.error(`schedule ${e.name} failed: ${e.error}`));
|
|
414
|
+
FrameworkEvents.on(TaskSkipped, (e) => logger.debug(`schedule ${e.name} skipped (${e.reason})`));
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
| Event | Fields | Emitted when |
|
|
418
|
+
| ------------- | ----------------------------- | -------------------------------------------- |
|
|
419
|
+
| `TaskRan` | `name`, `durationMs`, `ok` | A run finishes (success or handled failure). |
|
|
420
|
+
| `TaskFailed` | `name`, `durationMs`, `error` | The handler throws (`error` is the message). |
|
|
421
|
+
| `TaskSkipped` | `name`, `reason` | A tick is skipped before running. |
|
|
422
|
+
|
|
423
|
+
`TaskSkipped.reason` is one of `"env"`, `"window"`, `"when"`, `"skip"`,
|
|
424
|
+
`"overlap"`, or `"lock"` — matching each guard.
|
|
425
|
+
|
|
426
|
+
### In the monitoring panel
|
|
427
|
+
|
|
428
|
+
A cron task that silently stops firing is one of the harder failures to notice:
|
|
429
|
+
nothing errors, work just stops happening. When [`@zerotal/monitor`](/docs/monitor)
|
|
430
|
+
is installed, the scheduler contributes a **Scheduled tasks** section to it — no
|
|
431
|
+
configuration, just both providers registered.
|
|
432
|
+
|
|
433
|
+
It leads with counts of tasks that are currently running, failing, or have never
|
|
434
|
+
run at all, then lists every task with its cron expression, last result, run
|
|
435
|
+
duration and next due time. The "never run" count is the one worth watching: a
|
|
436
|
+
task that has been registered but never fired usually means a guard or an
|
|
437
|
+
environment filter is excluding it.
|
|
438
|
+
|
|
439
|
+
The scheduler does not depend on the monitor package to do this — it resolves the
|
|
440
|
+
panel's contribution surface from the container at boot and describes the section
|
|
441
|
+
as data. To keep the scheduler but drop the section, set
|
|
442
|
+
`sections: { scheduler: false }` in `config/monitor.ts`.
|
|
443
|
+
|
|
444
|
+
## Testing
|
|
445
|
+
|
|
446
|
+
`ScheduledTask` exposes introspection getters and a `runNow()` that executes the
|
|
447
|
+
handler immediately, bypassing the cron/time-window guards — ideal in tests:
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
// in a test
|
|
451
|
+
import { Scheduler } from "@zerotal/scheduler";
|
|
452
|
+
|
|
453
|
+
const task = Scheduler.job("report", () => generateReport()).dailyAt("08:00");
|
|
454
|
+
|
|
455
|
+
await task.runNow(); // run the body now, ignoring the schedule
|
|
456
|
+
expect(task.lastOk).toBe(true);
|
|
457
|
+
expect(task.lastRunAt).toBeInstanceOf(Date);
|
|
458
|
+
|
|
459
|
+
// Assert the cadence without waiting for the clock
|
|
460
|
+
const next = task.nextRunAt(new Date("2026-06-21T09:00:00Z"));
|
|
461
|
+
expect(next?.toISOString()).toBe("2026-06-22T08:00:00.000Z");
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
## Running the worker
|
|
465
|
+
|
|
466
|
+
Schedules fire in **worker mode** — a separate Bun process started by the CLI. When
|
|
467
|
+
`bun zt worker` boots, the framework sets `APP_ENV=worker`.
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
# in your project root
|
|
471
|
+
bun zt worker # starts the queue worker + scheduler
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
For simpler deployments, `AppServiceProvider.onStarted()` can run inline polling
|
|
475
|
+
instead of a dedicated worker process (skip it when this IS the worker):
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
// app/providers/AppServiceProvider.ts (onStarted)
|
|
479
|
+
override async onStarted(): Promise<void> {
|
|
480
|
+
if (Bun.env.APP_ENV === "worker") return; // dedicated worker handles it
|
|
481
|
+
|
|
482
|
+
setInterval(async () => {
|
|
483
|
+
if (Queue.isShuttingDown) return;
|
|
484
|
+
await Queue.processNext("default").catch(console.error);
|
|
485
|
+
}, 500);
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
For production, run the worker as a separate process so it can be scaled, restarted,
|
|
490
|
+
and monitored independently of the web server.
|
|
491
|
+
|
|
492
|
+
## References
|
|
493
|
+
|
|
494
|
+
The `Scheduler` facade resolves the `scheduler` container binding — a
|
|
495
|
+
`SchedulerManager`. `job()` returns a `SchedulerBuilder`; cadence methods return a
|
|
496
|
+
`ScheduledTask`.
|
|
497
|
+
|
|
498
|
+
### Commands
|
|
499
|
+
|
|
500
|
+
`@zerotal/scheduler` ships one command:
|
|
501
|
+
|
|
502
|
+
| Command | What it does |
|
|
503
|
+
| ----------------------------- | ------------------------------------------------------ |
|
|
504
|
+
| `bun zt schedule:list` | List scheduled tasks with their next run time |
|
|
505
|
+
| `bun zt schedule:runs [name]` | Recent recorded runs, newest first (`--limit` to page) |
|
|
506
|
+
|
|
507
|
+
### SchedulerManager
|
|
508
|
+
|
|
509
|
+
| Method | Signature | Description |
|
|
510
|
+
| ------- | ------------------------------------------------------------------ | -------------------------------------------------- |
|
|
511
|
+
| `add` | `add(name: string, cron: string, cb: TaskCallback): ScheduledTask` | Register a task from a raw cron expression. |
|
|
512
|
+
| `job` | `job(name: string, cb: TaskCallback): SchedulerBuilder` | Start a fluent definition; pick a cadence next. |
|
|
513
|
+
| `start` | `start(): void` | Arm every registered task (called in `onStarted`). |
|
|
514
|
+
| `stop` | `stop(): void` | Stop every running task. |
|
|
515
|
+
| `tasks` | `get tasks(): ReadonlyMap<string, ScheduledTask>` | The registered tasks, keyed by name. |
|
|
516
|
+
|
|
517
|
+
### ScheduledTask introspection
|
|
518
|
+
|
|
519
|
+
| Member | Signature | Description |
|
|
520
|
+
| ------------------ | ------------------------------------------- | ------------------------------------------------- |
|
|
521
|
+
| `runNow()` | `runNow(): Promise<void>` | Runs the handler now, skipping all guards. |
|
|
522
|
+
| `nextRunAt(from?)` | `nextRunAt(from?: Date): Date \| null` | Next fire time after `from` (or `null` if never). |
|
|
523
|
+
| `lastRunAt` | `get lastRunAt(): Date \| undefined` | When the task last ran, or `undefined`. |
|
|
524
|
+
| `lastOk` | `get lastOk(): boolean \| undefined` | Whether the last run succeeded. |
|
|
525
|
+
| `lastDurationMs` | `get lastDurationMs(): number \| undefined` | Duration of the last run in ms. |
|
|
526
|
+
| `isRunning` | `get isRunning(): boolean` | `true` while a run is in flight. |
|
|
527
|
+
|
|
528
|
+
## Next steps
|
|
529
|
+
|
|
530
|
+
- [Queue](/docs/queue) — schedules typically dispatch jobs; the worker runs both.
|
|
531
|
+
- [Conventions](/docs/conventions#schedules-appschedules) — how `app/schedules/` is discovered.
|
|
532
|
+
- [Events](/docs/events) — the `FrameworkEvents` bus the task events flow through.
|
|
533
|
+
- [Notifications](/docs/notifications) — wire the output mailer for `emailOutputTo`.
|