@relaybus/nestjs 0.1.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 CHANGED
@@ -1,5 +1,7 @@
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 `@RelayBusConsumer` and publish inside your database transaction, and every committed event reaches every handler at least once.
4
+
3
5
  NestJS integration for [relaybus](../core): one module, one decorator, correct lifecycle.
4
6
 
5
7
  ## Install
@@ -22,17 +24,26 @@ export class AppModule {}
22
24
  // shipping.handlers.ts - any provider of any module
23
25
  @Injectable()
24
26
  export class ShippingHandlers {
25
- @Subscribe(OrderPlaced)
27
+ @RelayBusConsumer(OrderPlaced)
26
28
  async onOrderPlaced(event: EventEnvelope<{ orderId: string }>) {
27
29
  /* ... */
28
30
  }
29
31
  }
30
32
 
31
33
  // order.service.ts
32
- await this.bus.transaction(async (tx) => {
33
- await tx.query('INSERT INTO orders ...');
34
- await this.bus.publish(new OrderPlaced({ orderId }), { transaction: tx });
35
- });
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
+ }
36
47
  ```
37
48
 
38
49
  That is all: the module is global, the handler is discovered, the bus starts after bootstrap and
@@ -41,7 +52,21 @@ add the transaction type when you want it typed: `RelayBus<PoolClient>` (the gen
41
52
  runtime, so DI is unaffected).
42
53
 
43
54
  Call `app.enableShutdownHooks()` in `main.ts` so SIGTERM/SIGINT close the app and let the bus
44
- drain running handlers (`dispatcher.shutdownTimeout`, default 30s).
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
+ ```
45
70
 
46
71
  ## `RelayBusModule`
47
72
 
@@ -63,6 +88,7 @@ logs through a Nest `Logger` with context `RelayBus`, so `app.useLogger(...)` co
63
88
  | `source` | `'relaybus'` | Service name recorded on every event; set it to your app name |
64
89
  | `transactionMode` | `'required'` | `'optional'` lets `publish` open its own transaction |
65
90
  | `transactionResolver` | none | Finds the caller's active transaction (see below) |
91
+ | `transactionTimeout` | `'5s'` | Budget of every `bus.transaction` / `@RelayBusTransaction()` (see below) |
66
92
  | `retry` | core default | Default retry policy of every handler and route |
67
93
  | `handlerTimeout` | `false` | Default handler timeout |
68
94
  | `dispatcher.enabled` | `true` | `false` on publish-only processes (API pods, scripts) |
@@ -70,7 +96,7 @@ logs through a Nest `Logger` with context `RelayBus`, so `app.useLogger(...)` co
70
96
  | `dispatcher.batchSize` | concurrency | Deliveries claimed per query |
71
97
  | `dispatcher.pollInterval` | `'1s'` | Wait between claims when idle |
72
98
  | `dispatcher.leaseDuration` | `'30s'` | Renewed every third of it |
73
- | `dispatcher.shutdownTimeout` | `'30s'` | How long shutdown waits for running handlers |
99
+ | `dispatcher.shutdownTimeout` | `'30s'` | Longest the whole shutdown takes; past it, handlers are abandoned |
74
100
  | `retention.completed` / `.dead` / `.interval` | `'7d'` / `false` / `'1h'` | Cleanup of finished deliveries |
75
101
  | `transports`, `routes`, `subscriptions` | none | Broker fan-out and fan-in (`@relaybus/kafka`, `@relaybus/rabbitmq`) |
76
102
  | `setupStore` | `true` | Create tables/indexes on start |
@@ -78,44 +104,162 @@ logs through a Nest `Logger` with context `RelayBus`, so `app.useLogger(...)` co
78
104
  | `clock`, `generateIdentifier`, `random` | system | Mainly for tests |
79
105
  | `isGlobal` | `true` | Module-level, see above |
80
106
 
81
- ## `@Subscribe(event, options?)`
107
+ ## `@RelayBusConsumer(event, options?)`
108
+
109
+ The default way to handle events: put `@RelayBusConsumer` on any method of any provider.
82
110
 
83
111
  ```ts
84
- @Subscribe(OrderPlaced) // class
85
- @Subscribe('order.placed.v1', { concurrency: 2 }) // type string
86
- @Subscribe(OrderPlaced, { id: 'billing.invoice', transactional: true, timeout: '10s' })
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
+ }
87
120
  ```
88
121
 
