@relaybus/postgres 0.3.0 → 0.3.1

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 CHANGED
@@ -189,6 +189,26 @@ var DEFAULT_SCHEMA = "public";
189
189
  var MAXIMUM_CHANNEL_LENGTH = 63;
190
190
  var DEFAULT_TABLE_PREFIX = "relaybus_";
191
191
  var TABLE_PREFIX_PATTERN = /^[a-z0-9_]*$/;
192
+ function indexNames(index) {
193
+ return {
194
+ deliveryEventHandler: index("deliveries_event_handler_unique"),
195
+ deliveryLaneDue: index("deliveries_lane_due_idx"),
196
+ deliveryKeyPending: index("deliveries_key_pending_idx"),
197
+ eventCreated: index("events_created_idx"),
198
+ deliveryCompleted: index("deliveries_completed_idx"),
199
+ deliveryDead: index("deliveries_dead_idx"),
200
+ completionCompleted: index("completed_deliveries_completed_idx"),
201
+ eventCompletionsCompleted: index("event_completions_completed_idx"),
202
+ obsolete: [
203
+ index("deliveries_due_idx"),
204
+ index("deliveries_due_unkeyed_idx"),
205
+ index("deliveries_handler_created_idx"),
206
+ index("deliveries_key_unfinished_idx"),
207
+ index("deliveries_key_processing_idx"),
208
+ index("deliveries_status_created_idx")
209
+ ]
210
+ };
211
+ }
192
212
  function resolvePostgresNames(options = {}) {
193
213
  const schema = options.schema ?? DEFAULT_SCHEMA;
194
214
  const tablePrefix = options.tablePrefix ?? DEFAULT_TABLE_PREFIX;
@@ -218,24 +238,7 @@ function resolvePostgresNames(options = {}) {
218
238
  completions: table("completed_deliveries"),
219
239
  completionHistory: options.completionHistory ?? false,
220
240
  notificationChannel: channelName(`${schema}.${tablePrefix}deliveries`),
221
- indexes: {
222
- deliveryEventHandler: index("deliveries_event_handler_unique"),
223
- deliveryLaneDue: index("deliveries_lane_due_idx"),
224
- deliveryKeyPending: index("deliveries_key_pending_idx"),
225
- eventCreated: index("events_created_idx"),
226
- deliveryCompleted: index("deliveries_completed_idx"),
227
- deliveryDead: index("deliveries_dead_idx"),
228
- completionCompleted: index("completed_deliveries_completed_idx"),
229
- eventCompletionsCompleted: index("event_completions_completed_idx"),
230
- obsolete: [
231
- index("deliveries_due_idx"),
232
- index("deliveries_due_unkeyed_idx"),
233
- index("deliveries_handler_created_idx"),
234
- index("deliveries_key_unfinished_idx"),
235
- index("deliveries_key_processing_idx"),
236
- index("deliveries_status_created_idx")
237
- ]
238
- }
241
+ indexes: indexNames(index)
239
242
  };
240
243
  }
241
244
  var CHANNEL_HASH_DIGITS = 32;
@@ -266,6 +269,9 @@ function createPostgresSchemaStatements(options = {}) {
266
269
  return statements;
267
270
  }
