@relaybus/postgres 0.3.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { ConfigurationError, RelayBusError, systemClock } from '@relaybus/core';
3
- import { assertPositiveInteger, schedule } from '@relaybus/core/internal';
3
+ import { LaneLeaseTokenFormat, HandedOutLedger, PositiveInteger, schedule, OwnerSerializedClaims, LeaseSettlement, CompositeKey, DeliveryLeaseIdentity } from '@relaybus/core/internal';
4
4
  import { Client, Pool } from 'pg';
5
5
 
6
6
  // src/pg-executor.ts
@@ -187,6 +187,26 @@ var DEFAULT_SCHEMA = "public";
187
187
  var MAXIMUM_CHANNEL_LENGTH = 63;
188
188
  var DEFAULT_TABLE_PREFIX = "relaybus_";
189
189
  var TABLE_PREFIX_PATTERN = /^[a-z0-9_]*$/;
190
+ function indexNames(index) {
191
+ return {
192
+ deliveryEventHandler: index("deliveries_event_handler_unique"),
193
+ deliveryLaneDue: index("deliveries_lane_due_idx"),
194
+ deliveryKeyPending: index("deliveries_key_pending_idx"),
195
+ eventCreated: index("events_created_idx"),
196
+ deliveryCompleted: index("deliveries_completed_idx"),
197
+ deliveryDead: index("deliveries_dead_idx"),
198
+ completionCompleted: index("completed_deliveries_completed_idx"),
199
+ eventCompletionsCompleted: index("event_completions_completed_idx"),
200
+ obsolete: [
201
+ index("deliveries_due_idx"),
202
+ index("deliveries_due_unkeyed_idx"),
203
+ index("deliveries_handler_created_idx"),
204
+ index("deliveries_key_unfinished_idx"),
205
+ index("deliveries_key_processing_idx"),
206
+ index("deliveries_status_created_idx")
207
+ ]
208
+ };
209
+ }
190
210
  function resolvePostgresNames(options = {}) {
191
211
  const schema = options.schema ?? DEFAULT_SCHEMA;
192
212
  const tablePrefix = options.tablePrefix ?? DEFAULT_TABLE_PREFIX;
@@ -216,24 +236,7 @@ function resolvePostgresNames(options = {}) {
216
236
  completions: table("completed_deliveries"),
217
237
  completionHistory: options.completionHistory ?? false,
218
238
  notificationChannel: channelName(`${schema}.${tablePrefix}deliveries`),
219
- indexes: {
220
- deliveryEventHandler: index("deliveries_event_handler_unique"),
221
- deliveryLaneDue: index("deliveries_lane_due_idx"),
222
- deliveryKeyPending: index("deliveries_key_pending_idx"),
223
- eventCreated: index("events_created_idx"),
224
- deliveryCompleted: index("deliveries_completed_idx"),
225
- deliveryDead: index("deliveries_dead_idx"),
226
- completionCompleted: index("completed_deliveries_completed_idx"),
227
- eventCompletionsCompleted: index("event_completions_completed_idx"),
228
- obsolete: [
229
- index("deliveries_due_idx"),
230
- index("deliveries_due_unkeyed_idx"),
231
- index("deliveries_handler_created_idx"),
232
- index("deliveries_key_unfinished_idx"),
233
- index("deliveries_key_processing_idx"),
234
- index("deliveries_status_created_idx")
235
- ]
236
- }
239
+ indexes: indexNames(index)
237
240
  };
238
241
  }
239
242
  var CHANNEL_HASH_DIGITS = 32;
@@ -264,6 +267,9 @@ function createPostgresSchemaStatements(options = {}) {
264
267
  return statements;
265
268
  }
