@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.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: () => {
@@ -473,8 +491,6 @@ var NotificationListener = class {
473
491
  await drop?.();
474
492
  }
475
493
  };
476
-
477
- // src/row-mapping.ts
478
494
  function text(row, column) {
479
495
  return row[column];
480
496
  }
@@ -501,16 +517,16 @@ function mapEventEnvelope(row) {
501
517
  occurredAt: new Date(text(row, "event_occurred_at"))
502
518
  };
503
519
  }
520
+ var LEASE_TOKENS = new LaneLeaseTokenFormat({
521
+ laneCount: LANE_COUNT,
522
+ epochDigits: 18,
523
+ claimsDigits: 9
524
+ });
504
525
  function formatLeaseToken(lane, { epoch, claims }) {
505
- return `${lane}:${epoch}:${claims}`;
526
+ return LEASE_TOKENS.format(lane, { epoch, claims });
506
527
  }
507
- var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,18}):(\d{1,9})$/;
508
528
  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]) };
529
+ return LEASE_TOKENS.parse(token);
514
530
  }
515
531
  function mapClaimedDelivery(row, leaseExpiresAt) {
516
532
  return {
@@ -562,22 +578,11 @@ var PostgresAppend = class {
562
578
  const createdAt = now.toISOString();
563
579
  const wakeAt = this.#wakeAt(plan.deliveries, { now, transaction });
564
580
  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
581
+ const fresh = await this.#insertFreshEvents(transaction, {
582
+ plan,
583
+ handlersByEvent,
584
+ createdAt,
585
+ wakeAt
581
586
  });
582
587
  const insertedEventIds = new Set(
583
588
  fresh.filter((row) => row["inserted"] === true).map((row) => row["id"])
@@ -599,6 +604,40 @@ var PostgresAppend = class {
599
604
  }
600
605
  return { ...splitOutcomes(plan, insertedEventIds), appendedDeliveryCount };
601
606
  }
607
+ /** Runs the publishing statement that inserts the events not stored yet, with their deliveries. */
608
+ async #insertFreshEvents(transaction, {
609
+ plan,
610
+ handlersByEvent,
611
+ createdAt,
612
+ wakeAt
613
+ }) {
614
+ const keyCounts = keyCountsOf(plan.events);
615
+ return this.#state.query({
616
+ sql: this.#state.statements.append.insertFreshEvents,
617
+ parameters: [
618
+ JSON.stringify(
619
+ plan.events.map((event, ordinal) => ({
620
+ ordinal,
621
+ ...toEventRow(event, { sequence: null, handlers: handlersByEvent.get(event.id) ?? [] })
622
+ }))
623
+ ),
624
+ createdAt,
625
+ JSON.stringify(plan.deliveries),
626
+ JSON.stringify([...keyCounts].map(([key, count]) => ({ key, count }))),
627
+ wakeAt
628
+ ],
629
+ transaction
630
+ });
631
+ }
632
+ /** Locks the given events and returns the ids of those that still exist. */
633
+ async #lockExistingEvents(transaction, existing) {
634
+ const locked = await this.#state.query({
635
+ sql: this.#state.statements.append.lockExistingEvents,
636
+ parameters: [JSON.stringify(existing.map((row) => row.id).sort())],
637
+ transaction
638
+ });
639
+ return new Set(locked.map((row) => row["id"]));
640
+ }
602
641
  /**
603
642
  * The time the publishing statements notify the dispatchers for (deliveries due by then wake them),
604
643
  * or `null` for no notification. An append in a transaction of the caller's always notifies, at its
@@ -666,13 +705,7 @@ var PostgresAppend = class {
666
705
  })
667
706
  );
668
707
  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
- );
708
+ const locked = await this.#lockExistingEvents(transaction, existing);
676
709
  const gone = existing.filter((row) => !locked.has(row.id));
677
710
  if (gone.length > 0) {
678
711
  const inserted2 = await this.#state.query({
@@ -709,7 +742,7 @@ function planAppend(entries) {
709
742
  events.push(event);
710
743
  }
711
744
  for (const delivery of newDeliveries) {
712
- const identity = `${event.id}\0${delivery.handler}`;
745
+ const identity = CompositeKey.of(event.id, delivery.handler);
713
746
  if (!seenDeliveries.has(identity)) {
714
747
  seenDeliveries.add(identity);
715
748
  deliveries.push({
@@ -774,49 +807,46 @@ var ReconcileFailedError = class extends RelayBusError {
774
807
  name = "ReconcileFailedError";
775
808
  code = "RELAYBUS_POSTGRES_RECONCILE_FAILED";
776
809
  };
777
-
778
- // src/postgres-store-state.ts
779
- function handedOutKey(deliveryId, leaseToken) {
780
- return `${deliveryId}\0${leaseToken}`;
810
+ function leftWorkBehind(row) {
811
+ return row["takeover_waiting"] === true || row["unexamined_free_lanes"] === true || row["stale"] === true;
812
+ }
813
+ function keepKeyHeads(picked, limit) {
814
+ const seenKeys = /* @__PURE__ */ new Set();
815
+ const kept = [...picked].sort((left, right) => compareSequence(left, right)).filter((row) => {
816
+ if (row["delivery_key"] === null) {
817
+ return true;
818
+ }
819
+ const group = CompositeKey.of(row["handler"], row["delivery_key"]);
820
+ return !seenKeys.has(group) && Boolean(seenKeys.add(group));
821
+ });
822
+ kept.sort(compareDue);
823
+ return kept.slice(0, limit);
824
+ }
825
+ function compareSequence(left, right) {
826
+ const first = sequenceOf(left);
827
+ const second = sequenceOf(right);
828
+ return Number(first > second) - Number(first < second);
829
+ }
830
+ function sequenceOf(row) {
831
+ return BigInt(row["sequence"] ?? 0);
832
+ }
833
+ function compareDue(left, right) {
834
+ return compareText(left["due_at"], right["due_at"]) || compareText(left["delivery_id"], right["delivery_id"]);
835
+ }
836
+ function compareText(left, right) {
837
+ return Number(left > right) - Number(left < right);
781
838
  }