268
271
  function eventAndDeliveryStatements(names) {
272
+ return [...eventAndKeySequenceStatements(names), ...deliveryStatements(names)];
273
+ }
274
+ function eventAndKeySequenceStatements(names) {
269
275
  return [
270
276
  `CREATE TABLE IF NOT EXISTS ${names.events} (
271
277
  id text PRIMARY KEY,
@@ -285,7 +291,11 @@ function eventAndDeliveryStatements(names) {
285
291
  `CREATE TABLE IF NOT EXISTS ${names.keySequences} (
286
292
  key text PRIMARY KEY,
287
293
  sequence bigint NOT NULL
288
- )`,
294
+ )`
295
+ ];
296
+ }
297
+ function deliveryStatements(names) {
298
+ return [
289
299
  `CREATE TABLE IF NOT EXISTS ${names.deliveries} (
290
300
  id text COLLATE "C" PRIMARY KEY,
291
301
  event_id text NOT NULL REFERENCES ${names.events} (id) ON DELETE CASCADE,
@@ -377,6 +387,16 @@ function createPostgresSchemaSql(options = {}) {
377
387
  `;
378
388
  }
379
389
  var RECONNECT_DELAY_MS = 1e3;
390
+ function endOnce(connection, ended) {
391
+ let ending = Promise.resolve();
392
+ return () => {
393
+ if (!ended.value) {
394
+ ended.value = true;
395
+ ending = connection.end().catch(() => void 0);
396
+ }
397
+ return ending;
398
+ };
399
+ }
380
400
  var NotificationListener = class {
381
401
  #connect;
382
402
  #channel;
@@ -395,15 +415,8 @@ var NotificationListener = class {
395
415
  /** Connects and listens. Rejects when that fails; a connection lost later is replaced. */
396
416
  async start() {
397
417
  const connection = await this.#connect();
398
- let ended = false;
399
- let ending = Promise.resolve();
400
- const end = () => {
401
- if (!ended) {
402
- ended = true;
403
- ending = connection.end().catch(() => void 0);
404
- }
405
- return ending;
406
- };
418
+ const ended = { value: false };
419
+ const end = endOnce(connection, ended);
407
420
  if (this.#closed) {
408
421
  await end();
409
422
  return;
@@ -411,7 +424,7 @@ var NotificationListener = class {
411
424
  this.#drop = end;
412
425
  let listening = false;
413
426
  const onLost = (error) => {
414
- if (!listening || ended) {
427
+ if (!listening || ended.value) {
415
428
  return;
416
429
  }
417
430
  void end();
@@ -427,14 +440,8 @@ var NotificationListener = class {
427
440
  try {
428
441
  await connection.query(`LISTEN "${this.#channel}"`);
429
442
  } catch (error) {
430
- if (this.#drop === end) {
431
- this.#drop = void 0;
432
- }
433
- await end();
434
- if (this.#closed) {
435
- return;
436
- }
437
- throw error;
443
+ await this.#abandonFailedListen(end, error);
444
+ return;
438
445
  }
439
446
  if (this.#closed) {
440
447
  await end();
@@ -442,6 +449,17 @@ var NotificationListener = class {
442
449
  }
443
450
  listening = true;
444
451
  }
452
+ /** Forgets and ends a connection whose LISTEN failed; rethrows the failure unless closing caused it. */
453
+ async #abandonFailedListen(end, error) {
454
+ if (this.#drop === end) {
455
+ this.#drop = void 0;
456
+ }
457
+ await end();
458
+ if (this.#closed) {
459
+ return;
460
+ }
461
+ throw error;
462
+ }
445
463
  #scheduleReconnect() {
446
464
  this.#reconnectTimer = internal.schedule(core.systemClock, {
447
465
  run: () => {
@@ -475,8 +493,6 @@ var NotificationListener = class {
475
493
  await drop?.();
476
494
  }
477
495
  };
478
-
479
- // src/row-mapping.ts
480
496
  function text(row, column) {
481
497
  return row[column];
482
498
  }
@@ -503,16 +519,16 @@ function mapEventEnvelope(row) {
503
519
  occurredAt: new Date(text(row, "event_occurred_at"))
504
520
  };
505
521
  }
522
+ var LEASE_TOKENS = new internal.LaneLeaseTokenFormat({
523
+ laneCount: LANE_COUNT,
524
+ epochDigits: 18,
525
+ claimsDigits: 9
526
+ });
506
527
  function formatLeaseToken(lane, { epoch, claims }) {
507
- return `${lane}:${epoch}:${claims}`;
528
+ return LEASE_TOKENS.format(lane, { epoch, claims });
508
529
  }
509
- var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,18}):(\d{1,9})$/;
510
530
  function parseLeaseToken(token) {
511
- const match = LEASE_TOKEN_PATTERN.exec(token);
512
- if (match === null || Number(match[1]) >= LANE_COUNT) {
513
- return void 0;
514
- }
515
- return { lane: Number(match[1]), epoch: match[2], claims: Number(match[3]) };
531
+ return LEASE_TOKENS.parse(token);
516
532
  }
517
533
  function mapClaimedDelivery(row, leaseExpiresAt) {
518
534
  return {
@@ -564,22 +580,11 @@ var PostgresAppend = class {
564
580
  const createdAt = now.toISOString();
565
581
  const wakeAt = this.#wakeAt(plan.deliveries, { now, transaction });
566
582
  const handlersByEvent = handlersByEventOf(plan.deliveries);
567
- const keyCounts = keyCountsOf(plan.events);
568
- const fresh = await this.#state.query({
569
- sql: this.#state.statements.append.insertFreshEvents,
570
- parameters: [
571
- JSON.stringify(
572
- plan.events.map((event, ordinal) => ({
573
- ordinal,
574
- ...toEventRow(event, { sequence: null, handlers: handlersByEvent.get(event.id) ?? [] })
575
- }))
576
- ),
577
- createdAt,
578
- JSON.stringify(plan.deliveries),
579
- JSON.stringify([...keyCounts].map(([key, count]) => ({ key, count }))),
580
- wakeAt
581
- ],
582
- transaction
583
+ const fresh = await this.#insertFreshEvents(transaction, {
584
+ plan,
585
+ handlersByEvent,
586
+ createdAt,
587
+ wakeAt
583
588
  });
584
589
  const insertedEventIds = new Set(
585
590
  fresh.filter((row) => row["inserted"] === true).map((row) => row["id"])
@@ -601,6 +606,40 @@ var PostgresAppend = class {
601
606
  }
602
607
  return { ...splitOutcomes(plan, insertedEventIds), appendedDeliveryCount };
603
608
  }
609
+ /** Runs the publishing statement that inserts the events not stored yet, with their deliveries. */
610
+ async #insertFreshEvents(transaction, {
611
+ plan,
612
+ handlersByEvent,
613
+ createdAt,
614
+ wakeAt
615
+ }) {
616
+ const keyCounts = keyCountsOf(plan.events);
617
+ return this.#state.query({
618
+ sql: this.#state.statements.append.insertFreshEvents,
619
+ parameters: [
620
+ JSON.stringify(
621
+ plan.events.map((event, ordinal) => ({
622
+ ordinal,
623
+ ...toEventRow(event, { sequence: null, handlers: handlersByEvent.get(event.id) ?? [] })
624
+ }))
625
+ ),
626
+ createdAt,
627
+ JSON.stringify(plan.deliveries),
628
+ JSON.stringify([...keyCounts].map(([key, count]) => ({ key, count }))),
629
+ wakeAt
630
+ ],
631
+ transaction
632
+ });
633
+ }
634
+ /** Locks the given events and returns the ids of those that still exist. */
635
+ async #lockExistingEvents(transaction, existing) {
636
+ const locked = await this.#state.query({
637
+ sql: this.#state.statements.append.lockExistingEvents,
638
+ parameters: [JSON.stringify(existing.map((row) => row.id).sort())],
639
+ transaction
640
+ });
641
+ return new Set(locked.map((row) => row["id"]));
642
+ }
604
643
  /**
605
644
  * The time the publishing statements notify the dispatchers for (deliveries due by then wake them),
606
645
  * or `null` for no notification. An append in a transaction of the caller's always notifies, at its
@@ -668,13 +707,7 @@ var PostgresAppend = class {
668
707
  })
669
708
  );
670
709
  while (existing.length > 0) {
671
- const locked = new Set(
672
- (await this.#state.query({
673
- sql: this.#state.statements.append.lockExistingEvents,
674
- parameters: [JSON.stringify(existing.map((row) => row.id).sort())],
675
- transaction
676
- })).map((row) => row["id"])
677
- );
710
+ const locked = await this.#lockExistingEvents(transaction, existing);
678
711
  const gone = existing.filter((row) => !locked.has(row.id));
679
712
  if (gone.length > 0) {
680
713
  const inserted2 = await this.#state.query({
@@ -711,7 +744,7 @@ function planAppend(entries) {
711
744
  events.push(event);
712
745
  }
713
746
  for (const delivery of newDeliveries) {
714
- const identity = `${event.id}\0${delivery.handler}`;
747
+ const identity = internal.CompositeKey.of(event.id, delivery.handler);
715
748
  if (!seenDeliveries.has(identity)) {
716
749
  seenDeliveries.add(identity);
717
750
  deliveries.push({
@@ -776,49 +809,46 @@ var ReconcileFailedError = class extends core.RelayBusError {
776
809
  name = "ReconcileFailedError";
777
810
  code = "RELAYBUS_POSTGRES_RECONCILE_FAILED";
778
811
  };
779
-
780
- // src/postgres-store-state.ts
781
- function handedOutKey(deliveryId, leaseToken) {
782
- return `${deliveryId}\0${leaseToken}`;
812
+ function leftWorkBehind(row) {
813
+ return row["takeover_waiting"] === true || row["unexamined_free_lanes"] === true || row["stale"] === true;
814
+ }
815
+ function keepKeyHeads(picked, limit) {
816
+ const seenKeys = /* @__PURE__ */ new Set();
817
+ const kept = [...picked].sort((left, right) => compareSequence(left, right)).filter((row) => {
818
+ if (row["delivery_key"] === null) {
819
+ return true;
820
+ }
821
+ const group = internal.CompositeKey.of(row["handler"], row["delivery_key"]);
822
+ return !seenKeys.has(group) && Boolean(seenKeys.add(group));
823
+ });
824
+ kept.sort(compareDue);
825
+ return kept.slice(0, limit);
826
+ }
827
+ function compareSequence(left, right) {
828
+ const first = sequenceOf(left);
829
+ const second = sequenceOf(right);
830
+ return Number(first > second) - Number(first < second);
831
+ }
832
+ function sequenceOf(row) {
833
+ return BigInt(row["sequence"] ?? 0);
834
+ }
835
+ function compareDue(left, right) {
836
+ return compareText(left["due_at"], right["due_at"]) || compareText(left["delivery_id"], right["delivery_id"]);
837
+ }
838
+ function compareText(left, right) {
839
+ return Number(left > right) - Number(left < right);
783
840
  }
784
841
 
785
842
  // src/postgres-claim.ts
786
843
  var INITIAL_LANES_PER_ROUND = 4;
787
844
  var MAXIMUM_STALE_CLAIM_ATTEMPTS = 3;
