@relaybus/postgres 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,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
 
@@ -48,7 +50,9 @@ try {
48
50
  ```
49
51
 
50
52
  The transaction handle is a `pg` `PoolClient`. If you pass `connectionString` instead of `pool`, the store creates and,
51
- on `close()`, ends its own pool. A pool or executor you pass in is never closed by the store.
53
+ on `close()`, ends its own pool. A pool or executor you pass in is never closed by the store. When `bus.stop()` runs past
54
+ its deadline, the store destroys the connections it opened itself (its own pool's and the one it listens for
55
+ notifications on), so a query stuck on a lock fails at once instead of holding the shutdown; your pool is left as is.
52
56
 
53
57
  ### Other libraries
54
58
 
@@ -76,23 +80,48 @@ await db.transaction().execute(async (trx) => {
76
80
  Use `transactionResolver` (for example from `@nestjs-cls/transactional`) to avoid passing the handle explicitly.
77
81
  For another client, implement `PostgresExecutor` (`transaction` and `query`; about 20 lines).
78
82
 
83
+ **Transaction time budget.** `bus.transaction` passes the bus `transactionTimeout` (default `'5s'`) to the store,
84
+ which sets it with `set_config(..., true)` (the `SET LOCAL` scope, gone after the transaction) as
85
+ `statement_timeout`. Executors that declare `sessionTimeouts: true` (`PgExecutor` does; it listens for `error` on the
86
+ connection while the transaction runs) also get `idle_in_transaction_session_timeout` and, on PostgreSQL 17+,
87
+ `transaction_timeout`, so a transaction left idle by a slow external call is ended by the server and its locks are
88
+ released. The other adapters keep only `statement_timeout`, because a server-ended session would emit an unhandled
89
+ `error` on their checked-out `pg` connection; the bus still rolls back an overrun before the commit. The caller gets a
90
+ `TransactionTimeoutError` with the server error as `cause`. `PgExecutor` sends the settings in the round trip of `BEGIN`
91
+ (the optional executor method `transactionWithBudget`), so the budget costs no extra round trip; executors without it
92
+ send them as the first statement of the transaction.
93
+
94
+ Where the budget lives depends on who owns the pool:
95
+
96
+ - **A pool the store created** (`connectionString`/`poolConfig`): each connection keeps the budget as a session setting.
97
+ The first `bus.transaction` on a connection sets it with `BEGIN`; later ones send nothing extra, so a publish costs
98
+ what it did without a budget. A transaction without a budget (setup, a transactional handler) puts the session
99
+ defaults back in its `BEGIN`. Statements outside a transaction (claims, completions) run under the budget the
100
+ connection last got. With `preparedStatements: false` (a pooler in transaction mode, which would carry session
101
+ settings to other clients) the budget stays transaction-scoped.
102
+ - **A pool or executor you pass in**: the budget is transaction-scoped (`SET LOCAL` semantics), set with `BEGIN` by
103
+ `PgExecutor` and by its own statement for the other adapters. Session settings of your pool are never changed.
104
+
79
105
  **Prisma is not integration-tested yet.** The adapter is typed structurally (no Prisma dependency) and unit-tested
80
106
  against a fake client only. Prisma interactive transactions default to a 5 s timeout, so pass
81
107
  `createPrismaExecutor(prisma, { timeout, maxWait })` if handlers run longer.
82
108
 
83
109
  ## Options
84
110
 
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. |
111
+ | Option | Default | Meaning |
112
+ | ---------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
113
+ | `pool` / `connectionString` / `executor` | required, exactly one | How to reach the database. |
114
+ | `poolConfig` | none | Extra `pg` pool settings when `connectionString` is used. |
115
+ | `schema` | `'public'` | Schema of the tables. Created by `setup()` when it is not `public`. |
116
+ | `tablePrefix` | `'relaybus_'` | Tables are `<prefix>events`, `<prefix>deliveries`, `<prefix>key_sequences`, `<prefix>lanes`. |
117
+ | `completionHistory` | `false` | Keep an append-only record of completed deliveries (`<prefix>completed_deliveries`). Same value in every process. |
118
+ | `purgeBatchSize` | `1000` | Deliveries deleted per transaction by `purge`. |
119
+ | `logger` | `console` | Receives idle-connection errors of a pool the store created itself. |
120
+ | `preparedStatements` | `true` | Prepare the statements the store repeats once per connection. `false` behind PgBouncer < 1.21 in transaction mode. |
121
+ | `wakeUp` | `'notify'` | `'notify'`: coalesced `NOTIFY` + `LISTEN` wake other processes. `'poll'`: no notifications, other processes poll. |
93
122
 
94
123
  **Pool errors.** A pool created from `connectionString` gets an `error` listener that logs through `logger`
95
- (`[PostgresEventStore](constructor) Idle connection failed ...`); `pg` then replaces the dead connection, so a failover
124
+ (`[PostgresEventStore](connect) Idle connection failed ...`); `pg` then replaces the dead connection, so a failover
96
125
  or `pg_terminate_backend` of an idle connection does not crash the process. A `pool` you pass in is yours: attach
97
126
  `pool.on('error', ...)` to it yourself, because without a listener `pg` re-throws the error as an uncaught exception.
98
127
 
@@ -112,19 +141,39 @@ import { createPostgresSchemaSql, createPostgresSchemaStatements } from '@relayb
112
141
  const migration = createPostgresSchemaSql({ schema: 'app', tablePrefix: 'bus_' });
113
142
  ```
114
143
 
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.
144
+ | Table | Purpose |
145
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | `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). |
147
+ | `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`. |
148
+ | `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). |
149
+ | `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. |
150
+ | `key_sequences` | One counter row per ordering key. |
151
+ | `completed_deliveries` | Only with `completionHistory`: one insert-only row per completed delivery. |
152
+
153
+ A delivery row is inserted once and deleted once when it completes. Claiming, renewing a lease and completing never
154
+ update it: they update the lane row (claim, renewal) or delete the delivery (completion), so the indexed columns of a
155
+ delivery are written exactly once, at publish. Indexes on `deliveries`: `(lane, available_at, id) WHERE status = 'pending'`
156
+ (claim and statistics), `(lane, handler, key, sequence) WHERE key IS NOT NULL AND status = 'pending'` (ordering check
157
+ and the blocked-key claim), the unique `(event_id, handler)`, and partial indexes for purging dead deliveries (and
158
+ completed ones left by 0.1.0); on `events`, `(created_at, id)` for the purge sweep. `listDeliveries` has no index of its
159
+ own: it orders by `created_at`, so on a large `deliveries` table combine it with `status`/`handler`, which keeps the scan
160
+ to the dead or pending rows, or add the index you need. `deliveries.id` uses the `C` collation so id tie-breaks are the
161
+ same on every database. The tables are created with aggressive autovacuum settings
162
+ (`autovacuum_vacuum_scale_factor = 0.02`): deleted deliveries leave dead tuples that autovacuum reclaims.
163
+
164
+ ## Upgrading from 0.1.0
165
+
166
+ The step-by-step guide, including `completionHistory` and PgBouncer settings, is [Upgrading from 0.1 to 0.2](https://berkcansavur.github.io/relaybus/upgrading).
167
+
168
+ `setup()` upgrades a 0.1.0 schema in place, in one transaction under the same advisory lock, and is safe to run again:
169
+ it adds `lane` to every delivery (hash of the key, or of the event id) and `handlers` to every event (from the
170
+ existing deliveries), turns `processing` deliveries into `pending` (their attempt stays counted, as a lost lease would
171
+ count it), drops the per-delivery lease columns and the indexes that no longer exist, and creates the lane table.
172
+ Deliveries that were `completed` stay as `completed` rows until `retention.completed` purges them (or forever with
173
+ `retention: { completed: false }`). **Stop every 0.1.0 worker before running it** (they cannot use the new tables), and
174
+ expect the one-off rewrite of `deliveries` (an `UPDATE` of every row plus `SET NOT NULL`) to take time proportional to
175
+ its size; purge old completed rows first to shorten it. If you apply `createPostgresSchemaSql()` with your own migration
176
+ tool, run it as one transaction with the workers stopped. Running 0.1.0 and the new version together is not supported.
128
177
 
129
178
  ## Guarantees
130
179
 
@@ -136,30 +185,42 @@ same on every database.
136
185
  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
186
  is pending or processing, so one key is processed one event at a time, in order. A retrying or leased head blocks its
138
187
  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.
188
+ - **Lane leases.** Every delivery belongs to one of 256 lanes: a hash of its ordering key, or of its event id when it
189
+ has none, so all deliveries of a key share a lane. Workers lease lanes, not rows. A claim runs in one short
190
+ transaction: it locks the lane rows this worker already holds, takes free lanes that have due work (four, then eight,
191
+ ... lanes at a time, so a worker holds only as many lanes as its batch needs, oldest due delivery first), reads the due
192
+ deliveries of those lanes and writes **one** lane row per lane to record the batch (`in_flight`). It writes no delivery
193
+ row and takes no row lock on one: exclusivity comes from the lease. A lane taken from another worker gets a new
194
+ `epoch`; lanes that yielded nothing are handed back at once. While a lane lease is live, other workers do not see the
195
+ pending deliveries of that lane. The lease is renewed with the running deliveries (`extendLease` updates the lane
196
+ row) and handed back at graceful shutdown (`relinquish`).
197
+ - **Per-key order under lanes.** A keyed delivery is claimed only when no earlier delivery of its handler and key is
198
+ pending (a running one is still a pending row) and none of its handler and key is in flight, and the lane belongs to
199
+ one worker, so a key is processed one event at a time, in order. A retrying or running head blocks its successors; a
200
+ dead-lettered head releases them. Claiming first looks at the 256 earliest due deliveries of the lanes it holds
201
+ (cheap); only when those are mostly followers of blocked keys and fewer than `limit` were picked does a second
202
+ statement find the head of every key with `DISTINCT ON (handler, key)`. The stress test in
203
+ `test/claim-concurrency.integration.test.ts` runs 12 claimers on 3 pools against 30 keys and fails if a key is ever in
204
+ flight twice or out of order.
205
+ - **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
206
+ `extendLease` apply only while the lane still has that epoch and lists the delivery as in flight; a worker that lost
207
+ its lane changes nothing. Completion takes a `FOR KEY SHARE` lock on the lane row until the transaction ends, so a takeover
208
+ (which locks the row `FOR UPDATE`) and a transactional handler's completion cannot both succeed: the handler's writes
209
+ and the deletion of its delivery commit together, or the takeover redelivers it.
210
+ - **Attempts survive crashes.** An attempt is recorded when it ends: a failure adds one to `attempts` (`reschedule`,
211
+ `bury`), a graceful `release` adds nothing, and a lane that is taken over charges one attempt to every delivery its
212
+ previous holder still had in flight. A worker that dies on a poison message therefore still runs it into the retry
213
+ limit and the dead-letter table.
214
+ - **No double run after completion.** An event lists every handler it ever had a delivery for, so republishing an
215
+ event id (or a broker redelivering it) creates deliveries only for handlers that never had one, even though completed
216
+ deliveries are deleted. This holds while the event row exists (`retention.completed`).
156
217
  - **App clock.** Every timestamp is the `now` the caller passes, never the database clock.
157
218
  - **Driver independent reads.** Timestamps and JSON are read as text and parsed by the store, so custom `pg` type
158
219
  parsers (or Drizzle's) cannot change results.
159
220
 
160
221
  ## Operations
161
222
 
162
- - **Isolation.** Claiming runs as autocommit statements and relies on `READ COMMITTED` (the PostgreSQL default). Do
223
+ - **Isolation.** Claiming runs as a short transaction and relies on `READ COMMITTED` (the PostgreSQL default). Do
163
224
  not change `default_transaction_isolation` for the relaybus pool. Your own publishing transaction may use a stricter
164
225
  level, but under `REPEATABLE READ` or `SERIALIZABLE` PostgreSQL can abort it with `40001` (serialization failure);
165
226
  retry the whole transaction.
@@ -168,11 +229,15 @@ same on every database.
168
229
  other) can deadlock; PostgreSQL aborts one with `40P01`. The aborted transaction rolls back completely, nothing is
169
230
  lost or half-written, and the caller should retry it. Publishing all keys of a transaction in one `publishAll` call
170
231
  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
232
+ - **Retention.** Completed deliveries are deleted when they complete, so `purge` has two jobs: it deletes dead
233
+ (`updated_at < cutoff`) deliveries, completion records (`completionHistory`) and `completed` rows left by 0.1.0 in
234
+ batches of `purgeBatchSize`, each batch in its own short transaction with `SKIP LOCKED`, and it deletes the events left
235
+ without deliveries or records, including events created before the earliest cutoff you passed that never had one (a publish
236
+ 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
237
  removed by cascade or by purge, never by hand while publishers are active.
238
+ - **Deduplication horizon.** An event remembers its handlers only as long as the event row exists. Once `purge` deletes
239
+ the event (older than `retention.completed` and without deliveries), a broker redelivery or publish retry with the same
240
+ id is a new event and runs its handlers again; keep `retention.completed` longer than any redelivery window.
176
241
  - **Lock duration.** A publisher holds its key's counter row until commit. Keep transactions that publish keyed events
177
242
  short; unkeyed events take no such lock.
178
243
  - **Key counters.** `key_sequences` keeps one row per key ever published and rows are never deleted; the table grows
@@ -183,10 +248,47 @@ same on every database.
183
248
  lone UTF-16 surrogate is rejected by PostgreSQL and fails the publish with a database error; strip or encode such
184
249
  text before publishing.
185
250
  - **Sequence gaps.** Republishing an existing event id consumes a sequence number. Gaps are normal; only order matters.
251
+ - **Vacuum.** Completing a delivery deletes its row, which leaves a dead tuple until autovacuum runs. The tables are
252
+ created with `autovacuum_vacuum_scale_factor = 0.02`; for sustained throughput above a few thousand deliveries per
253
+ second also lower `autovacuum_vacuum_cost_delay` for this table (or run a periodic `VACUUM`), and watch
254
+ `pg_stat_user_tables.n_dead_tup`. The lane table is vacuumed by HOT pruning alone.
255
+ - **Lost answers and stale reads.** A claim is one statement. If the connection fails after the database recorded the
256
+ 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
257
+ 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
258
+ never claims from a snapshot older than the lane it got. The dispatcher retries a completion, reschedule, bury or release that the store
259
+ 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.
260
+ - **Lane exclusivity.** While a worker holds a lane under a live lease, other workers do not see its pending
261
+ deliveries, even when they sit idle. A worker that dies delays its lanes by at most the lease duration
262
+ (`dispatcher.leaseDuration`); a worker that stops gracefully gives them back immediately. Lanes are chosen
263
+ per claim, so a busy backlog spreads over workers; a few hot keys live in few lanes and run on few workers by
264
+ design (they are ordered).
265
+ - **Round trips.** A claim is normally one statement: it claims from the lanes the worker holds and takes free lanes in
266
+ the same statement. Only taking over a lane whose previous holder died with deliveries running (their lost attempts
267
+ are charged first) continues in a short transaction. Publishing new events is one statement per `append`, whatever
268
+ the batch size; adding a handler to an event that already exists costs two more.
269
+ - **Wake-ups.** With `wakeUp: 'notify'` (the default) a publish that inserted a delivery due now notifies the
270
+ dispatchers (none for delayed deliveries; PostgreSQL merges repeats within a transaction and drops them on
271
+ rollback). A store sends at most one notification per 10 ms from its publishing statements; publishes in between
272
+ leave one trailing notification that the store sends after their commit, so concurrent publishers do not queue
273
+ behind PostgreSQL's notification lock. A dispatcher on a store built from `pool` or `connectionString` listens on
274
+ that channel (`<schema>.<prefix>deliveries`, hashed when longer than 63 characters) on one connection of its own,
275
+ opened with the pool's settings outside the pool, so an event published by any process runs within milliseconds
276
+ instead of at the next poll. A lost listening connection is replaced after a second; polling remains the safety
277
+ net, so a missed notification only costs latency. A store built on an `executor` cannot listen and relies on
278
+ polling (and on the wake-up of its own process after a publish). `wakeUp: 'poll'` sends and listens for nothing.
279
+ - **Prepared statements.** With `preparedStatements: true` (the default) the publish, claim, completion and renewal
280
+ statements are named prepared statements: each connection parses and plans them once. Only `PgExecutor` (the
281
+ default `pool` / `connectionString` path) prepares; the library adapters send every statement unprepared. See
282
+ `docs/performance/publish-path.md` for what that saves.
186
283
  - **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.
284
+ `dispatcher.concurrency` plus publishers; the listening connection comes on top of the pool's `max`. Listening and
285
+ prepared statements need a session: with PgBouncer in transaction pooling use `wakeUp: 'poll'`, and
286
+ `preparedStatements: false` unless PgBouncer is 1.21 or newer with `max_prepared_statements` above 0.
188
287
  - **Handlers added later.** A delivery added to an old event (new handler, republish with the same id) keeps the
189
288
  event's position in its key; if later events of that handler already finished, it runs after them.
289
+ - **Listing completed deliveries.** Without `completionHistory`, `listDeliveries({ status: 'completed' })` is empty and
290
+ `statistics().completed` is 0 (rows left by 0.1.0 excepted). With it, every completion also inserts one row into
291
+ `completed_deliveries` (one more heap and index write per delivery); use it for audits, not for throughput.
190
292
 
191
293
  ## Troubleshooting
192
294
 
@@ -199,7 +301,9 @@ same on every database.
199
301
  - Foreign key error on append: the only known cause is republishing an event id in the same instant its last delivery
200
302
  is purged. The publishing transaction rolls back and can simply be retried.
201
303
  - 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.
304
+ `requeue` dead heads; expired lane leases are taken over automatically.
305
+ - Deliveries wait although workers are idle: another worker holds their lane under a live lease (look at
306
+ `<prefix>lanes.lease_owner` and `lease_expires_at`); it is released when that worker finishes, stops, or the lease expires.
203
307
  - `deadlock detected` (`40P01`) or `could not serialize access` (`40001`) while publishing: see Deadlocks and
204
308
  Isolation under Operations; retry the transaction.
205
309
 
@@ -208,3 +312,28 @@ same on every database.
208
312
  Integration tests use Testcontainers against `postgres:17-alpine` and `postgres:13-alpine` (override with
209
313
  `RELAYBUS_POSTGRES_IMAGES`). Every adapter except Prisma runs the full `@relaybus/testing` event store contract plus
210
314
  transaction tests against the real database.
315
+
316
+ ## When to use @relaybus/postgres, and when not to
317
+
318
+ 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.
319
+
320
+ Documentation: <https://berkcansavur.github.io/relaybus/packages/postgres>
321
+
322
+ ### relaybus vs alternatives
323
+
324
+ <!-- relaybus:snippet:comparison -->
325
+
326
+ | | What it is | Survives a restart | Written in your database transaction | Choose it when |
327
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
328
+ | **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 |
329
+ | [`@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 |
330
+ | [`@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 |
331
+ | [`@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 |
332
+ | [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 |
333
+ | [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 |
334
+ | 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 |
335
+
336
+ This table summarises the vendors' own documentation as of October 2026; check their docs for current details.
337
+ Full comparison and sources: <https://berkcansavur.github.io/relaybus/comparison>.
338
+
339
+ <!-- relaybus:end -->
package/dist/drizzle.cjs CHANGED
@@ -20,6 +20,7 @@ function splitPositionalSql(sql) {
20
20
  function createDrizzleExecutor(database) {
21
21
  return {
22
22
  transaction: (work) => database.transaction(work),
23
+ // eslint-disable-next-line relaybus/max-two-params -- PostgresExecutor port signature
23
24
  async query(sql, parameters, transaction) {
24
25
  const chunks = splitPositionalSql(sql).map(
25
26
  (part) => typeof part === "string" ? drizzleOrm.sql.raw(part) : drizzleOrm.sql.param(parameters[part.parameterIndex])
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/positional-sql.ts","../src/drizzle/drizzle-executor.ts"],"names":["drizzleSql"],"mappings":";;;;;;;AASO,SAAS,mBAAmB,GAAA,EAAkC;AACnE,EAAA,MAAM,QAA6B,EAAC;AACpC,EAAA,IAAI,SAAA,GAAY,CAAA;AAChB,EAAA,KAAA,MAAW,KAAA,IAAS,GAAA,CAAI,QAAA,CAAS,UAAU,CAAA,EAAG;AAC5C,IAAA,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,SAAA,EAAW,MAAM,KAAK,CAAA,EAAG,EAAE,cAAA,EAAgB,OAAO,KAAA,CAAM,CAAC,CAAC,CAAA,GAAI,GAAG,CAAA;AACtF,IAAA,SAAA,GAAY,KAAA,CAAM,KAAA,GAAQ,KAAA,CAAM,CAAC,CAAA,CAAE,MAAA;AAAA,EACrC;AACA,EAAA,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,SAAS,CAAC,CAAA;AAC/B,EAAA,OAAO,KAAA;AACT;;;ACAO,SAAS,sBAAsB,QAAA,EAA2D;AAC/F,EAAA,OAAO;AAAA,IACL,WAAA,EAAa,CAAC,IAAA,KAAS,QAAA,CAAS,YAAY,IAAI,CAAA;AAAA,IAChD,MAAM,KAAA,CAAM,GAAA,EAAK,UAAA,EAA0C,WAAA,EAAa;AACtE,MAAA,MAAM,MAAA,GAAS,kBAAA,CAAmB,GAAG,CAAA,CAAE,GAAA;AAAA,QAAI,CAAC,IAAA,KAC1C,OAAO,IAAA,KAAS,WACZA,cAAA,CAAW,GAAA,CAAI,IAAI,CAAA,GACnBA,cAAA,CAAW,KAAA,CAAM,UAAA,CAAW,IAAA,CAAK,cAAc,CAAC;AAAA,OACtD;AACA,MAAA,MAAM,MAAA,GAAS,OAAO,WAAA,IAAe,QAAA,EAAU,QAAQA,cAAA,CAAW,IAAA,CAAK,MAAM,CAAC,CAAA;AAC9E,MAAA,OAAO,MAAA,CAAO,IAAA;AAAA,IAChB;AAAA,GACF;AACF","file":"drizzle.cjs","sourcesContent":["/** A piece of SQL text, or a reference to the (zero-based) parameter that stands at this position. */\nexport type PositionalSqlPart = string | { readonly parameterIndex: number };\n\n/**\n * Splits SQL written with PostgreSQL `$1, $2, ...` placeholders into text and\n * parameter references, in order of appearance. A placeholder used twice\n * appears twice. Adapters for libraries with their own placeholder syntax\n * (Knex `?`, Drizzle parameter chunks) rebuild the statement from the parts.\n */\nexport function splitPositionalSql(sql: string): PositionalSqlPart[] {\n const parts: PositionalSqlPart[] = [];\n let textStart = 0;\n for (const match of sql.matchAll(/\\$(\\d+)/g)) {\n parts.push(sql.slice(textStart, match.index), { parameterIndex: Number(match[1]) - 1 });\n textStart = match.index + match[0].length;\n }\n parts.push(sql.slice(textStart));\n return parts;\n}\n","import { sql as drizzleSql, type SQL } from 'drizzle-orm';\nimport type { PostgresExecutor, PostgresParameter } from '../executor.js';\nimport { splitPositionalSql } from '../positional-sql.js';\n\n/** What a Drizzle database and a Drizzle transaction have in common. */\nexport interface DrizzleQueryable {\n execute(query: SQL): PromiseLike<{ readonly rows: Record<string, unknown>[] }>;\n}\n\n/** The part of a Drizzle `node-postgres` database the executor uses. */\nexport interface DrizzleLike extends DrizzleQueryable {\n transaction<T>(work: (transaction: DrizzleQueryable) => Promise<T>): Promise<T>;\n}\n\n/**\n * Executor for Drizzle with the `node-postgres` driver. The transaction handle\n * is a Drizzle transaction: pass `tx` from `db.transaction(async (tx) => ...)`.\n */\nexport function createDrizzleExecutor(database: DrizzleLike): PostgresExecutor<DrizzleQueryable> {\n return {\n transaction: (work) => database.transaction(work),\n async query(sql, parameters: readonly PostgresParameter[], transaction) {\n const chunks = splitPositionalSql(sql).map((part) =>\n typeof part === 'string'\n ? drizzleSql.raw(part)\n : drizzleSql.param(parameters[part.parameterIndex]),\n );\n const result = await (transaction ?? database).execute(drizzleSql.join(chunks));\n return result.rows;\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/positional-sql.ts","../src/drizzle/drizzle-executor.ts"],"names":["drizzleSql"],"mappings":";;;;;;;AASO,SAAS,mBAAmB,GAAA,EAAkC;AACnE,EAAA,MAAM,QAA6B,EAAC;AACpC,EAAA,IAAI,SAAA,GAAY,CAAA;AAChB,EAAA,KAAA,MAAW,KAAA,IAAS,GAAA,CAAI,QAAA,CAAS,UAAU,CAAA,EAAG;AAC5C,IAAA,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,SAAA,EAAW,MAAM,KAAK,CAAA,EAAG,EAAE,cAAA,EAAgB,OAAO,KAAA,CAAM,CAAC,CAAC,CAAA,GAAI,GAAG,CAAA;AACtF,IAAA,SAAA,GAAY,KAAA,CAAM,KAAA,GAAQ,KAAA,CAAM,CAAC,CAAA,CAAE,MAAA;AAAA,EACrC;AACA,EAAA,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,SAAS,CAAC,CAAA;AAC/B,EAAA,OAAO,KAAA;AACT;;;ACAO,SAAS,sBAAsB,QAAA,EAA2D;AAC/F,EAAA,OAAO;AAAA,IACL,WAAA,EAAa,CAAC,IAAA,KAAS,QAAA,CAAS,YAAY,IAAI,CAAA;AAAA;AAAA,IAEhD,MAAM,KAAA,CAAM,GAAA,EAAK,UAAA,EAA0C,WAAA,EAAa;AACtE,MAAA,MAAM,MAAA,GAAS,kBAAA,CAAmB,GAAG,CAAA,CAAE,GAAA;AAAA,QAAI,CAAC,IAAA,KAC1C,OAAO,IAAA,KAAS,WACZA,cAAA,CAAW,GAAA,CAAI,IAAI,CAAA,GACnBA,cAAA,CAAW,KAAA,CAAM,UAAA,CAAW,IAAA,CAAK,cAAc,CAAC;AAAA,OACtD;AACA,MAAA,MAAM,MAAA,GAAS,OAAO,WAAA,IAAe,QAAA,EAAU,QAAQA,cAAA,CAAW,IAAA,CAAK,MAAM,CAAC,CAAA;AAC9E,MAAA,OAAO,MAAA,CAAO,IAAA;AAAA,IAChB;AAAA,GACF;AACF","file":"drizzle.cjs","sourcesContent":["/** A piece of SQL text, or a reference to the (zero-based) parameter that stands at this position. */\nexport type PositionalSqlPart = string | { readonly parameterIndex: number };\n\n/**\n * Splits SQL written with PostgreSQL `$1, $2, ...` placeholders into text and\n * parameter references, in order of appearance. A placeholder used twice\n * appears twice. Adapters for libraries with their own placeholder syntax\n * (Knex `?`, Drizzle parameter chunks) rebuild the statement from the parts.\n */\nexport function splitPositionalSql(sql: string): PositionalSqlPart[] {\n const parts: PositionalSqlPart[] = [];\n let textStart = 0;\n for (const match of sql.matchAll(/\\$(\\d+)/g)) {\n parts.push(sql.slice(textStart, match.index), { parameterIndex: Number(match[1]) - 1 });\n textStart = match.index + match[0].length;\n }\n parts.push(sql.slice(textStart));\n return parts;\n}\n","import { sql as drizzleSql, type SQL } from 'drizzle-orm';\nimport type { PostgresExecutor, PostgresParameter } from '../executor.js';\nimport { splitPositionalSql } from '../positional-sql.js';\n\n/** What a Drizzle database and a Drizzle transaction have in common. */\nexport interface DrizzleQueryable {\n execute(query: SQL): PromiseLike<{ readonly rows: Record<string, unknown>[] }>;\n}\n\n/** The part of a Drizzle `node-postgres` database the executor uses. */\nexport interface DrizzleLike extends DrizzleQueryable {\n transaction<T>(work: (transaction: DrizzleQueryable) => Promise<T>): Promise<T>;\n}\n\n/**\n * Executor for Drizzle with the `node-postgres` driver. The transaction handle\n * is a Drizzle transaction: pass `tx` from `db.transaction(async (tx) => ...)`.\n */\nexport function createDrizzleExecutor(database: DrizzleLike): PostgresExecutor<DrizzleQueryable> {\n return {\n transaction: (work) => database.transaction(work),\n // eslint-disable-next-line relaybus/max-two-params -- PostgresExecutor port signature\n async query(sql, parameters: readonly PostgresParameter[], transaction) {\n const chunks = splitPositionalSql(sql).map((part) =>\n typeof part === 'string'\n ? drizzleSql.raw(part)\n : drizzleSql.param(parameters[part.parameterIndex]),\n );\n const result = await (transaction ?? database).execute(drizzleSql.join(chunks));\n return result.rows;\n },\n };\n}\n"]}
@@ -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-CfJgktC6.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-CfJgktC6.js';
3
3
 
4
4
  /** What a Drizzle database and a Drizzle transaction have in common. */
5
5
  interface DrizzleQueryable {
package/dist/drizzle.js CHANGED
@@ -4,6 +4,7 @@ import { sql } from 'drizzle-orm';
4
4
  function createDrizzleExecutor(database) {
5
5
  return {
6
6
  transaction: (work) => database.transaction(work),
7
+ // eslint-disable-next-line relaybus/max-two-params -- PostgresExecutor port signature
7
8
  async query(sql$1, parameters, transaction) {
8
9
  const chunks = splitPositionalSql(sql$1).map(
9
10
  (part) => typeof part === "string" ? sql.raw(part) : sql.param(parameters[part.parameterIndex])
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/drizzle/drizzle-executor.ts"],"names":["sql","drizzleSql"],"mappings":";;;AAkBO,SAAS,sBAAsB,QAAA,EAA2D;AAC/F,EAAA,OAAO;AAAA,IACL,WAAA,EAAa,CAAC,IAAA,KAAS,QAAA,CAAS,YAAY,IAAI,CAAA;AAAA,IAChD,MAAM,KAAA,CAAMA,KAAA,EAAK,UAAA,EAA0C,WAAA,EAAa;AACtE,MAAA,MAAM,MAAA,GAAS,kBAAA,CAAmBA,KAAG,CAAA,CAAE,GAAA;AAAA,QAAI,CAAC,IAAA,KAC1C,OAAO,IAAA,KAAS,WACZC,GAAA,CAAW,GAAA,CAAI,IAAI,CAAA,GACnBA,GAAA,CAAW,KAAA,CAAM,UAAA,CAAW,IAAA,CAAK,cAAc,CAAC;AAAA,OACtD;AACA,MAAA,MAAM,MAAA,GAAS,OAAO,WAAA,IAAe,QAAA,EAAU,QAAQA,GAAA,CAAW,IAAA,CAAK,MAAM,CAAC,CAAA;AAC9E,MAAA,OAAO,MAAA,CAAO,IAAA;AAAA,IAChB;AAAA,GACF;AACF","file":"drizzle.js","sourcesContent":["import { sql as drizzleSql, type SQL } from 'drizzle-orm';\nimport type { PostgresExecutor, PostgresParameter } from '../executor.js';\nimport { splitPositionalSql } from '../positional-sql.js';\n\n/** What a Drizzle database and a Drizzle transaction have in common. */\nexport interface DrizzleQueryable {\n execute(query: SQL): PromiseLike<{ readonly rows: Record<string, unknown>[] }>;\n}\n\n/** The part of a Drizzle `node-postgres` database the executor uses. */\nexport interface DrizzleLike extends DrizzleQueryable {\n transaction<T>(work: (transaction: DrizzleQueryable) => Promise<T>): Promise<T>;\n}\n\n/**\n * Executor for Drizzle with the `node-postgres` driver. The transaction handle\n * is a Drizzle transaction: pass `tx` from `db.transaction(async (tx) => ...)`.\n */\nexport function createDrizzleExecutor(database: DrizzleLike): PostgresExecutor<DrizzleQueryable> {\n return {\n transaction: (work) => database.transaction(work),\n async query(sql, parameters: readonly PostgresParameter[], transaction) {\n const chunks = splitPositionalSql(sql).map((part) =>\n typeof part === 'string'\n ? drizzleSql.raw(part)\n : drizzleSql.param(parameters[part.parameterIndex]),\n );\n const result = await (transaction ?? database).execute(drizzleSql.join(chunks));\n return result.rows;\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/drizzle/drizzle-executor.ts"],"names":["sql","drizzleSql"],"mappings":";;;AAkBO,SAAS,sBAAsB,QAAA,EAA2D;AAC/F,EAAA,OAAO;AAAA,IACL,WAAA,EAAa,CAAC,IAAA,KAAS,QAAA,CAAS,YAAY,IAAI,CAAA;AAAA;AAAA,IAEhD,MAAM,KAAA,CAAMA,KAAA,EAAK,UAAA,EAA0C,WAAA,EAAa;AACtE,MAAA,MAAM,MAAA,GAAS,kBAAA,CAAmBA,KAAG,CAAA,CAAE,GAAA;AAAA,QAAI,CAAC,IAAA,KAC1C,OAAO,IAAA,KAAS,WACZC,GAAA,CAAW,GAAA,CAAI,IAAI,CAAA,GACnBA,GAAA,CAAW,KAAA,CAAM,UAAA,CAAW,IAAA,CAAK,cAAc,CAAC;AAAA,OACtD;AACA,MAAA,MAAM,MAAA,GAAS,OAAO,WAAA,IAAe,QAAA,EAAU,QAAQA,GAAA,CAAW,IAAA,CAAK,MAAM,CAAC,CAAA;AAC9E,MAAA,OAAO,MAAA,CAAO,IAAA;AAAA,IAChB;AAAA,GACF;AACF","file":"drizzle.js","sourcesContent":["import { sql as drizzleSql, type SQL } from 'drizzle-orm';\nimport type { PostgresExecutor, PostgresParameter } from '../executor.js';\nimport { splitPositionalSql } from '../positional-sql.js';\n\n/** What a Drizzle database and a Drizzle transaction have in common. */\nexport interface DrizzleQueryable {\n execute(query: SQL): PromiseLike<{ readonly rows: Record<string, unknown>[] }>;\n}\n\n/** The part of a Drizzle `node-postgres` database the executor uses. */\nexport interface DrizzleLike extends DrizzleQueryable {\n transaction<T>(work: (transaction: DrizzleQueryable) => Promise<T>): Promise<T>;\n}\n\n/**\n * Executor for Drizzle with the `node-postgres` driver. The transaction handle\n * is a Drizzle transaction: pass `tx` from `db.transaction(async (tx) => ...)`.\n */\nexport function createDrizzleExecutor(database: DrizzleLike): PostgresExecutor<DrizzleQueryable> {\n return {\n transaction: (work) => database.transaction(work),\n // eslint-disable-next-line relaybus/max-two-params -- PostgresExecutor port signature\n async query(sql, parameters: readonly PostgresParameter[], transaction) {\n const chunks = splitPositionalSql(sql).map((part) =>\n typeof part === 'string'\n ? drizzleSql.raw(part)\n : drizzleSql.param(parameters[part.parameterIndex]),\n );\n const result = await (transaction ?? database).execute(drizzleSql.join(chunks));\n return result.rows;\n },\n };\n}\n"]}
@@ -0,0 +1,51 @@
1
+ /** Values the store binds: always text, numbers or null (documents travel as JSON text). */
2
+ type PostgresParameter = string | number | null;
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
+ }
13
+ /**
14
+ * The only thing {@link PostgresEventStore} needs from a database library:
15
+ * run a parameterized statement, optionally on a transaction handle, and open
16
+ * a transaction. Implement it to support another client; adapters for `pg`,
17
+ * Kysely, Knex, TypeORM, Drizzle and Prisma ship with the package.
18
+ *
19
+ * Contract:
20
+ * - SQL uses PostgreSQL placeholders (`$1`, `$2`, ...). Adapters translate if
21
+ * their library needs another syntax.
22
+ * - Every statement the store sends returns its rows (or none), so `query`
23
+ * always resolves to an array of rows keyed by column name.
24
+ * - Without a `transaction`, a statement runs on its own connection in
25
+ * autocommit mode, in READ COMMITTED isolation.
26
+ * - `transaction` commits when `work` resolves and rolls back when it rejects.
27
+ * - `options` are hints ({@link PostgresQueryOptions}); ignoring them is correct, only slower.
28
+ */
29
+ interface PostgresExecutor<TTransaction> {
30
+ /**
31
+ * True when the process keeps running if the server ends the session of an open transaction
32
+ * (an `error` listener is on the connection while the transaction runs). The store then holds
33
+ * `bus.transaction` to its budget with `idle_in_transaction_session_timeout` and, on PostgreSQL
34
+ * 17+, `transaction_timeout` as well as `statement_timeout`. Without it only `statement_timeout`
35
+ * is set: a `pg` client checked out by another library without an `error` listener would crash
36
+ * the process with an unhandled `error` event when the server ends its session. `PgExecutor`
37
+ * sets it.
38
+ */
39
+ readonly sessionTimeouts?: boolean;
40
+ transaction<T>(work: (transaction: TTransaction) => Promise<T>): Promise<T>;
41
+ /**
42
+ * Optional. Opens a transaction like {@link PostgresExecutor.transaction} that the server holds to
43
+ * `timeoutMs` (`statement_timeout`, plus the session timeouts when {@link sessionTimeouts} is set),
44
+ * set in the round trip of its `BEGIN`. Without it the store sets the budget with a statement of
45
+ * its own, one round trip more per `bus.transaction`. `PgExecutor` implements it.
46
+ */
47
+ transactionWithBudget?<T>(work: (transaction: TTransaction) => Promise<T>, timeoutMs: number): Promise<T>;
48
+ query(sql: string, parameters: readonly PostgresParameter[], transaction?: TTransaction, options?: PostgresQueryOptions): Promise<readonly PostgresRow[]>;
49
+ }
50
+
51
+ export type { PostgresExecutor as P, PostgresParameter as a, PostgresQueryOptions as b, PostgresRow as c };
@@ -0,0 +1,51 @@
1
+ /** Values the store binds: always text, numbers or null (documents travel as JSON text). */
2
+ type PostgresParameter = string | number | null;
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
+ }
13
+ /**
14
+ * The only thing {@link PostgresEventStore} needs from a database library:
15
+ * run a parameterized statement, optionally on a transaction handle, and open
16
+ * a transaction. Implement it to support another client; adapters for `pg`,
17
+ * Kysely, Knex, TypeORM, Drizzle and Prisma ship with the package.
18
+ *
19
+ * Contract:
20
+ * - SQL uses PostgreSQL placeholders (`$1`, `$2`, ...). Adapters translate if
21
+ * their library needs another syntax.
22
+ * - Every statement the store sends returns its rows (or none), so `query`
23
+ * always resolves to an array of rows keyed by column name.
24
+ * - Without a `transaction`, a statement runs on its own connection in
25
+ * autocommit mode, in READ COMMITTED isolation.
26
+ * - `transaction` commits when `work` resolves and rolls back when it rejects.
27
+ * - `options` are hints ({@link PostgresQueryOptions}); ignoring them is correct, only slower.
28
+ */
29
+ interface PostgresExecutor<TTransaction> {
30
+ /**
31
+ * True when the process keeps running if the server ends the session of an open transaction
32
+ * (an `error` listener is on the connection while the transaction runs). The store then holds
33
+ * `bus.transaction` to its budget with `idle_in_transaction_session_timeout` and, on PostgreSQL
34
+ * 17+, `transaction_timeout` as well as `statement_timeout`. Without it only `statement_timeout`
35
+ * is set: a `pg` client checked out by another library without an `error` listener would crash
36
+ * the process with an unhandled `error` event when the server ends its session. `PgExecutor`
37
+ * sets it.
38
+ */
39
+ readonly sessionTimeouts?: boolean;
40
+ transaction<T>(work: (transaction: TTransaction) => Promise<T>): Promise<T>;
41
+ /**
42
+ * Optional. Opens a transaction like {@link PostgresExecutor.transaction} that the server holds to
43
+ * `timeoutMs` (`statement_timeout`, plus the session timeouts when {@link sessionTimeouts} is set),
44
+ * set in the round trip of its `BEGIN`. Without it the store sets the budget with a statement of
45
+ * its own, one round trip more per `bus.transaction`. `PgExecutor` implements it.
46
+ */
47
+ transactionWithBudget?<T>(work: (transaction: TTransaction) => Promise<T>, timeoutMs: number): Promise<T>;
48
+ query(sql: string, parameters: readonly PostgresParameter[], transaction?: TTransaction, options?: PostgresQueryOptions): Promise<readonly PostgresRow[]>;
49
+ }
50
+
51
+ export type { PostgresExecutor as P, PostgresParameter as a, PostgresQueryOptions as b, PostgresRow as c };