@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/dist/index.d.cts CHANGED
@@ -1,17 +1,49 @@
1
- import { P as PostgresExecutor, a as PostgresParameter, b as PostgresRow } from './executor-CSM7ZOhz.cjs';
1
+ import { P as PostgresExecutor, a as PostgresParameter, b as PostgresQueryOptions, c as PostgresRow } from './executor-CfJgktC6.cjs';
2
+ import { RelayBusLogger, EventStore, EventStoreTransactionOptions, AppendEntry, AppendResult, ClaimRequest, ClaimedDelivery, LeaseReference, ErrorInfo, DeliveryQuery, DeliveryRecord, PurgeRequest, PurgeResult, EventStoreStatistics, CloseOptions, RelayBusError } from '@relaybus/core';
2
3
  import { PoolClient, Pool, PoolConfig } from 'pg';
3
- import { RelayBusLogger, EventStore, AppendEntry, AppendResult, ClaimRequest, ClaimedDelivery, LeaseReference, ErrorInfo, DeliveryQuery, DeliveryRecord, PurgeRequest, PurgeResult, EventStoreStatistics } from '@relaybus/core';
4
4
 
5
+ interface PgExecutorOptions {
6
+ /** Receives the warning sent when prepared statements are switched off. Default `console`. */
7
+ readonly logger?: RelayBusLogger;
8
+ /**
9
+ * The pool is this executor's alone (the store sets it for the pool it creates from
10
+ * `connectionString`). A transaction budget then becomes a session setting of the connection, sent
11
+ * with `BEGIN` only when the connection does not have it yet, and a transaction without a budget on
12
+ * such a connection puts the session defaults back in its `BEGIN`. Statements outside a
13
+ * transaction run under the budget the connection last got. Never set it for a pool other code
14
+ * uses, or behind a pooler in transaction mode (session settings would reach other clients); the
15
+ * executor stops it by itself when it notices a pooler (see prepared statements). Default `false`.
16
+ */
17
+ readonly sessionBudget?: boolean;
18
+ }
5
19
  /**
6
20
  * Executor for a `pg` {@link Pool}. A transaction is a dedicated pool client
7
21
  * between `BEGIN` and `COMMIT`; it is handed to callers as the transaction
8
22
  * handle, so they can run their own queries on it.
23
+ *
24
+ * A statement sent with `prepare: true` is a named prepared statement: each
25
+ * connection parses and plans its text once and from then on only binds the
26
+ * parameters. Its name is derived from the text, so stores with different
27
+ * tables never share one. When the server reports that a prepared statement
28
+ * does not exist on the connection, or already does (a pooler such as
29
+ * PgBouncer before 1.21 in transaction mode hands every transaction another
30
+ * server session), the executor stops preparing for good, logs a warning, and
31
+ * runs the statement again unprepared unless it belonged to a transaction,
32
+ * which has failed by then and is the caller's to retry.
9
33
  */
10
34
  declare class PgExecutor implements PostgresExecutor<PoolClient> {
11
35
  #private;
12
- constructor(pool: Pool);
36
+ constructor(pool: Pool, options?: PgExecutorOptions);
37
+ /** The connection listens for `error` during the transaction, so a server-ended session fails the transaction instead of the process. */
38
+ readonly sessionTimeouts = true;
13
39
  transaction<T>(work: (transaction: PoolClient) => Promise<T>): Promise<T>;
14
- query(sql: string, parameters: readonly PostgresParameter[], transaction?: PoolClient): Promise<readonly PostgresRow[]>;
40
+ /**
41
+ * Sends `BEGIN` and the budget settings as one simple query (the value as a literal: the simple
42
+ * query protocol has no parameters), so the budget costs no round trip. With `sessionBudget` a
43
+ * connection that already keeps this budget only gets `BEGIN`.
44
+ */
45
+ transactionWithBudget<T>(work: (transaction: PoolClient) => Promise<T>, timeoutMs: number): Promise<T>;
46
+ query(sql: string, parameters: readonly PostgresParameter[], transaction?: PoolClient, options?: PostgresQueryOptions): Promise<readonly PostgresRow[]>;
15
47
  }
16
48
 
17
49
  /** Where relaybus keeps its tables. */
@@ -24,6 +56,14 @@ interface PostgresSchemaOptions {
24
56
  * Lower-case letters, digits and underscores. Default `'relaybus_'`.
25
57
  */
26
58
  readonly tablePrefix?: string;
59
+ /**
60
+ * Keep an append-only record of completed deliveries in
61
+ * `<prefix>completed_deliveries`, so `listDeliveries({ status: 'completed' })`,
62
+ * `statistics().completed` and `retention.completed` keep working. Default
63
+ * `false`: a completed delivery is deleted, which avoids the dead tuples and
64
+ * index churn of updating it. Use the same value in every process.
65
+ */
66
+ readonly completionHistory?: boolean;
27
67
  }
28
68
  /** Validated, quoted names used to build SQL. */
