@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. 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`.