# Releases

- [0.5.0](#_0-5-0-2026-10-27): `route(produce(Catalog, { events }), settings)` routes several catalog events with shared settings; wire names declared on catalogs and routes reach the codec; transactions at an isolation level with automatic rerun of serialization failures
- [0.4.1](#_0-4-1-2026-10-26): PostgreSQL hands out payloads exactly as published, Prisma 5 support, jsonEnvelope fixes, a codec contract for any test framework
- [0.4.0](#_0-4-0-2026-10-25): Prisma 7 on a real database, wake-up at the caller's commit, the Confluent Kafka client, codec contract helpers, record keys, legacy flat envelopes, an application-owned producer
- [0.3.1](#_0-3-1-2026-10-25): internal readability refactor, same behaviour
- [0.3.0](#_0-3-0-2026-10-25): code quality, per-key order across event types, codecs, transactions in NestJS, bounded shutdown, ESLint rules, OpenTelemetry
- [0.2.0](#_0-2-0-2026-10-24): high-throughput delivery, event catalogs, decoding of foreign messages
- [0.1.x](#_0-1-x): first public release
- [How versioning works](#how-versioning-works)

## 0.5.0 (2026-10-27)

A minor release with new API and nothing required when upgrading from 0.4.x: no export removed or renamed, every option additive, no database or default wire-format change, and per-event routes and `jsonEnvelope` `wireTypes` work as before. See [Upgrading from 0.4.1 to 0.5.0](https://berkcansavur.github.io/relaybus/upgrading#upgrading-from-0-4-1-to-0-5-0).

- **Catalog routes.** `route(produce(Catalog, { events }), { transport, destination, retry, timeout })` in `routes` turns every selected event into a route with the shared settings, where a service publishing several catalog events to one topic used to write one route per event and repeat the destination, retry and timeout. Without `destination`, each event goes to its own event type, as a plain route does; without `events`, the whole catalog is routed. The routes it creates are ordinary routes: one handler `relaybus.transport:<transport>:<destination>` per destination (the per-key order and the ids of stored deliveries are the same as with routes written by hand), counted by `produce(...)` boot checks and by `RelayBusModule.forFeature({ produces })`. See [Routing a catalog production](https://berkcansavur.github.io/relaybus/concepts/event-catalogs#routing-a-catalog-production).
- **Wire names next to the catalog and the route.** `defineCatalog(events, { wireTypes })` and `route(..., { wireTypes })` name the types a wire format uses, by catalog event name (checked by the compiler; the route's names win). The bus hands the names of the events a transport sends to it when the bus is created: the new optional `Transport.declareWireTypes` is implemented by the Kafka and RabbitMQ transports, which pass them to a `jsonEnvelope` codec through its new `withWireTypes`. The codec then writes and reads those events exactly as with its own `wireTypes`; a contract test on both transports checks that the bytes are identical. `catalogWireTypes(catalog)` lists a catalog's names for a codec on the consuming side. See [Wire names](https://berkcansavur.github.io/relaybus/concepts/event-catalogs#wire-names-next-to-the-catalog-and-the-route).
- **Boot checks with their own codes.** An event routed twice to one destination through a catalog route (`RELAYBUS_ROUTE_DUPLICATE_EVENT`), a wire name for an unknown event (`RELAYBUS_CATALOG_UNKNOWN_EVENT`), a clash of wire names on a transport or with the codec's own (`RELAYBUS_WIRE_TYPE_COLLISION`) and wire names for a transport or codec that cannot map type names (`RELAYBUS_WIRE_TYPES_UNSUPPORTED`) fail when the bus is created, before any transport connects. Every transport checks its names first and all apply them last, so a refused configuration changes no transport; a transport that two buses use, or that already started, refuses wire names. All four extend `ConfigurationError`. Two plain routes to one destination keep failing with the plain `ConfigurationError`.
- Additive API: core `route`, `catalogWireTypes`, `CatalogRoute`, `CatalogRouteOptions`, `RouteDefinition`, `DuplicateRouteEventError`, `UnknownCatalogEventError`, `WireTypeCollisionError`, `WireTypesUnsupportedError`, `DefineCatalogOptions.wireTypes`, `Transport.declareWireTypes` (checks the names and returns the function that applies them); `RelayBusOptions.routes` accepts `RouteDefinition` (a plain route or a catalog route); Kafka and RabbitMQ `KafkaCodec.withWireTypes` / `RabbitMqCodec.withWireTypes` and `declareWireTypes` on both transports; `InMemoryTransport.declaredWireTypes` in `@relaybus/testing`; `mergeWireTypes` and `withDeclaredWireTypes` in `@relaybus/core/internal`.
- **Isolation levels with automatic rerun.** `bus.transaction(work, { isolationLevel, maxAttempts })` and `@RelayBusTransaction({ isolationLevel, maxAttempts })` run a transaction at `'read committed'`, `'repeatable read'` or `'serializable'`. At the last two, a serialization failure (SQLSTATE `40001`, Prisma `P2034`) rolls the attempt back and runs `work` again in a new transaction, up to `maxAttempts` times (default 3), and the last failure is thrown unchanged. This lets PostgreSQL's optimistic SERIALIZABLE replace `pg_advisory_xact_lock` or `SELECT ... FOR UPDATE` for write-skew checks such as "count the active rows, check a limit, insert". `work` may run more than once, so keep external calls out of it; events of a failed attempt are rolled back with it, and `onPublished` and the dispatcher wake-up wait for the final commit. `PgExecutor` sets the level in the `BEGIN` round trip (no extra statement), TypeORM through its own isolation-level transaction, every other executor with `SET TRANSACTION ISOLATION LEVEL` as the first statement; MongoDB and the in-memory store reject a level with a `ConfigurationError`. A decorated method that asks for a level joins a running transaction only when relaybus opened it at the same level, else it throws `TransactionIsolationConflictError` (`RELAYBUS_TRANSACTION_ISOLATION_CONFLICT`). Without `isolationLevel` a transaction runs exactly as in 0.4.1: same statements, same round trips, no rerun. See [Serializable transactions](https://berkcansavur.github.io/relaybus/recipes/serializable-transactions).
- Additive API: core `TransactionOptions`, `TransactionIsolationLevel`, `TRANSACTION_ISOLATION_LEVELS`, `TransactionIsolationConflictError`, `RelayBus.currentIsolationLevel()`, `EventStoreTransactionOptions.isolationLevel`, and the optional `EventStore.isolationLevels` and `EventStore.isSerializationFailure`; `@RelayBusTransaction(options)`; postgres `PostgresIsolatedTransactionOptions` and the optional `PostgresExecutor.transactionWithIsolation` and `PostgresExecutor.isSerializationFailure`; a transaction-isolation section in the store contract of `@relaybus/testing`.
- Benchmark against 0.4.1 (quick gate, interleaved; publish 6 pairs, dispatch 3 pairs per cell): publish 0.92–1.01x (batch 1 1.00x, batch 100 1.01x, batch 100 with 5 handlers 0.92x, every interval spans 1.0), dispatch 0.96–1.02x (PostgreSQL 1 instance 1.02x, 4 instances 1.01x, 10 hot keys 0.96x), so speed is unchanged; statements per event and per delivery are identical. MongoDB was not measured for this release: the harness could not load the 0.4.1 side's MongoDB template within its transaction budget. See [Performance](https://berkcansavur.github.io/relaybus/performance#_0-5-0-vs-0-4-1).

## 0.4.1 (2026-10-26)

A patch release: no export removed or renamed, every option additive. PostgreSQL users get one column conversion that `setup()` applies; see [Upgrading from 0.4.0 to 0.4.1](https://berkcansavur.github.io/relaybus/upgrading#upgrading-from-0-4-0-to-0-4-1) for the DDL when you run your own migrations.

- **PostgreSQL keeps the payload as published.** `payload` and `headers` were `jsonb`, which re-orders object keys, and the insert also went through `jsonb_to_recordset`, so an event read back serialized differently from what the producer published. Both columns are now `json` and the event insert uses `json_to_recordset` end to end, so the transport and the handlers get the producer's exact text, key order included. `setup()` converts existing tables in place (idempotent, one table rewrite); rows stored before keep the order `jsonb` gave them. The schema marker stays the one 0.4.0 writes, so 0.4.0 and 0.4.1 instances can run side by side and roll back with the default `setupStore: true`: a 0.4.0 `setup()` on a converted database runs nothing. The other batch parameters (deliveries, key counts, id lists) stay on `jsonb_to_recordset`.
- **Payload fidelity is part of the store contract.** Every store (PostgreSQL through `pg`, Kysely, Knex, TypeORM, Drizzle and Prisma, MongoDB, in-memory) must hand back a payload with unsorted keys at every depth, unicode, `1e21`, `5e-324`, the largest safe integers and empty containers exactly as `JSON.stringify` wrote it, for single and batched appends and for a handler added later. A bus-level test checks the same through `publish` and `publishAll`. New tests also show that every built-in Kafka and RabbitMQ codec and both transports write and read payloads byte for byte; they did already.
- **Prisma 5.** The optional `@prisma/client` peer is `^5.0.0 || ^6.0.0 || ^7.0.0` (0.4.0: `^6 || ^7`, which made npm refuse the install next to Prisma 5 or add a second client). `createPrismaExecutor` works unchanged on Prisma 5.22; a `prisma compat (prisma 5)` CI job runs the Prisma tests on it, and CI checks that installing `@relaybus/postgres` into an app on Prisma 5.22 leaves its `@prisma/client` and `prisma generate` untouched.
- **jsonEnvelope keeps every top-level field type.** New `otherFields: { payloadField }` keeps the unknown top-level fields that are not read as event headers (numbers, booleans, null, objects, arrays, and text when headers do not live at the top level) unchanged under that payload field (an object payload always gets it, `{}` when there is nothing to keep), where the event's schema validates them; writing spreads them back over the top level, so the values survive a round trip, and the bytes do too when `bodyOrder` lists the fields in wire order. Without it, decode leaves them out as before.
- **jsonEnvelope `bodyOrder` covers spread fields.** Event headers spread by `headers: 'top-level-fields'` (and fields kept by `otherFields`) now take their `bodyOrder` position; before they always came last.
- **jsonEnvelope `wireTypes`.** Maps event types to the names a format uses on the wire, so one route carries many types whose wire names are not relaybus event types, without a `fixed` type per transport. The event type grammar is unchanged; it already accepts snake_case such as `transfer_auto_dispatched`.
- **The codec contract runs under any test framework.** `@relaybus/testing/codec-contract` imports no test framework: `assertKafkaCodecCase` / `assertRabbitMqCodecCase` throw a `CodecContractError` (code `RELAYBUS_TESTING_CODEC_CONTRACT`), and `registerKafkaCodecContract` / `registerRabbitMqCodecContract` register tests with any `describe` and `it`. `@relaybus/testing/jest` registers with Jest's globals. `vitest` is an optional peer now; `kafkaCodecContract` and `rabbitMqCodecContract` from the main entry work with Vitest as before. Compared byte for byte, the decoded payload must also keep the expected key order.
- Additive API: `jsonEnvelope` options `otherFields` and `wireTypes`; `@relaybus/testing` `assertKafkaCodecCase`, `assertRabbitMqCodecCase`, `registerKafkaCodecContract`, `registerRabbitMqCodecContract`, `CodecContractError`, `TestFramework`, and the `codec-contract` and `jest` entry points.
- Benchmark against 0.4.0 (quick gate, 3 interleaved pairs per cell): publish 0.98–1.10x (batch 1 0.98x, batch 100 1.04x, batch 100 with 5 handlers 1.10x, MongoDB 0.99x), dispatch 1.02–1.03x (PostgreSQL 1 instance 1.02x, 10 hot keys 1.02x, MongoDB 1.03x), so speed is unchanged. WAL bytes per event or delivery are 3–11% lower and `lanes` updates per delivery 0.88x on the PostgreSQL dispatch cell. The 4-instance PostgreSQL dispatch cell was not valid on either side (its template drains before the measurement window ends); that is a harness sizing issue, not a regression.

## 0.4.0 (2026-10-25)

Not breaking: the public API snapshot of every package changes by additions only (no export removed or renamed, checked with `git diff` of the `public-api` snapshots against 0.3.1), and nothing in the database, the SQL or the default wire format changes. Nothing to do when upgrading from 0.3; see [Upgrading from 0.3 to 0.4](https://berkcansavur.github.io/relaybus/upgrading#upgrading-from-0-3-to-0-4) for the optional features.

- Prisma 7 runs on a real database. The Prisma adapter now passes the full event store contract against PostgreSQL with Prisma Client 7.10 on `@prisma/adapter-pg` (6.19 is covered in CI too). This found one bug: Prisma raw queries reject a `void` column, so `setup()` and the trailing notification failed; both statements now return a boolean column. `@prisma/client` is an optional peer `^6 || ^7`.
- `PostgresEventStore` accepts `pool` next to `executor`. Statements outside a transaction (claims, completions, renewals) run on it as prepared statements (a warm dispatch batch parses 0 statements instead of 2 for Prisma, Kysely and Drizzle), and the store listens on it.
- Wake-up at the caller's commit. With a `pool`, an event published in your ORM transaction is dispatched right after that transaction commits (handler start a median under 10 ms after a commit held back 200 ms). A publish in a transaction relaybus does not own wakes the dispatcher 25, 100 and 250 ms after the latest publish instead of once at 25 ms. New `bus.wake()` for stores without notifications.
- Kafka: `client: 'confluent'` runs `@confluentinc/kafka-javascript` (its KafkaJS-compatible API), now the recommended client, tested on a real broker next to `kafkajs`. The default stays `'kafkajs'`. The peer range is `^1.11.0`.
- Kafka: `producer` publishes and dead-letters through an already-connected producer the application owns (kafkajs or Confluent). The transport never connects or disconnects it. With it, `kafka` is optional and the transport publishes only; `kafka` and `groupId` as well make it consume with its own consumer.
- Kafka `jsonEnvelope`: `recordKey: 'event-key' | 'none' | (event) => string | null` (default `'event-key'`, unchanged) and `contentTypeHeader`. Only the wire key changes; the outbox order of one event key stays.
- `jsonEnvelope` (Kafka and RabbitMQ) describes more in-house formats: `occurredAt` as `'epoch-millis'` or `'epoch-seconds'`, `extra` body values and headers (constants or functions of the event, written only), `bodyOrder` for formats compared byte for byte, an id that falls back to the transport's own (`whenMissing: 'record-position'`) and `headers: 'top-level-fields'`. Together they write and read legacy flat envelopes (one topic, many types, no key, no headers) byte for byte.
- `@relaybus/testing`: `kafkaCodecContract` and `rabbitMqCodecContract` (and the pure `kafkaCodecMismatches`, `rabbitMqCodecMismatches`) check a codec against captured messages in both directions. The transports' own decode path is exported as `decodeKafkaRecord` and `decodeRabbitMqMessage`.
- Peer and test dependencies moved to the latest versions: `@opentelemetry/api` 1.10 with the OpenTelemetry SDK 3, `@nestjs/*` 12.2.
- Additive API: Kafka `KafkaInjectedProducer`, `KafkaRecordKey`, `KafkaRecordOptions`, `decodeKafkaRecord`; RabbitMQ `decodeRabbitMqMessage`; testing the codec contract functions and their option types; `RelayBus.wake()`; `KafkaTransportOptions.kafka` is now optional.
- Benchmark against 0.3.1 (quick run, 3 interleaved pairs per cell): publish 0.98–1.03x, dispatch 0.99–1.06x, statements per delivery identical. See [Performance](https://berkcansavur.github.io/relaybus/performance#_0-4-0-vs-0-3-1).
- Known limits: on decode, unknown top-level fields are kept only when they are strings (0.4.1 adds `otherFields`); there is no consumer injection (only a producer can be passed in); RabbitMQ has no routing-key override.

## 0.3.1 (2026-10-25)

A readability release: no behaviour, API, SQL or wire-format change. Nothing to do when upgrading from 0.3.0.

- Every source file is now at most 300 code lines and every function at most 40 (enforced by lint). The largest files shrank from 794 to 294 lines (`relay-bus.ts`) and from 772 to 173 lines (`dispatcher.ts`).
- Shared skeletons moved into abstract base classes: one lease settlement flow for the PostgreSQL, MongoDB and in-memory stores, one claim flow for PostgreSQL and MongoDB, and one subscription lifecycle for Kafka and RabbitMQ. Value objects replace repeated primitives with rules (durations, backoff, lease identity, composite keys, bounded integers).
- Proof of no behaviour change: the public API snapshot, the SQL snapshot of every statement and the performance budgets are unchanged; 2,798 tests with 100% coverage.
- Additive type change: `KafkaTransport` and `RabbitMqTransport` now extend the internal `SubscribingTransport` base class.
- Benchmark against 0.3.0 (quick gate, 3 interleaved pairs per cell): publish 0.99–1.02x, dispatch 0.98–1.04x, every interval spans 1.0, so speed is unchanged. The 4-instance PostgreSQL dispatch cell was not valid on either side (its template drains before the measurement window ends); that is a harness sizing issue, not a regression.

## 0.3.0 (2026-10-25)

Follow [Upgrading from 0.2 to 0.3](https://berkcansavur.github.io/relaybus/upgrading). Nothing has to be migrated in the database; read [Breaking changes](#breaking-changes) first. What stays stable between releases is described in [Stability](https://berkcansavur.github.io/relaybus/stability), what is tested in [Compatibility](https://berkcansavur.github.io/relaybus/compatibility).

### What is new

| Area                      | Change                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Code quality              | The code now follows a strict set of limits: strict `tsconfig` flags (`noUnusedLocals`, `noImplicitReturns`, `noPropertyAccessFromIndexSignature` and more), lint rules for function size (60 lines), complexity (10), depth (3), at most two positional parameters, one class per file and a `[ClassName](methodName)` prefix on every log line. The two largest files, the PostgreSQL and MongoDB stores, are split into modules per concern with byte-identical SQL and unchanged filters. 2,773 tests, 100 % coverage unchanged. |
| Errors                    | Every error class carries a stable `code` (`RELAYBUS_LEASE_LOST`, ...), reported on dead letters, retries and observer events and listed on [Error codes](https://berkcansavur.github.io/relaybus/concepts/error-codes). Remaining bare throws became coded classes. Kafka and RabbitMQ dead letters carry the error name and code.                                                                                                                                                                                                                                         |
| API surface               | A snapshot test pins every export of every package, so a change of the public API is always deliberate.                                                                                                                                                                                                                                                                                                                                                                                                                              |
| Ordering                  | Per-key order now holds across event types routed to one destination: all routes to one destination are one handler. [Ordering keys](https://berkcansavur.github.io/relaybus/concepts/ordering-keys).                                                                                                                                                                                                                                                                                                                                                                       |
| Codecs                    | Kafka and RabbitMQ take a symmetric `codec` (`cloudEventsBinary`, `cloudEventsStructured`, `jsonEnvelope`, or your own `{ encode, decode }`) for the wire format both ways. The default stays byte for byte the same. [Guide](https://berkcansavur.github.io/relaybus/recipes/interoperating-with-other-services).                                                                                                                                                                                                                                                          |
| Typed ordering key        | `defineEvent(type, { schema, key: (payload) => payload.orderId })` derives the key from the payload; a wrong field fails to compile.                                                                                                                                                                                                                                                                                                                                                                                                 |
| Transactions in NestJS    | `@RelayBusTransaction()` opens a transaction around a method, `RelayBusTransactionHost` hands it to repositories, and every transaction is held to `transactionTimeout` (default `'5s'`). [Guide](https://berkcansavur.github.io/relaybus/recipes/transactions-in-nestjs).                                                                                                                                                                                                                                                                                                  |
| Decorators                | `@RelayBusConsumer(Event)` is the method decorator (default style) and `@RelayBusCatalogConsumer(Catalog)` the optional class decorator. `RelayBusModule.forFeature({ consumes, produces })` fails the boot when a module lacks a consumer or a route. [Event catalogs](https://berkcansavur.github.io/relaybus/concepts/event-catalogs).                                                                                                                                                                                                                                   |
| Bounded shutdown          | `bus.stop({ timeout })` covers the whole shutdown, force-closes what relaybus opened itself at the deadline and resolves with a `StopResult { graceful, abandonedDeliveries, durationMs }`. NestJS `app.close()` is bounded the same way.                                                                                                                                                                                                                                                                                            |
| `@relaybus/eslint-plugin` | New package. `no-external-call-in-transaction` and `publish-requires-transaction`, both `error` in the recommended config.                                                                                                                                                                                                                                                                                                                                                                                                           |
| `@relaybus/otel`          | New package. OpenTelemetry metrics (publish, claim, handler, end-to-end latency, retries, dead letters, backlog gauges, a counter of undecodable inbound messages) and traces across services. [Observability](https://berkcansavur.github.io/relaybus/concepts/observability).                                                                                                                                                                                                                                                                                             |
| Observer hooks            | Timed hooks (`onPublishFinished`, `onClaimFinished`, `onHandlerStarted`, `onHandlerFinished`, `onTransportSent`, `onReceiveFinished`, `onInvalidMessage`, `onBacklog`), skipped entirely when no observer implements them.                                                                                                                                                                                                                                                                                                           |
| Testing                   | Every timer of the packages runs through the `Clock`, so `FakeClock` controls them all.                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| Benchmarks                | `bench:full` gains two routed cells (`distinct:1000` and `hot:10` keyed events sent to an in-process fake transport that acks after 5 ms).                                                                                                                                                                                                                                                                                                                                                                                           |

### Breaking changes

| What                      | 0.2.0                                                            | 0.3.0                                                                                                                                             | Action                                                                                                                                                                            |
| ------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Route handler ids         | `relaybus.transport:<transport>:<destination>:<eventType>`       | `relaybus.transport:<transport>:<destination>`                                                                                                    | Update dashboards and code that match route ids (`handlers()`, `listDeliveries({ handler })`, `HandlerContext.handler`, observer events). Stored 0.2.0 deliveries are still sent. |
| Routes to one destination | Different `retry` or `timeout` were allowed                      | They must be equal, and an event type may be routed to a destination once; otherwise `new RelayBus` throws `ConfigurationError`                   | Give such routes the same settings.                                                                                                                                               |
| Transaction time budget   | No limit                                                         | `transactionTimeout`, default `'5s'`: a longer `bus.transaction` or `@RelayBusTransaction()` method is rolled back with `TransactionTimeoutError` | Move slow calls out of the transaction, or raise the option.                                                                                                                      |
| `bus.stop()`              | Returns `Promise<void>`, waits without a deadline                | Returns `Promise<StopResult>`, bounded by `timeout` (default `dispatcher.shutdownTimeout`, 30 s)                                                  | Only code that types the result as `void` needs a change.                                                                                                                         |
| Error names               | Five Kafka and RabbitMQ errors reported the name `RelayBusError` | They report their own class name                                                                                                                  | Code that matched `error.name === 'RelayBusError'` on dead letters must use the class name or `code`.                                                                             |
| Failure stage             | `onFailure` stages without `'backlog'`                           | New stage `'backlog'`                                                                                                                             | An exhaustive `switch` over the stage needs a new case.                                                                                                                           |

### Deprecations

Removed in 1.0.0; they still work and behave the same.

- `@Subscribe` is the earlier name of `@RelayBusConsumer`; `@EventConsumer` of `@RelayBusCatalogConsumer`. Their option and metadata types are deprecated too.
- The inbound-only `decode` option of `KafkaTransport` and `RabbitMqTransport`. Use `codec`; giving both is a configuration error.

### Benchmark: 0.2.0 vs 0.3.0

In short: 0.3.0 is as fast as 0.2.0 on publish and on MongoDB, and about 1.8 times faster for keyed PostgreSQL dispatch in two cells. No cell showed a lost, duplicated or out-of-order delivery. Most cells have one sample per side, so a difference below about 10 % is noise. The full tables and caveats are in [Performance: 0.3.0 vs 0.2.0](https://berkcansavur.github.io/relaybus/performance#_0-3-0-vs-0-2-0).

Method: `pnpm bench:full --base v0.2.0`, interleaved on one machine (Apple M4, Docker Desktop, PostgreSQL 17, MongoDB 7, Node 24.3.0), fixed windows, database counters and exactness validation, as described in the [benchmark README](https://github.com/berkcansavur/relaybus/blob/main/benchmarks/README.md). The full run (39 minutes, all cells) used commit `a8587ff`; every sample was valid except the two of the PostgreSQL fan-out cell on four instances, whose preloaded backlog was too small on both sides, so that cell is left out. The publish and mixed-load cells were measured again after two publish fixes, with 2 samples per side and the 0.2.0 side taken from stored 0.2.0 samples.

| Scenario                                          | 0.2.0 per second | 0.3.0 per second |        Ratio |        Samples        |
| ------------------------------------------------- | ---------------: | ---------------: | -----------: | :-------------------: |
| PostgreSQL publish, 1 publisher, 1 event per call |            1,543 |            1,573 |         1.02 |         2 / 2         |
| PostgreSQL publish, 32 publishers, 1 event        |            6,235 |            6,111 |         0.98 |         2 / 2         |
| PostgreSQL publish, 8 publishers, 1,000 keys      |            3,789 |            4,145 |         1.09 |         2 / 2         |
| PostgreSQL mixed load, 5,000 per second, tuned    |            4,133 |            4,032 |         0.98 |         2 / 2         |
| PostgreSQL routed keyed dispatch, 1,000 keys      |            3,267 |            5,827 |         1.78 |         1 / 1         |
| PostgreSQL keyed dispatch, 1,000 keys, 4 workers  |            4,453 |            7,931 |         1.78 |         1 / 1         |
| PostgreSQL and MongoDB publish, all other cells   |                - |                - | 0.94 to 1.09 |         2 / 2         |
| PostgreSQL dispatch, all other cells              |                - |                - | 0.88 to 1.19 | 1 / 1, one cell 3 / 3 |
| MongoDB dispatch, all cells                       |                - |                - | 0.98 to 1.06 | 1 / 1, one cell 3 / 3 |

Statements per publish are 4 in both versions (4.07 against 4.07 at one publisher), so the transaction budget costs no extra round trip. Mixed-load p99 ratios are not meaningful at 5,000 per second: both sides are saturated and p99 moved between 79 ms and 10.6 s from run to run.

Two things to know. First, PostgreSQL WAL bytes per delivery were 8 to 11 % higher in four dispatch cells and 5 to 8 % lower in five others (one sample each, except one cell with three), so there is no clear direction yet. Second, the first full run showed 25 % more statements per publish (5 against 4). That was the transaction budget and was fixed before this table was taken, in `a13fb7e` and `2fb3cde`. The publish cells that were not measured again (8 publishers with batches, five handlers, hot keys) were 0.79 to 1.02 in the full run, before the fix.

### Known issues

- **p99 latency near saturation.** Carried over from 0.2.0 and not changed by this release: at about 5,000 events per second on a laptop, end-to-end p99 on PostgreSQL was higher than 0.1.0's. Re-measure with `bench:full` for your hardware.
- **Order across the upgrade.** Deliveries stored under 0.2.0 route ids are a separate queue from new ones. See [Upgrading](https://berkcansavur.github.io/relaybus/upgrading#order-during-the-upgrade).
- **RabbitMQ and the shutdown deadline.** A connection opened with `url` is cut at the deadline, even during the TCP or AMQP handshake. A `connect` function you supply gets `connect({ signal })` and must honour it; a socket it keeps opening is not stopped by relaybus. Kafka has no forced disconnect, so past the deadline the bus stops waiting for it.
- **`TimeoutNegativeWarning` on Node 23 and newer.** `kafkajs` can print this warning; it comes from kafkajs, not from relaybus.
- **ORM adapters do not use prepared statements.** Unchanged from 0.2.0; run dispatchers on a `pg` pool ([Tuning](https://berkcansavur.github.io/relaybus/tuning)).

## 0.2.0 (2026-10-24)

**Breaking.** Completed deliveries are now deleted when they complete instead of being kept. To keep them for `listDeliveries`, pass `completionHistory: true`. The 0.1.0 PostgreSQL and MongoDB schemas are upgraded in place by `setup()`. Follow [Upgrading from 0.1 to 0.2](https://berkcansavur.github.io/relaybus/upgrading); stop every 0.1.0 worker first.

### What is new

| Area                  | Change                                                                                                                                                                                                                                                                      |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Delivery path         | Workers lease 256 _lanes_ instead of rows, claim and complete in batches, and delete a delivery when it completes. The delivery row is written once and deleted once. [Why](https://berkcansavur.github.io/relaybus/performance/write-amplification)                                                               |
| Throughput            | On one laptop against 0.1.0: PostgreSQL publish 1.1 to 1.9x, PostgreSQL dispatch up to 18x, MongoDB dispatch 2 to 26x, about half the WAL per delivery. [Numbers and method](https://berkcansavur.github.io/relaybus/performance)                                                                                  |
| Deadlock-free locking | One documented lock order for every statement, and permanent stress tests: 10 dispatchers on PostgreSQL and on MongoDB, concurrent publishers, takeovers and keyed ordering.                                                                                                |
| Wake-ups              | PostgreSQL `LISTEN`/`NOTIFY` (`wakeUp: 'notify'`) starts new work on other processes within milliseconds; at most one `NOTIFY` per 10 ms per store. `wakeUp: 'poll'` turns it off.                                                                                          |
| Prepared statements   | The statements relaybus repeats are parsed once per connection (`preparedStatements`, on by default for `pg` pools). [Why](https://berkcansavur.github.io/relaybus/performance/publish-path)                                                                                                                       |
| Event catalogs        | `defineCatalog`, `HandlersOf`, `defineHandlers`, `bus.forCatalog`, `bus.subscribeCatalog` and a boot-time coverage check. `consume(...)` and `produce(...)` roles let services share one catalog; `@EventConsumer` in `@relaybus/nestjs`. [Guide](https://berkcansavur.github.io/relaybus/concepts/event-catalogs) |
| Foreign messages      | Kafka and RabbitMQ `decode` option with `plainJsonDecoder` and `decodeCloudEvent` (structured mode). Default decoding is unchanged. [Guide](https://berkcansavur.github.io/relaybus/recipes/interoperating-with-other-services)                                                                                    |
| MongoDB               | Lane documents, bulk completion, smaller new documents.                                                                                                                                                                                                                     |
| Tuning                | New options `wakeUp`, `preparedStatements` and `completionHistory`. [Tuning guide](https://berkcansavur.github.io/relaybus/tuning)                                                                                                                                                                                 |
| Tooling               | Shared test containers (suite about 6 to 3.5 minutes), an interleaved benchmark harness (`pnpm bench:quick`, `pnpm bench:full`), performance budgets that can only go down.                                                                                                 |

Verification: 2,292 tests with 100 % statement, branch, function and line coverage; typecheck, lint, build and publint green; 0 lost, 0 duplicate and 0 out-of-order deliveries in the full benchmark.

### Known issues

- **p99 latency near saturation.** At 5,000 events per second on a laptop, end-to-end p99 on PostgreSQL is higher than 0.1.0's (524 ms against 114 ms in the single-sample run), although 0.2.0 accepts more load there. Below saturation (1,000 per second) p99 is far lower. To be addressed in a later release.
- **Ordering scope.** Order is guaranteed between deliveries of the same handler and the same ordering key. Unkeyed events, and deliveries to different handlers, have no relative order.
- **`bus.stop()` has no deadline yet.** It waits for running handlers to finish; a handler that never returns holds shutdown until the process manager kills it. Give handlers their own timeouts (`timeout` on `@Subscribe`).
- **ORM adapters do not use prepared statements.** Prisma, TypeORM, Drizzle, Knex and Kysely send every statement unprepared, so each publish and claim is parsed again (about 0.3 ms per publish and 1.3 ms per claim of 10 deliveries). Run dispatchers on a `pg` pool; see [Tuning](https://berkcansavur.github.io/relaybus/tuning).

## 0.1.x

First public release: the transactional outbox, per-handler deliveries, retries, dead letters and ordering keys for PostgreSQL and MongoDB, the Kafka and RabbitMQ transports and the NestJS module. Upgrade with [Upgrading from 0.1 to 0.2](https://berkcansavur.github.io/relaybus/upgrading).

## How versioning works

Every push to `main` with releasable [Conventional Commits](https://www.conventionalcommits.org/) publishes all seven `@relaybus/*` packages to npm with **one shared version**, computed by semantic-release. There is no committed changelog: the [GitHub Releases](https://github.com/berkcansavur/relaybus/releases) page is the changelog.

- Current release line: **0.2.x**. While the version is below 1.0.0, a breaking change bumps the minor version.
- `feat` commits bump the minor version; `fix`, `perf`, `refactor` and `revert` bump the patch version (a refactor changes the published code, never its behaviour). `docs`, `chore`, `ci`, `test`, `build` and `style` do not release.
- Prereleases are published from the `next` and `beta` branches under the matching npm dist-tags.
- Releases are published by CI with npm Trusted Publishing (OIDC) and provenance, so a tarball links back to the workflow run that built it.

## Install a specific version

```sh
pnpm add @relaybus/core@0.3.0 @relaybus/nestjs@0.3.0 @relaybus/postgres@0.3.0
```

Keep all `@relaybus/*` packages on the same version.

## Links

- [Upgrading from 0.1 to 0.2](https://berkcansavur.github.io/relaybus/upgrading)
- [Performance: 0.2.0 vs 0.1.0](https://berkcansavur.github.io/relaybus/performance)
- [GitHub Releases](https://github.com/berkcansavur/relaybus/releases)
- [npm: @relaybus/core](https://www.npmjs.com/package/@relaybus/core)
- [Security policy](https://github.com/berkcansavur/relaybus/blob/main/SECURITY.md)
- [Contributing](https://github.com/berkcansavur/relaybus/blob/main/CONTRIBUTING.md)