266
269
  function eventAndDeliveryStatements(names) {
270
+ return [...eventAndKeySequenceStatements(names), ...deliveryStatements(names)];
271
+ }
272
+ function eventAndKeySequenceStatements(names) {
267
273
  return [
268
274
  `CREATE TABLE IF NOT EXISTS ${names.events} (
269
275
  id text PRIMARY KEY,
@@ -283,7 +289,11 @@ function eventAndDeliveryStatements(names) {
283
289
  `CREATE TABLE IF NOT EXISTS ${names.keySequences} (
284
290
  key text PRIMARY KEY,
285
291
  sequence bigint NOT NULL
286
- )`,
292
+ )`
293
+ ];
294
+ }
295
+ function deliveryStatements(names) {
296
+ return [
287
297
  `CREATE TABLE IF NOT EXISTS ${names.deliveries} (
288
298
  id text COLLATE "C" PRIMARY KEY,
289
299
  event_id text NOT NULL REFERENCES ${names.events} (id) ON DELETE CASCADE,
@@ -375,6 +385,16 @@ function createPostgresSchemaSql(options = {}) {
375
385
  `;
376
386
  }
377
387
  var RECONNECT_DELAY_MS = 1e3;
388
+ function endOnce(connection, ended) {
389
+ let ending = Promise.resolve();
390
+ return () => {
391
+ if (!ended.value) {
392
+ ended.value = true;
393
+ ending = connection.end().catch(() => void 0);
394
+ }
395
+ return ending;
396
+ };
397
+ }
378
398
  var NotificationListener = class {
379
399
  #connect;
380
400
  #channel;
@@ -393,15 +413,8 @@ var NotificationListener = class {
393
413
  /** Connects and listens. Rejects when that fails; a connection lost later is replaced. */
394
414
  async start() {
395
415
  const connection = await this.#connect();
396
- let ended = false;
397
- let ending = Promise.resolve();
398
- const end = () => {
399
- if (!ended) {
400
- ended = true;
401
- ending = connection.end().catch(() => void 0);
402
- }
403
- return ending;
404
- };
416
+ const ended = { value: false };
417
+ const end = endOnce(connection, ended);
405
418
  if (this.#closed) {
406
419
  await end();
407
420
  return;
@@ -409,7 +422,7 @@ var NotificationListener = class {
409
422
  this.#drop = end;
410
423
  let listening = false;
411
424
  const onLost = (error) => {
412
- if (!listening || ended) {
425
+ if (!listening || ended.value) {
413
426
  return;
414
427
  }
415
428
  void end();
@@ -425,14 +438,8 @@ var NotificationListener = class {
425
438
  try {
426
439
  await connection.query(`LISTEN "${this.#channel}"`);
427
440
  } catch (error) {
428
- if (this.#drop === end) {
429
- this.#drop = void 0;
430
- }
431
- await end();
432
- if (this.#closed) {
433
- return;
434
- }
435
- throw error;
441
+ await this.#abandonFailedListen(end, error);
442
+ return;
436
443
  }
437
444
  if (this.#closed) {
438
445
  await end();
@@ -440,6 +447,17 @@ var NotificationListener = class {
440
447
  }
441
448
  listening = true;
442
449
  }
450
+ /** Forgets and ends a connection whose LISTEN failed; rethrows the failure unless closing caused it. */
451
+ async #abandonFailedListen(end, error) {
452
+ if (this.#drop === end) {
453
+ this.#drop = void 0;
454
+ }
455
+ await end();
456
+ if (this.#closed) {
457
+ return;
458
+ }
459
+ throw error;
460
+ }
443
461
  #scheduleReconnect() {
444
462
  this.#reconnectTimer = schedule(systemClock, {
445
463
  run: () => {
@@ -474,7 +492,18 @@ var NotificationListener = class {
474
492
  }
475
493
  };
476
494
 
477
- // src/row-mapping.ts
495
+ // src/root-pool-executor.ts
496
+ function withRootPool(executor, root) {
497
+ return {
498
+ ...executor.sessionTimeouts === void 0 ? {} : { sessionTimeouts: executor.sessionTimeouts },
499
+ transaction: (work) => executor.transaction(work),
500
+ ...executor.transactionWithBudget === void 0 ? {} : {
501
+ transactionWithBudget: (work, timeoutMs) => executor.transactionWithBudget(work, timeoutMs)
502
+ },
503
+ // eslint-disable-next-line relaybus/max-two-params -- PostgresExecutor port signature
504
+ query: (sql, parameters, transaction, options) => transaction === void 0 ? root.query(sql, parameters, void 0, options) : executor.query(sql, parameters, transaction, options)
505
+ };
506
+ }
478
507
  function text(row, column) {
479
508
  return row[column];
480
509
  }
@@ -501,16 +530,16 @@ function mapEventEnvelope(row) {
501
530
  occurredAt: new Date(text(row, "event_occurred_at"))
502
531
  };
503
532
  }
533
+ var LEASE_TOKENS = new LaneLeaseTokenFormat({
534
+ laneCount: LANE_COUNT,
535
+ epochDigits: 18,
536
+ claimsDigits: 9
537
+ });
504
538
  function formatLeaseToken(lane, { epoch, claims }) {
505
- return `${lane}:${epoch}:${claims}`;
539
+ return LEASE_TOKENS.format(lane, { epoch, claims });
506
540
  }
507
- var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,18}):(\d{1,9})$/;
508
541
  function parseLeaseToken(token) {
509
- const match = LEASE_TOKEN_PATTERN.exec(token);
510
- if (match === null || Number(match[1]) >= LANE_COUNT) {
511
- return void 0;
512
- }
513
- return { lane: Number(match[1]), epoch: match[2], claims: Number(match[3]) };
542
+ return LEASE_TOKENS.parse(token);
514
543
  }
515
544
  function mapClaimedDelivery(row, leaseExpiresAt) {
516
545
  return {
@@ -562,22 +591,11 @@ var PostgresAppend = class {
562
591
  const createdAt = now.toISOString();
563
592
  const wakeAt = this.#wakeAt(plan.deliveries, { now, transaction });
564
593
  const handlersByEvent = handlersByEventOf(plan.deliveries);
565
- const keyCounts = keyCountsOf(plan.events);
566
- const fresh = await this.#state.query({
567
- sql: this.#state.statements.append.insertFreshEvents,
568
- parameters: [
569
- JSON.stringify(
570
- plan.events.map((event, ordinal) => ({
571
- ordinal,
572
- ...toEventRow(event, { sequence: null, handlers: handlersByEvent.get(event.id) ?? [] })
573
- }))
574
- ),
575
- createdAt,
576
- JSON.stringify(plan.deliveries),
577
- JSON.stringify([...keyCounts].map(([key, count]) => ({ key, count }))),
578
- wakeAt
579
- ],
580
- transaction
594
+ const fresh = await this.#insertFreshEvents(transaction, {
595
+ plan,
596
+ handlersByEvent,
597
+ createdAt,
598
+ wakeAt
581
599
  });
582
600
  const insertedEventIds = new Set(
583
601
  fresh.filter((row) => row["inserted"] === true).map((row) => row["id"])
@@ -599,6 +617,40 @@ var PostgresAppend = class {
599
617
  }
600
618
  return { ...splitOutcomes(plan, insertedEventIds), appendedDeliveryCount };
601
619
  }
620
+ /** Runs the publishing statement that inserts the events not stored yet, with their deliveries. */
621
+ async #insertFreshEvents(transaction, {
622
+ plan,
623
+ handlersByEvent,
624
+ createdAt,
625
+ wakeAt
626
+ }) {
627
+ const keyCounts = keyCountsOf(plan.events);
628
+ return this.#state.query({
629
+ sql: this.#state.statements.append.insertFreshEvents,
630
+ parameters: [
631
+ JSON.stringify(
632
+ plan.events.map((event, ordinal) => ({
633
+ ordinal,
634
+ ...toEventRow(event, { sequence: null, handlers: handlersByEvent.get(event.id) ?? [] })
635
+ }))
636
+ ),
637
+ createdAt,
638
+ JSON.stringify(plan.deliveries),
639
+ JSON.stringify([...keyCounts].map(([key, count]) => ({ key, count }))),
640
+ wakeAt
641
+ ],
642
+ transaction
643
+ });
644
+ }
645
+ /** Locks the given events and returns the ids of those that still exist. */
646
+ async #lockExistingEvents(transaction, existing) {
647
+ const locked = await this.#state.query({
648
+ sql: this.#state.statements.append.lockExistingEvents,
649
+ parameters: [JSON.stringify(existing.map((row) => row.id).sort())],
650
+ transaction
651
+ });
652
+ return new Set(locked.map((row) => row["id"]));
653
+ }
602
654
  /**
603
655
  * The time the publishing statements notify the dispatchers for (deliveries due by then wake them),
604
656
  * or `null` for no notification. An append in a transaction of the caller's always notifies, at its
@@ -666,13 +718,7 @@ var PostgresAppend = class {
666
718
  })
667
719
  );
668
720
  while (existing.length > 0) {
669
- const locked = new Set(
670
- (await this.#state.query({
671
- sql: this.#state.statements.append.lockExistingEvents,
672
- parameters: [JSON.stringify(existing.map((row) => row.id).sort())],
673
- transaction
674
- })).map((row) => row["id"])
675
- );
721
+ const locked = await this.#lockExistingEvents(transaction, existing);
676
722
  const gone = existing.filter((row) => !locked.has(row.id));
677
723
  if (gone.length > 0) {
678
724
  const inserted2 = await this.#state.query({
@@ -709,7 +755,7 @@ function planAppend(entries) {
709
755
  events.push(event);
710
756
  }
711
757
  for (const delivery of newDeliveries) {
712
- const identity = `${event.id}\0${delivery.handler}`;
758
+ const identity = CompositeKey.of(event.id, delivery.handler);
713
759
  if (!seenDeliveries.has(identity)) {
714
760
  seenDeliveries.add(identity);
715
761
  deliveries.push({
@@ -774,49 +820,46 @@ var ReconcileFailedError = class extends RelayBusError {
774
820
  name = "ReconcileFailedError";
775
821
  code = "RELAYBUS_POSTGRES_RECONCILE_FAILED";
776
822
  };
777
-
778
- // src/postgres-store-state.ts
779
- function handedOutKey(deliveryId, leaseToken) {
780
- return `${deliveryId}\0${leaseToken}`;
823
+ function leftWorkBehind(row) {
824
+ return row["takeover_waiting"] === true || row["unexamined_free_lanes"] === true || row["stale"] === true;
825
+ }
826
+ function keepKeyHeads(picked, limit) {
827
+ const seenKeys = /* @__PURE__ */ new Set();
828
+ const kept = [...picked].sort((left, right) => compareSequence(left, right)).filter((row) => {
829
+ if (row["delivery_key"] === null) {
830
+ return true;
831
+ }
832
+ const group = CompositeKey.of(row["handler"], row["delivery_key"]);
833
+ return !seenKeys.has(group) && Boolean(seenKeys.add(group));
834
+ });
835
+ kept.sort(compareDue);
836
+ return kept.slice(0, limit);
837
+ }
838
+ function compareSequence(left, right) {
839
+ const first = sequenceOf(left);
840
+ const second = sequenceOf(right);
841
+ return Number(first > second) - Number(first < second);
842
+ }
843
+ function sequenceOf(row) {
844
+ return BigInt(row["sequence"] ?? 0);
845
+ }
846
+ function compareDue(left, right) {
847
+ return compareText(left["due_at"], right["due_at"]) || compareText(left["delivery_id"], right["delivery_id"]);
848
+ }
849
+ function compareText(left, right) {
850
+ return Number(left > right) - Number(left < right);
781
851
  }
782
852
 
783
853
  // src/postgres-claim.ts
784
854
  var INITIAL_LANES_PER_ROUND = 4;
785
855
  var MAXIMUM_STALE_CLAIM_ATTEMPTS = 3;