788
- var PostgresClaims = class {
845
+ var PostgresClaims = class extends internal.OwnerSerializedClaims {
789
846
  #state;
790
847
  constructor(state) {
848
+ super();
791
849
  this.#state = state;
792
850
  }
793
- /** Implements {@link PostgresEventStore.claim}. */
794
- claim(request) {
795
- if (request.handlers.length === 0 || !(request.limit > 0)) {
796
- return Promise.resolve([]);
797
- }
798
- return this.#serialized(request.leaseOwner, () => this.#claim(request));
799
- }
800
- /**
801
- * Runs `work` after the earlier claims of the same owner on this store settled. The
802
- * bookkeeping of what was handed out and the reconciliation of a failed claim need
803
- * one claim at a time per owner (the dispatcher claims one batch at a time anyway;
804
- * `runOnce` next to a running dispatcher is what this guards against).
805
- */
806
- #serialized(owner, work) {
807
- const previous = this.#state.ownerQueues.get(owner) ?? Promise.resolve();
808
- const run = previous.then(work, work);
809
- const tail = run.then(
810
- () => void 0,
811
- () => void 0
812
- );
813
- this.#state.ownerQueues.set(owner, tail);
814
- void tail.then(() => {
815
- if (this.#state.ownerQueues.get(owner) === tail) {
816
- this.#state.ownerQueues.delete(owner);
817
- }
818
- });
819
- return run;
820
- }
821
- async #claim(request) {
851
+ async claimInTurn(request) {
822
852
  await this.#assertReconciled(request.leaseOwner);
823
853
  const heldFirst = this.#state.drainingOwners.has(request.leaseOwner);
824
854
  let rows = await this.#claimFirst(request, heldFirst);
@@ -990,7 +1020,7 @@ var PostgresClaims = class {
990
1020
  #handOut(rows, leaseOwner) {
991
1021
  for (const row of rows) {
992
1022
  const claim = mapClaimedDelivery(row, /* @__PURE__ */ new Date(0));
993
- this.#state.handedOut.set(handedOutKey(claim.deliveryId, claim.leaseToken), {
1023
+ this.#state.handedOut.register(claim, {
994
1024
  owner: leaseOwner,
995
1025
  deliveryId: claim.deliveryId,
996
1026
  ...parseLeaseToken(claim.leaseToken)
@@ -1011,11 +1041,9 @@ var PostgresClaims = class {
1011
1041
  ...JSON.parse(row["running_ids"]),
1012
1042
  ...claimed.map((picked) => picked["delivery_id"])
1013
1043
  ]);
1014
- for (const [key, entry] of this.#state.handedOut) {
1015
- if (entry.owner === leaseOwner && heldLanes.has(entry.lane) && !running.has(entry.deliveryId)) {
1016
- this.#state.handedOut.delete(key);
1017
- }
1018
- }
1044
+ this.#state.handedOut.forgetWhere(
1045
+ (entry) => entry.owner === leaseOwner && heldLanes.has(entry.lane) && !running.has(entry.deliveryId)
1046
+ );
1019
1047
  }
1020
1048
  /**
1021
1049
  * After a claim failed, possibly after the database recorded its batch: gives back the recorded
@@ -1025,7 +1053,7 @@ var PostgresClaims = class {
1025
1053
  */
1026
1054
  async #reconcile(leaseOwner) {
1027
1055
  const generation = this.#generation(leaseOwner, true);
1028
- const handedOut = [...this.#state.handedOut.values()].filter((entry) => entry.owner === leaseOwner).map(({ deliveryId, lane, epoch, claims }) => ({ id: deliveryId, lane, epoch, claims }));
1056
+ const handedOut = this.#state.handedOut.entriesOf(leaseOwner).map(({ deliveryId, lane, epoch, claims }) => ({ id: deliveryId, lane, epoch, claims }));
1029
1057
  try {
1030
1058
  await this.#state.query({
1031
1059
  sql: this.#state.statements.claim.reconcileClaims,
@@ -1062,16 +1090,7 @@ var PostgresClaims = class {
1062
1090
  (row) => row["delivery_id"] !== null && !pickedIds.has(row["delivery_id"])
1063
1091
  )
1064
1092
  );
1065
- const seenKeys = /* @__PURE__ */ new Set();
1066
- const kept = [...picked].sort((left, right) => compareSequence(left, right)).filter((row) => {
1067
- if (row["delivery_key"] === null) {
1068
- return true;
1069
- }
1070
- const group = `${row["handler"]}\0${row["delivery_key"]}`;
1071
- return !seenKeys.has(group) && Boolean(seenKeys.add(group));
1072
- });
1073
- kept.sort(compareDue);
1074
- return kept.slice(0, limit);
1093
+ return keepKeyHeads(picked, limit);
1075
1094
  }
1076
1095
  return picked;
1077
1096
  }
@@ -1103,43 +1122,12 @@ var PostgresClaims = class {
1103
1122
  }
1104
1123
  };
1105
1124
  var GENERATION_STEP = 1n;
1106
- function leftWorkBehind(row) {
1107
- return row["takeover_waiting"] === true || row["unexamined_free_lanes"] === true || row["stale"] === true;
1108
- }
1109
- function compareSequence(left, right) {
1110
- const first = sequenceOf(left);
1111
- const second = sequenceOf(right);
1112
- return Number(first > second) - Number(first < second);
1113
- }
1114
- function sequenceOf(row) {
1115
- return BigInt(row["sequence"] ?? 0);
1116
- }
1117
- function compareDue(left, right) {
1118
- return compareText(left["due_at"], right["due_at"]) || compareText(left["delivery_id"], right["delivery_id"]);
1119
- }
1120
- function compareText(left, right) {
1121
- return Number(left > right) - Number(left < right);
1122
- }
1123
-
1124
- // src/postgres-completion.ts
1125
- var PostgresCompletion = class {
1125
+ var PostgresCompletion = class extends internal.LeaseSettlement {
1126
1126
  #state;
1127
1127
  constructor(state) {
1128
+ super();
1128
1129
  this.#state = state;
1129
1130
  }
1130
- /** Implements {@link PostgresEventStore.extendLease}. */
1131
- extendLease(lease, leaseExpiresAt) {
1132
- return this.#whenIssued(
1133
- lease,
1134
- (held) => this.#changed(this.#state.statements.completion.extendLease, [
1135
- lease.deliveryId,
1136
- held.lane,
1137
- held.epoch,
1138
- held.claims,
1139
- leaseExpiresAt.toISOString()
1140
- ])
1141
- );
1142
- }
1143
1131
  /** Implements {@link PostgresEventStore.complete}. */
1144
1132
  async complete(lease, { now, transaction }) {
1145
1133
  return (await this.#completeLeases([lease], { now, transaction }))[0];
@@ -1148,76 +1136,44 @@ var PostgresCompletion = class {
1148
1136
  completeMany(leases, now) {
1149
1137
  return this.#completeLeases(leases, { now, transaction: void 0 });
1150
1138
  }
1151
- /** Implements {@link PostgresEventStore.reschedule}. */
1152
- reschedule(lease, {
1153
- availableAt,
1154
- error,
1155
- now
1156
- }) {
1157
- return this.#settled(
1158
- lease,
1159
- this.#whenIssued(
1160
- lease,
1161
- (held) => this.#changed(this.#state.statements.completion.reschedule, [
1162
- lease.deliveryId,
1163
- held.lane,
1164
- held.epoch,
1165
- held.claims,
1166
- availableAt.toISOString(),
1167
- JSON.stringify(error),
1168
- now.toISOString()
1169
- ])
1170
- )
1171
- );
1139
+ /** A lease this store issued; any other token cannot be held. */
1140
+ findHeld(lease) {
1141
+ return parseLeaseToken(lease.leaseToken);
1172
1142
  }
1173
- /** Implements {@link PostgresEventStore.bury}. */
1174
- bury(lease, { error, now }) {
1175
- return this.#settled(
1176
- lease,
1177
- this.#whenIssued(
1178
- lease,
1179
- (held) => this.#changed(this.#state.statements.completion.bury, [
1180
- lease.deliveryId,
1181
- held.lane,
1182
- held.epoch,
1183
- held.claims,
1184
- JSON.stringify(error),
1185
- now.toISOString()
1186
- ])
1187
- )
1188
- );
1143
+ writeExtension(held, { lease, leaseExpiresAt }) {
1144
+ return this.#changed(this.#state.statements.completion.extendLease, [
1145
+ ...fenceOf(lease, held),
1146
+ leaseExpiresAt.toISOString()
1147
+ ]);
1189
1148
  }