782
839
 
783
840
  // src/postgres-claim.ts
784
841
  var INITIAL_LANES_PER_ROUND = 4;
785
842
  var MAXIMUM_STALE_CLAIM_ATTEMPTS = 3;
786
- var PostgresClaims = class {
843
+ var PostgresClaims = class extends OwnerSerializedClaims {
787
844
  #state;
788
845
  constructor(state) {
846
+ super();
789
847
  this.#state = state;
790
848
  }
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) {
849
+ async claimInTurn(request) {
820
850
  await this.#assertReconciled(request.leaseOwner);
821
851
  const heldFirst = this.#state.drainingOwners.has(request.leaseOwner);
822
852
  let rows = await this.#claimFirst(request, heldFirst);
@@ -988,7 +1018,7 @@ var PostgresClaims = class {
988
1018
  #handOut(rows, leaseOwner) {
989
1019
  for (const row of rows) {
990
1020
  const claim = mapClaimedDelivery(row, /* @__PURE__ */ new Date(0));
991
- this.#state.handedOut.set(handedOutKey(claim.deliveryId, claim.leaseToken), {
1021
+ this.#state.handedOut.register(claim, {
992
1022
  owner: leaseOwner,
993
1023
  deliveryId: claim.deliveryId,
994
1024
  ...parseLeaseToken(claim.leaseToken)
@@ -1009,11 +1039,9 @@ var PostgresClaims = class {
1009
1039
  ...JSON.parse(row["running_ids"]),
1010
1040
  ...claimed.map((picked) => picked["delivery_id"])
1011
1041
  ]);
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
- }
1042
+ this.#state.handedOut.forgetWhere(
1043
+ (entry) => entry.owner === leaseOwner && heldLanes.has(entry.lane) && !running.has(entry.deliveryId)
1044
+ );
1017
1045
  }
1018
1046
  /**
1019
1047
  * After a claim failed, possibly after the database recorded its batch: gives back the recorded
@@ -1023,7 +1051,7 @@ var PostgresClaims = class {
1023
1051
  */
1024
1052
  async #reconcile(leaseOwner) {
1025
1053
  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 }));
1054
+ const handedOut = this.#state.handedOut.entriesOf(leaseOwner).map(({ deliveryId, lane, epoch, claims }) => ({ id: deliveryId, lane, epoch, claims }));
1027
1055
  try {
1028
1056
  await this.#state.query({
1029
1057
  sql: this.#state.statements.claim.reconcileClaims,
@@ -1060,16 +1088,7 @@ var PostgresClaims = class {
1060
1088
  (row) => row["delivery_id"] !== null && !pickedIds.has(row["delivery_id"])
1061
1089
  )
1062
1090
  );
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);
1091
+ return keepKeyHeads(picked, limit);
1073
1092
  }
1074
1093
  return picked;
1075
1094
  }
@@ -1101,43 +1120,12 @@ var PostgresClaims = class {
1101
1120
  }
1102
1121
  };