786
- var PostgresClaims = class {
856
+ var PostgresClaims = class extends OwnerSerializedClaims {
787
857
  #state;
788
858
  constructor(state) {
859
+ super();
789
860
  this.#state = state;
790
861
  }
791
- /** Implements {@link PostgresEventStore.claim}. */
792
- claim(request) {
793
- if (request.handlers.length === 0 || !(request.limit > 0)) {
794
- return Promise.resolve([]);
795
- }
796
- return this.#serialized(request.leaseOwner, () => this.#claim(request));
797
- }
798
- /**
799
- * Runs `work` after the earlier claims of the same owner on this store settled. The
800
- * bookkeeping of what was handed out and the reconciliation of a failed claim need
801
- * one claim at a time per owner (the dispatcher claims one batch at a time anyway;
802
- * `runOnce` next to a running dispatcher is what this guards against).
803
- */
804
- #serialized(owner, work) {
805
- const previous = this.#state.ownerQueues.get(owner) ?? Promise.resolve();
806
- const run = previous.then(work, work);
807
- const tail = run.then(
808
- () => void 0,
809
- () => void 0
810
- );
811
- this.#state.ownerQueues.set(owner, tail);
812
- void tail.then(() => {
813
- if (this.#state.ownerQueues.get(owner) === tail) {
814
- this.#state.ownerQueues.delete(owner);
815
- }
816
- });
817
- return run;
818
- }
819
- async #claim(request) {
862
+ async claimInTurn(request) {
820
863
  await this.#assertReconciled(request.leaseOwner);
821
864
  const heldFirst = this.#state.drainingOwners.has(request.leaseOwner);
822
865
  let rows = await this.#claimFirst(request, heldFirst);
@@ -988,7 +1031,7 @@ var PostgresClaims = class {
988
1031
  #handOut(rows, leaseOwner) {
989
1032
  for (const row of rows) {
990
1033
  const claim = mapClaimedDelivery(row, /* @__PURE__ */ new Date(0));
991
- this.#state.handedOut.set(handedOutKey(claim.deliveryId, claim.leaseToken), {
1034
+ this.#state.handedOut.register(claim, {
992
1035
  owner: leaseOwner,
993
1036
  deliveryId: claim.deliveryId,
994
1037
  ...parseLeaseToken(claim.leaseToken)
@@ -1009,11 +1052,9 @@ var PostgresClaims = class {
1009
1052
  ...JSON.parse(row["running_ids"]),
1010
1053
  ...claimed.map((picked) => picked["delivery_id"])
1011
1054
  ]);
1012
- for (const [key, entry] of this.#state.handedOut) {
1013
- if (entry.owner === leaseOwner && heldLanes.has(entry.lane) && !running.has(entry.deliveryId)) {
1014
- this.#state.handedOut.delete(key);
1015
- }
1016
- }
1055
+ this.#state.handedOut.forgetWhere(
1056
+ (entry) => entry.owner === leaseOwner && heldLanes.has(entry.lane) && !running.has(entry.deliveryId)
1057
+ );
1017
1058
  }
1018
1059
  /**
1019
1060
  * After a claim failed, possibly after the database recorded its batch: gives back the recorded
@@ -1023,7 +1064,7 @@ var PostgresClaims = class {
1023
1064
  */
1024
1065
  async #reconcile(leaseOwner) {
1025
1066
  const generation = this.#generation(leaseOwner, true);
1026
- const handedOut = [...this.#state.handedOut.values()].filter((entry) => entry.owner === leaseOwner).map(({ deliveryId, lane, epoch, claims }) => ({ id: deliveryId, lane, epoch, claims }));
1067
+ const handedOut = this.#state.handedOut.entriesOf(leaseOwner).map(({ deliveryId, lane, epoch, claims }) => ({ id: deliveryId, lane, epoch, claims }));
1027
1068
  try {
1028
1069
  await this.#state.query({
1029
1070
  sql: this.#state.statements.claim.reconcileClaims,
@@ -1060,16 +1101,7 @@ var PostgresClaims = class {
1060
1101
  (row) => row["delivery_id"] !== null && !pickedIds.has(row["delivery_id"])
1061
1102
  )
1062
1103
  );
1063
- const seenKeys = /* @__PURE__ */ new Set();
1064
- const kept = [...picked].sort((left, right) => compareSequence(left, right)).filter((row) => {
1065
- if (row["delivery_key"] === null) {
1066
- return true;
1067
- }
1068
- const group = `${row["handler"]}\0${row["delivery_key"]}`;
1069
- return !seenKeys.has(group) && Boolean(seenKeys.add(group));
1070
- });
1071
- kept.sort(compareDue);
1072
- return kept.slice(0, limit);
1104
+ return keepKeyHeads(picked, limit);
1073
1105
  }
1074
1106
  return picked;
1075
1107
  }
@@ -1101,43 +1133,12 @@ var PostgresClaims = class {
1101
1133
  }
1102
1134
  };
1103
1135
  var GENERATION_STEP = 1n;
1104
- function leftWorkBehind(row) {
1105
- return row["takeover_waiting"] === true || row["unexamined_free_lanes"] === true || row["stale"] === true;
1106
- }
1107
- function compareSequence(left, right) {
1108
- const first = sequenceOf(left);
1109
- const second = sequenceOf(right);
1110
- return Number(first > second) - Number(first < second);
1111
- }
1112
- function sequenceOf(row) {
1113
- return BigInt(row["sequence"] ?? 0);
1114
- }
1115
- function compareDue(left, right) {
1116
- return compareText(left["due_at"], right["due_at"]) || compareText(left["delivery_id"], right["delivery_id"]);
1117
- }
1118
- function compareText(left, right) {
1119
- return Number(left > right) - Number(left < right);
1120
- }
1121
-
1122
- // src/postgres-completion.ts
1123
- var PostgresCompletion = class {
1136
+ var PostgresCompletion = class extends LeaseSettlement {
1124
1137
  #state;
1125
1138
  constructor(state) {
1139
+ super();
1126
1140
  this.#state = state;
1127
1141
  }
1128
- /** Implements {@link PostgresEventStore.extendLease}. */
1129
- extendLease(lease, leaseExpiresAt) {
1130
- return this.#whenIssued(
1131
- lease,
1132
- (held) => this.#changed(this.#state.statements.completion.extendLease, [
1133
- lease.deliveryId,
1134
- held.lane,
1135
- held.epoch,
1136
- held.claims,
1137
- leaseExpiresAt.toISOString()
1138
- ])
1139
- );
1140
- }
1141
1142
  /** Implements {@link PostgresEventStore.complete}. */
1142
1143
  async complete(lease, { now, transaction }) {
1143
1144
  return (await this.#completeLeases([lease], { now, transaction }))[0];
@@ -1146,76 +1147,44 @@ var PostgresCompletion = class {
1146
1147
  completeMany(leases, now) {
1147
1148
  return this.#completeLeases(leases, { now, transaction: void 0 });
1148
1149
  }
1149
- /** Implements {@link PostgresEventStore.reschedule}. */
1150
- reschedule(lease, {
1151
- availableAt,
1152
- error,
1153
- now
1154
- }) {
1155
- return this.#settled(
1156
- lease,
1157
- this.#whenIssued(
1158
- lease,
1159
- (held) => this.#changed(this.#state.statements.completion.reschedule, [
1160
- lease.deliveryId,
1161
- held.lane,
1162
- held.epoch,
1163
- held.claims,
1164
- availableAt.toISOString(),
1165
- JSON.stringify(error),
1166
- now.toISOString()
1167
- ])
1168
- )
1169
- );
1150
+ /** A lease this store issued; any other token cannot be held. */
1151
+ findHeld(lease) {
1152
+ return parseLeaseToken(lease.leaseToken);
1170
1153
  }
1171
- /** Implements {@link PostgresEventStore.bury}. */
1172
- bury(lease, { error, now }) {
1173
- return this.#settled(
1174
- lease,
1175
- this.#whenIssued(
1176
- lease,
1177
- (held) => this.#changed(this.#state.statements.completion.bury, [
1178
- lease.deliveryId,
1179
- held.lane,
1180
- held.epoch,
1181
- held.claims,
1182
- JSON.stringify(error),
1183
- now.toISOString()
1184
- ])
1185
- )
1186
- );
1154
+ writeExtension(held, { lease, leaseExpiresAt }) {
1155
+ return this.#changed(this.#state.statements.completion.extendLease, [
1156
+ ...fenceOf(lease, held),
1157
+ leaseExpiresAt.toISOString()
1158
+ ]);
1187
1159
  }
