@relaybus/postgres 0.0.0-stage → 0.0.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.js ADDED
@@ -0,0 +1,969 @@
1
+ import { ConfigurationError } from '@relaybus/core';
2
+ import { Pool } from 'pg';
3
+
4
+ // src/pg-executor.ts
5
+ var PgExecutor = class {
6
+ #pool;
7
+ constructor(pool) {
8
+ this.#pool = pool;
9
+ }
10
+ async transaction(work) {
11
+ const client = await this.#pool.connect();
12
+ try {
13
+ await client.query("BEGIN");
14
+ const result = await work(client);
15
+ await client.query("COMMIT");
16
+ client.release();
17
+ return result;
18
+ } catch (error) {
19
+ await this.#rollbackAndRelease(client);
20
+ throw error;
21
+ }
22
+ }
23
+ async query(sql, parameters, transaction) {
24
+ const result = await (transaction ?? this.#pool).query(sql, [...parameters]);
25
+ return result.rows;
26
+ }
27
+ /** A client whose rollback fails is in an unknown state, so it is destroyed instead of reused. */
28
+ async #rollbackAndRelease(client) {
29
+ try {
30
+ await client.query("ROLLBACK");
31
+ client.release();
32
+ } catch {
33
+ client.release(true);
34
+ }
35
+ }
36
+ };
37
+ var IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_]*$/;
38
+ var MAXIMUM_IDENTIFIER_LENGTH = 63;
39
+ function assertIdentifier(label, value) {
40
+ if (!IDENTIFIER_PATTERN.test(value) || value.length > MAXIMUM_IDENTIFIER_LENGTH) {
41
+ throw new ConfigurationError(
42
+ `${label} '${value}' must match ${String(IDENTIFIER_PATTERN)} and be at most ${MAXIMUM_IDENTIFIER_LENGTH} characters`
43
+ );
44
+ }
45
+ }
46
+ function quoteIdentifier(value) {
47
+ return `"${value}"`;
48
+ }
49
+
50
+ // src/names.ts
51
+ var DEFAULT_SCHEMA = "public";
52
+ var DEFAULT_TABLE_PREFIX = "relaybus_";
53
+ var TABLE_PREFIX_PATTERN = /^[a-z0-9_]*$/;
54
+ function resolvePostgresNames(options = {}) {
55
+ const schema = options.schema ?? DEFAULT_SCHEMA;
56
+ const tablePrefix = options.tablePrefix ?? DEFAULT_TABLE_PREFIX;
57
+ assertIdentifier("schema", schema);
58
+ if (!TABLE_PREFIX_PATTERN.test(tablePrefix)) {
59
+ throw new ConfigurationError(
60
+ `tablePrefix '${tablePrefix}' must match ${String(TABLE_PREFIX_PATTERN)}`
61
+ );
62
+ }
63
+ const name = (suffix) => {
64
+ const identifier = `${tablePrefix}${suffix}`;
65
+ assertIdentifier(`tablePrefix '${tablePrefix}' produces the name`, identifier);
66
+ return identifier;
67
+ };
68
+ const quotedSchema = quoteIdentifier(schema);
69
+ const table = (suffix) => `${quotedSchema}.${quoteIdentifier(name(suffix))}`;
70
+ const index = (suffix) => quoteIdentifier(name(suffix));
71
+ return {
72
+ schema,
73
+ quotedSchema,
74
+ events: table("events"),
75
+ deliveries: table("deliveries"),
76
+ keySequences: table("key_sequences"),
77
+ indexes: {
78
+ deliveryEventHandler: index("deliveries_event_handler_unique"),
79
+ deliveryDue: index("deliveries_due_idx"),
80
+ deliveryDueUnkeyed: index("deliveries_due_unkeyed_idx"),
81
+ deliveryHandlerCreated: index("deliveries_handler_created_idx"),
82
+ eventCreated: index("events_created_idx"),
83
+ deliveryKeyUnfinished: index("deliveries_key_unfinished_idx"),
84
+ deliveryKeyProcessing: index("deliveries_key_processing_idx"),
85
+ deliveryStatusCreated: index("deliveries_status_created_idx"),
86
+ deliveryCompleted: index("deliveries_completed_idx"),
87
+ deliveryDead: index("deliveries_dead_idx")
88
+ }
89
+ };
90
+ }
91
+
92
+ // src/sql-fragments.ts
93
+ function dueAtExpression(alias) {
94
+ const prefix = alias === void 0 ? "" : `${alias}.`;
95
+ return `CASE WHEN ${prefix}status = 'pending' THEN ${prefix}available_at ELSE ${prefix}lease_expires_at END`;
96
+ }
97
+ function isoText(expression) {
98
+ return `to_char(${expression} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
99
+ }
100
+
101
+ // src/schema.ts
102
+ function createPostgresSchemaStatements(options = {}) {
103
+ const names = resolvePostgresNames(options);
104
+ const unfinished = `status IN ('pending', 'processing')`;
105
+ const statements = [
106
+ `CREATE TABLE IF NOT EXISTS ${names.events} (
107
+ id text PRIMARY KEY,
108
+ type text NOT NULL,
109
+ source text NOT NULL,
110
+ key text,
111
+ sequence bigint,
112
+ payload jsonb NOT NULL,
113
+ headers jsonb NOT NULL DEFAULT '{}'::jsonb,
114
+ correlation_id text,
115
+ causation_id text,
116
+ occurred_at timestamptz NOT NULL,
117
+ created_at timestamptz NOT NULL
118
+ )`,
119
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.eventCreated} ON ${names.events} (created_at, id)`,
120
+ `CREATE TABLE IF NOT EXISTS ${names.keySequences} (
121
+ key text PRIMARY KEY,
122
+ sequence bigint NOT NULL
123
+ )`,
124
+ `CREATE TABLE IF NOT EXISTS ${names.deliveries} (
125
+ id text COLLATE "C" PRIMARY KEY,
126
+ event_id text NOT NULL REFERENCES ${names.events} (id) ON DELETE CASCADE,
127
+ handler text NOT NULL,
128
+ status text NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'dead')),
129
+ attempts integer NOT NULL DEFAULT 0,
130
+ available_at timestamptz NOT NULL,
131
+ lease_owner text,
132
+ lease_token text,
133
+ lease_expires_at timestamptz,
134
+ last_error jsonb,
135
+ key text,
136
+ sequence bigint,
137
+ created_at timestamptz NOT NULL,
138
+ updated_at timestamptz NOT NULL,
139
+ completed_at timestamptz,
140
+ CONSTRAINT ${names.indexes.deliveryEventHandler} UNIQUE (event_id, handler)
141
+ )`,
142
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDue} ON ${names.deliveries} ((${dueAtExpression()}), id) WHERE ${unfinished}`,
143
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDueUnkeyed} ON ${names.deliveries} ((${dueAtExpression()}), id) WHERE key IS NULL AND ${unfinished}`,
144
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryKeyUnfinished} ON ${names.deliveries} (handler, key, sequence) WHERE key IS NOT NULL AND ${unfinished}`,
145
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryKeyProcessing} ON ${names.deliveries} (handler, key) WHERE key IS NOT NULL AND status = 'processing'`,
146
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryStatusCreated} ON ${names.deliveries} (status, created_at, id)`,
147
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryHandlerCreated} ON ${names.deliveries} (handler, created_at, id)`,
148
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryCompleted} ON ${names.deliveries} (completed_at, id) WHERE status = 'completed'`,
149
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDead} ON ${names.deliveries} (updated_at, id) WHERE status = 'dead'`
150
+ ];
151
+ if (names.schema !== "public") {
152
+ statements.unshift(`CREATE SCHEMA IF NOT EXISTS ${names.quotedSchema}`);
153
+ }
154
+ return statements;
155
+ }
156
+ function createPostgresSchemaSql(options = {}) {
157
+ return `${createPostgresSchemaStatements(options).join(";\n\n")};
158
+ `;
159
+ }
160
+
161
+ // src/row-mapping.ts
162
+ function text(row, column) {
163
+ return row[column];
164
+ }
165
+ function nullableText(row, column) {
166
+ return row[column];
167
+ }
168
+ function nullableDate(row, column) {
169
+ const value = nullableText(row, column);
170
+ return value === null ? null : new Date(value);
171
+ }
172
+ function readCount(row, column) {
173
+ return Number(row[column]);
174
+ }
175
+ function mapEventEnvelope(row) {
176
+ return {
177
+ id: text(row, "event_id"),
178
+ type: text(row, "event_type"),
179
+ source: text(row, "event_source"),
180
+ key: nullableText(row, "event_key"),
181
+ payload: JSON.parse(text(row, "event_payload")),
182
+ headers: JSON.parse(text(row, "event_headers")),
183
+ correlationId: nullableText(row, "event_correlation_id"),
184
+ causationId: nullableText(row, "event_causation_id"),
185
+ occurredAt: new Date(text(row, "event_occurred_at"))
186
+ };
187
+ }
188
+ function mapClaimedDelivery(row, leaseExpiresAt) {
189
+ return {
190
+ deliveryId: text(row, "delivery_id"),
191
+ leaseToken: text(row, "lease_token"),
192
+ handler: text(row, "handler"),
193
+ attempt: readCount(row, "attempts"),
194
+ leaseExpiresAt,
195
+ event: mapEventEnvelope(row)
196
+ };
197
+ }
198
+ function mapDeliveryRecord(row) {
199
+ const lastError = nullableText(row, "last_error");
200
+ return {
201
+ id: text(row, "delivery_id"),
202
+ handler: text(row, "handler"),
203
+ status: text(row, "status"),
204
+ attempts: readCount(row, "attempts"),
205
+ availableAt: new Date(text(row, "available_at")),
206
+ leaseOwner: nullableText(row, "lease_owner"),
207
+ leaseExpiresAt: nullableDate(row, "lease_expires_at"),
208
+ lastError: lastError === null ? null : JSON.parse(lastError),
209
+ createdAt: new Date(text(row, "created_at")),
210
+ updatedAt: new Date(text(row, "updated_at")),
211
+ completedAt: nullableDate(row, "completed_at"),
212
+ event: mapEventEnvelope(row)
213
+ };
214
+ }
215
+ function mapOldestDueAt(rows) {
216
+ return rows.length === 0 ? null : new Date(text(rows[0], "oldest_due_at"));
217
+ }
218
+
219
+ // src/statements.ts
220
+ var DEFAULT_LIST_LIMIT = 100;
221
+ function eventColumns(alias) {
222
+ return `${alias}.id AS event_id,
223
+ ${alias}.type AS event_type,
224
+ ${alias}.source AS event_source,
225
+ ${alias}.key AS event_key,
226
+ ${alias}.payload::text AS event_payload,
227
+ ${alias}.headers::text AS event_headers,
228
+ ${alias}.correlation_id AS event_correlation_id,
229
+ ${alias}.causation_id AS event_causation_id,
230
+ ${isoText(`${alias}.occurred_at`)} AS event_occurred_at`;
231
+ }
232
+ function countChanged(dml) {
233
+ return `WITH changed AS (${dml} RETURNING 1) SELECT count(*)::int AS count FROM changed`;
234
+ }
235
+ var CLAIM_SCAN_WINDOW = 256;
236
+ var UNFINISHED_PREDICATE = (alias) => `${alias}.status IN ('pending', 'processing')`;
237
+ var HANDLER_PREDICATE = `delivery.handler = ANY (ARRAY(SELECT jsonb_array_elements_text($2::jsonb)))`;
238
+ var CLEAR_LEASE = "lease_owner = NULL, lease_token = NULL, lease_expires_at = NULL";
239
+ var PostgresStatements = class {
240
+ #names;
241
+ constructor(names) {
242
+ this.#names = names;
243
+ }
244
+ /**
245
+ * Lock advisory key text for {@link setupLock}. It is derived from the schema
246
+ * name only, so stores with different table prefixes in one schema also
247
+ * serialize their `CREATE SCHEMA IF NOT EXISTS`, which is not race-free.
248
+ */
249
+ get setupLockKey() {
250
+ return `relaybus:${this.#names.schema}`;
251
+ }
252
+ get setupLock() {
253
+ return "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))";
254
+ }
255
+ /**
256
+ * Reserves sequence numbers per key. Parameters: `[{ key, count }]` as JSON.
257
+ * Keys are locked in sorted order, so publishers cannot deadlock within one
258
+ * call (across calls of one transaction they can, see the README); each row lock is held until the publishing transaction ends, which makes
259
+ * sequence order equal commit order for a key.
260
+ */
261
+ get reserveSequences() {
262
+ return `INSERT INTO ${this.#names.keySequences} AS reserved (key, sequence)
263
+ SELECT requested.key, requested.count
264
+ FROM jsonb_to_recordset($1::jsonb) AS requested (key text, count bigint)
265
+ ORDER BY requested.key
266
+ ON CONFLICT (key) DO UPDATE SET sequence = reserved.sequence + EXCLUDED.sequence
267
+ RETURNING reserved.key AS key, reserved.sequence::text AS sequence`;
268
+ }
269
+ /** Parameters: `[eventsJson, createdAt]`. Returns the ids that were inserted. */
270
+ get insertEvents() {
271
+ return `INSERT INTO ${this.#names.events}
272
+ (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at)
273
+ SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
274
+ COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
275
+ incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz
276
+ FROM jsonb_to_recordset($1::jsonb) AS incoming (
277
+ id text, type text, source text, key text, sequence bigint, payload jsonb, headers jsonb,
278
+ correlation_id text, causation_id text, occurred_at timestamptz)
279
+ ORDER BY incoming.id
280
+ ON CONFLICT (id) DO NOTHING
281
+ RETURNING id`;
282
+ }
283
+ /**
284
+ * Parameters: `[deliveriesJson, createdAt]`. Key and sequence are copied from
285
+ * the stored event, so a delivery added later to an existing event keeps the
286
+ * event's position in its key. Returns the ids that were inserted.
287
+ */
288
+ get insertDeliveries() {
289
+ return `INSERT INTO ${this.#names.deliveries}
290
+ (id, event_id, handler, status, attempts, available_at, key, sequence, created_at, updated_at)
291
+ SELECT incoming.id, incoming.event_id, incoming.handler, 'pending', 0, incoming.available_at,
292
+ event.key, event.sequence, $2::timestamptz, $2::timestamptz
293
+ FROM jsonb_to_recordset($1::jsonb) AS incoming (id text, event_id text, handler text, available_at timestamptz)
294
+ JOIN ${this.#names.events} AS event ON event.id = incoming.event_id
295
+ ORDER BY incoming.event_id, incoming.handler
296
+ ON CONFLICT (event_id, handler) DO NOTHING
297
+ RETURNING id`;
298
+ }
299
+ /**
300
+ * Fast claim: leases from the first {@link CLAIM_SCAN_WINDOW} due deliveries
301
+ * in due order, which is everything the claim needs while the front of the
302
+ * queue is not clogged with blocked followers of busy keys. Parameters:
303
+ * `[now, handlersJson, limit, leaseOwner, leaseExpiresAt]`.
304
+ *
305
+ * Always returns one row per leased delivery, or a single row whose
306
+ * `delivery_id` is null when nothing was leased. `window_saturated` says
307
+ * whether the window was full: when it is and fewer than `limit` deliveries
308
+ * were leased, deliveries beyond the window may still be claimable and the
309
+ * caller runs {@link claimBehindBlockedKeys}. When the window is not
310
+ * saturated every due delivery was examined, so the result is complete.
311
+ *
312
+ * Candidates are chosen with `FOR UPDATE SKIP LOCKED`, so two claimers never
313
+ * lock the same row. A keyed delivery is a candidate only when it is the head
314
+ * of its (handler, key), that is (a) no earlier delivery of that handler and
315
+ * key is pending or processing, which is the ordering guarantee, and (b) no
316
+ * other delivery of that handler and key holds a live lease. Rule b covers a
317
+ * delivery that re-enters the key behind a running one (requeue, or a handler
318
+ * added to an old event); an expired lease does not count, because its worker
319
+ * is presumed dead and the key must not stall behind it.
320
+ *
321
+ * Why SKIP LOCKED alone is enough for ordering: only the head of a key can
322
+ * ever be a candidate, and the head is a single row. Two claimers racing
323
+ * for it either skip it (the other holds the row lock) or, if the other
324
+ * already committed, re-check the row's new version (now processing and not
325
+ * due), which excludes it. Followers stay blocked because their predecessor
326
+ * remains pending or processing in every snapshot until it completes or is
327
+ * buried, and that commit happens-before the next claim that sees it.
328
+ */
329
+ get claim() {
330
+ const deliveries = this.#names.deliveries;
331
+ return `WITH due_window AS MATERIALIZED (
332
+ SELECT delivery.id
333
+ FROM ${deliveries} AS delivery
334
+ WHERE ${UNFINISHED_PREDICATE("delivery")}
335
+ AND ${dueAtExpression("delivery")} <= $1::timestamptz
336
+ AND ${HANDLER_PREDICATE}
337
+ ORDER BY ${dueAtExpression("delivery")}, delivery.id
338
+ LIMIT ${CLAIM_SCAN_WINDOW}
339
+ ), candidates AS MATERIALIZED (
340
+ SELECT delivery.id, ${dueAtExpression("delivery")} AS due_at
341
+ FROM due_window
342
+ JOIN ${deliveries} AS delivery ON delivery.id = due_window.id
343
+ WHERE ${UNFINISHED_PREDICATE("delivery")}
344
+ AND ${dueAtExpression("delivery")} <= $1::timestamptz
345
+ AND (
346
+ delivery.key IS NULL
347
+ OR NOT (
348
+ EXISTS (
349
+ SELECT 1 FROM ${deliveries} AS earlier
350
+ WHERE earlier.key IS NOT NULL AND earlier.handler = delivery.handler AND earlier.key = delivery.key
351
+ AND ${UNFINISHED_PREDICATE("earlier")}
352
+ AND earlier.sequence < delivery.sequence
353
+ )
354
+ OR ${this.#liveLeaseElsewhere()}
355
+ )
356
+ )
357
+ ORDER BY due_at, delivery.id
358
+ LIMIT $3::int
359
+ FOR UPDATE OF delivery SKIP LOCKED
360
+ ), ${this.#leaseCandidates()}
361
+ SELECT (SELECT count(*) FROM due_window) >= ${CLAIM_SCAN_WINDOW} AS window_saturated, leased.*
362
+ FROM (SELECT 1) AS anchor
363
+ LEFT JOIN (${this.#leasedRows()}) AS leased ON true
364
+ ORDER BY leased.due_at, leased.delivery_id`;
365
+ }
366
+ /**
367
+ * Slow claim, run when {@link claim} leased too few deliveries from a full
368
+ * window: the front of the due queue is mostly followers of blocked keys.
369
+ * Same parameters, rules and locking as {@link claim}, but heads are found
370
+ * with `DISTINCT ON (handler, key)` over the unfinished-key index, so the
371
+ * cost follows the number of unfinished keyed deliveries (one index-only
372
+ * scan) instead of one probe per due follower.
373
+ */
374
+ get claimBehindBlockedKeys() {
375
+ const deliveries = this.#names.deliveries;
376
+ return `WITH heads AS MATERIALIZED (
377
+ SELECT DISTINCT ON (head.handler, head.key) head.id
378
+ FROM ${deliveries} AS head
379
+ WHERE head.key IS NOT NULL
380
+ AND ${UNFINISHED_PREDICATE("head")}
381
+ AND head.handler = ANY (ARRAY(SELECT jsonb_array_elements_text($2::jsonb)))
382
+ ORDER BY head.handler, head.key, head.sequence
383
+ ), keyed AS MATERIALIZED (
384
+ SELECT delivery.id, ${dueAtExpression("delivery")} AS due_at
385
+ FROM heads
386
+ JOIN ${deliveries} AS delivery ON delivery.id = heads.id
387
+ WHERE ${UNFINISHED_PREDICATE("delivery")}
388
+ AND ${dueAtExpression("delivery")} <= $1::timestamptz
389
+ AND NOT ${this.#liveLeaseElsewhere()}
390
+ ORDER BY due_at, delivery.id
391
+ LIMIT $3::int
392
+ FOR UPDATE OF delivery SKIP LOCKED
393
+ ), unkeyed AS MATERIALIZED (
394
+ SELECT delivery.id, ${dueAtExpression("delivery")} AS due_at
395
+ FROM ${deliveries} AS delivery
396
+ WHERE delivery.key IS NULL
397
+ AND ${UNFINISHED_PREDICATE("delivery")}
398
+ AND ${dueAtExpression("delivery")} <= $1::timestamptz
399
+ AND ${HANDLER_PREDICATE}
400
+ ORDER BY due_at, delivery.id
401
+ LIMIT $3::int
402
+ FOR UPDATE OF delivery SKIP LOCKED
403
+ ), candidates AS MATERIALIZED (
404
+ SELECT id, due_at FROM (
405
+ SELECT id, due_at FROM keyed
406
+ UNION ALL
407
+ SELECT id, due_at FROM unkeyed
408
+ ) AS both_sets
409
+ ORDER BY due_at, id
410
+ LIMIT $3::int
411
+ ), ${this.#leaseCandidates()}
412
+ SELECT false AS window_saturated, leased.*
413
+ FROM (SELECT 1) AS anchor
414
+ LEFT JOIN (${this.#leasedRows()}) AS leased ON true
415
+ ORDER BY leased.due_at, leased.delivery_id`;
416
+ }
417
+ /** True when another delivery of the candidate's handler and key holds a live lease. */
418
+ #liveLeaseElsewhere() {
419
+ return `EXISTS (
420
+ SELECT 1 FROM ${this.#names.deliveries} AS running
421
+ WHERE running.key IS NOT NULL
422
+ AND running.handler = delivery.handler AND running.key = delivery.key
423
+ AND running.status = 'processing' AND running.lease_expires_at > $1::timestamptz
424
+ AND running.id <> delivery.id
425
+ )`;
426
+ }
427
+ /** The `claimed` CTE: leases every row of `candidates`. */
428
+ #leaseCandidates() {
429
+ return `claimed AS (
430
+ UPDATE ${this.#names.deliveries} AS target
431
+ SET status = 'processing',
432
+ attempts = target.attempts + 1,
433
+ lease_owner = $4::text,
434
+ lease_token = gen_random_uuid()::text,
435
+ lease_expires_at = $5::timestamptz,
436
+ updated_at = $1::timestamptz
437
+ FROM candidates
438
+ WHERE target.id = candidates.id
439
+ RETURNING target.id, target.event_id, target.handler, target.attempts, target.lease_token, candidates.due_at
440
+ )`;
441
+ }
442
+ /** Leased deliveries joined with their events, as the claim result columns. */
443
+ #leasedRows() {
444
+ return `SELECT claimed.due_at, claimed.id AS delivery_id, claimed.handler, claimed.attempts::int AS attempts, claimed.lease_token,
445
+ ${eventColumns("event")}
446
+ FROM claimed
447
+ JOIN ${this.#names.events} AS event ON event.id = claimed.event_id`;
448
+ }
449
+ /** Parameters: `[deliveryId, leaseToken, leaseExpiresAt, now]`. */
450
+ get extendLease() {
451
+ return countChanged(`UPDATE ${this.#names.deliveries}
452
+ SET lease_expires_at = $3::timestamptz, updated_at = $4::timestamptz
453
+ WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
454
+ }
455
+ /** Parameters: `[deliveryId, leaseToken, now]`. */
456
+ get complete() {
457
+ return countChanged(`UPDATE ${this.#names.deliveries}
458
+ SET status = 'completed', completed_at = $3::timestamptz, updated_at = $3::timestamptz, ${CLEAR_LEASE}
459
+ WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
460
+ }
461
+ /** Parameters: `[deliveryId, leaseToken, availableAt, errorJson, now]`. */
462
+ get reschedule() {
463
+ return countChanged(`UPDATE ${this.#names.deliveries}
464
+ SET status = 'pending', available_at = $3::timestamptz, last_error = $4::jsonb, updated_at = $5::timestamptz, ${CLEAR_LEASE}
465
+ WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
466
+ }
467
+ /** Parameters: `[deliveryId, leaseToken, errorJson, now]`. */
468
+ get bury() {
469
+ return countChanged(`UPDATE ${this.#names.deliveries}
470
+ SET status = 'dead', last_error = $3::jsonb, updated_at = $4::timestamptz, ${CLEAR_LEASE}
471
+ WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
472
+ }
473
+ /**
474
+ * Gives a leased delivery back without spending its attempt: pending, due
475
+ * `now`, attempt count decremented. Parameters: `[deliveryId, leaseToken, now]`.
476
+ */
477
+ get release() {
478
+ return countChanged(`UPDATE ${this.#names.deliveries}
479
+ SET status = 'pending', available_at = $3::timestamptz, attempts = attempts - 1, updated_at = $3::timestamptz, ${CLEAR_LEASE}
480
+ WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
481
+ }
482
+ /** Parameters: `[deliveryIdsJson, now]`. */
483
+ get requeue() {
484
+ return countChanged(`UPDATE ${this.#names.deliveries}
485
+ SET status = 'pending', attempts = 0, available_at = $2::timestamptz, updated_at = $2::timestamptz
486
+ WHERE id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb))) AND status = 'dead'`);
487
+ }
488
+ /** Builds the filtered, creation-ordered listing. */
489
+ listDeliveries(query) {
490
+ const parameters = [];
491
+ const conditions = [];
492
+ const bind = (value) => {
493
+ parameters.push(value);
494
+ return `$${parameters.length}::${typeof value === "number" ? "int" : "text"}`;
495
+ };
496
+ if (query.status !== void 0) {
497
+ conditions.push(`delivery.status = ${bind(query.status)}`);
498
+ }
499
+ if (query.handler !== void 0) {
500
+ conditions.push(`delivery.handler = ${bind(query.handler)}`);
501
+ }
502
+ if (query.eventType !== void 0) {
503
+ conditions.push(`event.type = ${bind(query.eventType)}`);
504
+ }
505
+ if (query.eventId !== void 0) {
506
+ conditions.push(`delivery.event_id = ${bind(query.eventId)}`);
507
+ }
508
+ const where = conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`;
509
+ const limit = bind(query.limit ?? DEFAULT_LIST_LIMIT);
510
+ const sql = `SELECT delivery.id AS delivery_id, delivery.handler, delivery.status, delivery.attempts::int AS attempts,
511
+ ${isoText("delivery.available_at")} AS available_at,
512
+ delivery.lease_owner,
513
+ ${isoText("delivery.lease_expires_at")} AS lease_expires_at,
514
+ delivery.last_error::text AS last_error,
515
+ ${isoText("delivery.created_at")} AS created_at,
516
+ ${isoText("delivery.updated_at")} AS updated_at,
517
+ ${isoText("delivery.completed_at")} AS completed_at,
518
+ ${eventColumns("event")}
519
+ FROM ${this.#names.deliveries} AS delivery
520
+ JOIN ${this.#names.events} AS event ON event.id = delivery.event_id
521
+ ${where}
522
+ ORDER BY delivery.created_at, delivery.id
523
+ LIMIT ${limit}`;
524
+ return { sql, parameters };
525
+ }
526
+ get countByStatus() {
527
+ return `SELECT status, count(*)::text AS count FROM ${this.#names.deliveries} GROUP BY status`;
528
+ }
529
+ /** Parameters: `[now]`. */
530
+ get oldestDue() {
531
+ return `SELECT ${isoText("due_at")} AS oldest_due_at FROM (
532
+ SELECT ${dueAtExpression("delivery")} AS due_at
533
+ FROM ${this.#names.deliveries} AS delivery
534
+ WHERE delivery.status IN ('pending', 'processing') AND ${dueAtExpression("delivery")} <= $1::timestamptz
535
+ ORDER BY due_at
536
+ LIMIT 1
537
+ ) AS oldest`;
538
+ }
539
+ /**
540
+ * Deletes one bounded batch of purgeable deliveries and returns the event id
541
+ * of each deleted delivery. Parameters: `[cutoff, batchSize]`.
542
+ * `FOR UPDATE SKIP LOCKED` keeps purge from waiting on, or being waited on by, anything.
543
+ */
544
+ purgeCompletedBatch() {
545
+ return this.#purgeBatch(
546
+ `status = 'completed' AND completed_at < $1::timestamptz`,
547
+ "completed_at"
548
+ );
549
+ }
550
+ purgeDeadBatch() {
551
+ return this.#purgeBatch(`status = 'dead' AND updated_at < $1::timestamptz`, "updated_at");
552
+ }
553
+ /**
554
+ * Selects and locks one bounded batch of events created before the cutoff
555
+ * that no delivery references. Parameters: `[cutoff, batchSize]`. The ids go
556
+ * through {@link deleteOrphanEvents}, which re-checks under a fresh snapshot.
557
+ */
558
+ get lockOrphanEventsBatch() {
559
+ return `SELECT event.id FROM ${this.#names.events} AS event
560
+ WHERE event.created_at < $1::timestamptz
561
+ AND NOT EXISTS (SELECT 1 FROM ${this.#names.deliveries} AS delivery WHERE delivery.event_id = event.id)
562
+ ORDER BY event.created_at, event.id
563
+ LIMIT $2::int
564
+ FOR UPDATE OF event SKIP LOCKED`;
565
+ }
566
+ /**
567
+ * Locks the given events, skipping any that a concurrent transaction is
568
+ * referencing from a new delivery (a foreign key check holds `FOR KEY
569
+ * SHARE`, which conflicts with this lock). Parameters: `[eventIdsJson]`.
570
+ */
571
+ get lockEvents() {
572
+ return `SELECT id FROM ${this.#names.events}
573
+ WHERE id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb)))
574
+ FOR UPDATE SKIP LOCKED`;
575
+ }
576
+ /**
577
+ * Deletes locked events that no delivery references. It must run as a
578
+ * statement of its own after {@link lockEvents}: it then takes a fresh
579
+ * snapshot that includes every delivery committed before the lock was
580
+ * granted, so an event that just gained a delivery is never cascaded away.
581
+ * Parameters: `[eventIdsJson]`.
582
+ */
583
+ get deleteOrphanEvents() {
584
+ const events = this.#names.events;
585
+ return countChanged(`DELETE FROM ${events} AS event
586
+ WHERE event.id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb)))
587
+ AND NOT EXISTS (SELECT 1 FROM ${this.#names.deliveries} AS delivery WHERE delivery.event_id = event.id)`);
588
+ }
589
+ #purgeBatch(condition, orderColumn) {
590
+ const deliveries = this.#names.deliveries;
591
+ return `WITH batch AS MATERIALIZED (
592
+ SELECT id FROM ${deliveries}
593
+ WHERE ${condition}
594
+ ORDER BY ${orderColumn}, id
595
+ LIMIT $2::int
596
+ FOR UPDATE SKIP LOCKED
597
+ ), deleted AS (
598
+ DELETE FROM ${deliveries} AS delivery USING batch WHERE delivery.id = batch.id RETURNING delivery.event_id
599
+ )
600
+ SELECT event_id FROM deleted`;
601
+ }
602
+ };
603
+
604
+ // src/postgres-event-store.ts
605
+ var DEFAULT_PURGE_BATCH_SIZE = 1e3;
606
+ var PostgresEventStore = class {
607
+ #executor;
608
+ #statements;
609
+ #schemaStatements;
610
+ #setupLockKey;
611
+ #purgeBatchSize;
612
+ #ownedPool;
613
+ constructor(options) {
614
+ const sources = [options.pool, options.connectionString, options.executor].filter(
615
+ (source) => source !== void 0
616
+ );
617
+ if (sources.length !== 1) {
618
+ throw new ConfigurationError(
619
+ "PostgresEventStore needs exactly one of pool, connectionString or executor"
620
+ );
621
+ }
622
+ const purgeBatchSize = options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE;
623
+ if (!Number.isInteger(purgeBatchSize) || purgeBatchSize < 1) {
624
+ throw new ConfigurationError(
625
+ `purgeBatchSize must be a positive integer, received ${String(purgeBatchSize)}`
626
+ );
627
+ }
628
+ const names = resolvePostgresNames(options);
629
+ this.#statements = new PostgresStatements(names);
630
+ this.#schemaStatements = createPostgresSchemaStatements(options);
631
+ this.#setupLockKey = this.#statements.setupLockKey;
632
+ this.#purgeBatchSize = purgeBatchSize;
633
+ if (options.executor !== void 0) {
634
+ this.#executor = options.executor;
635
+ } else {
636
+ const pool = options.pool ?? new Pool({ ...options.poolConfig, connectionString: options.connectionString });
637
+ if (options.pool === void 0) {
638
+ const logger = options.logger ?? console;
639
+ pool.on("error", (error) => {
640
+ logger.error(
641
+ `[PostgresEventStore](constructor) Idle connection failed, the pool replaces it | error=${error.message}`
642
+ );
643
+ });
644
+ this.#ownedPool = pool;
645
+ }
646
+ this.#executor = new PgExecutor(pool);
647
+ }
648
+ }
649
+ /**
650
+ * Creates the schema, tables and indexes if they do not exist. Safe to call
651
+ * from many processes at once: a transaction-scoped advisory lock serializes
652
+ * the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
653
+ * Skip it (`setupStore: false`) when a migration tool applies
654
+ * `createPostgresSchemaSql()` instead.
655
+ */
656
+ async setup() {
657
+ await this.#executor.transaction(async (transaction) => {
658
+ await this.#executor.query(this.#statements.setupLock, [this.#setupLockKey], transaction);
659
+ for (const statement of this.#schemaStatements) {
660
+ await this.#executor.query(statement, [], transaction);
661
+ }
662
+ });
663
+ }
664
+ transaction(work) {
665
+ return this.#executor.transaction(work);
666
+ }
667
+ /**
668
+ * Writes the entries in `transaction`. Keyed events reserve their sequence
669
+ * numbers first (locking the key rows in sorted order, so publishers cannot
670
+ * deadlock inside one call), then events, then deliveries, each in a single
671
+ * multi-row statement ordered by primary key. Two transactions that append
672
+ * keys in opposite order across several calls can still deadlock (40P01);
673
+ * the database rolls one back and the caller retries.
674
+ * A duplicate event id still consumes a sequence number; gaps are harmless
675
+ * because only the order of sequences matters.
676
+ */
677
+ async append(transaction, entries, now) {
678
+ const plan = planAppend(entries);
679
+ const createdAt = now.toISOString();
680
+ const sequences = await this.#reserveSequences(plan.events, transaction);
681
+ const insertedEventIds = new Set(
682
+ plan.events.length === 0 ? [] : (await this.#executor.query(
683
+ this.#statements.insertEvents,
684
+ [JSON.stringify(toEventRows(plan.events, sequences)), createdAt],
685
+ transaction
686
+ )).map((row) => row["id"])
687
+ );
688
+ const insertedDeliveries = plan.deliveries.length === 0 ? [] : await this.#executor.query(
689
+ this.#statements.insertDeliveries,
690
+ [JSON.stringify(plan.deliveries), createdAt],
691
+ transaction
692
+ );
693
+ const appendedEventIds = [];
694
+ const duplicateEventIds = [];
695
+ for (const { eventId, repeated } of plan.outcomes) {
696
+ (!repeated && insertedEventIds.has(eventId) ? appendedEventIds : duplicateEventIds).push(
697
+ eventId
698
+ );
699
+ }
700
+ return {
701
+ appendedEventIds,
702
+ duplicateEventIds,
703
+ appendedDeliveryCount: insertedDeliveries.length
704
+ };
705
+ }
706
+ /**
707
+ * Leases up to `limit` due deliveries with `FOR UPDATE SKIP LOCKED`. The fast
708
+ * statement examines the first due deliveries; only when they are mostly
709
+ * blocked followers of busy keys does a second statement look at the heads of
710
+ * all keys. See {@link PostgresStatements.claim} for why that keeps a
711
+ * (handler, key) strictly one-at-a-time and in order.
712
+ */
713
+ async claim(request) {
714
+ if (request.handlers.length === 0 || !(request.limit > 0)) {
715
+ return [];
716
+ }
717
+ const parameters = (limit) => [
718
+ request.now.toISOString(),
719
+ JSON.stringify(request.handlers),
720
+ limit,
721
+ request.leaseOwner,
722
+ request.leaseExpiresAt.toISOString()
723
+ ];
724
+ const fastRows = await this.#executor.query(this.#statements.claim, parameters(request.limit));
725
+ const leased = fastRows.filter((row) => row["delivery_id"] !== null);
726
+ if (leased.length < request.limit && fastRows[0]["window_saturated"] === true) {
727
+ leased.push(
728
+ ...(await this.#executor.query(
729
+ this.#statements.claimBehindBlockedKeys,
730
+ parameters(request.limit - leased.length)
731
+ )).filter((row) => row["delivery_id"] !== null)
732
+ );
733
+ }
734
+ return leased.map((row) => mapClaimedDelivery(row, request.leaseExpiresAt));
735
+ }
736
+ extendLease(lease, leaseExpiresAt, now) {
737
+ return this.#changed(this.#statements.extendLease, [
738
+ lease.deliveryId,
739
+ lease.leaseToken,
740
+ leaseExpiresAt.toISOString(),
741
+ now.toISOString()
742
+ ]);
743
+ }
744
+ complete(lease, now, transaction) {
745
+ return this.#changed(
746
+ this.#statements.complete,
747
+ [lease.deliveryId, lease.leaseToken, now.toISOString()],
748
+ transaction
749
+ );
750
+ }
751
+ reschedule(lease, availableAt, error, now) {
752
+ return this.#changed(this.#statements.reschedule, [
753
+ lease.deliveryId,
754
+ lease.leaseToken,
755
+ availableAt.toISOString(),
756
+ JSON.stringify(error),
757
+ now.toISOString()
758
+ ]);
759
+ }
760
+ bury(lease, error, now) {
761
+ return this.#changed(this.#statements.bury, [
762
+ lease.deliveryId,
763
+ lease.leaseToken,
764
+ JSON.stringify(error),
765
+ now.toISOString()
766
+ ]);
767
+ }
768
+ /** Returns a leased delivery to pending, due now, without counting the attempt. */
769
+ release(lease, now) {
770
+ return this.#changed(this.#statements.release, [
771
+ lease.deliveryId,
772
+ lease.leaseToken,
773
+ now.toISOString()
774
+ ]);
775
+ }
776
+ async listDeliveries(query) {
777
+ const statement = this.#statements.listDeliveries(query);
778
+ const rows = await this.#executor.query(statement.sql, statement.parameters);
779
+ return rows.map(mapDeliveryRecord);
780
+ }
781
+ async requeue(deliveryIds, now) {
782
+ const rows = await this.#executor.query(this.#statements.requeue, [
783
+ JSON.stringify(deliveryIds),
784
+ now.toISOString()
785
+ ]);
786
+ return readCount(rows[0], "count");
787
+ }
788
+ /**
789
+ * Deletes purgeable deliveries in batches of `purgeBatchSize`, one short
790
+ * transaction per batch, then the events those batches left without any
791
+ * delivery. Finally, events created before the earliest given cutoff that
792
+ * have no delivery at all are removed: they are what a publish leaves behind
793
+ * when its last delivery was purged in the same instant, or when no handler
794
+ * was subscribed. Events that a concurrent publish is attaching a delivery
795
+ * to are skipped and kept.
796
+ */
797
+ async purge(request) {
798
+ const total = { deletedDeliveries: 0, deletedEvents: 0 };
799
+ const batches = [
800
+ [this.#statements.purgeCompletedBatch(), request.completedBefore],
801
+ [this.#statements.purgeDeadBatch(), request.deadBefore]
802
+ ];
803
+ for (const [sql, cutoff] of batches) {
804
+ if (cutoff === void 0) {
805
+ continue;
806
+ }
807
+ let deletedInBatch = this.#purgeBatchSize;
808
+ while (deletedInBatch === this.#purgeBatchSize) {
809
+ const batch = await this.#purgeBatch(sql, cutoff);
810
+ deletedInBatch = batch.deletedDeliveries;
811
+ total.deletedDeliveries += batch.deletedDeliveries;
812
+ total.deletedEvents += batch.deletedEvents;
813
+ }
814
+ }
815
+ const orphanCutoff = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
816
+ if (orphanCutoff !== void 0) {
817
+ total.deletedEvents += await this.#purgeOrphanEvents(orphanCutoff);
818
+ }
819
+ return total;
820
+ }
821
+ async statistics(now) {
822
+ const counts = {
823
+ pending: 0,
824
+ processing: 0,
825
+ completed: 0,
826
+ dead: 0
827
+ };
828
+ for (const row of await this.#executor.query(this.#statements.countByStatus, [])) {
829
+ counts[row["status"]] = readCount(row, "count");
830
+ }
831
+ const oldestDue = await this.#executor.query(this.#statements.oldestDue, [now.toISOString()]);
832
+ return { ...counts, oldestDueAt: mapOldestDueAt(oldestDue) };
833
+ }
834
+ /** Ends the pool this store created from `connectionString`. Pools and executors you passed in stay open. */
835
+ async close() {
836
+ const pool = this.#ownedPool;
837
+ this.#ownedPool = void 0;
838
+ await pool?.end();
839
+ }
840
+ async #changed(sql, parameters, transaction) {
841
+ const rows = await this.#executor.query(sql, parameters, transaction);
842
+ return readCount(rows[0], "count") > 0;
843
+ }
844
+ async #reserveSequences(events, transaction) {
845
+ const counts = /* @__PURE__ */ new Map();
846
+ for (const event of events) {
847
+ if (event.key !== null) {
848
+ counts.set(event.key, (counts.get(event.key) ?? 0) + 1);
849
+ }
850
+ }
851
+ const firstSequences = /* @__PURE__ */ new Map();
852
+ if (counts.size === 0) {
853
+ return firstSequences;
854
+ }
855
+ const requested = [...counts].map(([key, count]) => ({ key, count }));
856
+ const rows = await this.#executor.query(
857
+ this.#statements.reserveSequences,
858
+ [JSON.stringify(requested)],
859
+ transaction
860
+ );
861
+ for (const row of rows) {
862
+ const key = row["key"];
863
+ firstSequences.set(key, BigInt(row["sequence"]) - BigInt(counts.get(key)) + 1n);
864
+ }
865
+ return firstSequences;
866
+ }
867
+ async #purgeOrphanEvents(cutoff) {
868
+ let deletedEvents = 0;
869
+ let removedInBatch = this.#purgeBatchSize;
870
+ while (removedInBatch === this.#purgeBatchSize) {
871
+ removedInBatch = await this.#executor.transaction(async (transaction) => {
872
+ const locked = await this.#executor.query(
873
+ this.#statements.lockOrphanEventsBatch,
874
+ [cutoff.toISOString(), this.#purgeBatchSize],
875
+ transaction
876
+ );
877
+ const removed = await this.#executor.query(
878
+ this.#statements.deleteOrphanEvents,
879
+ [JSON.stringify(locked.map((row) => row["id"]))],
880
+ transaction
881
+ );
882
+ return readCount(removed[0], "count");
883
+ });
884
+ deletedEvents += removedInBatch;
885
+ }
886
+ return deletedEvents;
887
+ }
888
+ async #purgeBatch(sql, cutoff) {
889
+ return this.#executor.transaction(async (transaction) => {
890
+ const deleted = await this.#executor.query(
891
+ sql,
892
+ [cutoff.toISOString(), this.#purgeBatchSize],
893
+ transaction
894
+ );
895
+ if (deleted.length === 0) {
896
+ return { deletedDeliveries: 0, deletedEvents: 0 };
897
+ }
898
+ const eventIds = JSON.stringify([
899
+ ...new Set(deleted.map((row) => row["event_id"]))
900
+ ]);
901
+ const locked = await this.#executor.query(
902
+ this.#statements.lockEvents,
903
+ [eventIds],
904
+ transaction
905
+ );
906
+ const removed = await this.#executor.query(
907
+ this.#statements.deleteOrphanEvents,
908
+ [JSON.stringify(locked.map((row) => row["id"]))],
909
+ transaction
910
+ );
911
+ return { deletedDeliveries: deleted.length, deletedEvents: readCount(removed[0], "count") };
912
+ });
913
+ }
914
+ };
915
+ function planAppend(entries) {
916
+ const events = [];
917
+ const deliveries = [];
918
+ const outcomes = [];
919
+ const seenEventIds = /* @__PURE__ */ new Set();
920
+ const seenDeliveries = /* @__PURE__ */ new Set();
921
+ for (const { event, deliveries: newDeliveries } of entries) {
922
+ const repeated = seenEventIds.has(event.id);
923
+ seenEventIds.add(event.id);
924
+ outcomes.push({ eventId: event.id, repeated });
925
+ if (!repeated) {
926
+ events.push(event);
927
+ }
928
+ for (const delivery of newDeliveries) {
929
+ const identity = `${event.id}\0${delivery.handler}`;
930
+ if (!seenDeliveries.has(identity)) {
931
+ seenDeliveries.add(identity);
932
+ deliveries.push({
933
+ id: delivery.id,
934
+ event_id: event.id,
935
+ handler: delivery.handler,
936
+ available_at: delivery.availableAt.toISOString()
937
+ });
938
+ }
939
+ }
940
+ }
941
+ return { events, deliveries, outcomes };
942
+ }
943
+ function toEventRows(events, firstSequences) {
944
+ const nextSequences = new Map(firstSequences);
945
+ return events.map((event) => {
946
+ let sequence = null;
947
+ if (event.key !== null) {
948
+ const next = nextSequences.get(event.key);
949
+ nextSequences.set(event.key, next + 1n);
950
+ sequence = next.toString();
951
+ }
952
+ return {
953
+ id: event.id,
954
+ type: event.type,
955
+ source: event.source,
956
+ key: event.key,
957
+ sequence,
958
+ payload: event.payload,
959
+ headers: event.headers,
960
+ correlation_id: event.correlationId,
961
+ causation_id: event.causationId,
962
+ occurred_at: event.occurredAt.toISOString()
963
+ };
964
+ });
965
+ }
966
+
967
+ export { PgExecutor, PostgresEventStore, createPostgresSchemaSql, createPostgresSchemaStatements, resolvePostgresNames };
968
+ //# sourceMappingURL=index.js.map
969
+ //# sourceMappingURL=index.js.map