1103
1122
  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 {
1123
+ var PostgresCompletion = class extends LeaseSettlement {
1124
1124
  #state;
1125
1125
  constructor(state) {
1126
+ super();
1126
1127
  this.#state = state;
1127
1128
  }
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
1129
  /** Implements {@link PostgresEventStore.complete}. */
1142
1130
  async complete(lease, { now, transaction }) {
1143
1131
  return (await this.#completeLeases([lease], { now, transaction }))[0];
@@ -1146,76 +1134,44 @@ var PostgresCompletion = class {
1146
1134
  completeMany(leases, now) {
1147
1135
  return this.#completeLeases(leases, { now, transaction: void 0 });
1148
1136
  }
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
- );
1137
+ /** A lease this store issued; any other token cannot be held. */
1138
+ findHeld(lease) {
1139
+ return parseLeaseToken(lease.leaseToken);
1170
1140
  }
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
- );
1141
+ writeExtension(held, { lease, leaseExpiresAt }) {
1142
+ return this.#changed(this.#state.statements.completion.extendLease, [
1143
+ ...fenceOf(lease, held),
1144
+ leaseExpiresAt.toISOString()
1145
+ ]);
1187
1146
  }
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
- );
1147
+ writeChange(held, { lease, change }) {
1148
+ const { completion } = this.#state.statements;
1149
+ const fence = fenceOf(lease, held);
1150
+ if (change.kind === "reschedule") {
1151
+ return this.#changed(completion.reschedule, [
1152
+ ...fence,
1153
+ change.availableAt.toISOString(),
1154
+ JSON.stringify(change.error),
1155
+ change.now.toISOString()
1156
+ ]);
1157
+ }
1158
+ if (change.kind === "bury") {
1159
+ return this.#changed(completion.bury, [
1160
+ ...fence,
1161
+ JSON.stringify(change.error),
1162
+ change.now.toISOString()
1163
+ ]);
1164
+ }
1165
+ return this.#changed(completion.release, [...fence, change.now.toISOString()]);
1203
1166
  }
1204
1167
  /** 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;
1168
+ forgetHandedOut(lease) {
1169
+ this.#state.handedOut.forget(lease);
1209
1170
  }
1210
1171
  async #changed(sql, parameters) {
1211
1172
  const rows = await this.#state.query({ sql, parameters });
1212
1173
  return readCount(rows[0], "count") > 0;
1213
1174
  }
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
1175
  /**
1220
1176
  * Deletes the delivery of every lease that is still the current claim of its
1221
1177
  * delivery. Returns one flag per lease, in order. Results are matched to leases
@@ -1223,16 +1179,7 @@ var PostgresCompletion = class {
1223
1179
  * their own answers; a lease listed twice completes once, at its first position.
1224
1180
  */
1225
1181
  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
- });
1182
+ const issued = issuedLeases(leases);
1236
1183
  const completed = /* @__PURE__ */ new Set();
1237
1184
  if (issued.length > 0) {
1238
1185
  const rows = await this.#state.query({
@@ -1246,12 +1193,28 @@ var PostgresCompletion = class {
1246
1193
  }
1247
1194
  if (transaction === void 0) {
1248
1195
  for (const lease of leases) {
1249
- this.#state.handedOut.delete(handedOutKey(lease.deliveryId, lease.leaseToken));
1196
+ this.#state.handedOut.forget(lease);
1250
1197
  }
1251
1198
  }
1252
1199
  return leases.map((_, ord) => completed.has(ord));
1253
1200
  }
1254
1201
  };