1188
- /** Implements {@link PostgresEventStore.release}. */
1189
- release(lease, now) {
1190
- return this.#settled(
1191
- lease,
1192
- this.#whenIssued(
1193
- lease,
1194
- (held) => this.#changed(this.#state.statements.completion.release, [
1195
- lease.deliveryId,
1196
- held.lane,
1197
- held.epoch,
1198
- held.claims,
1199
- now.toISOString()
1200
- ])
1201
- )
1202
- );
1160
+ writeChange(held, { lease, change }) {
1161
+ const { completion } = this.#state.statements;
1162
+ const fence = fenceOf(lease, held);
1163
+ if (change.kind === "reschedule") {
1164
+ return this.#changed(completion.reschedule, [
1165
+ ...fence,
1166
+ change.availableAt.toISOString(),
1167
+ JSON.stringify(change.error),
1168
+ change.now.toISOString()
1169
+ ]);
1170
+ }
1171
+ if (change.kind === "bury") {
1172
+ return this.#changed(completion.bury, [
1173
+ ...fence,
1174
+ JSON.stringify(change.error),
1175
+ change.now.toISOString()
1176
+ ]);
1177
+ }
1178
+ return this.#changed(completion.release, [...fence, change.now.toISOString()]);
1203
1179
  }
1204
1180
  /** Forgets a handed-out lease once the store answered about it (any answer: the claim is over or fenced out). */
1205
- async #settled(lease, work) {
1206
- const result = await work;
1207
- this.#state.handedOut.delete(handedOutKey(lease.deliveryId, lease.leaseToken));
1208
- return result;
1181
+ forgetHandedOut(lease) {
1182
+ this.#state.handedOut.forget(lease);
1209
1183
  }
1210
1184
  async #changed(sql, parameters) {
1211
1185
  const rows = await this.#state.query({ sql, parameters });
1212
1186
  return readCount(rows[0], "count") > 0;
1213
1187
  }
1214
- /** Runs `operation` for a lease this store issued; any other token cannot be held. */
1215
- async #whenIssued(lease, operation) {
1216
- const held = parseLeaseToken(lease.leaseToken);
1217
- return held !== void 0 && await operation(held);
1218
- }
1219
1188
  /**
1220
1189
  * Deletes the delivery of every lease that is still the current claim of its
1221
1190
  * delivery. Returns one flag per lease, in order. Results are matched to leases
@@ -1223,16 +1192,7 @@ var PostgresCompletion = class {
1223
1192
  * their own answers; a lease listed twice completes once, at its first position.
1224
1193
  */
1225
1194
  async #completeLeases(leases, { now, transaction }) {
1226
- const issued = [];
1227
- const seen = /* @__PURE__ */ new Set();
1228
- leases.forEach((lease, ord) => {
1229
- const held = parseLeaseToken(lease.leaseToken);
1230
- const identity = `${lease.deliveryId}\0${lease.leaseToken}`;
1231
- if (held !== void 0 && !seen.has(identity)) {
1232
- seen.add(identity);
1233
- issued.push({ ord, id: lease.deliveryId, ...held });
1234
- }
1235
- });
1195
+ const issued = issuedLeases(leases);
1236
1196
  const completed = /* @__PURE__ */ new Set();
1237
1197
  if (issued.length > 0) {
1238
1198
  const rows = await this.#state.query({
@@ -1246,12 +1206,28 @@ var PostgresCompletion = class {
1246
1206
  }
1247
1207
  if (transaction === void 0) {
1248
1208
  for (const lease of leases) {
1249
- this.#state.handedOut.delete(handedOutKey(lease.deliveryId, lease.leaseToken));
1209
+ this.#state.handedOut.forget(lease);
1250
1210
  }
1251
1211
  }
1252
1212
  return leases.map((_, ord) => completed.has(ord));
1253
1213
  }
1254
1214
  };
1215
+ function fenceOf(lease, held) {
1216
+ return [lease.deliveryId, held.lane, held.epoch, held.claims];
1217
+ }
1218
+ function issuedLeases(leases) {
1219
+ const issued = [];
1220
+ const seen = /* @__PURE__ */ new Set();
1221
+ leases.forEach((lease, ord) => {
1222
+ const held = parseLeaseToken(lease.leaseToken);
1223
+ const identity = DeliveryLeaseIdentity.of(lease.deliveryId, lease.leaseToken);
1224
+ if (held !== void 0 && !seen.has(identity)) {
1225
+ seen.add(identity);
1226
+ issued.push({ ord, id: lease.deliveryId, ...held });
1227
+ }
1228
+ });
1229
+ return issued;
1230
+ }
1255
1231
  var OwnedConnections = class {
1256
1232
  #clients = /* @__PURE__ */ new Set();
1257
1233
  /**
@@ -1350,6 +1326,24 @@ var PostgresMaintenance = class {
1350
1326
  /** Implements {@link PostgresEventStore.purge}. */
1351
1327
  async purge(request) {
1352
1328
  const total = { deletedDeliveries: 0, deletedEvents: 0 };
1329
+ const batches = this.#purgeBatchPlan(request);
1330
+ for (const [sql, cutoff, deleteOrphans] of batches) {
1331
+ if (cutoff === void 0) {
1332
+ continue;
1333
+ }
1334
+ let deletedInBatch = this.#state.purgeBatchSize;
1335
+ while (deletedInBatch === this.#state.purgeBatchSize) {
1336
+ const batch = await this.#purgeBatch(sql, { cutoff, deleteOrphans });
1337
+ deletedInBatch = batch.deletedDeliveries;
1338
+ total.deletedDeliveries += batch.deletedDeliveries;
1339
+ total.deletedEvents += batch.deletedEvents;
1340
+ }
1341
+ }
1342
+ total.deletedEvents += await this.#purgeAllOrphanEvents(request);
1343
+ return total;
1344
+ }
1345
+ /** The purge statements to run in order: SQL, cutoff (undefined skips it) and the orphan-event delete. */
1346
+ #purgeBatchPlan(request) {
1353
1347
  const batches = [
1354
1348
  [
1355
1349
  this.#state.statements.purge.purgeCompletedBatch(),
@@ -1369,21 +1363,14 @@ var PostgresMaintenance = class {
1369
1363
  this.#state.statements.purge.deleteOrphanEventsWithoutCompletion
1370
1364
  ]);
1371
1365
  }
1372
- for (const [sql, cutoff, deleteOrphans] of batches) {
1373
- if (cutoff === void 0) {
1374
- continue;
1375
- }
1376
- let deletedInBatch = this.#state.purgeBatchSize;
1377
- while (deletedInBatch === this.#state.purgeBatchSize) {
1378
- const batch = await this.#purgeBatch(sql, { cutoff, deleteOrphans });
1379
- deletedInBatch = batch.deletedDeliveries;
1380
- total.deletedDeliveries += batch.deletedDeliveries;
1381
- total.deletedEvents += batch.deletedEvents;
1382
- }
1383
- }
1366
+ return batches;
1367
+ }
1368
+ /** Removes the events left without deliveries, in two passes; returns how many events were deleted. */
1369
+ async #purgeAllOrphanEvents(request) {
1370
+ let deletedEvents = 0;
1384
1371
  const orphanCutoff = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