1190
- /** Implements {@link PostgresEventStore.release}. */
1191
- release(lease, now) {
1192
- return this.#settled(
1193
- lease,
1194
- this.#whenIssued(
1195
- lease,
1196
- (held) => this.#changed(this.#state.statements.completion.release, [
1197
- lease.deliveryId,
1198
- held.lane,
1199
- held.epoch,
1200
- held.claims,
1201
- now.toISOString()
1202
- ])
1203
- )
1204
- );
1149
+ writeChange(held, { lease, change }) {
1150
+ const { completion } = this.#state.statements;
1151
+ const fence = fenceOf(lease, held);
1152
+ if (change.kind === "reschedule") {
1153
+ return this.#changed(completion.reschedule, [
1154
+ ...fence,
1155
+ change.availableAt.toISOString(),
1156
+ JSON.stringify(change.error),
1157
+ change.now.toISOString()
1158
+ ]);
1159
+ }
1160
+ if (change.kind === "bury") {
1161
+ return this.#changed(completion.bury, [
1162
+ ...fence,
1163
+ JSON.stringify(change.error),
1164
+ change.now.toISOString()
1165
+ ]);
1166
+ }
1167
+ return this.#changed(completion.release, [...fence, change.now.toISOString()]);
1205
1168
  }
1206
1169
  /** Forgets a handed-out lease once the store answered about it (any answer: the claim is over or fenced out). */
1207
- async #settled(lease, work) {
1208
- const result = await work;
1209
- this.#state.handedOut.delete(handedOutKey(lease.deliveryId, lease.leaseToken));
1210
- return result;
1170
+ forgetHandedOut(lease) {
1171
+ this.#state.handedOut.forget(lease);
1211
1172
  }
1212
1173
  async #changed(sql, parameters) {
1213
1174
  const rows = await this.#state.query({ sql, parameters });
1214
1175
  return readCount(rows[0], "count") > 0;
1215
1176
  }
1216
- /** Runs `operation` for a lease this store issued; any other token cannot be held. */
1217
- async #whenIssued(lease, operation) {
1218
- const held = parseLeaseToken(lease.leaseToken);
1219
- return held !== void 0 && await operation(held);
1220
- }
1221
1177
  /**
1222
1178
  * Deletes the delivery of every lease that is still the current claim of its
1223
1179
  * delivery. Returns one flag per lease, in order. Results are matched to leases
@@ -1225,16 +1181,7 @@ var PostgresCompletion = class {
1225
1181
  * their own answers; a lease listed twice completes once, at its first position.
1226
1182
  */
1227
1183
  async #completeLeases(leases, { now, transaction }) {
1228
- const issued = [];
1229
- const seen = /* @__PURE__ */ new Set();
1230
- leases.forEach((lease, ord) => {
1231
- const held = parseLeaseToken(lease.leaseToken);
1232
- const identity = `${lease.deliveryId}\0${lease.leaseToken}`;
1233
- if (held !== void 0 && !seen.has(identity)) {
1234
- seen.add(identity);
1235
- issued.push({ ord, id: lease.deliveryId, ...held });
1236
- }
1237
- });
1184
+ const issued = issuedLeases(leases);
1238
1185
  const completed = /* @__PURE__ */ new Set();
1239
1186
  if (issued.length > 0) {
1240
1187
  const rows = await this.#state.query({
@@ -1248,12 +1195,28 @@ var PostgresCompletion = class {
1248
1195
  }
1249
1196
  if (transaction === void 0) {
1250
1197
  for (const lease of leases) {
1251
- this.#state.handedOut.delete(handedOutKey(lease.deliveryId, lease.leaseToken));
1198
+ this.#state.handedOut.forget(lease);
1252
1199
  }
1253
1200
  }
1254
1201
  return leases.map((_, ord) => completed.has(ord));
1255
1202
  }
1256
1203
  };
1204
+ function fenceOf(lease, held) {
1205
+ return [lease.deliveryId, held.lane, held.epoch, held.claims];
1206
+ }
1207
+ function issuedLeases(leases) {
1208
+ const issued = [];
1209
+ const seen = /* @__PURE__ */ new Set();
1210
+ leases.forEach((lease, ord) => {
1211
+ const held = parseLeaseToken(lease.leaseToken);
1212
+ const identity = internal.DeliveryLeaseIdentity.of(lease.deliveryId, lease.leaseToken);
1213
+ if (held !== void 0 && !seen.has(identity)) {
1214
+ seen.add(identity);
1215
+ issued.push({ ord, id: lease.deliveryId, ...held });
1216
+ }
1217
+ });
1218
+ return issued;
1219
+ }
1257
1220
  var OwnedConnections = class {
1258
1221
  #clients = /* @__PURE__ */ new Set();
1259
1222
  /**
@@ -1352,6 +1315,24 @@ var PostgresMaintenance = class {
1352
1315
  /** Implements {@link PostgresEventStore.purge}. */
1353
1316
  async purge(request) {
1354
1317
  const total = { deletedDeliveries: 0, deletedEvents: 0 };
1318
+ const batches = this.#purgeBatchPlan(request);
1319
+ for (const [sql, cutoff, deleteOrphans] of batches) {
1320
+ if (cutoff === void 0) {
1321
+ continue;
1322
+ }
1323
+ let deletedInBatch = this.#state.purgeBatchSize;
1324
+ while (deletedInBatch === this.#state.purgeBatchSize) {
1325
+ const batch = await this.#purgeBatch(sql, { cutoff, deleteOrphans });
1326
+ deletedInBatch = batch.deletedDeliveries;
1327
+ total.deletedDeliveries += batch.deletedDeliveries;
1328
+ total.deletedEvents += batch.deletedEvents;
1329
+ }
1330
+ }
1331
+ total.deletedEvents += await this.#purgeAllOrphanEvents(request);
1332
+ return total;
1333
+ }
1334
+ /** The purge statements to run in order: SQL, cutoff (undefined skips it) and the orphan-event delete. */
1335
+ #purgeBatchPlan(request) {
1355
1336
  const batches = [
1356
1337
  [
1357
1338
  this.#state.statements.purge.purgeCompletedBatch(),
@@ -1371,21 +1352,14 @@ var PostgresMaintenance = class {
1371
1352
  this.#state.statements.purge.deleteOrphanEventsWithoutCompletion
1372
1353
  ]);
1373
1354
  }
1374
- for (const [sql, cutoff, deleteOrphans] of batches) {
1375
- if (cutoff === void 0) {
1376
- continue;
1377
- }
1378
- let deletedInBatch = this.#state.purgeBatchSize;
1379
- while (deletedInBatch === this.#state.purgeBatchSize) {
1380
- const batch = await this.#purgeBatch(sql, { cutoff, deleteOrphans });
1381
- deletedInBatch = batch.deletedDeliveries;
1382
- total.deletedDeliveries += batch.deletedDeliveries;
1383
- total.deletedEvents += batch.deletedEvents;
1384
- }
1385
- }
1355
+ return batches;
1356
+ }
1357
+ /** Removes the events left without deliveries, in two passes; returns how many events were deleted. */
1358
+ async #purgeAllOrphanEvents(request) {
1359
+ let deletedEvents = 0;
1386
1360
  const orphanCutoff = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
