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