1385
1372
  if (orphanCutoff !== void 0) {
1386
- total.deletedEvents += await this.#purgeOrphanEvents(
1373
+ deletedEvents += await this.#purgeOrphanEvents(
1387
1374
  this.#state.statements.purge.lockOrphanEventsBatch,
1388
1375
  {
1389
1376
  deleteStatement: this.#state.statements.purge.deleteOrphanEventsWithoutCompletion,
@@ -1393,7 +1380,7 @@ var PostgresMaintenance = class {
1393
1380
  );
1394
1381
  }
1395
1382
  if (request.completedBefore !== void 0) {
1396
- total.deletedEvents += await this.#purgeOrphanEvents(
1383
+ deletedEvents += await this.#purgeOrphanEvents(
1397
1384
  this.#state.statements.purge.lockCompletedOrphanEventsBatch,
1398
1385
  {
1399
1386
  deleteStatement: this.#state.statements.purge.deleteExpiredCompletedOrphanEvents,
@@ -1402,7 +1389,7 @@ var PostgresMaintenance = class {
1402
1389
  }
1403
1390
  );
1404
1391
  }
1405
- return total;
1392
+ return deletedEvents;
1406
1393
  }
1407
1394
  async #purgeOrphanEvents(lockStatement, {
1408
1395
  deleteStatement,
@@ -1457,6 +1444,37 @@ var PostgresMaintenance = class {
1457
1444
  });
1458
1445
  }
1459
1446
  };
1447
+ var WAKE_UPS = ["notify", "poll"];
1448
+ var DEFAULT_PURGE_BATCH_SIZE = 1e3;
1449
+ function resolveStoreSettings(options) {
1450
+ const sources = [options.executor ?? options.pool, options.connectionString].filter(
1451
+ (source) => source !== void 0
1452
+ );
1453
+ if (sources.length !== 1) {
1454
+ throw new ConfigurationError(
1455
+ "PostgresEventStore needs exactly one of pool, connectionString or executor"
1456
+ );
1457
+ }
1458
+ const purgeBatchSize = PositiveInteger.of(
1459
+ options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
1460
+ "purgeBatchSize"
1461
+ );
1462
+ const wakeUp = options.wakeUp ?? "notify";
1463
+ if (!WAKE_UPS.includes(wakeUp)) {
1464
+ throw new ConfigurationError(`wakeUp must be 'notify' or 'poll', received ${String(wakeUp)}`);
1465
+ }
1466
+ const preparedStatements = options.preparedStatements ?? true;
1467
+ if (typeof preparedStatements !== "boolean") {
1468
+ throw new ConfigurationError(
1469
+ `preparedStatements must be a boolean, received ${String(preparedStatements)}`
1470
+ );
1471
+ }
1472
+ return {
1473
+ purgeBatchSize,
1474
+ wakeUp,
1475
+ queryOptions: preparedStatements ? { prepare: true } : void 0
1476
+ };
1477
+ }
1460
1478
 
1461
1479
  // src/statements-append.ts
