@relaybus/postgres 0.1.0 → 0.2.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,7 +1,9 @@
1
1
  # @relaybus/postgres
2
2
 
3
+ @relaybus/postgres is the PostgreSQL event store of relaybus, a reliable, storage-agnostic event bus for NestJS and Node.js: it writes the transactional outbox in your own transaction, with `pg`, Prisma, TypeORM, Drizzle, Knex or Kysely.
4
+
3
5
  PostgreSQL event store for [relaybus](https://github.com/berkcansavur/relaybus): a transactional outbox with
4
- `FOR UPDATE SKIP LOCKED` leasing, per-key ordering and fenced state changes. PostgreSQL 13 or newer.
6
+ lane leases, batched claim and completion, per-key ordering and fenced state changes. PostgreSQL 13 or newer.
5
7
  Works with `pg`, Kysely, Knex, TypeORM, Drizzle and Prisma, so events are written in the **same transaction as your
6
8
  business data**.
7
9
 
@@ -82,14 +84,17 @@ against a fake client only. Prisma interactive transactions default to a 5 s tim
82
84
 
83
85
  ## Options
84
86
 
85
- | Option | Default | Meaning |
86
- | ---------------------------------------- | --------------------- | --------------------------------------------------------------------------- |
87
- | `pool` / `connectionString` / `executor` | required, exactly one | How to reach the database. |
88
- | `poolConfig` | none | Extra `pg` pool settings when `connectionString` is used. |
89
- | `schema` | `'public'` | Schema of the tables. Created by `setup()` when it is not `public`. |
90
- | `tablePrefix` | `'relaybus_'` | Tables are `<prefix>events`, `<prefix>deliveries`, `<prefix>key_sequences`. |
91
- | `purgeBatchSize` | `1000` | Deliveries deleted per transaction by `purge`. |
92
- | `logger` | `console` | Receives idle-connection errors of a pool the store created itself. |
87
+ | Option | Default | Meaning |
88
+ | ---------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
89
+ | `pool` / `connectionString` / `executor` | required, exactly one | How to reach the database. |
90
+ | `poolConfig` | none | Extra `pg` pool settings when `connectionString` is used. |
91
+ | `schema` | `'public'` | Schema of the tables. Created by `setup()` when it is not `public`. |
92
+ | `tablePrefix` | `'relaybus_'` | Tables are `<prefix>events`, `<prefix>deliveries`, `<prefix>key_sequences`, `<prefix>lanes`. |
93
+ | `completionHistory` | `false` | Keep an append-only record of completed deliveries (`<prefix>completed_deliveries`). Same value in every process. |
94
+ | `purgeBatchSize` | `1000` | Deliveries deleted per transaction by `purge`. |
95
+ | `logger` | `console` | Receives idle-connection errors of a pool the store created itself. |
96
+ | `preparedStatements` | `true` | Prepare the statements the store repeats once per connection. `false` behind PgBouncer < 1.21 in transaction mode. |
97
+ | `wakeUp` | `'notify'` | `'notify'`: coalesced `NOTIFY` + `LISTEN` wake other processes. `'poll'`: no notifications, other processes poll. |
93
98
 
94
99
  **Pool errors.** A pool created from `connectionString` gets an `error` listener that logs through `logger`
95
100
  (`[PostgresEventStore](constructor) Idle connection failed ...`); `pg` then replaces the dead connection, so a failover
@@ -112,19 +117,37 @@ import { createPostgresSchemaSql, createPostgresSchemaStatements } from '@relayb
112
117
  const migration = createPostgresSchemaSql({ schema: 'app', tablePrefix: 'bus_' });
113
118
  ```
114
119
 
115
- | Table | Purpose |
116
- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
117
- | `events` | One row per event: `id` (**text**, so any caller id works), `type`, `source`, `key`, `sequence`, `payload jsonb`, `headers jsonb`, correlation and causation ids, `occurred_at`, `created_at`. |
118
- | `deliveries` | One row per (event, handler): status, attempts, `available_at`, lease owner/token/expiry, `last_error jsonb`, key and sequence copied from the event, timestamps. `unique (event_id, handler)`, foreign key to events with `ON DELETE CASCADE`. |
119
- | `key_sequences` | One counter row per ordering key. |
120
-
121
- Indexes on `deliveries`: a partial index on the due time of pending/processing rows (statistics), the same for unkeyed
122
- rows only (claim), two partial indexes on `(handler, key, ...)` for the ordering check, `(status, created_at, id)` and
123
- `(handler, created_at, id)` for `listDeliveries` filtered by status or handler, and partial indexes for purging
124
- completed and dead rows; on `events`, `(created_at, id)` for the purge sweep. `listDeliveries({ eventType })` has no
125
- index of its own (it filters through a join with `events`); combine it with `status` or `handler`, or add
126
- `CREATE INDEX ON <prefix>events (type)` if you query by type often. `deliveries.id` uses the `C` collation so id tie-breaks are the
127
- same on every database.
120
+ | Table | Purpose |
121
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
122
+ | `events` | One row per event: `id` (**text**, so any caller id works), `type`, `source`, `key`, `sequence`, `payload jsonb`, `headers jsonb`, correlation and causation ids, `occurred_at`, `created_at`, and `handlers`, the handlers that ever got a delivery (it outlives the deliveries). |
123
+ | `deliveries` | One row per (event, handler) that is not finished: `lane`, `status` (`pending` or `dead`), `attempts`, `available_at`, `last_error jsonb`, key and sequence copied from the event, timestamps. `unique (event_id, handler)`, foreign key to events with `ON DELETE CASCADE`. |
124
+ | `lanes` | 256 rows, one per lane: `epoch` (the fencing token), `lease_owner`, `lease_expires_at` and `in_flight` (ids of the deliveries the owner claimed). `fillfactor = 50` and no secondary index, so its updates are heap-only (HOT). |
125
+ | `event_completions` | One row per event that had a delivery complete, with the time of its last completion. Retention of completed work (`retention.completed`) is measured from it, however old the event is; events that never had a delivery complete are measured from their creation. |
126
+ | `key_sequences` | One counter row per ordering key. |
127
+ | `completed_deliveries` | Only with `completionHistory`: one insert-only row per completed delivery. |
128
+
129
+ A delivery row is inserted once and deleted once when it completes. Claiming, renewing a lease and completing never
130
+ update it: they update the lane row (claim, renewal) or delete the delivery (completion), so the indexed columns of a
131
+ delivery are written exactly once, at publish. Indexes on `deliveries`: `(lane, available_at, id) WHERE status = 'pending'`
132
+ (claim and statistics), `(lane, handler, key, sequence) WHERE key IS NOT NULL AND status = 'pending'` (ordering check
133
+ and the blocked-key claim), the unique `(event_id, handler)`, and partial indexes for purging dead deliveries (and
134
+ completed ones left by 0.1.0); on `events`, `(created_at, id)` for the purge sweep. `listDeliveries` has no index of its
135
+ own: it orders by `created_at`, so on a large `deliveries` table combine it with `status`/`handler`, which keeps the scan
136
+ to the dead or pending rows, or add the index you need. `deliveries.id` uses the `C` collation so id tie-breaks are the
137
+ same on every database. The tables are created with aggressive autovacuum settings
138
+ (`autovacuum_vacuum_scale_factor = 0.02`): deleted deliveries leave dead tuples that autovacuum reclaims.
139
+
140
+ ## Upgrading from 0.1.0
141
+
142
+ `setup()` upgrades a 0.1.0 schema in place, in one transaction under the same advisory lock, and is safe to run again:
143
+ it adds `lane` to every delivery (hash of the key, or of the event id) and `handlers` to every event (from the
144
+ existing deliveries), turns `processing` deliveries into `pending` (their attempt stays counted, as a lost lease would
145
+ count it), drops the per-delivery lease columns and the indexes that no longer exist, and creates the lane table.
146
+ Deliveries that were `completed` stay as `completed` rows until `retention.completed` purges them (or forever with
147
+ `retention: { completed: false }`). **Stop every 0.1.0 worker before running it** (they cannot use the new tables), and
148
+ expect the one-off rewrite of `deliveries` (an `UPDATE` of every row plus `SET NOT NULL`) to take time proportional to
149
+ its size; purge old completed rows first to shorten it. If you apply `createPostgresSchemaSql()` with your own migration
150
+ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the new version together is not supported.
128
151
 
129
152
  ## Guarantees
130
153
 
@@ -136,30 +159,42 @@ same on every database.
136
159
  primary-key order, so concurrent single calls cannot deadlock each other. Claiming hands out a keyed delivery only when no earlier (same handler and key) delivery
137
160
  is pending or processing, so one key is processed one event at a time, in order. A retrying or leased head blocks its
138
161
  successors; a dead-lettered head releases them. A `limit` of zero or less claims nothing.
139
- - **Safe parallel claiming.** One statement chooses candidates with `FOR UPDATE SKIP LOCKED`, applies `LIMIT` after the
140
- ordering filter, and leases them (`processing`, `attempts + 1`, a new `gen_random_uuid()` lease token). SKIP LOCKED
141
- alone is sufficient for ordering: only the head of a key is ever a candidate, and a racing claimer either skips the
142
- locked head or, if the other claim already committed, re-checks the row (now processing, not due) and drops it.
143
- Followers stay blocked because the head remains pending or processing in every snapshot until it is completed or
144
- buried. The stress test in `test/claim-concurrency.integration.test.ts` runs 12 claimers on 3 pools against 30 keys
145
- and fails if a key is ever in flight twice or out of order. A keyed delivery is also not claimed while another
146
- delivery of the same handler and key holds a **live** lease, which covers a requeued or newly attached delivery
147
- re-entering behind a running one. An expired lease does not block: if the running successor's worker crashed, the
148
- requeued head is claimed after the lease expires and the successor follows once it completes.
149
- Claiming first looks at the 256 earliest due deliveries (one cheap statement). Only when those are mostly followers of
150
- blocked keys and fewer than `limit` were leased does a second statement find the head of every key with
151
- `DISTINCT ON (handler, key)`, so a deep backlog behind one busy key costs one index-only scan instead of a probe per
152
- follower.
153
- - **Fenced.** `complete`, `reschedule`, `bury`, `release` and `extendLease` apply only to
154
- `status = 'processing' AND lease_token = <token>`; a worker that lost its lease changes nothing. `complete` takes the
155
- handler's transaction, so a transactional handler's writes and its completion commit together.
162
+ - **Lane leases.** Every delivery belongs to one of 256 lanes: a hash of its ordering key, or of its event id when it
163
+ has none, so all deliveries of a key share a lane. Workers lease lanes, not rows. A claim runs in one short
164
+ transaction: it locks the lane rows this worker already holds, takes free lanes that have due work (four, then eight,
165
+ ... lanes at a time, so a worker holds only as many lanes as its batch needs, oldest due delivery first), reads the due
166
+ deliveries of those lanes and writes **one** lane row per lane to record the batch (`in_flight`). It writes no delivery
167
+ row and takes no row lock on one: exclusivity comes from the lease. A lane taken from another worker gets a new
168
+ `epoch`; lanes that yielded nothing are handed back at once. While a lane lease is live, other workers do not see the
169
+ pending deliveries of that lane. The lease is renewed with the running deliveries (`extendLease` updates the lane
170
+ row) and handed back at graceful shutdown (`relinquish`).
171
+ - **Per-key order under lanes.** A keyed delivery is claimed only when no earlier delivery of its handler and key is
172
+ pending (a running one is still a pending row) and none of its handler and key is in flight, and the lane belongs to
173
+ one worker, so a key is processed one event at a time, in order. A retrying or running head blocks its successors; a
174
+ dead-lettered head releases them. Claiming first looks at the 256 earliest due deliveries of the lanes it holds
175
+ (cheap); only when those are mostly followers of blocked keys and fewer than `limit` were picked does a second
176
+ statement find the head of every key with `DISTINCT ON (handler, key)`. The stress test in
177
+ `test/claim-concurrency.integration.test.ts` runs 12 claimers on 3 pools against 30 keys and fails if a key is ever in
178
+ flight twice or out of order.
179
+ - **Fenced.** A lease token is `<lane>:<epoch>:<claims>`, where `claims` counts how many claims of that delivery ended before this one (every `reschedule`, `release`, `bury` and takeover bumps it), so a lease of an older attempt never matches a newer claim even when the same worker re-claims the delivery in the same lane epoch. `complete`, `completeMany`, `reschedule`, `bury`, `release` and
180
+ `extendLease` apply only while the lane still has that epoch and lists the delivery as in flight; a worker that lost
181
+ its lane changes nothing. Completion takes a `FOR KEY SHARE` lock on the lane row until the transaction ends, so a takeover
182
+ (which locks the row `FOR UPDATE`) and a transactional handler's completion cannot both succeed: the handler's writes
183
+ and the deletion of its delivery commit together, or the takeover redelivers it.
184
+ - **Attempts survive crashes.** An attempt is recorded when it ends: a failure adds one to `attempts` (`reschedule`,
185
+ `bury`), a graceful `release` adds nothing, and a lane that is taken over charges one attempt to every delivery its
186
+ previous holder still had in flight. A worker that dies on a poison message therefore still runs it into the retry
187
+ limit and the dead-letter table.
188
+ - **No double run after completion.** An event lists every handler it ever had a delivery for, so republishing an
189
+ event id (or a broker redelivering it) creates deliveries only for handlers that never had one, even though completed
190
+ deliveries are deleted. This holds while the event row exists (`retention.completed`).
156
191
  - **App clock.** Every timestamp is the `now` the caller passes, never the database clock.
157
192
  - **Driver independent reads.** Timestamps and JSON are read as text and parsed by the store, so custom `pg` type
158
193
  parsers (or Drizzle's) cannot change results.
159
194
 
160
195
  ## Operations
161
196
 
162
- - **Isolation.** Claiming runs as autocommit statements and relies on `READ COMMITTED` (the PostgreSQL default). Do
197
+ - **Isolation.** Claiming runs as a short transaction and relies on `READ COMMITTED` (the PostgreSQL default). Do
163
198
  not change `default_transaction_isolation` for the relaybus pool. Your own publishing transaction may use a stricter
164
199
  level, but under `REPEATABLE READ` or `SERIALIZABLE` PostgreSQL can abort it with `40001` (serialization failure);
165
200
  retry the whole transaction.
@@ -168,11 +203,15 @@ same on every database.
168
203
  other) can deadlock; PostgreSQL aborts one with `40P01`. The aborted transaction rolls back completely, nothing is
169
204
  lost or half-written, and the caller should retry it. Publishing all keys of a transaction in one `publishAll` call
170
205
  avoids the problem because the store locks them in sorted order.
171
- - **Retention.** `purge` deletes completed (`completed_at < cutoff`) and dead (`updated_at < cutoff`) deliveries in
172
- batches of `purgeBatchSize`, each batch in its own short transaction with `SKIP LOCKED`, then deletes the events left
173
- without deliveries, including events created before the earliest cutoff you passed that never had one (a publish
174
- whose delivery was purged in the same instant, or an event with no subscriber). Configure it through the bus `retention` option. Delete rows through `purge` only; events are
206
+ - **Retention.** Completed deliveries are deleted when they complete, so `purge` has two jobs: it deletes dead
207
+ (`updated_at < cutoff`) deliveries, completion records (`completionHistory`) and `completed` rows left by 0.1.0 in
208
+ batches of `purgeBatchSize`, each batch in its own short transaction with `SKIP LOCKED`, and it deletes the events left
209
+ without deliveries or records, including events created before the earliest cutoff you passed that never had one (a publish
210
+ whose delivery was purged in the same instant, or an event with no subscriber). Configure it through the bus `retention` option (`retention.completed` is therefore the horizon of event deduplication and of the events' storage). Delete rows through `purge` only; events are
175
211
  removed by cascade or by purge, never by hand while publishers are active.
212
+ - **Deduplication horizon.** An event remembers its handlers only as long as the event row exists. Once `purge` deletes
213
+ the event (older than `retention.completed` and without deliveries), a broker redelivery or publish retry with the same
214
+ id is a new event and runs its handlers again; keep `retention.completed` longer than any redelivery window.
176
215
  - **Lock duration.** A publisher holds its key's counter row until commit. Keep transactions that publish keyed events
177
216
  short; unkeyed events take no such lock.
178
217
  - **Key counters.** `key_sequences` keeps one row per key ever published and rows are never deleted; the table grows
@@ -183,10 +222,47 @@ same on every database.
183
222
  lone UTF-16 surrogate is rejected by PostgreSQL and fails the publish with a database error; strip or encode such
184
223
  text before publishing.
185
224
  - **Sequence gaps.** Republishing an existing event id consumes a sequence number. Gaps are normal; only order matters.
225
+ - **Vacuum.** Completing a delivery deletes its row, which leaves a dead tuple until autovacuum runs. The tables are
226
+ created with `autovacuum_vacuum_scale_factor = 0.02`; for sustained throughput above a few thousand deliveries per
227
+ second also lower `autovacuum_vacuum_cost_delay` for this table (or run a periodic `VACUUM`), and watch
228
+ `pg_stat_user_tables.n_dead_tup`. The lane table is vacuumed by HOT pruning alone.
229
+ - **Lost answers and stale reads.** A claim is one statement. If the connection fails after the database recorded the
230
+ batch but before the answer arrived, the store gives back every delivery of its own lanes that it never handed out (it remembers what it
231
+ handed out until the dispatcher settled it). A claim that waited for a lane lock re-runs itself when the lane changed meanwhile, so it
232
+ never claims from a snapshot older than the lane it got. The dispatcher retries a completion, reschedule, bury or release that the store
233
+ did not answer until it lands or the lease is gone, so a delivery cannot stay in flight under a lane that other deliveries keep renewing.
234
+ - **Lane exclusivity.** While a worker holds a lane under a live lease, other workers do not see its pending
235
+ deliveries, even when they sit idle. A worker that dies delays its lanes by at most the lease duration
236
+ (`dispatcher.leaseDuration`); a worker that stops gracefully gives them back immediately. Lanes are chosen
237
+ per claim, so a busy backlog spreads over workers; a few hot keys live in few lanes and run on few workers by
238
+ design (they are ordered).
239
+ - **Round trips.** A claim is normally one statement: it claims from the lanes the worker holds and takes free lanes in
240
+ the same statement. Only taking over a lane whose previous holder died with deliveries running (their lost attempts
241
+ are charged first) continues in a short transaction. Publishing new events is one statement per `append`, whatever
242
+ the batch size; adding a handler to an event that already exists costs two more.
243
+ - **Wake-ups.** With `wakeUp: 'notify'` (the default) a publish that inserted a delivery due now notifies the
244
+ dispatchers (none for delayed deliveries; PostgreSQL merges repeats within a transaction and drops them on
245
+ rollback). A store sends at most one notification per 10 ms from its publishing statements; publishes in between
246
+ leave one trailing notification that the store sends after their commit, so concurrent publishers do not queue
247
+ behind PostgreSQL's notification lock. A dispatcher on a store built from `pool` or `connectionString` listens on
248
+ that channel (`<schema>.<prefix>deliveries`, hashed when longer than 63 characters) on one connection of its own,
249
+ opened with the pool's settings outside the pool, so an event published by any process runs within milliseconds
250
+ instead of at the next poll. A lost listening connection is replaced after a second; polling remains the safety
251
+ net, so a missed notification only costs latency. A store built on an `executor` cannot listen and relies on
252
+ polling (and on the wake-up of its own process after a publish). `wakeUp: 'poll'` sends and listens for nothing.
253
+ - **Prepared statements.** With `preparedStatements: true` (the default) the publish, claim, completion and renewal
254
+ statements are named prepared statements: each connection parses and plans them once. Only `PgExecutor` (the
255
+ default `pool` / `connectionString` path) prepares; the library adapters send every statement unprepared. See
256
+ `docs/performance/publish-path.md` for what that saves.
186
257
  - **Pooling.** Claim, complete, extend and purge need a free connection besides your transactions; size the pool for
187
- `dispatcher.concurrency` plus publishers. With PgBouncer use session or transaction pooling; no session state is used.
258
+ `dispatcher.concurrency` plus publishers; the listening connection comes on top of the pool's `max`. Listening and
259
+ prepared statements need a session: with PgBouncer in transaction pooling use `wakeUp: 'poll'`, and
260
+ `preparedStatements: false` unless PgBouncer is 1.21 or newer with `max_prepared_statements` above 0.
188
261
  - **Handlers added later.** A delivery added to an old event (new handler, republish with the same id) keeps the
189
262
  event's position in its key; if later events of that handler already finished, it runs after them.
263
+ - **Listing completed deliveries.** Without `completionHistory`, `listDeliveries({ status: 'completed' })` is empty and
264
+ `statistics().completed` is 0 (rows left by 0.1.0 excepted). With it, every completion also inserts one row into
265
+ `completed_deliveries` (one more heap and index write per delivery); use it for audits, not for throughput.
190
266
 
191
267
  ## Troubleshooting
192
268
 
@@ -199,7 +275,9 @@ same on every database.
199
275
  - Foreign key error on append: the only known cause is republishing an event id in the same instant its last delivery
200
276
  is purged. The publishing transaction rolls back and can simply be retried.
201
277
  - A key looks stuck: a pending or processing head blocks it. Inspect `listDeliveries({ status: 'processing' })` and
202
- `requeue` dead heads; expired leases are reclaimed automatically.
278
+ `requeue` dead heads; expired lane leases are taken over automatically.
279
+ - Deliveries wait although workers are idle: another worker holds their lane under a live lease (look at
280
+ `<prefix>lanes.lease_owner` and `lease_expires_at`); it is released when that worker finishes, stops, or the lease expires.
203
281
  - `deadlock detected` (`40P01`) or `could not serialize access` (`40001`) while publishing: see Deadlocks and
204
282
  Isolation under Operations; retry the transaction.
205
283
 
@@ -208,3 +286,28 @@ same on every database.
208
286
  Integration tests use Testcontainers against `postgres:17-alpine` and `postgres:13-alpine` (override with
209
287
  `RELAYBUS_POSTGRES_IMAGES`). Every adapter except Prisma runs the full `@relaybus/testing` event store contract plus
210
288
  transaction tests against the real database.
289
+
290
+ ## When to use @relaybus/postgres, and when not to
291
+
292
+ Use it when your data lives in PostgreSQL 13 or newer and events must commit or roll back with it. It is not for MySQL or MongoDB (see `@relaybus/mongodb` for MongoDB), and the Prisma adapter is typed and unit-tested but not yet integration-tested.
293
+
294
+ Documentation: <https://berkcansavur.github.io/relaybus/packages/postgres>
295
+
296
+ ### relaybus vs alternatives
297
+
298
+ <!-- relaybus:snippet:comparison -->
299
+
300
+ | | What it is | Survives a restart | Written in your database transaction | Choose it when |
301
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
302
+ | **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 |
303
+ | [`@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 |
304
+ | [`@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 |
305
+ | [`@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 |
306
+ | [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 |
307
+ | [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 |
308
+ | 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 |
309
+
310
+ This table summarises the vendors' own documentation as of October 2026; check their docs for current details.
311
+ Full comparison and sources: <https://berkcansavur.github.io/relaybus/comparison>.
312
+
313
+ <!-- relaybus:end -->
@@ -1,5 +1,5 @@
1
1
  import { SQL } from 'drizzle-orm';
2
- import { P as PostgresExecutor } from './executor-CSM7ZOhz.cjs';
2
+ import { P as PostgresExecutor } from './executor-VZGmsMX4.cjs';
3
3
 
4
4
  /** What a Drizzle database and a Drizzle transaction have in common. */
5
5
  interface DrizzleQueryable {
package/dist/drizzle.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SQL } from 'drizzle-orm';
2
- import { P as PostgresExecutor } from './executor-CSM7ZOhz.js';
2
+ import { P as PostgresExecutor } from './executor-VZGmsMX4.js';
3
3
 
4
4
  /** What a Drizzle database and a Drizzle transaction have in common. */
5
5
  interface DrizzleQueryable {
@@ -1,6 +1,15 @@
1
1
  /** Values the store binds: always text, numbers or null (documents travel as JSON text). */
2
2
  type PostgresParameter = string | number | null;
3
3
  type PostgresRow = Readonly<Record<string, unknown>>;
4
+ /** Hints about one statement. An executor may ignore them. */
5
+ interface PostgresQueryOptions {
6
+ /**
7
+ * The text never changes and the store sends it again and again (publish, claim, complete): an
8
+ * executor that can keep it as a prepared statement per connection should, so the server parses and
9
+ * plans it once instead of on every call. `PgExecutor` does.
10
+ */
11
+ readonly prepare?: boolean;
12
+ }
4
13
  /**
5
14
  * The only thing {@link PostgresEventStore} needs from a database library:
6
15
  * run a parameterized statement, optionally on a transaction handle, and open
@@ -15,10 +24,11 @@ type PostgresRow = Readonly<Record<string, unknown>>;
15
24
  * - Without a `transaction`, a statement runs on its own connection in
16
25
  * autocommit mode, in READ COMMITTED isolation.
17
26
  * - `transaction` commits when `work` resolves and rolls back when it rejects.
27
+ * - `options` are hints ({@link PostgresQueryOptions}); ignoring them is correct, only slower.
18
28
  */
19
29
  interface PostgresExecutor<TTransaction> {
20
30
  transaction<T>(work: (transaction: TTransaction) => Promise<T>): Promise<T>;
21
- query(sql: string, parameters: readonly PostgresParameter[], transaction?: TTransaction): Promise<readonly PostgresRow[]>;
31
+ query(sql: string, parameters: readonly PostgresParameter[], transaction?: TTransaction, options?: PostgresQueryOptions): Promise<readonly PostgresRow[]>;
22
32
  }
23
33
 
24
- export type { PostgresExecutor as P, PostgresParameter as a, PostgresRow as b };
34
+ export type { PostgresExecutor as P, PostgresParameter as a, PostgresQueryOptions as b, PostgresRow as c };
@@ -1,6 +1,15 @@
1
1
  /** Values the store binds: always text, numbers or null (documents travel as JSON text). */
2
2
  type PostgresParameter = string | number | null;
3
3
  type PostgresRow = Readonly<Record<string, unknown>>;
4
+ /** Hints about one statement. An executor may ignore them. */
5
+ interface PostgresQueryOptions {
6
+ /**
7
+ * The text never changes and the store sends it again and again (publish, claim, complete): an
8
+ * executor that can keep it as a prepared statement per connection should, so the server parses and
9
+ * plans it once instead of on every call. `PgExecutor` does.
10
+ */
11
+ readonly prepare?: boolean;
12
+ }
4
13
  /**
5
14
  * The only thing {@link PostgresEventStore} needs from a database library:
6
15
  * run a parameterized statement, optionally on a transaction handle, and open
@@ -15,10 +24,11 @@ type PostgresRow = Readonly<Record<string, unknown>>;
15
24
  * - Without a `transaction`, a statement runs on its own connection in
16
25
  * autocommit mode, in READ COMMITTED isolation.
17
26
  * - `transaction` commits when `work` resolves and rolls back when it rejects.
27
+ * - `options` are hints ({@link PostgresQueryOptions}); ignoring them is correct, only slower.
18
28
  */
19
29
  interface PostgresExecutor<TTransaction> {
20
30
  transaction<T>(work: (transaction: TTransaction) => Promise<T>): Promise<T>;
21
- query(sql: string, parameters: readonly PostgresParameter[], transaction?: TTransaction): Promise<readonly PostgresRow[]>;
31
+ query(sql: string, parameters: readonly PostgresParameter[], transaction?: TTransaction, options?: PostgresQueryOptions): Promise<readonly PostgresRow[]>;
22
32
  }
23
33
 
24
- export type { PostgresExecutor as P, PostgresParameter as a, PostgresRow as b };
34
+ export type { PostgresExecutor as P, PostgresParameter as a, PostgresQueryOptions as b, PostgresRow as c };