1202
+ function fenceOf(lease, held) {
1203
+ return [lease.deliveryId, held.lane, held.epoch, held.claims];
1204
+ }
1205
+ function issuedLeases(leases) {
1206
+ const issued = [];
1207
+ const seen = /* @__PURE__ */ new Set();
1208
+ leases.forEach((lease, ord) => {
1209
+ const held = parseLeaseToken(lease.leaseToken);
1210
+ const identity = DeliveryLeaseIdentity.of(lease.deliveryId, lease.leaseToken);
1211
+ if (held !== void 0 && !seen.has(identity)) {
1212
+ seen.add(identity);
1213
+ issued.push({ ord, id: lease.deliveryId, ...held });
1214
+ }
1215
+ });
1216
+ return issued;
1217
+ }
1255
1218
  var OwnedConnections = class {
1256
1219
  #clients = /* @__PURE__ */ new Set();
1257
1220
  /**
@@ -1350,6 +1313,24 @@ var PostgresMaintenance = class {
1350
1313
  /** Implements {@link PostgresEventStore.purge}. */
1351
1314
  async purge(request) {
1352
1315
  const total = { deletedDeliveries: 0, deletedEvents: 0 };
1316
+ const batches = this.#purgeBatchPlan(request);
1317
+ for (const [sql, cutoff, deleteOrphans] of batches) {
1318
+ if (cutoff === void 0) {
1319
+ continue;
1320
+ }
1321
+ let deletedInBatch = this.#state.purgeBatchSize;
1322
+ while (deletedInBatch === this.#state.purgeBatchSize) {
1323
+ const batch = await this.#purgeBatch(sql, { cutoff, deleteOrphans });
1324
+ deletedInBatch = batch.deletedDeliveries;
1325
+ total.deletedDeliveries += batch.deletedDeliveries;
1326
+ total.deletedEvents += batch.deletedEvents;
1327
+ }
1328
+ }
1329
+ total.deletedEvents += await this.#purgeAllOrphanEvents(request);
1330
+ return total;
1331
+ }
1332
+ /** The purge statements to run in order: SQL, cutoff (undefined skips it) and the orphan-event delete. */
1333
+ #purgeBatchPlan(request) {
1353
1334
  const batches = [
1354
1335
  [
1355
1336
  this.#state.statements.purge.purgeCompletedBatch(),
@@ -1369,21 +1350,14 @@ var PostgresMaintenance = class {
1369
1350
  this.#state.statements.purge.deleteOrphanEventsWithoutCompletion
1370
1351
  ]);
1371
1352
  }
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
- }
1353
+ return batches;
1354
+ }
1355
+ /** Removes the events left without deliveries, in two passes; returns how many events were deleted. */
1356
+ async #purgeAllOrphanEvents(request) {
1357
+ let deletedEvents = 0;
1384
1358
  const orphanCutoff = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
1385
1359
  if (orphanCutoff !== void 0) {
1386
- total.deletedEvents += await this.#purgeOrphanEvents(
1360
+ deletedEvents += await this.#purgeOrphanEvents(
1387
1361
  this.#state.statements.purge.lockOrphanEventsBatch,
1388
1362
  {
1389
1363
  deleteStatement: this.#state.statements.purge.deleteOrphanEventsWithoutCompletion,
@@ -1393,7 +1367,7 @@ var PostgresMaintenance = class {
1393
1367
  );
1394
1368
  }
1395
1369
  if (request.completedBefore !== void 0) {
1396
- total.deletedEvents += await this.#purgeOrphanEvents(
1370
+ deletedEvents += await this.#purgeOrphanEvents(
1397
1371
  this.#state.statements.purge.lockCompletedOrphanEventsBatch,
1398
1372
  {
1399
1373
  deleteStatement: this.#state.statements.purge.deleteExpiredCompletedOrphanEvents,
@@ -1402,7 +1376,7 @@ var PostgresMaintenance = class {
1402
1376
  }
1403
1377
  );
1404
1378
  }
1405
- return total;
1379
+ return deletedEvents;
1406
1380
  }
1407
1381
  async #purgeOrphanEvents(lockStatement, {
1408
1382
  deleteStatement,
@@ -1457,6 +1431,37 @@ var PostgresMaintenance = class {
1457
1431
  });
1458
1432
  }
1459
1433
  };
1434
+ var WAKE_UPS = ["notify", "poll"];
1435
+ var DEFAULT_PURGE_BATCH_SIZE = 1e3;
1436
+ function resolveStoreSettings(options) {
1437
+ const sources = [options.pool, options.connectionString, options.executor].filter(
1438
+ (source) => source !== void 0
1439
+ );
1440
+ if (sources.length !== 1) {
1441
+ throw new ConfigurationError(
1442
+ "PostgresEventStore needs exactly one of pool, connectionString or executor"
1443
+ );
1444
+ }
1445
+ const purgeBatchSize = PositiveInteger.of(
1446
+ options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
1447
+ "purgeBatchSize"
1448
+ );
1449
+ const wakeUp = options.wakeUp ?? "notify";
1450
+ if (!WAKE_UPS.includes(wakeUp)) {
1451
+ throw new ConfigurationError(`wakeUp must be 'notify' or 'poll', received ${String(wakeUp)}`);
1452
+ }
1453
+ const preparedStatements = options.preparedStatements ?? true;
1454
+ if (typeof preparedStatements !== "boolean") {
1455
+ throw new ConfigurationError(
1456
+ `preparedStatements must be a boolean, received ${String(preparedStatements)}`
1457
+ );
1458
+ }
1459
+ return {
1460
+ purgeBatchSize,
1461
+ wakeUp,
1462
+ queryOptions: preparedStatements ? { prepare: true } : void 0
1463
+ };
1464
+ }
1460
1465
 
1461
1466
  // src/statements-append.ts
