@zerotal/arch 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/events.md
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Events
|
|
3
|
+
description: React to things that happen in your app, and observe the framework itself, through two purpose-built event buses.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Events
|
|
7
|
+
|
|
8
|
+
Zerotal has **two** event systems: one for reacting to things that happen in your
|
|
9
|
+
domain, and one for observing the framework's own internals. Almost all of the time
|
|
10
|
+
you want just one of them — **application events** — so start there and treat the
|
|
11
|
+
other as advanced.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
your code ──emit──▶ Events facade (Emitter) ──▶ listener classes (async, queueable)
|
|
15
|
+
framework ──emit──▶ FrameworkEvents (bus) ──▶ handler functions (sync, no I/O)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Application events** are for things that happen in _your app_: a user signed
|
|
19
|
+
up, an order was placed. You emit them and write listeners that react — send an
|
|
20
|
+
email, grant a bonus. You'll use these through the `Events` facade. **This is the
|
|
21
|
+
one you reach for.** → [jump to it](#emitter-the-events-facade)
|
|
22
|
+
- **Framework events** are for _observing the framework itself_: slow queries,
|
|
23
|
+
finished requests, cache hits. The framework emits them and built-in tools (the
|
|
24
|
+
logger, devtools) listen. You only touch these to build your own instrumentation
|
|
25
|
+
— metrics, audit, alerting. → [jump to it](#frameworkevents)
|
|
26
|
+
|
|
27
|
+
**Which one do I want?** Ask what fired the event:
|
|
28
|
+
|
|
29
|
+
- Something _you_ did in your domain → **application event**.
|
|
30
|
+
- Something the _framework_ did under the hood → **framework event**.
|
|
31
|
+
|
|
32
|
+
Two rules keep them from blurring: never do I/O (network, disk, DB) inside a
|
|
33
|
+
framework-event handler — they run synchronously on the request hot path — and
|
|
34
|
+
never use framework events to drive business logic.
|
|
35
|
+
|
|
36
|
+
If you want the full side-by-side, here it is — but you can skip it and come back
|
|
37
|
+
once you've read the section for the one you need:
|
|
38
|
+
|
|
39
|
+
| | Framework events | Application events |
|
|
40
|
+
| ------------- | --------------------------------- | --------------------------- |
|
|
41
|
+
| Purpose | Observing the framework | Reacting to your domain |
|
|
42
|
+
| Who emits | The framework | Your code |
|
|
43
|
+
| Who listens | Built-in tools (logger, devtools) | Your code |
|
|
44
|
+
| A listener is | a plain **function** | a **class** with `handle()` |
|
|
45
|
+
| Timing | **synchronous**, fast, no I/O | async; can be **queued** |
|
|
46
|
+
|
|
47
|
+
## FrameworkEvents
|
|
48
|
+
|
|
49
|
+
A single synchronous bus, exported as a singleton from `zerotal`. Framework
|
|
50
|
+
packages emit lifecycle events into it; infrastructure packages subscribe to react.
|
|
51
|
+
It is the reason domain packages never import devtools or the logger — everyone
|
|
52
|
+
talks through the bus instead.
|
|
53
|
+
|
|
54
|
+
### API
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { FrameworkEvents, QueryExecuted } from "zerotal";
|
|
58
|
+
|
|
59
|
+
// Subscribe — returns an unsubscribe function.
|
|
60
|
+
const off = FrameworkEvents.on(QueryExecuted, (event) => {
|
|
61
|
+
metrics.observe("db.query.ms", event.durationMs);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Emit (done by the framework; shown for completeness).
|
|
65
|
+
FrameworkEvents.emit(new QueryExecuted(sql, bindings, startMs, durationMs, rowCount, ctx));
|
|
66
|
+
|
|
67
|
+
// Stop listening.
|
|
68
|
+
off();
|
|
69
|
+
|
|
70
|
+
// Test isolation: drop every subscription.
|
|
71
|
+
FrameworkEvents.clear();
|
|
72
|
+
|
|
73
|
+
// How many handlers are currently registered (leak assertions in tests).
|
|
74
|
+
FrameworkEvents.handlerCount();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Handler contract
|
|
78
|
+
|
|
79
|
+
`emit()` runs every handler **synchronously in the caller's stack** and **swallows
|
|
80
|
+
handler errors** — a throwing subscriber can never affect the code that emitted the
|
|
81
|
+
event. Because emission is on the hot path, handlers must be fast and must not
|
|
82
|
+
perform I/O. If you need to do real work, buffer the data and hand it off (e.g.
|
|
83
|
+
dispatch a queue job) from outside the handler.
|
|
84
|
+
|
|
85
|
+
### Subscribing from a provider
|
|
86
|
+
|
|
87
|
+
Subscribe in `onBooted`, keep the unsubscribe functions, and release them in
|
|
88
|
+
`onStopping` so handlers never leak between boots or test suites:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import { ServiceProvider, FrameworkEvents, RequestFailed } from "zerotal";
|
|
92
|
+
|
|
93
|
+
export class MetricsProvider extends ServiceProvider {
|
|
94
|
+
private _unsubs: Array<() => void> = [];
|
|
95
|
+
|
|
96
|
+
override async onBooted(): Promise<void> {
|
|
97
|
+
this._unsubs.push(
|
|
98
|
+
FrameworkEvents.on(RequestFailed, (e) => {
|
|
99
|
+
metrics.increment("http.5xx", { path: (e.ctx as any).url?.pathname });
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override onStopping(): void {
|
|
105
|
+
for (const off of this._unsubs) off();
|
|
106
|
+
this._unsubs = [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Event catalogue
|
|
112
|
+
|
|
113
|
+
Every event class is exported from `zerotal`. Events carry the active
|
|
114
|
+
`RequestContext` as `ctx` where one applies (it is `undefined` outside a request).
|
|
115
|
+
|
|
116
|
+
**HTTP** (`@zerotal/core`)
|
|
117
|
+
|
|
118
|
+
| Event | Fields | Emitted by |
|
|
119
|
+
| ------------------- | ----------------------------------------- | ---------------------------------------- |
|
|
120
|
+
| `RequestHandled` | `ctx, startMs, durationMs` | route dispatcher / server, on success |
|
|
121
|
+
| `RequestFailed` | `ctx, startMs, durationMs, error, status` | dispatcher / server catch blocks |
|
|
122
|
+
| `MiddlewareSkipped` | `name, reason, ctx` | a middleware short-circuits the pipeline |
|
|
123
|
+
|
|
124
|
+
**ORM** (`@zerotal/orm`)
|
|
125
|
+
|
|
126
|
+
| Event | Fields |
|
|
127
|
+
| ----------------------- | --------------------------------------------------- |
|
|
128
|
+
| `QueryExecuted` | `sql, bindings, startMs, durationMs, rowCount, ctx` |
|
|
129
|
+
| `NPlusOneDetected` | `fingerprint, count, ctx` |
|
|
130
|
+
| `TransactionStarted` | `txId, ctx` |
|
|
131
|
+
| `TransactionCommitted` | `txId, durationMs, ctx` |
|
|
132
|
+
| `TransactionRolledBack` | `txId, durationMs, reason, ctx` |
|
|
133
|
+
| `MigrationRan` | `name, direction, durationMs, ok, error?` |
|
|
134
|
+
|
|
135
|
+
**Mail** (`@zerotal/notifications`)
|
|
136
|
+
|
|
137
|
+
| Event | Fields |
|
|
138
|
+
| --------------- | -------------------------------------------------- |
|
|
139
|
+
| `MessageSent` | `className, to, subject, html, durationMs, queued` |
|
|
140
|
+
| `MessageQueued` | `className, to, subject, queue` |
|
|
141
|
+
| `MessageFailed` | `className, to, subject, durationMs, error` |
|
|
142
|
+
|
|
143
|
+
**Cache** (`@zerotal/cache`)
|
|
144
|
+
|
|
145
|
+
| Event | Fields |
|
|
146
|
+
| -------------- | ----------------------------------------------------------------------------- |
|
|
147
|
+
| `CacheQueried` | `op ('hit'\|'miss'\|'write'\|'forget'\|'flush'\|'has'), key, ttl, durationMs` |
|
|
148
|
+
| `CacheEvicted` | `key, reason ('ttl'\|'capacity'\|'manual')` |
|
|
149
|
+
|
|
150
|
+
**Queue** (`@zerotal/queue`)
|
|
151
|
+
|
|
152
|
+
| Event | Fields |
|
|
153
|
+
| -------- | ----------------------------------------------------------------------------------------------- |
|
|
154
|
+
| `JobRan` | `className, queue, status ('dispatched'\|'completed'\|'failed'\|'retried'), durationMs, error?` |
|
|
155
|
+
|
|
156
|
+
**Scheduler** (`@zerotal/scheduler`)
|
|
157
|
+
|
|
158
|
+
| Event | Fields |
|
|
159
|
+
| ------------- | ------------------------------------------------------------------- |
|
|
160
|
+
| `TaskRan` | `name, durationMs, ok` |
|
|
161
|
+
| `TaskFailed` | `name, durationMs, error` |
|
|
162
|
+
| `TaskSkipped` | `name, reason ('env'\|'window'\|'when'\|'skip'\|'overlap'\|'lock')` |
|
|
163
|
+
|
|
164
|
+
**Auth** (`@zerotal/auth`)
|
|
165
|
+
|
|
166
|
+
| Event | Fields |
|
|
167
|
+
| --------------------- | -------------------------------- |
|
|
168
|
+
| `LoginAttempted` | `guard, identifier, ctx` |
|
|
169
|
+
| `LoginSucceeded` | `guard, userId, ctx` |
|
|
170
|
+
| `LoginFailed` | `guard, identifier, reason, ctx` |
|
|
171
|
+
| `LoggedOut` | `guard, userId, ctx` |
|
|
172
|
+
| `TokenIssued` | `tokenId, abilities, userId` |
|
|
173
|
+
| `AuthorizationDenied` | `ability, userId, ctx` |
|
|
174
|
+
|
|
175
|
+
### Who already subscribes
|
|
176
|
+
|
|
177
|
+
- The **logger** auto-logs slow queries, N+1, and 4xx/5xx responses — see [Logger](/docs/logger).
|
|
178
|
+
- **Devtools** buffers per-request traces keyed on the request context and streams them to the live dashboard — see [Devtools](/docs/devtools).
|
|
179
|
+
|
|
180
|
+
You can add your own subscribers (metrics, audit, alerting) the same way, without
|
|
181
|
+
the emitting packages knowing you exist.
|
|
182
|
+
|
|
183
|
+
### Reading the request context in a handler
|
|
184
|
+
|
|
185
|
+
Some events carry `ctx`; for those that do not (mail/cache/queue happen outside the
|
|
186
|
+
HTTP frame of reference), resolve the current request lazily:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import { FrameworkEvents, RequestContext } from "zerotal";
|
|
190
|
+
import { CacheQueried } from "@zerotal/cache";
|
|
191
|
+
|
|
192
|
+
FrameworkEvents.on(CacheQueried, (e) => {
|
|
193
|
+
const requestId = RequestContext.tryGet()?.requestId; // undefined outside a request
|
|
194
|
+
metrics.increment(`cache.${e.op}`, { requestId });
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Emitter / the Events facade
|
|
199
|
+
|
|
200
|
+
For business events — things that happened in your domain that other parts of your
|
|
201
|
+
app should react to. Events are plain classes; listeners are classes with a
|
|
202
|
+
`handle()` method. Access the per-application `Emitter` singleton through the
|
|
203
|
+
`Events` facade.
|
|
204
|
+
|
|
205
|
+
### Define an event and a listener
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// app/events/UserRegistered.ts
|
|
209
|
+
export class UserRegistered {
|
|
210
|
+
constructor(
|
|
211
|
+
readonly userId: number,
|
|
212
|
+
readonly email: string,
|
|
213
|
+
) {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// app/listeners/SendWelcomeEmail.ts
|
|
217
|
+
import type { UserRegistered } from "../events/UserRegistered.ts";
|
|
218
|
+
import { Notify } from "@zerotal/notifications";
|
|
219
|
+
import { WelcomeNotification } from "../notifications/WelcomeNotification.ts";
|
|
220
|
+
|
|
221
|
+
export class SendWelcomeEmail {
|
|
222
|
+
async handle(event: UserRegistered): Promise<void> {
|
|
223
|
+
await Notify.send({ email: event.email }, new WelcomeNotification(event.userId));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Register and emit
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// usually a provider's onBooted(), or bootstrap code
|
|
232
|
+
import { Events } from "zerotal";
|
|
233
|
+
import { UserRegistered } from "./app/events/UserRegistered.ts";
|
|
234
|
+
import { SendWelcomeEmail } from "./app/listeners/SendWelcomeEmail.ts";
|
|
235
|
+
|
|
236
|
+
Events.on(UserRegistered, SendWelcomeEmail);
|
|
237
|
+
|
|
238
|
+
// Later, in a controller or service:
|
|
239
|
+
await Events.emit(new UserRegistered(user.id, user.email));
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`Events` is a facade over the per-application `Emitter` singleton (the `events`
|
|
243
|
+
container binding). Whatever you call on `Events` runs against the live emitter.
|
|
244
|
+
|
|
245
|
+
**What `emit()` actually does:** it looks up every listener registered for the
|
|
246
|
+
event's class, **instantiates a fresh listener per dispatch**, and runs them all
|
|
247
|
+
**concurrently** with `Promise.allSettled`. That has two consequences worth
|
|
248
|
+
internalising:
|
|
249
|
+
|
|
250
|
+
- **Failures are isolated.** One listener throwing doesn't stop the others, and
|
|
251
|
+
the error is _logged, not rethrown_ — `await Events.emit(...)` never rejects
|
|
252
|
+
because a listener failed. Don't rely on a `try/catch` around `emit()` to catch
|
|
253
|
+
listener errors; it won't.
|
|
254
|
+
- **Order is not guaranteed.** Listeners run in parallel, so don't write one that
|
|
255
|
+
assumes another has already finished. If you genuinely need ordered, awaited
|
|
256
|
+
execution, use `Events.emitSync(event)` — it runs listeners one at a time in
|
|
257
|
+
registration order and, unlike `emit()`, **does propagate** the first error.
|
|
258
|
+
|
|
259
|
+
`await`-ing `emit()` waits for all inline listeners to settle (queued ones return
|
|
260
|
+
as soon as the job is dispatched — see [Queued listeners](#queued-listeners)).
|
|
261
|
+
To unregister a listener, call `Events.off(EventClass, ListenerClass)`.
|
|
262
|
+
|
|
263
|
+
### Auto-discovered listeners
|
|
264
|
+
|
|
265
|
+
You usually don't call `Events.on(...)` yourself. A listener placed in `app/listeners/`
|
|
266
|
+
declares the event(s) it handles via `static listens`, and the framework binds it on the
|
|
267
|
+
emitter at boot — no registration code required.
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// app/listeners/SendWelcomeEmail.ts
|
|
271
|
+
import { UserRegistered } from "../events/UserRegistered.ts";
|
|
272
|
+
|
|
273
|
+
export class SendWelcomeEmail {
|
|
274
|
+
static listens = UserRegistered;
|
|
275
|
+
|
|
276
|
+
async handle(event: UserRegistered): Promise<void> {
|
|
277
|
+
await Notify.send({ email: event.email }, new WelcomeNotification(event.userId));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### One listener, several events
|
|
283
|
+
|
|
284
|
+
`static listens` accepts an array — the listener binds to each event and `handle()`
|
|
285
|
+
receives whichever one fired:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// app/listeners/GrantWelcomeBonus.ts
|
|
289
|
+
import { UserRegistered } from "../events/UserRegistered.ts";
|
|
290
|
+
import { UserReactivated } from "../events/UserReactivated.ts";
|
|
291
|
+
|
|
292
|
+
export class GrantWelcomeBonus {
|
|
293
|
+
static listens = [UserRegistered, UserReactivated];
|
|
294
|
+
|
|
295
|
+
async handle(event: UserRegistered | UserReactivated): Promise<void> {
|
|
296
|
+
await Credits.grant(event.userId, 100);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Multiple listeners can subscribe to the same event; each is instantiated and run with
|
|
302
|
+
`Promise.allSettled`, so one failure never blocks the others.
|
|
303
|
+
|
|
304
|
+
### Queued listeners
|
|
305
|
+
|
|
306
|
+
Listeners run inline by default. For slow work (sending mail, calling an API), mark the
|
|
307
|
+
listener to run on the queue instead — the emitter serialises the event and dispatches a
|
|
308
|
+
job, so `emit()` returns without waiting for the work to finish. Set `queue` to `true` for
|
|
309
|
+
the default queue, or to a queue name. Two optional fields tune the retry behaviour of the
|
|
310
|
+
dispatched job:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// app/listeners/SendWelcomeEmail.ts
|
|
314
|
+
import { UserRegistered } from "../events/UserRegistered.ts";
|
|
315
|
+
|
|
316
|
+
export class SendWelcomeEmail {
|
|
317
|
+
static listens = UserRegistered;
|
|
318
|
+
|
|
319
|
+
queue = "mail"; // or `true` for the default queue
|
|
320
|
+
maxAttempts = 3; // optional: retry up to 3 times on failure
|
|
321
|
+
retryDelay = 30; // optional: seconds to wait between attempts
|
|
322
|
+
|
|
323
|
+
async handle(event: UserRegistered): Promise<void> {
|
|
324
|
+
await Notify.send({ email: event.email }, new WelcomeNotification(event.userId));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Note `queue` (and the retry fields) are **instance** properties, not `static` — they
|
|
330
|
+
describe how _this_ listener runs, whereas `static listens` describes _what_ it listens to.
|
|
331
|
+
|
|
332
|
+
If no queue manager is registered, a queued listener falls back to running inline, so it's
|
|
333
|
+
safe to add before you've configured a queue — it simply runs synchronously until a queue
|
|
334
|
+
exists. Requires [`@zerotal/queue`](/docs/queue) for actual deferral.
|
|
335
|
+
|
|
336
|
+
### Events are auto-discovered too
|
|
337
|
+
|
|
338
|
+
Event classes are plain data classes — they need no registration. Files in `app/events/`
|
|
339
|
+
are imported at boot so their module-level side effects (if any) are in place and a
|
|
340
|
+
production manifest can reference them. You can also just import an event class directly
|
|
341
|
+
where you emit it; the `app/events/` folder is a convention, not a requirement.
|
|
342
|
+
|
|
343
|
+
### Reacting to model changes
|
|
344
|
+
|
|
345
|
+
To emit application events from ORM lifecycle hooks (created / updated / deleted), use a
|
|
346
|
+
model's `dispatchesEvents` map — see
|
|
347
|
+
[Conventions → Model events](/docs/conventions#model-events).
|
|
348
|
+
|
|
349
|
+
### Broadcasting an event to the browser
|
|
350
|
+
|
|
351
|
+
If an event class defines a `broadcastOn()` method, emitting it **also broadcasts it**
|
|
352
|
+
over WebSockets to the channels it names — in addition to running any listeners. This is
|
|
353
|
+
wired through [`@zerotal/broadcasting`](/docs/broadcasting); core stays broadcasting-free
|
|
354
|
+
and calls the hook only when one is registered. Worth knowing here for one subtlety: the
|
|
355
|
+
broadcast fires **even when the event has no listeners**, so an event can be purely a
|
|
356
|
+
broadcast with no `handle()` anywhere. See [Broadcasting](/docs/broadcasting) for channels,
|
|
357
|
+
authorization, and the client side.
|
|
358
|
+
|
|
359
|
+
### Testing events
|
|
360
|
+
|
|
361
|
+
Both buses expose a reset so one test never leaks subscriptions into the next:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { Events, FrameworkEvents } from "zerotal";
|
|
365
|
+
|
|
366
|
+
// Remove every application listener
|
|
367
|
+
Events.clear();
|
|
368
|
+
// Assert a specific event has a listener wired
|
|
369
|
+
Events.hasListeners(UserRegistered); // → boolean
|
|
370
|
+
|
|
371
|
+
// Drop every framework subscription, and check for leaks
|
|
372
|
+
FrameworkEvents.clear();
|
|
373
|
+
FrameworkEvents.handlerCount(); // → number of live handlers
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
To assert that emitting an event triggered the right side effect, register a tiny inline
|
|
377
|
+
listener (or spy) before emitting and check it ran — `emit()` awaits inline listeners, so a
|
|
378
|
+
plain `await Events.emit(...)` is enough to observe their effects.
|
|
379
|
+
|
|
380
|
+
## References
|
|
381
|
+
|
|
382
|
+
**`Events` facade** (Tier 2 — application events, from `zerotal`):
|
|
383
|
+
|
|
384
|
+
| Method | Description |
|
|
385
|
+
| ----------------------------- | --------------------------------------------------------------- |
|
|
386
|
+
| `Events.on(Event, Listener)` | Register a listener class for an event class. |
|
|
387
|
+
| `Events.off(Event, Listener)` | Remove a previously registered listener. |
|
|
388
|
+
| `Events.emit(event)` | Dispatch concurrently; failures isolated and logged. |
|
|
389
|
+
| `Events.emitSync(event)` | Dispatch sequentially in order; **propagates** the first error. |
|
|
390
|
+
| `Events.hasListeners(Event)` | Whether any listener is registered for the event class. |
|
|
391
|
+
| `Events.clear()` | Remove every listener (use in tests). |
|
|
392
|
+
|
|
393
|
+
**`FrameworkEvents`** (Tier 1 — instrumentation, from `zerotal`):
|
|
394
|
+
|
|
395
|
+
| Method | Description |
|
|
396
|
+
| -------------------------------- | ------------------------------------------------------ |
|
|
397
|
+
| `FrameworkEvents.on(Event, fn)` | Subscribe a function; returns an unsubscribe function. |
|
|
398
|
+
| `FrameworkEvents.emit(event)` | Fire synchronously to all handlers; errors swallowed. |
|
|
399
|
+
| `FrameworkEvents.clear()` | Drop every subscription (use in tests). |
|
|
400
|
+
| `FrameworkEvents.handlerCount()` | Count of live handlers, for leak assertions in tests. |
|
|
401
|
+
|
|
402
|
+
**Listener declaration fields** (Tier 2 listener classes):
|
|
403
|
+
|
|
404
|
+
| Field | Kind | Description |
|
|
405
|
+
| ---------------- | -------- | ------------------------------------------------------------ |
|
|
406
|
+
| `static listens` | static | The event class (or array of classes) this listener handles. |
|
|
407
|
+
| `handle(event)` | method | Runs when a subscribed event fires. |
|
|
408
|
+
| `queue` | instance | `true` or a queue name → run on the queue instead of inline. |
|
|
409
|
+
| `maxAttempts` | instance | Optional retry count for a queued listener. |
|
|
410
|
+
| `retryDelay` | instance | Optional seconds between retry attempts. |
|
|
411
|
+
|
|
412
|
+
For the full list of Tier 1 event classes and their fields, see the
|
|
413
|
+
[Event catalogue](#event-catalogue) above.
|
|
414
|
+
|
|
415
|
+
## Next steps
|
|
416
|
+
|
|
417
|
+
- [Conventions](/docs/conventions#events-listeners-appevents-applisteners) — how `app/events` and `app/listeners` are discovered.
|
|
418
|
+
- [Queue](/docs/queue) — deferring listener work to background jobs.
|
|
419
|
+
- [Logger](/docs/logger) — a built-in FrameworkEvents subscriber.
|
|
420
|
+
- [Telemetry](/docs/telemetry) — turn framework events into metrics.
|