@shaferllc/keel 0.66.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# Notifications
|
|
2
|
+
|
|
3
|
+
Send a message to a recipient over one or more **channels** — mail, database, or
|
|
4
|
+
your own — inline or through the queue. This is where the mail and queue layers
|
|
5
|
+
compose: a notification declares *what* to say and *which channels* carry it,
|
|
6
|
+
and each channel decides *how*. Edge-safe, like everything under it.
|
|
7
|
+
|
|
8
|
+
## Defining a notification
|
|
9
|
+
|
|
10
|
+
Subclass `Notification`. `via()` lists the channels; each channel reads from a
|
|
11
|
+
matching method (`toMail`, `toArray`):
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Notification, type Notifiable, type MailContent } from "@shaferllc/keel/core";
|
|
15
|
+
|
|
16
|
+
export class InvoicePaid extends Notification {
|
|
17
|
+
constructor(private amount: number) {
|
|
18
|
+
super();
|
|
19
|
+
}
|
|
20
|
+
via(_notifiable: Notifiable) {
|
|
21
|
+
return ["mail", "database"];
|
|
22
|
+
}
|
|
23
|
+
toMail(): MailContent {
|
|
24
|
+
return { subject: "Payment received", text: `Thanks for $${this.amount}.` };
|
|
25
|
+
}
|
|
26
|
+
toArray() {
|
|
27
|
+
return { amount: this.amount };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Generate one with `keel make:notification InvoicePaid` (→
|
|
33
|
+
`app/Notifications/InvoicePaidNotification.ts`).
|
|
34
|
+
|
|
35
|
+
## Sending
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { notify } from "@shaferllc/keel/core";
|
|
39
|
+
|
|
40
|
+
await notify(user, new InvoicePaid(4200)); // one recipient
|
|
41
|
+
await notify([alice, bob], new InvoicePaid(4200)); // many
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A recipient is any object with routing info — usually a `User` model. The mail
|
|
45
|
+
channel routes to `notifiable.email`; override per channel with
|
|
46
|
+
`routeNotificationFor`:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
class User extends Model {
|
|
50
|
+
static table = "users";
|
|
51
|
+
routeNotificationFor(channel: string) {
|
|
52
|
+
return channel === "mail" ? this.billing_email : undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Routing
|
|
58
|
+
|
|
59
|
+
Each channel needs to know *where* a recipient receives it. `routeFor` resolves
|
|
60
|
+
that: it calls the notifiable's `routeNotificationFor(channel)` first, and if
|
|
61
|
+
that returns nothing it falls back to `notifiable.email` for the `mail` channel
|
|
62
|
+
or `notifiable.id` for everything else.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { routeFor } from "@shaferllc/keel/core";
|
|
66
|
+
|
|
67
|
+
routeFor(user, "mail"); // user.email, unless routeNotificationFor overrides it
|
|
68
|
+
routeFor(user, "database"); // user.id
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
So the common case needs no routing method at all — a `User` with `email` and
|
|
72
|
+
`id` columns just works. Override `routeNotificationFor` only when a channel
|
|
73
|
+
addresses the recipient differently (a billing address, a Slack id, a phone
|
|
74
|
+
number). Return `undefined` from it to fall back to the default.
|
|
75
|
+
|
|
76
|
+
The mail channel throws `Notification: no mail route …` if it can't resolve an
|
|
77
|
+
address — set `email`, implement `routeNotificationFor`, or put a `to` on the
|
|
78
|
+
`MailContent`.
|
|
79
|
+
|
|
80
|
+
## Channels
|
|
81
|
+
|
|
82
|
+
Register channels on the notifier (typically in a service provider). The `mail`
|
|
83
|
+
channel is registered by default:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { setNotifier, Notifier, DatabaseChannel } from "@shaferllc/keel/core";
|
|
87
|
+
|
|
88
|
+
setNotifier(new Notifier().channel("database", new DatabaseChannel()));
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Channel | Delivers by |
|
|
92
|
+
|---------|-------------|
|
|
93
|
+
| `MailChannel` (`mail`, default) | The mailer, using the notification's `toMail`. Routes to `email`. |
|
|
94
|
+
| `DatabaseChannel` (`database`) | Inserting `toArray` into a table (`type`, `notifiable_id`, `data`). |
|
|
95
|
+
| `ArrayChannel` (`array`) | Collecting deliveries in `.sent` — for tests. |
|
|
96
|
+
|
|
97
|
+
The database channel expects a table (default `notifications`) with `type`,
|
|
98
|
+
`notifiable_id`, and a `data` (JSON) column — create it in a migration. Point it
|
|
99
|
+
at another table by passing the name: `new DatabaseChannel("alerts")`.
|
|
100
|
+
|
|
101
|
+
Delivery walks the channels named by `via()` in order, and each is looked up by
|
|
102
|
+
name. If `via()` names a channel that was never registered, the notifier throws
|
|
103
|
+
`No notification channel "…" registered.` — so register a channel before a
|
|
104
|
+
notification routes to it. Likewise the mail channel throws if the notification
|
|
105
|
+
has no `toMail()`.
|
|
106
|
+
|
|
107
|
+
## Queued notifications
|
|
108
|
+
|
|
109
|
+
Set `shouldQueue = true` and delivery happens from a queued job instead of on
|
|
110
|
+
the request path — every channel runs inside the job:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
export class InvoicePaid extends Notification {
|
|
114
|
+
shouldQueue = true;
|
|
115
|
+
// …
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await notify(user, new InvoicePaid(4200)); // returns immediately; runs on the worker
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
With the `SyncDriver` (the default queue) it still runs immediately; with a
|
|
122
|
+
`MemoryDriver` or a real broker it's deferred until a worker drains it.
|
|
123
|
+
|
|
124
|
+
## A custom channel
|
|
125
|
+
|
|
126
|
+
A channel is one method — `send`. That's the seam for SMS, Slack, push, or any
|
|
127
|
+
provider:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import type { Channel, Notifiable, Notification } from "@shaferllc/keel/core";
|
|
131
|
+
|
|
132
|
+
const slack: Channel = {
|
|
133
|
+
async send(notifiable, notification) {
|
|
134
|
+
const payload = notification.toArray?.(notifiable) ?? {};
|
|
135
|
+
// POST payload to a Slack webhook via fetch…
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
setNotifier(new Notifier().channel("slack", slack));
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## In tests
|
|
142
|
+
|
|
143
|
+
Register an `ArrayChannel` (or assert on the mail `ArrayTransport`) and check
|
|
144
|
+
what was delivered — no network:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { setNotifier, Notifier, ArrayChannel, notify } from "@shaferllc/keel/core";
|
|
148
|
+
|
|
149
|
+
const array = new ArrayChannel();
|
|
150
|
+
setNotifier(new Notifier().channel("array", array));
|
|
151
|
+
|
|
152
|
+
await notify(user, new InvoicePaid(4200)); // a notification whose via() returns ["array"]
|
|
153
|
+
|
|
154
|
+
assert.equal(array.sent.length, 1);
|
|
155
|
+
assert.ok(array.sent[0].notification instanceof InvoicePaid);
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Related
|
|
159
|
+
|
|
160
|
+
Notifications compose the [mail](./mail.md) and [queue](./queues.md) layers, and
|
|
161
|
+
the database channel writes through the [query builder](./database.md). Reach for
|
|
162
|
+
those directly when you need a one-off email or an ad-hoc queued job.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## API reference
|
|
167
|
+
|
|
168
|
+
### `notify(notifiables, notification)`
|
|
169
|
+
|
|
170
|
+
`notify(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>`
|
|
171
|
+
|
|
172
|
+
Sends a notification to one or many recipients through the default notifier.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
await notify(user, new InvoicePaid(4200));
|
|
176
|
+
await notify([alice, bob], new InvoicePaid(4200));
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Notes:** a thin wrapper over `getNotifier().send(...)`. If the notification's
|
|
180
|
+
`shouldQueue` is `true`, it resolves once the job is *dispatched*, not once
|
|
181
|
+
delivery finishes; otherwise it awaits every channel inline. A single recipient
|
|
182
|
+
is normalized to a one-element list.
|
|
183
|
+
|
|
184
|
+
### `setNotifier(instance)`
|
|
185
|
+
|
|
186
|
+
`setNotifier(instance: Notifier): Notifier`
|
|
187
|
+
|
|
188
|
+
Replaces the global notifier used by `notify()` and returns it.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
setNotifier(new Notifier().channel("database", new DatabaseChannel()));
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Notes:** global — the last call wins. Because the default notifier only has the
|
|
195
|
+
`mail` channel, this is how you register `database`, `array`, or custom channels
|
|
196
|
+
app-wide (usually in a service provider).
|
|
197
|
+
|
|
198
|
+
### `getNotifier()`
|
|
199
|
+
|
|
200
|
+
`getNotifier(): Notifier`
|
|
201
|
+
|
|
202
|
+
Returns the current global notifier — handy for registering a channel without
|
|
203
|
+
swapping the instance.
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
getNotifier().channel("slack", slack);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Notes:** returns the same instance every call until `setNotifier` replaces it.
|
|
210
|
+
|
|
211
|
+
### `routeFor(notifiable, channel)`
|
|
212
|
+
|
|
213
|
+
`routeFor(notifiable: Notifiable, channel: string): string | number | undefined`
|
|
214
|
+
|
|
215
|
+
Resolves the address/id a notifiable receives a given channel at.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
routeFor(user, "mail"); // user.email (string)
|
|
219
|
+
routeFor(user, "database"); // user.id (number)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Notes:** tries `notifiable.routeNotificationFor(channel)` first (skipped if it
|
|
223
|
+
returns `null`/`undefined`), then falls back to `email` for `"mail"` and `id` for
|
|
224
|
+
any other channel. Returns `undefined` when nothing resolves — channels decide
|
|
225
|
+
whether that's an error.
|
|
226
|
+
|
|
227
|
+
### `Notification`
|
|
228
|
+
|
|
229
|
+
The abstract base for a notification. Subclass it, list channels from `via()`,
|
|
230
|
+
and add a `to<Channel>()` builder per channel.
|
|
231
|
+
|
|
232
|
+
#### `shouldQueue`
|
|
233
|
+
|
|
234
|
+
`shouldQueue: boolean`
|
|
235
|
+
|
|
236
|
+
Instance flag — set `true` to deliver from a queued job instead of inline.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
class InvoicePaid extends Notification {
|
|
240
|
+
shouldQueue = true;
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Notes:** defaults to `false`. Read by `Notifier.send`; when `true`, all
|
|
245
|
+
channels run inside the dispatched job.
|
|
246
|
+
|
|
247
|
+
#### `via(notifiable)`
|
|
248
|
+
|
|
249
|
+
`via(notifiable: Notifiable): string[]`
|
|
250
|
+
|
|
251
|
+
Returns the channel names to deliver on for this recipient.
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
via(notifiable: Notifiable) {
|
|
255
|
+
return notifiable.email ? ["mail", "database"] : ["database"];
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Notes:** defaults to `["mail"]`. Called once per recipient, so you can branch
|
|
260
|
+
on the notifiable. Every name it returns must be a registered channel or delivery
|
|
261
|
+
throws.
|
|
262
|
+
|
|
263
|
+
#### `toMail(notifiable)`
|
|
264
|
+
|
|
265
|
+
`toMail?(notifiable: Notifiable): MailContent`
|
|
266
|
+
|
|
267
|
+
Optional. Builds the content the `mail` channel sends. Required if `via()`
|
|
268
|
+
includes `"mail"`.
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
toMail(): MailContent {
|
|
272
|
+
return { subject: "Payment received", text: "Thanks!" };
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Notes:** the mail channel throws if `via()` names `"mail"` but this is
|
|
277
|
+
undefined. Set `to` on the returned `MailContent` to override the resolved
|
|
278
|
+
recipient address.
|
|
279
|
+
|
|
280
|
+
#### `toArray(notifiable)`
|
|
281
|
+
|
|
282
|
+
`toArray?(notifiable: Notifiable): Record<string, unknown>`
|
|
283
|
+
|
|
284
|
+
Optional. Builds the payload the `database` and `array` channels serialize/store.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
toArray() {
|
|
288
|
+
return { amount: this.amount };
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Notes:** the database channel stores `{}` when it's undefined; the array
|
|
293
|
+
channel keeps the whole notification, not this payload, so a missing `toArray`
|
|
294
|
+
still works in tests.
|
|
295
|
+
|
|
296
|
+
### `Notifier`
|
|
297
|
+
|
|
298
|
+
Holds the channel registry and drives delivery. `notify()` uses a global one, but
|
|
299
|
+
you can construct your own.
|
|
300
|
+
|
|
301
|
+
#### `channel(name, channel)`
|
|
302
|
+
|
|
303
|
+
`channel(name: string, channel: Channel): this`
|
|
304
|
+
|
|
305
|
+
Registers (or replaces) a channel under a name; returns `this` to chain.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
new Notifier()
|
|
309
|
+
.channel("database", new DatabaseChannel())
|
|
310
|
+
.channel("array", new ArrayChannel());
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Notes:** a fresh `Notifier` already has `mail` → `MailChannel`. Registering the
|
|
314
|
+
same name again replaces it.
|
|
315
|
+
|
|
316
|
+
#### `send(notifiables, notification)`
|
|
317
|
+
|
|
318
|
+
`send(notifiables: Notifiable | Notifiable[], notification: Notification): Promise<void>`
|
|
319
|
+
|
|
320
|
+
Delivers a notification to one or many recipients across the channels its `via()`
|
|
321
|
+
returns.
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
await new Notifier().send(user, new InvoicePaid(4200));
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Notes:** normalizes a single recipient to a list, then delivers to each in
|
|
328
|
+
order. Honors `notification.shouldQueue` (dispatches to the queue when set).
|
|
329
|
+
Throws on the first unregistered channel name.
|
|
330
|
+
|
|
331
|
+
### `MailChannel`
|
|
332
|
+
|
|
333
|
+
The default `mail` channel. Registered on every `Notifier`; you rarely construct
|
|
334
|
+
it yourself.
|
|
335
|
+
|
|
336
|
+
#### `send(notifiable, notification)`
|
|
337
|
+
|
|
338
|
+
`send(notifiable: Notifiable, notification: Notification): Promise<void>`
|
|
339
|
+
|
|
340
|
+
Builds a message from `notification.toMail()` and sends it through the mailer.
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
await new MailChannel().send(user, new InvoicePaid(4200));
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Notes:** throws `… has no toMail()` if the notification lacks one, and
|
|
347
|
+
`Notification: no mail route …` if it can't resolve an address (from
|
|
348
|
+
`MailContent.to` or `routeFor(notifiable, "mail")`). Applies `from`, `text`, and
|
|
349
|
+
`html` only when present.
|
|
350
|
+
|
|
351
|
+
### `DatabaseChannel`
|
|
352
|
+
|
|
353
|
+
The `database` channel. Persists the `toArray` payload through the query builder.
|
|
354
|
+
|
|
355
|
+
#### `new DatabaseChannel(table?)`
|
|
356
|
+
|
|
357
|
+
`new DatabaseChannel(table?: string)`
|
|
358
|
+
|
|
359
|
+
Creates a channel that writes to `table`.
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
new DatabaseChannel(); // → "notifications"
|
|
363
|
+
new DatabaseChannel("alerts"); // → "alerts"
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**Notes:** defaults to the `notifications` table.
|
|
367
|
+
|
|
368
|
+
#### `send(notifiable, notification)`
|
|
369
|
+
|
|
370
|
+
`send(notifiable: Notifiable, notification: Notification): Promise<void>`
|
|
371
|
+
|
|
372
|
+
Inserts one row: `type` (the notification's class name), `notifiable_id`
|
|
373
|
+
(`routeFor(notifiable, "database")`, or `null`), and `data` (JSON of `toArray`).
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
await new DatabaseChannel().send(user, new InvoicePaid(4200));
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Notes:** stores `"{}"` for `data` when the notification has no `toArray`. The
|
|
380
|
+
target table must exist — create it in a migration.
|
|
381
|
+
|
|
382
|
+
### `ArrayChannel`
|
|
383
|
+
|
|
384
|
+
An in-memory channel for tests — records deliveries instead of sending them.
|
|
385
|
+
|
|
386
|
+
#### `sent`
|
|
387
|
+
|
|
388
|
+
`readonly sent: { notifiable: Notifiable; notification: Notification }[]`
|
|
389
|
+
|
|
390
|
+
The log of everything this channel received, in delivery order.
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
const array = new ArrayChannel();
|
|
394
|
+
// … after notify …
|
|
395
|
+
array.sent[0].notification; // the Notification instance
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**Notes:** it keeps the notification *instance*, so you can `instanceof`-check it
|
|
399
|
+
or read its fields — no serialization through `toArray`.
|
|
400
|
+
|
|
401
|
+
#### `send(notifiable, notification)`
|
|
402
|
+
|
|
403
|
+
`send(notifiable: Notifiable, notification: Notification): Promise<void>`
|
|
404
|
+
|
|
405
|
+
Pushes `{ notifiable, notification }` onto `sent`. Never touches the network.
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
new Notifier().channel("array", new ArrayChannel());
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Interfaces & types
|
|
412
|
+
|
|
413
|
+
#### `Notifiable`
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
interface Notifiable {
|
|
417
|
+
routeNotificationFor?(channel: string): string | number | undefined;
|
|
418
|
+
[key: string]: unknown;
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
A recipient — anything with routing info, most often a `User` model. Implement
|
|
423
|
+
`routeNotificationFor` to steer specific channels; otherwise `routeFor` reads
|
|
424
|
+
`email`/`id` off the index signature.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
class User extends Model {
|
|
428
|
+
routeNotificationFor(channel: string) {
|
|
429
|
+
return channel === "mail" ? this.billing_email : undefined;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
#### `MailContent`
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
interface MailContent {
|
|
438
|
+
subject: string;
|
|
439
|
+
text?: string;
|
|
440
|
+
html?: string;
|
|
441
|
+
from?: string;
|
|
442
|
+
to?: string;
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
What `toMail()` returns and the `mail` channel consumes. `subject` is required;
|
|
447
|
+
supply `text`, `html`, or both. `to` overrides the resolved recipient; `from`
|
|
448
|
+
overrides the mailer default.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
toMail(): MailContent {
|
|
452
|
+
return { subject: "Welcome", html: "<h1>Hi</h1>", to: "override@app.com" };
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
#### `Channel`
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
interface Channel {
|
|
460
|
+
send(notifiable: Notifiable, notification: Notification): Promise<void>;
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
The seam a custom transport implements — SMS, Slack, push, anything. One method:
|
|
465
|
+
`send`. Register your implementation with `Notifier.channel(name, channel)`.
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
const slack: Channel = {
|
|
469
|
+
async send(notifiable, notification) {
|
|
470
|
+
const payload = notification.toArray?.(notifiable) ?? {};
|
|
471
|
+
// POST payload to a Slack webhook via fetch…
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
```
|
package/docs/openapi.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# OpenAPI
|
|
2
|
+
|
|
3
|
+
Keel OpenAPI generates an [OpenAPI 3](https://spec.openapis.org/oas/v3.0.3) spec
|
|
4
|
+
from your routes and serves [Swagger UI](https://swagger.io/tools/swagger-ui/) to
|
|
5
|
+
explore it. It's a Keel [package](./packages.md): one `register()` mounts the docs
|
|
6
|
+
at `/docs` and the spec at `/docs/openapi.json`.
|
|
7
|
+
|
|
8
|
+
Nothing is scraped or guessed. The generator reads Keel's own route table —
|
|
9
|
+
methods, paths, names, and param constraints are always correct — and enriches
|
|
10
|
+
each operation with whatever the route attaches via `.config(apiDoc(...))`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// bootstrap/providers.ts
|
|
16
|
+
import { OpenApiServiceProvider } from "@shaferllc/keel/openapi";
|
|
17
|
+
|
|
18
|
+
export const providers = [AppServiceProvider, OpenApiServiceProvider];
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Open `http://localhost:3000/docs`. That's enough for a spec of every route (paths,
|
|
22
|
+
methods, path params). To add summaries, request/response schemas, and tags,
|
|
23
|
+
document the routes.
|
|
24
|
+
|
|
25
|
+
## Documenting a route
|
|
26
|
+
|
|
27
|
+
`apiDoc()` returns route config the generator understands. Its `request` field is
|
|
28
|
+
the same `{ body, query, params }` shape you hand `validateRequest`, so one set of
|
|
29
|
+
Zod schemas both validates and documents:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { apiDoc } from "@shaferllc/keel/openapi";
|
|
33
|
+
import { validateRequest } from "@shaferllc/keel/core";
|
|
34
|
+
import { z } from "zod";
|
|
35
|
+
|
|
36
|
+
const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
|
|
37
|
+
|
|
38
|
+
router
|
|
39
|
+
.post("/users", [Users, "store"])
|
|
40
|
+
.config(apiDoc({
|
|
41
|
+
summary: "Create a user",
|
|
42
|
+
tags: ["users"],
|
|
43
|
+
request: { body: NewUser },
|
|
44
|
+
responses: { 201: { description: "The created user", schema: UserShape } },
|
|
45
|
+
}))
|
|
46
|
+
.middleware([validateRequest({ body: NewUser })]);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
What the generator does with it:
|
|
50
|
+
|
|
51
|
+
- **Path params** — `/users/:id` becomes `/users/{id}`; a `.where("id", /\d+/)`
|
|
52
|
+
constraint becomes a `pattern`.
|
|
53
|
+
- **Query params** — a `request.query` schema's fields expand into query
|
|
54
|
+
parameters (each `required` per the schema).
|
|
55
|
+
- **Request body** — a `request.body` schema becomes a JSON request body
|
|
56
|
+
(Zod → JSON Schema via Zod 4's `z.toJSONSchema`).
|
|
57
|
+
- **Responses** — your documented responses, plus an automatic `422` when the
|
|
58
|
+
route validates input. Undocumented routes get a default `200`.
|
|
59
|
+
- **Tags** — `tags`, or the first path segment.
|
|
60
|
+
- **operationId** — the route's `.name()`, else `method_path`.
|
|
61
|
+
|
|
62
|
+
Fields on `apiDoc`: `summary`, `description`, `tags`, `operationId`,
|
|
63
|
+
`deprecated`, `request`, `responses`, and `hidden` (leave the route out entirely).
|
|
64
|
+
Response and request schemas accept a Zod schema **or** a plain JSON Schema
|
|
65
|
+
object.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
`config/openapi.ts` (publish with `keel vendor:publish --tag openapi-config`):
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
export default {
|
|
73
|
+
enabled: true,
|
|
74
|
+
path: "docs", // /docs and /docs/openapi.json
|
|
75
|
+
title: "", // defaults to config("app.name")
|
|
76
|
+
version: "1.0.0",
|
|
77
|
+
servers: [], // e.g. ["https://api.example.com"]
|
|
78
|
+
public: false, // serve in production too
|
|
79
|
+
cdn: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14",
|
|
80
|
+
ignorePaths: ["/watch"], // route prefixes to leave out
|
|
81
|
+
};
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Access
|
|
85
|
+
|
|
86
|
+
Like [Watch](./watch.md), the docs are gated shut in production by default (open
|
|
87
|
+
only when `app.debug` is on or the app isn't in production). Set `public: true` to
|
|
88
|
+
serve them everywhere, or plug in your own check:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { OpenApi } from "@shaferllc/keel/openapi";
|
|
92
|
+
OpenApi.auth((c) => auth().check());
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The gate guards the spec endpoint too.
|
|
96
|
+
|
|
97
|
+
## Exporting the spec
|
|
98
|
+
|
|
99
|
+
Write the spec to a file — for CI, client generation, or committing it:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
keel openapi:export --out openapi.json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## On the UI dependency
|
|
106
|
+
|
|
107
|
+
The spec (`/docs/openapi.json`) is generated with **zero dependencies** and runs
|
|
108
|
+
anywhere Keel does, including the edge. The Swagger **UI** loads its assets from
|
|
109
|
+
the configured `cdn` — the one external dependency, confined to the browser. Pin
|
|
110
|
+
the version (the default is pinned) or point `cdn` at a copy you host if you need
|
|
111
|
+
a fully self-contained deployment.
|
package/docs/packages.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Packages
|
|
2
|
+
|
|
3
|
+
A **package** is a redistributable slice of a Keel app — routes, a UI, config,
|
|
4
|
+
migrations, console commands — that installs with a single `app.register(...)`.
|
|
5
|
+
Keel's `ServiceProvider` is already the unit of composition; `PackageProvider`
|
|
6
|
+
adds the conventions a *shippable* package needs so it can carry its own schema
|
|
7
|
+
and assets instead of asking the app to wire them by hand.
|
|
8
|
+
|
|
9
|
+
[Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
|
|
10
|
+
reference implementation of everything below.
|
|
11
|
+
|
|
12
|
+
## The shape of a package
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { PackageProvider, type Router } from "@shaferllc/keel/core";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
export class BillingServiceProvider extends PackageProvider {
|
|
22
|
+
readonly name = "billing"; // used for publish grouping and diagnostics
|
|
23
|
+
|
|
24
|
+
register(): void {
|
|
25
|
+
this.mergeConfig("billing", { enabled: true, path: "billing" });
|
|
26
|
+
this.migrations([createInvoicesTable]);
|
|
27
|
+
this.publishes({ [join(here, "config.stub")]: "config/billing.ts" }, "billing-config");
|
|
28
|
+
this.commands([syncInvoicesCommand]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
boot(): void {
|
|
32
|
+
this.assets("billing/assets", join(here, "ui/dist"), { maxAge: 3600 });
|
|
33
|
+
this.routes((r: Router) => registerBillingRoutes(r), { prefix: "billing", as: "billing" });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Scaffold that skeleton with `keel make:package billing`.
|
|
39
|
+
|
|
40
|
+
## The helpers
|
|
41
|
+
|
|
42
|
+
Each is a thin wrapper over an existing Keel primitive — the value is the
|
|
43
|
+
convention, not new machinery.
|
|
44
|
+
|
|
45
|
+
| Helper | What it does |
|
|
46
|
+
|--------|--------------|
|
|
47
|
+
| `mergeConfig(key, defaults)` | Set config defaults under `key`. The app's `config/<key>.ts` deep-merges **over** them, so the app always wins. |
|
|
48
|
+
| `routes(register, { prefix, middleware, as })` | Register a route group (the callback gets the `Router`), already prefixed/guarded/name-prefixed. |
|
|
49
|
+
| `assets(urlPrefix, dir, { maxAge, immutable })` | Serve a directory of built files (a bundled UI) under a URL prefix. Node-only. |
|
|
50
|
+
| `migrations(list)` | Contribute migrations, run by `keel migrate` alongside the app's own. |
|
|
51
|
+
| `commands(list)` | Add `keel` console commands (e.g. `billing:sync`). |
|
|
52
|
+
| `publishes(map, tag?)` | Declare files a consuming app can copy in with `keel vendor:publish`. |
|
|
53
|
+
|
|
54
|
+
## Lifecycle: mind the kernel
|
|
55
|
+
|
|
56
|
+
`register()` and `boot()` run **before** the app's HTTP kernel is bound (see
|
|
57
|
+
`bootstrap/app.ts`). So a package must not reach for `HttpKernel`. That's why
|
|
58
|
+
`routes()` and `assets()` go through the `Router` (bound in the Application
|
|
59
|
+
constructor) — routes are compiled onto the kernel later, at build time. Use
|
|
60
|
+
`register()` for config/bindings and `boot()` for wiring; both are safe for the
|
|
61
|
+
helpers above.
|
|
62
|
+
|
|
63
|
+
## Migrations
|
|
64
|
+
|
|
65
|
+
Package migrations join the app's under one command:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
keel migrate # run pending (app + package) migrations
|
|
69
|
+
keel migrate:status # show which have run
|
|
70
|
+
keel migrate:rollback # roll back the last batch
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
App migrations are discovered from `database/migrations/*.ts` (each file
|
|
74
|
+
default-exports a `Migration` or `Migration[]`); package migrations come from
|
|
75
|
+
`this.migrations(...)`. Both run against the default connection.
|
|
76
|
+
|
|
77
|
+
## Publishing files
|
|
78
|
+
|
|
79
|
+
`publishes()` declares source→destination copies; `keel vendor:publish` performs
|
|
80
|
+
them (skipping files that already exist unless `--force`):
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
keel vendor:publish # everything
|
|
84
|
+
keel vendor:publish --tag billing-config # just one tagged group
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This is how a package ships an overridable config stub, or copies a starter view
|
|
88
|
+
into the consuming app.
|
|
89
|
+
|
|
90
|
+
## Observing the framework
|
|
91
|
+
|
|
92
|
+
A package often wants to *see* what the app is doing — every query, request, or
|
|
93
|
+
job — without patching anything. The framework emits a typed **instrumentation
|
|
94
|
+
event stream** for exactly this; subscribe with `listen()`:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { listen, type QueryEvent } from "@shaferllc/keel/core";
|
|
98
|
+
|
|
99
|
+
listen<QueryEvent>("db.query", (e) => metrics.timing("db", e.durationMs));
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Event | Fired when |
|
|
103
|
+
|-------|-----------|
|
|
104
|
+
| `db.query` | a query runs (sql, bindings, durationMs, connection, kind) |
|
|
105
|
+
| `request.handled` | a request finishes (method, path, status, durationMs, headers) |
|
|
106
|
+
| `exception` | an error reaches the HTTP kernel |
|
|
107
|
+
| `job.processing` / `job.processed` / `job.failed` | a queued job's lifecycle |
|
|
108
|
+
| `cache.hit` / `cache.miss` | a cache lookup |
|
|
109
|
+
| `notification.sent` | a notification is delivered |
|
|
110
|
+
| `schedule.task.run` | a scheduled task runs |
|
|
111
|
+
| `mail.sending` / `mail.sent` | mail lifecycle |
|
|
112
|
+
|
|
113
|
+
Every request opens a scope with a **request id** that flows through async work,
|
|
114
|
+
so anything emitted inside a request can attribute itself to it via
|
|
115
|
+
`currentRequestId()` — that's what lets [Watch](./watch.md) tie a request to the
|
|
116
|
+
queries and logs it produced. Emitting is fire-and-forget: a broken listener can
|
|
117
|
+
never break the work it observes.
|
|
118
|
+
```
|