@relaybus/nestjs 0.2.0 → 0.3.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/README.md +181 -33
- package/dist/index.cjs +282 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +187 -34
- package/dist/index.d.ts +187 -34
- package/dist/index.js +281 -59
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @relaybus/nestjs
|
|
2
2
|
|
|
3
|
-
@relaybus/nestjs is the NestJS module of relaybus, a reliable, storage-agnostic event bus for NestJS and Node.js: declare handlers with `@
|
|
3
|
+
@relaybus/nestjs is the NestJS module of relaybus, a reliable, storage-agnostic event bus for NestJS and Node.js: declare handlers with `@RelayBusConsumer` and publish inside your database transaction, and every committed event reaches every handler at least once.
|
|
4
4
|
|
|
5
5
|
NestJS integration for [relaybus](../core): one module, one decorator, correct lifecycle.
|
|
6
6
|
|
|
@@ -24,17 +24,26 @@ export class AppModule {}
|
|
|
24
24
|
// shipping.handlers.ts - any provider of any module
|
|
25
25
|
@Injectable()
|
|
26
26
|
export class ShippingHandlers {
|
|
27
|
-
@
|
|
27
|
+
@RelayBusConsumer(OrderPlaced)
|
|
28
28
|
async onOrderPlaced(event: EventEnvelope<{ orderId: string }>) {
|
|
29
29
|
/* ... */
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// order.service.ts
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
@Injectable()
|
|
35
|
+
export class OrderService {
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly bus: RelayBus,
|
|
38
|
+
private readonly transactions: RelayBusTransactionHost<PoolClient>,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
@RelayBusTransaction()
|
|
42
|
+
async place(orderId: string): Promise<void> {
|
|
43
|
+
await this.transactions.current().query('INSERT INTO orders (id) VALUES ($1)', [orderId]);
|
|
44
|
+
await this.bus.publish(new OrderPlaced({ orderId })); // joins the transaction
|
|
45
|
+
}
|
|
46
|
+
}
|
|
38
47
|
```
|
|
39
48
|
|
|
40
49
|
That is all: the module is global, the handler is discovered, the bus starts after bootstrap and
|
|
@@ -43,7 +52,21 @@ add the transaction type when you want it typed: `RelayBus<PoolClient>` (the gen
|
|
|
43
52
|
runtime, so DI is unaffected).
|
|
44
53
|
|
|
45
54
|
Call `app.enableShutdownHooks()` in `main.ts` so SIGTERM/SIGINT close the app and let the bus
|
|
46
|
-
drain running handlers (`dispatcher.shutdownTimeout
|
|
55
|
+
drain running handlers. The whole `bus.stop()` is bounded by `dispatcher.shutdownTimeout`
|
|
56
|
+
(default 30s): past it, running handlers are abandoned (their leases expire and another pod runs
|
|
57
|
+
them again) and the connections relaybus opened itself are force-closed, so a hung query or broker
|
|
58
|
+
cannot keep the pod alive until Kubernetes kills it. Keep the timeout below the pod's
|
|
59
|
+
`terminationGracePeriodSeconds`, leaving room for the rest of `app.close()`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// main.ts
|
|
63
|
+
const app = await NestFactory.create(AppModule);
|
|
64
|
+
app.enableShutdownHooks();
|
|
65
|
+
await app.listen(3000);
|
|
66
|
+
|
|
67
|
+
// app.module.ts, for a pod with terminationGracePeriodSeconds: 30
|
|
68
|
+
RelayBusModule.forRoot({ store, dispatcher: { shutdownTimeout: '25s' } });
|
|
69
|
+
```
|
|
47
70
|
|
|
48
71
|
## `RelayBusModule`
|
|
49
72
|
|
|
@@ -65,6 +88,7 @@ logs through a Nest `Logger` with context `RelayBus`, so `app.useLogger(...)` co
|
|
|
65
88
|
| `source` | `'relaybus'` | Service name recorded on every event; set it to your app name |
|
|
66
89
|
| `transactionMode` | `'required'` | `'optional'` lets `publish` open its own transaction |
|
|
67
90
|
| `transactionResolver` | none | Finds the caller's active transaction (see below) |
|
|
91
|
+
| `transactionTimeout` | `'5s'` | Budget of every `bus.transaction` / `@RelayBusTransaction()` (see below) |
|
|
68
92
|
| `retry` | core default | Default retry policy of every handler and route |
|
|
69
93
|
| `handlerTimeout` | `false` | Default handler timeout |
|
|
70
94
|
| `dispatcher.enabled` | `true` | `false` on publish-only processes (API pods, scripts) |
|
|
@@ -72,7 +96,7 @@ logs through a Nest `Logger` with context `RelayBus`, so `app.useLogger(...)` co
|
|
|
72
96
|
| `dispatcher.batchSize` | concurrency | Deliveries claimed per query |
|
|
73
97
|
| `dispatcher.pollInterval` | `'1s'` | Wait between claims when idle |
|
|
74
98
|
| `dispatcher.leaseDuration` | `'30s'` | Renewed every third of it |
|
|
75
|
-
| `dispatcher.shutdownTimeout` | `'30s'` |
|
|
99
|
+
| `dispatcher.shutdownTimeout` | `'30s'` | Longest the whole shutdown takes; past it, handlers are abandoned |
|
|
76
100
|
| `retention.completed` / `.dead` / `.interval` | `'7d'` / `false` / `'1h'` | Cleanup of finished deliveries |
|
|
77
101
|
| `transports`, `routes`, `subscriptions` | none | Broker fan-out and fan-in (`@relaybus/kafka`, `@relaybus/rabbitmq`) |
|
|
78
102
|
| `setupStore` | `true` | Create tables/indexes on start |
|
|
@@ -80,43 +104,99 @@ logs through a Nest `Logger` with context `RelayBus`, so `app.useLogger(...)` co
|
|
|
80
104
|
| `clock`, `generateIdentifier`, `random` | system | Mainly for tests |
|
|
81
105
|
| `isGlobal` | `true` | Module-level, see above |
|
|
82
106
|
|
|
83
|
-
## `@
|
|
107
|
+
## `@RelayBusConsumer(event, options?)`
|
|
108
|
+
|
|
109
|
+
The default way to handle events: put `@RelayBusConsumer` on any method of any provider.
|
|
84
110
|
|
|
85
111
|
```ts
|
|
86
|
-
@
|
|
87
|
-
|
|
88
|
-
@
|
|
112
|
+
@Injectable()
|
|
113
|
+
export class ShippingHandlers {
|
|
114
|
+
@RelayBusConsumer(OrderPlaced)
|
|
115
|
+
async shipOrder(event: EventEnvelope<{ orderId: string }>) {}
|
|
116
|
+
|
|
117
|
+
@RelayBusConsumer(UserRegistered, { id: 'shipping.welcome-pack', retry: { attempts: 5 } })
|
|
118
|
+
async sendWelcomePack(event: EventOf<typeof UserRegistered>, context: HandlerContext) {}
|
|
119
|
+
}
|
|
89
120
|
```
|
|
90
121
|
|
|
91
|
-
|
|
92
|
-
|
|
122
|
+
```ts
|
|
123
|
+
@RelayBusConsumer(OrderPlaced) // class
|
|
124
|
+
@RelayBusConsumer('order.placed.v1', { concurrency: 2 }) // type string
|
|
125
|
+
@RelayBusConsumer(OrderPlaced, { id: 'billing.invoice', transactional: true, timeout: '10s' })
|
|
126
|
+
```
|
|
93
127
|
|
|
94
|
-
**
|
|
95
|
-
|
|
128
|
+
- **No class-level setup.** No catalog, interface or base class: method names are free, and one
|
|
129
|
+
class may handle events of different catalogs (or of none).
|
|
130
|
+
- **Typed at compile time.** With a class argument the first parameter must be the envelope of
|
|
131
|
+
that event: `@RelayBusConsumer(OrderPlaced) ship(event: EventEnvelope<{ userId: number }>)` does
|
|
132
|
+
not compile.
|
|
133
|
+
- Options are core's `SubscribeOptions` with `id` optional. Methods receive `(envelope, context)`.
|
|
134
|
+
|
|
135
|
+
**Names.** The relaybus decorators share the `RelayBus` prefix (`@RelayBusConsumer`,
|
|
136
|
+
`@RelayBusCatalogConsumer`, `@RelayBusTransaction`), so they never collide with `@nestjs/event-emitter`'s
|
|
137
|
+
`@OnEvent` or `@nestjs/cqrs`. `@Subscribe` and `@EventConsumer` are the earlier names of
|
|
138
|
+
`@RelayBusConsumer` and `@RelayBusCatalogConsumer`: they still work, are the same functions, are marked
|
|
139
|
+
`@deprecated` (editors strike them through) and are removed in 1.0.0. `@RelayBusConsumer` only
|
|
140
|
+
compiles on a method and takes an event; `@RelayBusCatalogConsumer` only compiles on a class and
|
|
141
|
+
takes a catalog.
|
|
96
142
|
|
|
97
143
|
Rules, all enforced at boot (the application fails to start, no silent skips):
|
|
98
144
|
|
|
99
145
|
- **Stable ids.** The default id is `ClassName.methodName`. Deliveries are stored under the id, so
|
|
100
146
|
renaming the class or method orphans pending deliveries; pin `{ id }` on handlers that matter.
|
|
101
|
-
- **Duplicate ids** fail init with both locations named. A method with several `@
|
|
147
|
+
- **Duplicate ids** fail init with both locations named. A method with several `@RelayBusConsumer`
|
|
102
148
|
decorators must give each an explicit `id`.
|
|
103
149
|
- **Singletons only.** Handlers on request-scoped or transient providers (or default-scoped
|
|
104
150
|
providers that depend on one) are rejected: deliveries run outside any request, so there is no
|
|
105
151
|
request instance to call. This covers class, `useClass` and `useFactory` providers (a factory is
|
|
106
152
|
checked through its class token; a request-scoped factory under a string token cannot be
|
|
107
|
-
inspected). Providers without `@
|
|
153
|
+
inspected). Providers without `@RelayBusConsumer` are never inspected for scope.
|
|
108
154
|
- `this` is bound to the provider instance. Only providers are scanned, in every module. A handler
|
|
109
155
|
reachable through several tokens (`useExisting`) is registered once. Declarations are stored per
|
|
110
156
|
class and method, so decorators that wrap the method later do not drop them, and subclasses
|
|
111
157
|
inherit the handlers of their base class.
|
|
112
158
|
|
|
113
|
-
##
|
|
159
|
+
## Module contracts: `RelayBusModule.forFeature(...)`
|
|
160
|
+
|
|
161
|
+
The recommended way to use catalogs in NestJS. A feature module states which events it consumes
|
|
162
|
+
and produces; the method-level `@RelayBusConsumer` handlers of its providers fulfil the contract,
|
|
163
|
+
spread over any classes and method names.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
@Module({
|
|
167
|
+
imports: [
|
|
168
|
+
RelayBusModule.forFeature({
|
|
169
|
+
consumes: [OrderEvents], // or consume(OrderEvents, { events: ['OrderPlaced'] })
|
|
170
|
+
produces: [produce(BillingEvents, { events: ['InvoiceIssued'] })],
|
|
171
|
+
}),
|
|
172
|
+
],
|
|
173
|
+
providers: [ChargeHandlers, RefundHandlers],
|
|
174
|
+
})
|
|
175
|
+
export class BillingModule {}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
When the application initialises:
|
|
179
|
+
|
|
180
|
+
- every consumed event needs a `@RelayBusConsumer` method (or a `@RelayBusCatalogConsumer` class)
|
|
181
|
+
among the providers **of that module**; consumers in other modules do not count;
|
|
182
|
+
- every produced event needs a route to a transport (`routes` of `forRoot`).
|
|
183
|
+
|
|
184
|
+
Otherwise the application fails to start with one line per gap, for example
|
|
185
|
+
`[RelayBusHandlerExplorer](assertModuleContracts) BillingModule: OrderEvents.OrderCancelled (order.cancelled.v1) has no consumer`.
|
|
186
|
+
`forRoot({ catalogs })` keeps working as the application-wide check (handlers anywhere).
|
|
187
|
+
|
|
188
|
+
## Stricter checking (optional): catalogs and `@RelayBusCatalogConsumer(catalog, options?)`
|
|
189
|
+
|
|
190
|
+
Method-level handlers are enough for most services. When a team wants the compiler and the boot
|
|
191
|
+
to prove that _every_ event of a shared contract is handled, group the events in a catalog and
|
|
192
|
+
let a class consume it. Everything here is optional and can be added later, next to
|
|
193
|
+
`@RelayBusConsumer` methods.
|
|
114
194
|
|
|
115
195
|
```ts
|
|
116
196
|
export const OrderEvents = defineCatalog({ OrderPlaced, OrderShipped }, { name: 'OrderEvents' });
|
|
117
197
|
|
|
118
198
|
@Injectable()
|
|
119
|
-
@
|
|
199
|
+
@RelayBusCatalogConsumer(OrderEvents, { transactional: true })
|
|
120
200
|
export class ShippingHandlers implements HandlersOf<typeof OrderEvents> {
|
|
121
201
|
onOrderPlaced(event: EventOf<typeof OrderPlaced>, context: HandlerContext) {}
|
|
122
202
|
onOrderShipped(event: EventOf<typeof OrderShipped>) {}
|
|
@@ -132,13 +212,13 @@ documents a deliberate subset. Options are core's `SubscribeOptions` without `id
|
|
|
132
212
|
handler of the class.
|
|
133
213
|
|
|
134
214
|
- `catalogs: [OrderEvents]` in the module options is the boot check: if no provider (via
|
|
135
|
-
`@
|
|
215
|
+
`@RelayBusCatalogConsumer`, `@RelayBusConsumer` or a configured route) handles an event, the application fails to
|
|
136
216
|
start with the list of missing events and the methods they expect.
|
|
137
|
-
- It works next to `@
|
|
138
|
-
id (`@
|
|
139
|
-
- Same rules as `@
|
|
217
|
+
- It works next to `@RelayBusConsumer` in the same class. A `@RelayBusConsumer` that would take the same default
|
|
218
|
+
id (`@RelayBusConsumer(OrderPlaced) onOrderPlaced()`) fails init naming both; give it an explicit `id`.
|
|
219
|
+
- Same rules as `@RelayBusConsumer`: singleton providers only, `useExisting` aliases register once,
|
|
140
220
|
subclasses inherit the decoration and use their own class name in ids.
|
|
141
|
-
- A class decorated with `@
|
|
221
|
+
- A class decorated with `@RelayBusCatalogConsumer` that defines none of the catalog's methods fails init,
|
|
142
222
|
which catches misspelled method names.
|
|
143
223
|
- Declarations are inherited from base classes; a subclass that declares the same catalog again
|
|
144
224
|
replaces the inherited declaration, and one catalog declared twice on one class throws.
|
|
@@ -148,7 +228,7 @@ handler of the class.
|
|
|
148
228
|
```ts
|
|
149
229
|
// shipping service
|
|
150
230
|
@Injectable()
|
|
151
|
-
@
|
|
231
|
+
@RelayBusCatalogConsumer(OrderEvents, { events: ['OrderPlaced'] })
|
|
152
232
|
export class ShippingHandlers {
|
|
153
233
|
onOrderPlaced(event: EventOf<typeof OrderPlaced>) {}
|
|
154
234
|
}
|
|
@@ -177,8 +257,9 @@ RelayBusModule.forRoot({
|
|
|
177
257
|
2. `onApplicationBootstrap`: `bus.start()` creates the schema (unless `setupStore: false`),
|
|
178
258
|
connects transports and starts the dispatcher. If it rejects (database down, broker
|
|
179
259
|
unreachable) the application does not boot.
|
|
180
|
-
3. `beforeApplicationShutdown`: `bus.stop()` stops claiming, waits for running handlers
|
|
181
|
-
|
|
260
|
+
3. `beforeApplicationShutdown`: `bus.stop()` stops claiming, waits for running handlers, then
|
|
261
|
+
closes transports and the store, all within `shutdownTimeout` (it force-closes and resolves
|
|
262
|
+
when the deadline passes, see above). Needs `app.enableShutdownHooks()` for
|
|
182
263
|
signals; `app.close()` runs it regardless. Nest runs `onApplicationShutdown` hooks (where
|
|
183
264
|
TypeORM, Mongoose and most pools disconnect) after this, so handlers still have their
|
|
184
265
|
connections while draining. If your own provider disconnects in `onModuleDestroy` (common with
|
|
@@ -186,14 +267,81 @@ RelayBusModule.forRoot({
|
|
|
186
267
|
|
|
187
268
|
## Transactions
|
|
188
269
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
270
|
+
### `@RelayBusTransaction()`
|
|
271
|
+
|
|
272
|
+
Runs an async method in a relaybus transaction. Every `bus.publish(...)` inside it, and inside
|
|
273
|
+
everything it awaits, joins the transaction without `{ transaction }`; the transaction commits when
|
|
274
|
+
the method resolves and rolls back when it throws.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// Before: the transaction is threaded through by hand
|
|
278
|
+
async place(orderId: string): Promise<void> {
|
|
279
|
+
await this.bus.transaction(async (tx) => {
|
|
280
|
+
await tx.query('INSERT INTO orders (id) VALUES ($1)', [orderId]);
|
|
281
|
+
await this.bus.publish(new OrderPlaced({ orderId }), { transaction: tx });
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// After
|
|
286
|
+
@RelayBusTransaction()
|
|
287
|
+
async place(orderId: string): Promise<void> {
|
|
288
|
+
await this.orders.insert(orderId); // the repository reads RelayBusTransactionHost.current()
|
|
289
|
+
await this.bus.publish(new OrderPlaced({ orderId }));
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- **Self-calls are transactional.** The decorator replaces the method itself (not an interceptor
|
|
294
|
+
or proxy), so `this.place()` from another method of the class opens the transaction too.
|
|
295
|
+
- **Nested calls share one transaction.** A decorated method called inside another one, inside
|
|
296
|
+
`bus.transaction`, inside a transactional handler, or while `transactionResolver` finds a
|
|
297
|
+
transaction (for example `@nestjs-cls/transactional`) joins it instead of opening a second one.
|
|
298
|
+
- **No injection needed.** The bus is the one of the application's `RelayBusModule`. Calling the
|
|
299
|
+
method before the application initialised, after it shut down, or with no `RelayBusModule`
|
|
300
|
+
throws a `ConfigurationError` that says so.
|
|
301
|
+
- **Async methods only.** On a method that does not return a Promise it fails to compile:
|
|
302
|
+
`'@RelayBusTransaction() needs a method that returns a Promise: make the method async'`.
|
|
303
|
+
|
|
304
|
+
### Time budget: `transactionTimeout`
|
|
305
|
+
|
|
306
|
+
Every relaybus transaction (`bus.transaction` and `@RelayBusTransaction()`) is held to
|
|
307
|
+
`transactionTimeout` (default `'5s'`). The database enforces it where it can, so a transaction
|
|
308
|
+
waiting on something slow is ended by the server and its row locks are released:
|
|
309
|
+
|
|
310
|
+
- **PostgreSQL:** `SET LOCAL statement_timeout`, and with the `pg` executor also
|
|
311
|
+
`idle_in_transaction_session_timeout` and, on PostgreSQL 17+, `transaction_timeout` (detected
|
|
312
|
+
per transaction). Adapters other than `pg` get `statement_timeout` only, because a server-ended
|
|
313
|
+
session would leave their checked-out connection without an `error` listener.
|
|
314
|
+
- **MongoDB:** `maxCommitTimeMS` on the commit.
|
|
315
|
+
|
|
316
|
+
A transaction that still runs past the budget is rolled back before it commits. Either way the
|
|
317
|
+
caller gets a `TransactionTimeoutError` (`RELAYBUS_TRANSACTION_TIMEOUT`, with the database error as
|
|
318
|
+
`cause`) whose message points at the usual cause: a slow call to another service inside the
|
|
319
|
+
transaction. Move HTTP calls before the transaction or after it commits (the
|
|
320
|
+
`@relaybus/eslint-plugin` rule `no-external-call-in-transaction` catches them) instead of raising
|
|
321
|
+
the budget.
|
|
322
|
+
|
|
323
|
+
### Repositories: `RelayBusTransactionHost<T>`
|
|
324
|
+
|
|
325
|
+
Inject `RelayBusTransactionHost` typed with your store's transaction handle. `current()` returns
|
|
326
|
+
the active transaction and throws `TransactionRequiredError` outside one; `find()` returns
|
|
327
|
+
`undefined` instead. Outside Nest, `bus.currentTransaction()` does the same as `find()`.
|
|
328
|
+
|
|
329
|
+
| Store / executor | `T` of `RelayBusTransactionHost<T>` | Write with |
|
|
330
|
+
| ----------------------------------- | ----------------------------------- | -------------------------------------------- |
|
|
331
|
+
| `PostgresEventStore` with `pg` | `PoolClient` | `tx.query(sql, values)` |
|
|
332
|
+
| `createKyselyExecutor(db)` | `Transaction<DB>` | `tx.insertInto('orders')...execute()` |
|
|
333
|
+
| `createTypeOrmExecutor(dataSource)` | `EntityManager` | `tx.getRepository(Order).save(order)` |
|
|
334
|
+
| `createPrismaExecutor(prisma)` | `Prisma.TransactionClient` | `tx.order.create({ data })` |
|
|
335
|
+
| `MongoEventStore` | `ClientSession` | `collection.insertOne(doc, { session: tx })` |
|
|
336
|
+
|
|
337
|
+
Without a decorator or `bus.transaction`, `publish` needs a transaction: pass `{ transaction }`,
|
|
338
|
+
configure `transactionResolver`, or set `transactionMode: 'optional'` (event stays durable but is
|
|
339
|
+
no longer atomic with your writes).
|
|
192
340
|
|
|
193
341
|
### Transactional handlers
|
|
194
342
|
|
|
195
343
|
```ts
|
|
196
|
-
@
|
|
344
|
+
@RelayBusConsumer(PaymentCaptured, { id: 'ledger.record-capture', transactional: true })
|
|
197
345
|
async record(event: EventEnvelope<{ paymentId: string }>, context: HandlerContext<PoolClient>) {
|
|
198
346
|
// Writes through context.transaction commit atomically with the delivery completion:
|
|
199
347
|
// effectively-once processing. Keep these handlers short.
|
|
@@ -293,7 +441,7 @@ await moduleRef.close();
|
|
|
293
441
|
global (`isGlobal: false`) and not imported by the consuming module.
|
|
294
442
|
- **Handler never runs:** the provider is not listed in any module `providers`, the process runs
|
|
295
443
|
with `dispatcher.enabled: false`, or the event type differs (check `bus.handlers()`).
|
|
296
|
-
- **`uses @
|
|
444
|
+
- **`uses @RelayBusConsumer but its provider is request-scoped or transient`:** make the provider a
|
|
297
445
|
singleton; inject request data through the event payload instead.
|
|
298
446
|
- **Vitest/esbuild:** esbuild does not emit decorator metadata; use explicit `@Inject(Token)` in
|
|
299
447
|
test classes.
|