1462
1467
  var AppendStatements = class {
@@ -1474,15 +1479,27 @@ var AppendStatements = class {
1474
1479
  * inserted. Deliveries of events that already existed are left to {@link insertDeliveries}.
1475
1480
  */
1476
1481
  get insertFreshEvents() {
1477
- const events = this.#names.events;
1478
- return `WITH reserved AS (
1482
+ return `WITH ${this.#reservedSequences()}, ${this.#incomingEvents()}, ${this.#insertedEvents()}, ${this.#insertedDeliveries()}, ${this.#wakeUp("inserted_deliveries", "$5")}
1483
+ SELECT incoming.id, incoming.sequence::text AS sequence,
1484
+ incoming.id IN (SELECT inserted_events.id FROM inserted_events) AS inserted,
1485
+ (SELECT count(*) FROM inserted_deliveries)::int AS deliveries
1486
+ FROM incoming
1487
+ WHERE (SELECT count(*) FROM woken) >= 0`;
1488
+ }
1489
+ /** The CTE `reserved` of {@link insertFreshEvents}: the sequences the keys of `$4` reserve. */
1490
+ #reservedSequences() {
1491
+ return `reserved AS (
1479
1492
  INSERT INTO ${this.#names.keySequences} AS reserved (key, sequence)
1480
1493
  SELECT requested.key, requested.count
1481
1494
  FROM jsonb_to_recordset($4::jsonb) AS requested (key text, count bigint)
1482
1495
  ORDER BY requested.key
1483
1496
  ON CONFLICT (key) DO UPDATE SET sequence = reserved.sequence + EXCLUDED.sequence
1484
1497
  RETURNING reserved.key AS key, reserved.sequence AS sequence
1485
- ), incoming AS (
1498
+ )`;
1499
+ }
1500
+ /** The CTE `incoming` of {@link insertFreshEvents}: the events of `$1` with their key sequences. */
1501
+ #incomingEvents() {
1502
+ return `incoming AS (
1486
1503
  SELECT incoming.*,
1487
1504
  CASE WHEN incoming.key IS NULL THEN NULL ELSE
1488
1505
  reserved.sequence - count(*) OVER (PARTITION BY incoming.key)
@@ -1492,8 +1509,12 @@ var AppendStatements = class {
1492
1509
  ordinal int, id text, type text, source text, key text, payload jsonb, headers jsonb,
1493
1510
  correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1494
1511
  LEFT JOIN reserved ON reserved.key = incoming.key
1495
- ), inserted_events AS (
1496
- INSERT INTO ${events}
1512
+ )`;
1513
+ }
1514
+ /** The CTE `inserted_events` of {@link insertFreshEvents}: the events of `$1` that do not exist yet. */
1515
+ #insertedEvents() {
1516
+ return `inserted_events AS (
1517
+ INSERT INTO ${this.#names.events}
1497
1518
  (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1498
1519
  SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1499
1520
  COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
@@ -1503,7 +1524,11 @@ var AppendStatements = class {
1503
1524
  ORDER BY incoming.id
1504
1525
  ON CONFLICT (id) DO NOTHING
1505
1526
  RETURNING id, key, sequence
1506
- ), inserted_deliveries AS (
1527
+ )`;
1528
+ }
1529
+ /** The CTE `inserted_deliveries` of {@link insertFreshEvents}: the deliveries of `$3` of the inserted events. */
1530
+ #insertedDeliveries() {
1531
+ return `inserted_deliveries AS (
1507
1532
  INSERT INTO ${this.#names.deliveries}
1508
1533
  (id, event_id, handler, lane, status, attempts, available_at, key, sequence, created_at, updated_at)
1509
1534
  SELECT delivery.id, delivery.event_id, delivery.handler, ${laneExpression("event.key", "event.id")}, 'pending', 0,
@@ -1513,12 +1538,7 @@ var AppendStatements = class {
1513
1538
  ORDER BY delivery.event_id, delivery.handler
1514
1539
  ON CONFLICT (event_id, handler) DO NOTHING
1515
1540
  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`;
1541
+ )`;
1522
1542
  }
1523
1543
  /** One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing transaction. */
1524
1544
  get notify() {
@@ -2045,11 +2065,146 @@ SELECT count(*)::int AS count FROM changed`;
2045
2065
  }
2046
2066
  };
2047
2067
 
2048
- // src/statements-lanes.ts
2049
- var FREE_LANE_SURPLUS = 16;
2068
+ // src/statements-candidates.ts
2050
2069
  function notRunning(running, alias) {
2051
2070
  return running === null ? "true" : `NOT EXISTS (SELECT 1 FROM ${running} AS running WHERE running.handler = ${alias}.handler AND running.key = ${alias}.key)`;
2052
2071
  }
2072
+ var CandidateStatements = class {
2073
+ #names;
2074
+ constructor(names) {
2075
+ this.#names = names;
2076
+ }
2077
+ /**
2078
+ * CTEs that pick up to `limit` claimable deliveries from the lanes of the CTE `scope` (columns `lane`,
2079
+ * `epoch`, `in_flight`), named after `prefix`; the result is `<prefix>_candidates (id, lane, available_at)`.
2080
+ * The rules are those of `ClaimStatements.claim`. The fast window reads the first {@link LANE_SCAN_WINDOW} due
2081
+ * deliveries of each lane (at most {@link CLAIM_SCAN_WINDOW} together); of a key, only the lowest sequence
2082
+ * in the window can be its head, so only that one is checked for an earlier pending delivery. Only when
2083
+ * that yields fewer than `limit` are the lanes whose window filled up looked at again with a loose index
2084
+ * scan over the heads of all their keys. `running` names the CTE of the keyed deliveries running in the
2085
+ * lanes, or is null when nothing runs there.
2086
+ */
2087
+ candidates(prefix, {
2088
+ scope,
2089
+ running,
2090
+ limit
2091
+ }) {
2092
+ const spec = { prefix, scope, running, limit };
2093
+ return this.#fastCandidates(spec) + this.#slowCandidates(spec);
2094
+ }
2095
+ /** The fast window of each lane and the claimable deliveries in it (`<prefix>_window`, `<prefix>_fast`). */
2096
+ #fastCandidates({ prefix, scope, running, limit }) {
2097
+ const deliveries = this.#names.deliveries;
2098
+ return `${prefix}_window AS MATERIALIZED (
2099
+ SELECT recent.id, recent.lane, recent.available_at, recent.handler, recent.key, recent.sequence
2100
+ FROM ${scope}
2101
+ CROSS JOIN LATERAL (
2102
+ SELECT delivery.id, delivery.lane, delivery.available_at, delivery.handler, delivery.key, delivery.sequence
2103
+ FROM ${deliveries} AS delivery
2104
+ WHERE delivery.lane = ${scope}.lane AND delivery.status = 'pending'
2105
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2106
+ AND delivery.id <> ALL (${scope}.in_flight)
2107
+ ORDER BY delivery.available_at, delivery.id
2108
+ LIMIT ${LANE_SCAN_WINDOW}
2109
+ ) AS recent
2110
+ ORDER BY recent.available_at, recent.id
2111
+ LIMIT ${CLAIM_SCAN_WINDOW}
2112
+ ), ${prefix}_fast AS MATERIALIZED (
2113
+ SELECT picked.id, picked.lane, picked.available_at FROM (
2114
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_window AS unkeyed WHERE unkeyed.key IS NULL
2115
+ UNION ALL
2116
+ SELECT head.id, head.lane, head.available_at
2117
+ FROM (
2118
+ SELECT DISTINCT ON (keyed.handler, keyed.key) keyed.id, keyed.lane, keyed.available_at, keyed.handler, keyed.key, keyed.sequence
2119
+ FROM ${prefix}_window AS keyed
2120
+ WHERE keyed.key IS NOT NULL
2121
+ ORDER BY keyed.handler, keyed.key, keyed.sequence
2122
+ ) AS head
2123
+ WHERE NOT EXISTS (
2124
+ SELECT 1 FROM ${deliveries} AS earlier
2125
+ WHERE earlier.lane = head.lane AND earlier.handler = head.handler AND earlier.key = head.key
2126
+ AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < head.sequence
2127
+ ) AND ${notRunning(running, "head")}
2128
+ ) AS picked
2129
+ ORDER BY picked.available_at, picked.id
2130
+ LIMIT ${limit}
2131
+ `;
2132
+ }
2133
+ /**
2134
+ * For lanes whose window filled up while the fast pick came up short: the heads of all their keys and
2135
+ * their unkeyed deliveries, then the union with the fast pick (`<prefix>_candidates`).
2136
+ */
2137
+ #slowCandidates(spec) {
2138
+ return this.#saturatedLanes(spec) + this.#keyHeads(spec) + this.#keyedHeads(spec) + this.#unkeyedRecent(spec) + this.#unionCandidates(spec);
2139
+ }
2140
+ /** `<prefix>_saturated`: the lanes whose window filled up while the fast pick came up short. */
2141
+ #saturatedLanes({ prefix, scope, limit }) {
2142
+ return `), ${prefix}_saturated AS MATERIALIZED (
2143
+ SELECT ${scope}.lane, ${scope}.in_flight FROM ${scope}
2144
+ WHERE (SELECT count(*) FROM ${prefix}_fast) < ${limit}
2145
+ AND ((SELECT count(*) FROM ${prefix}_window) >= ${CLAIM_SCAN_WINDOW}
2146
+ OR ${scope}.lane IN (
2147
+ SELECT full_lane.lane FROM ${prefix}_window AS full_lane
2148
+ GROUP BY full_lane.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
2149
+ ))
2150
+ `;
2151
+ }
2152
+ /** `<prefix>_heads`: the head of every key of the saturated lanes. */
2153
+ #keyHeads({ prefix }) {
2154
+ return `), ${prefix}_heads AS MATERIALIZED (
2155
+ SELECT head.id, head.lane, head.available_at, head.handler, head.key
2156
+ FROM ${prefix}_saturated AS scope
2157
+ CROSS JOIN LATERAL (${keyHeads(this.#names, "scope")}) AS head
2158
+ `;
2159
+ }
2160
+ /** `<prefix>_keyed`: the due heads that are not in flight and whose key runs nowhere. */
2161
+ #keyedHeads({ prefix, running, limit }) {
2162
+ return `), ${prefix}_keyed AS MATERIALIZED (
2163
+ SELECT heads.id, heads.lane, heads.available_at
2164
+ FROM ${prefix}_heads AS heads
2165
+ JOIN ${prefix}_saturated AS scope ON scope.lane = heads.lane
2166
+ WHERE heads.available_at <= $1::timestamptz AND heads.id <> ALL (scope.in_flight) AND ${notRunning(running, "heads")}
2167
+ ORDER BY heads.available_at, heads.id
2168
+ LIMIT ${limit}
2169
+ `;
2170
+ }
2171
+ /** `<prefix>_unkeyed`: the due unkeyed deliveries of the saturated lanes that are not in flight. */
2172
+ #unkeyedRecent({ prefix, limit }) {
2173
+ const deliveries = this.#names.deliveries;
2174
+ return `), ${prefix}_unkeyed AS MATERIALIZED (
2175
+ SELECT recent.id, recent.lane, recent.available_at
2176
+ FROM ${prefix}_saturated AS scope
2177
+ CROSS JOIN LATERAL (
2178
+ SELECT delivery.id, delivery.lane, delivery.available_at
2179
+ FROM ${deliveries} AS delivery
2180
+ WHERE delivery.lane = scope.lane AND delivery.key IS NULL AND delivery.status = 'pending'
2181
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2182
+ AND delivery.id <> ALL (scope.in_flight)
2183
+ ORDER BY delivery.available_at, delivery.id
2184
+ LIMIT ${limit}
2185
+ ) AS recent
2186
+ ORDER BY recent.available_at, recent.id
2187
+ LIMIT ${limit}
2188
+ `;
2189
+ }
2190
+ /** `<prefix>_candidates`: the union of the fast pick and the slow picks, oldest first, at most `limit`. */
2191
+ #unionCandidates({ prefix, limit }) {
2192
+ return `), ${prefix}_candidates AS MATERIALIZED (
2193
+ SELECT chosen.id, chosen.lane, chosen.available_at FROM (
2194
+ SELECT fast.id, fast.lane, fast.available_at FROM ${prefix}_fast AS fast
2195
+ UNION
2196
+ SELECT keyed.id, keyed.lane, keyed.available_at FROM ${prefix}_keyed AS keyed
2197
+ UNION
2198
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_unkeyed AS unkeyed
2199
+ ) AS chosen
2200
+ ORDER BY chosen.available_at, chosen.id
2201
+ LIMIT ${limit}
2202
+ )`;
2203
+ }
2204
+ };
2205
+
2206
+ // src/statements-lanes.ts
2207
+ var FREE_LANE_SURPLUS = 16;
2053
2208
  var freeLane = (alias) => `(${alias}.lease_expires_at IS NULL OR ${alias}.lease_expires_at <= $1::timestamptz
2054
2209
  OR (${alias}.lease_owner = $4::text AND ${alias}.claim_floor < $6::bigint))`;
2055
2210
  var NO_FREE_LANES = `), free_lanes AS (
@@ -2061,8 +2216,10 @@ var NO_FREE_LANES = `), free_lanes AS (
2061
2216
  )`;
2062
2217
  var LaneStatements = class {
2063
2218
  #names;
2219
+ #candidateStatements;
2064
2220
  constructor(names) {
2065
2221
  this.#names = names;
2222
+ this.#candidateStatements = new CandidateStatements(names);
2066
2223
  }
2067
2224
  /**
2068
2225
  * A claim in one statement, the only one in the common case. Parameters:
@@ -2137,7 +2294,7 @@ var LaneStatements = class {
2137
2294
  CROSS JOIN LATERAL unnest(held_scope.in_flight) AS flying (id)
2138
2295
  JOIN ${deliveries} AS delivery ON delivery.id = flying.id
2139
2296
  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 (
2297
+ ), ${this.#candidateStatements.candidates("held", { scope: "held_scope", running: "held_running", limit: "$3::int" })}, wanted AS MATERIALIZED (
2141
2298
  SELECT CASE WHEN (SELECT value FROM stale) THEN 0
2142
2299
  ELSE $3::int - (SELECT count(*) FROM held_candidates)::int END AS count
2143
2300
  `;
@@ -2226,7 +2383,7 @@ var LaneStatements = class {
2226
2383
  WHERE locked.epoch = seen.epoch AND locked.in_flight = seen.in_flight
2227
2384
  AND locked.lease_owner IS NOT DISTINCT FROM seen.lease_owner
2228
2385
  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)" })}`;
2386
+ ), ${this.#candidateStatements.candidates("free", { scope: "free_scope", running: null, limit: "(SELECT count FROM wanted)" })}`;
2230
2387
  }
2231
2388
  /** The picked deliveries of held and taken lanes with their events, and the picks per lane. */
2232
2389
  #claimPicks() {
@@ -2315,113 +2472,6 @@ SELECT (SELECT value FROM stale) AS stale,
2315
2472
  FROM (SELECT 1) AS anchor
2316
2473
  LEFT JOIN picked_rows AS picked ON true
2317
2474
  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
2475
  }
2426
2476
  /**
2427
2477
  * Takes up to `$3` free lanes (no lease, or an expired one) that have a due
@@ -2808,8 +2858,6 @@ var PostgresStatements = class {
2808
2858
  };
2809
2859
 
2810
2860
  // src/postgres-event-store.ts
2811
- var WAKE_UPS = ["notify", "poll"];
2812
- var DEFAULT_PURGE_BATCH_SIZE = 1e3;
2813
2861
  var PostgresEventStore = class {
2814
2862
  #state;
2815
2863
  #appender;
@@ -2836,10 +2884,9 @@ var PostgresEventStore = class {
2836
2884
  queryOptions,
2837
2885
  notifies: wakeUp === "notify",
2838
2886
  query: ({ sql, parameters, transaction }) => executor.query(sql, parameters, transaction, queryOptions),
2839
- handedOut: /* @__PURE__ */ new Map(),
2887
+ handedOut: new HandedOutLedger(),
2840
2888
  unreconciled: /* @__PURE__ */ new Set(),
2841
2889
  generations: /* @__PURE__ */ new Map(),
2842
- ownerQueues: /* @__PURE__ */ new Map(),
2843
2890
  drainingOwners: /* @__PURE__ */ new Set(),
2844
2891
  ownedPool,
2845
2892
  pool,
@@ -2946,7 +2993,7 @@ var PostgresEventStore = class {
2946
2993
  return this.#claims.claim(request);
2947
2994
  }
2948
2995
  extendLease(lease, leaseExpiresAt) {
2949
- return this.#completion.extendLease(lease, leaseExpiresAt);
2996
+ return Promise.resolve(this.#completion.extendLease({ lease, leaseExpiresAt }));
2950
2997
  }
2951
2998
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2952
2999
  complete(lease, now, transaction) {
@@ -2957,15 +3004,24 @@ var PostgresEventStore = class {
2957
3004
  }
2958
3005
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2959
3006
  reschedule(lease, availableAt, error, now) {
2960
- return this.#completion.reschedule(lease, { availableAt, error, now });
3007
+ return Promise.resolve(
3008
+ this.#completion.changeClaim({
3009
+ lease,
3010
+ change: { kind: "reschedule", availableAt, error, now }
3011
+ })
3012
+ );
2961
3013
  }
2962
3014
  // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2963
3015
  bury(lease, error, now) {
2964
- return this.#completion.bury(lease, { error, now });
3016
+ return Promise.resolve(
3017
+ this.#completion.changeClaim({ lease, change: { kind: "bury", error, now } })
3018
+ );
2965
3019
  }
2966
3020
  /** Returns a leased delivery to pending, due now, without counting the attempt. */
2967
3021
  release(lease, now) {
2968
- return this.#completion.release(lease, now);
3022
+ return Promise.resolve(
3023
+ this.#completion.changeClaim({ lease, change: { kind: "release", now } })
3024
+ );
2969
3025
  }
2970
3026
  /**
2971
3027
  * Calls `listener` soon after a transaction that published deliveries due at once commits, in any
@@ -3044,35 +3100,6 @@ var PostgresEventStore = class {
3044
3100
  }
3045
3101
  }
3046
3102
  };
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
3103
  function connect(options, { logger, owned }) {
3077
3104
  if (options.executor !== void 0) {
3078
3105
  return { executor: options.executor, pool: void 0, ownedPool: void 0 };