@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,848 @@
1
+ ---
2
+ title: Notifications
3
+ description: Send one notification across mail, database, Slack, SMS, and real-time broadcast channels from a single class.
4
+ ---
5
+
6
+ # Notifications
7
+
8
+ `@zerotal/notifications` lets you describe a notification once and deliver it
9
+ over many channels — mail, database, Slack, SMS, or a real-time broadcast. A
10
+ notification class says _what_ to send and on which channels; the manager handles
11
+ routing each channel to its driver.
12
+
13
+ ## Getting Started
14
+
15
+ ```bash
16
+ # in your project root
17
+ bun add @zerotal/notifications
18
+ ```
19
+
20
+ The mail, database, Slack, SMS, and broadcast channels are all built into this
21
+ package — no extra channel packages to install.
22
+
23
+ ## Register the provider
24
+
25
+ Add `NotificationProvider` to the providers array in `bootstrap/providers.ts`.
26
+ The database channel reads from your ORM connection, so register it after
27
+ `DatabaseProvider`:
28
+
29
+ ```ts
30
+ // bootstrap/providers.ts
31
+ import { DatabaseProvider } from "@zerotal/orm";
32
+ import { NotificationProvider } from "@zerotal/notifications";
33
+
34
+ const providers = [
35
+ // …your other providers
36
+ DatabaseProvider,
37
+ NotificationProvider,
38
+ ];
39
+
40
+ export default providers;
41
+ ```
42
+
43
+ Registering the provider switches on the following:
44
+
45
+ - `onRegister` — binds `NotificationManager` as a lazy singleton under the
46
+ `"notifications"` container key, built from `config/notifications.ts`.
47
+ - `onBooted` — eagerly resolves that binding so the manager (and its database
48
+ table) is ready before the first request.
49
+
50
+ ## Configuration
51
+
52
+ Create `config/notifications.ts` with the `NotificationConfig()` helper — it
53
+ merges your overrides over sensible defaults, so you only set the keys you need:
54
+
55
+ ```ts
56
+ // config/notifications.ts
57
+ import { NotificationConfig } from "@zerotal/notifications";
58
+ import { env } from "zerotal";
59
+
60
+ export default NotificationConfig({
61
+ database: {
62
+ table: "notifications", // table for stored notifications
63
+ },
64
+
65
+ // mail channel works out of the box on the 'log' driver; switch to smtp/resend here
66
+ mail: {
67
+ driver: "log",
68
+ from: { address: "hello@example.com", name: "Zerotal App" },
69
+ },
70
+
71
+ // Optional — global Slack webhook fallback (per-notification toSlack() can override):
72
+ slack: {
73
+ webhook: env("SLACK_WEBHOOK_URL", ""),
74
+ },
75
+
76
+ // Optional — required only for the 'sms' channel:
77
+ sms: {
78
+ driver: "twilio", // 'twilio' | 'vonage'
79
+ twilio: {
80
+ accountSid: env("TWILIO_ACCOUNT_SID", ""),
81
+ authToken: env("TWILIO_AUTH_TOKEN", ""),
82
+ from: env("TWILIO_FROM", ""),
83
+ },
84
+ },
85
+ });
86
+ ```
87
+
88
+ | Field | Required | Default | Description |
89
+ | ---------------- | -------- | --------------------------------- | --------------------------------------------------------------------------- |
90
+ | `database.table` | no | `"notifications"` | Table where database-channel notifications are stored (auto-created). |
91
+ | `mail.driver` | no | `"log"` | Mail transport: `"log"` (prints to console), `"smtp"`, or `"resend"`. |
92
+ | `mail.from` | no | `hello@example.com` / Zerotal App | Default sender, used unless a `MailMessage` overrides it with `from()`. |
93
+ | `mail.smtp` | no | localhost:1025 | SMTP host/port/credentials, used when `driver` is `"smtp"`. |
94
+ | `mail.resend` | no | `{ apiKey: "" }` | Resend API key, used when `driver` is `"resend"`. |
95
+ | `slack` | no | unset | Global Slack webhook fallback. Include only if you use the `slack` channel. |
96
+ | `sms` | no | unset | SMS driver (`"twilio"` or `"vonage"`) and credentials. Required for `sms`. |
97
+
98
+ > **Note** — The `slack` and `sms` keys are optional. If a notification declares
99
+ > a channel whose config is missing, the manager throws a
100
+ > `NotificationChannelNotConfiguredError` at send time.
101
+
102
+ `NotificationConfig()` checks the result before returning it, so combinations
103
+ that could only fail at send time fail at boot instead: a `resend` driver with no
104
+ API key, an `smtp` driver with no host, a username without a password, a `from`
105
+ that is not an address, or an SMS driver missing its credential block. Each
106
+ raises a `NotificationConfigError` naming the key to fix.
107
+
108
+ ### SMTP transport security
109
+
110
+ The `secure` flag chooses how the connection is protected, and the driver refuses
111
+ combinations that would leak credentials:
112
+
113
+ - `secure: true` — TLS from the first byte, the usual choice for port 465.
114
+ - `secure: false` — connects in the clear and upgrades via STARTTLS when the
115
+ server offers it, the usual choice for port 587.
116
+ - `secure: false` against a server with no STARTTLS — stays plaintext. If
117
+ credentials are configured, the send is refused rather than sent in the open,
118
+ because SMTP authentication is base64-encoded, not encrypted.
119
+
120
+ That last case is the one worth knowing about: a local relay like Mailpit needs
121
+ no credentials, so it just works. If you genuinely want to authenticate against a
122
+ trusted relay over plaintext, set `mail.smtp.allowInsecureAuth: true` to say so
123
+ deliberately.
124
+
125
+ | Field | Default | Description |
126
+ | ------------------------- | --------- | ------------------------------------------------------ |
127
+ | `smtp.allowInsecureAuth` | `false` | Permit authentication over an unencrypted connection. |
128
+ | `smtp.rejectUnauthorized` | `true` | Reject servers presenting an untrusted certificate. |
129
+ | `smtp.timeoutMs` | `30000` | How long to wait for any single reply from the server. |
130
+ | `smtp.clientName` | `zerotal` | The name sent in the EHLO greeting. |
131
+
132
+ To check a transport end to end, `bun zt notifications:test you@example.com`
133
+ sends one real message and prints whatever the server said.
134
+
135
+ ## Writing a notification
136
+
137
+ Extend `Notification`, declare `channels()`, then implement a `to*()` method for
138
+ each declared channel:
139
+
140
+ ```ts
141
+ // app/notifications/OrderShippedNotification.ts
142
+ import { Notification, MailMessage } from "@zerotal/notifications";
143
+ import type { Notifiable } from "@zerotal/notifications";
144
+
145
+ export class OrderShippedNotification extends Notification {
146
+ constructor(private order: Order) {
147
+ super();
148
+ }
149
+
150
+ // Which channels to deliver on
151
+ channels() {
152
+ return ["mail", "database", "slack"];
153
+ }
154
+
155
+ // mail channel — return a MailMessage
156
+ toMail(notifiable: Notifiable) {
157
+ return new MailMessage()
158
+ .subject(`Order #${this.order.id} shipped`)
159
+ .line("Your order is on its way.")
160
+ .action("Track package", `https://app.test/orders/${this.order.id}`);
161
+ }
162
+
163
+ // database channel — stored in the notifications table
164
+ toDatabase(_notifiable: Notifiable) {
165
+ return {
166
+ orderId: this.order.id,
167
+ status: "shipped",
168
+ message: `Order #${this.order.id} has been shipped.`,
169
+ };
170
+ }
171
+
172
+ // slack channel
173
+ toSlack(_notifiable: Notifiable) {
174
+ return {
175
+ webhookUrl: "https://hooks.slack.com/services/...", // optional if a global webhook is configured
176
+ text: `Order #${this.order.id} shipped to ${this.order.customerName}`,
177
+ };
178
+ }
179
+ }
180
+ ```
181
+
182
+ > **Warning** — A `to*()` method you don't implement throws a
183
+ > `NotificationContractError` if its channel is declared in `channels()`. Keep
184
+ > the two in sync.
185
+
186
+ ### Routing per recipient
187
+
188
+ `channels()` receives the recipient, so a single notification can respect each
189
+ person's preferences instead of forcing every recipient down the same path:
190
+
191
+ ```ts
192
+ // app/notifications/OrderShippedNotification.ts
193
+ channels(user: Notifiable) {
194
+ // Everyone gets the inbox copy; how they're alerted is their choice.
195
+ return user.prefersSms ? ["database", "sms"] : ["database", "mail"];
196
+ }
197
+ ```
198
+
199
+ Ignore the parameter when every recipient gets the same channels — that is the
200
+ common case, and `channels()` with no arguments stays valid.
201
+
202
+ A recipient can also redirect an individual channel without any notification
203
+ knowing about it, by implementing `routeNotificationFor`:
204
+
205
+ ```ts
206
+ // app/models/User.ts
207
+ routeNotificationFor(channel: string) {
208
+ // Invoices go to the billing contact; everything else to the usual address.
209
+ return channel === "mail" ? this.billingEmail : undefined;
210
+ }
211
+ ```
212
+
213
+ Return `undefined` to fall back to the default for that channel — `email` for
214
+ mail, `phone` for SMS, the configured webhook for Slack.
215
+
216
+ ### When a channel fails
217
+
218
+ Channels are independent, so one failing does not cancel the others: every
219
+ declared channel is attempted, and the failures are collected and thrown together
220
+ afterwards as a `NotificationDispatchError`. A Slack webhook returning a 500 does
221
+ not cost the recipient the email and the stored row that were declared alongside
222
+ it.
223
+
224
+ ```ts
225
+ // in a controller or service
226
+ try {
227
+ await Notify.send(user, new OrderShippedNotification(order));
228
+ } catch (error) {
229
+ if (error instanceof NotificationDispatchError) {
230
+ error.delivered; // ["mail", "database"] — these did arrive
231
+ error.failures; // [{ channel: "slack", error }] — this did not
232
+ }
233
+ }
234
+ ```
235
+
236
+ When a notification declares exactly one channel and it fails, that channel's own
237
+ error is thrown unwrapped, so a `catch` narrowing on `NotificationDeliveryError`
238
+ still reads naturally.
239
+
240
+ ## The Notifiable interface
241
+
242
+ The entity receiving the notification must satisfy `Notifiable`:
243
+
244
+ ```ts
245
+ // from @zerotal/notifications
246
+ interface Notifiable {
247
+ id: number | string;
248
+ email?: string; // default recipient for the 'mail' channel
249
+ name?: string;
250
+ phone?: string; // default recipient for the 'sms' channel (E.164 format)
251
+ receivesBroadcastNotificationsOn?(): string; // override the broadcast channel
252
+ routeNotificationFor?(channel: string): string | undefined; // per-channel override
253
+ }
254
+ ```
255
+
256
+ Your `User` model already satisfies this if it has `id` and `email` fields. To
257
+ get the object-oriented API (`user.notify(...)`, inbox helpers), compose the
258
+ [`Notifiable` mixin](#via-the-notifiable-mixin).
259
+
260
+ ## Sending notifications
261
+
262
+ There are three entry points — all delegate to the same `NotificationManager`,
263
+ so behaviour is identical.
264
+
265
+ ### Which should I use?
266
+
267
+ - **`Notifiable` mixin** — the ergonomic default when the recipient is a
268
+ model. Gives you `user.notify(...)` plus the database-inbox helpers.
269
+ - **`Notify` facade** — when you have a notifiable that isn't a mixin-composed
270
+ model, or you prefer a static call site.
271
+ - **`NotificationManager` directly** — when you've resolved the manager from the
272
+ container yourself (e.g. in a service with the container in hand).
273
+
274
+ ### Via the Notifiable mixin
275
+
276
+ Compose `Notifiable(Base)` onto your notifiable model — the
277
+ `Notifiable` mixin. It adds `notify` / `notifyLater` plus
278
+ database-inbox helpers:
279
+
280
+ ```ts
281
+ // app/models/User.ts
282
+ import { AuthUser } from "@zerotal/auth";
283
+ import { column, table } from "@zerotal/orm";
284
+ import { Notifiable } from "@zerotal/notifications";
285
+
286
+ @table("users")
287
+ export class User extends AuthUser.using(Notifiable) {
288
+ @column() email!: string;
289
+ }
290
+ ```
291
+
292
+ ```ts
293
+ // in a controller or service
294
+ await user.notify(new OrderShippedNotification(order)); // send now
295
+ await user.notifyLater(new OrderShippedNotification(order)); // queue for background
296
+
297
+ const unread = await user.unreadNotifications(); // database-channel inbox
298
+ const all = await user.notifications();
299
+ await user.markNotificationsAsRead();
300
+ ```
301
+
302
+ ### Via the Notify facade
303
+
304
+ ```ts
305
+ // in a controller or service
306
+ import { Notify } from "@zerotal/notifications";
307
+
308
+ await Notify.send(user, new OrderShippedNotification(order)); // send now
309
+ await Notify.queue(user, new OrderShippedNotification(order)); // queue for background
310
+ ```
311
+
312
+ ### To many recipients at once
313
+
314
+ `sendMany` and `queueMany` take any iterable of notifiables. Recipients are
315
+ independent: one failing does not stop the rest, and the errors are reported
316
+ together at the end.
317
+
318
+ ```ts
319
+ // in a controller or service
320
+ const admins = await User.where("role", "admin").get();
321
+ await Notify.sendMany(admins, new LowStockNotification(product));
322
+ ```
323
+
324
+ ### To an address with no model behind it
325
+
326
+ Some notifications go to a destination rather than a user — an on-call address, a
327
+ webhook, a number typed into a form. `route()` takes the destinations directly:
328
+
329
+ ```ts
330
+ // in a controller or service
331
+ await Notify.route({ mail: "ops@acme.test" }).notify(new DeployFinished(build));
332
+
333
+ await Notify.route({
334
+ sms: "+15551234567",
335
+ slack: "https://hooks.slack.com/services/…",
336
+ }).notifyLater(new PagerAlert(incident));
337
+ ```
338
+
339
+ Each key routes one channel, and `notifyLater` queues exactly as it does for a
340
+ model. The `database` channel is the one to avoid here: rows it writes are keyed
341
+ to a generated id that nothing can query back, so an on-demand notification
342
+ normally declares only transport channels.
343
+
344
+ ### Via the NotificationManager directly
345
+
346
+ ```ts
347
+ // in a service
348
+ import { NotificationManager } from "@zerotal/notifications";
349
+ import { Application } from "zerotal";
350
+
351
+ const manager = await currentApp().container.make(NotificationManager);
352
+ await manager.send(user, new OrderShippedNotification(order));
353
+ ```
354
+
355
+ ## Channels
356
+
357
+ ### mail
358
+
359
+ Implement `toMail(notifiable)` returning a `MailMessage`. The recipient defaults
360
+ to the notifiable's `email`, so you rarely set `to()`. The mail channel is
361
+ built-in and works on the `log` driver out of the box; switch to `smtp` or
362
+ `resend` in `config/notifications.ts`.
363
+
364
+ ```ts
365
+ // in a Notification
366
+ import { MailMessage } from "@zerotal/notifications";
367
+ import type { Notifiable } from "@zerotal/notifications";
368
+
369
+ toMail(n: Notifiable): MailMessage {
370
+ return new MailMessage()
371
+ .subject("Your order shipped")
372
+ .greeting(`Hi ${n.name ?? "there"},`, { bold: true })
373
+ .line("Your order is on its way.")
374
+ .action("Track package", "https://app.test/orders/123");
375
+ }
376
+ ```
377
+
378
+ Attach files with `attach()` when you already have the bytes, or `attachFile()`
379
+ to read one from disk. `embed()` places an image in the body rather than listing
380
+ it as a download — reference it from your HTML as `cid:the-id`:
381
+
382
+ ```ts
383
+ // in a Notification
384
+ async toMail(_n: Notifiable): Promise<MailMessage> {
385
+ return (await new MailMessage()
386
+ .subject("Your invoice")
387
+ .line("This month's invoice is attached.")
388
+ .attachFile("./storage/invoices/2026-07.pdf"))
389
+ .embed("logo", { filename: "logo.png", content: logoBytes, contentType: "image/png" });
390
+ }
391
+ ```
392
+
393
+ ### database
394
+
395
+ Implement `toDatabase(notifiable)` returning a plain object. The notification is
396
+ stored in the configured table (`notifications` by default), which is
397
+ auto-created on first use:
398
+
399
+ | Column | Value |
400
+ | ----------------- | --------------------------------------- |
401
+ | `id` | UUID |
402
+ | `notifiable_type` | recipient model name (currently `User`) |
403
+ | `notifiable_id` | stringified recipient id |
404
+ | `type` | notification class name |
405
+ | `data` | JSON payload from `toDatabase()` |
406
+ | `read_at` | `null` until marked read |
407
+ | `created_at` | ISO timestamp |
408
+
409
+ `notifiable_type` is the recipient's own class name, and every read is scoped by
410
+ the type and the id together — ids are only unique within a model, so a `User#1`
411
+ and a `Team#1` keep separate inboxes.
412
+
413
+ Read and mark stored notifications through the [mixin helpers](#via-the-notifiable-mixin)
414
+ (`notifications()`, `unreadNotifications()`, `markNotificationsAsRead()`).
415
+
416
+ Inbox reads return the 100 most recent rows unless told otherwise. Pass a limit
417
+ and offset to page, or `limit: 0` for everything:
418
+
419
+ ```ts
420
+ // in a controller
421
+ const page = await user.notifications({ limit: 20, offset: 40 });
422
+ const badge = await user.unreadNotificationCount(); // counts without loading rows
423
+ ```
424
+
425
+ Nothing is deleted automatically, so a long-lived app accumulates rows
426
+ indefinitely. `bun zt notifications:prune --days 30` deletes read notifications
427
+ past an age threshold; add `--all` to include unread ones. Schedule it.
428
+
429
+ ### slack
430
+
431
+ Implement `toSlack(notifiable)` returning a `SlackMessage`:
432
+
433
+ ```ts
434
+ // in a Notification
435
+ import type { SlackMessage } from "@zerotal/notifications";
436
+
437
+ toSlack(_notifiable: Notifiable): SlackMessage {
438
+ return {
439
+ text: `New signup: ${user.email}`,
440
+ // blocks: [...] // optional Block Kit blocks for rich formatting
441
+ };
442
+ }
443
+ ```
444
+
445
+ The webhook URL is resolved from the most specific source available: the
446
+ message's own `webhookUrl`, then the recipient's `routeNotificationFor("slack")`,
447
+ then `slack.webhook` in `config/notifications.ts`. Set the global one and most
448
+ notifications need only supply text. When none of the three yields a URL, the
449
+ channel raises a `NotificationChannelNotConfiguredError` naming the notification.
450
+
451
+ ### sms
452
+
453
+ Implement `toSms(notifiable)` returning an `SmsMessage`. Supported drivers:
454
+ `twilio` and `vonage`.
455
+
456
+ ```ts
457
+ // in a Notification
458
+ import type { SmsMessage } from "@zerotal/notifications";
459
+
460
+ toSms(_notifiable: Notifiable): SmsMessage {
461
+ return { body: `Your verification code is ${this.code}.` };
462
+ }
463
+ ```
464
+
465
+ The recipient defaults to the notifiable's `phone`, exactly as mail defaults to
466
+ `email`, so `to` is only needed when sending somewhere else. A notifiable with no
467
+ phone and no `to` raises an error naming the notification rather than silently
468
+ sending nothing.
469
+
470
+ ### broadcast
471
+
472
+ Push a notification to a connected client in real time via
473
+ [`@zerotal/broadcasting`](/docs/broadcasting). Implement `toBroadcast(notifiable)`
474
+ returning a `BroadcastMessage` (or a plain data object), and optionally
475
+ `broadcastType()` to set the wire `type`:
476
+
477
+ ```ts
478
+ // app/notifications/InvoicePaid.ts
479
+ import { Notification, BroadcastMessage } from "@zerotal/notifications";
480
+ import type { Notifiable } from "@zerotal/notifications";
481
+
482
+ export class InvoicePaid extends Notification {
483
+ constructor(private invoice: Invoice) {
484
+ super();
485
+ }
486
+
487
+ channels() {
488
+ return ["database", "broadcast"];
489
+ }
490
+
491
+ toBroadcast(_notifiable: Notifiable): BroadcastMessage {
492
+ return new BroadcastMessage({ invoiceId: this.invoice.id, amount: this.invoice.amount });
493
+ }
494
+
495
+ broadcastType() {
496
+ return "invoice.paid"; // default: the class name
497
+ }
498
+ }
499
+ ```
500
+
501
+ The broadcast channel works like this:
502
+
503
+ - **Channel.** Broadcasts on the notifiable's private channel —
504
+ `private-notifications.{id}` by default. Override per notifiable with
505
+ `receivesBroadcastNotificationsOn(): string`:
506
+
507
+ ```ts
508
+ // app/models/User.ts
509
+ class User extends Model {
510
+ receivesBroadcastNotificationsOn() {
511
+ return `users.${this.id}`;
512
+ }
513
+ }
514
+ ```
515
+
516
+ - **Event + payload.** The wire event name is `"notification"` — exported as
517
+ `BROADCAST_NOTIFICATION_EVENT` if you would rather import it than repeat the
518
+ string. Every broadcast notification uses that one event name; the payload's
519
+ `type` is what distinguishes them. The payload is your data merged with
520
+ `{ id, type, readAt: null, createdAt }`, so the client can render it like a
521
+ stored notification.
522
+ - **Authorize** the per-user channel in `routes/channels.ts`:
523
+
524
+ ```ts
525
+ // routes/channels.ts
526
+ Broadcast.channel("notifications.[id]", (user, id) => String(user.id) === id);
527
+ ```
528
+
529
+ - **Client** (any Pusher-compatible client):
530
+
531
+ ```ts
532
+ // in your frontend
533
+ Echo.private(`notifications.${userId}`).listen("notification", (n) => {
534
+ console.log(n.type, n);
535
+ });
536
+ ```
537
+
538
+ > **Note** — The broadcast channel requires `BroadcastProvider` to be registered.
539
+
540
+ A broadcast goes out inline, which is the point of the channel. When one fans out
541
+ widely enough that the request should not wait for it, `.onQueue(name)` hands it
542
+ to a worker instead — at the cost of arriving whenever that worker picks it up.
543
+
544
+ ```ts
545
+ // in a Notification
546
+ toBroadcast(_n: Notifiable) {
547
+ return new BroadcastMessage({ id: this.report.id }).onQueue("broadcasts");
548
+ }
549
+ ```
550
+
551
+ ## Custom channels
552
+
553
+ The five built-in channels are registered the same way yours are, so adding a
554
+ channel is not a special case. Register a factory under a name, and any
555
+ notification can declare it:
556
+
557
+ ```ts
558
+ // app/providers/DiscordChannelProvider.ts
559
+ import type { NotificationChannel, Notifiable } from "@zerotal/notifications";
560
+
561
+ class DiscordChannel implements NotificationChannel {
562
+ async send(notifiable: Notifiable, notification: Notification) {
563
+ const message = await (
564
+ notification as { toDiscord(n: Notifiable): { content: string } }
565
+ ).toDiscord(notifiable);
566
+ await fetch(notifiable.routeNotificationFor?.("discord") ?? this.webhook, {
567
+ method: "POST",
568
+ headers: { "Content-Type": "application/json" },
569
+ body: JSON.stringify(message),
570
+ });
571
+ }
572
+ }
573
+
574
+ // in the provider's onBooted()
575
+ const notifications = this.app.container.makeSync("notifications");
576
+ notifications.extend("discord", () => new DiscordChannel());
577
+ ```
578
+
579
+ ```ts
580
+ // app/notifications/DeployFinished.ts
581
+ channels() {
582
+ return ["database", "discord"];
583
+ }
584
+
585
+ toDiscord(_n: Notifiable) {
586
+ return { content: `Deploy ${this.build.sha} finished` };
587
+ }
588
+ ```
589
+
590
+ The factory runs once, the first time something sends on that channel, so a
591
+ channel nobody uses costs nothing. Registering a name that already exists
592
+ replaces it, which is how you swap a built-in channel for your own
593
+ implementation. Sending on a name that was never registered raises an
594
+ `UnknownNotificationChannelError` listing the names that were.
595
+
596
+ ## Queued notifications
597
+
598
+ `notifyLater()` and `Notify.queue()` hand delivery to
599
+ [the queue](/docs/queue). With the sync driver the notification is passed
600
+ through in memory; with a persistent driver (SQLite or Redis) it is serialized,
601
+ so both the notification and its recipient have to survive a round trip through
602
+ JSON.
603
+
604
+ Two consequences are worth knowing before you queue anything.
605
+
606
+ **The recipient crosses as a snapshot.** Channels read the `Notifiable` contract
607
+ plus whatever else the model exposes through `toJSON()`, and the rebuilt
608
+ recipient is a plain object. Read fields on a notifiable, not methods, in any
609
+ notification you queue.
610
+
611
+ **The notification is rebuilt by class name.** Classes under
612
+ `app/notifications/` are found automatically. One that lives elsewhere must
613
+ register itself:
614
+
615
+ ```ts
616
+ // app/domain/billing/InvoiceOverdue.ts
617
+ import { NotificationRegistry } from "@zerotal/notifications";
618
+
619
+ export class InvoiceOverdue extends Notification {
620
+ /* … */
621
+ }
622
+
623
+ NotificationRegistry.register(InvoiceOverdue);
624
+ ```
625
+
626
+ By default a notification's own enumerable fields are serialized, which covers a
627
+ constructor that assigns plain values. When it holds something JSON cannot carry
628
+ — a model instance, a `Map`, a closure — say how to shrink and rebuild it:
629
+
630
+ ```ts
631
+ // app/notifications/InvoicePaid.ts
632
+ export class InvoicePaid extends Notification {
633
+ constructor(private invoice: Invoice) {
634
+ super();
635
+ }
636
+
637
+ override payload() {
638
+ return { invoiceId: this.invoice.id };
639
+ }
640
+
641
+ static override async fromPayload(data: Record<string, unknown>) {
642
+ return new InvoicePaid(await Invoice.findOrFail(data["invoiceId"] as number));
643
+ }
644
+ }
645
+ ```
646
+
647
+ ## Testing
648
+
649
+ `NotificationFake` swaps the `"notifications"` container binding for an
650
+ in-memory recorder, so assertions run without hitting any real channel:
651
+
652
+ ```ts
653
+ // tests/orders.test.ts
654
+ import { NotificationFake } from "@zerotal/notifications";
655
+ import { describe, it, beforeEach, afterEach } from "bun:test";
656
+
657
+ let notify: NotificationFake;
658
+
659
+ beforeEach(() => {
660
+ notify = NotificationFake.install();
661
+ });
662
+ afterEach(() => notify.restore());
663
+
664
+ it("notifies the user when order ships", async () => {
665
+ const user = await UserFactory.create();
666
+ const order = await OrderFactory.create({ userId: user.id });
667
+
668
+ await triggerShipment(order);
669
+
670
+ // Assert the right user got the right notification
671
+ notify.assertSentTo(user, OrderShippedNotification);
672
+
673
+ // With an optional filter callback
674
+ notify.assertSentTo(user, OrderShippedNotification, (n) => n instanceof OrderShippedNotification);
675
+
676
+ // Assert a notification was NOT sent to a specific user
677
+ notify.assertNotSentTo(adminUser, OrderShippedNotification);
678
+
679
+ // Assert the channels it was declared to go out on
680
+ notify.assertSentOn(user, OrderShippedNotification, "mail");
681
+
682
+ // Assert it was queued rather than sent immediately
683
+ notify.assertQueued(user, OrderShippedNotification);
684
+
685
+ // Assert how many times one class was sent, across all recipients
686
+ notify.assertSentTimes(OrderShippedNotification, 1);
687
+
688
+ // Assert the exact total count
689
+ notify.assertSentCount(1);
690
+
691
+ // Assert nothing at all was sent
692
+ // notify.assertNothingSent();
693
+ });
694
+ ```
695
+
696
+ A failing assertion prints what was actually captured — the notification, the
697
+ recipient, its channels, and whether it was queued — which is normally the fact
698
+ you need next.
699
+
700
+ > **Warning** — `NotificationFake.install()` captures `send` _and_ `queue`, but
701
+ > it has no channel behaviour — `toDatabase()` rows are never written. Its
702
+ > `database` accessor answers as an empty inbox so code under test that reads
703
+ > `unreadNotifications()` keeps working; assert on what was sent, not on the
704
+ > inbox, inside a faked test.
705
+
706
+ ## Watching deliveries
707
+
708
+ With [`@zerotal/admin`](/docs/admin) installed, the notifications console
709
+ appears under Operations, gated on the `notifications.view` ability. It shows
710
+ recent delivery attempts with the channel, recipient, duration, and the
711
+ provider's own error text; per-channel totals since boot, which is where a
712
+ failing channel stands out; and the stored inbox, with actions to delete a row or
713
+ prune read notifications.
714
+
715
+ The recent-delivery and per-channel figures are in-process counters, not history
716
+ — they reset when the process does. The durable record of a notification is the
717
+ database channel.
718
+
719
+ ## References
720
+
721
+ ### NotificationManager
722
+
723
+ | Method | Signature | Description |
724
+ | ----------- | ---------------------------------------------------------------------------------- | -------------------------------------------------- |
725
+ | `send` | `(notifiable: Notifiable, notification: Notification) => Promise<void>` | Deliver over every declared channel now. |
726
+ | `sendMany` | `(notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>` | Deliver to many recipients now. |
727
+ | `queue` | `(notifiable: Notifiable, notification: Notification) => Promise<void>` | Queue for background delivery via the queue. |
728
+ | `queueMany` | `(notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>` | Queue for many recipients. |
729
+ | `route` | `(routes: OnDemandRoutes) => { notify, notifyLater }` | Address a destination with no model behind it. |
730
+ | `extend` | `(channel: string, factory: () => NotificationChannel) => this` | Register a custom channel, or replace a built-in. |
731
+ | `channels` | `() => string[]` | Every registered channel name. |
732
+ | `database` | `DatabaseChannel` (getter) | Direct access to the database channel for queries. |
733
+
734
+ The `Notify` facade proxies these — `Notify.send(...)`, `Notify.sendMany(...)`,
735
+ `Notify.queue(...)`, `Notify.route(...)`.
736
+
737
+ ### Notification (extend this)
738
+
739
+ Every `to*()` method may return its message directly or a promise of it, so
740
+ building one can do I/O — reading an attachment, loading a record.
741
+
742
+ | Member | Signature | Description |
743
+ | --------------- | ------------------------------------------------------------------------- | ---------------------------------------------------- |
744
+ | `channels` | `(notifiable?: Notifiable) => string[]` | Declare the channels to deliver on (abstract). |
745
+ | `toMail` | `(notifiable: Notifiable) => MailMessage` | Build the email for the `mail` channel. |
746
+ | `toDatabase` | `(notifiable: Notifiable) => Record<string, unknown>` | Build the stored payload for the `database` channel. |
747
+ | `toSlack` | `(notifiable: Notifiable) => SlackMessage` | Build the Slack message for the `slack` channel. |
748
+ | `toSms` | `(notifiable: Notifiable) => SmsMessage` | Build the SMS for the `sms` channel. |
749
+ | `toBroadcast` | `(notifiable: Notifiable) => BroadcastMessage \| Record<string, unknown>` | Build the broadcast payload. |
750
+ | `broadcastType` | `() => string` | Wire `type` of a broadcast (default: class name). |
751
+ | `payload` | `() => Record<string, unknown>` | State to store when queued (default: own fields). |
752
+ | `fromPayload` | `static (data) => Notification \| Promise<Notification>` | Rebuild from stored state (optional). |
753
+
754
+ ### Notifiable mixin
755
+
756
+ | Method | Signature | Description |
757
+ | ------------------------- | ------------------------------------------------------- | -------------------------------------------- |
758
+ | `notify` | `(n: Notification) => Promise<void>` | Send now across the notification's channels. |
759
+ | `notifyLater` | `(n: Notification) => Promise<void>` | Queue for background delivery. |
760
+ | `notifications` | `(query?: InboxQuery) => Promise<NotificationRecord[]>` | All stored notifications, newest first. |
761
+ | `unreadNotifications` | `(query?: InboxQuery) => Promise<NotificationRecord[]>` | Unread stored notifications, newest first. |
762
+ | `unreadNotificationCount` | `() => Promise<number>` | Unread count, without loading rows. |
763
+ | `markNotificationsAsRead` | `() => Promise<void>` | Mark all unread notifications as read. |
764
+ | `clearNotifications` | `() => Promise<void>` | Delete every stored notification. |
765
+
766
+ ### NotificationFake
767
+
768
+ | Method | Description |
769
+ | ------------------------------------------ | ----------------------------------------------------------------------------------------- |
770
+ | `NotificationFake.install()` | Replace the `"notifications"` container binding. Returns the `NotificationFake` instance. |
771
+ | `restore()` | Restore the original binding. Call in `afterEach`. |
772
+ | `sent()` | Return all captured `{ notifiable, notification }` pairs. |
773
+ | `sentTo(notifiable)` | Return the captured notifications for one recipient. |
774
+ | `assertSentTo(notifiable, Class, filter?)` | Throw if `Class` was not sent to `notifiable`. |
775
+ | `assertNotSentTo(notifiable, Class)` | Throw if `Class` was sent to `notifiable`. |
776
+ | `assertSentOn(notifiable, Class, channel)` | Throw unless `Class` declared `channel` for `notifiable`. |
777
+ | `assertQueued(notifiable, Class)` | Throw unless `Class` was queued rather than sent immediately. |
778
+ | `assertSentTimes(Class, n)` | Throw if `Class` was not sent exactly `n` times, across all recipients. |
779
+ | `assertNothingSent()` | Throw if any notification was sent. |
780
+ | `assertSentCount(n)` | Throw if total sent count is not `n`. |
781
+
782
+ ### Channel classes
783
+
784
+ You name channels as strings from `channels()`; these are the classes behind them,
785
+ exported so a custom channel can wrap one rather than reimplement it, and so
786
+ `extend()` can replace a built-in with a subclass.
787
+
788
+ | Channel | Class | Builds from |
789
+ | ----------- | ------------------ | --------------- |
790
+ | `mail` | `MailChannel` | `toMail()` |
791
+ | `database` | `DatabaseChannel` | `toDatabase()` |
792
+ | `slack` | `SlackChannel` | `toSlack()` |
793
+ | `sms` | `SmsChannel` | `toSms()` |
794
+ | `broadcast` | `BroadcastChannel` | `toBroadcast()` |
795
+
796
+ ### Mail drivers
797
+
798
+ Selected through `config/notifications.ts` rather than constructed directly.
799
+
800
+ | Driver | Class | Notes |
801
+ | -------- | -------------- | ------------------------------------------------------------- |
802
+ | `smtp` | `SmtpDriver` | Speaks SMTP directly — no npm dependency. |
803
+ | `resend` | `ResendDriver` | Posts to the Resend HTTP API; needs an API key. |
804
+ | `log` | `LogDriver` | Writes the rendered message to the log instead of sending it. |
805
+
806
+ ### Events
807
+
808
+ Emitted on the [event bus](/docs/events), so an app can observe delivery without
809
+ wrapping the manager.
810
+
811
+ | Event | Fired when |
812
+ | ------------------------------- | ---------------------------------------------------------- |
813
+ | `NotificationSent` | A notification finished delivering across its channels. |
814
+ | `MessageSent` / `MessageFailed` | One channel's delivery succeeded / failed, with the error. |
815
+ | `MessageQueued` | A notification was queued rather than sent inline. |
816
+
817
+ ### Delivery counters
818
+
819
+ `recentDeliveries()` returns the most recent attempts, newest first, and
820
+ `channelStats()` per-channel totals, busiest first — the two figures the admin
821
+ console renders. Both are in-process counters that reset with the process; the
822
+ durable record is the database channel.
823
+
824
+ ### Errors
825
+
826
+ | Error | Thrown when |
827
+ | ------------------------------------- | ------------------------------------------------------------- |
828
+ | `NotificationError` | Base class — catch this to handle any notification failure. |
829
+ | `NotificationDeliveryError` | A channel's transport rejected the message. |
830
+ | `NotificationChannelUnavailableError` | A declared channel is not registered or is missing config. |
831
+ | `UnknownNotificationTypeError` | A queued notification's stored type cannot be resolved back. |
832
+ | `UnknownSmsDriverError` | The configured SMS driver name is not recognised. |
833
+ | `SmtpConnectionError` | The SMTP server could not be reached or the handshake failed. |
834
+ | `SmtpResponseError` | The SMTP server rejected a command, carrying its reply code. |
835
+
836
+ ### Other exports
837
+
838
+ | Export | Purpose |
839
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
840
+ | `OnDemandNotifiable` | The recipient `Notify.route()` builds — a destination with no model behind it. Its database rows are keyed to a random id nothing can query back, so on-demand notifications normally declare transport channels only. |
841
+ | `RichLine` | The chainable line returned inside `MailMessage` for mixed formatting (`.text()`, `.color()`). |
842
+
843
+ ## Next steps
844
+
845
+ - [Broadcasting](/docs/broadcasting) — the real-time broadcast channel and channel auth.
846
+ - [Queue](/docs/queue) — background delivery with `notifyLater()` / `Notify.queue()`.
847
+ - [Database](/docs/database) — where stored notifications live.
848
+ - [Admin](/docs/admin) — the panel hosting the notifications console.