@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
package/docs/queue.md ADDED
@@ -0,0 +1,549 @@
1
+ ---
2
+ title: Queue
3
+ description: Offload slow work to background jobs that run outside the request cycle.
4
+ ---
5
+
6
+ # Queue
7
+
8
+ Offload slow work to background jobs. Define jobs, dispatch them (singly, batched,
9
+ or chained), pick a driver, and process them with a worker loop or Bun Worker
10
+ threads.
11
+
12
+ ## Getting Started
13
+
14
+ ```bash
15
+ # in your project root
16
+ bun add @zerotal/queue
17
+ ```
18
+
19
+ ## Register the provider
20
+
21
+ Add `QueueProvider` to the providers array in `bootstrap/providers.ts`:
22
+
23
+ ```typescript
24
+ // bootstrap/providers.ts
25
+ import { QueueProvider } from "@zerotal/queue";
26
+
27
+ const providers = [
28
+ // …your other providers
29
+ QueueProvider,
30
+ ];
31
+
32
+ export default providers;
33
+ ```
34
+
35
+ Registering the provider switches on the following (in lifecycle order):
36
+
37
+ - `onRegister` — binds `queue` (a `QueueManager`) as a singleton, selecting the
38
+ driver from `config('queue.driver')`.
39
+ - `onBooting` — registers the internal `CallQueuedListener` job and points `Bus`
40
+ at the manager so batching and chaining work.
41
+ - `onBooted` — registers the `queue:work`, `queue:failed`, `queue:retry`, and
42
+ `queue:flush` commands.
43
+ - `onStarted` — starts the polling loop when running as a dedicated `worker`
44
+ process, or spawns Bun Worker threads when `workers > 0` on the web server.
45
+ - `onStopping` — clears the poll interval, drains in-flight jobs, and terminates
46
+ any Bun Worker threads, so nothing leaks between boots or test suites.
47
+
48
+ ## Configuration
49
+
50
+ Create `config/queue.ts` using the `QueueConfig()` helper so every field stays
51
+ type-checked while defaults fill in the rest:
52
+
53
+ ```typescript
54
+ // config/queue.ts
55
+ import { QueueConfig } from "@zerotal/queue";
56
+ import { env } from "zerotal";
57
+
58
+ export default QueueConfig({
59
+ driver: env("QUEUE_DRIVER", "sqlite"),
60
+ pollInterval: env("QUEUE_POLL_INTERVAL", 500),
61
+ queues: ["default"],
62
+ workers: env("QUEUE_WORKERS", 0),
63
+ // workerBootstrap: new URL("../bootstrap/queue-worker.ts", import.meta.url).href,
64
+ });
65
+ ```
66
+
67
+ | Field | Required | Default | Description |
68
+ | ----------------- | ------------------ | ------------- | ------------------------------------------------------------------- |
69
+ | `driver` | no | `"sqlite"` | Queue driver: `"sqlite"`, `"redis"`, or `"sync"`. |
70
+ | `pollInterval` | no | `500` | Milliseconds between worker polls for new jobs. |
71
+ | `queues` | no | `["default"]` | Queue names the worker listens on. |
72
+ | `workers` | no | `0` | Number of Bun Worker threads to spawn. `0` = main-thread only. |
73
+ | `workerBootstrap` | when `workers > 0` | — | Absolute path or file URL to a module that imports every job class. |
74
+
75
+ > **Note** — `QueueConfig()` supplies the defaults above, so you only set the
76
+ > fields you want to change.
77
+
78
+ ## Writing a job
79
+
80
+ A job is a class that extends `Job` and implements `handle()`. Constructor
81
+ arguments are the job's state — serialise them in `payload()` and restore them in
82
+ a static `fromPayload()`:
83
+
84
+ ```typescript
85
+ // app/jobs/NotifyFollowersJob.ts
86
+ import { Job, JobRegistry } from "@zerotal/queue";
87
+
88
+ export class NotifyFollowersJob extends Job {
89
+ // Route this job to a specific named queue (default: 'default')
90
+ override readonly queue = "notifications";
91
+
92
+ // Number of attempts before the job is marked as permanently failed (default: 3)
93
+ override readonly maxAttempts = 3;
94
+
95
+ // Milliseconds to wait between retries (default: 1000)
96
+ override readonly retryDelay = 5000;
97
+
98
+ constructor(public readonly postId: number) {
99
+ super();
100
+ }
101
+
102
+ // Serialise state for storage
103
+ payload(): Record<string, unknown> {
104
+ return { postId: this.postId };
105
+ }
106
+
107
+ // Deserialise from storage — called by the worker
108
+ static fromPayload(p: Record<string, unknown>): NotifyFollowersJob {
109
+ return new NotifyFollowersJob(p["postId"] as number);
110
+ }
111
+
112
+ // The actual work
113
+ async handle(): Promise<void> {
114
+ const followers = await Follower.query().where("following_id", this.postId).get();
115
+ for (const follower of followers) {
116
+ await Mail.send(new NewPostMail(follower.email));
117
+ }
118
+ }
119
+ }
120
+
121
+ // Register so the worker can deserialise it by class name
122
+ JobRegistry.register(NotifyFollowersJob as never);
123
+ ```
124
+
125
+ ### Minimal job
126
+
127
+ A job with no constructor state needs only `handle()` plus the registration line:
128
+
129
+ ```typescript
130
+ // app/jobs/PruneDeletedContentJob.ts
131
+ import { Job, JobRegistry } from "@zerotal/queue";
132
+
133
+ export class PruneDeletedContentJob extends Job {
134
+ async handle(): Promise<void> {
135
+ await Post.query().withTrashed().where("deleted_at", "<", cutoff).forceDelete();
136
+ }
137
+ }
138
+
139
+ JobRegistry.register(PruneDeletedContentJob as never);
140
+ ```
141
+
142
+ ## Auto-registration
143
+
144
+ You don't import or wire up your jobs anywhere. Any job class placed under
145
+ `app/jobs/` is **auto-discovered at boot** — the convention loader imports each
146
+ file, which runs the `JobRegistry.register(...)` call at the bottom of it. That
147
+ registration is what lets the worker rebuild a job from its serialized payload by
148
+ class name, so `Queue.dispatch(new NotifyFollowersJob(id))` works from anywhere
149
+ with no manual import.
150
+
151
+ ```text
152
+ // app/jobs/
153
+ app/jobs/
154
+ NotifyFollowersJob.ts ← discovered + registered automatically
155
+ PruneDeletedContentJob.ts
156
+ SendWeeklyDigestJob.ts
157
+ ```
158
+
159
+ The discovery runs in every runtime (web, console, worker) so dispatching works
160
+ the same everywhere. The Bun Worker thread re-runs the same scan unless you point
161
+ `workerBootstrap` at an explicit barrel module. The only per-job requirement is
162
+ the `JobRegistry.register(...)` line — keep it at the bottom of each job file. See
163
+ [Conventions](/docs/conventions#jobs-appjobs).
164
+
165
+ ## Dispatching jobs
166
+
167
+ ```typescript
168
+ // in a controller
169
+ import { Queue } from "@zerotal/queue";
170
+
171
+ // Dispatch a job to the queue
172
+ await Queue.dispatch(new NotifyFollowersJob(post.id));
173
+
174
+ // The job's `queue` property decides which named queue it lands on
175
+ await Queue.dispatch(new SendWeeklyDigestJob());
176
+ ```
177
+
178
+ The `Queue` facade resolves the `queue` container binding (a `QueueManager`). All
179
+ dispatched jobs are persisted to the queue driver; they are processed by the
180
+ worker loop, not the web request.
181
+
182
+ ## Debounced jobs
183
+
184
+ A document saved eight times in a minute should rebuild its search index once, and the only rebuild anyone sees is the last one. Set `debounce` to a number of seconds and repeated dispatches collapse into a single run:
185
+
186
+ ```typescript
187
+ export class ReindexDocument extends Job {
188
+ /** Run 30s after the last dispatch, not once per dispatch. */
189
+ override readonly debounce = 30;
190
+
191
+ constructor(private documentId: number) {
192
+ super();
193
+ }
194
+
195
+ override payload(): Record<string, unknown> {
196
+ return { documentId: this.documentId };
197
+ }
198
+
199
+ async handle(): Promise<void> {
200
+ await search.reindex(this.documentId);
201
+ }
202
+ }
203
+ ```
204
+
205
+ ```typescript
206
+ // Eight saves in quick succession…
207
+ for (const _ of edits) await Queue.dispatch(new ReindexDocument(doc.id));
208
+ // …one job, running 30s after the last one.
209
+ ```
210
+
211
+ This is a **trailing** debounce, and the name is accurate: each dispatch pushes the run further out, and the job runs once, after the dispatches stop. The other behaviour that sometimes wears this word — the first dispatch runs and the rest are dropped within the window — is a different thing, and is not what this does. If you need that, dispatch once and rate-limit at the edge.
212
+
213
+ ### The last payload wins
214
+
215
+ When eight dispatches collapse, the surviving job carries the **eighth** one's data. That is the whole premise: the earlier dispatches are stale, and running with the newest state is the point.
216
+
217
+ ### What counts as "the same job"
218
+
219
+ By default, the class name plus the serialised payload. So `ReindexDocument(1)` and `ReindexDocument(2)` are different work and never collapse into each other — which is what makes the common case need no configuration.
220
+
221
+ Override `debounceKey()` when two payloads mean the same work. A job carrying a timestamp or a request id is unique on every dispatch and would otherwise never collapse with anything:
222
+
223
+ ```typescript
224
+ export class ReindexDocument extends Job {
225
+ override readonly debounce = 30;
226
+
227
+ override payload(): Record<string, unknown> {
228
+ return { documentId: this.documentId, requestedAt: Date.now() };
229
+ }
230
+
231
+ /** Ignore `requestedAt` — two requests for the same document are one job. */
232
+ override debounceKey(): string {
233
+ return `reindex:${this.documentId}`;
234
+ }
235
+ }
236
+ ```
237
+
238
+ The key lives in the queue's own backing store, so it is stable **across processes**. A debounce that only held inside one worker would appear to work in development and do nothing in production, where more than one process dispatches.
239
+
240
+ ### Driver support
241
+
242
+ | Driver | Debounce |
243
+ | -------- | ------------------------------------------------------------------ |
244
+ | `sqlite` | Yes — one `INSERT … ON CONFLICT` against a partial unique index |
245
+ | `redis` | Yes — one `EVAL`, so two processes cannot both enqueue |
246
+ | `sync` | Inert: every job runs inline, so there is no window to collapse in |
247
+
248
+ Collapsing has to be **atomic**, or two processes dispatching at the same instant both find nothing pending and both enqueue — the exact failure the feature exists to prevent. A driver that cannot promise that throws `QueueDebounceUnsupportedError` (`E_QUEUE_DEBOUNCE_UNSUPPORTED`) rather than silently degrading to a per-process debounce, and the message names the driver and what to change.
249
+
250
+ ### A job already being worked is never collapsed into
251
+
252
+ Once a worker has claimed a job, it is running, and the next dispatch is genuinely new work — it becomes its own pending job rather than trying to reschedule something already in flight. On `sqlite` the unique index covers only unreserved rows; on `redis` the key is released when the job is promoted to the ready list.
253
+
254
+ This is the behaviour you want for the reindex case: a save that lands while the previous rebuild is running still gets a rebuild.
255
+
256
+ ## Job batching
257
+
258
+ Batch a set of jobs and react when they all finish. Batching uses the
259
+ `zerotal_job_batches` table (auto-created by `SqliteDriver`).
260
+
261
+ ```typescript
262
+ // in a controller
263
+ import { Bus } from "@zerotal/queue";
264
+
265
+ // Dispatch 10,000 import jobs; send a summary email when all finish.
266
+ const batch = await Bus.batch(rows.map((row) => new ImportCsvRowJob(row)))
267
+ .name("csv-import-2024") // optional label
268
+ .then(new SendImportSummaryJob(user)) // dispatched when ALL succeed
269
+ .catch(new NotifyAdminOfFailureJob(user)) // dispatched when ANY fail
270
+ .finally(new CleanupTempFilesJob(uploadId)) // always dispatched when complete
271
+ .dispatch();
272
+ ```
273
+
274
+ > **Note** — `then`, `catch`, and `finally` accept `Job | Job[]`. They are
275
+ > serialized as class name + payload and stored in the batch row, so they survive
276
+ > process restarts.
277
+
278
+ > **Warning** — Batching requires `SqliteDriver`. `SyncDriver` and `RedisDriver`
279
+ > do not implement the batch table, so `Bus.batch(...).dispatch()` throws a
280
+ > `QueueBatchingUnsupportedError` with them.
281
+
282
+ ### Batch status object
283
+
284
+ `Bus.batch(...).dispatch()` resolves to a `Batch` instance:
285
+
286
+ ```typescript
287
+ // after .dispatch()
288
+ batch.id; // UUID string
289
+ batch.name; // label from .name()
290
+ batch.totalJobs; // jobs dispatched
291
+ batch.pendingJobs; // jobs not yet processed
292
+ batch.failedJobs; // jobs permanently failed
293
+ batch.failedJobIds; // array of zerotal_jobs.id values
294
+ batch.finished(); // true once the batch has a finishedAt timestamp
295
+ batch.failed(); // true if any job failed
296
+ batch.progress(); // 0.0 → 1.0
297
+ ```
298
+
299
+ > **Note** — The `Batch` you get back is a snapshot taken at dispatch time; it is
300
+ > not a live view. Re-fetch the batch from the driver to see updated progress.
301
+
302
+ ## Job chaining
303
+
304
+ Run jobs sequentially: each job dispatches the next one only after it succeeds. If
305
+ any job fails, the rest of the chain is abandoned.
306
+
307
+ ```typescript
308
+ // in a controller
309
+ import { Bus } from "@zerotal/queue";
310
+
311
+ await Bus.chain([
312
+ new ValidateImportJob(fileId),
313
+ new ProcessImportJob(fileId),
314
+ new SendImportCompleteEmailJob(user),
315
+ ]).dispatch();
316
+ ```
317
+
318
+ The chain is stored in the payload of each job under `__chain` — no extra table is
319
+ needed, so chaining works with any driver.
320
+
321
+ ## Processing jobs
322
+
323
+ ### Dedicated worker process
324
+
325
+ The standard way to process jobs in production is a long-running worker process:
326
+
327
+ ```bash
328
+ # in your project root
329
+ bun zt queue:work # process the 'default' queue
330
+ bun zt queue:work --queue=emails # process a specific queue
331
+ bun zt queue:work --once # process one job, then exit
332
+ ```
333
+
334
+ The worker polls continuously, retries failed jobs up to `maxAttempts`, and moves
335
+ permanently-failed jobs to the `zerotal_failed_jobs` table.
336
+
337
+ ### Manual processing
338
+
339
+ For development or small apps that don't need a separate process, drive
340
+ `Queue.processNext()` on an interval from a provider:
341
+
342
+ ```typescript
343
+ // in AppServiceProvider.onStarted()
344
+ import { Queue } from "@zerotal/queue";
345
+
346
+ const queues = ["default", "notifications", "emails"];
347
+ setInterval(async () => {
348
+ for (const q of queues) {
349
+ await Queue.processNext(q).catch(console.error);
350
+ }
351
+ }, 500);
352
+ ```
353
+
354
+ ### Which should I use?
355
+
356
+ - **`queue:work`** — production and anything with real volume. Failures, retries,
357
+ and graceful shutdown are handled for you in a process you can scale separately.
358
+ - **Manual `setInterval`** — local development or tiny apps where running a second
359
+ process isn't worth it.
360
+ - **`sync` driver** — tests and scripts where you want jobs to run inline and
361
+ immediately rather than in the background.
362
+
363
+ ### Draining on shutdown
364
+
365
+ `Queue.isShuttingDown` flips to `true` once the provider's `onStopping` hook runs
366
+ (on `SIGTERM`). The worker stops accepting new jobs, and `QueueManager.drain()`
367
+ waits for in-flight jobs to finish before the process exits.
368
+
369
+ ### In the admin panel
370
+
371
+ When [`@zerotal/admin`](/docs/admin) is installed, the queue puts a **Jobs**
372
+ console in the panel — no configuration, just both providers registered. It has a
373
+ tab each for failed jobs, pending jobs, per-queue depth, and this process's
374
+ throughput counters, and it offers the same operations as the CLI commands: retry
375
+ or forget a single failed job, clear all of them, flush the pending queue. The
376
+ sidebar entry carries a failed-job count, which is the number you want to notice
377
+ without going looking for it.
378
+
379
+ Access is gated on the `queue.view` ability, checked both when the sidebar is
380
+ drawn and again on every action. To keep the queue provider but drop the console,
381
+ set `plugins: { queue: false }` in `config/admin.ts`.
382
+
383
+ The queue does not depend on the admin package to do this — it resolves the
384
+ panel's contribution surface from the container at boot and describes the console
385
+ as data. An app running the queue without the panel pulls in nothing extra.
386
+
387
+ ## Queue drivers
388
+
389
+ | Driver | Notes |
390
+ | ---------- | ----------------------------------------------------------------------------------------- |
391
+ | `"sqlite"` | Jobs stored in a `zerotal_jobs` table in the app database. Default. Good for most apps. |
392
+ | `"redis"` | Jobs stored in Redis lists. Better throughput for high-volume apps. Requires `REDIS_URL`. |
393
+ | `"sync"` | Jobs run immediately and synchronously in the dispatching process. Intended for tests. |
394
+
395
+ > **Warning** — Only `"sqlite"` supports batching. Pick it if you rely on
396
+ > `Bus.batch()`.
397
+
398
+ ## Bun Worker threads
399
+
400
+ Set `workers > 0` in `config/queue.ts` and the web server process runs jobs in Bun
401
+ Worker threads — genuine OS threads — so CPU-bound jobs don't stall the HTTP event
402
+ loop. The provider builds and wires the `WorkerPool` for you from config; you do
403
+ not construct it yourself.
404
+
405
+ ```typescript
406
+ // config/queue.ts
407
+ import { QueueConfig } from "@zerotal/queue";
408
+
409
+ export default QueueConfig({
410
+ workers: 4, // spawn 4 Bun Worker threads
411
+ workerBootstrap: new URL("../bootstrap/queue-worker.ts", import.meta.url).href,
412
+ });
413
+ ```
414
+
415
+ > **Note** — When `workerBootstrap` is omitted, each worker thread re-discovers
416
+ > jobs by scanning `app/jobs/*.ts`. Set `workerBootstrap` to a barrel module that
417
+ > imports every job when you want to skip the filesystem scan.
418
+
419
+ > **Warning** — `workerBootstrap` is required in practice once `workers > 0` if
420
+ > your jobs aren't all under `app/jobs/`: a worker thread can only run a job whose
421
+ > class it has registered.
422
+
423
+ On `SIGTERM` the provider drains the manager and calls `WorkerPool.terminate()`,
424
+ which stops every thread. Any in-flight or queued work is resolved with
425
+ `{ success: false }` so the driver can retry it.
426
+
427
+ ## Testing
428
+
429
+ `QueueFake` swaps the `queue` binding for a fake that captures dispatched jobs
430
+ instead of running them, so you can assert on them:
431
+
432
+ ```typescript
433
+ // in a test
434
+ import { QueueFake } from "@zerotal/queue";
435
+
436
+ const queue = QueueFake.install(); // replaces the 'queue' binding with a fake
437
+
438
+ await MyController.store({ http: ctx });
439
+
440
+ queue.assertDispatched(NotifyFollowersJob);
441
+ queue.assertDispatchedCount(1);
442
+
443
+ queue.restore(); // call in afterEach
444
+ ```
445
+
446
+ ## References
447
+
448
+ ### Commands
449
+
450
+ `@zerotal/queue` ships the worker and the failed-job tools:
451
+
452
+ | Command | What it does |
453
+ | -------------------------- | ---------------------------------------------------------------- |
454
+ | `bun zt queue:work` | Process jobs from the queue — run this as a daemon in production |
455
+ | `bun zt queue:work --once` | Process a single job, then exit |
456
+ | `bun zt queue:failed` | List all failed jobs |
457
+ | `bun zt queue:retry <id>` | Retry a failed job by id, or `all` to retry everything |
458
+ | `bun zt queue:flush` | Delete all failed jobs from the database |
459
+
460
+ ### `Queue` facade
461
+
462
+ The facade proxies a `QueueManager` resolved from the `queue` binding.
463
+
464
+ | Method | Signature | Description |
465
+ | ---------------- | -------------------------------------- | ------------------------------------------------ |
466
+ | `dispatch` | `(job: Job) => Promise<void>` | Persist a job to its queue for later processing. |
467
+ | `processNext` | `(queue?: string) => Promise<boolean>` | Pop and run the next job; `false` if none. |
468
+ | `size` | `(queue?: string) => Promise<number>` | Count pending jobs on a queue. |
469
+ | `drain` | `() => Promise<void>` | Stop accepting work and wait for in-flight jobs. |
470
+ | `isShuttingDown` | `boolean` | `true` once shutdown has begun. |
471
+
472
+ ### `Bus`
473
+
474
+ | Method | Signature | Description |
475
+ | ------- | ------------------------------------------------ | ---------------------------------------------------- |
476
+ | `batch` | `(jobs: Job[]) => PendingBatch` | Start a batch builder (`.then`/`.catch`/`.finally`). |
477
+ | `chain` | `(jobs: Job[]) => { dispatch(): Promise<void> }` | Run jobs sequentially, stopping on first failure. |
478
+
479
+ ### `PendingBatch`
480
+
481
+ | Method | Signature | Description |
482
+ | ---------- | ----------------------------- | ------------------------------------------- |
483
+ | `name` | `(n: string) => this` | Label the batch. |
484
+ | `then` | `(job: Job \| Job[]) => this` | Dispatched when all batched jobs succeed. |
485
+ | `catch` | `(job: Job \| Job[]) => this` | Dispatched when any batched job fails. |
486
+ | `finally` | `(job: Job \| Job[]) => this` | Always dispatched once the batch completes. |
487
+ | `dispatch` | `() => Promise<Batch>` | Persist the batch and its jobs. |
488
+
489
+ ### `Job` (extend this)
490
+
491
+ | Member | Type | Description |
492
+ | ------------- | ---------------------------------------------- | ---------------------------------------------- |
493
+ | `queue` | `string` (default `"default"`) | Named queue to route this job to. |
494
+ | `maxAttempts` | `number` (default `3`) | Attempts before the job is permanently failed. |
495
+ | `retryDelay` | `number` (default `1000`) | Milliseconds to wait between retries. |
496
+ | `handle` | `() => Promise<void>` | The work to perform. Required. |
497
+ | `payload` | `() => Record<string, unknown>` | Serialise constructor state for storage. |
498
+ | `fromPayload` | `(p: Record<string, unknown>) => Job` (static) | Rebuild the job from its payload. |
499
+
500
+ ### `QueueFake`
501
+
502
+ | Method | Signature | Description |
503
+ | ------------------------- | ----------------------------------------------- | -------------------------------------- |
504
+ | `install` | `() => QueueFake` (static) | Swap the `queue` binding for the fake. |
505
+ | `restore` | `() => void` | Restore the original `queue` binding. |
506
+ | `dispatched` | `() => Job[]` | All captured jobs. |
507
+ | `assertDispatched` | `(JobClass, filter?: (job) => boolean) => void` | Assert a job class was dispatched. |
508
+ | `assertNotDispatched` | `(JobClass) => void` | Assert a job class was not dispatched. |
509
+ | `assertNothingDispatched` | `() => void` | Assert no jobs were dispatched. |
510
+ | `assertDispatchedCount` | `(count: number) => void` | Assert the exact dispatched count. |
511
+
512
+ ### Errors
513
+
514
+ Every queue error extends `QueueError`, which extends the framework's
515
+ `ZerotalError` — so `catch (e) { if (e instanceof QueueError) … }` catches the lot
516
+ while leaving unrelated failures alone.
517
+
518
+ | Error | Code | Raised when |
519
+ | ------------------------------- | ------------------------------ | ----------------------------------------------------------------- |
520
+ | `QueueError` | `E_QUEUE` | Base class — catch this to handle any queue failure. |
521
+ | `QueueNotInitializedError` | `E_QUEUE_NOT_INITIALIZED` | Dispatching before `QueueProvider` is registered. |
522
+ | `QueueShuttingDownError` | `E_QUEUE_SHUTTING_DOWN` | Dispatching during a graceful shutdown — the manager is draining. |
523
+ | `QueueBatchingUnsupportedError` | `E_QUEUE_BATCHING_UNSUPPORTED` | Using batches on a driver that has no batch support. |
524
+
525
+ ```typescript
526
+ // in a controller or service
527
+ import { QueueError, QueueShuttingDownError } from "@zerotal/queue";
528
+
529
+ try {
530
+ await ProcessPayment.dispatch({ orderId });
531
+ } catch (error) {
532
+ // A shutdown is expected during a deploy — retry rather than alert.
533
+ if (error instanceof QueueShuttingDownError) return retryLater(orderId);
534
+ if (error instanceof QueueError) return reportQueueOutage(error);
535
+ throw error;
536
+ }
537
+ ```
538
+
539
+ `QueueShuttingDownError` is the one worth handling explicitly: it means the
540
+ process is draining, not that anything is broken, so the right response is to
541
+ re-dispatch on the next boot rather than to fail the request.
542
+
543
+ ## Next steps
544
+
545
+ - [Scheduler](/docs/scheduler) — run recurring jobs alongside the queue worker.
546
+ - [Notifications](/docs/notifications) — a common payload for background jobs.
547
+ - [Notifications](/docs/notifications) — queue user notifications off the request path.
548
+ - [Conventions](/docs/conventions#jobs-appjobs) — how `app/jobs/` auto-registration works.
549
+ - [Testing mocking](/docs/testing/mocking) — assert dispatched jobs with `QueueFake`.