29
69
  interface PostgresNames {
@@ -32,18 +72,32 @@ interface PostgresNames {
32
72
  readonly events: string;
33
73
  readonly deliveries: string;
34
74
  readonly keySequences: string;
75
+ /** Lane lease rows: one per lane, see {@link LANE_COUNT}. */
76
+ readonly lanes: string;
77
+ /** Unquoted, unqualified name of {@link lanes}, for catalog lookups. */
78
+ readonly lanesTableName: string;
79
+ /** One row per event that had a delivery complete: when the last one did. Retention of completed work is measured from it. */
80
+ readonly eventCompletions: string;
81
+ /** Completion history, present only with `completionHistory`. */
82
+ readonly completions: string;
83
+ readonly completionHistory: boolean;
84
+ /**
85
+ * Channel of the notifications that wake dispatchers when deliveries were published:
86
+ * `<schema>.<prefix>deliveries`, or a hash of it when that is longer than a channel name may be.
87
+ */
88
+ readonly notificationChannel: string;
35
89
  /** Index names, quoted but not schema-qualified (an index lives in its table's schema). */
36
90
  readonly indexes: {
37
91
  readonly deliveryEventHandler: string;
38
- readonly deliveryDue: string;
39
- readonly deliveryDueUnkeyed: string;
40
- readonly deliveryHandlerCreated: string;
92
+ readonly deliveryLaneDue: string;
93
+ readonly deliveryKeyPending: string;
41
94
  readonly eventCreated: string;
42
- readonly deliveryKeyUnfinished: string;
43
- readonly deliveryKeyProcessing: string;
44
- readonly deliveryStatusCreated: string;
45
95
  readonly deliveryCompleted: string;
46
96
  readonly deliveryDead: string;
97
+ readonly completionCompleted: string;
98
+ readonly eventCompletionsCompleted: string;
99
+ /** Indexes of the 0.1.0 schema that the current schema drops. */
100
+ readonly obsolete: readonly string[];
47
101
  };
48
102
  }
49
103
  /**
@@ -55,15 +109,23 @@ interface PostgresNames {
55
109
  declare function resolvePostgresNames(options?: PostgresSchemaOptions): PostgresNames;
56
110
 
57
111
  /**
58
- * Statements that create the schema, tables and indexes, in dependency order.
59
- * Every statement is idempotent (`IF NOT EXISTS`). Run them in one transaction
60
- * and serialize concurrent runs (see {@link PostgresEventStore.setup}), or
61
- * paste {@link createPostgresSchemaSql} into your own migration tool.
112
+ * Statements that create the schema, tables and indexes, in dependency order,
113
+ * and upgrade a 0.1.0 schema in place. Every statement is idempotent
114
+ * (`IF NOT EXISTS`, or guarded by a check for the 0.1.0 columns). Run them in
115
+ * one transaction and serialize concurrent runs (see
116
+ * {@link PostgresEventStore.setup}), or paste {@link createPostgresSchemaSql}
117
+ * into your own migration tool.
62
118
  *
63
119
  * `events.id` and `deliveries.id` are `text` so that any caller-supplied id
64
120
  * (UUID, ULID, deterministic business id) is accepted. `deliveries.id` uses the
65
121
  * `C` collation so that ordering by id matches a byte-wise comparison on every
66
122
  * database, whatever its locale.
123
+ *
124
+ * A delivery row is written once (insert) and removed once (delete) on the
125
+ * normal path. The lease lives in the lane row a delivery belongs to, so
126
+ * claiming, renewing and completing never update an indexed column of the
127
+ * delivery. The lane table is tiny, has no index besides its primary key and
128
+ * leaves free space in its pages, so its updates are heap-only (HOT).
67
129
  */
68
130
  declare function createPostgresSchemaStatements(options?: PostgresSchemaOptions): string[];
69
131
  /**
@@ -87,7 +149,29 @@ interface PostgresEventStoreSettings extends PostgresSchemaOptions {
87
149
  * it, otherwise `pg` raises an unhandled `error` event and the process exits.
88
150
  */
89
151
  readonly logger?: RelayBusLogger;
152
+ /**
153
+ * Send the statements the store repeats (publish, claim, complete, renew) as named prepared
154
+ * statements, so each connection parses and plans their text once instead of on every call. Default
155
+ * `true`. Set `false` behind a pooler in transaction mode that does not keep prepared statements per
156
+ * client (PgBouncer before 1.21, or 1.21+ with `max_prepared_statements = 0`). Left on there, the
157
+ * first statement that lands on another server session fails once, then the store stops preparing
158
+ * and logs a warning. Only executors that support it prepare (`PgExecutor`, the default); the
159
+ * other adapters send every statement unprepared.
160
+ */
161
+ readonly preparedStatements?: boolean;
162
+ /**
163
+ * How dispatchers in other processes learn about new deliveries.
164
+ * - `'notify'` (default): a publishing transaction sends `NOTIFY` (at most one per 10 ms per store,
165
+ * the rest merged into one trailing notification after the commit) and each dispatcher listens
166
+ * with `LISTEN` on a connection of its own, so a delivery is picked up within milliseconds.
167
+ * - `'poll'`: no `NOTIFY`, no listening connection. Other processes find new deliveries when they
168
+ * poll (`dispatcher.pollInterval`, default 1 s); a dispatcher in the publishing process is still
169
+ * woken at once. Use it behind a pooler in transaction mode, where `LISTEN` does not work.
170
+ */
171
+ readonly wakeUp?: PostgresWakeUp;
90
172
  }
173
+ /** See {@link PostgresEventStoreSettings.wakeUp}. */
174
+ type PostgresWakeUp = 'notify' | 'poll';
91
175
  /** Use an existing `pg` pool. The store never closes it. */
