@zerotal/arch 1.7.0

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