89
- Options are core's `SubscribeOptions` with `id` optional. Methods receive `(envelope, context)`;
90
- with a class argument the envelope payload is typed from the event.
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
+ ```
91
127
 
92
- **Why `@Subscribe`:** `@nestjs/event-emitter` already owns `@OnEvent`, and the two are commonly
93
- installed together. `@Subscribe` mirrors core's `bus.subscribe` and cannot collide.
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.
94
142
 
95
143
  Rules, all enforced at boot (the application fails to start, no silent skips):
96
144
 
97
145
  - **Stable ids.** The default id is `ClassName.methodName`. Deliveries are stored under the id, so
98
146
  renaming the class or method orphans pending deliveries; pin `{ id }` on handlers that matter.
99
- - **Duplicate ids** fail init with both locations named. A method with several `@Subscribe`
147
+ - **Duplicate ids** fail init with both locations named. A method with several `@RelayBusConsumer`
100
148
  decorators must give each an explicit `id`.
101
149
  - **Singletons only.** Handlers on request-scoped or transient providers (or default-scoped
102
150
  providers that depend on one) are rejected: deliveries run outside any request, so there is no
103
151
  request instance to call. This covers class, `useClass` and `useFactory` providers (a factory is
104
152
  checked through its class token; a request-scoped factory under a string token cannot be
105
- inspected). Providers without `@Subscribe` are never inspected for scope.
153
+ inspected). Providers without `@RelayBusConsumer` are never inspected for scope.
106
154
  - `this` is bound to the provider instance. Only providers are scanned, in every module. A handler
107
155
  reachable through several tokens (`useExisting`) is registered once. Declarations are stored per
108
156
  class and method, so decorators that wrap the method later do not drop them, and subclasses
109
157
  inherit the handlers of their base class.
110
158
 
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.
194
+
195
+ ```ts
196
+ export const OrderEvents = defineCatalog({ OrderPlaced, OrderShipped }, { name: 'OrderEvents' });
197
+
198
+ @Injectable()
199
+ @RelayBusCatalogConsumer(OrderEvents, { transactional: true })
200
+ export class ShippingHandlers implements HandlersOf<typeof OrderEvents> {
201
+ onOrderPlaced(event: EventOf<typeof OrderPlaced>, context: HandlerContext) {}
202
+ onOrderShipped(event: EventOf<typeof OrderShipped>) {}
203
+ }
204
+
205
+ RelayBusModule.forRoot({ store, catalogs: [OrderEvents] });
206
+ ```
207
+
208
+ Registers every `on${EventName}` method the class defines, under the stable id
209
+ `ClassName.on${EventName}`, with `this` bound to the provider. `implements HandlersOf<...>` makes
210
+ the compiler reject a class that misses a handler; `PartialHandlersOf<C, 'OrderShipped'>`
211
+ documents a deliberate subset. Options are core's `SubscribeOptions` without `id`, applied to every
212
+ handler of the class.
213
+
214
+ - `catalogs: [OrderEvents]` in the module options is the boot check: if no provider (via
215
+ `@RelayBusCatalogConsumer`, `@RelayBusConsumer` or a configured route) handles an event, the application fails to
216
+ start with the list of missing events and the methods they expect.
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,
220
+ subclasses inherit the decoration and use their own class name in ids.
221
+ - A class decorated with `@RelayBusCatalogConsumer` that defines none of the catalog's methods fails init,
222
+ which catches misspelled method names.
223
+ - Declarations are inherited from base classes; a subclass that declares the same catalog again
224
+ replaces the inherited declaration, and one catalog declared twice on one class throws.
225
+
226
+ ### Microservices: consuming part of a shared catalog
227
+
228
+ ```ts
229
+ // shipping service
230
+ @Injectable()
231
+ @RelayBusCatalogConsumer(OrderEvents, { events: ['OrderPlaced'] })
232
+ export class ShippingHandlers {
233
+ onOrderPlaced(event: EventOf<typeof OrderPlaced>) {}
234
+ }
235
+
236
+ RelayBusModule.forRoot({
237
+ store,
238
+ transports: [rabbit],
239
+ subscriptions: [{ transport: 'rabbit', destinations: ['orders'] }],
240
+ routes: [{ event: OrderShipped, transport: 'rabbit', destination: 'shipments' }],
241
+ catalogs: [
242
+ consume(OrderEvents, { events: ['OrderPlaced'] }),
243
+ produce(OrderEvents, { events: ['OrderShipped'] }),
244
+ ],
245
+ });
246
+ ```
247
+
248
+ - With `events`, exactly those methods are registered (other `on${EventName}` methods are
249
+ ignored), and a class that misses one of them does not compile and fails init.
250
+ - `consume(...)` fails the boot when a consumed event has no handler in the application;
251
+ `produce(...)` fails it when a produced event has no route. See `@relaybus/core`, "Microservices:
252
+ shared catalogs".
253
+
111
254
  ## Lifecycle
112
255
 
113
256
  1. `onModuleInit`: handlers are discovered and registered on the bus.
114
257
  2. `onApplicationBootstrap`: `bus.start()` creates the schema (unless `setupStore: false`),
115
258
  connects transports and starts the dispatcher. If it rejects (database down, broker
116
259
  unreachable) the application does not boot.
117
- 3. `beforeApplicationShutdown`: `bus.stop()` stops claiming, waits for running handlers up to
118
- `shutdownTimeout`, then closes transports and the store. Needs `app.enableShutdownHooks()` for
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
119
263
  signals; `app.close()` runs it regardless. Nest runs `onApplicationShutdown` hooks (where
120
264
  TypeORM, Mongoose and most pools disconnect) after this, so handlers still have their
121
265
  connections while draining. If your own provider disconnects in `onModuleDestroy` (common with
@@ -123,14 +267,81 @@ Rules, all enforced at boot (the application fails to start, no silent skips):
123
267
 
124
268
  ## Transactions
125
269
 
126
- Inside `bus.transaction(fn)` every `publish` joins the transaction. Without it, publish needs a
127
- transaction: pass `{ transaction }`, configure `transactionResolver`, or set
128
- `transactionMode: 'optional'` (event stays durable but is no longer atomic with your writes).
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).
129
340
 
130
341
  ### Transactional handlers
131
342
 
132
343
  ```ts