92
176
  interface PostgresPoolOptions extends PostgresEventStoreSettings {
93
177
  readonly pool: Pool;
@@ -126,13 +210,26 @@ type PostgresEventStoreOptions<TTransaction = PoolClient> = PostgresPoolOptions
126
210
  * Publishing locks that row and holds the lock until commit, so two
127
211
  * transactions publishing the same key are serialized and sequence order
128
212
  * equals commit order. Claiming then hands out only the lowest unfinished
129
- * sequence of a (handler, key).
130
- * - **Safe parallel claiming.** One `FOR UPDATE SKIP LOCKED` statement leases
131
- * deliveries; any number of processes can claim at once.
213
+ * sequence of a (handler, key), and all deliveries of a key live in one lane,
214
+ * so one worker at a time sees them.
215
+ * - **Lane leases.** Workers lease lanes (256 hash buckets of the ordering key
216
+ * or event id), not single deliveries. A claim writes one lane row for a
217
+ * whole batch and never writes a delivery row; a completion deletes the
218
+ * delivery rows. Delivery rows are therefore inserted once and deleted once,
219
+ * which leaves no dead index entries from updates on the normal path.
132
220
  * - **Fenced state changes.** Complete, reschedule, bury and extend only apply
133
- * to the lease token that claimed the delivery.
221
+ * with the epoch of the lane lease that claimed the delivery. A lane taken
222
+ * over by another worker gets a new epoch, which fences everything the
223
+ * previous holder still runs. Completion takes a key-share lock on the lane
224
+ * row, so it cannot interleave with a takeover.
225
+ * - **Attempts survive crashes.** A claim records which deliveries it put in
226
+ * flight in the lane row. If the holder dies, the next holder charges one
227
+ * attempt to each of them, so a poison message still reaches the retry limit.
134
228
  * - **App clock.** Every timestamp comes from the `now` the caller passes in,
135
229
  * never from the database clock.
230
+ *
231
+ * Completed deliveries are deleted. Pass `completionHistory: true` to keep an
232
+ * append-only record for `listDeliveries({ status: 'completed' })`.
136
233
  */
137
234
  declare class PostgresEventStore<TTransaction = PoolClient> implements EventStore<TTransaction> {
138
235
  #private;
@@ -141,36 +238,66 @@ declare class PostgresEventStore<TTransaction = PoolClient> implements EventStor
141
238
  * Creates the schema, tables and indexes if they do not exist. Safe to call
142
239
  * from many processes at once: a transaction-scoped advisory lock serializes
143
240
  * the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
241
+ * When the lane table already carries the marker of the current statements
242
+ * ({@link postgresSchemaMarker}) nothing runs and no table is locked, so instances
243
+ * that start next to running dispatchers cannot deadlock with their claims.
144
244
  * Skip it (`setupStore: false`) when a migration tool applies
145
245
  * `createPostgresSchemaSql()` instead.
146
246
  */
147
247
  setup(): Promise<void>;
148
- transaction<T>(work: (transaction: TTransaction) => Promise<T>): Promise<T>;
149
- /**
150
- * Writes the entries in `transaction`. Keyed events reserve their sequence
151
- * numbers first (locking the key rows in sorted order, so publishers cannot
152
- * deadlock inside one call), then events, then deliveries, each in a single
153
- * multi-row statement ordered by primary key. Two transactions that append
154
- * keys in opposite order across several calls can still deadlock (40P01);
155
- * the database rolls one back and the caller retries.
248
+ /**
249
+ * Runs `work` in a transaction of the executor. With `options.timeoutMs` (from `bus.transaction`)
250
+ * the server holds the transaction to the budget: `statement_timeout`, and for executors with
251
+ * `sessionTimeouts` (`pg`) also `idle_in_transaction_session_timeout` and, on PostgreSQL 17+,
252
+ * `transaction_timeout`, all scoped to the transaction. An executor with `transactionWithBudget`
253
+ * (`PgExecutor`) sets them with `BEGIN`, so the budget costs no round trip; on a pool the store
254
+ * created they are kept per connection and sent only when the connection does not have them yet.
255
+ */
256
+ transaction<T>(work: (transaction: TTransaction) => Promise<T>, options?: EventStoreTransactionOptions): Promise<T>;
257
+ /**
258
+ * Writes the entries in `transaction`, normally with one statement
259
+ * (`AppendStatements.insertFreshEvents`): keyed events reserve their sequence numbers (locking
260
+ * the key rows in sorted order, so publishers cannot deadlock inside one call), then the new events and
261
+ * their deliveries are inserted, ordered by primary key, and one notification wakes the dispatchers
262
+ * when a delivery is due now. Events that already existed get their missing handlers' deliveries in
263
+ * two more statements. Two transactions that append keys in opposite order across several calls can
264
+ * still deadlock (40P01); the database rolls one back and the caller retries.
156
265
  * A duplicate event id still consumes a sequence number; gaps are harmless
157
266
  * because only the order of sequences matters.
158
267
  */
159
268
  append(transaction: TTransaction, entries: readonly AppendEntry[], now: Date): Promise<AppendResult>;
160
269
  /**
161
- * Leases up to `limit` due deliveries with `FOR UPDATE SKIP LOCKED`. The fast
162
- * statement examines the first due deliveries; only when they are mostly
163
- * blocked followers of busy keys does a second statement look at the heads of
164
- * all keys. See {@link PostgresStatements.claim} for why that keeps a
165
- * (handler, key) strictly one-at-a-time and in order.
270
+ * Claims up to `limit` due deliveries, normally with one statement
271
+ * (`LaneStatements.claimLanes`): from the lanes this owner already holds, then from free lanes
272
+ * it takes in the same statement, recording the batch in the lane rows. A claim that would have to take
273
+ * over a lane whose previous holder still runs deliveries under an expired lease continues in a
274
+ * transaction that charges their lost attempts first, taking free lanes a few at a time (4, 8, 16, ...);
275
+ * so does a claim whose statement kept seeing a lane it holds change under it, and one that came up
276
+ * short with more free lanes left than it looked at. The step that fails after
277
+ * the first one has recorded deliveries returns those instead of failing: a recorded delivery nobody
278
+ * received would be held for a whole lease. See `ClaimStatements.claim` for the rules a delivery
279
+ * must satisfy.
166
280
  */
167
281
  claim(request: ClaimRequest): Promise<ClaimedDelivery[]>;
168
- extendLease(lease: LeaseReference, leaseExpiresAt: Date, now: Date): Promise<boolean>;
282
+ extendLease(lease: LeaseReference, leaseExpiresAt: Date): Promise<boolean>;
169
283
  complete(lease: LeaseReference, now: Date, transaction?: TTransaction): Promise<boolean>;
284
+ completeMany(leases: readonly LeaseReference[], now: Date): Promise<boolean[]>;
170
285
  reschedule(lease: LeaseReference, availableAt: Date, error: ErrorInfo, now: Date): Promise<boolean>;
171
286
  bury(lease: LeaseReference, error: ErrorInfo, now: Date): Promise<boolean>;
172
287
  /** Returns a leased delivery to pending, due now, without counting the attempt. */
173
288
  release(lease: LeaseReference, now: Date): Promise<boolean>;
289
+ /**
290
+ * Calls `listener` soon after a transaction that published deliveries due at once commits, in any
291
+ * process: `append` sends one `pg_notify` per call (merged per transaction, none for delayed
292
+ * deliveries), and this listens with `LISTEN` on a connection of its own, opened with the settings of
293
+ * the store's pool (one connection more than the pool's `max`). A lost connection is replaced after a
294
+ * second. Resolves with `undefined` for a store built on an `executor`, which has no connection of its
295
+ * own to listen on, and with `wakeUp: 'poll'`: polling then finds new deliveries (and the bus still
296
+ * wakes its own dispatcher).
297
+ */
298
+ watchNewDeliveries(listener: () => void): Promise<(() => Promise<void>) | undefined>;
299
+ /** Frees the lanes `leaseOwner` holds that run no delivery, so other workers need not wait for the lease to expire. */
300
+ relinquish(leaseOwner: string): Promise<void>;
174
301
  listDeliveries(query: DeliveryQuery): Promise<DeliveryRecord[]>;
175
302
  requeue(deliveryIds: readonly string[], now: Date): Promise<number>;
176
303
  /**
@@ -184,8 +311,22 @@ declare class PostgresEventStore<TTransaction = PoolClient> implements EventStor
184
311
  */
185
312
  purge(request: PurgeRequest): Promise<PurgeResult>;
186
313
  statistics(now: Date): Promise<EventStoreStatistics>;
187
- /** Ends the pool this store created from `connectionString`. Pools and executors you passed in stay open. */
188
- close(): Promise<void>;
314
+ /**
315
+ * Ends the pool this store created from `connectionString`. Pools and executors you passed in stay
316
+ * open. Once `options.signal` aborts (the shutdown deadline passed), the connections the store opened
317
+ * itself are destroyed: queries still running on them fail at once instead of holding the shutdown.
318
+ */
319
+ close(options?: CloseOptions): Promise<void>;
320
+ }
321
+
322
+ /**
323
+ * A claim was refused because giving back the deliveries of an earlier, failed
324
+ * claim still fails. Claiming before that would take the owner's live lanes for
325
+ * foreign ones; the next claim tries the reconciliation again.
326
+ */
327
+ declare class ReconcileFailedError extends RelayBusError {
328
+ name: string;
329
+ readonly code: string;
189
330
  }
190
331
 
191
- export { PgExecutor, type PostgresConnectionOptions, PostgresEventStore, type PostgresEventStoreOptions, type PostgresEventStoreSettings, PostgresExecutor, type PostgresExecutorOptions, type PostgresNames, PostgresParameter, type PostgresPoolOptions, PostgresRow, type PostgresSchemaOptions, createPostgresSchemaSql, createPostgresSchemaStatements, resolvePostgresNames };
332
+ export { PgExecutor, type PgExecutorOptions, type PostgresConnectionOptions, PostgresEventStore, type PostgresEventStoreOptions, type PostgresEventStoreSettings, PostgresExecutor, type PostgresExecutorOptions, type PostgresNames, PostgresParameter, type PostgresPoolOptions, PostgresQueryOptions, PostgresRow, type PostgresSchemaOptions, type PostgresWakeUp, ReconcileFailedError, createPostgresSchemaSql, createPostgresSchemaStatements, resolvePostgresNames };
package/dist/index.d.ts CHANGED
@@ -1,17 +1,49 @@
1
- import { P as PostgresExecutor, a as PostgresParameter, b as PostgresRow } from './executor-CSM7ZOhz.js';
1
+ import { P as PostgresExecutor, a as PostgresParameter, b as PostgresQueryOptions, c as PostgresRow } from './executor-CfJgktC6.js';
2
+ import { RelayBusLogger, EventStore, EventStoreTransactionOptions, AppendEntry, AppendResult, ClaimRequest, ClaimedDelivery, LeaseReference, ErrorInfo, DeliveryQuery, DeliveryRecord, PurgeRequest, PurgeResult, EventStoreStatistics, CloseOptions, RelayBusError } from '@relaybus/core';
2
3
  import { PoolClient, Pool, PoolConfig } from 'pg';
3
- import { RelayBusLogger, EventStore, AppendEntry, AppendResult, ClaimRequest, ClaimedDelivery, LeaseReference, ErrorInfo, DeliveryQuery, DeliveryRecord, PurgeRequest, PurgeResult, EventStoreStatistics } from '@relaybus/core';
4
4
 
5
+ interface PgExecutorOptions {
6
+ /** Receives the warning sent when prepared statements are switched off. Default `console`. */
7
+ readonly logger?: RelayBusLogger;
8
+ /**
9
+ * The pool is this executor's alone (the store sets it for the pool it creates from
10
+ * `connectionString`). A transaction budget then becomes a session setting of the connection, sent
11
+ * with `BEGIN` only when the connection does not have it yet, and a transaction without a budget on
12
+ * such a connection puts the session defaults back in its `BEGIN`. Statements outside a
13
+ * transaction run under the budget the connection last got. Never set it for a pool other code
14
+ * uses, or behind a pooler in transaction mode (session settings would reach other clients); the
15
+ * executor stops it by itself when it notices a pooler (see prepared statements). Default `false`.
16
+ */
17
+ readonly sessionBudget?: boolean;
18
+ }
5
19
  /**
6
20
  * Executor for a `pg` {@link Pool}. A transaction is a dedicated pool client
7
21
  * between `BEGIN` and `COMMIT`; it is handed to callers as the transaction
8
22
  * handle, so they can run their own queries on it.
23
+ *
24
+ * A statement sent with `prepare: true` is a named prepared statement: each
25
+ * connection parses and plans its text once and from then on only binds the
26
+ * parameters. Its name is derived from the text, so stores with different
27
+ * tables never share one. When the server reports that a prepared statement
28
+ * does not exist on the connection, or already does (a pooler such as
29
+ * PgBouncer before 1.21 in transaction mode hands every transaction another
30
+ * server session), the executor stops preparing for good, logs a warning, and
31
+ * runs the statement again unprepared unless it belonged to a transaction,
32
+ * which has failed by then and is the caller's to retry.
9
33
  */
10
34
  declare class PgExecutor implements PostgresExecutor<PoolClient> {
11
35
  #private;
12
- constructor(pool: Pool);
36
+ constructor(pool: Pool, options?: PgExecutorOptions);
37
+ /** The connection listens for `error` during the transaction, so a server-ended session fails the transaction instead of the process. */
38
+ readonly sessionTimeouts = true;
13
39
  transaction<T>(work: (transaction: PoolClient) => Promise<T>): Promise<T>;
14
- query(sql: string, parameters: readonly PostgresParameter[], transaction?: PoolClient): Promise<readonly PostgresRow[]>;
40
+ /**
41
+ * Sends `BEGIN` and the budget settings as one simple query (the value as a literal: the simple
42
+ * query protocol has no parameters), so the budget costs no round trip. With `sessionBudget` a
43
+ * connection that already keeps this budget only gets `BEGIN`.
44
+ */
45
+ transactionWithBudget<T>(work: (transaction: PoolClient) => Promise<T>, timeoutMs: number): Promise<T>;
46
+ query(sql: string, parameters: readonly PostgresParameter[], transaction?: PoolClient, options?: PostgresQueryOptions): Promise<readonly PostgresRow[]>;
15
47
  }
16
48
 
17
49
  /** Where relaybus keeps its tables. */
@@ -24,6 +56,14 @@ interface PostgresSchemaOptions {
24
56
  * Lower-case letters, digits and underscores. Default `'relaybus_'`.
25
57
  */
26
58
  readonly tablePrefix?: string;
59
+ /**
60
+ * Keep an append-only record of completed deliveries in
61
+ * `<prefix>completed_deliveries`, so `listDeliveries({ status: 'completed' })`,
62
+ * `statistics().completed` and `retention.completed` keep working. Default
63
+ * `false`: a completed delivery is deleted, which avoids the dead tuples and
64
+ * index churn of updating it. Use the same value in every process.
65
+ */
66
+ readonly completionHistory?: boolean;
27
67
  }
28
68
  /** Validated, quoted names used to build SQL. */
29
69
  interface PostgresNames {
@@ -32,18 +72,32 @@ interface PostgresNames {
32
72
  readonly events: string;
33
73
  readonly deliveries: string;
34
74
  readonly keySequences: string;
75
+ /** Lane lease rows: one per lane, see {@link LANE_COUNT}. */
76
+ readonly lanes: string;
77
+ /** Unquoted, unqualified name of {@link lanes}, for catalog lookups. */
78
+ readonly lanesTableName: string;
79
+ /** One row per event that had a delivery complete: when the last one did. Retention of completed work is measured from it. */
80
+ readonly eventCompletions: string;
81
+ /** Completion history, present only with `completionHistory`. */
82
+ readonly completions: string;
83
+ readonly completionHistory: boolean;
84
+ /**
85
+ * Channel of the notifications that wake dispatchers when deliveries were published:
86
+ * `<schema>.<prefix>deliveries`, or a hash of it when that is longer than a channel name may be.
87
+ */
88
+ readonly notificationChannel: string;
35
89
  /** Index names, quoted but not schema-qualified (an index lives in its table's schema). */
36
90
  readonly indexes: {
37
91
  readonly deliveryEventHandler: string;
38
- readonly deliveryDue: string;
39
- readonly deliveryDueUnkeyed: string;
40
- readonly deliveryHandlerCreated: string;
92
+ readonly deliveryLaneDue: string;
93
+ readonly deliveryKeyPending: string;
41
94
  readonly eventCreated: string;
42
- readonly deliveryKeyUnfinished: string;
43
- readonly deliveryKeyProcessing: string;
44
- readonly deliveryStatusCreated: string;
45
95
  readonly deliveryCompleted: string;
46
96
  readonly deliveryDead: string;
97
+ readonly completionCompleted: string;
98
+ readonly eventCompletionsCompleted: string;
99
+ /** Indexes of the 0.1.0 schema that the current schema drops. */
100
+ readonly obsolete: readonly string[];
47
101
  };
48
102
  }
49
103
  /**
@@ -55,15 +109,23 @@ interface PostgresNames {
55
109
  declare function resolvePostgresNames(options?: PostgresSchemaOptions): PostgresNames;
56
110
 
57
111
  /**
58
- * Statements that create the schema, tables and indexes, in dependency order.
59
- * Every statement is idempotent (`IF NOT EXISTS`). Run them in one transaction
60
- * and serialize concurrent runs (see {@link PostgresEventStore.setup}), or
61
- * paste {@link createPostgresSchemaSql} into your own migration tool.
112
+ * Statements that create the schema, tables and indexes, in dependency order,
113
+ * and upgrade a 0.1.0 schema in place. Every statement is idempotent
114
+ * (`IF NOT EXISTS`, or guarded by a check for the 0.1.0 columns). Run them in
115
+ * one transaction and serialize concurrent runs (see
116
+ * {@link PostgresEventStore.setup}), or paste {@link createPostgresSchemaSql}
117
+ * into your own migration tool.
62
118
  *
63
119
  * `events.id` and `deliveries.id` are `text` so that any caller-supplied id
64
120
  * (UUID, ULID, deterministic business id) is accepted. `deliveries.id` uses the
65
121
  * `C` collation so that ordering by id matches a byte-wise comparison on every
66
122
  * database, whatever its locale.
123
+ *
124
+ * A delivery row is written once (insert) and removed once (delete) on the
125
+ * normal path. The lease lives in the lane row a delivery belongs to, so
126
+ * claiming, renewing and completing never update an indexed column of the
127
+ * delivery. The lane table is tiny, has no index besides its primary key and
128
+ * leaves free space in its pages, so its updates are heap-only (HOT).
67
129
  */
68
130
  declare function createPostgresSchemaStatements(options?: PostgresSchemaOptions): string[];
69
131
  /**
@@ -87,7 +149,29 @@ interface PostgresEventStoreSettings extends PostgresSchemaOptions {
87
149
  * it, otherwise `pg` raises an unhandled `error` event and the process exits.
88
150
  */
89
151
  readonly logger?: RelayBusLogger;
152
+ /**
153
+ * Send the statements the store repeats (publish, claim, complete, renew) as named prepared
154
+ * statements, so each connection parses and plans their text once instead of on every call. Default
155
+ * `true`. Set `false` behind a pooler in transaction mode that does not keep prepared statements per
156
+ * client (PgBouncer before 1.21, or 1.21+ with `max_prepared_statements = 0`). Left on there, the
157
+ * first statement that lands on another server session fails once, then the store stops preparing
158
+ * and logs a warning. Only executors that support it prepare (`PgExecutor`, the default); the
159
+ * other adapters send every statement unprepared.
160
+ */
161
+ readonly preparedStatements?: boolean;
162
+ /**
163
+ * How dispatchers in other processes learn about new deliveries.
164
+ * - `'notify'` (default): a publishing transaction sends `NOTIFY` (at most one per 10 ms per store,
165
+ * the rest merged into one trailing notification after the commit) and each dispatcher listens
166
+ * with `LISTEN` on a connection of its own, so a delivery is picked up within milliseconds.
167
+ * - `'poll'`: no `NOTIFY`, no listening connection. Other processes find new deliveries when they
168
+ * poll (`dispatcher.pollInterval`, default 1 s); a dispatcher in the publishing process is still
169
+ * woken at once. Use it behind a pooler in transaction mode, where `LISTEN` does not work.
170
+ */
171
+ readonly wakeUp?: PostgresWakeUp;
90
172
  }
173
+ /** See {@link PostgresEventStoreSettings.wakeUp}. */
174
+ type PostgresWakeUp = 'notify' | 'poll';
91
175
  /** Use an existing `pg` pool. The store never closes it. */
92
176
  interface PostgresPoolOptions extends PostgresEventStoreSettings {
93
177
  readonly pool: Pool;
@@ -126,13 +210,26 @@ type PostgresEventStoreOptions<TTransaction = PoolClient> = PostgresPoolOptions
126
210
  * Publishing locks that row and holds the lock until commit, so two
127
211
  * transactions publishing the same key are serialized and sequence order
128
212
  * equals commit order. Claiming then hands out only the lowest unfinished
129
- * sequence of a (handler, key).
130
- * - **Safe parallel claiming.** One `FOR UPDATE SKIP LOCKED` statement leases
131
- * deliveries; any number of processes can claim at once.
213
+ * sequence of a (handler, key), and all deliveries of a key live in one lane,
214
+ * so one worker at a time sees them.
215
+ * - **Lane leases.** Workers lease lanes (256 hash buckets of the ordering key
216
+ * or event id), not single deliveries. A claim writes one lane row for a
217
+ * whole batch and never writes a delivery row; a completion deletes the
218
+ * delivery rows. Delivery rows are therefore inserted once and deleted once,
219
+ * which leaves no dead index entries from updates on the normal path.
132
220
  * - **Fenced state changes.** Complete, reschedule, bury and extend only apply
133
- * to the lease token that claimed the delivery.
221
+ * with the epoch of the lane lease that claimed the delivery. A lane taken
222
+ * over by another worker gets a new epoch, which fences everything the
223
+ * previous holder still runs. Completion takes a key-share lock on the lane
224
+ * row, so it cannot interleave with a takeover.
225
+ * - **Attempts survive crashes.** A claim records which deliveries it put in
226
+ * flight in the lane row. If the holder dies, the next holder charges one
227
+ * attempt to each of them, so a poison message still reaches the retry limit.
134
228
  * - **App clock.** Every timestamp comes from the `now` the caller passes in,
135
229
  * never from the database clock.
230
+ *
231
+ * Completed deliveries are deleted. Pass `completionHistory: true` to keep an
232
+ * append-only record for `listDeliveries({ status: 'completed' })`.
136
233
  */
137
234
  declare class PostgresEventStore<TTransaction = PoolClient> implements EventStore<TTransaction> {
138
235
  #private;
@@ -141,36 +238,66 @@ declare class PostgresEventStore<TTransaction = PoolClient> implements EventStor
141
238
  * Creates the schema, tables and indexes if they do not exist. Safe to call
142
239
  * from many processes at once: a transaction-scoped advisory lock serializes
143
240
  * the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
241
+ * When the lane table already carries the marker of the current statements
242
+ * ({@link postgresSchemaMarker}) nothing runs and no table is locked, so instances
243
+ * that start next to running dispatchers cannot deadlock with their claims.
144
244
  * Skip it (`setupStore: false`) when a migration tool applies
145
245
  * `createPostgresSchemaSql()` instead.
146
246
  */
147
247
  setup(): Promise<void>;
148
- transaction<T>(work: (transaction: TTransaction) => Promise<T>): Promise<T>;
149
- /**
150
- * Writes the entries in `transaction`. Keyed events reserve their sequence
151
- * numbers first (locking the key rows in sorted order, so publishers cannot
152
- * deadlock inside one call), then events, then deliveries, each in a single
153
- * multi-row statement ordered by primary key. Two transactions that append
154
- * keys in opposite order across several calls can still deadlock (40P01);
155
- * the database rolls one back and the caller retries.
248
+ /**
249
+ * Runs `work` in a transaction of the executor. With `options.timeoutMs` (from `bus.transaction`)
250
+ * the server holds the transaction to the budget: `statement_timeout`, and for executors with
251
+ * `sessionTimeouts` (`pg`) also `idle_in_transaction_session_timeout` and, on PostgreSQL 17+,
252
+ * `transaction_timeout`, all scoped to the transaction. An executor with `transactionWithBudget`
253
+ * (`PgExecutor`) sets them with `BEGIN`, so the budget costs no round trip; on a pool the store
254
+ * created they are kept per connection and sent only when the connection does not have them yet.
255
+ */
256
+ transaction<T>(work: (transaction: TTransaction) => Promise<T>, options?: EventStoreTransactionOptions): Promise<T>;
257
+ /**
258
+ * Writes the entries in `transaction`, normally with one statement
259
+ * (`AppendStatements.insertFreshEvents`): keyed events reserve their sequence numbers (locking
260
+ * the key rows in sorted order, so publishers cannot deadlock inside one call), then the new events and
261
+ * their deliveries are inserted, ordered by primary key, and one notification wakes the dispatchers
262
+ * when a delivery is due now. Events that already existed get their missing handlers' deliveries in
263
+ * two more statements. Two transactions that append keys in opposite order across several calls can
264
+ * still deadlock (40P01); the database rolls one back and the caller retries.
156
265
  * A duplicate event id still consumes a sequence number; gaps are harmless
157
266
  * because only the order of sequences matters.
158
267
  */
159
268
  append(transaction: TTransaction, entries: readonly AppendEntry[], now: Date): Promise<AppendResult>;
160
269
  /**
161
- * Leases up to `limit` due deliveries with `FOR UPDATE SKIP LOCKED`. The fast
162
- * statement examines the first due deliveries; only when they are mostly
163
- * blocked followers of busy keys does a second statement look at the heads of
164
- * all keys. See {@link PostgresStatements.claim} for why that keeps a
165
- * (handler, key) strictly one-at-a-time and in order.
270
+ * Claims up to `limit` due deliveries, normally with one statement
271
+ * (`LaneStatements.claimLanes`): from the lanes this owner already holds, then from free lanes
272
+ * it takes in the same statement, recording the batch in the lane rows. A claim that would have to take
273
+ * over a lane whose previous holder still runs deliveries under an expired lease continues in a
274
+ * transaction that charges their lost attempts first, taking free lanes a few at a time (4, 8, 16, ...);
275
+ * so does a claim whose statement kept seeing a lane it holds change under it, and one that came up
276
+ * short with more free lanes left than it looked at. The step that fails after
277
+ * the first one has recorded deliveries returns those instead of failing: a recorded delivery nobody
278
+ * received would be held for a whole lease. See `ClaimStatements.claim` for the rules a delivery
279
+ * must satisfy.
166
280
  */
167
281
  claim(request: ClaimRequest): Promise<ClaimedDelivery[]>;
168
- extendLease(lease: LeaseReference, leaseExpiresAt: Date, now: Date): Promise<boolean>;
282
+ extendLease(lease: LeaseReference, leaseExpiresAt: Date): Promise<boolean>;
169
283
  complete(lease: LeaseReference, now: Date, transaction?: TTransaction): Promise<boolean>;
284
+ completeMany(leases: readonly LeaseReference[], now: Date): Promise<boolean[]>;
170
285
  reschedule(lease: LeaseReference, availableAt: Date, error: ErrorInfo, now: Date): Promise<boolean>;
171
286
  bury(lease: LeaseReference, error: ErrorInfo, now: Date): Promise<boolean>;
172
287
  /** Returns a leased delivery to pending, due now, without counting the attempt. */
173
288
  release(lease: LeaseReference, now: Date): Promise<boolean>;
289
+ /**
290
+ * Calls `listener` soon after a transaction that published deliveries due at once commits, in any
291
+ * process: `append` sends one `pg_notify` per call (merged per transaction, none for delayed
292
+ * deliveries), and this listens with `LISTEN` on a connection of its own, opened with the settings of
293
+ * the store's pool (one connection more than the pool's `max`). A lost connection is replaced after a
294
+ * second. Resolves with `undefined` for a store built on an `executor`, which has no connection of its
295
+ * own to listen on, and with `wakeUp: 'poll'`: polling then finds new deliveries (and the bus still
296
+ * wakes its own dispatcher).
297
+ */
298
+ watchNewDeliveries(listener: () => void): Promise<(() => Promise<void>) | undefined>;
299
+ /** Frees the lanes `leaseOwner` holds that run no delivery, so other workers need not wait for the lease to expire. */
300
+ relinquish(leaseOwner: string): Promise<void>;
174
301
  listDeliveries(query: DeliveryQuery): Promise<DeliveryRecord[]>;
175
302
  requeue(deliveryIds: readonly string[], now: Date): Promise<number>;
176
303
  /**
@@ -184,8 +311,22 @@ declare class PostgresEventStore<TTransaction = PoolClient> implements EventStor
184
311
  */
185
312
  purge(request: PurgeRequest): Promise<PurgeResult>;
186
313
  statistics(now: Date): Promise<EventStoreStatistics>;
187
- /** Ends the pool this store created from `connectionString`. Pools and executors you passed in stay open. */
188
- close(): Promise<void>;
314
+ /**
315
+ * Ends the pool this store created from `connectionString`. Pools and executors you passed in stay
316
+ * open. Once `options.signal` aborts (the shutdown deadline passed), the connections the store opened
317
+ * itself are destroyed: queries still running on them fail at once instead of holding the shutdown.
318
+ */
319
+ close(options?: CloseOptions): Promise<void>;
320
+ }
321
+
322
+ /**
323
+ * A claim was refused because giving back the deliveries of an earlier, failed
324
+ * claim still fails. Claiming before that would take the owner's live lanes for
325
+ * foreign ones; the next claim tries the reconciliation again.
326
+ */
327
+ declare class ReconcileFailedError extends RelayBusError {
328
+ name: string;
329
+ readonly code: string;
189
330
  }
190
331
 
191
- export { PgExecutor, type PostgresConnectionOptions, PostgresEventStore, type PostgresEventStoreOptions, type PostgresEventStoreSettings, PostgresExecutor, type PostgresExecutorOptions, type PostgresNames, PostgresParameter, type PostgresPoolOptions, PostgresRow, type PostgresSchemaOptions, createPostgresSchemaSql, createPostgresSchemaStatements, resolvePostgresNames };
332
+ export { PgExecutor, type PgExecutorOptions, type PostgresConnectionOptions, PostgresEventStore, type PostgresEventStoreOptions, type PostgresEventStoreSettings, PostgresExecutor, type PostgresExecutorOptions, type PostgresNames, PostgresParameter, type PostgresPoolOptions, PostgresQueryOptions, PostgresRow, type PostgresSchemaOptions, type PostgresWakeUp, ReconcileFailedError, createPostgresSchemaSql, createPostgresSchemaStatements, resolvePostgresNames };