1387
1361
  if (orphanCutoff !== void 0) {
1388
- total.deletedEvents += await this.#purgeOrphanEvents(
1362
+ deletedEvents += await this.#purgeOrphanEvents(
1389
1363
  this.#state.statements.purge.lockOrphanEventsBatch,
1390
1364
  {
1391
1365
  deleteStatement: this.#state.statements.purge.deleteOrphanEventsWithoutCompletion,
@@ -1395,7 +1369,7 @@ var PostgresMaintenance = class {
1395
1369
  );
1396
1370
  }
1397
1371
  if (request.completedBefore !== void 0) {
1398
- total.deletedEvents += await this.#purgeOrphanEvents(
1372
+ deletedEvents += await this.#purgeOrphanEvents(
1399
1373
  this.#state.statements.purge.lockCompletedOrphanEventsBatch,
1400
1374
  {
1401
1375
  deleteStatement: this.#state.statements.purge.deleteExpiredCompletedOrphanEvents,
@@ -1404,7 +1378,7 @@ var PostgresMaintenance = class {
1404
1378
  }
1405
1379
  );
1406
1380
  }
1407
- return total;
1381
+ return deletedEvents;
1408
1382
  }
1409
1383
  async #purgeOrphanEvents(lockStatement, {
1410
1384
  deleteStatement,
@@ -1459,6 +1433,37 @@ var PostgresMaintenance = class {
1459
1433
  });
1460
1434
  }
1461
1435
  };
1436
+ var WAKE_UPS = ["notify", "poll"];
1437
+ var DEFAULT_PURGE_BATCH_SIZE = 1e3;
1438
+ function resolveStoreSettings(options) {
1439
+ const sources = [options.pool, options.connectionString, options.executor].filter(
1440
+ (source) => source !== void 0
1441
+ );
1442
+ if (sources.length !== 1) {
1443
+ throw new core.ConfigurationError(
1444
+ "PostgresEventStore needs exactly one of pool, connectionString or executor"
1445
+ );
1446
+ }
1447
+ const purgeBatchSize = internal.PositiveInteger.of(
1448
+ options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
1449
+ "purgeBatchSize"
1450
+ );
1451
+ const wakeUp = options.wakeUp ?? "notify";
1452
+ if (!WAKE_UPS.includes(wakeUp)) {
1453
+ throw new core.ConfigurationError(`wakeUp must be 'notify' or 'poll', received ${String(wakeUp)}`);
1454
+ }
1455
+ const preparedStatements = options.preparedStatements ?? true;
1456
+ if (typeof preparedStatements !== "boolean") {
1457
+ throw new core.ConfigurationError(
1458
+ `preparedStatements must be a boolean, received ${String(preparedStatements)}`
1459
+ );
1460
+ }
1461
+ return {
1462
+ purgeBatchSize,
1463
+ wakeUp,
1464
+ queryOptions: preparedStatements ? { prepare: true } : void 0
1465
+ };
1466
+ }
1462
1467
 
1463
1468
  // src/statements-append.ts