133
- @Subscribe(PaymentCaptured, { id: 'ledger.record-capture', transactional: true })
344
+ @RelayBusConsumer(PaymentCaptured, { id: 'ledger.record-capture', transactional: true })
134
345
  async record(event: EventEnvelope<{ paymentId: string }>, context: HandlerContext<PoolClient>) {
135
346
  // Writes through context.transaction commit atomically with the delivery completion:
136
347
  // effectively-once processing. Keep these handlers short.
@@ -230,9 +441,34 @@ await moduleRef.close();
230
441
  global (`isGlobal: false`) and not imported by the consuming module.
231
442
  - **Handler never runs:** the provider is not listed in any module `providers`, the process runs
232
443
  with `dispatcher.enabled: false`, or the event type differs (check `bus.handlers()`).
233
- - **`uses @Subscribe but its provider is request-scoped or transient`:** make the provider a
444
+ - **`uses @RelayBusConsumer but its provider is request-scoped or transient`:** make the provider a
234
445
  singleton; inject request data through the event payload instead.
235
446
  - **Vitest/esbuild:** esbuild does not emit decorator metadata; use explicit `@Inject(Token)` in
236
447
  test classes.
237
448
  - **App hangs on shutdown:** a handler ignores `context.signal`; it is waited for until
238
449
  `dispatcher.shutdownTimeout`.
450
+
451
+ ## When to use @relaybus/nestjs, and when not to
452
+
453
+ Use it when your service is a NestJS 11 or 12 application and you want decorator-based handlers, lifecycle integration and a health check. It needs `@relaybus/core` and a store; for other frameworks use `@relaybus/core` directly.
454
+
455
+ Documentation: <https://berkcansavur.github.io/relaybus/packages/nestjs>
456
+
457
+ ### relaybus vs alternatives
458
+
459
+ <!-- relaybus:snippet:comparison -->
460
+
461
+ | | What it is | Survives a restart | Written in your database transaction | Choose it when |
462
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
463
+ | **relaybus** | Event bus built on the transactional outbox: PostgreSQL and MongoDB stores, Kafka and RabbitMQ transports, per-handler deliveries | Yes, events live in your database | Yes | Events must never be lost or phantom, and handlers need retries, dead letters and ordering keys |
464
+ | [`@nestjs/event-emitter`](https://docs.nestjs.com/techniques/events) | In-process event emitter built on `eventemitter2` | No persistence is documented | No | Simple in-process notifications where losing an event on restart is acceptable |
465
+ | [`@nestjs/cqrs`](https://docs.nestjs.com/recipes/cqrs) | CommandBus, QueryBus, EventBus and sagas; the docs say sagas and event handlers run in memory | No built-in persistence | No | You want CQRS structure; it can sit on top of an outbox |
466
+ | [`@nestjs/outbox`](https://github.com/nestjs/outbox) | Transactional outbox module from the NestJS organisation: PostgreSQL, MySQL and in-memory stores, relay with leases and retries, dead-letter table, inbox | Yes with the database stores | Yes | You need MySQL, or a NestJS-organisation module; compare features with relaybus before choosing |
467
+ | [BullMQ](https://docs.bullmq.io/) | Job queue on Redis with retries, delayed jobs and flows; at-least-once in the worst case | Yes, in Redis | No, jobs are stored in Redis and cannot join a SQL or MongoDB transaction | Your stack is Redis-based and background jobs matter more than event atomicity |
468
+ | [pg-boss](https://github.com/timgit/pg-boss) | Job queue on PostgreSQL using `SKIP LOCKED`, with retries, dead letter queues, scheduling and pub/sub; jobs can be created in your own transaction | Yes, in PostgreSQL | Yes, through its database adapters | You want a PostgreSQL job queue with scheduling; relaybus is an event bus with per-handler deliveries and transports |
469
+ | Kafka client directly | A distributed log; the idempotent producer and transactions give exactly-once within Kafka | Yes, in Kafka | No, producing to Kafka is a second write | You consume and produce only inside Kafka; add an outbox when a database write must be announced |
470
+
471
+ This table summarises the vendors' own documentation as of October 2026; check their docs for current details.
472
+ Full comparison and sources: <https://berkcansavur.github.io/relaybus/comparison>.
473
+
474
+ <!-- relaybus:end -->