@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 +177 -48
- package/dist/drizzle.cjs +1 -0
- package/dist/drizzle.cjs.map +1 -1
- package/dist/drizzle.d.cts +1 -1
- package/dist/drizzle.d.ts +1 -1
- package/dist/drizzle.js +1 -0
- package/dist/drizzle.js.map +1 -1
- package/dist/executor-CfJgktC6.d.cts +51 -0
- package/dist/executor-CfJgktC6.d.ts +51 -0
- package/dist/index.cjs +2797 -661
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +176 -35
- package/dist/index.d.ts +176 -35
- package/dist/index.js +2799 -664
- package/dist/index.js.map +1 -1
- package/dist/knex.cjs +1 -0
- package/dist/knex.cjs.map +1 -1
- package/dist/knex.d.cts +1 -1
- package/dist/knex.d.ts +1 -1
- package/dist/knex.js +1 -0
- package/dist/knex.js.map +1 -1
- package/dist/kysely.cjs +1 -0
- package/dist/kysely.cjs.map +1 -1
- package/dist/kysely.d.cts +1 -1
- package/dist/kysely.d.ts +1 -1
- package/dist/kysely.js +1 -0
- package/dist/kysely.js.map +1 -1
- package/dist/prisma.cjs +1 -0
- package/dist/prisma.cjs.map +1 -1
- package/dist/prisma.d.cts +1 -1
- package/dist/prisma.d.ts +1 -1
- package/dist/prisma.js +1 -0
- package/dist/prisma.js.map +1 -1
- package/dist/typeorm.cjs +1 -0
- package/dist/typeorm.cjs.map +1 -1
- package/dist/typeorm.d.cts +1 -1
- package/dist/typeorm.d.ts +1 -1
- package/dist/typeorm.js +1 -0
- package/dist/typeorm.js.map +1 -1
- package/package.json +24 -11
- package/dist/executor-CSM7ZOhz.d.cts +0 -24
- package/dist/executor-CSM7ZOhz.d.ts +0 -24
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
|
-
|
|
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
|
-
| `
|
|
92
|
-
| `
|
|
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](
|
|
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
|
|
116
|
-
|
|
|
117
|
-
| `events`
|
|
118
|
-
| `deliveries`
|
|
119
|
-
| `
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
- **
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
`
|
|
155
|
-
|
|
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
|
|
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.**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
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])
|
package/dist/drizzle.cjs.map
CHANGED
|
@@ -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,
|
|
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"]}
|
package/dist/drizzle.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SQL } from 'drizzle-orm';
|
|
2
|
-
import { P as PostgresExecutor } from './executor-
|
|
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-
|
|
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])
|
package/dist/drizzle.js.map
CHANGED
|
@@ -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,
|
|
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 };
|