1462
1480
  var AppendStatements = class {
@@ -1474,15 +1492,27 @@ var AppendStatements = class {
1474
1492
  * inserted. Deliveries of events that already existed are left to {@link insertDeliveries}.
1475
1493
  */
1476
1494
  get insertFreshEvents() {
1477
- const events = this.#names.events;
1478
- return `WITH reserved AS (
1495
+ return `WITH ${this.#reservedSequences()}, ${this.#incomingEvents()}, ${this.#insertedEvents()}, ${this.#insertedDeliveries()}, ${this.#wakeUp("inserted_deliveries", "$5")}
1496
+ SELECT incoming.id, incoming.sequence::text AS sequence,
1497
+ incoming.id IN (SELECT inserted_events.id FROM inserted_events) AS inserted,
1498
+ (SELECT count(*) FROM inserted_deliveries)::int AS deliveries
1499
+ FROM incoming
1500
+ WHERE (SELECT count(*) FROM woken) >= 0`;
1501
+ }
1502
+ /** The CTE `reserved` of {@link insertFreshEvents}: the sequences the keys of `$4` reserve. */
1503
+ #reservedSequences() {
1504
+ return `reserved AS (
1479
1505
  INSERT INTO ${this.#names.keySequences} AS reserved (key, sequence)
1480
1506
  SELECT requested.key, requested.count
1481
1507
  FROM jsonb_to_recordset($4::jsonb) AS requested (key text, count bigint)
1482
1508
  ORDER BY requested.key
1483
1509
  ON CONFLICT (key) DO UPDATE SET sequence = reserved.sequence + EXCLUDED.sequence
1484
1510
  RETURNING reserved.key AS key, reserved.sequence AS sequence
1485
- ), incoming AS (
1511
+ )`;
1512
+ }
1513
+ /** The CTE `incoming` of {@link insertFreshEvents}: the events of `$1` with their key sequences. */
1514
+ #incomingEvents() {
1515
+ return `incoming AS (
1486
1516
  SELECT incoming.*,
1487
1517
  CASE WHEN incoming.key IS NULL THEN NULL ELSE
1488
1518
  reserved.sequence - count(*) OVER (PARTITION BY incoming.key)
@@ -1492,8 +1522,12 @@ var AppendStatements = class {
1492
1522
  ordinal int, id text, type text, source text, key text, payload jsonb, headers jsonb,
1493
1523
  correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1494
1524
  LEFT JOIN reserved ON reserved.key = incoming.key
1495
- ), inserted_events AS (
1496
- INSERT INTO ${events}
1525
+ )`;
1526
+ }
1527
+ /** The CTE `inserted_events` of {@link insertFreshEvents}: the events of `$1` that do not exist yet. */
1528
+ #insertedEvents() {
1529
+ return `inserted_events AS (
1530
+ INSERT INTO ${this.#names.events}
1497
1531
  (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1498
1532
  SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1499
1533
  COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
@@ -1503,7 +1537,11 @@ var AppendStatements = class {
1503
1537
  ORDER BY incoming.id
1504
1538
  ON CONFLICT (id) DO NOTHING
1505
1539
  RETURNING id, key, sequence
1506
- ), inserted_deliveries AS (
1540
+ )`;
1541
+ }
1542
+ /** The CTE `inserted_deliveries` of {@link insertFreshEvents}: the deliveries of `$3` of the inserted events. */
1543
+ #insertedDeliveries() {
1544
+ return `inserted_deliveries AS (
1507
1545
  INSERT INTO ${this.#names.deliveries}
1508
1546
  (id, event_id, handler, lane, status, attempts, available_at, key, sequence, created_at, updated_at)
1509
1547
  SELECT delivery.id, delivery.event_id, delivery.handler, ${laneExpression("event.key", "event.id")}, 'pending', 0,
@@ -1513,16 +1551,15 @@ var AppendStatements = class {
1513
1551
  ORDER BY delivery.event_id, delivery.handler
1514
1552
  ON CONFLICT (event_id, handler) DO NOTHING
1515
1553
  RETURNING available_at
1516
- ), ${this.#wakeUp("inserted_deliveries", "$5")}
1517
- SELECT incoming.id, incoming.sequence::text AS sequence,
1518
- incoming.id IN (SELECT inserted_events.id FROM inserted_events) AS inserted,
1519
- (SELECT count(*) FROM inserted_deliveries)::int AS deliveries
1520
- FROM incoming
1521
- WHERE (SELECT count(*) FROM woken) >= 0`;
1554
+ )`;
1522
1555
  }
1523
- /** One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing transaction. */
1556
+ /**
1557
+ * One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing
1558
+ * transaction. Returns a boolean column rather than the `void` of `pg_notify`, which some libraries
1559
+ * cannot read (Prisma's raw queries reject a `void` column).
1560
+ */
1524
1561
  get notify() {
1525
- return `SELECT pg_notify('${this.#names.notificationChannel}', '')`;
1562
+ return `SELECT true AS notified FROM pg_notify('${this.#names.notificationChannel}', '')`;
1526
1563
  }
1527
1564
  /**
1528
1565
  * A CTE `woken` that sends one notification on {@link PostgresNames.notificationChannel} when a row of
@@ -2045,11 +2082,146 @@ SELECT count(*)::int AS count FROM changed`;
2045
2082
  }
2046
2083
  };
2047
2084
 
2048
- // src/statements-lanes.ts
2049
- var FREE_LANE_SURPLUS = 16;
2085
+ // src/statements-candidates.ts
2050
2086
  function notRunning(running, alias) {
2051
2087
  return running === null ? "true" : `NOT EXISTS (SELECT 1 FROM ${running} AS running WHERE running.handler = ${alias}.handler AND running.key = ${alias}.key)`;
2052
2088
  }
2089
+ var CandidateStatements = class {
2090
+ #names;
2091
+ constructor(names) {
2092
+ this.#names = names;
2093
+ }
2094
+ /**
2095
+ * CTEs that pick up to `limit` claimable deliveries from the lanes of the CTE `scope` (columns `lane`,
2096
+ * `epoch`, `in_flight`), named after `prefix`; the result is `<prefix>_candidates (id, lane, available_at)`.
2097
+ * The rules are those of `ClaimStatements.claim`. The fast window reads the first {@link LANE_SCAN_WINDOW} due
2098
+ * deliveries of each lane (at most {@link CLAIM_SCAN_WINDOW} together); of a key, only the lowest sequence
2099
+ * in the window can be its head, so only that one is checked for an earlier pending delivery. Only when
2100
+ * that yields fewer than `limit` are the lanes whose window filled up looked at again with a loose index
2101
+ * scan over the heads of all their keys. `running` names the CTE of the keyed deliveries running in the
2102
+ * lanes, or is null when nothing runs there.
2103
+ */
2104
+ candidates(prefix, {
2105
+ scope,
2106
+ running,
2107
+ limit
2108
+ }) {
2109
+ const spec = { prefix, scope, running, limit };
2110
+ return this.#fastCandidates(spec) + this.#slowCandidates(spec);
2111
+ }
2112
+ /** The fast window of each lane and the claimable deliveries in it (`<prefix>_window`, `<prefix>_fast`). */
2113
+ #fastCandidates({ prefix, scope, running, limit }) {
2114
+ const deliveries = this.#names.deliveries;
2115
+ return `${prefix}_window AS MATERIALIZED (
2116
+ SELECT recent.id, recent.lane, recent.available_at, recent.handler, recent.key, recent.sequence
2117
+ FROM ${scope}
2118
+ CROSS JOIN LATERAL (
2119
+ SELECT delivery.id, delivery.lane, delivery.available_at, delivery.handler, delivery.key, delivery.sequence
2120
+ FROM ${deliveries} AS delivery
2121
+ WHERE delivery.lane = ${scope}.lane AND delivery.status = 'pending'
2122
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2123
+ AND delivery.id <> ALL (${scope}.in_flight)
2124
+ ORDER BY delivery.available_at, delivery.id
2125
+ LIMIT ${LANE_SCAN_WINDOW}
2126
+ ) AS recent
2127
+ ORDER BY recent.available_at, recent.id
2128
+ LIMIT ${CLAIM_SCAN_WINDOW}
2129
+ ), ${prefix}_fast AS MATERIALIZED (
2130
+ SELECT picked.id, picked.lane, picked.available_at FROM (
2131
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_window AS unkeyed WHERE unkeyed.key IS NULL
2132
+ UNION ALL
2133
+ SELECT head.id, head.lane, head.available_at
2134
+ FROM (
2135
+ SELECT DISTINCT ON (keyed.handler, keyed.key) keyed.id, keyed.lane, keyed.available_at, keyed.handler, keyed.key, keyed.sequence
2136
+ FROM ${prefix}_window AS keyed
2137
+ WHERE keyed.key IS NOT NULL
2138
+ ORDER BY keyed.handler, keyed.key, keyed.sequence
2139
+ ) AS head
2140
+ WHERE NOT EXISTS (
2141
+ SELECT 1 FROM ${deliveries} AS earlier
2142
+ WHERE earlier.lane = head.lane AND earlier.handler = head.handler AND earlier.key = head.key
2143
+ AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < head.sequence
2144
+ ) AND ${notRunning(running, "head")}
2145
+ ) AS picked
2146
+ ORDER BY picked.available_at, picked.id
2147
+ LIMIT ${limit}
2148
+ `;
2149
+ }
2150
+ /**
2151
+ * For lanes whose window filled up while the fast pick came up short: the heads of all their keys and
2152
+ * their unkeyed deliveries, then the union with the fast pick (`<prefix>_candidates`).
2153
+ */
2154
+ #slowCandidates(spec) {
2155
+ return this.#saturatedLanes(spec) + this.#keyHeads(spec) + this.#keyedHeads(spec) + this.#unkeyedRecent(spec) + this.#unionCandidates(spec);
2156
+ }
2157
+ /** `<prefix>_saturated`: the lanes whose window filled up while the fast pick came up short. */
2158
+ #saturatedLanes({ prefix, scope, limit }) {
2159
+ return `), ${prefix}_saturated AS MATERIALIZED (
2160
+ SELECT ${scope}.lane, ${scope}.in_flight FROM ${scope}
2161
+ WHERE (SELECT count(*) FROM ${prefix}_fast) < ${limit}
2162
+ AND ((SELECT count(*) FROM ${prefix}_window) >= ${CLAIM_SCAN_WINDOW}
2163
+ OR ${scope}.lane IN (
2164
+ SELECT full_lane.lane FROM ${prefix}_window AS full_lane
2165
+ GROUP BY full_lane.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
2166
+ ))
2167
+ `;
2168
+ }
2169
+ /** `<prefix>_heads`: the head of every key of the saturated lanes. */
2170
+ #keyHeads({ prefix }) {
2171
+ return `), ${prefix}_heads AS MATERIALIZED (
2172
+ SELECT head.id, head.lane, head.available_at, head.handler, head.key
2173
+ FROM ${prefix}_saturated AS scope
2174
+ CROSS JOIN LATERAL (${keyHeads(this.#names, "scope")}) AS head
2175
+ `;
2176
+ }
2177
+ /** `<prefix>_keyed`: the due heads that are not in flight and whose key runs nowhere. */
2178
+ #keyedHeads({ prefix, running, limit }) {
2179
+ return `), ${prefix}_keyed AS MATERIALIZED (
2180
+ SELECT heads.id, heads.lane, heads.available_at
2181
+ FROM ${prefix}_heads AS heads
2182
+ JOIN ${prefix}_saturated AS scope ON scope.lane = heads.lane
2183
+ WHERE heads.available_at <= $1::timestamptz AND heads.id <> ALL (scope.in_flight) AND ${notRunning(running, "heads")}
2184
+ ORDER BY heads.available_at, heads.id
2185
+ LIMIT ${limit}
2186
+ `;
2187
+ }
2188
+ /** `<prefix>_unkeyed`: the due unkeyed deliveries of the saturated lanes that are not in flight. */
2189
+ #unkeyedRecent({ prefix, limit }) {
2190
+ const deliveries = this.#names.deliveries;
2191
+ return `), ${prefix}_unkeyed AS MATERIALIZED (
2192
+ SELECT recent.id, recent.lane, recent.available_at
2193
+ FROM ${prefix}_saturated AS scope
2194
+ CROSS JOIN LATERAL (
2195
+ SELECT delivery.id, delivery.lane, delivery.available_at
2196
+ FROM ${deliveries} AS delivery
2197
+ WHERE delivery.lane = scope.lane AND delivery.key IS NULL AND delivery.status = 'pending'
2198
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2199
+ AND delivery.id <> ALL (scope.in_flight)
2200
+ ORDER BY delivery.available_at, delivery.id
2201
+ LIMIT ${limit}
2202
+ ) AS recent
2203
+ ORDER BY recent.available_at, recent.id
2204
+ LIMIT ${limit}
2205
+ `;
2206
+ }
2207
+ /** `<prefix>_candidates`: the union of the fast pick and the slow picks, oldest first, at most `limit`. */
2208
+ #unionCandidates({ prefix, limit }) {
2209
+ return `), ${prefix}_candidates AS MATERIALIZED (
2210
+ SELECT chosen.id, chosen.lane, chosen.available_at FROM (
2211
+ SELECT fast.id, fast.lane, fast.available_at FROM ${prefix}_fast AS fast
2212
+ UNION
2213
+ SELECT keyed.id, keyed.lane, keyed.available_at FROM ${prefix}_keyed AS keyed
2214
+ UNION
2215
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_unkeyed AS unkeyed
2216
+ ) AS chosen
2217
+ ORDER BY chosen.available_at, chosen.id
2218
+ LIMIT ${limit}
2219
+ )`;
2220
+ }
2221
+ };
2222
+
2223
+ // src/statements-lanes.ts
2224
+ var FREE_LANE_SURPLUS = 16;
2053
2225
  var freeLane = (alias) => `(${alias}.lease_expires_at IS NULL OR ${alias}.lease_expires_at <= $1::timestamptz
2054
2226
  OR (${alias}.lease_owner = $4::text AND ${alias}.claim_floor < $6::bigint))`;
2055
2227
  var NO_FREE_LANES = `), free_lanes AS (
@@ -2061,8 +2233,10 @@ var NO_FREE_LANES = `), free_lanes AS (
2061
2233
  )`;
2062
2234
  var LaneStatements = class {
2063
2235
  #names;
2236
+ #candidateStatements;
2064
2237
  constructor(names) {
2065
2238
  this.#names = names;
2239
+ this.#candidateStatements = new CandidateStatements(names);
2066
2240
  }
2067
2241
  /**
2068
2242
  * A claim in one statement, the only one in the common case. Parameters:
@@ -2137,7 +2311,7 @@ var LaneStatements = class {
2137
2311
  CROSS JOIN LATERAL unnest(held_scope.in_flight) AS flying (id)
2138
2312
  JOIN ${deliveries} AS delivery ON delivery.id = flying.id
2139
2313
  WHERE delivery.status = 'pending' AND delivery.key IS NOT NULL
2140
- ), ${this.#candidates("held", { scope: "held_scope", running: "held_running", limit: "$3::int" })}, wanted AS MATERIALIZED (
2314
+ ), ${this.#candidateStatements.candidates("held", { scope: "held_scope", running: "held_running", limit: "$3::int" })}, wanted AS MATERIALIZED (
2141
2315
  SELECT CASE WHEN (SELECT value FROM stale) THEN 0
2142
2316
  ELSE $3::int - (SELECT count(*) FROM held_candidates)::int END AS count
2143
2317
  `;
@@ -2226,7 +2400,7 @@ var LaneStatements = class {
2226
2400
  WHERE locked.epoch = seen.epoch AND locked.in_flight = seen.in_flight
2227
2401
  AND locked.lease_owner IS NOT DISTINCT FROM seen.lease_owner
2228
2402
  AND locked.lease_expires_at IS NOT DISTINCT FROM seen.lease_expires_at
2229
- ), ${this.#candidates("free", { scope: "free_scope", running: null, limit: "(SELECT count FROM wanted)" })}`;
2403
+ ), ${this.#candidateStatements.candidates("free", { scope: "free_scope", running: null, limit: "(SELECT count FROM wanted)" })}`;
2230
2404
  }
2231
2405
  /** The picked deliveries of held and taken lanes with their events, and the picks per lane. */
2232
2406
  #claimPicks() {
@@ -2315,113 +2489,6 @@ SELECT (SELECT value FROM stale) AS stale,
2315
2489
  FROM (SELECT 1) AS anchor
2316
2490
  LEFT JOIN picked_rows AS picked ON true
2317
2491
  ORDER BY picked.due_at, picked.delivery_id`;
2318
- }
2319
- /**
2320
- * CTEs that pick up to `limit` claimable deliveries from the lanes of the CTE `scope` (columns `lane`,
2321
- * `epoch`, `in_flight`), named after `prefix`; the result is `<prefix>_candidates (id, lane, available_at)`.
2322
- * The rules are those of `ClaimStatements.claim`. The fast window reads the first {@link LANE_SCAN_WINDOW} due
2323
- * deliveries of each lane (at most {@link CLAIM_SCAN_WINDOW} together); of a key, only the lowest sequence
2324
- * in the window can be its head, so only that one is checked for an earlier pending delivery. Only when
2325
- * that yields fewer than `limit` are the lanes whose window filled up looked at again with a loose index
2326
- * scan over the heads of all their keys. `running` names the CTE of the keyed deliveries running in the
2327
- * lanes, or is null when nothing runs there.
2328
- */
2329
- #candidates(prefix, {
2330
- scope,
2331
- running,
2332
- limit
2333
- }) {
2334
- const spec = { prefix, scope, running, limit };
2335
- return this.#fastCandidates(spec) + this.#slowCandidates(spec);
2336
- }
2337
- /** The fast window of each lane and the claimable deliveries in it (`<prefix>_window`, `<prefix>_fast`). */
2338
- #fastCandidates({ prefix, scope, running, limit }) {
2339
- const deliveries = this.#names.deliveries;
2340
- return `${prefix}_window AS MATERIALIZED (
2341
- SELECT recent.id, recent.lane, recent.available_at, recent.handler, recent.key, recent.sequence
2342
- FROM ${scope}
2343
- CROSS JOIN LATERAL (
2344
- SELECT delivery.id, delivery.lane, delivery.available_at, delivery.handler, delivery.key, delivery.sequence
2345
- FROM ${deliveries} AS delivery
2346
- WHERE delivery.lane = ${scope}.lane AND delivery.status = 'pending'
2347
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2348
- AND delivery.id <> ALL (${scope}.in_flight)
2349
- ORDER BY delivery.available_at, delivery.id
2350
- LIMIT ${LANE_SCAN_WINDOW}
2351
- ) AS recent
2352
- ORDER BY recent.available_at, recent.id
2353
- LIMIT ${CLAIM_SCAN_WINDOW}
2354
- ), ${prefix}_fast AS MATERIALIZED (
2355
- SELECT picked.id, picked.lane, picked.available_at FROM (
2356
- SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_window AS unkeyed WHERE unkeyed.key IS NULL
2357
- UNION ALL
2358
- SELECT head.id, head.lane, head.available_at
2359
- FROM (
2360
- SELECT DISTINCT ON (keyed.handler, keyed.key) keyed.id, keyed.lane, keyed.available_at, keyed.handler, keyed.key, keyed.sequence
2361
- FROM ${prefix}_window AS keyed
2362
- WHERE keyed.key IS NOT NULL
2363
- ORDER BY keyed.handler, keyed.key, keyed.sequence
2364
- ) AS head
2365
- WHERE NOT EXISTS (
2366
- SELECT 1 FROM ${deliveries} AS earlier
2367
- WHERE earlier.lane = head.lane AND earlier.handler = head.handler AND earlier.key = head.key
2368
- AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < head.sequence
2369
- ) AND ${notRunning(running, "head")}
2370
- ) AS picked
2371
- ORDER BY picked.available_at, picked.id
2372
- LIMIT ${limit}
2373
- `;
2374
- }
2375
- /**
2376
- * For lanes whose window filled up while the fast pick came up short: the heads of all their keys and
2377
- * their unkeyed deliveries, then the union with the fast pick (`<prefix>_candidates`).
2378
- */
2379
- #slowCandidates({ prefix, scope, running, limit }) {
2380
- const deliveries = this.#names.deliveries;
2381
- return `), ${prefix}_saturated AS MATERIALIZED (
2382
- SELECT ${scope}.lane, ${scope}.in_flight FROM ${scope}
2383
- WHERE (SELECT count(*) FROM ${prefix}_fast) < ${limit}
2384
- AND ((SELECT count(*) FROM ${prefix}_window) >= ${CLAIM_SCAN_WINDOW}
2385
- OR ${scope}.lane IN (
2386
- SELECT full_lane.lane FROM ${prefix}_window AS full_lane
2387
- GROUP BY full_lane.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
2388
- ))
2389
- ), ${prefix}_heads AS MATERIALIZED (
2390
- SELECT head.id, head.lane, head.available_at, head.handler, head.key
2391
- FROM ${prefix}_saturated AS scope
2392
- CROSS JOIN LATERAL (${keyHeads(this.#names, "scope")}) AS head
2393
- ), ${prefix}_keyed AS MATERIALIZED (
2394
- SELECT heads.id, heads.lane, heads.available_at
2395
- FROM ${prefix}_heads AS heads
2396
- JOIN ${prefix}_saturated AS scope ON scope.lane = heads.lane
2397
- WHERE heads.available_at <= $1::timestamptz AND heads.id <> ALL (scope.in_flight) AND ${notRunning(running, "heads")}
2398
- ORDER BY heads.available_at, heads.id
2399
- LIMIT ${limit}
2400
- ), ${prefix}_unkeyed AS MATERIALIZED (
2401
- SELECT recent.id, recent.lane, recent.available_at
2402
- FROM ${prefix}_saturated AS scope
2403
- CROSS JOIN LATERAL (
2404
- SELECT delivery.id, delivery.lane, delivery.available_at
2405
- FROM ${deliveries} AS delivery
2406
- WHERE delivery.lane = scope.lane AND delivery.key IS NULL AND delivery.status = 'pending'
2407
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2408
- AND delivery.id <> ALL (scope.in_flight)
2409
- ORDER BY delivery.available_at, delivery.id
2410
- LIMIT ${limit}
2411
- ) AS recent
2412
- ORDER BY recent.available_at, recent.id
2413
- LIMIT ${limit}
2414
- ), ${prefix}_candidates AS MATERIALIZED (
2415
- SELECT chosen.id, chosen.lane, chosen.available_at FROM (
2416
- SELECT fast.id, fast.lane, fast.available_at FROM ${prefix}_fast AS fast
2417
- UNION
2418
- SELECT keyed.id, keyed.lane, keyed.available_at FROM ${prefix}_keyed AS keyed
2419
- UNION
2420
- SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_unkeyed AS unkeyed
2421
- ) AS chosen
2422
- ORDER BY chosen.available_at, chosen.id
2423
- LIMIT ${limit}
2424
- )`;
2425
2492
  }
2426
2493
  /**
2427
2494
  * Takes up to `$3` free lanes (no lease, or an expired one) that have a due
@@ -2782,8 +2849,12 @@ var SetupStatements = class {
2782
2849
  WHERE n.nspname = '${this.#names.schema}' AND c.relname = '${this.#names.lanesTableName}' AND d.description = $1::text
2783
2850
  ) AS current`;
2784
2851
  }
2852
+ /**
2853
+ * Takes the setup lock. Returns a boolean column rather than the `void` of the lock function, which
2854
+ * some libraries cannot read (Prisma's raw queries reject a `void` column).
2855
+ */
2785
2856
  get setupLock() {
2786
- return "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))";
2857
+ return "SELECT true AS locked FROM pg_advisory_xact_lock(hashtextextended($1::text, 0))";
2787
2858
  }
2788
2859
  };
2789
2860
 
@@ -2808,8 +2879,6 @@ var PostgresStatements = class {
2808
2879
  };
2809
2880
 
2810
2881
  // src/postgres-event-store.ts
2811
- var WAKE_UPS = ["notify", "poll"];
2812
- var DEFAULT_PURGE_BATCH_SIZE = 1e3;
2813
2882
  var PostgresEventStore = class {
2814
2883
  #state;
2815
2884
  #appender;
@@ -2836,10 +2905,9 @@ var PostgresEventStore = class {
2836
2905
  queryOptions,
2837
2906
  notifies: wakeUp === "notify",
2838
2907
  query: ({ sql, parameters, transaction }) => executor.query(sql, parameters, transaction, queryOptions),
2839
- handedOut: /* @__PURE__ */ new Map(),
2908
+ handedOut: new HandedOutLedger(),
2840
2909
  unreconciled: /* @__PURE__ */ new Set(),
2841
2910
  generations: /* @__PURE__ */ new Map(),
2842
- ownerQueues: /* @__PURE__ */ new Map(),
2843
2911
  drainingOwners: /* @__PURE__ */ new Set(),
2844
2912
  ownedPool,
2845
2913
  pool,
@@ -2946,7 +3014,7 @@ var PostgresEventStore = class {
2946
3014
  return this.#claims.claim(request);
2947
3015
  }
2948
3016
  extendLease(lease, leaseExpiresAt) {
2949
- return this.#completion.extendLease(lease, leaseExpiresAt);
3017
+ return Promise.resolve(this.#completion.extendLease({ lease, leaseExpiresAt }));
2950
3018
  }
2951
3019
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2952
3020
  complete(lease, now, transaction) {
@@ -2957,15 +3025,24 @@ var PostgresEventStore = class {
2957
3025
  }
2958
3026
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2959
3027
  reschedule(lease, availableAt, error, now) {
2960
- return this.#completion.reschedule(lease, { availableAt, error, now });
3028
+ return Promise.resolve(
3029
+ this.#completion.changeClaim({
3030
+ lease,
3031
+ change: { kind: "reschedule", availableAt, error, now }
3032
+ })
3033
+ );
2961
3034
  }
2962
3035
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2963
3036
  bury(lease, error, now) {
2964
- return this.#completion.bury(lease, { error, now });
3037
+ return Promise.resolve(
3038
+ this.#completion.changeClaim({ lease, change: { kind: "bury", error, now } })
3039
+ );
2965
3040
  }
2966
3041
  /** Returns a leased delivery to pending, due now, without counting the attempt. */
2967
3042
  release(lease, now) {
2968
- return this.#completion.release(lease, now);
3043
+ return Promise.resolve(
3044
+ this.#completion.changeClaim({ lease, change: { kind: "release", now } })
3045
+ );
2969
3046
  }
2970
3047
  /**
2971
3048
  * Calls `listener` soon after a transaction that published deliveries due at once commits, in any
@@ -3044,38 +3121,14 @@ var PostgresEventStore = class {
3044
3121
  }
3045
3122
  }
3046
3123
  };
3047
- function resolveStoreSettings(options) {
3048
- const sources = [options.pool, options.connectionString, options.executor].filter(
3049
- (source) => source !== void 0
3050
- );
3051
- if (sources.length !== 1) {
3052
- throw new ConfigurationError(
3053
- "PostgresEventStore needs exactly one of pool, connectionString or executor"
3054
- );
3055
- }
3056
- const purgeBatchSize = assertPositiveInteger(
3057
- options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
3058
- "purgeBatchSize"
3059
- );
3060
- const wakeUp = options.wakeUp ?? "notify";
3061
- if (!WAKE_UPS.includes(wakeUp)) {
3062
- throw new ConfigurationError(`wakeUp must be 'notify' or 'poll', received ${String(wakeUp)}`);
3063
- }
3064
- const preparedStatements = options.preparedStatements ?? true;
3065
- if (typeof preparedStatements !== "boolean") {
3066
- throw new ConfigurationError(
3067
- `preparedStatements must be a boolean, received ${String(preparedStatements)}`
3068
- );
3069
- }
3070
- return {
3071
- purgeBatchSize,
3072
- wakeUp,
3073
- queryOptions: preparedStatements ? { prepare: true } : void 0
3074
- };
3075
- }
3076
3124
  function connect(options, { logger, owned }) {
3077
3125
  if (options.executor !== void 0) {
3078
- return { executor: options.executor, pool: void 0, ownedPool: void 0 };
3126
+ const { executor: executor2, pool: pool2 } = options;
3127
+ return {
3128
+ executor: pool2 === void 0 ? executor2 : withRootPool(executor2, new PgExecutor(pool2, { logger })),
3129
+ pool: pool2,
3130
+ ownedPool: void 0
3131
+ };
3079
3132
  }
3080
3133
  const pool = options.pool ?? new Pool({
3081
3134
  ...options.poolConfig,