1464
1469
  var AppendStatements = class {
@@ -1476,15 +1481,27 @@ var AppendStatements = class {
1476
1481
  * inserted. Deliveries of events that already existed are left to {@link insertDeliveries}.
1477
1482
  */
1478
1483
  get insertFreshEvents() {
1479
- const events = this.#names.events;
1480
- return `WITH reserved AS (
1484
+ return `WITH ${this.#reservedSequences()}, ${this.#incomingEvents()}, ${this.#insertedEvents()}, ${this.#insertedDeliveries()}, ${this.#wakeUp("inserted_deliveries", "$5")}
1485
+ SELECT incoming.id, incoming.sequence::text AS sequence,
1486
+ incoming.id IN (SELECT inserted_events.id FROM inserted_events) AS inserted,
1487
+ (SELECT count(*) FROM inserted_deliveries)::int AS deliveries
1488
+ FROM incoming
1489
+ WHERE (SELECT count(*) FROM woken) >= 0`;
1490
+ }
1491
+ /** The CTE `reserved` of {@link insertFreshEvents}: the sequences the keys of `$4` reserve. */
1492
+ #reservedSequences() {
1493
+ return `reserved AS (
1481
1494
  INSERT INTO ${this.#names.keySequences} AS reserved (key, sequence)
1482
1495
  SELECT requested.key, requested.count
1483
1496
  FROM jsonb_to_recordset($4::jsonb) AS requested (key text, count bigint)
1484
1497
  ORDER BY requested.key
1485
1498
  ON CONFLICT (key) DO UPDATE SET sequence = reserved.sequence + EXCLUDED.sequence
1486
1499
  RETURNING reserved.key AS key, reserved.sequence AS sequence
1487
- ), incoming AS (
1500
+ )`;
1501
+ }
1502
+ /** The CTE `incoming` of {@link insertFreshEvents}: the events of `$1` with their key sequences. */
1503
+ #incomingEvents() {
1504
+ return `incoming AS (
1488
1505
  SELECT incoming.*,
1489
1506
  CASE WHEN incoming.key IS NULL THEN NULL ELSE
1490
1507
  reserved.sequence - count(*) OVER (PARTITION BY incoming.key)
@@ -1494,8 +1511,12 @@ var AppendStatements = class {
1494
1511
  ordinal int, id text, type text, source text, key text, payload jsonb, headers jsonb,
1495
1512
  correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1496
1513
  LEFT JOIN reserved ON reserved.key = incoming.key
1497
- ), inserted_events AS (
1498
- INSERT INTO ${events}
1514
+ )`;
1515
+ }
1516
+ /** The CTE `inserted_events` of {@link insertFreshEvents}: the events of `$1` that do not exist yet. */
1517
+ #insertedEvents() {
1518
+ return `inserted_events AS (
1519
+ INSERT INTO ${this.#names.events}
1499
1520
  (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1500
1521
  SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1501
1522
  COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
@@ -1505,7 +1526,11 @@ var AppendStatements = class {
1505
1526
  ORDER BY incoming.id
1506
1527
  ON CONFLICT (id) DO NOTHING
1507
1528
  RETURNING id, key, sequence
1508
- ), inserted_deliveries AS (
1529
+ )`;
1530
+ }
1531
+ /** The CTE `inserted_deliveries` of {@link insertFreshEvents}: the deliveries of `$3` of the inserted events. */
1532
+ #insertedDeliveries() {
1533
+ return `inserted_deliveries AS (
1509
1534
  INSERT INTO ${this.#names.deliveries}
1510
1535
  (id, event_id, handler, lane, status, attempts, available_at, key, sequence, created_at, updated_at)
1511
1536
  SELECT delivery.id, delivery.event_id, delivery.handler, ${laneExpression("event.key", "event.id")}, 'pending', 0,
@@ -1515,12 +1540,7 @@ var AppendStatements = class {
1515
1540
  ORDER BY delivery.event_id, delivery.handler
1516
1541
  ON CONFLICT (event_id, handler) DO NOTHING
1517
1542
  RETURNING available_at
1518
- ), ${this.#wakeUp("inserted_deliveries", "$5")}
1519
- SELECT incoming.id, incoming.sequence::text AS sequence,
1520
- incoming.id IN (SELECT inserted_events.id FROM inserted_events) AS inserted,
1521
- (SELECT count(*) FROM inserted_deliveries)::int AS deliveries
1522
- FROM incoming
1523
- WHERE (SELECT count(*) FROM woken) >= 0`;
1543
+ )`;
1524
1544
  }
1525
1545
  /** One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing transaction. */
1526
1546
  get notify() {
@@ -2047,11 +2067,146 @@ SELECT count(*)::int AS count FROM changed`;
2047
2067
  }
2048
2068
  };
2049
2069
 
2050
- // src/statements-lanes.ts
2051
- var FREE_LANE_SURPLUS = 16;
2070
+ // src/statements-candidates.ts
2052
2071
  function notRunning(running, alias) {
2053
2072
  return running === null ? "true" : `NOT EXISTS (SELECT 1 FROM ${running} AS running WHERE running.handler = ${alias}.handler AND running.key = ${alias}.key)`;
2054
2073
  }
2074
+ var CandidateStatements = class {
2075
+ #names;
2076
+ constructor(names) {
2077
+ this.#names = names;
2078
+ }
2079
+ /**
2080
+ * CTEs that pick up to `limit` claimable deliveries from the lanes of the CTE `scope` (columns `lane`,
2081
+ * `epoch`, `in_flight`), named after `prefix`; the result is `<prefix>_candidates (id, lane, available_at)`.
2082
+ * The rules are those of `ClaimStatements.claim`. The fast window reads the first {@link LANE_SCAN_WINDOW} due
2083
+ * deliveries of each lane (at most {@link CLAIM_SCAN_WINDOW} together); of a key, only the lowest sequence
2084
+ * in the window can be its head, so only that one is checked for an earlier pending delivery. Only when
2085
+ * that yields fewer than `limit` are the lanes whose window filled up looked at again with a loose index
2086
+ * scan over the heads of all their keys. `running` names the CTE of the keyed deliveries running in the
2087
+ * lanes, or is null when nothing runs there.
2088
+ */
2089
+ candidates(prefix, {
2090
+ scope,
2091
+ running,
2092
+ limit
2093
+ }) {
2094
+ const spec = { prefix, scope, running, limit };
2095
+ return this.#fastCandidates(spec) + this.#slowCandidates(spec);
2096
+ }
2097
+ /** The fast window of each lane and the claimable deliveries in it (`<prefix>_window`, `<prefix>_fast`). */
2098
+ #fastCandidates({ prefix, scope, running, limit }) {
2099
+ const deliveries = this.#names.deliveries;
2100
+ return `${prefix}_window AS MATERIALIZED (
2101
+ SELECT recent.id, recent.lane, recent.available_at, recent.handler, recent.key, recent.sequence
2102
+ FROM ${scope}
2103
+ CROSS JOIN LATERAL (
2104
+ SELECT delivery.id, delivery.lane, delivery.available_at, delivery.handler, delivery.key, delivery.sequence
2105
+ FROM ${deliveries} AS delivery
2106
+ WHERE delivery.lane = ${scope}.lane AND delivery.status = 'pending'
2107
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2108
+ AND delivery.id <> ALL (${scope}.in_flight)
2109
+ ORDER BY delivery.available_at, delivery.id
2110
+ LIMIT ${LANE_SCAN_WINDOW}
2111
+ ) AS recent
2112
+ ORDER BY recent.available_at, recent.id
2113
+ LIMIT ${CLAIM_SCAN_WINDOW}
2114
+ ), ${prefix}_fast AS MATERIALIZED (
2115
+ SELECT picked.id, picked.lane, picked.available_at FROM (
2116
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_window AS unkeyed WHERE unkeyed.key IS NULL
2117
+ UNION ALL
2118
+ SELECT head.id, head.lane, head.available_at
2119
+ FROM (
2120
+ SELECT DISTINCT ON (keyed.handler, keyed.key) keyed.id, keyed.lane, keyed.available_at, keyed.handler, keyed.key, keyed.sequence
2121
+ FROM ${prefix}_window AS keyed
2122
+ WHERE keyed.key IS NOT NULL
2123
+ ORDER BY keyed.handler, keyed.key, keyed.sequence
2124
+ ) AS head
2125
+ WHERE NOT EXISTS (
2126
+ SELECT 1 FROM ${deliveries} AS earlier
2127
+ WHERE earlier.lane = head.lane AND earlier.handler = head.handler AND earlier.key = head.key
2128
+ AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < head.sequence
2129
+ ) AND ${notRunning(running, "head")}
2130
+ ) AS picked
2131
+ ORDER BY picked.available_at, picked.id
2132
+ LIMIT ${limit}
2133
+ `;
2134
+ }
2135
+ /**
2136
+ * For lanes whose window filled up while the fast pick came up short: the heads of all their keys and
2137
+ * their unkeyed deliveries, then the union with the fast pick (`<prefix>_candidates`).
2138
+ */
2139
+ #slowCandidates(spec) {
2140
+ return this.#saturatedLanes(spec) + this.#keyHeads(spec) + this.#keyedHeads(spec) + this.#unkeyedRecent(spec) + this.#unionCandidates(spec);
2141
+ }
2142
+ /** `<prefix>_saturated`: the lanes whose window filled up while the fast pick came up short. */
2143
+ #saturatedLanes({ prefix, scope, limit }) {
2144
+ return `), ${prefix}_saturated AS MATERIALIZED (
2145
+ SELECT ${scope}.lane, ${scope}.in_flight FROM ${scope}
2146
+ WHERE (SELECT count(*) FROM ${prefix}_fast) < ${limit}
2147
+ AND ((SELECT count(*) FROM ${prefix}_window) >= ${CLAIM_SCAN_WINDOW}
2148
+ OR ${scope}.lane IN (
2149
+ SELECT full_lane.lane FROM ${prefix}_window AS full_lane
2150
+ GROUP BY full_lane.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
2151
+ ))
2152
+ `;
2153
+ }
2154
+ /** `<prefix>_heads`: the head of every key of the saturated lanes. */
2155
+ #keyHeads({ prefix }) {
2156
+ return `), ${prefix}_heads AS MATERIALIZED (
2157
+ SELECT head.id, head.lane, head.available_at, head.handler, head.key
2158
+ FROM ${prefix}_saturated AS scope
2159
+ CROSS JOIN LATERAL (${keyHeads(this.#names, "scope")}) AS head
2160
+ `;
2161
+ }
2162
+ /** `<prefix>_keyed`: the due heads that are not in flight and whose key runs nowhere. */
2163
+ #keyedHeads({ prefix, running, limit }) {
2164
+ return `), ${prefix}_keyed AS MATERIALIZED (
2165
+ SELECT heads.id, heads.lane, heads.available_at
2166
+ FROM ${prefix}_heads AS heads
2167
+ JOIN ${prefix}_saturated AS scope ON scope.lane = heads.lane
2168
+ WHERE heads.available_at <= $1::timestamptz AND heads.id <> ALL (scope.in_flight) AND ${notRunning(running, "heads")}
2169
+ ORDER BY heads.available_at, heads.id
2170
+ LIMIT ${limit}
2171
+ `;
2172
+ }
2173
+ /** `<prefix>_unkeyed`: the due unkeyed deliveries of the saturated lanes that are not in flight. */
2174
+ #unkeyedRecent({ prefix, limit }) {
2175
+ const deliveries = this.#names.deliveries;
2176
+ return `), ${prefix}_unkeyed AS MATERIALIZED (
2177
+ SELECT recent.id, recent.lane, recent.available_at
2178
+ FROM ${prefix}_saturated AS scope
2179
+ CROSS JOIN LATERAL (
2180
+ SELECT delivery.id, delivery.lane, delivery.available_at
2181
+ FROM ${deliveries} AS delivery
2182
+ WHERE delivery.lane = scope.lane AND delivery.key IS NULL AND delivery.status = 'pending'
2183
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2184
+ AND delivery.id <> ALL (scope.in_flight)
2185
+ ORDER BY delivery.available_at, delivery.id
2186
+ LIMIT ${limit}
2187
+ ) AS recent
2188
+ ORDER BY recent.available_at, recent.id
2189
+ LIMIT ${limit}
2190
+ `;
2191
+ }
2192
+ /** `<prefix>_candidates`: the union of the fast pick and the slow picks, oldest first, at most `limit`. */
2193
+ #unionCandidates({ prefix, limit }) {
2194
+ return `), ${prefix}_candidates AS MATERIALIZED (
2195
+ SELECT chosen.id, chosen.lane, chosen.available_at FROM (
2196
+ SELECT fast.id, fast.lane, fast.available_at FROM ${prefix}_fast AS fast
2197
+ UNION
2198
+ SELECT keyed.id, keyed.lane, keyed.available_at FROM ${prefix}_keyed AS keyed
2199
+ UNION
2200
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_unkeyed AS unkeyed
2201
+ ) AS chosen
2202
+ ORDER BY chosen.available_at, chosen.id
2203
+ LIMIT ${limit}
2204
+ )`;
2205
+ }
2206
+ };
2207
+
2208
+ // src/statements-lanes.ts
2209
+ var FREE_LANE_SURPLUS = 16;
2055
2210
  var freeLane = (alias) => `(${alias}.lease_expires_at IS NULL OR ${alias}.lease_expires_at <= $1::timestamptz
2056
2211
  OR (${alias}.lease_owner = $4::text AND ${alias}.claim_floor < $6::bigint))`;
2057
2212
  var NO_FREE_LANES = `), free_lanes AS (
@@ -2063,8 +2218,10 @@ var NO_FREE_LANES = `), free_lanes AS (
2063
2218
  )`;
2064
2219
  var LaneStatements = class {
2065
2220
  #names;
2221
+ #candidateStatements;
2066
2222
  constructor(names) {
2067
2223
  this.#names = names;
2224
+ this.#candidateStatements = new CandidateStatements(names);
2068
2225
  }
2069
2226
  /**
2070
2227
  * A claim in one statement, the only one in the common case. Parameters:
@@ -2139,7 +2296,7 @@ var LaneStatements = class {
2139
2296
  CROSS JOIN LATERAL unnest(held_scope.in_flight) AS flying (id)
2140
2297
  JOIN ${deliveries} AS delivery ON delivery.id = flying.id
2141
2298
  WHERE delivery.status = 'pending' AND delivery.key IS NOT NULL
2142
- ), ${this.#candidates("held", { scope: "held_scope", running: "held_running", limit: "$3::int" })}, wanted AS MATERIALIZED (
2299
+ ), ${this.#candidateStatements.candidates("held", { scope: "held_scope", running: "held_running", limit: "$3::int" })}, wanted AS MATERIALIZED (
2143
2300
  SELECT CASE WHEN (SELECT value FROM stale) THEN 0
2144
2301
  ELSE $3::int - (SELECT count(*) FROM held_candidates)::int END AS count
2145
2302
  `;
@@ -2228,7 +2385,7 @@ var LaneStatements = class {
2228
2385
  WHERE locked.epoch = seen.epoch AND locked.in_flight = seen.in_flight
2229
2386
  AND locked.lease_owner IS NOT DISTINCT FROM seen.lease_owner
2230
2387
  AND locked.lease_expires_at IS NOT DISTINCT FROM seen.lease_expires_at
2231
- ), ${this.#candidates("free", { scope: "free_scope", running: null, limit: "(SELECT count FROM wanted)" })}`;
2388
+ ), ${this.#candidateStatements.candidates("free", { scope: "free_scope", running: null, limit: "(SELECT count FROM wanted)" })}`;
2232
2389
  }
2233
2390
  /** The picked deliveries of held and taken lanes with their events, and the picks per lane. */
2234
2391
  #claimPicks() {
@@ -2317,113 +2474,6 @@ SELECT (SELECT value FROM stale) AS stale,
2317
2474
  FROM (SELECT 1) AS anchor
2318
2475
  LEFT JOIN picked_rows AS picked ON true
2319
2476
  ORDER BY picked.due_at, picked.delivery_id`;
2320
- }
2321
- /**
2322
- * CTEs that pick up to `limit` claimable deliveries from the lanes of the CTE `scope` (columns `lane`,
2323
- * `epoch`, `in_flight`), named after `prefix`; the result is `<prefix>_candidates (id, lane, available_at)`.
2324
- * The rules are those of `ClaimStatements.claim`. The fast window reads the first {@link LANE_SCAN_WINDOW} due
2325
- * deliveries of each lane (at most {@link CLAIM_SCAN_WINDOW} together); of a key, only the lowest sequence
2326
- * in the window can be its head, so only that one is checked for an earlier pending delivery. Only when
2327
- * that yields fewer than `limit` are the lanes whose window filled up looked at again with a loose index
2328
- * scan over the heads of all their keys. `running` names the CTE of the keyed deliveries running in the
2329
- * lanes, or is null when nothing runs there.
2330
- */
2331
- #candidates(prefix, {
2332
- scope,
2333
- running,
2334
- limit
2335
- }) {
2336
- const spec = { prefix, scope, running, limit };
2337
- return this.#fastCandidates(spec) + this.#slowCandidates(spec);
2338
- }
2339
- /** The fast window of each lane and the claimable deliveries in it (`<prefix>_window`, `<prefix>_fast`). */
2340
- #fastCandidates({ prefix, scope, running, limit }) {
2341
- const deliveries = this.#names.deliveries;
2342
- return `${prefix}_window AS MATERIALIZED (
2343
- SELECT recent.id, recent.lane, recent.available_at, recent.handler, recent.key, recent.sequence
2344
- FROM ${scope}
2345
- CROSS JOIN LATERAL (
2346
- SELECT delivery.id, delivery.lane, delivery.available_at, delivery.handler, delivery.key, delivery.sequence
2347
- FROM ${deliveries} AS delivery
2348
- WHERE delivery.lane = ${scope}.lane AND delivery.status = 'pending'
2349
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2350
- AND delivery.id <> ALL (${scope}.in_flight)
2351
- ORDER BY delivery.available_at, delivery.id
2352
- LIMIT ${LANE_SCAN_WINDOW}
2353
- ) AS recent
2354
- ORDER BY recent.available_at, recent.id
2355
- LIMIT ${CLAIM_SCAN_WINDOW}
2356
- ), ${prefix}_fast AS MATERIALIZED (
2357
- SELECT picked.id, picked.lane, picked.available_at FROM (
2358
- SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_window AS unkeyed WHERE unkeyed.key IS NULL
2359
- UNION ALL
2360
- SELECT head.id, head.lane, head.available_at
2361
- FROM (
2362
- SELECT DISTINCT ON (keyed.handler, keyed.key) keyed.id, keyed.lane, keyed.available_at, keyed.handler, keyed.key, keyed.sequence
2363
- FROM ${prefix}_window AS keyed
2364
- WHERE keyed.key IS NOT NULL
2365
- ORDER BY keyed.handler, keyed.key, keyed.sequence
2366
- ) AS head
2367
- WHERE NOT EXISTS (
2368
- SELECT 1 FROM ${deliveries} AS earlier
2369
- WHERE earlier.lane = head.lane AND earlier.handler = head.handler AND earlier.key = head.key
2370
- AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < head.sequence
2371
- ) AND ${notRunning(running, "head")}
2372
- ) AS picked
2373
- ORDER BY picked.available_at, picked.id
2374
- LIMIT ${limit}
2375
- `;
2376
- }
2377
- /**
2378
- * For lanes whose window filled up while the fast pick came up short: the heads of all their keys and
2379
- * their unkeyed deliveries, then the union with the fast pick (`<prefix>_candidates`).
2380
- */
2381
- #slowCandidates({ prefix, scope, running, limit }) {
2382
- const deliveries = this.#names.deliveries;
2383
- return `), ${prefix}_saturated AS MATERIALIZED (
2384
- SELECT ${scope}.lane, ${scope}.in_flight FROM ${scope}
2385
- WHERE (SELECT count(*) FROM ${prefix}_fast) < ${limit}
2386
- AND ((SELECT count(*) FROM ${prefix}_window) >= ${CLAIM_SCAN_WINDOW}
2387
- OR ${scope}.lane IN (
2388
- SELECT full_lane.lane FROM ${prefix}_window AS full_lane
2389
- GROUP BY full_lane.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
2390
- ))
2391
- ), ${prefix}_heads AS MATERIALIZED (
2392
- SELECT head.id, head.lane, head.available_at, head.handler, head.key
2393
- FROM ${prefix}_saturated AS scope
2394
- CROSS JOIN LATERAL (${keyHeads(this.#names, "scope")}) AS head
2395
- ), ${prefix}_keyed AS MATERIALIZED (
2396
- SELECT heads.id, heads.lane, heads.available_at
2397
- FROM ${prefix}_heads AS heads
2398
- JOIN ${prefix}_saturated AS scope ON scope.lane = heads.lane
2399
- WHERE heads.available_at <= $1::timestamptz AND heads.id <> ALL (scope.in_flight) AND ${notRunning(running, "heads")}
2400
- ORDER BY heads.available_at, heads.id
2401
- LIMIT ${limit}
2402
- ), ${prefix}_unkeyed AS MATERIALIZED (
2403
- SELECT recent.id, recent.lane, recent.available_at
2404
- FROM ${prefix}_saturated AS scope
2405
- CROSS JOIN LATERAL (
2406
- SELECT delivery.id, delivery.lane, delivery.available_at
2407
- FROM ${deliveries} AS delivery
2408
- WHERE delivery.lane = scope.lane AND delivery.key IS NULL AND delivery.status = 'pending'
2409
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2410
- AND delivery.id <> ALL (scope.in_flight)
2411
- ORDER BY delivery.available_at, delivery.id
2412
- LIMIT ${limit}
2413
- ) AS recent
2414
- ORDER BY recent.available_at, recent.id
2415
- LIMIT ${limit}
2416
- ), ${prefix}_candidates AS MATERIALIZED (
2417
- SELECT chosen.id, chosen.lane, chosen.available_at FROM (
2418
- SELECT fast.id, fast.lane, fast.available_at FROM ${prefix}_fast AS fast
2419
- UNION
2420
- SELECT keyed.id, keyed.lane, keyed.available_at FROM ${prefix}_keyed AS keyed
2421
- UNION
2422
- SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_unkeyed AS unkeyed
2423
- ) AS chosen
2424
- ORDER BY chosen.available_at, chosen.id
2425
- LIMIT ${limit}
2426
- )`;
2427
2477
  }
2428
2478
  /**
2429
2479
  * Takes up to `$3` free lanes (no lease, or an expired one) that have a due
@@ -2810,8 +2860,6 @@ var PostgresStatements = class {
2810
2860
  };
2811
2861
 
2812
2862
  // src/postgres-event-store.ts
2813
- var WAKE_UPS = ["notify", "poll"];
2814
- var DEFAULT_PURGE_BATCH_SIZE = 1e3;
2815
2863
  var PostgresEventStore = class {
2816
2864
  #state;
2817
2865
  #appender;
@@ -2838,10 +2886,9 @@ var PostgresEventStore = class {
2838
2886
  queryOptions,
2839
2887
  notifies: wakeUp === "notify",
2840
2888
  query: ({ sql, parameters, transaction }) => executor.query(sql, parameters, transaction, queryOptions),
2841
- handedOut: /* @__PURE__ */ new Map(),
2889
+ handedOut: new internal.HandedOutLedger(),
2842
2890
  unreconciled: /* @__PURE__ */ new Set(),
2843
2891
  generations: /* @__PURE__ */ new Map(),
2844
- ownerQueues: /* @__PURE__ */ new Map(),
2845
2892
  drainingOwners: /* @__PURE__ */ new Set(),
2846
2893
  ownedPool,
2847
2894
  pool,
@@ -2948,7 +2995,7 @@ var PostgresEventStore = class {
2948
2995
  return this.#claims.claim(request);
2949
2996
  }
2950
2997
  extendLease(lease, leaseExpiresAt) {
2951
- return this.#completion.extendLease(lease, leaseExpiresAt);
2998
+ return Promise.resolve(this.#completion.extendLease({ lease, leaseExpiresAt }));
2952
2999
  }
2953
3000
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2954
3001
  complete(lease, now, transaction) {
@@ -2959,15 +3006,24 @@ var PostgresEventStore = class {
2959
3006
  }
2960
3007
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2961
3008
  reschedule(lease, availableAt, error, now) {
2962
- return this.#completion.reschedule(lease, { availableAt, error, now });
3009
+ return Promise.resolve(
3010
+ this.#completion.changeClaim({
3011
+ lease,
3012
+ change: { kind: "reschedule", availableAt, error, now }
3013
+ })
3014
+ );
2963
3015
  }
2964
3016
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2965
3017
  bury(lease, error, now) {
2966
- return this.#completion.bury(lease, { error, now });
3018
+ return Promise.resolve(
3019
+ this.#completion.changeClaim({ lease, change: { kind: "bury", error, now } })
3020
+ );
2967
3021
  }
2968
3022
  /** Returns a leased delivery to pending, due now, without counting the attempt. */
2969
3023
  release(lease, now) {
2970
- return this.#completion.release(lease, now);
3024
+ return Promise.resolve(
3025
+ this.#completion.changeClaim({ lease, change: { kind: "release", now } })
3026
+ );
2971
3027
  }
2972
3028
  /**
2973
3029
  * Calls `listener` soon after a transaction that published deliveries due at once commits, in any
@@ -3046,35 +3102,6 @@ var PostgresEventStore = class {
3046
3102
  }
3047
3103
  }
3048
3104
  };
3049
- function resolveStoreSettings(options) {
3050
- const sources = [options.pool, options.connectionString, options.executor].filter(
3051
- (source) => source !== void 0
3052
- );
3053
- if (sources.length !== 1) {
3054
- throw new core.ConfigurationError(
3055
- "PostgresEventStore needs exactly one of pool, connectionString or executor"
3056
- );
3057
- }
3058
- const purgeBatchSize = internal.assertPositiveInteger(
3059
- options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
3060
- "purgeBatchSize"
3061
- );
3062
- const wakeUp = options.wakeUp ?? "notify";
3063
- if (!WAKE_UPS.includes(wakeUp)) {
3064
- throw new core.ConfigurationError(`wakeUp must be 'notify' or 'poll', received ${String(wakeUp)}`);
3065
- }
3066
- const preparedStatements = options.preparedStatements ?? true;
3067
- if (typeof preparedStatements !== "boolean") {
3068
- throw new core.ConfigurationError(
3069
- `preparedStatements must be a boolean, received ${String(preparedStatements)}`
3070
- );
3071
- }
3072
- return {
3073
- purgeBatchSize,
3074
- wakeUp,
3075
- queryOptions: preparedStatements ? { prepare: true } : void 0
3076
- };
3077
- }
3078
3105
  function connect(options, { logger, owned }) {
3079
3106
  if (options.executor !== void 0) {
3080
3107
  return { executor: options.executor, pool: void 0, ownedPool: void 0 };