@relaybus/core 0.2.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,153 +1,14 @@
1
+ import { ConfigurationError, assertEventType, CompositeKey, Milliseconds, NonRetryableError, DrainIncompleteError, LifecycleError, schedule, toErrorInfo, LeaseSettlement, TransactionTimeoutError, PositiveInteger, DispatcherStoppedError, DeliveryLeaseIdentity, LeaseLostError, AttemptsExhaustedError, validatePayload, HandlerTimeoutError, TransactionRequiredError, keyFromDefinition, InvalidEnvelopeError } from './chunk-NOR5JHVN.js';
2
+ export { AttemptsExhaustedError, ConfigurationError, DispatcherStoppedError, DrainIncompleteError, EventValidationError, HandlerTimeoutError, InvalidEnvelopeError, LeaseLostError, LifecycleError, MAX_TIMER_DELAY_MS, NonRetryableError, RelayBusError, TransactionRequiredError, TransactionTimeoutError, assertEventType, defineEvent, toErrorInfo, toMilliseconds, validatePayload } from './chunk-NOR5JHVN.js';
1
3
  import { randomBytes } from 'crypto';
2
- import { hostname } from 'os';
3
4
  import { AsyncLocalStorage } from 'async_hooks';
5
+ import { hostname } from 'os';
4
6
 
5
7
  // src/clock.ts
6
8
  var systemClock = {
7
9
  now: () => /* @__PURE__ */ new Date()
8
10
  };
9
11
 
10
- // src/errors.ts
11
- var RelayBusError = class extends Error {
12
- // Explicit names survive minification, unlike `new.target.name`.
13
- name = "RelayBusError";
14
- };
15
- var ConfigurationError = class extends RelayBusError {
16
- name = "ConfigurationError";
17
- };
18
- var TransactionRequiredError = class extends RelayBusError {
19
- name = "TransactionRequiredError";
20
- };
21
- var NonRetryableError = class extends RelayBusError {
22
- name = "NonRetryableError";
23
- };
24
- var EventValidationError = class extends NonRetryableError {
25
- constructor(message, issues) {
26
- super(message);
27
- this.issues = issues;
28
- }
29
- issues;
30
- name = "EventValidationError";
31
- };
32
- var InvalidEnvelopeError = class extends NonRetryableError {
33
- name = "InvalidEnvelopeError";
34
- };
35
- var LeaseLostError = class extends RelayBusError {
36
- name = "LeaseLostError";
37
- /**
38
- * Whether the store said so (a fenced write returned false). A worker that only noticed
39
- * locally that its lease ran out may still own the delivery in the store and has to give it back.
40
- */
41
- storeConfirmed;
42
- constructor(message, options = {}) {
43
- super(message);
44
- this.storeConfirmed = options.storeConfirmed ?? false;
45
- }
46
- };
47
- var HandlerTimeoutError = class extends RelayBusError {
48
- name = "HandlerTimeoutError";
49
- };
50
- var DispatcherStoppedError = class extends RelayBusError {
51
- name = "DispatcherStoppedError";
52
- };
53
- function toErrorInfo(error) {
54
- if (error instanceof Error) {
55
- return { name: error.name, message: error.message, stack: error.stack };
56
- }
57
- return { name: "NonError", message: safeStringify(error) };
58
- }
59
- function safeStringify(value) {
60
- if (typeof value === "string") {
61
- return value;
62
- }
63
- try {
64
- return JSON.stringify(value) ?? String(value);
65
- } catch {
66
- return String(value);
67
- }
68
- }
69
-
70
- // src/duration.ts
71
- var MILLISECONDS_PER_UNIT = {
72
- ms: 1,
73
- s: 1e3,
74
- m: 6e4,
75
- h: 36e5,
76
- d: 864e5
77
- };
78
- var DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
79
- function toMilliseconds(value, name) {
80
- if (typeof value === "number") {
81
- if (!Number.isFinite(value) || value < 0) {
82
- throw new ConfigurationError(
83
- `${name} must be a non-negative finite number of milliseconds, received ${value}`
84
- );
85
- }
86
- return value;
87
- }
88
- const match = DURATION_PATTERN.exec(value);
89
- if (match === null) {
90
- throw new ConfigurationError(
91
- `${name} must look like '500ms', '30s', '5m', '1h' or '7d', received '${value}'`
92
- );
93
- }
94
- const [, amount, unit] = match;
95
- return Number(amount) * MILLISECONDS_PER_UNIT[unit];
96
- }
97
- var MAX_TIMER_DELAY_MS = 2147483647;
98
- function toTimerMilliseconds(value, name) {
99
- const milliseconds = toMilliseconds(value, name);
100
- if (milliseconds === 0) {
101
- throw new ConfigurationError(`${name} must be greater than zero`);
102
- }
103
- if (milliseconds > MAX_TIMER_DELAY_MS) {
104
- throw new ConfigurationError(
105
- `${name} must not exceed ${MAX_TIMER_DELAY_MS}ms (about 24.8 days), received ${milliseconds}ms`
106
- );
107
- }
108
- return milliseconds;
109
- }
110
-
111
- // src/event.ts
112
- var EVENT_TYPE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
113
- function assertEventType(type) {
114
- if (!EVENT_TYPE_PATTERN.test(type)) {
115
- throw new ConfigurationError(
116
- `Event type '${type}' must be 1-255 characters of letters, digits, '.', '_', ':', '/' or '-', starting with a letter or digit`
117
- );
118
- }
119
- }
120
- function defineEvent(type, options) {
121
- assertEventType(type);
122
- const schema = options?.schema;
123
- return class DefinedEvent {
124
- constructor(payload) {
125
- this.payload = payload;
126
- }
127
- payload;
128
- static type = type;
129
- static schema = schema;
130
- type = type;
131
- };
132
- }
133
- async function validatePayload(type, schema, payload) {
134
- if (schema === void 0) {
135
- return payload;
136
- }
137
- const result = await schema["~standard"].validate(payload);
138
- if (result.issues !== void 0) {
139
- const issues = result.issues.map((issue) => ({
140
- message: issue.message,
141
- path: issue.path?.map((segment) => typeof segment === "object" ? segment.key : segment)
142
- }));
143
- throw new EventValidationError(
144
- `Payload of '${type}' failed validation: ${issues.map((issue) => issue.message).join("; ")}`,
145
- issues
146
- );
147
- }
148
- return result.value;
149
- }
150
-
151
12
  // src/catalog.ts
152
13
  var EVENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
153
14
  var descriptions = /* @__PURE__ */ new WeakMap();
@@ -232,7 +93,10 @@ function requireCatalogEntry(catalog, event) {
232
93
  }
233
94
  return entry;
234
95
  }
235
- function resolveCatalogHandlers(catalog, handlers, options) {
96
+ function resolveCatalogHandlers(catalog, {
97
+ handlers,
98
+ options
99
+ }) {
236
100
  const { name } = describeCatalog(catalog);
237
101
  const { idPrefix, events, ...subscribeOptions } = options;
238
102
  const entries = selectCatalogEntries(catalog, events);
@@ -262,7 +126,7 @@ function classNameOf(handlers) {
262
126
  const className = handlers.constructor.name;
263
127
  return className === "Object" || className === "" ? void 0 : className;
264
128
  }
265
- function assertExactHandlers(catalog, entries, handlers) {
129
+ function assertExactHandlers(catalog, { entries, handlers }) {
266
130
  const expected = new Set(entries.map((entry) => entry.handlerName));
267
131
  const present = Object.keys(handlers);
268
132
  const missing = [...expected].filter((handlerName) => !present.includes(handlerName));
@@ -282,7 +146,10 @@ function assertExactHandlers(catalog, entries, handlers) {
282
146
  }
283
147
  }
284
148
  function defineHandlers(catalog, handlers, options = {}) {
285
- assertExactHandlers(catalog, selectCatalogEntries(catalog, options.events), handlers);
149
+ assertExactHandlers(catalog, {
150
+ entries: selectCatalogEntries(catalog, options.events),
151
+ handlers
152
+ });
286
153
  return handlers;
287
154
  }
288
155
 
@@ -336,12 +203,12 @@ var CatalogPublisher = class {
336
203
  // src/catalog-coverage.ts
337
204
  var declarations = /* @__PURE__ */ new WeakSet();
338
205
  function consume(catalog, options = {}) {
339
- return declare(catalog, "consume", options);
206
+ return declare(catalog, { role: "consume", options });
340
207
  }
341
208
  function produce(catalog, options = {}) {
342
- return declare(catalog, "produce", options);
209
+ return declare(catalog, { role: "produce", options });
343
210
  }
344
- function declare(catalog, role, options) {
211
+ function declare(catalog, { role, options }) {
345
212
  const entries = selectCatalogEntries(catalog, options.events);
346
213
  const declaration = Object.freeze({
347
214
  catalog,
@@ -369,7 +236,7 @@ function assertCatalogsCovered(requirements, registrationsOf) {
369
236
  let onlyBareCatalogs = true;
370
237
  for (const { catalogName, entries, need } of requirements) {
371
238
  for (const entry of entries) {
372
- const problem = describeGap(entry, need, registrationsOf(entry.type));
239
+ const problem = describeGap(entry, { need, registrations: registrationsOf(entry.type) });
373
240
  if (problem !== void 0) {
374
241
  problems.add(`${catalogName === void 0 ? "" : `${catalogName}.`}${problem}`);
375
242
  onlyBareCatalogs &&= need === "handler-or-route";
@@ -384,7 +251,10 @@ ${[...problems].map((line) => ` - ${line}`).join("\n")}`
384
251
  );
385
252
  }
386
253
  }
387
- function describeGap(entry, need, { handlers, routes }) {
254
+ function describeGap(entry, {
255
+ need,
256
+ registrations: { handlers, routes }
257
+ }) {
388
258
  const subject = `${entry.name} (${entry.type})`;
389
259
  if (need === "handler-or-route") {
390
260
  return handlers + routes > 0 ? void 0 : `${subject}: expected handler method ${entry.handlerName}`;
@@ -410,232 +280,107 @@ var DELIVERY_STATUSES = [
410
280
  "completed",
411
281
  "dead"
412
282
  ];
283
+ var UUID_BYTES = 16;
284
+ var TIMESTAMP_BYTES = 6;
285
+ var BYTE_VALUES = 256;
286
+ var LOW_NIBBLE = 15;
287
+ var VERSION_7 = 112;
288
+ var VARIANT_PAYLOAD = 63;
289
+ var RFC_9562_VARIANT = 128;
413
290
  function createUuidV7(timestamp = Date.now()) {
414
- const bytes = randomBytes(16);
291
+ const bytes = randomBytes(UUID_BYTES);
415
292
  let remaining = timestamp;
416
- for (let index = 5; index >= 0; index -= 1) {
417
- bytes[index] = remaining % 256;
418
- remaining = Math.floor(remaining / 256);
293
+ for (let index = TIMESTAMP_BYTES - 1; index >= 0; index -= 1) {
294
+ bytes[index] = remaining % BYTE_VALUES;
295
+ remaining = Math.floor(remaining / BYTE_VALUES);
419
296
  }
420
- bytes[6] = bytes[6] & 15 | 112;
421
- bytes[8] = bytes[8] & 63 | 128;
422
- const hex = bytes.toString("hex");
423
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
297
+ bytes[6] = bytes[6] & LOW_NIBBLE | VERSION_7;
298
+ bytes[8] = bytes[8] & VARIANT_PAYLOAD | RFC_9562_VARIANT;
299
+ return bytes.toString("hex").replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "$1-$2-$3-$4-$5");
424
300
  }
425
301
 
426
- // src/in-memory-event-store.ts
427
- var InMemoryTransaction = class {
428
- #operations = [];
429
- #finishers = [];
430
- #state = "open";
431
- get state() {
432
- return this.#state;
433
- }
434
- enqueue(operation) {
435
- if (this.#state !== "open") {
436
- throw new ConfigurationError(`Cannot write through a ${this.#state} in-memory transaction`);
437
- }
438
- this.#operations.push(operation);
439
- }
440
- /** Runs `finisher` once the transaction commits or rolls back, like a lock release. */
441
- onFinish(finisher) {
442
- this.#finishers.push(finisher);
443
- }
444
- commit() {
445
- this.#state = "committed";
446
- for (const operation of this.#operations) {
447
- operation();
448
- }
449
- this.#finish();
450
- }
451
- rollback() {
452
- this.#state = "rolled-back";
453
- this.#finish();
454
- }
455
- #finish() {
456
- for (const finisher of this.#finishers.splice(0)) {
457
- finisher();
458
- }
459
- }
460
- };
461
- var InMemoryEventStore = class {
462
- #events = /* @__PURE__ */ new Map();
463
- #deliveries = /* @__PURE__ */ new Map();
464
- #keySequences = /* @__PURE__ */ new Map();
465
- /** Row-lock emulation: event ids and ordering keys written by open transactions. */
466
- #locks = /* @__PURE__ */ new Map();
467
- #writes = /* @__PURE__ */ new WeakMap();
468
- #generateIdentifier;
469
- #retainCompleted;
470
- constructor(options = {}) {
471
- if (process.env["NODE_ENV"] === "production" && options.allowInProduction !== true) {
472
- throw new ConfigurationError(
473
- "InMemoryEventStore loses every event on restart. Use a database store in production, or pass allowInProduction: true"
474
- );
475
- }
476
- this.#generateIdentifier = options.generateIdentifier ?? (() => createUuidV7());
477
- this.#retainCompleted = options.retainCompleted ?? false;
478
- }
479
- async transaction(work) {
480
- const transaction = new InMemoryTransaction();
481
- try {
482
- const result = await work(transaction);
483
- transaction.commit();
484
- return result;
485
- } catch (error) {
486
- transaction.rollback();
487
- throw error;
488
- }
489
- }
490
- async append(transaction, entries, now) {
491
- await this.#lock(
492
- transaction,
493
- entries.flatMap((entry) => [
494
- `event:${entry.event.id}`,
495
- ...entry.event.key === null ? [] : [`key:${entry.event.key}`]
496
- ])
497
- );
498
- const written = this.#written(transaction);
499
- const pendingEventIds = written.eventIds;
500
- const pendingDeliveryKeys = written.deliveryKeys;
501
- const appendedEventIds = [];
502
- const duplicateEventIds = [];
503
- let appendedDeliveryCount = 0;
504
- for (const entry of entries) {
505
- const eventId = entry.event.id;
506
- const serialized = serializeEnvelope(entry.event);
507
- const isDuplicate = this.#events.has(eventId) || pendingEventIds.has(eventId);
508
- if (isDuplicate) {
509
- duplicateEventIds.push(eventId);
510
- } else {
511
- appendedEventIds.push(eventId);
512
- pendingEventIds.add(eventId);
513
- }
514
- const newDeliveries = entry.deliveries.filter((delivery) => {
515
- const deliveryKey = `${eventId}\0${delivery.handler}`;
516
- if (pendingDeliveryKeys.has(deliveryKey) || this.#events.get(eventId)?.handlers.has(delivery.handler) === true) {
517
- return false;
518
- }
519
- pendingDeliveryKeys.add(deliveryKey);
520
- return true;
521
- });
522
- appendedDeliveryCount += newDeliveries.length;
523
- transaction.enqueue(() => {
524
- if (!isDuplicate) {
525
- this.#events.set(eventId, {
526
- envelope: serialized,
527
- sequence: this.#nextSequence(serialized.key),
528
- createdAt: now,
529
- handlers: /* @__PURE__ */ new Set(),
530
- completedAt: null
531
- });
532
- }
533
- const stored = this.#events.get(eventId);
534
- for (const delivery of newDeliveries) {
535
- stored.handlers.add(delivery.handler);
536
- this.#deliveries.set(delivery.id, {
537
- id: delivery.id,
538
- eventId,
539
- handler: delivery.handler,
540
- key: stored.envelope.key,
541
- sequence: stored.sequence,
542
- status: "pending",
543
- attempts: 0,
544
- availableAt: delivery.availableAt,
545
- leaseOwner: null,
546
- leaseToken: null,
547
- leaseExpiresAt: null,
548
- lastError: null,
549
- createdAt: now,
550
- updatedAt: now,
551
- completedAt: null
552
- });
553
- }
554
- });
555
- }
556
- return { appendedEventIds, duplicateEventIds, appendedDeliveryCount };
557
- }
558
- async claim(request) {
559
- const handlers = new Set(request.handlers);
560
- const due = [...this.#deliveries.values()].filter((delivery) => handlers.has(delivery.handler) && isDue(delivery, request.now)).sort(compareDue);
561
- const claimed = [];
562
- for (const delivery of due) {
563
- if (claimed.length >= request.limit) {
564
- break;
565
- }
566
- if (this.#isKeyBlocked(delivery, request.now)) {
567
- continue;
568
- }
569
- delivery.status = "processing";
570
- delivery.attempts += 1;
571
- delivery.leaseOwner = request.leaseOwner;
572
- delivery.leaseToken = this.#generateIdentifier();
573
- delivery.leaseExpiresAt = request.leaseExpiresAt;
574
- delivery.updatedAt = request.now;
575
- claimed.push({
576
- deliveryId: delivery.id,
577
- leaseToken: delivery.leaseToken,
578
- handler: delivery.handler,
579
- attempt: delivery.attempts,
580
- leaseExpiresAt: request.leaseExpiresAt,
581
- event: this.#readEnvelope(delivery.eventId)
582
- });
583
- }
584
- return claimed;
585
- }
586
- async extendLease(lease, leaseExpiresAt, now) {
587
- return this.#updateLeased(lease, (delivery) => {
588
- delivery.leaseExpiresAt = leaseExpiresAt;
589
- delivery.updatedAt = now;
590
- });
591
- }
592
- async complete(lease, now, transaction) {
593
- if (transaction === void 0) {
594
- return this.#updateLeased(lease, (delivery) => this.#finish(delivery, now));
595
- }
596
- if (this.#findLeased(lease) === void 0) {
597
- return false;
598
- }
599
- transaction.enqueue(() => {
600
- const delivery = this.#findLeased(lease);
601
- if (delivery !== void 0) {
602
- this.#finish(delivery, now);
603
- }
604
- });
605
- return true;
606
- }
607
- async completeMany(leases, now) {
608
- return Promise.all(leases.map((lease) => this.complete(lease, now)));
302
+ // src/in-memory/stored-records.ts
303
+ function applyTransition(delivery, change) {
304
+ if (change.kind === "reschedule") {
305
+ delivery.status = "pending";
306
+ delivery.availableAt = change.availableAt;
307
+ delivery.lastError = change.error;
308
+ } else if (change.kind === "release") {
309
+ delivery.status = "pending";
310
+ delivery.attempts -= 1;
311
+ delivery.availableAt = change.now;
312
+ } else {
313
+ delivery.status = "dead";
314
+ delivery.lastError = change.error;
315
+ }
316
+ delivery.updatedAt = change.now;
317
+ clearLease(delivery);
318
+ }
319
+ function blocksKey(other, { delivery, now }) {
320
+ if (other === delivery || other.handler !== delivery.handler || other.key !== delivery.key) {
321
+ return false;
609
322
  }
610
- async reschedule(lease, availableAt, error, now) {
611
- return this.#updateLeased(lease, (delivery) => {
612
- delivery.status = "pending";
613
- delivery.availableAt = availableAt;
614
- delivery.lastError = error;
615
- delivery.updatedAt = now;
616
- clearLease(delivery);
617
- });
323
+ const unfinishedPredecessor = other.sequence < delivery.sequence && (other.status === "pending" || other.status === "processing");
324
+ const runningUnderLiveLease = other.status === "processing" && other.leaseExpiresAt > now;
325
+ return unfinishedPredecessor || runningUnderLiveLease;
326
+ }
327
+ function eventCutoffs(request) {
328
+ const cutoffs = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).map((cutoff) => cutoff.getTime());
329
+ return {
330
+ orphanCutoff: cutoffs.length === 0 ? Number.NEGATIVE_INFINITY : Math.min(...cutoffs),
331
+ completedCutoff: request.completedBefore?.getTime() ?? Number.NEGATIVE_INFINITY
332
+ };
333
+ }
334
+ function serializeEnvelope(envelope) {
335
+ return JSON.parse(JSON.stringify(envelope));
336
+ }
337
+ function isDue(delivery, now) {
338
+ if (delivery.status === "pending") {
339
+ return delivery.availableAt <= now;
618
340
  }
619
- async release(lease, now) {
620
- return this.#updateLeased(lease, (delivery) => {
621
- delivery.status = "pending";
622
- delivery.attempts -= 1;
623
- delivery.availableAt = now;
624
- delivery.updatedAt = now;
625
- clearLease(delivery);
626
- });
341
+ return delivery.status === "processing" && delivery.leaseExpiresAt <= now;
342
+ }
343
+ function dueAtOf(delivery) {
344
+ return delivery.status === "pending" ? delivery.availableAt : delivery.leaseExpiresAt;
345
+ }
346
+ function compareDue(left, right) {
347
+ const byDueTime = dueAtOf(left).getTime() - dueAtOf(right).getTime();
348
+ return byDueTime === 0 ? compareIds(left.id, right.id) : byDueTime;
349
+ }
350
+ function compareIds(left, right) {
351
+ return Number(left > right) - Number(left < right);
352
+ }
353
+ function clearLease(delivery) {
354
+ delivery.leaseOwner = null;
355
+ delivery.leaseToken = null;
356
+ delivery.leaseExpiresAt = null;
357
+ }
358
+ function matchesQuery(delivery, { event, query }) {
359
+ return (query.status === void 0 || delivery.status === query.status) && (query.handler === void 0 || delivery.handler === query.handler) && (query.eventType === void 0 || event.envelope.type === query.eventType) && (query.eventId === void 0 || delivery.eventId === query.eventId);
360
+ }
361
+ function isPurgeable(delivery, request) {
362
+ if (delivery.status === "completed" && request.completedBefore !== void 0) {
363
+ return delivery.completedAt < request.completedBefore;
627
364
  }
628
- async bury(lease, error, now) {
629
- return this.#updateLeased(lease, (delivery) => {
630
- delivery.status = "dead";
631
- delivery.lastError = error;
632
- delivery.updatedAt = now;
633
- clearLease(delivery);
634
- });
365
+ if (delivery.status === "dead" && request.deadBefore !== void 0) {
366
+ return delivery.updatedAt < request.deadBefore;
635
367
  }
636
- async listDeliveries(query) {
637
- const limit = query.limit ?? 100;
638
- return [...this.#deliveries.values()].filter((delivery) => matchesQuery(delivery, this.#events.get(delivery.eventId), query)).sort(
368
+ return false;
369
+ }
370
+
371
+ // src/in-memory/in-memory-maintenance.ts
372
+ var DEFAULT_LIST_LIMIT = 100;
373
+ var InMemoryMaintenance = class {
374
+ #tables;
375
+ #locks;
376
+ constructor(tables, locks) {
377
+ this.#tables = tables;
378
+ this.#locks = locks;
379
+ }
380
+ listDeliveries(query) {
381
+ const limit = query.limit ?? DEFAULT_LIST_LIMIT;
382
+ const { events, deliveries } = this.#tables;
383
+ return [...deliveries.values()].filter((delivery) => matchesQuery(delivery, { event: events.get(delivery.eventId), query })).sort(
639
384
  (left, right) => left.createdAt.getTime() - right.createdAt.getTime() || compareIds(left.id, right.id)
640
385
  ).slice(0, limit).map((delivery) => ({
641
386
  id: delivery.id,
@@ -649,13 +394,13 @@ var InMemoryEventStore = class {
649
394
  createdAt: delivery.createdAt,
650
395
  updatedAt: delivery.updatedAt,
651
396
  completedAt: delivery.completedAt,
652
- event: this.#readEnvelope(delivery.eventId)
397
+ event: this.#tables.readEnvelope(delivery.eventId)
653
398
  }));
654
399
  }
655
- async requeue(deliveryIds, now) {
400
+ requeue(deliveryIds, now) {
656
401
  let moved = 0;
657
402
  for (const deliveryId of new Set(deliveryIds)) {
658
- const delivery = this.#deliveries.get(deliveryId);
403
+ const delivery = this.#tables.deliveries.get(deliveryId);
659
404
  if (delivery?.status !== "dead") {
660
405
  continue;
661
406
  }
@@ -667,36 +412,19 @@ var InMemoryEventStore = class {
667
412
  }
668
413
  return moved;
669
414
  }
670
- async purge(request) {
415
+ purge(request) {
671
416
  let deletedDeliveries = 0;
672
417
  const touchedEventIds = /* @__PURE__ */ new Set();
673
- for (const delivery of [...this.#deliveries.values()]) {
418
+ for (const delivery of [...this.#tables.deliveries.values()]) {
674
419
  if (isPurgeable(delivery, request)) {
675
- this.#deliveries.delete(delivery.id);
420
+ this.#tables.deliveries.delete(delivery.id);
676
421
  touchedEventIds.add(delivery.eventId);
677
422
  deletedDeliveries += 1;
678
423
  }
679
424
  }
680
- const referencedEventIds = new Set(
681
- [...this.#deliveries.values()].map((delivery) => delivery.eventId)
682
- );
683
- const cutoffs = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).map((cutoff) => cutoff.getTime());
684
- const orphanCutoff = cutoffs.length === 0 ? Number.NEGATIVE_INFINITY : Math.min(...cutoffs);
685
- const completedCutoff = request.completedBefore?.getTime() ?? Number.NEGATIVE_INFINITY;
686
- let deletedEvents = 0;
687
- for (const [eventId, event] of [...this.#events]) {
688
- if (referencedEventIds.has(eventId) || this.#locks.has(`event:${eventId}`)) {
689
- continue;
690
- }
691
- const expired = event.completedAt === null ? touchedEventIds.has(eventId) || event.createdAt.getTime() < orphanCutoff : event.completedAt.getTime() < completedCutoff;
692
- if (expired) {
693
- this.#events.delete(eventId);
694
- deletedEvents += 1;
695
- }
696
- }
697
- return { deletedDeliveries, deletedEvents };
425
+ return { deletedDeliveries, deletedEvents: this.#purgeEvents(request, touchedEventIds) };
698
426
  }
699
- async statistics(now) {
427
+ statistics(now) {
700
428
  const counts = {
701
429
  pending: 0,
702
430
  processing: 0,
@@ -704,7 +432,7 @@ var InMemoryEventStore = class {
704
432
  dead: 0
705
433
  };
706
434
  let oldestDueAt = null;
707
- for (const delivery of this.#deliveries.values()) {
435
+ for (const delivery of this.#tables.deliveries.values()) {
708
436
  counts[delivery.status] += 1;
709
437
  if (isDue(delivery, now)) {
710
438
  const dueAt = dueAtOf(delivery);
@@ -715,13 +443,38 @@ var InMemoryEventStore = class {
715
443
  }
716
444
  return { ...counts, oldestDueAt };
717
445
  }
718
- /**
719
- * Waits for other open transactions holding any of `names`, then holds them
720
- * until `transaction` finishes, the way a database holds row locks. Sorted
721
- * acquisition keeps two transactions from waiting on each other.
722
- */
446
+ /** Deletes the events no delivery references whose retention ran out; returns how many. */
447
+ #purgeEvents(request, touchedEventIds) {
448
+ const { events, deliveries } = this.#tables;
449
+ const referencedEventIds = new Set(
450
+ [...deliveries.values()].map((delivery) => delivery.eventId)
451
+ );
452
+ const { orphanCutoff, completedCutoff } = eventCutoffs(request);
453
+ let deletedEvents = 0;
454
+ for (const [eventId, event] of [...events]) {
455
+ if (referencedEventIds.has(eventId) || this.#locks.isHeld(`event:${eventId}`)) {
456
+ continue;
457
+ }
458
+ const expired = event.completedAt === null ? touchedEventIds.has(eventId) || event.createdAt.getTime() < orphanCutoff : event.completedAt.getTime() < completedCutoff;
459
+ if (expired) {
460
+ events.delete(eventId);
461
+ deletedEvents += 1;
462
+ }
463
+ }
464
+ return deletedEvents;
465
+ }
466
+ };
467
+
468
+ // src/in-memory/in-memory-row-locks.ts
469
+ var InMemoryRowLocks = class {
470
+ #locks = /* @__PURE__ */ new Map();
471
+ #writes = /* @__PURE__ */ new WeakMap();
472
+ /** Whether an open transaction holds `name`. */
473
+ isHeld(name) {
474
+ return this.#locks.has(name);
475
+ }
723
476
  /** The events and deliveries the transaction appended so far, which are not applied before it commits. */
724
- #written(transaction) {
477
+ written(transaction) {
725
478
  let written = this.#writes.get(transaction);
726
479
  if (written === void 0) {
727
480
  written = { eventIds: /* @__PURE__ */ new Set(), deliveryKeys: /* @__PURE__ */ new Set() };
@@ -729,7 +482,12 @@ var InMemoryEventStore = class {
729
482
  }
730
483
  return written;
731
484
  }
732
- async #lock(transaction, names) {
485
+ /**
486
+ * Waits for other open transactions holding any of `names`, then holds them
487
+ * until `transaction` finishes, the way a database holds row locks. Sorted
488
+ * acquisition keeps two transactions from waiting on each other.
489
+ */
490
+ async lock(transaction, names) {
733
491
  for (const name of [...new Set(names)].sort()) {
734
492
  let held = this.#locks.get(name);
735
493
  while (held !== void 0 && held.owner !== transaction) {
@@ -750,44 +508,62 @@ var InMemoryEventStore = class {
750
508
  });
751
509
  }
752
510
  }
753
- #nextSequence(key) {
754
- if (key === null) {
755
- return null;
756
- }
757
- const next = (this.#keySequences.get(key) ?? 0) + 1;
758
- this.#keySequences.set(key, next);
759
- return next;
760
- }
761
- /**
762
- * A keyed delivery waits while an earlier delivery of the same handler and
763
- * key is unfinished, or while any other delivery of that handler and key is
764
- * running under a live lease (a requeued dead letter must not run alongside
765
- * its successor).
766
- */
767
- #isKeyBlocked(delivery, now) {
768
- if (delivery.key === null) {
511
+ };
512
+
513
+ // src/in-memory/in-memory-settlement.ts
514
+ var InMemorySettlement = class extends LeaseSettlement {
515
+ #tables;
516
+ #retainCompleted;
517
+ constructor(tables, retainCompleted) {
518
+ super();
519
+ this.#tables = tables;
520
+ this.#retainCompleted = retainCompleted;
521
+ }
522
+ /** Completes now, or when `transaction` commits if the lease is still held then. */
523
+ complete(lease, { now, transaction }) {
524
+ const delivery = this.findHeld(lease);
525
+ if (delivery === void 0) {
769
526
  return false;
770
527
  }
771
- for (const other of this.#deliveries.values()) {
772
- if (other === delivery || other.handler !== delivery.handler || other.key !== delivery.key) {
773
- continue;
774
- }
775
- const unfinishedPredecessor = other.sequence < delivery.sequence && (other.status === "pending" || other.status === "processing");
776
- const runningUnderLiveLease = other.status === "processing" && other.leaseExpiresAt > now;
777
- if (unfinishedPredecessor || runningUnderLiveLease) {
778
- return true;
528
+ if (transaction === void 0) {
529
+ this.#finish(delivery, now);
530
+ return true;
531
+ }
532
+ transaction.enqueue(() => {
533
+ const held = this.findHeld(lease);
534
+ if (held !== void 0) {
535
+ this.#finish(held, now);
779
536
  }
537
+ });
538
+ return true;
539
+ }
540
+ findHeld(lease) {
541
+ const delivery = this.#tables.deliveries.get(lease.deliveryId);
542
+ if (delivery?.status === "processing" && delivery.leaseToken === lease.leaseToken) {
543
+ return delivery;
780
544
  }
781
- return false;
545
+ return void 0;
546
+ }
547
+ writeChange(held, { change }) {
548
+ applyTransition(held, change);
549
+ return true;
550
+ }
551
+ /** The in-memory store always passes `now`: its `extendLease` takes it from the port. */
552
+ writeExtension(held, { leaseExpiresAt, now }) {
553
+ held.leaseExpiresAt = leaseExpiresAt;
554
+ held.updatedAt = now;
555
+ return true;
556
+ }
557
+ forgetHandedOut() {
782
558
  }
783
559
  /** Deletes a completed delivery, or marks it completed when a history is kept. */
784
560
  #finish(delivery, now) {
785
- const event = this.#events.get(delivery.eventId);
561
+ const event = this.#tables.events.get(delivery.eventId);
786
562
  if (event.completedAt === null || event.completedAt < now) {
787
563
  event.completedAt = now;
788
564
  }
789
565
  if (!this.#retainCompleted) {
790
- this.#deliveries.delete(delivery.id);
566
+ this.#tables.deliveries.delete(delivery.id);
791
567
  return;
792
568
  }
793
569
  delivery.status = "completed";
@@ -795,450 +571,787 @@ var InMemoryEventStore = class {
795
571
  delivery.updatedAt = now;
796
572
  clearLease(delivery);
797
573
  }
798
- #findLeased(lease) {
799
- const delivery = this.#deliveries.get(lease.deliveryId);
800
- if (delivery?.status === "processing" && delivery.leaseToken === lease.leaseToken) {
801
- return delivery;
802
- }
803
- return void 0;
804
- }
805
- #updateLeased(lease, update) {
806
- const delivery = this.#findLeased(lease);
807
- if (delivery === void 0) {
808
- return false;
574
+ };
575
+
576
+ // src/in-memory/in-memory-tables.ts
577
+ var InMemoryTables = class {
578
+ events = /* @__PURE__ */ new Map();
579
+ deliveries = /* @__PURE__ */ new Map();
580
+ #keySequences = /* @__PURE__ */ new Map();
581
+ /** The next sequence of `key`; null for an unkeyed event. */
582
+ nextSequence(key) {
583
+ if (key === null) {
584
+ return null;
809
585
  }
810
- update(delivery);
811
- return true;
586
+ const next = (this.#keySequences.get(key) ?? 0) + 1;
587
+ this.#keySequences.set(key, next);
588
+ return next;
812
589
  }
813
- #readEnvelope(eventId) {
814
- const serialized = this.#events.get(eventId).envelope;
590
+ /** A fresh copy of a stored event, as a database would read it back. */
591
+ readEnvelope(eventId) {
592
+ const serialized = this.events.get(eventId).envelope;
815
593
  const copy = JSON.parse(JSON.stringify(serialized));
816
594
  return { ...copy, occurredAt: new Date(copy.occurredAt) };
817
595
  }
818
596
  };
819
- function serializeEnvelope(envelope) {
820
- return JSON.parse(JSON.stringify(envelope));
821
- }
822
- function isDue(delivery, now) {
823
- if (delivery.status === "pending") {
824
- return delivery.availableAt <= now;
597
+
598
+ // src/in-memory-transaction.ts
599
+ var InMemoryTransaction = class {
600
+ #operations = [];
601
+ #finishers = [];
602
+ #state = "open";
603
+ get state() {
604
+ return this.#state;
825
605
  }
826
- return delivery.status === "processing" && delivery.leaseExpiresAt <= now;
827
- }
828
- function dueAtOf(delivery) {
829
- return delivery.status === "pending" ? delivery.availableAt : delivery.leaseExpiresAt;
830
- }
831
- function compareDue(left, right) {
832
- const byDueTime = dueAtOf(left).getTime() - dueAtOf(right).getTime();
833
- return byDueTime === 0 ? compareIds(left.id, right.id) : byDueTime;
834
- }
835
- function compareIds(left, right) {
836
- return Number(left > right) - Number(left < right);
837
- }
838
- function clearLease(delivery) {
839
- delivery.leaseOwner = null;
840
- delivery.leaseToken = null;
841
- delivery.leaseExpiresAt = null;
842
- }
843
- function matchesQuery(delivery, event, query) {
844
- return (query.status === void 0 || delivery.status === query.status) && (query.handler === void 0 || delivery.handler === query.handler) && (query.eventType === void 0 || event.envelope.type === query.eventType) && (query.eventId === void 0 || delivery.eventId === query.eventId);
845
- }
846
- function isPurgeable(delivery, request) {
847
- if (delivery.status === "completed" && request.completedBefore !== void 0) {
848
- return delivery.completedAt < request.completedBefore;
606
+ enqueue(operation) {
607
+ if (this.#state !== "open") {
608
+ throw new ConfigurationError(`Cannot write through a ${this.#state} in-memory transaction`);
609
+ }
610
+ this.#operations.push(operation);
849
611
  }
850
- if (delivery.status === "dead" && request.deadBefore !== void 0) {
851
- return delivery.updatedAt < request.deadBefore;
612
+ /** Runs `finisher` once the transaction commits or rolls back, like a lock release. */
613
+ onFinish(finisher) {
614
+ this.#finishers.push(finisher);
852
615
  }
853
- return false;
854
- }
616
+ commit() {
617
+ this.#state = "committed";
618
+ for (const operation of this.#operations) {
619
+ operation();
620
+ }
621
+ this.#finish();
622
+ }
623
+ rollback() {
624
+ this.#state = "rolled-back";
625
+ this.#finish();
626
+ }
627
+ #finish() {
628
+ for (const finisher of this.#finishers.splice(0)) {
629
+ finisher();
630
+ }
631
+ }
632
+ };
855
633
 
856
- // src/completion-batcher.ts
857
- var CompletionBatcher = class {
858
- #store;
859
- #clock;
860
- #settings;
861
- #runningCount;
862
- #pending = [];
863
- #timer;
864
- #closing = false;
865
- /**
866
- * @param runningCount Number of deliveries the dispatcher is running right
867
- * now, including the ones waiting in this batcher.
868
- */
869
- constructor(store, clock, settings, runningCount) {
870
- this.#store = store;
871
- this.#clock = clock;
872
- this.#settings = settings;
873
- this.#runningCount = runningCount;
634
+ // src/in-memory-event-store.ts
635
+ var InMemoryEventStore = class {
636
+ #tables = new InMemoryTables();
637
+ #locks = new InMemoryRowLocks();
638
+ #settlement;
639
+ #maintenance = new InMemoryMaintenance(this.#tables, this.#locks);
640
+ #generateIdentifier;
641
+ constructor(options = {}) {
642
+ if (process.env["NODE_ENV"] === "production" && options.allowInProduction !== true) {
643
+ throw new ConfigurationError(
644
+ "InMemoryEventStore loses every event on restart. Use a database store in production, or pass allowInProduction: true"
645
+ );
646
+ }
647
+ this.#generateIdentifier = options.generateIdentifier ?? (() => createUuidV7());
648
+ this.#settlement = new InMemorySettlement(this.#tables, options.retainCompleted ?? false);
874
649
  }
875
- /**
876
- * Queues a completion. Resolves with whether the lease was still held;
877
- * rejects with the store error when the batch could not be written.
878
- */
879
- submit(lease) {
880
- return new Promise((resolve, reject) => {
881
- this.#pending.push({ lease, resolve, reject });
882
- if (this.#closing || this.#pending.length >= this.#settings.maxBatchSize || this.#pending.length >= this.#runningCount()) {
883
- this.#flush();
650
+ async transaction(work) {
651
+ const transaction = new InMemoryTransaction();
652
+ try {
653
+ const result = await work(transaction);
654
+ transaction.commit();
655
+ return result;
656
+ } catch (error) {
657
+ transaction.rollback();
658
+ throw error;
659
+ }
660
+ }
661
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
662
+ async append(transaction, entries, now) {
663
+ await this.#locks.lock(
664
+ transaction,
665
+ entries.flatMap((entry) => [
666
+ `event:${entry.event.id}`,
667
+ ...entry.event.key === null ? [] : [`key:${entry.event.key}`]
668
+ ])
669
+ );
670
+ const written = this.#locks.written(transaction);
671
+ const pendingEventIds = written.eventIds;
672
+ const pendingDeliveryKeys = written.deliveryKeys;
673
+ const appendedEventIds = [];
674
+ const duplicateEventIds = [];
675
+ let appendedDeliveryCount = 0;
676
+ for (const entry of entries) {
677
+ const eventId = entry.event.id;
678
+ const serialized = serializeEnvelope(entry.event);
679
+ const isDuplicate = this.#tables.events.has(eventId) || pendingEventIds.has(eventId);
680
+ if (isDuplicate) {
681
+ duplicateEventIds.push(eventId);
884
682
  } else {
885
- this.#timer ??= setTimeout(() => this.#flush(), this.#settings.windowMs);
683
+ appendedEventIds.push(eventId);
684
+ pendingEventIds.add(eventId);
886
685
  }
887
- });
686
+ const newDeliveries = this.#newDeliveries(entry, pendingDeliveryKeys);
687
+ appendedDeliveryCount += newDeliveries.length;
688
+ transaction.enqueue(() => {
689
+ this.#applyAppend({ eventId, serialized, isDuplicate, newDeliveries, now });
690
+ });
691
+ }
692
+ return { appendedEventIds, duplicateEventIds, appendedDeliveryCount };
888
693
  }
889
694
  /**
890
- * Shutdown has begun (or ended): while closing, nothing waits for company any more, so the completions
891
- * already queued are written now and later ones at once.
695
+ * The deliveries of `entry` whose handler the event neither has nor gets earlier in the
696
+ * transaction; remembers them in `pendingDeliveryKeys`.
892
697
  */
893
- setClosing(closing) {
894
- this.#closing = closing;
895
- if (closing && this.#pending.length > 0) {
896
- this.#flush();
897
- }
698
+ #newDeliveries(entry, pendingDeliveryKeys) {
699
+ const eventId = entry.event.id;
700
+ return entry.deliveries.filter((delivery) => {
701
+ const deliveryKey = CompositeKey.of(eventId, delivery.handler);
702
+ if (pendingDeliveryKeys.has(deliveryKey) || this.#tables.events.get(eventId)?.handlers.has(delivery.handler) === true) {
703
+ return false;
704
+ }
705
+ pendingDeliveryKeys.add(deliveryKey);
706
+ return true;
707
+ });
898
708
  }
899
- #flush() {
900
- clearTimeout(this.#timer);
901
- this.#timer = void 0;
902
- const batch = this.#pending;
903
- this.#pending = [];
904
- Promise.resolve().then(
905
- () => this.#store.completeMany(
906
- batch.map((entry) => entry.lease),
907
- this.#clock.now()
908
- )
909
- ).then(
910
- (outcomes) => batch.forEach((entry, index) => entry.resolve(outcomes[index])),
911
- (error) => batch.forEach((entry) => entry.reject(error))
912
- );
709
+ /** Applies one entry of an append when its transaction commits. */
710
+ #applyAppend(input) {
711
+ const { eventId, serialized, isDuplicate, newDeliveries, now } = input;
712
+ const { events, deliveries } = this.#tables;
713
+ if (!isDuplicate) {
714
+ events.set(eventId, {
715
+ envelope: serialized,
716
+ sequence: this.#tables.nextSequence(serialized.key),
717
+ createdAt: now,
718
+ handlers: /* @__PURE__ */ new Set(),
719
+ completedAt: null
720
+ });
721
+ }
722
+ const stored = events.get(eventId);
723
+ for (const delivery of newDeliveries) {
724
+ stored.handlers.add(delivery.handler);
725
+ deliveries.set(delivery.id, {
726
+ id: delivery.id,
727
+ eventId,
728
+ handler: delivery.handler,
729
+ key: stored.envelope.key,
730
+ sequence: stored.sequence,
731
+ status: "pending",
732
+ attempts: 0,
733
+ availableAt: delivery.availableAt,
734
+ leaseOwner: null,
735
+ leaseToken: null,
736
+ leaseExpiresAt: null,
737
+ lastError: null,
738
+ createdAt: now,
739
+ updatedAt: now,
740
+ completedAt: null
741
+ });
742
+ }
913
743
  }
914
- };
915
- var storage = new AsyncLocalStorage();
916
- function runInScope(scope, work) {
917
- return storage.run({ ...storage.getStore(), ...scope }, work);
918
- }
919
- function runInIsolatedScope(scope, work) {
920
- return storage.run(scope, work);
921
- }
922
- function getCurrentScope() {
923
- return storage.getStore();
924
- }
925
-
926
- // src/retry-policy.ts
927
- var DEFAULT_RETRY_POLICY = {
928
- attempts: 10,
929
- initialDelayMs: 1e3,
930
- factor: 2,
931
- maxDelayMs: 6e5,
932
- jitter: "full",
933
- retryIf: void 0
934
- };
935
- var DEFAULT_ROUTE_RETRY_POLICY = {
936
- attempts: Number.POSITIVE_INFINITY,
937
- initialDelayMs: 1e3,
938
- factor: 2,
939
- maxDelayMs: 3e5,
940
- jitter: "full",
941
- retryIf: void 0
942
- };
943
- var JITTER_STRATEGIES = ["full", "equal", "none"];
944
- function createRetryPolicy(options, base = DEFAULT_RETRY_POLICY) {
945
- const initialDelayMs = options?.initialDelay === void 0 ? base.initialDelayMs : toMilliseconds(options.initialDelay, "retry.initialDelay");
946
- const policy = {
947
- attempts: options?.attempts ?? base.attempts,
948
- initialDelayMs,
949
- factor: options?.factor ?? base.factor,
950
- maxDelayMs: options?.maxDelay === void 0 ? (
951
- // Raising only the initial delay must not trip the inherited cap.
952
- Math.max(base.maxDelayMs, initialDelayMs)
953
- ) : toMilliseconds(options.maxDelay, "retry.maxDelay"),
954
- jitter: options?.jitter ?? base.jitter,
955
- retryIf: options?.retryIf ?? base.retryIf
956
- };
957
- if (policy.attempts !== Number.POSITIVE_INFINITY && (!Number.isInteger(policy.attempts) || policy.attempts < 1)) {
958
- throw new ConfigurationError(
959
- `retry.attempts must be a positive integer or Infinity, received ${policy.attempts}`
744
+ async claim(request) {
745
+ const handlers = new Set(request.handlers);
746
+ const due = [...this.#tables.deliveries.values()].filter((delivery) => handlers.has(delivery.handler) && isDue(delivery, request.now)).sort(compareDue);
747
+ const claimed = [];
748
+ for (const delivery of due) {
749
+ if (claimed.length >= request.limit) {
750
+ break;
751
+ }
752
+ if (this.#isKeyBlocked(delivery, request.now)) {
753
+ continue;
754
+ }
755
+ delivery.status = "processing";
756
+ delivery.attempts += 1;
757
+ delivery.leaseOwner = request.leaseOwner;
758
+ delivery.leaseToken = this.#generateIdentifier();
759
+ delivery.leaseExpiresAt = request.leaseExpiresAt;
760
+ delivery.updatedAt = request.now;
761
+ claimed.push({
762
+ deliveryId: delivery.id,
763
+ leaseToken: delivery.leaseToken,
764
+ handler: delivery.handler,
765
+ attempt: delivery.attempts,
766
+ leaseExpiresAt: request.leaseExpiresAt,
767
+ event: this.#tables.readEnvelope(delivery.eventId)
768
+ });
769
+ }
770
+ return claimed;
771
+ }
772
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
773
+ async extendLease(lease, leaseExpiresAt, now) {
774
+ return this.#settlement.extendLease({ lease, leaseExpiresAt, now });
775
+ }
776
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
777
+ async complete(lease, now, transaction) {
778
+ return this.#settlement.complete(
779
+ lease,
780
+ transaction === void 0 ? { now } : { now, transaction }
960
781
  );
961
782
  }
962
- if (!Number.isFinite(policy.factor) || policy.factor < 1) {
963
- throw new ConfigurationError(`retry.factor must be at least 1, received ${policy.factor}`);
783
+ async completeMany(leases, now) {
784
+ return Promise.all(leases.map((lease) => this.complete(lease, now)));
964
785
  }
965
- if (policy.maxDelayMs < policy.initialDelayMs) {
966
- throw new ConfigurationError("retry.maxDelay must not be smaller than retry.initialDelay");
786
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
787
+ async reschedule(lease, availableAt, error, now) {
788
+ return this.#settlement.changeClaim({
789
+ lease,
790
+ change: { kind: "reschedule", availableAt, error, now }
791
+ });
967
792
  }
968
- if (!JITTER_STRATEGIES.includes(policy.jitter)) {
969
- throw new ConfigurationError(
970
- `retry.jitter must be one of ${JITTER_STRATEGIES.join(", ")}, received '${String(policy.jitter)}'`
971
- );
793
+ async release(lease, now) {
794
+ return this.#settlement.changeClaim({ lease, change: { kind: "release", now } });
972
795
  }
973
- return policy;
974
- }
975
- function computeRetryDelay(policy, failedAttempts, random = Math.random) {
976
- const exponential = policy.initialDelayMs * policy.factor ** (failedAttempts - 1);
977
- const capped = Math.min(policy.maxDelayMs, exponential);
978
- switch (policy.jitter) {
979
- case "full":
980
- return Math.round(random() * capped);
981
- case "equal":
982
- return Math.round(capped / 2 + random() * capped / 2);
983
- case "none":
984
- return Math.round(capped);
796
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
797
+ async bury(lease, error, now) {
798
+ return this.#settlement.changeClaim({ lease, change: { kind: "bury", error, now } });
985
799
  }
986
- }
987
- function isRetryableError(policy, error) {
988
- if (error instanceof NonRetryableError) {
800
+ async listDeliveries(query) {
801
+ return this.#maintenance.listDeliveries(query);
802
+ }
803
+ async requeue(deliveryIds, now) {
804
+ return this.#maintenance.requeue(deliveryIds, now);
805
+ }
806
+ async purge(request) {
807
+ return this.#maintenance.purge(request);
808
+ }
809
+ async statistics(now) {
810
+ return this.#maintenance.statistics(now);
811
+ }
812
+ /**
813
+ * A keyed delivery waits while an earlier delivery of the same handler and
814
+ * key is unfinished, or while any other delivery of that handler and key is
815
+ * running under a live lease (a requeued dead letter must not run alongside
816
+ * its successor).
817
+ */
818
+ #isKeyBlocked(delivery, now) {
819
+ if (delivery.key === null) {
820
+ return false;
821
+ }
822
+ for (const other of this.#tables.deliveries.values()) {
823
+ if (blocksKey(other, { delivery, now })) {
824
+ return true;
825
+ }
826
+ }
989
827
  return false;
990
828
  }
991
- if (policy.retryIf === void 0) {
992
- return true;
829
+ };
830
+
831
+ // src/shutdown-deadline.ts
832
+ var DRAIN_SHARE = 0.8;
833
+ var ShutdownDeadline = class {
834
+ #controller = new AbortController();
835
+ #drained;
836
+ #expired;
837
+ #cancelTimers;
838
+ constructor(clock, timeoutMs) {
839
+ let drain;
840
+ this.#drained = new Promise((resolve) => {
841
+ drain = () => resolve("timeout");
842
+ });
843
+ const signal = this.#controller.signal;
844
+ this.#expired = new Promise((resolve) => {
845
+ signal.addEventListener("abort", () => resolve("timeout"), { once: true });
846
+ });
847
+ this.#cancelTimers = [
848
+ schedule(clock, { run: drain, delayMs: Math.floor(timeoutMs * DRAIN_SHARE) }),
849
+ schedule(clock, { run: () => this.#controller.abort(), delayMs: timeoutMs })
850
+ ];
993
851
  }
852
+ /** Aborted when the deadline passes. */
853
+ get signal() {
854
+ return this.#controller.signal;
855
+ }
856
+ get passed() {
857
+ return this.#controller.signal.aborted;
858
+ }
859
+ /** Waits for `work` until the deadline. Rejects when `work` rejects before it. */
860
+ race(work) {
861
+ return Promise.race([work.then(() => "done"), this.#expired]);
862
+ }
863
+ /** Waits for running handlers until the drain point, when they are aborted. */
864
+ raceDrain(work) {
865
+ return Promise.race([work.then(() => "done"), this.#drained]);
866
+ }
867
+ /** Cancels the timers once the shutdown is over. */
868
+ dispose() {
869
+ for (const cancel of this.#cancelTimers) {
870
+ cancel();
871
+ }
872
+ }
873
+ };
874
+ async function withinDeadline({ clock, timeoutMs }, work) {
875
+ const startedAt = clock.now().getTime();
876
+ const deadline = new ShutdownDeadline(clock, timeoutMs);
994
877
  try {
995
- return policy.retryIf(error);
996
- } catch {
997
- return true;
878
+ const abandonedDeliveries = await work(deadline);
879
+ return {
880
+ graceful: !deadline.passed,
881
+ abandonedDeliveries,
882
+ durationMs: clock.now().getTime() - startedAt
883
+ };
884
+ } finally {
885
+ deadline.dispose();
998
886
  }
999
887
  }
1000
888
 
1001
- // src/dispatcher.ts
1002
- var COMPLETION_RETRY_DELAYS_MS = [50, 250, 1e3];
1003
- var WAKE_COOLDOWN_MS = 5;
1004
- var QUIET_REFILL_MS = 10;
1005
- var REFILL_FRACTION = 0.7;
1006
- var defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
1007
- var Dispatcher = class {
1008
- #dependencies;
1009
- #sleep;
1010
- #inFlight = /* @__PURE__ */ new Set();
1011
- #completions;
1012
- #pending = /* @__PURE__ */ new Map();
1013
- #recoveryTimer;
1014
- #recovery;
1015
- #running = false;
1016
- #loop;
1017
- #wakeUp;
1018
- /** Free slots the claim the loop was woken for needs at least; undefined while nobody woke it. */
1019
- #signalledMinimum;
1020
- /** Free slots a claim needs unless the poll interval or the quiet refill time passed: claims refill in groups. */
1021
- #refillThreshold;
1022
- /** Handlers whose last claim got less than it asked for: the store has nothing more for them right now. */
1023
- #exhaustedHandlers = /* @__PURE__ */ new Set();
1024
- /** Grows with every wake-up: a claim that started before one must not mark a handler exhausted. */
1025
- #wakeGeneration = 0;
1026
- #lastClaimStartedAt = Number.NEGATIVE_INFINITY;
1027
- #cooldownTimer;
1028
- #refillScheduled = false;
1029
- #quietRefillTimer;
1030
- #unwatch;
1031
- #watching;
1032
- constructor(dependencies) {
1033
- this.#dependencies = dependencies;
1034
- this.#sleep = dependencies.sleep ?? defaultSleep;
1035
- const { store, clock, settings } = dependencies;
1036
- this.#refillThreshold = Math.ceil(
1037
- Math.min(settings.concurrency, settings.batchSize) * REFILL_FRACTION
1038
- );
1039
- const { completionBatchWindowMs: windowMs = 0, completionBatchSize: maxBatchSize = 1 } = settings;
1040
- if (store.completeMany !== void 0 && windowMs > 0) {
1041
- this.#completions = new CompletionBatcher(
1042
- { completeMany: (leases, now) => store.completeMany(leases, now) },
1043
- clock,
1044
- { windowMs, maxBatchSize },
1045
- () => this.#inFlight.size
889
+ // src/bus/bus-lifecycle.ts
890
+ var BusLifecycle = class {
891
+ #parts;
892
+ #state = "idle";
893
+ #starting;
894
+ #stopping;
895
+ constructor(parts) {
896
+ this.#parts = parts;
897
+ }
898
+ get state() {
899
+ return this.#state;
900
+ }
901
+ /** Throws unless the bus is idle: a bus starts once. */
902
+ assertStartable() {
903
+ if (this.#state !== "idle") {
904
+ throw new LifecycleError(
905
+ this.#state === "stopped" ? "A stopped relaybus cannot be started again; create a new instance" : `Cannot start a relaybus that is ${this.#state}`
1046
906
  );
1047
907
  }
1048
908
  }
1049
- get isRunning() {
1050
- return this.#running;
909
+ /** Runs `startComponents` as the start in progress, which a stop waits for; returns it. */
910
+ beginStart(startComponents) {
911
+ this.#state = "starting";
912
+ this.#starting = startComponents();
913
+ return this.#starting;
1051
914
  }
1052
- get inFlightCount() {
1053
- return this.#inFlight.size;
915
+ /** The start in progress settled. */
916
+ endStart() {
917
+ this.#starting = void 0;
1054
918
  }
1055
- /**
1056
- * True while the store reports new deliveries to this dispatcher by itself (see
1057
- * {@link EventStore.watchNewDeliveries}), from every process: a publisher then has no reason to wake it.
1058
- */
1059
- get watchesNewDeliveries() {
1060
- return this.#unwatch !== void 0;
919
+ markRunning() {
920
+ this.#state = "running";
1061
921
  }
1062
- start() {
1063
- if (this.#running) {
1064
- return;
1065
- }
1066
- this.#running = true;
1067
- this.#completions?.setClosing(false);
1068
- this.#watching = this.#watchNewDeliveries();
1069
- this.#loop = this.#runLoop();
922
+ markStopped() {
923
+ this.#state = "stopped";
1070
924
  }
1071
- /**
1072
- * Asks for a claim soon, for example right after a publish: at once after a quiet period, otherwise
1073
- * at the end of a short cooldown that merges the wake-ups arriving meanwhile into one claim.
1074
- */
1075
- wake() {
1076
- this.#exhaustedHandlers.clear();
1077
- this.#wakeGeneration += 1;
1078
- const wait = this.#lastClaimStartedAt + WAKE_COOLDOWN_MS - Date.now();
1079
- if (wait <= 0) {
1080
- this.#requestClaim();
1081
- return;
925
+ /** The stop of the bus within `timeoutMs`; a later call shares the first one's. */
926
+ stop(timeoutMs) {
927
+ if (this.#stopping !== void 0) {
928
+ return { stopping: this.#stopping, first: false };
1082
929
  }
1083
- this.#cooldownTimer ??= setTimeout(() => {
1084
- this.#cooldownTimer = void 0;
1085
- this.#requestClaim();
1086
- }, wait);
1087
- }
1088
- /** Ends the current wait of the claim loop, or the next one at once, for a claim once `minimumFree` slots are free. */
1089
- #signal(minimumFree) {
1090
- this.#signalledMinimum = Math.min(this.#signalledMinimum ?? minimumFree, minimumFree);
1091
- this.#wakeUp?.();
930
+ if (this.#state !== "running" && this.#state !== "starting") {
931
+ return { stopping: this.#stopOutsideRun(timeoutMs), first: false };
932
+ }
933
+ this.#stopping = this.#stopComponents(timeoutMs);
934
+ return { stopping: this.#stopping, first: true };
1092
935
  }
1093
936
  /**
1094
- * Starts listening to the store's reports of new deliveries. Polling stays the safety net: a store
1095
- * that cannot listen, or fails to, only costs latency, so a failure is reported and otherwise ignored.
937
+ * A bus that is not running: an idle one only settles what batches run by hand left behind (and
938
+ * stays startable); a stopped one has nothing left to do.
1096
939
  */
1097
- async #watchNewDeliveries() {
1098
- const { store, notifier } = this.#dependencies;
1099
- try {
1100
- const unwatch = await store.watchNewDeliveries?.(() => this.wake());
1101
- if (unwatch !== void 0 && !this.#running) {
1102
- await unwatch();
1103
- return;
1104
- }
1105
- this.#unwatch = unwatch;
1106
- if (unwatch !== void 0) {
1107
- this.wake();
1108
- }
1109
- } catch (error) {
1110
- notifier.failure("watch", error);
1111
- }
1112
- }
1113
- /** Stops listening to the store's reports of new deliveries. */
1114
- async #stopWatching() {
1115
- await this.#watching;
1116
- const unwatch = this.#unwatch;
1117
- this.#unwatch = void 0;
1118
- try {
1119
- await unwatch?.();
1120
- } catch (error) {
1121
- this.#dependencies.notifier.failure("watch", error);
940
+ async #stopOutsideRun(timeoutMs) {
941
+ if (this.#state !== "idle") {
942
+ return { graceful: true, abandonedDeliveries: 0, durationMs: 0 };
1122
943
  }
944
+ return withinDeadline(
945
+ { clock: this.#parts.clock, timeoutMs },
946
+ (deadline) => this.#parts.dispatcher.stop(deadline)
947
+ );
1123
948
  }
1124
949
  /**
1125
- * Stops claiming, waits up to the shutdown timeout for running handlers,
1126
- * then aborts the rest and releases their deliveries for another process.
950
+ * Waits for a start in progress (a failed start leaves nothing to stop), then stops the dispatcher
951
+ * and closes the transports and the store. A start still running at the deadline is given up: the
952
+ * transports and the store are force-closed and the start rejects when it resumes.
1127
953
  */
1128
- async stop() {
1129
- if (!this.#running) {
1130
- await this.#closeRecovery(Date.now() + this.#dependencies.settings.shutdownTimeoutMs);
1131
- return;
1132
- }
1133
- this.#running = false;
1134
- this.#completions?.setClosing(true);
1135
- clearTimeout(this.#cooldownTimer);
1136
- this.#cooldownTimer = void 0;
1137
- clearTimeout(this.#quietRefillTimer);
1138
- this.#quietRefillTimer = void 0;
1139
- this.#signal(this.#refillThreshold);
1140
- const deadline = Date.now() + this.#dependencies.settings.shutdownTimeoutMs;
1141
- await this.#waitUntil(this.#stopWatching(), deadline);
1142
- await this.#waitUntil(this.#loop, deadline);
1143
- const settled = Promise.allSettled([...this.#inFlight].map((delivery) => delivery.settled));
1144
- if (await this.#waitUntil(settled, deadline) === "timeout") {
1145
- for (const delivery of this.#inFlight) {
1146
- delivery.controller.abort(new DispatcherStoppedError("relaybus dispatcher stopped"));
1147
- }
1148
- await settled;
954
+ async #stopComponents(timeoutMs) {
955
+ try {
956
+ return await withinDeadline({ clock: this.#parts.clock, timeoutMs }, async (deadline) => {
957
+ if (this.#state === "starting") {
958
+ const started = await deadline.race(this.#starting.catch(() => void 0));
959
+ if (started === "timeout") {
960
+ this.#state = "stopping";
961
+ await this.#closeConnections(deadline);
962
+ return 0;
963
+ }
964
+ }
965
+ if (this.#state !== "running") {
966
+ return 0;
967
+ }
968
+ this.#state = "stopping";
969
+ this.#parts.maintenance.stop();
970
+ const abandoned = await this.#parts.dispatcher.stop(deadline);
971
+ await this.#closeConnections(deadline);
972
+ return abandoned;
973
+ });
974
+ } finally {
975
+ this.#state = "stopped";
1149
976
  }
1150
- await this.#closeRecovery(deadline);
1151
- await this.#waitUntil(this.#relinquish(), deadline);
1152
977
  }
1153
- /**
1154
- * Gives the settlements still pending one last try and forgets them. A pass of the timer may be inside the
1155
- * store: it ends before the last pass starts. A store that does not answer before the deadline gets no
1156
- * further call, since it may be closed right after.
1157
- */
1158
- async #closeRecovery(deadline) {
1159
- clearInterval(this.#recoveryTimer);
1160
- this.#recoveryTimer = void 0;
1161
- const lastTry = (async () => {
1162
- await this.#recovery;
1163
- await this.#recoverSettlements();
1164
- })();
1165
- await this.#waitUntil(lastTry, deadline);
1166
- this.#pending.clear();
978
+ async #closeConnections(deadline) {
979
+ await this.#parts.closeTransports(deadline);
980
+ const options = { signal: deadline.signal };
981
+ await deadline.race(Promise.resolve(this.#parts.store.close?.(options)));
982
+ }
983
+ };
984
+
985
+ // src/bus/handler-descriptions.ts
986
+ function toDescriptor(registration, eventType) {
987
+ return {
988
+ id: registration.id,
989
+ eventType,
990
+ retry: registration.retry,
991
+ timeoutMs: registration.timeoutMs,
992
+ concurrency: registration.concurrency,
993
+ transactional: registration.transactional,
994
+ forwardsToTransport: registration.forwardsToTransport
995
+ };
996
+ }
997
+ function describeEvent(event) {
998
+ if (typeof event === "string") {
999
+ assertEventType(event);
1000
+ return { type: event, schema: void 0 };
1001
+ }
1002
+ assertEventType(event.type);
1003
+ return { type: event.type, schema: event.schema };
1004
+ }
1005
+
1006
+ // src/tracing.ts
1007
+ var TRACEPARENT_HEADER = "traceparent";
1008
+ var TRACESTATE_HEADER = "tracestate";
1009
+ var TRACEPARENT_FIELDS = "(?!0{32})[\\da-f]{32}-(?!0{16})[\\da-f]{16}-[\\da-f]{2}";
1010
+ var TRACEPARENT = new RegExp(
1011
+ `^(?:00-${TRACEPARENT_FIELDS}|(?!00|ff)[\\da-f]{2}-${TRACEPARENT_FIELDS}(?:-.*)?)$`
1012
+ );
1013
+ function traceContextOf(headers) {
1014
+ const traceparent = headers[TRACEPARENT_HEADER];
1015
+ if (traceparent === void 0 || !TRACEPARENT.test(traceparent)) {
1016
+ return void 0;
1017
+ }
1018
+ const tracestate = headers[TRACESTATE_HEADER];
1019
+ return tracestate === void 0 ? { traceparent } : { traceparent, tracestate };
1020
+ }
1021
+
1022
+ // src/bus-observation.ts
1023
+ function withTraceHeaders(options, headers) {
1024
+ return Object.keys(headers).length === 0 ? options : { ...options, headers: { ...headers, ...options.headers } };
1025
+ }
1026
+ var BusObservation = class _BusObservation {
1027
+ #notifier;
1028
+ #tracer;
1029
+ #clock;
1030
+ #publishes;
1031
+ #receives;
1032
+ #sends;
1033
+ constructor(input) {
1034
+ this.#notifier = input.notifier;
1035
+ this.#tracer = input.tracer;
1036
+ this.#clock = input.clock;
1037
+ this.#publishes = input.notifier.wants("onPublishFinished");
1038
+ this.#receives = input.notifier.wants("onReceiveFinished");
1039
+ this.#sends = input.notifier.wants("onTransportSent");
1040
+ }
1041
+ /** An observation, or undefined when no observer hook and no tracer would see anything. */
1042
+ static create(input) {
1043
+ const observed = input.tracer !== void 0 || ["onPublishFinished", "onReceiveFinished", "onTransportSent", "onBacklog"].some(
1044
+ (method) => input.notifier.wants(method)
1045
+ );
1046
+ return observed ? new _BusObservation(input) : void 0;
1047
+ }
1048
+ /** Runs a publish call inside the tracer's publish span, reporting its duration and outcome. */
1049
+ async publish(input, run) {
1050
+ const startedAt = this.#publishes ? this.#now() : 0;
1051
+ const tracer = this.#tracer;
1052
+ const traced = tracer?.publish === void 0 ? run({}) : tracer.publish(input, run);
1053
+ if (!this.#publishes) {
1054
+ return traced;
1055
+ }
1056
+ const report = (outcome, error) => {
1057
+ this.#notifier.notify("onPublishFinished", {
1058
+ eventTypes: input.eventTypes,
1059
+ durationMs: this.#now() - startedAt,
1060
+ outcome,
1061
+ ...error === void 0 ? {} : { error: toErrorInfo(error) }
1062
+ });
1063
+ };
1064
+ try {
1065
+ const result = await traced;
1066
+ report("success");
1067
+ return result;
1068
+ } catch (error) {
1069
+ report("error", error);
1070
+ throw error;
1071
+ }
1072
+ }
1073
+ /** Runs `receive` for one event, reporting where it came from and what became of it. */
1074
+ async receive(input, run) {
1075
+ if (!this.#receives) {
1076
+ return run();
1077
+ }
1078
+ const startedAt = this.#now();
1079
+ try {
1080
+ const outcome = await run();
1081
+ this.#reportReceive(input, { outcome, startedAt, failure: void 0 });
1082
+ return outcome;
1083
+ } catch (error) {
1084
+ const outcome = error instanceof InvalidEnvelopeError ? "invalid" : "error";
1085
+ this.#reportReceive(input, { outcome, startedAt, failure: error });
1086
+ throw error;
1087
+ }
1088
+ }
1089
+ #reportReceive({ envelope, origin }, report) {
1090
+ const { headers } = envelope;
1091
+ this.#notifier.notify("onReceiveFinished", {
1092
+ transport: origin?.transport.name,
1093
+ system: origin === void 0 ? void 0 : systemOf(origin.transport),
1094
+ destination: origin?.destination,
1095
+ eventType: textOrUndefined(envelope.type),
1096
+ source: textOrUndefined(envelope.source),
1097
+ traceContext: isObject(headers) ? traceContextOf(headers) : void 0,
1098
+ outcome: report.outcome,
1099
+ durationMs: this.#now() - report.startedAt,
1100
+ ...report.failure === void 0 ? {} : { error: toErrorInfo(report.failure) }
1101
+ });
1102
+ }
1103
+ /** The handler of a route: sends inside the tracer's send span and reports each send. */
1104
+ sender(transport, destination) {
1105
+ if (this.#tracer?.send === void 0 && !this.#sends) {
1106
+ return (envelope) => transport.publish(envelope, destination);
1107
+ }
1108
+ return (envelope) => this.#send({ envelope, transport, destination });
1109
+ }
1110
+ async #send({ envelope, transport, destination }) {
1111
+ const startedAt = this.#sends ? this.#now() : 0;
1112
+ const system = systemOf(transport);
1113
+ const run = (headers) => transport.publish(
1114
+ Object.keys(headers).length === 0 ? envelope : { ...envelope, headers: { ...envelope.headers, ...headers } },
1115
+ destination
1116
+ );
1117
+ const tracer = this.#tracer;
1118
+ const sent = tracer?.send === void 0 ? run({}) : tracer.send({ envelope, transport: transport.name, system, destination }, run);
1119
+ if (!this.#sends) {
1120
+ return sent;
1121
+ }
1122
+ const report = (outcome, error) => {
1123
+ this.#notifier.notify("onTransportSent", {
1124
+ envelope,
1125
+ transport: transport.name,
1126
+ system,
1127
+ destination,
1128
+ durationMs: this.#now() - startedAt,
1129
+ outcome,
1130
+ ...error === void 0 ? {} : { error: toErrorInfo(error) }
1131
+ });
1132
+ };
1133
+ try {
1134
+ await sent;
1135
+ report("success");
1136
+ } catch (error) {
1137
+ report("error", error);
1138
+ throw error;
1139
+ }
1167
1140
  }
1168
1141
  /**
1169
- * Retries every pending settlement once; a pass that is already running is the one returned. A store that
1170
- * still fails leaves the settlement for the next attempt (it was reported when it first failed).
1142
+ * Reads the backlog every `intervalMs` and reports it, when an observer wants it. A reading that
1143
+ * is still running when the next one is due is not overlapped. Returns the cancel of the timer.
1171
1144
  */
1172
- #recoverSettlements() {
1173
- this.#recovery ??= this.#recoverAll().finally(() => {
1174
- this.#recovery = void 0;
1145
+ startBacklog({ intervalMs, statistics }) {
1146
+ if (!this.#notifier.wants("onBacklog")) {
1147
+ return void 0;
1148
+ }
1149
+ let reading = false;
1150
+ return schedule(this.#clock, {
1151
+ run: () => {
1152
+ if (reading) {
1153
+ return;
1154
+ }
1155
+ reading = true;
1156
+ statistics().then((result) => this.#reportBacklog(result)).catch((error) => this.#notifier.failure("backlog", error)).finally(() => {
1157
+ reading = false;
1158
+ });
1159
+ },
1160
+ delayMs: intervalMs,
1161
+ repeat: true,
1162
+ unref: true
1175
1163
  });
1176
- return this.#recovery;
1177
1164
  }
1178
- async #recoverAll() {
1179
- for (const [key, entry] of [...this.#pending]) {
1180
- if (this.#pending.get(key) !== entry) {
1181
- continue;
1182
- }
1183
- try {
1184
- const applied = await entry.settle();
1185
- this.#pending.delete(pendingKey(entry.delivery));
1186
- if (applied) {
1187
- entry.applied();
1188
- } else {
1189
- this.#dependencies.notifier.notify("onLeaseLost", { delivery: entry.delivery });
1190
- }
1191
- } catch {
1192
- }
1165
+ #reportBacklog(statistics) {
1166
+ const oldestDueAt = statistics.oldestDueAt;
1167
+ this.#notifier.notify("onBacklog", {
1168
+ pending: statistics.pending,
1169
+ oldestPendingAgeMs: oldestDueAt === null ? 0 : Math.max(0, this.#now() - oldestDueAt.getTime()),
1170
+ statistics
1171
+ });
1172
+ }
1173
+ #now() {
1174
+ return this.#clock.now().getTime();
1175
+ }
1176
+ };
1177
+ function systemOf(transport) {
1178
+ return transport.system ?? transport.name;
1179
+ }
1180
+ function textOrUndefined(value) {
1181
+ return typeof value === "string" ? value : void 0;
1182
+ }
1183
+ function isObject(value) {
1184
+ return typeof value === "object" && value !== null;
1185
+ }
1186
+
1187
+ // src/dispatch-observation.ts
1188
+ var DispatchObservation = class _DispatchObservation {
1189
+ #notifier;
1190
+ #tracer;
1191
+ #clock;
1192
+ #claims;
1193
+ #runs;
1194
+ constructor(input) {
1195
+ this.#notifier = input.notifier;
1196
+ this.#tracer = input.tracer;
1197
+ this.#clock = input.clock;
1198
+ this.#claims = input.notifier.wants("onClaimFinished");
1199
+ this.#runs = input.notifier.wants("onHandlerStarted") || input.notifier.wants("onHandlerFinished");
1200
+ }
1201
+ /** An observation, or undefined when no observer hook and no tracer would see anything. */
1202
+ static create(input) {
1203
+ const observation = new _DispatchObservation(input);
1204
+ return observation.#claims || observation.#runs || input.tracer?.process !== void 0 ? observation : void 0;
1205
+ }
1206
+ /** Claims through `store`, reporting the query's size, lanes and duration. */
1207
+ async claim(store, request) {
1208
+ if (!this.#claims) {
1209
+ return store.claim(request);
1210
+ }
1211
+ const startedAt = this.#clock.now().getTime();
1212
+ const report = { handlers: request.handlers, requested: request.limit };
1213
+ try {
1214
+ const claimed = await store.claim(request);
1215
+ this.#notifier.notify("onClaimFinished", {
1216
+ ...report,
1217
+ claimed: claimed.length,
1218
+ lanes: new Set(claimed.map((delivery) => delivery.leaseToken)).size,
1219
+ durationMs: this.#clock.now().getTime() - startedAt,
1220
+ outcome: "success"
1221
+ });
1222
+ return claimed;
1223
+ } catch (error) {
1224
+ this.#notifier.notify("onClaimFinished", {
1225
+ ...report,
1226
+ claimed: 0,
1227
+ lanes: 0,
1228
+ durationMs: this.#clock.now().getTime() - startedAt,
1229
+ outcome: "error",
1230
+ error: toErrorInfo(error)
1231
+ });
1232
+ throw error;
1193
1233
  }
1194
1234
  }
1195
- #retain(entry) {
1196
- this.#pending.set(pendingKey(entry.delivery), entry);
1197
- this.#recoveryTimer ??= setInterval(
1198
- () => void this.#recoverSettlements(),
1199
- this.#dependencies.settings.pollIntervalMs
1200
- );
1201
- this.#recoveryTimer.unref();
1235
+ /** Runs a local handler inside the tracer's process span; routes are traced as sends instead. */
1236
+ process(run, handle) {
1237
+ const tracer = this.#tracer;
1238
+ if (tracer?.process === void 0 || run.registration.forwardsToTransport) {
1239
+ return handle();
1240
+ }
1241
+ return tracer.process({ delivery: run.delivery }, async () => handle());
1202
1242
  }
1203
- /** Gives lease groups the owner no longer works on back to the store, so other workers need not wait for expiry. */
1204
- async #relinquish() {
1205
- const { store, clock, notifier, settings } = this.#dependencies;
1243
+ /** Reports the start of a handler run now and its end when `settled` settles, passing it through. */
1244
+ async observe(run, settled) {
1245
+ if (!this.#runs) {
1246
+ await settled;
1247
+ return;
1248
+ }
1249
+ const { delivery } = run;
1250
+ const kind = run.registration.forwardsToTransport ? "route" : "handler";
1251
+ const startedAt = this.#clock.now().getTime();
1252
+ this.#notifier.notify("onHandlerStarted", {
1253
+ delivery,
1254
+ kind,
1255
+ endToEndLatencyMs: startedAt - delivery.event.occurredAt.getTime()
1256
+ });
1206
1257
  try {
1207
- await store.relinquish?.(settings.owner, clock.now());
1258
+ await settled;
1208
1259
  } catch (error) {
1209
- notifier.failure("relinquish", error);
1260
+ const durationMs2 = this.#clock.now().getTime() - startedAt;
1261
+ const errorInfo = toErrorInfo(error);
1262
+ this.#notifier.notify("onHandlerFinished", {
1263
+ delivery,
1264
+ kind,
1265
+ durationMs: durationMs2,
1266
+ outcome: "error",
1267
+ error: errorInfo
1268
+ });
1269
+ throw error;
1210
1270
  }
1271
+ const durationMs = this.#clock.now().getTime() - startedAt;
1272
+ this.#notifier.notify("onHandlerFinished", { delivery, kind, durationMs, outcome: "success" });
1211
1273
  }
1212
- /** Claims one batch and resolves when every claimed delivery settled. Returns how many ran. */
1213
- async processBatch() {
1214
- const { started } = await this.#claimAndStart();
1215
- await Promise.allSettled(started.map((delivery) => delivery.settled));
1216
- return started.length;
1274
+ };
1275
+
1276
+ // src/dispatch/claim-loop.ts
1277
+ var WAKE_COOLDOWN_MS = 5;
1278
+ var QUIET_REFILL_MS = 10;
1279
+ var REFILL_FRACTION = 0.7;
1280
+ var ClaimLoop = class {
1281
+ #parts;
1282
+ #running = false;
1283
+ #wakeUp;
1284
+ /** Free slots the claim the loop was woken for needs at least; undefined while nobody woke it. */
1285
+ #signalledMinimum;
1286
+ /** Free slots a claim needs unless the poll interval or the quiet refill time passed: claims refill in groups. */
1287
+ #refillThreshold;
1288
+ /** Handlers whose last claim got less than it asked for: the store has nothing more for them right now. */
1289
+ #exhaustedHandlers = /* @__PURE__ */ new Set();
1290
+ /** Grows with every wake-up: a claim that started before one must not mark a handler exhausted. */
1291
+ #wakeGeneration = 0;
1292
+ #lastClaimStartedAt = Number.NEGATIVE_INFINITY;
1293
+ #cooldownTimer;
1294
+ #refillScheduled = false;
1295
+ #quietRefillTimer;
1296
+ constructor(parts) {
1297
+ this.#parts = parts;
1298
+ const { concurrency, batchSize } = parts.dependencies.settings;
1299
+ this.#refillThreshold = Math.ceil(Math.min(concurrency, batchSize) * REFILL_FRACTION);
1300
+ }
1301
+ get isRunning() {
1302
+ return this.#running;
1303
+ }
1304
+ /** Marks the loop running; {@link ClaimLoop.run} then claims until {@link ClaimLoop.halt}. */
1305
+ open() {
1306
+ this.#running = true;
1307
+ }
1308
+ /** Stops claiming: the running pass ends at its next wait. */
1309
+ halt() {
1310
+ this.#running = false;
1311
+ }
1312
+ /** Cancels the claims waiting for a timer and ends the current wait, so a halted loop returns. */
1313
+ interrupt() {
1314
+ this.#cooldownTimer?.();
1315
+ this.#cooldownTimer = void 0;
1316
+ this.#quietRefillTimer?.();
1317
+ this.#quietRefillTimer = void 0;
1318
+ this.#signal(this.#refillThreshold);
1217
1319
  }
1218
- async #waitUntil(work, deadline) {
1219
- let timer;
1220
- const timedOut = new Promise((resolve) => {
1221
- timer = setTimeout(() => resolve("timeout"), Math.max(0, deadline - Date.now()));
1320
+ /**
1321
+ * Asks for a claim soon, for example right after a publish: at once after a quiet period, otherwise
1322
+ * at the end of a short cooldown that merges the wake-ups arriving meanwhile into one claim.
1323
+ */
1324
+ wake() {
1325
+ this.#exhaustedHandlers.clear();
1326
+ this.#wakeGeneration += 1;
1327
+ const wait = this.#lastClaimStartedAt + WAKE_COOLDOWN_MS - Date.now();
1328
+ if (wait <= 0) {
1329
+ this.#requestClaim();
1330
+ return;
1331
+ }
1332
+ this.#cooldownTimer ??= schedule(this.#parts.dependencies.clock, {
1333
+ run: () => {
1334
+ this.#cooldownTimer = void 0;
1335
+ this.#requestClaim();
1336
+ },
1337
+ delayMs: wait
1222
1338
  });
1223
- const outcome = await Promise.race([work.then(() => "done"), timedOut]);
1224
- clearTimeout(timer);
1225
- return outcome;
1226
1339
  }
1227
1340
  /**
1228
- * Claims until stopped. A claim that filled its batch is followed by the next one at once while enough
1341
+ * Claims until halted. A claim that filled its batch is followed by the next one at once while enough
1229
1342
  * slots are free. Otherwise the loop sleeps until a wake-up (a publish, a store notification, or enough
1230
1343
  * deliveries settling to refill a group of slots) or the poll interval. Only the poll interval claims
1231
1344
  * for fewer free slots than the refill threshold, so a busy dispatcher never claims slot by slot.
1232
1345
  */
1233
- async #runLoop() {
1346
+ async run() {
1234
1347
  let minimumFree = 1;
1235
1348
  while (this.#running) {
1236
1349
  let round = { requested: 0, claimed: 0 };
1237
1350
  if (this.#freeSlots() >= minimumFree) {
1238
1351
  try {
1239
- round = await this.#claimAndStart();
1352
+ round = await this.claimAndStart();
1240
1353
  } catch (error) {
1241
- this.#dependencies.notifier.failure("claim", error);
1354
+ this.#parts.dependencies.notifier.failure("claim", error);
1242
1355
  }
1243
1356
  }
1244
1357
  if (round.requested > 0 && round.claimed === round.requested && this.#freeSlots() >= this.#refillThreshold) {
@@ -1251,16 +1364,74 @@ var Dispatcher = class {
1251
1364
  }
1252
1365
  }
1253
1366
  }
1367
+ /**
1368
+ * A delivery settled. Its slot is worth refilling when the store may have more for its handler: a keyed
1369
+ * delivery unblocks the next one of its key, a delivery that did not succeed may be due again at once
1370
+ * (a retry without delay, a release), and a handler whose last claim was full may have a backlog.
1371
+ */
1372
+ onSettled(delivery, succeeded) {
1373
+ if (delivery.event.key !== null || !succeeded || !this.#exhaustedHandlers.has(delivery.handler)) {
1374
+ this.#requestClaim();
1375
+ }
1376
+ }
1377
+ /** Claims for every handler with room and starts what was claimed. */
1378
+ async claimAndStart() {
1379
+ const { dependencies, planner, shutdown, start } = this.#parts;
1380
+ const { store, registry, clock, settings, observation } = dependencies;
1381
+ const started = [];
1382
+ let requested = 0;
1383
+ for (const request of planner.plan(registry.all(), this.#freeSlots())) {
1384
+ const now = clock.now();
1385
+ const wakeGeneration = this.#wakeGeneration;
1386
+ this.#lastClaimStartedAt = Date.now();
1387
+ this.#quietRefillTimer?.();
1388
+ this.#quietRefillTimer = void 0;
1389
+ requested += request.limit;
1390
+ const claim = {
1391
+ handlers: request.handlers,
1392
+ limit: request.limit,
1393
+ leaseOwner: settings.owner,
1394
+ leaseExpiresAt: new Date(now.getTime() + settings.leaseDurationMs),
1395
+ now
1396
+ };
1397
+ const claimed = await (observation === void 0 ? store.claim(claim) : observation.claim(store, claim));
1398
+ this.#markExhausted(
1399
+ request.handlers,
1400
+ claimed.length < request.limit && wakeGeneration === this.#wakeGeneration
1401
+ );
1402
+ for (const delivery of shutdown.abandoned ? [] : claimed) {
1403
+ started.push(start(delivery));
1404
+ }
1405
+ }
1406
+ return { requested, claimed: started.length, started };
1407
+ }
1408
+ #markExhausted(handlers, exhausted) {
1409
+ for (const handler of handlers) {
1410
+ if (exhausted) {
1411
+ this.#exhaustedHandlers.add(handler);
1412
+ } else {
1413
+ this.#exhaustedHandlers.delete(handler);
1414
+ }
1415
+ }
1416
+ }
1417
+ /** Ends the current wait of the claim loop, or the next one at once, for a claim once `minimumFree` slots are free. */
1418
+ #signal(minimumFree) {
1419
+ this.#signalledMinimum = Math.min(this.#signalledMinimum ?? minimumFree, minimumFree);
1420
+ this.#wakeUp?.();
1421
+ }
1254
1422
  /** Waits for a wake-up or the poll interval. Resolves with the free slots the next claim needs. */
1255
1423
  async #waitForWakeUp() {
1256
1424
  if (this.#signalledMinimum === void 0) {
1257
1425
  await new Promise((resolve) => {
1258
- const timer = setTimeout(() => {
1259
- this.#signalledMinimum = 1;
1260
- resolve();
1261
- }, this.#dependencies.settings.pollIntervalMs);
1426
+ const cancel = schedule(this.#parts.dependencies.clock, {
1427
+ run: () => {
1428
+ this.#signalledMinimum = 1;
1429
+ resolve();
1430
+ },
1431
+ delayMs: this.#parts.dependencies.settings.pollIntervalMs
1432
+ });
1262
1433
  this.#wakeUp = () => {
1263
- clearTimeout(timer);
1434
+ cancel();
1264
1435
  resolve();
1265
1436
  };
1266
1437
  });
@@ -1270,16 +1441,6 @@ var Dispatcher = class {
1270
1441
  this.#signalledMinimum = void 0;
1271
1442
  return minimumFree;
1272
1443
  }
1273
- /**
1274
- * A delivery settled. Its slot is worth refilling when the store may have more for its handler: a keyed
1275
- * delivery unblocks the next one of its key, a delivery that did not succeed may be due again at once
1276
- * (a retry without delay, a release), and a handler whose last claim was full may have a backlog.
1277
- */
1278
- #onSettled(delivery, succeeded) {
1279
- if (delivery.event.key !== null || !succeeded || !this.#exhaustedHandlers.has(delivery.handler)) {
1280
- this.#requestClaim();
1281
- }
1282
- }
1283
1444
  /**
1284
1445
  * Asks the loop for a claim. Once a group of slots is free the claim waits only for the deliveries
1285
1446
  * settling in the same tick, so they are claimed for together; fewer free slots are refilled when no
@@ -1296,111 +1457,163 @@ var Dispatcher = class {
1296
1457
  });
1297
1458
  }
1298
1459
  } else if (free > 0) {
1299
- this.#quietRefillTimer ??= setTimeout(
1300
- () => {
1460
+ this.#quietRefillTimer ??= schedule(this.#parts.dependencies.clock, {
1461
+ run: () => {
1301
1462
  this.#quietRefillTimer = void 0;
1302
1463
  this.#signal(1);
1303
1464
  },
1304
- Math.max(0, this.#lastClaimStartedAt + QUIET_REFILL_MS - Date.now())
1305
- );
1465
+ delayMs: Math.max(0, this.#lastClaimStartedAt + QUIET_REFILL_MS - Date.now())
1466
+ });
1306
1467
  }
1307
1468
  }
1308
1469
  #freeSlots() {
1309
- return this.#dependencies.settings.concurrency - this.#inFlight.size;
1470
+ return this.#parts.dependencies.settings.concurrency - this.#parts.inFlight.size;
1310
1471
  }
1311
- /** Claims for every handler with room and starts what was claimed. */
1312
- async #claimAndStart() {
1313
- const { store, registry, clock, settings } = this.#dependencies;
1314
- const started = [];
1315
- let requested = 0;
1316
- for (const request of this.#planClaims(registry.all())) {
1317
- const now = clock.now();
1318
- const wakeGeneration = this.#wakeGeneration;
1319
- this.#lastClaimStartedAt = Date.now();
1320
- clearTimeout(this.#quietRefillTimer);
1321
- this.#quietRefillTimer = void 0;
1322
- requested += request.limit;
1323
- const claimed = await store.claim({
1324
- handlers: request.handlers,
1325
- limit: request.limit,
1326
- leaseOwner: settings.owner,
1327
- leaseExpiresAt: new Date(now.getTime() + settings.leaseDurationMs),
1328
- now
1329
- });
1330
- for (const handler of request.handlers) {
1331
- if (claimed.length < request.limit && wakeGeneration === this.#wakeGeneration) {
1332
- this.#exhaustedHandlers.add(handler);
1333
- } else {
1334
- this.#exhaustedHandlers.delete(handler);
1335
- }
1336
- }
1337
- for (const delivery of claimed) {
1338
- started.push(this.#start(delivery));
1339
- }
1340
- }
1341
- return { requested, claimed: started.length, started };
1472
+ };
1473
+
1474
+ // src/dispatch/claim-planner.ts
1475
+ var ClaimPlanner = class {
1476
+ #inFlight;
1477
+ #batchSize;
1478
+ constructor(inFlight, batchSize) {
1479
+ this.#inFlight = inFlight;
1480
+ this.#batchSize = batchSize;
1342
1481
  }
1343
1482
  /** One claim for handlers without a concurrency limit, one per limited handler with room. */
1344
- #planClaims(registrations) {
1345
- let free = Math.min(this.#freeSlots(), this.#dependencies.settings.batchSize);
1483
+ plan(registrations, freeSlots) {
1484
+ let free = Math.min(freeSlots, this.#batchSize);
1346
1485
  const plans = [];
1347
1486
  const unlimited = registrations.filter((registration) => registration.concurrency === null);
1348
1487
  for (const registration of registrations) {
1349
1488
  if (registration.concurrency === null || free === 0) {
1350
1489
  continue;
1351
1490
  }
1352
- const room = registration.concurrency - this.#countInFlight(registration.id);
1491
+ const room = registration.concurrency - this.#inFlight.countOf(registration);
1353
1492
  const limit = Math.min(room, free);
1354
1493
  if (limit > 0) {
1355
- plans.push({ handlers: [registration.id], limit });
1494
+ plans.push({ handlers: [...registration.claimIds], limit });
1356
1495
  free -= limit;
1357
1496
  }
1358
1497
  }
1359
1498
  if (unlimited.length > 0 && free > 0) {
1360
- plans.push({ handlers: unlimited.map((registration) => registration.id), limit: free });
1499
+ plans.push({
1500
+ handlers: unlimited.flatMap((registration) => registration.claimIds),
1501
+ limit: free
1502
+ });
1361
1503
  }
1362
1504
  return plans;
1363
1505
  }
1364
- #countInFlight(handler) {
1365
- let count = 0;
1366
- for (const delivery of this.#inFlight) {
1367
- if (delivery.handler === handler) {
1368
- count += 1;
1369
- }
1370
- }
1371
- return count;
1506
+ };
1507
+
1508
+ // src/dispatch/dispatch-context.ts
1509
+ var ShutdownLatch = class {
1510
+ #abandoned = false;
1511
+ get abandoned() {
1512
+ return this.#abandoned;
1372
1513
  }
1373
- #start(delivery) {
1374
- const controller = new AbortController();
1375
- let succeeded = false;
1376
- const entry = {
1377
- handler: delivery.handler,
1378
- controller,
1379
- settled: Promise.resolve().then(async () => {
1380
- succeeded = await this.#execute(delivery, controller);
1381
- }).catch((error) => this.#dependencies.notifier.failure("dispatch", error)).finally(() => {
1382
- this.#inFlight.delete(entry);
1383
- this.#onSettled(delivery, succeeded);
1384
- })
1385
- };
1386
- this.#inFlight.add(entry);
1387
- return entry;
1514
+ abandon() {
1515
+ this.#abandoned = true;
1388
1516
  }
1389
- /** Runs one delivery. Resolves true when its handler succeeded, so the delivery is done for good. */
1390
- async #execute(delivery, controller) {
1391
- const { registry, notifier, clock } = this.#dependencies;
1392
- const registration = registry.get(delivery.handler);
1393
- for (const key of [...this.#pending.keys()].filter(
1394
- (pending) => pending.startsWith(`${delivery.deliveryId}\0`)
1395
- )) {
1396
- this.#pending.delete(key);
1397
- }
1398
- notifier.notify("onDeliveryStarted", { delivery });
1517
+ };
1518
+
1519
+ // src/lease-heartbeat.ts
1520
+ var RENEWALS_PER_LEASE = 3;
1521
+ var LeaseRenewal = class {
1522
+ #dependencies;
1523
+ #target;
1524
+ #confirmedExpiry;
1525
+ #renewing = false;
1526
+ constructor(dependencies, target) {
1527
+ this.#dependencies = dependencies;
1528
+ this.#target = target;
1529
+ this.#confirmedExpiry = target.delivery.leaseExpiresAt.getTime();
1530
+ }
1531
+ /** One beat: aborts once the confirmed expiry passed, else renews unless a renewal is running. */
1532
+ beat() {
1533
+ const { store, clock, notifier, settings } = this.#dependencies;
1534
+ const now = clock.now();
1535
+ if (now.getTime() >= this.#confirmedExpiry) {
1536
+ this.#abortLeaseLost();
1537
+ return;
1538
+ }
1539
+ if (this.#renewing) {
1540
+ return;
1541
+ }
1542
+ this.#renewing = true;
1543
+ const expiry = now.getTime() + settings.leaseDurationMs;
1544
+ store.extendLease(this.#target.delivery, new Date(expiry), now).then((extended) => {
1545
+ if (extended) {
1546
+ this.#confirmedExpiry = expiry;
1547
+ } else {
1548
+ this.#abortLeaseLost(true);
1549
+ }
1550
+ }).catch((error) => notifier.failure("extend-lease", error)).finally(() => {
1551
+ this.#renewing = false;
1552
+ });
1553
+ }
1554
+ #abortLeaseLost(storeConfirmed = false) {
1555
+ const { delivery, controller } = this.#target;
1556
+ controller.abort(
1557
+ new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, { storeConfirmed })
1558
+ );
1559
+ }
1560
+ };
1561
+ function startLeaseHeartbeat(dependencies, target) {
1562
+ const renewal = new LeaseRenewal(dependencies, target);
1563
+ const { clock, settings } = dependencies;
1564
+ return schedule(clock, {
1565
+ run: () => renewal.beat(),
1566
+ delayMs: Math.max(1, Math.floor(settings.leaseDurationMs / RENEWALS_PER_LEASE)),
1567
+ repeat: true,
1568
+ unref: true
1569
+ });
1570
+ }
1571
+
1572
+ // src/dispatch/delivery-executor.ts
1573
+ var DeliveryExecutor = class {
1574
+ #parts;
1575
+ /** Stops the lease renewal (or transactional lease guard) of each delivery whose handler runs. */
1576
+ #leaseKeepers = /* @__PURE__ */ new Map();
1577
+ constructor(parts) {
1578
+ this.#parts = parts;
1579
+ }
1580
+ /** Starts a delivery and tracks it as in flight until it settled. */
1581
+ start(delivery) {
1582
+ const { dependencies, inFlight, onSettled } = this.#parts;
1583
+ const controller = new AbortController();
1584
+ let succeeded = false;
1585
+ const entry = {
1586
+ handler: delivery.handler,
1587
+ controller,
1588
+ settled: Promise.resolve().then(async () => {
1589
+ succeeded = await this.#execute(delivery, controller);
1590
+ }).catch((error) => dependencies.notifier.failure("dispatch", error)).finally(() => {
1591
+ inFlight.delete(entry);
1592
+ onSettled(delivery, succeeded);
1593
+ })
1594
+ };
1595
+ inFlight.add(entry);
1596
+ return entry;
1597
+ }
1598
+ /** Stop gave up: lets every lease run out, so that another process takes the deliveries. */
1599
+ abandon() {
1600
+ for (const stopKeeping of this.#leaseKeepers.values()) {
1601
+ stopKeeping();
1602
+ }
1603
+ this.#leaseKeepers.clear();
1604
+ }
1605
+ /** Runs one delivery. Resolves true when its handler succeeded, so the delivery is done for good. */
1606
+ async #execute(delivery, controller) {
1607
+ const { dependencies, recovery, outcomes } = this.#parts;
1608
+ const { registry, notifier, clock } = dependencies;
1609
+ const registration = registry.get(delivery.handler);
1610
+ recovery.forgetEarlierClaims(delivery);
1611
+ notifier.notify("onDeliveryStarted", { delivery });
1399
1612
  const startedAt = clock.now().getTime();
1400
1613
  if (delivery.attempt > registration.retry.attempts) {
1401
- await this.#bury(
1614
+ await outcomes.bury(
1402
1615
  delivery,
1403
- new RelayBusError(
1616
+ new AttemptsExhaustedError(
1404
1617
  `Delivery reached attempt ${delivery.attempt} of ${registration.retry.attempts} after its lease expired`
1405
1618
  )
1406
1619
  );
@@ -1408,37 +1621,54 @@ var Dispatcher = class {
1408
1621
  }
1409
1622
  let stopHeartbeat = () => void 0;
1410
1623
  try {
1411
- stopHeartbeat = registration.transactional ? await this.#guardTransactionalLease(registration, delivery, controller) : this.#startHeartbeat(delivery, controller);
1412
- const outcome = await this.#runHandler(registration, delivery, controller);
1413
- const durationMs = clock.now().getTime() - startedAt;
1414
- if (outcome === "completed") {
1415
- notifier.notify("onDeliverySucceeded", { delivery, durationMs });
1416
- } else {
1417
- this.#retain({
1418
- delivery,
1419
- settle: () => this.#complete(delivery),
1420
- applied: () => notifier.notify("onDeliverySucceeded", { delivery, durationMs })
1421
- });
1422
- }
1624
+ stopHeartbeat = registration.transactional ? await this.#guardTransactionalLease(registration, { delivery, controller }) : startLeaseHeartbeat(dependencies, { delivery, controller });
1625
+ this.#leaseKeepers.set(delivery, stopHeartbeat);
1626
+ const outcome = await this.#runHandler(registration, { delivery, controller });
1627
+ this.#reportSuccess(delivery, { outcome, durationMs: clock.now().getTime() - startedAt });
1423
1628
  return true;
1424
1629
  } catch (error) {
1425
- if (error instanceof LeaseLostError) {
1426
- notifier.notify("onLeaseLost", { delivery });
1427
- if (!error.storeConfirmed) {
1428
- await this.#giveBack(registration, delivery, error);
1429
- }
1430
- } else if (error instanceof DispatcherStoppedError) {
1431
- await this.#release(delivery);
1432
- } else {
1433
- await this.#fail(registration, delivery, error);
1630
+ if (this.#parts.shutdown.abandoned) {
1631
+ return false;
1632
+ }
1633
+ const recording = this.#recordFailure(registration, { delivery, error });
1634
+ if (recording !== void 0) {
1635
+ await recording;
1434
1636
  }
1435
1637
  return false;
1436
1638
  } finally {
1437
1639
  stopHeartbeat();
1640
+ this.#leaseKeepers.delete(delivery);
1641
+ }
1642
+ }
1643
+ /** Reports a success now, or once a completion the store did not confirm is recovered. */
1644
+ #reportSuccess(delivery, { outcome, durationMs }) {
1645
+ const { dependencies, outcomes, recovery } = this.#parts;
1646
+ const { notifier } = dependencies;
1647
+ if (outcome === "completed") {
1648
+ notifier.notify("onDeliverySucceeded", { delivery, durationMs });
1649
+ } else {
1650
+ recovery.retain({
1651
+ delivery,
1652
+ settle: () => outcomes.complete(delivery),
1653
+ applied: () => notifier.notify("onDeliverySucceeded", { delivery, durationMs })
1654
+ });
1438
1655
  }
1439
1656
  }
1440
- async #runHandler(registration, delivery, controller) {
1441
- const { store, clock } = this.#dependencies;
1657
+ /** Records a failed run; undefined when there is nothing to record (the store confirmed the lost lease). */
1658
+ #recordFailure(registration, { delivery, error }) {
1659
+ const { dependencies, outcomes } = this.#parts;
1660
+ if (error instanceof LeaseLostError) {
1661
+ dependencies.notifier.notify("onLeaseLost", { delivery });
1662
+ return error.storeConfirmed ? void 0 : outcomes.giveBack(registration, { delivery, error });
1663
+ }
1664
+ if (error instanceof DispatcherStoppedError) {
1665
+ return outcomes.release(delivery);
1666
+ }
1667
+ return outcomes.fail(registration, { delivery, error });
1668
+ }
1669
+ async #runHandler(registration, { delivery, controller }) {
1670
+ const { dependencies, invocation, outcomes } = this.#parts;
1671
+ const { store, clock } = dependencies;
1442
1672
  const payload = await validatePayload(
1443
1673
  delivery.event.type,
1444
1674
  registration.schema,
@@ -1447,7 +1677,7 @@ var Dispatcher = class {
1447
1677
  const event = { ...delivery.event, payload };
1448
1678
  if (registration.transactional) {
1449
1679
  await store.transaction(async (transaction) => {
1450
- await this.#invoke(registration, event, delivery, controller, transaction);
1680
+ await invocation.invoke({ registration, event, delivery, controller, transaction });
1451
1681
  if (!await store.complete(delivery, clock.now(), transaction)) {
1452
1682
  throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, {
1453
1683
  storeConfirmed: true
@@ -1456,26 +1686,154 @@ var Dispatcher = class {
1456
1686
  });
1457
1687
  return "completed";
1458
1688
  }
1459
- await this.#invoke(registration, event, delivery, controller, void 0);
1460
- return this.#confirmCompletion(delivery);
1689
+ await invocation.invoke({ registration, event, delivery, controller, transaction: void 0 });
1690
+ return outcomes.confirmCompletion(delivery);
1691
+ }
1692
+ /**
1693
+ * Transactional handlers get no renewals: a renewal would write the delivery
1694
+ * the open transaction completes, which databases with snapshot isolation
1695
+ * (MongoDB) turn into a write conflict that reruns the handler. Instead the
1696
+ * lease is extended once to cover the handler timeout, and the handler is
1697
+ * aborted when that lease ends.
1698
+ */
1699
+ async #guardTransactionalLease(registration, { delivery, controller }) {
1700
+ const { store, clock, settings } = this.#parts.dependencies;
1701
+ const now = clock.now();
1702
+ let expiry = delivery.leaseExpiresAt.getTime();
1703
+ if (registration.timeoutMs !== null && now.getTime() + registration.timeoutMs >= expiry) {
1704
+ expiry = now.getTime() + registration.timeoutMs + settings.leaseDurationMs;
1705
+ if (!await store.extendLease(delivery, new Date(expiry), now)) {
1706
+ throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, {
1707
+ storeConfirmed: true
1708
+ });
1709
+ }
1710
+ }
1711
+ return schedule(clock, {
1712
+ run: () => {
1713
+ controller.abort(new LeaseLostError(`Lease on delivery ${delivery.deliveryId} expired`));
1714
+ },
1715
+ delayMs: Math.max(0, expiry - clock.now().getTime()),
1716
+ unref: true
1717
+ });
1718
+ }
1719
+ };
1720
+
1721
+ // src/retry-policy.ts
1722
+ var DEFAULT_RETRY_POLICY = {
1723
+ attempts: 10,
1724
+ initialDelayMs: 1e3,
1725
+ factor: 2,
1726
+ maxDelayMs: 6e5,
1727
+ jitter: "full",
1728
+ retryIf: void 0
1729
+ };
1730
+ var DEFAULT_ROUTE_RETRY_POLICY = {
1731
+ attempts: Number.POSITIVE_INFINITY,
1732
+ initialDelayMs: 1e3,
1733
+ factor: 2,
1734
+ maxDelayMs: 3e5,
1735
+ jitter: "full",
1736
+ retryIf: void 0
1737
+ };
1738
+ var JITTER_STRATEGIES = ["full", "equal", "none"];
1739
+ function createRetryPolicy(options, base = DEFAULT_RETRY_POLICY) {
1740
+ const policy = mergeRetryPolicy(options, base);
1741
+ assertValidRetryPolicy(policy);
1742
+ return policy;
1743
+ }
1744
+ function mergeRetryPolicy(options, base) {
1745
+ const overrides = options ?? {};
1746
+ const initialDelayMs = overrides.initialDelay === void 0 ? base.initialDelayMs : Milliseconds.of(overrides.initialDelay, "retry.initialDelay");
1747
+ return {
1748
+ attempts: overrides.attempts ?? base.attempts,
1749
+ initialDelayMs,
1750
+ factor: overrides.factor ?? base.factor,
1751
+ maxDelayMs: overrides.maxDelay === void 0 ? (
1752
+ // Raising only the initial delay must not trip the inherited cap.
1753
+ Math.max(base.maxDelayMs, initialDelayMs)
1754
+ ) : Milliseconds.of(overrides.maxDelay, "retry.maxDelay"),
1755
+ jitter: overrides.jitter ?? base.jitter,
1756
+ retryIf: overrides.retryIf ?? base.retryIf
1757
+ };
1758
+ }
1759
+ function assertValidRetryPolicy(policy) {
1760
+ if (policy.attempts !== Number.POSITIVE_INFINITY && (!Number.isInteger(policy.attempts) || policy.attempts < 1)) {
1761
+ throw new ConfigurationError(
1762
+ `retry.attempts must be a positive integer or Infinity, received ${policy.attempts}`
1763
+ );
1764
+ }
1765
+ if (!Number.isFinite(policy.factor) || policy.factor < 1) {
1766
+ throw new ConfigurationError(`retry.factor must be at least 1, received ${policy.factor}`);
1767
+ }
1768
+ if (policy.maxDelayMs < policy.initialDelayMs) {
1769
+ throw new ConfigurationError("retry.maxDelay must not be smaller than retry.initialDelay");
1770
+ }
1771
+ if (!JITTER_STRATEGIES.includes(policy.jitter)) {
1772
+ throw new ConfigurationError(
1773
+ `retry.jitter must be one of ${JITTER_STRATEGIES.join(", ")}, received '${String(policy.jitter)}'`
1774
+ );
1775
+ }
1776
+ }
1777
+ function computeRetryDelay(policy, failedAttempts, random = Math.random) {
1778
+ const exponential = policy.initialDelayMs * policy.factor ** (failedAttempts - 1);
1779
+ const capped = Math.min(policy.maxDelayMs, exponential);
1780
+ switch (policy.jitter) {
1781
+ case "full":
1782
+ return Math.round(random() * capped);
1783
+ case "equal":
1784
+ return Math.round(capped / 2 + random() * capped / 2);
1785
+ case "none":
1786
+ return Math.round(capped);
1787
+ }
1788
+ }
1789
+ function isRetryableError(policy, error) {
1790
+ if (error instanceof NonRetryableError) {
1791
+ return false;
1792
+ }
1793
+ if (policy.retryIf === void 0) {
1794
+ return true;
1795
+ }
1796
+ try {
1797
+ return policy.retryIf(error);
1798
+ } catch {
1799
+ return true;
1800
+ }
1801
+ }
1802
+
1803
+ // src/dispatch/delivery-outcomes.ts
1804
+ var FIRST_COMPLETION_RETRY_MS = 50;
1805
+ var SECOND_COMPLETION_RETRY_MS = 250;
1806
+ var LAST_COMPLETION_RETRY_MS = 1e3;
1807
+ var COMPLETION_RETRY_DELAYS_MS = [
1808
+ FIRST_COMPLETION_RETRY_MS,
1809
+ SECOND_COMPLETION_RETRY_MS,
1810
+ LAST_COMPLETION_RETRY_MS
1811
+ ];
1812
+ var DeliveryOutcomes = class {
1813
+ #parts;
1814
+ constructor(parts) {
1815
+ this.#parts = parts;
1461
1816
  }
1462
1817
  /**
1463
1818
  * Records a completion, retrying transient store errors while the lease is
1464
1819
  * still held. Gives up to lease expiry, which reruns the handler: at-least-once.
1465
1820
  */
1466
- async #confirmCompletion(delivery) {
1467
- const { notifier } = this.#dependencies;
1821
+ async confirmCompletion(delivery) {
1822
+ const { dependencies, shutdown, sleep } = this.#parts;
1468
1823
  for (let attempt = 0; ; attempt += 1) {
1824
+ if (shutdown.abandoned) {
1825
+ return "unconfirmed";
1826
+ }
1469
1827
  let completed;
1470
1828
  try {
1471
- completed = await this.#complete(delivery);
1829
+ completed = await this.complete(delivery);
1472
1830
  } catch (error) {
1473
1831
  const delay = COMPLETION_RETRY_DELAYS_MS[attempt];
1474
1832
  if (delay === void 0) {
1475
- notifier.failure("complete", error);
1833
+ dependencies.notifier.failure("complete", error);
1476
1834
  return "unconfirmed";
1477
1835
  }
1478
- await this.#sleep(delay);
1836
+ await sleep(delay);
1479
1837
  continue;
1480
1838
  }
1481
1839
  if (!completed) {
@@ -1487,46 +1845,159 @@ var Dispatcher = class {
1487
1845
  }
1488
1846
  }
1489
1847
  /** Records one completion, grouped with its neighbours when the store can complete many at once. */
1490
- #complete(delivery) {
1491
- const { store, clock } = this.#dependencies;
1492
- return this.#completions === void 0 ? store.complete(delivery, clock.now()) : this.#completions.submit(delivery);
1848
+ complete(delivery) {
1849
+ const { dependencies, completions } = this.#parts;
1850
+ const { store, clock } = dependencies;
1851
+ return completions === void 0 ? store.complete(delivery, clock.now()) : completions.submit(delivery);
1493
1852
  }
1494
- async #invoke(registration, event, delivery, controller, transaction) {
1495
- const context = {
1496
- deliveryId: delivery.deliveryId,
1497
- handler: delivery.handler,
1498
- attempt: delivery.attempt,
1499
- maxAttempts: registration.retry.attempts,
1500
- signal: controller.signal,
1501
- transaction
1502
- };
1853
+ async fail(registration, { delivery, error }) {
1854
+ const policy = registration.retry;
1855
+ if (delivery.attempt >= policy.attempts || !isRetryableError(policy, error)) {
1856
+ await this.bury(delivery, error);
1857
+ return;
1858
+ }
1859
+ await this.#reschedule(registration, { delivery, error });
1860
+ }
1861
+ /**
1862
+ * A worker that only noticed locally that its lease ran out may still own the delivery in the
1863
+ * store, where other deliveries of its lane keep renewing the lane: it must not stay in flight
1864
+ * for ever. The attempt took longer than its lease, so it counts as a failed attempt (an
1865
+ * exhausted delivery is dead-lettered, whatever a custom `retryIf` says): a handler that always
1866
+ * outlives its lease cannot run for ever at attempt 1. If the lease is gone for real the store
1867
+ * refuses and nothing changes. The lost lease was reported already.
1868
+ */
1869
+ async giveBack(registration, { delivery, error }) {
1870
+ if (delivery.attempt >= registration.retry.attempts) {
1871
+ await this.bury(delivery, error);
1872
+ } else {
1873
+ await this.#reschedule(registration, { delivery, error });
1874
+ }
1875
+ }
1876
+ bury(delivery, error) {
1877
+ const { store, clock, notifier } = this.#parts.dependencies;
1878
+ const errorInfo = toErrorInfo(error);
1879
+ return this.#change({
1880
+ operation: "bury",
1881
+ delivery,
1882
+ write: () => store.bury(delivery, errorInfo, clock.now()),
1883
+ rewrite: () => store.bury(delivery, errorInfo, clock.now()),
1884
+ applied: () => notifier.notify("onDeliveryDead", { delivery, error: errorInfo })
1885
+ });
1886
+ }
1887
+ release(delivery) {
1888
+ const { store, clock, notifier } = this.#parts.dependencies;
1889
+ return this.#change({
1890
+ operation: "release",
1891
+ delivery,
1892
+ write: () => store.release(delivery, clock.now()),
1893
+ rewrite: () => store.release(delivery, clock.now()),
1894
+ applied: () => notifier.notify("onDeliveryReleased", { delivery })
1895
+ });
1896
+ }
1897
+ /** Schedules the next attempt with the retry policy's backoff. */
1898
+ #reschedule(registration, { delivery, error }) {
1899
+ const { store, clock, random, notifier } = this.#parts.dependencies;
1900
+ const policy = registration.retry;
1901
+ const errorInfo = toErrorInfo(error);
1902
+ const now = clock.now();
1903
+ const availableAt = new Date(
1904
+ now.getTime() + computeRetryDelay(policy, delivery.attempt, random)
1905
+ );
1906
+ const write = () => store.reschedule(delivery, availableAt, errorInfo, now);
1907
+ return this.#change({
1908
+ operation: "reschedule",
1909
+ delivery,
1910
+ write,
1911
+ rewrite: write,
1912
+ applied: () => notifier.notify("onDeliveryRetryScheduled", { delivery, error: errorInfo, availableAt })
1913
+ });
1914
+ }
1915
+ /** Writes a state change; reports it, or the lost lease, or retains it when the store failed. */
1916
+ async #change(change) {
1917
+ const { dependencies, recovery } = this.#parts;
1918
+ const { delivery } = change;
1919
+ let changed;
1920
+ try {
1921
+ changed = await change.write();
1922
+ } catch (storeError) {
1923
+ dependencies.notifier.failure(change.operation, storeError);
1924
+ recovery.retain({ delivery, settle: change.rewrite, applied: change.applied });
1925
+ return;
1926
+ }
1927
+ if (changed) {
1928
+ change.applied();
1929
+ } else {
1930
+ dependencies.notifier.notify("onLeaseLost", { delivery });
1931
+ }
1932
+ }
1933
+ };
1934
+ var storage = new AsyncLocalStorage();
1935
+ function runInScope(scope, work) {
1936
+ return storage.run({ ...storage.getStore(), ...scope }, work);
1937
+ }
1938
+ function runInIsolatedScope(scope, work) {
1939
+ return storage.run(scope, work);
1940
+ }
1941
+ function getCurrentScope() {
1942
+ return storage.getStore();
1943
+ }
1944
+
1945
+ // src/dispatch/handler-invocation.ts
1946
+ function rejectOnAbort(signal) {
1947
+ return new Promise((_, reject) => {
1948
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
1949
+ });
1950
+ }
1951
+ var HandlerInvocation = class {
1952
+ #dependencies;
1953
+ #timers;
1954
+ constructor(dependencies, timers) {
1955
+ this.#dependencies = dependencies;
1956
+ this.#timers = timers;
1957
+ }
1958
+ async invoke(input) {
1959
+ const { registration, event, delivery, controller, transaction } = input;
1960
+ const context = contextOf(input);
1503
1961
  const scope = transaction === void 0 ? { event } : { event, transaction };
1504
1962
  if (controller.signal.aborted) {
1505
1963
  throw controller.signal.reason;
1506
1964
  }
1965
+ const handle = () => runInIsolatedScope(scope, () => registration.handle(event, context));
1966
+ const { observation } = this.#dependencies;
1507
1967
  const work = Promise.resolve().then(
1508
- () => runInIsolatedScope(scope, () => registration.handle(event, context))
1968
+ observation === void 0 ? handle : () => observation.process({ registration, delivery }, handle)
1509
1969
  );
1510
1970
  work.catch(() => void 0);
1511
- let timer;
1512
- if (registration.timeoutMs !== null) {
1513
- const timeoutMs = registration.timeoutMs;
1514
- timer = setTimeout(() => {
1515
- controller.abort(
1516
- new HandlerTimeoutError(
1517
- `Handler '${registration.id}' did not settle within ${timeoutMs}ms`
1518
- )
1519
- );
1520
- }, timeoutMs);
1521
- }
1971
+ const cancelTimeout = this.#startTimeout(registration, controller);
1522
1972
  try {
1523
- await Promise.race([work, rejectOnAbort(controller.signal)]);
1973
+ const settled = Promise.race([work, rejectOnAbort(controller.signal)]);
1974
+ await (observation === void 0 ? settled : observation.observe({ registration, delivery }, settled));
1524
1975
  } catch (error) {
1525
- await this.#awaitAbandonedHandler(work, error, transaction !== void 0);
1976
+ await this.#awaitAbandonedHandler(work, {
1977
+ reason: error,
1978
+ transactional: transaction !== void 0
1979
+ });
1526
1980
  throw error;
1527
1981
  } finally {
1528
- clearTimeout(timer);
1982
+ cancelTimeout?.();
1983
+ }
1984
+ }
1985
+ /** Aborts the handler when its timeout passes; undefined for a handler without one. */
1986
+ #startTimeout(registration, controller) {
1987
+ if (registration.timeoutMs === null) {
1988
+ return void 0;
1529
1989
  }
1990
+ const timeoutMs = registration.timeoutMs;
1991
+ return this.#timers.start({
1992
+ run: () => {
1993
+ controller.abort(
1994
+ new HandlerTimeoutError(
1995
+ `Handler '${registration.id}' did not settle within ${timeoutMs}ms`
1996
+ )
1997
+ );
1998
+ },
1999
+ delayMs: timeoutMs
2000
+ });
1530
2001
  }
1531
2002
  /**
1532
2003
  * After an abort the handler may still be running. Waiting for it keeps a
@@ -1535,7 +2006,7 @@ var Dispatcher = class {
1535
2006
  * Transactional handlers are always awaited; others for one lease duration,
1536
2007
  * except at shutdown, which already waited.
1537
2008
  */
1538
- async #awaitAbandonedHandler(work, reason, transactional) {
2009
+ async #awaitAbandonedHandler(work, { reason, transactional }) {
1539
2010
  const settledWork = work.then(
1540
2011
  () => void 0,
1541
2012
  () => void 0
@@ -1548,413 +2019,1264 @@ var Dispatcher = class {
1548
2019
  return;
1549
2020
  }
1550
2021
  const deadline = Date.now() + this.#dependencies.settings.leaseDurationMs;
1551
- await this.#waitUntil(settledWork, deadline);
1552
- }
1553
- /**
1554
- * Transactional handlers get no renewals: a renewal would write the delivery
1555
- * the open transaction completes, which databases with snapshot isolation
1556
- * (MongoDB) turn into a write conflict that reruns the handler. Instead the
1557
- * lease is extended once to cover the handler timeout, and the handler is
1558
- * aborted when that lease ends.
1559
- */
1560
- async #guardTransactionalLease(registration, delivery, controller) {
1561
- const { store, clock, settings } = this.#dependencies;
1562
- const now = clock.now();
1563
- let expiry = delivery.leaseExpiresAt.getTime();
1564
- if (registration.timeoutMs !== null && now.getTime() + registration.timeoutMs >= expiry) {
1565
- expiry = now.getTime() + registration.timeoutMs + settings.leaseDurationMs;
1566
- if (!await store.extendLease(delivery, new Date(expiry), now)) {
1567
- throw new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, {
1568
- storeConfirmed: true
1569
- });
1570
- }
1571
- }
1572
- const timer = setTimeout(
1573
- () => {
1574
- controller.abort(new LeaseLostError(`Lease on delivery ${delivery.deliveryId} expired`));
1575
- },
1576
- Math.max(0, expiry - clock.now().getTime())
1577
- );
1578
- timer.unref();
1579
- return () => clearTimeout(timer);
2022
+ await waitUntil(this.#dependencies.clock, { work: settledWork, deadline });
1580
2023
  }
1581
- /**
1582
- * Renews the lease every third of its duration. A worker that cannot renew
1583
- * aborts its handler once the last confirmed expiry passes, because another
1584
- * worker may then claim the delivery (self-fencing).
1585
- */
1586
- #startHeartbeat(delivery, controller) {
1587
- const { store, clock, notifier, settings } = this.#dependencies;
1588
- let confirmedExpiry = delivery.leaseExpiresAt.getTime();
1589
- let renewing = false;
1590
- const abortLeaseLost = (storeConfirmed = false) => {
1591
- controller.abort(
1592
- new LeaseLostError(`Lease on delivery ${delivery.deliveryId} was lost`, { storeConfirmed })
1593
- );
1594
- };
1595
- const timer = setInterval(
1596
- () => {
1597
- const now = clock.now();
1598
- if (now.getTime() >= confirmedExpiry) {
1599
- abortLeaseLost();
1600
- return;
1601
- }
1602
- if (renewing) {
1603
- return;
2024
+ };
2025
+ function contextOf(input) {
2026
+ const { registration, delivery, controller, transaction } = input;
2027
+ return {
2028
+ deliveryId: delivery.deliveryId,
2029
+ handler: delivery.handler,
2030
+ attempt: delivery.attempt,
2031
+ maxAttempts: registration.retry.attempts,
2032
+ signal: controller.signal,
2033
+ transaction
2034
+ };
2035
+ }
2036
+ async function waitUntil(clock, { work, deadline }) {
2037
+ let cancel;
2038
+ const timedOut = new Promise((resolve) => {
2039
+ cancel = schedule(clock, {
2040
+ run: () => resolve("timeout"),
2041
+ delayMs: Math.max(0, deadline - Date.now())
2042
+ });
2043
+ });
2044
+ const outcome = await Promise.race([work.then(() => "done"), timedOut]);
2045
+ cancel?.();
2046
+ return outcome;
2047
+ }
2048
+
2049
+ // src/dispatch/in-flight-deliveries.ts
2050
+ var InFlightDeliveries = class {
2051
+ #deliveries = /* @__PURE__ */ new Set();
2052
+ get size() {
2053
+ return this.#deliveries.size;
2054
+ }
2055
+ add(delivery) {
2056
+ this.#deliveries.add(delivery);
2057
+ }
2058
+ delete(delivery) {
2059
+ this.#deliveries.delete(delivery);
2060
+ }
2061
+ /** How many of the deliveries run a handler of `registration`. */
2062
+ countOf(registration) {
2063
+ let count = 0;
2064
+ for (const delivery of this.#deliveries) {
2065
+ if (registration.claimIds.includes(delivery.handler)) {
2066
+ count += 1;
2067
+ }
2068
+ }
2069
+ return count;
2070
+ }
2071
+ /** Settles once every delivery running now settled, whatever its outcome. */
2072
+ allSettled() {
2073
+ return Promise.allSettled([...this.#deliveries].map((delivery) => delivery.settled));
2074
+ }
2075
+ abortAll(reason) {
2076
+ for (const delivery of this.#deliveries) {
2077
+ delivery.controller.abort(reason);
2078
+ }
2079
+ }
2080
+ };
2081
+
2082
+ // src/dispatch/settlement-recovery.ts
2083
+ function pendingKeyOf(delivery) {
2084
+ return DeliveryLeaseIdentity.of(delivery.deliveryId, delivery.leaseToken);
2085
+ }
2086
+ var SettlementRecovery = class {
2087
+ #dependencies;
2088
+ #shutdown;
2089
+ #pending = /* @__PURE__ */ new Map();
2090
+ #recoveryTimer;
2091
+ #recovery;
2092
+ constructor(dependencies, shutdown) {
2093
+ this.#dependencies = dependencies;
2094
+ this.#shutdown = shutdown;
2095
+ }
2096
+ retain(entry) {
2097
+ if (this.#shutdown.abandoned) {
2098
+ return;
2099
+ }
2100
+ this.#pending.set(pendingKeyOf(entry.delivery), entry);
2101
+ this.#recoveryTimer ??= schedule(this.#dependencies.clock, {
2102
+ run: () => void this.#recoverSettlements(),
2103
+ delayMs: this.#dependencies.settings.pollIntervalMs,
2104
+ repeat: true,
2105
+ unref: true
2106
+ });
2107
+ }
2108
+ /** A new claim of this delivery makes whatever was pending for its earlier claims moot. */
2109
+ forgetEarlierClaims(delivery) {
2110
+ for (const key of [...this.#pending.keys()].filter(
2111
+ (pending) => pending.startsWith(DeliveryLeaseIdentity.prefixOf(delivery.deliveryId))
2112
+ )) {
2113
+ this.#pending.delete(key);
2114
+ }
2115
+ }
2116
+ /**
2117
+ * Gives the settlements still pending one last try and forgets them. A pass of the timer may be inside the
2118
+ * store: it ends before the last pass starts. A store that does not answer before the deadline gets no
2119
+ * further call, since it may be closed right after.
2120
+ */
2121
+ async close(deadline) {
2122
+ this.#stopTimer();
2123
+ const lastTry = (async () => {
2124
+ await this.#recovery;
2125
+ await this.#recoverSettlements();
2126
+ })();
2127
+ await deadline.race(lastTry);
2128
+ this.#pending.clear();
2129
+ }
2130
+ /** Stop gave up: forgets every settlement still to retry, which may reach a closed store. */
2131
+ abandon() {
2132
+ this.#stopTimer();
2133
+ this.#pending.clear();
2134
+ }
2135
+ #stopTimer() {
2136
+ this.#recoveryTimer?.();
2137
+ this.#recoveryTimer = void 0;
2138
+ }
2139
+ /**
2140
+ * Retries every pending settlement once; a pass that is already running is the one returned. A store that
2141
+ * still fails leaves the settlement for the next attempt (it was reported when it first failed).
2142
+ */
2143
+ #recoverSettlements() {
2144
+ this.#recovery ??= this.#recoverAll().finally(() => {
2145
+ this.#recovery = void 0;
2146
+ });
2147
+ return this.#recovery;
2148
+ }
2149
+ async #recoverAll() {
2150
+ for (const [key, entry] of [...this.#pending]) {
2151
+ if (this.#pending.get(key) !== entry) {
2152
+ continue;
2153
+ }
2154
+ try {
2155
+ const applied = await entry.settle();
2156
+ this.#pending.delete(pendingKeyOf(entry.delivery));
2157
+ if (applied) {
2158
+ entry.applied();
2159
+ } else {
2160
+ this.#dependencies.notifier.notify("onLeaseLost", { delivery: entry.delivery });
1604
2161
  }
1605
- renewing = true;
1606
- const expiry = now.getTime() + settings.leaseDurationMs;
1607
- store.extendLease(delivery, new Date(expiry), now).then((extended) => {
1608
- if (extended) {
1609
- confirmedExpiry = expiry;
1610
- } else {
1611
- abortLeaseLost(true);
1612
- }
1613
- }).catch((error) => notifier.failure("extend-lease", error)).finally(() => {
1614
- renewing = false;
2162
+ } catch {
2163
+ }
2164
+ }
2165
+ }
2166
+ };
2167
+
2168
+ // src/completion-batcher.ts
2169
+ var CompletionBatcher = class {
2170
+ #store;
2171
+ #clock;
2172
+ #settings;
2173
+ #runningCount;
2174
+ #pending = [];
2175
+ #timer;
2176
+ #closing = false;
2177
+ /**
2178
+ * @param runningCount Number of deliveries the dispatcher is running right
2179
+ * now, including the ones waiting in this batcher.
2180
+ */
2181
+ constructor(store, clock, settings, runningCount) {
2182
+ this.#store = store;
2183
+ this.#clock = clock;
2184
+ this.#settings = settings;
2185
+ this.#runningCount = runningCount;
2186
+ }
2187
+ /**
2188
+ * Queues a completion. Resolves with whether the lease was still held;
2189
+ * rejects with the store error when the batch could not be written.
2190
+ */
2191
+ submit(lease) {
2192
+ return new Promise((resolve, reject) => {
2193
+ this.#pending.push({ lease, resolve, reject });
2194
+ if (this.#closing || this.#pending.length >= this.#settings.maxBatchSize || this.#pending.length >= this.#runningCount()) {
2195
+ this.#flush();
2196
+ } else {
2197
+ this.#timer ??= schedule(this.#clock, {
2198
+ run: () => this.#flush(),
2199
+ delayMs: this.#settings.windowMs
1615
2200
  });
2201
+ }
2202
+ });
2203
+ }
2204
+ /**
2205
+ * Shutdown has begun (or ended): while closing, nothing waits for company any more, so the completions
2206
+ * already queued are written now and later ones at once.
2207
+ */
2208
+ setClosing(closing) {
2209
+ this.#closing = closing;
2210
+ if (closing && this.#pending.length > 0) {
2211
+ this.#flush();
2212
+ }
2213
+ }
2214
+ #flush() {
2215
+ this.#timer?.();
2216
+ this.#timer = void 0;
2217
+ const batch = this.#pending;
2218
+ this.#pending = [];
2219
+ Promise.resolve().then(
2220
+ () => this.#store.completeMany(
2221
+ batch.map((entry) => entry.lease),
2222
+ this.#clock.now()
2223
+ )
2224
+ ).then(
2225
+ (outcomes) => batch.forEach((entry, index) => entry.resolve(outcomes[index])),
2226
+ (error) => batch.forEach((entry) => entry.reject(error))
2227
+ );
2228
+ }
2229
+ };
2230
+
2231
+ // src/delivery-timers.ts
2232
+ var DeliveryTimers = class {
2233
+ #clock;
2234
+ /** Cancel of every timer still waiting, with what its cancellation must also do. */
2235
+ #waiting = /* @__PURE__ */ new Map();
2236
+ constructor(clock) {
2237
+ this.#clock = clock;
2238
+ }
2239
+ /** Runs `task.run` after `task.delayMs`; returns what cancels it. */
2240
+ start(task) {
2241
+ return this.#start(task, () => void 0);
2242
+ }
2243
+ /** Resolves after `milliseconds`, or at once when {@link DeliveryTimers.cancelAll} runs. */
2244
+ sleep(milliseconds) {
2245
+ return new Promise((resolve) => {
2246
+ this.#start({ run: resolve, delayMs: milliseconds }, resolve);
2247
+ });
2248
+ }
2249
+ cancelAll() {
2250
+ for (const [cancel, onCancel] of [...this.#waiting]) {
2251
+ cancel();
2252
+ onCancel();
2253
+ }
2254
+ }
2255
+ #start(task, onCancel) {
2256
+ const cancel = () => {
2257
+ cancelTimer();
2258
+ this.#waiting.delete(cancel);
2259
+ };
2260
+ const cancelTimer = schedule(this.#clock, {
2261
+ run: () => {
2262
+ this.#waiting.delete(cancel);
2263
+ task.run();
1616
2264
  },
1617
- Math.max(1, Math.floor(settings.leaseDurationMs / 3))
2265
+ delayMs: task.delayMs
2266
+ });
2267
+ this.#waiting.set(cancel, onCancel);
2268
+ return cancel;
2269
+ }
2270
+ };
2271
+
2272
+ // src/dispatcher.ts
2273
+ var Dispatcher = class {
2274
+ #dependencies;
2275
+ #inFlight = new InFlightDeliveries();
2276
+ #shutdown = new ShutdownLatch();
2277
+ #timers;
2278
+ #completions;
2279
+ #recovery;
2280
+ #executor;
2281
+ #loop;
2282
+ #looping;
2283
+ #unwatch;
2284
+ #watching;
2285
+ constructor(dependencies) {
2286
+ this.#dependencies = dependencies;
2287
+ this.#timers = new DeliveryTimers(dependencies.clock);
2288
+ this.#completions = this.#createCompletionBatcher();
2289
+ this.#recovery = new SettlementRecovery(dependencies, this.#shutdown);
2290
+ this.#executor = this.#createExecutor();
2291
+ this.#loop = new ClaimLoop({
2292
+ dependencies,
2293
+ inFlight: this.#inFlight,
2294
+ planner: new ClaimPlanner(this.#inFlight, dependencies.settings.batchSize),
2295
+ shutdown: this.#shutdown,
2296
+ start: (delivery) => this.#executor.start(delivery)
2297
+ });
2298
+ }
2299
+ #createCompletionBatcher() {
2300
+ const { store, clock, settings } = this.#dependencies;
2301
+ const { completionBatchWindowMs: windowMs = 0, completionBatchSize: maxBatchSize = 1 } = settings;
2302
+ if (store.completeMany === void 0 || !(windowMs > 0)) {
2303
+ return void 0;
2304
+ }
2305
+ return new CompletionBatcher(
2306
+ { completeMany: (leases, now) => store.completeMany(leases, now) },
2307
+ clock,
2308
+ { windowMs, maxBatchSize },
2309
+ () => this.#inFlight.size
1618
2310
  );
1619
- timer.unref();
1620
- return () => clearInterval(timer);
1621
2311
  }
1622
- async #fail(registration, delivery, error) {
1623
- const policy = registration.retry;
1624
- if (delivery.attempt >= policy.attempts || !isRetryableError(policy, error)) {
1625
- await this.#bury(delivery, error);
2312
+ #createExecutor() {
2313
+ const dependencies = this.#dependencies;
2314
+ const outcomes = new DeliveryOutcomes({
2315
+ dependencies,
2316
+ recovery: this.#recovery,
2317
+ shutdown: this.#shutdown,
2318
+ completions: this.#completions,
2319
+ sleep: dependencies.sleep ?? ((milliseconds) => this.#timers.sleep(milliseconds))
2320
+ });
2321
+ return new DeliveryExecutor({
2322
+ dependencies,
2323
+ inFlight: this.#inFlight,
2324
+ recovery: this.#recovery,
2325
+ outcomes,
2326
+ invocation: new HandlerInvocation(dependencies, this.#timers),
2327
+ timers: this.#timers,
2328
+ shutdown: this.#shutdown,
2329
+ onSettled: (delivery, succeeded) => this.#loop.onSettled(delivery, succeeded)
2330
+ });
2331
+ }
2332
+ get isRunning() {
2333
+ return this.#loop.isRunning;
2334
+ }
2335
+ get inFlightCount() {
2336
+ return this.#inFlight.size;
2337
+ }
2338
+ /**
2339
+ * True while the store reports new deliveries to this dispatcher by itself (see
2340
+ * {@link EventStore.watchNewDeliveries}), from every process: a publisher then has no reason to wake it.
2341
+ */
2342
+ get watchesNewDeliveries() {
2343
+ return this.#unwatch !== void 0;
2344
+ }
2345
+ start() {
2346
+ if (this.#loop.isRunning) {
1626
2347
  return;
1627
2348
  }
1628
- await this.#reschedule(registration, delivery, error);
2349
+ this.#loop.open();
2350
+ this.#completions?.setClosing(false);
2351
+ this.#watching = this.#watchNewDeliveries();
2352
+ this.#looping = this.#loop.run();
2353
+ }
2354
+ /**
2355
+ * Asks for a claim soon, for example right after a publish: at once after a quiet period, otherwise
2356
+ * at the end of a short cooldown that merges the wake-ups arriving meanwhile into one claim.
2357
+ */
2358
+ wake() {
2359
+ this.#loop.wake();
2360
+ }
2361
+ /**
2362
+ * Starts listening to the store's reports of new deliveries. Polling stays the safety net: a store
2363
+ * that cannot listen, or fails to, only costs latency, so a failure is reported and otherwise ignored.
2364
+ */
2365
+ async #watchNewDeliveries() {
2366
+ const { store, notifier } = this.#dependencies;
2367
+ try {
2368
+ const unwatch = await store.watchNewDeliveries?.(() => this.wake());
2369
+ if (unwatch !== void 0 && !this.#loop.isRunning) {
2370
+ await unwatch();
2371
+ return;
2372
+ }
2373
+ this.#unwatch = unwatch;
2374
+ if (unwatch !== void 0) {
2375
+ this.wake();
2376
+ }
2377
+ } catch (error) {
2378
+ notifier.failure("watch", error);
2379
+ }
2380
+ }
2381
+ /** Stops listening to the store's reports of new deliveries. */
2382
+ async #stopWatching() {
2383
+ await this.#watching;
2384
+ const unwatch = this.#unwatch;
2385
+ this.#unwatch = void 0;
2386
+ try {
2387
+ await unwatch?.();
2388
+ } catch (error) {
2389
+ this.#dependencies.notifier.failure("watch", error);
2390
+ }
2391
+ }
2392
+ /**
2393
+ * Stops claiming and waits for running handlers until the drain point of `deadline` (default: the
2394
+ * shutdown timeout from now), then aborts the rest and releases their deliveries for another
2395
+ * process. Deliveries still in flight when the deadline itself passes are abandoned: their leases
2396
+ * expire and they run again elsewhere. Resolves with how many were abandoned.
2397
+ */
2398
+ async stop(deadline) {
2399
+ const { clock, settings } = this.#dependencies;
2400
+ const own = deadline ?? new ShutdownDeadline(clock, settings.shutdownTimeoutMs);
2401
+ try {
2402
+ return await this.#stopWithin(own);
2403
+ } finally {
2404
+ if (deadline === void 0) {
2405
+ own.dispose();
2406
+ }
2407
+ }
2408
+ }
2409
+ async #stopWithin(deadline) {
2410
+ if (!this.#loop.isRunning) {
2411
+ await this.#recovery.close(deadline);
2412
+ return 0;
2413
+ }
2414
+ this.#loop.halt();
2415
+ this.#completions?.setClosing(true);
2416
+ this.#loop.interrupt();
2417
+ await deadline.race(this.#stopWatching());
2418
+ await deadline.race(this.#looping);
2419
+ const settled = this.#inFlight.allSettled();
2420
+ if (await deadline.raceDrain(settled) === "timeout") {
2421
+ this.#inFlight.abortAll(new DispatcherStoppedError("relaybus dispatcher stopped"));
2422
+ if (await deadline.race(settled) === "timeout") {
2423
+ return this.#abandon();
2424
+ }
2425
+ }
2426
+ await this.#recovery.close(deadline);
2427
+ await deadline.race(this.#relinquish());
2428
+ return 0;
2429
+ }
2430
+ /**
2431
+ * The deadline passed with deliveries still in flight (aborted, their release not answered): forgets
2432
+ * them and every settlement still to retry. Whatever they release or complete from now on may reach a
2433
+ * closed store; a delivery whose state change is lost stays leased until the lease expires and then
2434
+ * runs again (at-least-once).
2435
+ */
2436
+ #abandon() {
2437
+ this.#shutdown.abandon();
2438
+ this.#executor.abandon();
2439
+ this.#timers.cancelAll();
2440
+ this.#recovery.abandon();
2441
+ return this.#inFlight.size;
2442
+ }
2443
+ /** Gives lease groups the owner no longer works on back to the store, so other workers need not wait for expiry. */
2444
+ async #relinquish() {
2445
+ const { store, clock, notifier, settings } = this.#dependencies;
2446
+ try {
2447
+ await store.relinquish?.(settings.owner, clock.now());
2448
+ } catch (error) {
2449
+ notifier.failure("relinquish", error);
2450
+ }
2451
+ }
2452
+ /** Claims one batch and resolves when every claimed delivery settled. Returns how many ran. */
2453
+ async processBatch() {
2454
+ const { started } = await this.#loop.claimAndStart();
2455
+ await Promise.allSettled(started.map((delivery) => delivery.settled));
2456
+ return started.length;
2457
+ }
2458
+ };
2459
+
2460
+ // src/handler-registry.ts
2461
+ var HANDLER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
2462
+ var HandlerRegistry = class {
2463
+ #byId = /* @__PURE__ */ new Map();
2464
+ #byClaimId = /* @__PURE__ */ new Map();
2465
+ #byEventType = /* @__PURE__ */ new Map();
2466
+ register(input, defaults) {
2467
+ const { options } = input;
2468
+ if (!HANDLER_ID_PATTERN.test(options.id)) {
2469
+ throw new ConfigurationError(
2470
+ `Handler id '${options.id}' must be 1-255 characters of letters, digits, '.', '_', ':', '/' or '-'`
2471
+ );
2472
+ }
2473
+ const claimIds = [options.id, ...input.legacyIds ?? []];
2474
+ this.#assertUnregistered(claimIds);
2475
+ if (options.concurrency !== void 0) {
2476
+ PositiveInteger.of(options.concurrency, `Concurrency of handler '${options.id}'`);
2477
+ }
2478
+ const registration = {
2479
+ id: options.id,
2480
+ eventTypes: input.eventTypes,
2481
+ claimIds,
2482
+ schema: input.schema,
2483
+ handle: input.handle,
2484
+ retry: createRetryPolicy(options.retry, defaults.retry),
2485
+ timeoutMs: resolveTimeout(options.timeout, {
2486
+ fallback: defaults.timeoutMs,
2487
+ handlerId: options.id
2488
+ }),
2489
+ concurrency: options.concurrency ?? null,
2490
+ transactional: options.transactional ?? false,
2491
+ forwardsToTransport: input.forwardsToTransport
2492
+ };
2493
+ this.#index(registration);
2494
+ return registration;
2495
+ }
2496
+ #assertUnregistered(claimIds) {
2497
+ for (const id of claimIds) {
2498
+ if (this.#byClaimId.has(id)) {
2499
+ throw new ConfigurationError(`Handler id '${id}' is already registered`);
2500
+ }
2501
+ }
2502
+ }
2503
+ #index(registration) {
2504
+ this.#byId.set(registration.id, registration);
2505
+ for (const id of registration.claimIds) {
2506
+ this.#byClaimId.set(id, registration);
2507
+ }
2508
+ for (const eventType of registration.eventTypes) {
2509
+ const forType = this.#byEventType.get(eventType) ?? [];
2510
+ forType.push(registration);
2511
+ this.#byEventType.set(eventType, forType);
2512
+ }
2513
+ }
2514
+ /** The registration that runs deliveries stored under `id`, its own id or a legacy one. */
2515
+ get(id) {
2516
+ return this.#byClaimId.get(id);
2517
+ }
2518
+ forEventType(eventType) {
2519
+ return this.#byEventType.get(eventType) ?? [];
2520
+ }
2521
+ all() {
2522
+ return [...this.#byId.values()];
2523
+ }
2524
+ };
2525
+ function resolveTimeout(timeout, { fallback, handlerId }) {
2526
+ if (timeout === void 0) {
2527
+ return fallback;
2528
+ }
2529
+ if (timeout === false) {
2530
+ return null;
2531
+ }
2532
+ return Milliseconds.forTimer(timeout, `Timeout of handler '${handlerId}'`);
2533
+ }
2534
+
2535
+ // src/notifier.ts
2536
+ var Notifier = class {
2537
+ #observers;
2538
+ #logger;
2539
+ /** Hooks at least one observer implements, so callers skip building events nobody reads. */
2540
+ #implemented;
2541
+ constructor(observers, logger) {
2542
+ this.#observers = observers;
2543
+ this.#logger = logger;
2544
+ this.#implemented = new Set(
2545
+ observers.flatMap((observer) => hookNames(observer))
2546
+ );
2547
+ }
2548
+ /** True when some observer implements `method`. */
2549
+ wants(method) {
2550
+ return this.#implemented.has(method);
2551
+ }
2552
+ notify(method, argument) {
2553
+ for (const observer of this.#observers) {
2554
+ try {
2555
+ observer[method]?.call(
2556
+ observer,
2557
+ argument
2558
+ );
2559
+ } catch (error) {
2560
+ this.#reportObserverFailure(observer, error);
2561
+ }
2562
+ }
2563
+ }
2564
+ failure(stage, error) {
2565
+ const errorInfo = toErrorInfo(error);
2566
+ this.#logger.error(
2567
+ `[Notifier](failure) relaybus ${stage} failed | ${errorInfo.name}: ${errorInfo.message}`
2568
+ );
2569
+ for (const observer of this.#observers) {
2570
+ try {
2571
+ observer.onFailure?.({ stage, error: errorInfo });
2572
+ } catch (observerError) {
2573
+ this.#logObserverFailure(observerError);
2574
+ }
2575
+ }
2576
+ }
2577
+ #reportObserverFailure(failedObserver, error) {
2578
+ this.#logObserverFailure(error);
2579
+ const errorInfo = toErrorInfo(error);
2580
+ for (const observer of this.#observers) {
2581
+ if (observer === failedObserver) {
2582
+ continue;
2583
+ }
2584
+ try {
2585
+ observer.onFailure?.({ stage: "observer", error: errorInfo });
2586
+ } catch (observerError) {
2587
+ this.#logObserverFailure(observerError);
2588
+ }
2589
+ }
2590
+ }
2591
+ #logObserverFailure(error) {
2592
+ const errorInfo = toErrorInfo(error);
2593
+ this.#logger.warn(
2594
+ `[Notifier](logObserverFailure) relaybus observer threw | ${errorInfo.name}: ${errorInfo.message}`
2595
+ );
2596
+ }
2597
+ };
2598
+ function hookNames(observer) {
2599
+ const names = [];
2600
+ for (let target = observer; target !== null && target !== Object.prototype; target = Object.getPrototypeOf(target)) {
2601
+ names.push(
2602
+ ...Object.getOwnPropertyNames(target).filter(
2603
+ (name) => typeof observer[name] === "function"
2604
+ )
2605
+ );
2606
+ }
2607
+ return names;
2608
+ }
2609
+ var DEFAULTS = {
2610
+ concurrency: 10,
2611
+ pollInterval: "1s",
2612
+ leaseDuration: "30s",
2613
+ shutdownTimeout: "30s",
2614
+ completionBatchWindow: "2ms",
2615
+ completionBatchSize: 200,
2616
+ completedRetention: "7d",
2617
+ retentionInterval: "1h",
2618
+ routeTimeoutMs: 3e4,
2619
+ transactionTimeout: "5s"
2620
+ };
2621
+ function oneOf(value, { allowed, name }) {
2622
+ if (!allowed.includes(value)) {
2623
+ throw new ConfigurationError(
2624
+ `${name} must be one of ${allowed.map((option) => `'${option}'`).join(", ")}, received '${String(value)}'`
2625
+ );
2626
+ }
2627
+ return value;
2628
+ }
2629
+ function isDispatcherEnabled(options) {
2630
+ return options.dispatcher?.enabled ?? true;
2631
+ }
2632
+ function resolveBacklogInterval(options) {
2633
+ return options.backlogInterval === void 0 ? void 0 : Milliseconds.forTimer(options.backlogInterval, "backlogInterval");
2634
+ }
2635
+ function resolveTransactionTimeout(options) {
2636
+ return Milliseconds.forTimer(
2637
+ options.transactionTimeout ?? DEFAULTS.transactionTimeout,
2638
+ "transactionTimeout"
2639
+ );
2640
+ }
2641
+ function createHandlerDefaults(options) {
2642
+ return {
2643
+ retry: createRetryPolicy(options.retry),
2644
+ timeoutMs: options.handlerTimeout === void 0 || options.handlerTimeout === false ? null : Milliseconds.forTimer(options.handlerTimeout, "handlerTimeout")
2645
+ };
2646
+ }
2647
+ function createDispatcherSettings(options, generateIdentifier) {
2648
+ const dispatcher = options ?? {};
2649
+ const concurrency = PositiveInteger.of(
2650
+ dispatcher.concurrency ?? DEFAULTS.concurrency,
2651
+ "dispatcher.concurrency"
2652
+ );
2653
+ return {
2654
+ owner: `${hostname()}:${process.pid}:${generateIdentifier()}`,
2655
+ concurrency,
2656
+ batchSize: PositiveInteger.of(dispatcher.batchSize ?? concurrency, "dispatcher.batchSize"),
2657
+ ...createDispatcherTimings(dispatcher)
2658
+ };
2659
+ }
2660
+ function createDispatcherTimings(dispatcher) {
2661
+ return {
2662
+ pollIntervalMs: Milliseconds.forTimer(
2663
+ dispatcher.pollInterval ?? DEFAULTS.pollInterval,
2664
+ "dispatcher.pollInterval"
2665
+ ),
2666
+ leaseDurationMs: Milliseconds.forTimer(
2667
+ dispatcher.leaseDuration ?? DEFAULTS.leaseDuration,
2668
+ "dispatcher.leaseDuration"
2669
+ ),
2670
+ shutdownTimeoutMs: Milliseconds.forTimer(
2671
+ dispatcher.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
2672
+ "dispatcher.shutdownTimeout"
2673
+ ),
2674
+ completionBatchWindowMs: Milliseconds.of(
2675
+ dispatcher.completionBatchWindow ?? DEFAULTS.completionBatchWindow,
2676
+ "dispatcher.completionBatchWindow"
2677
+ ),
2678
+ completionBatchSize: PositiveInteger.of(
2679
+ dispatcher.completionBatchSize ?? DEFAULTS.completionBatchSize,
2680
+ "dispatcher.completionBatchSize"
2681
+ )
2682
+ };
2683
+ }
2684
+ function createRetentionSettings(options) {
2685
+ const completed = options?.completed ?? DEFAULTS.completedRetention;
2686
+ const dead = options?.dead ?? false;
2687
+ return {
2688
+ completedMs: completed === false ? null : Milliseconds.of(completed, "retention.completed"),
2689
+ deadMs: dead === false ? null : Milliseconds.of(dead, "retention.dead"),
2690
+ intervalMs: Milliseconds.forTimer(
2691
+ options?.interval ?? DEFAULTS.retentionInterval,
2692
+ "retention.interval"
2693
+ )
2694
+ };
2695
+ }
2696
+
2697
+ // src/route-groups.ts
2698
+ var TRANSPORT_HANDLER_PREFIX = "relaybus.transport";
2699
+ function groupRoutes(routes) {
2700
+ const groups = /* @__PURE__ */ new Map();
2701
+ for (const route of routes) {
2702
+ const type = routedType(route.event);
2703
+ const destination = route.destination ?? type;
2704
+ const id = `${TRANSPORT_HANDLER_PREFIX}:${route.transport}:${destination}`;
2705
+ const group = groups.get(id);
2706
+ if (group === void 0) {
2707
+ groups.set(id, createGroup(route, { id, destination, type }));
2708
+ } else {
2709
+ addRoute(group, { route, type, destination });
2710
+ }
2711
+ }
2712
+ return [...groups.values()];
2713
+ }
2714
+ function createGroup(route, {
2715
+ id,
2716
+ destination,
2717
+ type
2718
+ }) {
2719
+ return {
2720
+ transport: route.transport,
2721
+ destination,
2722
+ id,
2723
+ eventTypes: [type],
2724
+ legacyIds: [`${id}:${type}`],
2725
+ route,
2726
+ ...resolveSettings(route, id)
2727
+ };
2728
+ }
2729
+ function addRoute(group, {
2730
+ route,
2731
+ type,
2732
+ destination
2733
+ }) {
2734
+ const target = `'${group.destination}' of transport '${group.transport}'`;
2735
+ if (route.transport !== group.transport) {
2736
+ throw new ConfigurationError(
2737
+ `Routes to ${target} and to '${destination}' of transport '${route.transport}' would share the handler id '${group.id}'; rename the transport or the destination`
2738
+ );
2739
+ }
2740
+ if (group.eventTypes.includes(type)) {
2741
+ throw new ConfigurationError(`Event type ${type} is routed to ${target} twice`);
2742
+ }
2743
+ const { retry, timeoutMs } = resolveSettings(route, group.id);
2744
+ if (timeoutMs !== group.timeoutMs || !sameRetryPolicy(retry, group.retry)) {
2745
+ throw new ConfigurationError(
2746
+ `Routes to ${target} must share retry and timeout, because their events are sent in one queue per key | types=${group.eventTypes[0]},${type}`
2747
+ );
2748
+ }
2749
+ group.eventTypes.push(type);
2750
+ group.legacyIds.push(`${group.id}:${type}`);
2751
+ }
2752
+ function resolveSettings(route, id) {
2753
+ return {
2754
+ retry: createRetryPolicy(route.retry, DEFAULT_ROUTE_RETRY_POLICY),
2755
+ timeoutMs: route.timeout === void 0 ? DEFAULTS.routeTimeoutMs : Milliseconds.forTimer(route.timeout, `Timeout of handler '${id}'`)
2756
+ };
2757
+ }
2758
+ function sameRetryPolicy(left, right) {
2759
+ return Object.keys(left).every(
2760
+ (field) => left[field] === right[field]
2761
+ );
2762
+ }
2763
+ function routedType(event) {
2764
+ const type = typeof event === "string" ? event : event.type;
2765
+ assertEventType(type);
2766
+ return type;
2767
+ }
2768
+
2769
+ // src/bus/bus-connections.ts
2770
+ var BusConnections = class {
2771
+ #parts;
2772
+ #transports = /* @__PURE__ */ new Map();
2773
+ #subscriptions;
2774
+ constructor(parts, subscriptions) {
2775
+ this.#parts = parts;
2776
+ this.#subscriptions = subscriptions;
2777
+ }
2778
+ /** Every transport, in the order it was configured. */
2779
+ get transports() {
2780
+ return this.#transports.values();
2781
+ }
2782
+ /** Registers the transports, then the routes, and checks the subscriptions. */
2783
+ register({ transports, routes }) {
2784
+ for (const transport of transports) {
2785
+ if (this.#transports.has(transport.name)) {
2786
+ throw new ConfigurationError(`Transport name '${transport.name}' is used twice`);
2787
+ }
2788
+ this.#transports.set(transport.name, transport);
2789
+ }
2790
+ this.#registerRoutes(routes);
2791
+ for (const subscription of this.#subscriptions) {
2792
+ const transport = this.#requireTransport(subscription.transport);
2793
+ if (transport.subscribe === void 0) {
2794
+ throw new ConfigurationError(`Transport '${transport.name}' cannot subscribe`);
2795
+ }
2796
+ }
2797
+ }
2798
+ /** True while every transport reports itself ready. */
2799
+ allReady() {
2800
+ for (const transport of this.#transports.values()) {
2801
+ if (transport.isReady?.() === false) {
2802
+ return false;
2803
+ }
2804
+ }
2805
+ return true;
2806
+ }
2807
+ /** The inbound subscriptions, each with a transport that can subscribe. */
2808
+ get subscriptions() {
2809
+ return this.#subscriptions;
2810
+ }
2811
+ /** Subscribes one subscription, handing its messages to `receive`; the transport's own promise. */
2812
+ subscribe(subscription, receive) {
2813
+ const transport = this.#transports.get(subscription.transport);
2814
+ return transport.subscribe(
2815
+ subscription.destinations,
2816
+ async (envelope, message) => {
2817
+ await receive(envelope, { transport, destination: message?.destination });
2818
+ },
2819
+ this.#subscriptionHooks(transport)
2820
+ );
2821
+ }
2822
+ /** What a transport may report back while subscribed; undefined when no observer wants it. */
2823
+ #subscriptionHooks(transport) {
2824
+ const { notifier } = this.#parts;
2825
+ if (!notifier.wants("onInvalidMessage")) {
2826
+ return void 0;
2827
+ }
2828
+ return {
2829
+ onInvalidMessage: ({ destination, error }) => {
2830
+ const errorInfo = toErrorInfo(error);
2831
+ notifier.notify("onInvalidMessage", {
2832
+ transport: transport.name,
2833
+ system: transport.system ?? transport.name,
2834
+ destination,
2835
+ reason: errorInfo.message,
2836
+ error: errorInfo
2837
+ });
2838
+ }
2839
+ };
2840
+ }
2841
+ #registerRoutes(routes) {
2842
+ for (const route of routes) {
2843
+ this.#requireTransport(route.transport);
2844
+ }
2845
+ for (const group of groupRoutes(routes)) {
2846
+ this.#registerRoute(group);
2847
+ }
2848
+ }
2849
+ /** Registers one handler for every route to a destination, so a key's events reach it in commit order. */
2850
+ #registerRoute(group) {
2851
+ const transport = this.#transports.get(group.transport);
2852
+ const { route, destination } = group;
2853
+ this.#parts.registry.register(
2854
+ {
2855
+ eventTypes: group.eventTypes,
2856
+ legacyIds: group.legacyIds,
2857
+ schema: void 0,
2858
+ handle: this.#parts.observation?.sender(transport, destination) ?? ((envelope) => transport.publish(envelope, destination)),
2859
+ options: {
2860
+ id: group.id,
2861
+ ...route.retry === void 0 ? {} : { retry: route.retry },
2862
+ ...route.timeout === void 0 ? {} : { timeout: route.timeout }
2863
+ },
2864
+ forwardsToTransport: true
2865
+ },
2866
+ { retry: DEFAULT_ROUTE_RETRY_POLICY, timeoutMs: DEFAULTS.routeTimeoutMs }
2867
+ );
2868
+ }
2869
+ #requireTransport(name) {
2870
+ const transport = this.#transports.get(name);
2871
+ if (transport === void 0) {
2872
+ throw new ConfigurationError(`No transport named '${name}' is configured`);
2873
+ }
2874
+ return transport;
1629
2875
  }
1630
- /** Schedules the next attempt with the retry policy's backoff. */
1631
- async #reschedule(registration, delivery, error) {
1632
- const { store, clock, random, notifier } = this.#dependencies;
1633
- const policy = registration.retry;
1634
- const errorInfo = toErrorInfo(error);
1635
- const now = clock.now();
1636
- const availableAt = new Date(
1637
- now.getTime() + computeRetryDelay(policy, delivery.attempt, random)
1638
- );
1639
- let rescheduled;
2876
+ };
2877
+
2878
+ // src/bus/bus-maintenance.ts
2879
+ var BusMaintenance = class {
2880
+ #parts;
2881
+ #retentionTimer;
2882
+ #backlogTimer;
2883
+ constructor(parts) {
2884
+ this.#parts = parts;
2885
+ }
2886
+ /** Applies the retention policy once. */
2887
+ async purgeExpired() {
2888
+ const { store, clock, notifier, retention } = this.#parts;
2889
+ const now = clock.now().getTime();
2890
+ const { completedMs, deadMs } = retention;
1640
2891
  try {
1641
- rescheduled = await store.reschedule(delivery, availableAt, errorInfo, now);
1642
- } catch (storeError) {
1643
- notifier.failure("reschedule", storeError);
1644
- this.#retain({
1645
- delivery,
1646
- settle: () => store.reschedule(delivery, availableAt, errorInfo, now),
1647
- applied: () => notifier.notify("onDeliveryRetryScheduled", { delivery, error: errorInfo, availableAt })
2892
+ const result = await store.purge({
2893
+ ...completedMs === null ? {} : { completedBefore: new Date(now - completedMs) },
2894
+ ...deadMs === null ? {} : { deadBefore: new Date(now - deadMs) }
1648
2895
  });
1649
- return;
1650
- }
1651
- if (rescheduled) {
1652
- notifier.notify("onDeliveryRetryScheduled", { delivery, error: errorInfo, availableAt });
1653
- } else {
1654
- notifier.notify("onLeaseLost", { delivery });
2896
+ notifier.notify("onPurged", { result });
2897
+ return result;
2898
+ } catch (error) {
2899
+ notifier.failure("purge", error);
2900
+ throw error;
1655
2901
  }
1656
2902
  }
1657
- async #bury(delivery, error) {
1658
- const { store, clock, notifier } = this.#dependencies;
1659
- const errorInfo = toErrorInfo(error);
1660
- let buried;
1661
- try {
1662
- buried = await store.bury(delivery, errorInfo, clock.now());
1663
- } catch (storeError) {
1664
- notifier.failure("bury", storeError);
1665
- this.#retain({
1666
- delivery,
1667
- settle: () => store.bury(delivery, errorInfo, clock.now()),
1668
- applied: () => notifier.notify("onDeliveryDead", { delivery, error: errorInfo })
2903
+ /** Starts the retention purge and the backlog report, each when it has anything to do. */
2904
+ start() {
2905
+ this.#scheduleRetention();
2906
+ this.#scheduleBacklog();
2907
+ }
2908
+ stop() {
2909
+ this.#retentionTimer?.();
2910
+ this.#backlogTimer?.();
2911
+ }
2912
+ /** Applies the retention policy every `retention.interval` while the bus runs, when it deletes anything. */
2913
+ #scheduleRetention() {
2914
+ const { clock, retention } = this.#parts;
2915
+ if (retention.completedMs !== null || retention.deadMs !== null) {
2916
+ this.#retentionTimer = schedule(clock, {
2917
+ run: () => {
2918
+ this.purgeExpired().catch(() => void 0);
2919
+ },
2920
+ delayMs: retention.intervalMs,
2921
+ repeat: true,
2922
+ unref: true
1669
2923
  });
1670
- return;
1671
2924
  }
1672
- if (buried) {
1673
- notifier.notify("onDeliveryDead", { delivery, error: errorInfo });
1674
- } else {
1675
- notifier.notify("onLeaseLost", { delivery });
2925
+ }
2926
+ /** Reports the backlog every `backlogInterval` while the bus runs, when an observer wants it. */
2927
+ #scheduleBacklog() {
2928
+ const { observation, backlogIntervalMs, statistics } = this.#parts;
2929
+ if (backlogIntervalMs !== void 0) {
2930
+ this.#backlogTimer = observation?.startBacklog({ intervalMs: backlogIntervalMs, statistics });
1676
2931
  }
1677
2932
  }
1678
- /**
1679
- * A worker that only noticed locally that its lease ran out may still own the delivery in the
1680
- * store, where other deliveries of its lane keep renewing the lane: it must not stay in flight
1681
- * for ever. The attempt took longer than its lease, so it counts as a failed attempt (an
1682
- * exhausted delivery is dead-lettered, whatever a custom `retryIf` says): a handler that always
1683
- * outlives its lease cannot run for ever at attempt 1. If the lease is gone for real the store
1684
- * refuses and nothing changes. The lost lease was reported already.
1685
- */
1686
- async #giveBack(registration, delivery, error) {
1687
- if (delivery.attempt >= registration.retry.attempts) {
1688
- await this.#bury(delivery, error);
1689
- } else {
1690
- await this.#reschedule(registration, delivery, error);
2933
+ };
2934
+
2935
+ // src/bus/event-publisher.ts
2936
+ var EventPublisher = class {
2937
+ #parts;
2938
+ constructor(parts) {
2939
+ this.#parts = parts;
2940
+ }
2941
+ /** Publishes inside the tracer's span, with its trace headers, when the bus is observed. */
2942
+ publish(events, call) {
2943
+ const { observation } = this.#parts;
2944
+ if (observation === void 0) {
2945
+ return this.#publish(events, call);
2946
+ }
2947
+ return observation.publish(
2948
+ { eventTypes: events.map((event) => String(event.type)) },
2949
+ (headers) => this.#publish(events, { ...call, options: withTraceHeaders(call.options, headers) })
2950
+ );
2951
+ }
2952
+ async #publish(events, { options, id }) {
2953
+ if (events.length === 0) {
2954
+ return [];
2955
+ }
2956
+ const now = this.#parts.clock.now();
2957
+ const delayMs = options.delay === void 0 ? 0 : Milliseconds.of(options.delay, "delay");
2958
+ const availableAt = new Date(now.getTime() + delayMs);
2959
+ const context = { options, id, scope: getCurrentScope(), now };
2960
+ const entries = await this.#createEntries(events, { context, availableAt });
2961
+ const deliverable = entries.filter((entry) => entry.deliveries.length > 0);
2962
+ if (deliverable.length > 0) {
2963
+ await this.#appendDeliverable(deliverable, { context, delayMs });
1691
2964
  }
2965
+ return entries.map((entry) => entry.event);
1692
2966
  }
1693
- async #release(delivery) {
1694
- const { store, clock, notifier } = this.#dependencies;
1695
- let released;
1696
- try {
1697
- released = await store.release(delivery, clock.now());
1698
- } catch (storeError) {
1699
- notifier.failure("release", storeError);
1700
- this.#retain({
1701
- delivery,
1702
- settle: () => store.release(delivery, clock.now()),
1703
- applied: () => notifier.notify("onDeliveryReleased", { delivery })
2967
+ /** One entry per event, in order: its envelope and a delivery for every registration of its type. */
2968
+ async #createEntries(events, {
2969
+ context,
2970
+ availableAt
2971
+ }) {
2972
+ const { registry, createDelivery, reportUnrouted } = this.#parts;
2973
+ const entries = [];
2974
+ const { options, scope } = context;
2975
+ let correlationId = options.correlationId ?? scope?.event?.correlationId ?? scope?.event?.id;
2976
+ for (const event of events) {
2977
+ const envelope = await this.#createEnvelope({ ...context, event, correlationId });
2978
+ correlationId = envelope.correlationId;
2979
+ const registrations = registry.forEventType(envelope.type);
2980
+ if (registrations.length === 0) {
2981
+ reportUnrouted(envelope);
2982
+ }
2983
+ entries.push({
2984
+ event: envelope,
2985
+ deliveries: registrations.map((registration) => createDelivery(registration, availableAt))
1704
2986
  });
1705
- return;
1706
2987
  }
1707
- if (released) {
1708
- notifier.notify("onDeliveryReleased", { delivery });
1709
- } else {
1710
- notifier.notify("onLeaseLost", { delivery });
2988
+ return entries;
2989
+ }
2990
+ /** Appends the entries that have deliveries, reports what was new and wakes the dispatcher when it is due now. */
2991
+ async #appendDeliverable(entries, {
2992
+ context,
2993
+ delayMs
2994
+ }) {
2995
+ const result = await this.#appendInTransaction(entries, context);
2996
+ const appended = new Set(result.appendedEventIds);
2997
+ const published = entries.map((entry) => entry.event).filter((envelope) => appended.has(envelope.id));
2998
+ if (published.length > 0) {
2999
+ this.#parts.notifier.notify("onPublished", {
3000
+ envelopes: published,
3001
+ deliveries: result.appendedDeliveryCount
3002
+ });
3003
+ if (delayMs === 0) {
3004
+ this.#parts.scheduleWake();
3005
+ }
1711
3006
  }
1712
3007
  }
1713
- };
1714
- function pendingKey(delivery) {
1715
- return `${delivery.deliveryId}\0${delivery.leaseToken}`;
1716
- }
1717
- function rejectOnAbort(signal) {
1718
- return new Promise((_, reject) => {
1719
- signal.addEventListener("abort", () => reject(signal.reason), { once: true });
1720
- });
1721
- }
1722
-
1723
- // src/handler-registry.ts
1724
- var HANDLER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
1725
- var HandlerRegistry = class {
1726
- #byId = /* @__PURE__ */ new Map();
1727
- #byEventType = /* @__PURE__ */ new Map();
1728
- register(input, defaults) {
1729
- const { options } = input;
1730
- if (!HANDLER_ID_PATTERN.test(options.id)) {
1731
- throw new ConfigurationError(
1732
- `Handler id '${options.id}' must be 1-255 characters of letters, digits, '.', '_', ':', '/' or '-'`
1733
- );
3008
+ async #appendInTransaction(entries, { options, scope, now }) {
3009
+ const { store, transactionMode, transactionResolver } = this.#parts;
3010
+ const transaction = options.transaction ?? scope?.transaction ?? transactionResolver?.();
3011
+ if (transaction !== void 0) {
3012
+ return store.append(transaction, entries, now);
1734
3013
  }
1735
- if (this.#byId.has(options.id)) {
1736
- throw new ConfigurationError(`Handler id '${options.id}' is already registered`);
3014
+ if (transactionMode === "required") {
3015
+ throw new TransactionRequiredError(
3016
+ "publish found no transaction. Pass { transaction }, publish inside bus.transaction(), configure transactionResolver, or set transactionMode to optional"
3017
+ );
1737
3018
  }
1738
- if (options.concurrency !== void 0 && (!Number.isInteger(options.concurrency) || options.concurrency < 1)) {
3019
+ return store.transaction((ownTransaction) => store.append(ownTransaction, entries, now));
3020
+ }
3021
+ async #createEnvelope(input) {
3022
+ const { event, options, id, scope, now, correlationId } = input;
3023
+ if (typeof event.type !== "string") {
1739
3024
  throw new ConfigurationError(
1740
- `Concurrency of handler '${options.id}' must be a positive integer, received ${options.concurrency}`
3025
+ "publish expects an instance of a class created with defineEvent"
1741
3026
  );
1742
3027
  }
1743
- const registration = {
1744
- id: options.id,
1745
- eventType: input.eventType,
1746
- schema: input.schema,
1747
- handle: input.handle,
1748
- retry: createRetryPolicy(options.retry, defaults.retry),
1749
- timeoutMs: resolveTimeout(options.timeout, defaults.timeoutMs, options.id),
1750
- concurrency: options.concurrency ?? null,
1751
- transactional: options.transactional ?? false,
1752
- forwardsToTransport: input.forwardsToTransport
3028
+ assertEventType(event.type);
3029
+ const schema = event.constructor.schema;
3030
+ const payload = await validatePayload(event.type, schema, event.payload);
3031
+ if (options.key !== void 0 && options.key.length === 0) {
3032
+ throw new ConfigurationError("key must not be empty");
3033
+ }
3034
+ const eventId = id ?? this.#parts.generateIdentifier();
3035
+ return {
3036
+ id: eventId,
3037
+ type: event.type,
3038
+ source: this.#parts.source,
3039
+ key: options.key ?? keyFromDefinition(event, payload),
3040
+ payload,
3041
+ headers: { ...options.headers },
3042
+ correlationId: correlationId ?? eventId,
3043
+ causationId: scope?.event?.id ?? null,
3044
+ occurredAt: now
1753
3045
  };
1754
- this.#byId.set(registration.id, registration);
1755
- const forType = this.#byEventType.get(registration.eventType) ?? [];
1756
- forType.push(registration);
1757
- this.#byEventType.set(registration.eventType, forType);
1758
- return registration;
1759
- }
1760
- get(id) {
1761
- return this.#byId.get(id);
1762
3046
  }
1763
- forEventType(eventType) {
1764
- return this.#byEventType.get(eventType) ?? [];
3047
+ };
3048
+
3049
+ // src/received-envelope.ts
3050
+ function normalizeEnvelope(envelope) {
3051
+ if (typeof envelope.id !== "string" || envelope.id.length === 0) {
3052
+ throw new InvalidEnvelopeError("Received event has no id");
1765
3053
  }
1766
- all() {
1767
- return [...this.#byId.values()];
3054
+ if (typeof envelope.type !== "string") {
3055
+ throw new InvalidEnvelopeError(`Received event ${envelope.id} has no type`);
1768
3056
  }
1769
- };
1770
- function resolveTimeout(timeout, fallback, handlerId) {
1771
- if (timeout === void 0) {
1772
- return fallback;
3057
+ try {
3058
+ assertEventType(envelope.type);
3059
+ } catch (error) {
3060
+ throw new InvalidEnvelopeError(toErrorInfo(error).message);
1773
3061
  }
1774
- if (timeout === false) {
1775
- return null;
3062
+ const occurredAt = toDate(envelope.occurredAt);
3063
+ if (occurredAt === void 0) {
3064
+ throw new InvalidEnvelopeError(`Received event ${envelope.id} has an invalid occurredAt`);
1776
3065
  }
1777
- return toTimerMilliseconds(timeout, `Timeout of handler '${handlerId}'`);
3066
+ return {
3067
+ id: envelope.id,
3068
+ type: envelope.type,
3069
+ source: typeof envelope.source === "string" ? envelope.source : "unknown",
3070
+ key: optionalText(envelope.key),
3071
+ payload: envelope.payload,
3072
+ headers: { ...envelope.headers },
3073
+ correlationId: optionalText(envelope.correlationId),
3074
+ causationId: optionalText(envelope.causationId),
3075
+ occurredAt
3076
+ };
3077
+ }
3078
+ function toDate(value) {
3079
+ const date = typeof value === "string" ? new Date(value) : value;
3080
+ return date instanceof Date && !Number.isNaN(date.getTime()) ? date : void 0;
3081
+ }
3082
+ function optionalText(value) {
3083
+ return typeof value === "string" && value.length > 0 ? value : null;
1778
3084
  }
1779
3085
 
1780
- // src/notifier.ts
1781
- var Notifier = class {
1782
- #observers;
1783
- #logger;
1784
- constructor(observers, logger) {
1785
- this.#observers = observers;
1786
- this.#logger = logger;
1787
- }
1788
- notify(method, argument) {
1789
- for (const observer of this.#observers) {
1790
- try {
1791
- observer[method]?.call(
1792
- observer,
1793
- argument
1794
- );
1795
- } catch (error) {
1796
- this.#reportObserverFailure(observer, error);
1797
- }
3086
+ // src/bus/event-receiver.ts
3087
+ var EventReceiver = class {
3088
+ #parts;
3089
+ constructor(parts) {
3090
+ this.#parts = parts;
3091
+ }
3092
+ /** Stores `envelope` for local handlers; observed when the bus is, with where it came from. */
3093
+ receive(envelope, origin) {
3094
+ const { observation } = this.#parts;
3095
+ return observation === void 0 ? this.#receive(envelope) : observation.receive({ envelope, origin }, () => this.#receive(envelope));
3096
+ }
3097
+ async #receive(envelope) {
3098
+ const { store, registry, clock, notifier, createDelivery, scheduleWake } = this.#parts;
3099
+ const normalized = normalizeEnvelope(envelope);
3100
+ const registrations = registry.forEventType(normalized.type).filter((registration) => !registration.forwardsToTransport);
3101
+ if (registrations.length === 0) {
3102
+ notifier.notify("onReceived", { envelope: normalized, status: "ignored" });
3103
+ return "ignored";
1798
3104
  }
1799
- }
1800
- failure(stage, error) {
1801
- const errorInfo = toErrorInfo(error);
1802
- this.#logger.error(
1803
- `[Notifier](failure) relaybus ${stage} failed | ${errorInfo.name}: ${errorInfo.message}`
3105
+ const now = clock.now();
3106
+ const entry = {
3107
+ event: normalized,
3108
+ deliveries: registrations.map((registration) => createDelivery(registration, now))
3109
+ };
3110
+ const result = await store.transaction(
3111
+ (transaction) => store.append(transaction, [entry], now)
1804
3112
  );
1805
- for (const observer of this.#observers) {
1806
- try {
1807
- observer.onFailure?.({ stage, error: errorInfo });
1808
- } catch (observerError) {
1809
- this.#logObserverFailure(observerError);
1810
- }
1811
- }
1812
- }
1813
- #reportObserverFailure(failedObserver, error) {
1814
- this.#logObserverFailure(error);
1815
- const errorInfo = toErrorInfo(error);
1816
- for (const observer of this.#observers) {
1817
- if (observer === failedObserver) {
1818
- continue;
1819
- }
1820
- try {
1821
- observer.onFailure?.({ stage: "observer", error: errorInfo });
1822
- } catch (observerError) {
1823
- this.#logObserverFailure(observerError);
1824
- }
3113
+ const status = result.duplicateEventIds.length > 0 ? "duplicate" : "accepted";
3114
+ notifier.notify("onReceived", { envelope: normalized, status });
3115
+ if (result.appendedDeliveryCount > 0) {
3116
+ scheduleWake();
1825
3117
  }
1826
- }
1827
- #logObserverFailure(error) {
1828
- const errorInfo = toErrorInfo(error);
1829
- this.#logger.warn(
1830
- `[Notifier](notify) relaybus observer threw | ${errorInfo.name}: ${errorInfo.message}`
1831
- );
3118
+ return status;
1832
3119
  }
1833
3120
  };
1834
3121
 
1835
- // src/relay-bus.ts
1836
- var DEFAULTS = {
1837
- concurrency: 10,
1838
- pollInterval: "1s",
1839
- leaseDuration: "30s",
1840
- shutdownTimeout: "30s",
1841
- completionBatchWindow: "2ms",
1842
- completionBatchSize: 200,
1843
- completedRetention: "7d",
1844
- retentionInterval: "1h",
1845
- routeTimeoutMs: 3e4
1846
- };
1847
- var TRANSPORT_HANDLER_PREFIX = "relaybus.transport";
3122
+ // src/bus/relay-bus-assembly.ts
3123
+ function assembleRelayBus(options, hooks) {
3124
+ const core = resolveCore(options);
3125
+ const backlogIntervalMs = resolveBacklogInterval(options);
3126
+ const maintenance = new BusMaintenance({
3127
+ ...core,
3128
+ retention: createRetentionSettings(options.retention),
3129
+ backlogIntervalMs,
3130
+ statistics: hooks.statistics
3131
+ });
3132
+ const connections = new BusConnections(core, options.subscriptions ?? []);
3133
+ connections.register({ transports: options.transports ?? [], routes: options.routes ?? [] });
3134
+ const catalogRequirements = [];
3135
+ for (const catalog of options.catalogs ?? []) {
3136
+ catalogRequirements.push(toCatalogRequirement(catalog));
3137
+ }
3138
+ const dispatcherEnabled = isDispatcherEnabled(options);
3139
+ const settings = createDispatcherSettings(options.dispatcher, core.generateIdentifier);
3140
+ const dispatcher = new Dispatcher({
3141
+ ...core,
3142
+ random: options.random ?? Math.random,
3143
+ settings,
3144
+ observation: DispatchObservation.create({ ...core, tracer: options.tracer })
3145
+ });
3146
+ return {
3147
+ ...core,
3148
+ maintenance,
3149
+ connections,
3150
+ catalogRequirements,
3151
+ dispatcherEnabled,
3152
+ shutdownTimeoutMs: settings.shutdownTimeoutMs,
3153
+ dispatcher,
3154
+ ...createInboxAndOutbox(core, hooks)
3155
+ };
3156
+ }
3157
+ function resolveCore(options) {
3158
+ const source = options.source ?? "relaybus";
3159
+ const transactionMode = oneOf(options.transactionMode ?? "required", {
3160
+ allowed: ["required", "optional"],
3161
+ name: "transactionMode"
3162
+ });
3163
+ const unroutedEvents = oneOf(options.unroutedEvents ?? "warn", {
3164
+ allowed: ["warn", "throw"],
3165
+ name: "unroutedEvents"
3166
+ });
3167
+ const transactionTimeoutMs = resolveTransactionTimeout(options);
3168
+ const defaults = createHandlerDefaults(options);
3169
+ const logger = options.logger ?? console;
3170
+ const notifier = new Notifier(options.observers ?? [], logger);
3171
+ const clock = options.clock ?? systemClock;
3172
+ return {
3173
+ store: options.store,
3174
+ source,
3175
+ transactionMode,
3176
+ unroutedEvents,
3177
+ transactionResolver: options.transactionResolver,
3178
+ transactionTimeoutMs,
3179
+ registry: new HandlerRegistry(),
3180
+ defaults,
3181
+ logger,
3182
+ notifier,
3183
+ clock,
3184
+ observation: BusObservation.create({ notifier, tracer: options.tracer, clock }),
3185
+ generateIdentifier: options.generateIdentifier ?? (() => createUuidV7()),
3186
+ setupStore: options.setupStore ?? true
3187
+ };
3188
+ }
3189
+ function createInboxAndOutbox(core, hooks) {
3190
+ const createDelivery = (registration, availableAt) => ({
3191
+ id: core.generateIdentifier(),
3192
+ handler: registration.id,
3193
+ availableAt
3194
+ });
3195
+ return {
3196
+ publisher: new EventPublisher({
3197
+ ...core,
3198
+ createDelivery,
3199
+ reportUnrouted: hooks.reportUnrouted,
3200
+ scheduleWake: hooks.scheduleWake
3201
+ }),
3202
+ receiver: new EventReceiver({ ...core, createDelivery, scheduleWake: hooks.scheduleWake })
3203
+ };
3204
+ }
3205
+
3206
+ // src/transaction-budget.ts
3207
+ async function runBudgetedTransaction(store, { clock, timeoutMs, work }) {
3208
+ const startedAt = clock.now().getTime();
3209
+ const overrun = () => clock.now().getTime() - startedAt >= timeoutMs;
3210
+ let committing = false;
3211
+ try {
3212
+ return await store.transaction(
3213
+ async (transaction) => {
3214
+ committing = false;
3215
+ const result = await work(transaction);
3216
+ if (overrun()) {
3217
+ throw createTimeoutError(timeoutMs, void 0);
3218
+ }
3219
+ committing = true;
3220
+ return result;
3221
+ },
3222
+ { timeoutMs }
3223
+ );
3224
+ } catch (error) {
3225
+ if (error instanceof TransactionTimeoutError || committing || !overrun()) {
3226
+ throw error;
3227
+ }
3228
+ throw createTimeoutError(timeoutMs, error);
3229
+ }
3230
+ }
3231
+ function createTimeoutError(timeoutMs, cause) {
3232
+ const message = `The transaction ran past its ${timeoutMs} ms budget (transactionTimeout) and was rolled back. The usual cause is a slow call to another service (HTTP, gRPC, a broker, a lock) inside the transaction: make external calls before the transaction starts or after it commits, or raise transactionTimeout`;
3233
+ return cause === void 0 ? new TransactionTimeoutError(message) : new TransactionTimeoutError(`${message} | cause=${toErrorInfo(cause).message}`, { cause });
3234
+ }
3235
+
3236
+ // src/relay-bus.ts
3237
+ var DEFAULT_DRAIN_BATCHES = 1e3;
1848
3238
  var WAKE_DELAY_MS = 25;
1849
3239
  var RelayBus = class {
1850
- #store;
1851
- #source;
1852
- #transactionMode;
1853
- #transactionResolver;
1854
- #unroutedEvents;
1855
- #registry = new HandlerRegistry();
1856
- #defaults;
1857
- #transports = /* @__PURE__ */ new Map();
1858
- #subscriptions;
1859
- #notifier;
1860
- #logger;
1861
- #clock;
1862
- #generateIdentifier;
1863
- #dispatcher;
1864
- #dispatcherEnabled;
1865
- #catalogRequirements = [];
1866
- #setupStore;
1867
- #retention;
1868
- #retentionTimer;
1869
- #state = "idle";
1870
- #starting;
1871
- #stopping;
3240
+ #parts;
3241
+ #lifecycle;
1872
3242
  constructor(options) {
1873
- this.#store = options.store;
1874
- this.#source = options.source ?? "relaybus";
1875
- this.#transactionMode = oneOf(
1876
- options.transactionMode ?? "required",
1877
- ["required", "optional"],
1878
- "transactionMode"
1879
- );
1880
- this.#unroutedEvents = oneOf(
1881
- options.unroutedEvents ?? "warn",
1882
- ["warn", "throw"],
1883
- "unroutedEvents"
1884
- );
1885
- this.#transactionResolver = options.transactionResolver;
1886
- this.#defaults = {
1887
- retry: createRetryPolicy(options.retry),
1888
- timeoutMs: options.handlerTimeout === void 0 || options.handlerTimeout === false ? null : toTimerMilliseconds(options.handlerTimeout, "handlerTimeout")
1889
- };
1890
- this.#logger = options.logger ?? console;
1891
- this.#notifier = new Notifier(options.observers ?? [], this.#logger);
1892
- this.#clock = options.clock ?? systemClock;
1893
- this.#generateIdentifier = options.generateIdentifier ?? (() => createUuidV7());
1894
- this.#setupStore = options.setupStore ?? true;
1895
- this.#retention = createRetentionSettings(options.retention);
1896
- for (const transport of options.transports ?? []) {
1897
- if (this.#transports.has(transport.name)) {
1898
- throw new ConfigurationError(`Transport name '${transport.name}' is used twice`);
1899
- }
1900
- this.#transports.set(transport.name, transport);
1901
- }
1902
- for (const route of options.routes ?? []) {
1903
- this.#registerRoute(route);
1904
- }
1905
- this.#subscriptions = options.subscriptions ?? [];
1906
- for (const subscription of this.#subscriptions) {
1907
- const transport = this.#requireTransport(subscription.transport);
1908
- if (transport.subscribe === void 0) {
1909
- throw new ConfigurationError(`Transport '${transport.name}' cannot subscribe`);
1910
- }
1911
- }
1912
- for (const catalog of options.catalogs ?? []) {
1913
- this.registerCatalog(catalog);
1914
- }
1915
- this.#dispatcherEnabled = options.dispatcher?.enabled ?? true;
1916
- this.#dispatcher = new Dispatcher({
1917
- store: this.#store,
1918
- registry: this.#registry,
1919
- clock: this.#clock,
1920
- random: options.random ?? Math.random,
1921
- notifier: this.#notifier,
1922
- settings: createDispatcherSettings(options.dispatcher, this.#generateIdentifier)
3243
+ this.#parts = assembleRelayBus(options, {
3244
+ reportUnrouted: (envelope) => this.#reportUnrouted(envelope),
3245
+ scheduleWake: () => this.#scheduleWake(),
3246
+ statistics: () => this.statistics()
3247
+ });
3248
+ this.#lifecycle = new BusLifecycle({
3249
+ ...this.#parts,
3250
+ closeTransports: (deadline) => this.#closeTransports(deadline)
1923
3251
  });
1924
3252
  }
1925
3253
  get store() {
1926
- return this.#store;
3254
+ return this.#parts.store;
1927
3255
  }
1928
3256
  get isRunning() {
1929
- return this.#state === "running";
3257
+ return this.#lifecycle.state === "running";
1930
3258
  }
1931
3259
  /** True while the bus runs and every transport reports itself ready. */
1932
3260
  isReady() {
1933
- if (this.#state !== "running") {
1934
- return false;
1935
- }
1936
- for (const transport of this.#transports.values()) {
1937
- if (transport.isReady?.() === false) {
1938
- return false;
1939
- }
1940
- }
1941
- return true;
3261
+ return this.#lifecycle.state === "running" && this.#parts.connections.allReady();
1942
3262
  }
3263
+ // eslint-disable-next-line relaybus/max-two-params -- public RelayBus.subscribe signature
1943
3264
  subscribe(event, handler, options) {
1944
3265
  const { type, schema } = describeEvent(event);
1945
- this.#registry.register(
3266
+ this.#parts.registry.register(
1946
3267
  {
1947
- eventType: type,
3268
+ eventTypes: [type],
1948
3269
  schema,
1949
3270
  handle: handler,
1950
3271
  options,
1951
3272
  forwardsToTransport: false
1952
3273
  },
1953
- this.#defaults
3274
+ this.#parts.defaults
1954
3275
  );
1955
3276
  }
3277
+ // eslint-disable-next-line relaybus/max-two-params -- public RelayBus.subscribeCatalog signature
1956
3278
  subscribeCatalog(catalog, handlers, options = {}) {
1957
- for (const binding of resolveCatalogHandlers(catalog, handlers, options)) {
3279
+ for (const binding of resolveCatalogHandlers(catalog, { handlers, options })) {
1958
3280
  this.subscribe(binding.eventClass, binding.handle, binding.options);
1959
3281
  }
1960
3282
  }
@@ -1965,7 +3287,7 @@ var RelayBus = class {
1965
3287
  * option.
1966
3288
  */
1967
3289
  registerCatalog(catalog) {
1968
- this.#catalogRequirements.push(toCatalogRequirement(catalog));
3290
+ this.#parts.catalogRequirements.push(toCatalogRequirement(catalog));
1969
3291
  }
1970
3292
  /**
1971
3293
  * A view of this bus that publishes only the events of `catalog`, or only
@@ -1977,7 +3299,9 @@ var RelayBus = class {
1977
3299
  }
1978
3300
  /** Registered handlers and routes, for diagnostics. */
1979
3301
  handlers() {
1980
- return this.#registry.all().map(toDescriptor);
3302
+ return this.#parts.registry.all().flatMap(
3303
+ (registration) => registration.eventTypes.map((eventType) => toDescriptor(registration, eventType))
3304
+ );
1981
3305
  }
1982
3306
  /**
1983
3307
  * Stores an event in the current transaction. Handlers run after the
@@ -1985,21 +3309,32 @@ var RelayBus = class {
1985
3309
  * have been written when the id already exists.
1986
3310
  */
1987
3311
  async publish(event, options = {}) {
1988
- const [envelope] = await this.#publish([event], options, options.id);
3312
+ const [envelope] = await this.#parts.publisher.publish([event], { options, id: options.id });
1989
3313
  return envelope;
1990
3314
  }
1991
3315
  /** Stores several events atomically, in order, sharing one correlation id. */
1992
3316
  async publishAll(events, options = {}) {
1993
- return this.#publish(events, options, void 0);
3317
+ return this.#parts.publisher.publish(events, { options, id: void 0 });
1994
3318
  }
1995
3319
  /**
1996
3320
  * Runs `work` in a new store transaction. `publish` calls inside it join the
1997
- * transaction without passing it explicitly.
3321
+ * transaction without passing it explicitly. The transaction is held to `transactionTimeout`
3322
+ * and rolled back with a {@link TransactionTimeoutError} when it runs past it.
1998
3323
  */
1999
3324
  async transaction(work) {
2000
- return this.#store.transaction(
2001
- (transaction) => runInScope({ transaction }, () => work(transaction))
2002
- );
3325
+ return runBudgetedTransaction(this.#parts.store, {
3326
+ clock: this.#parts.clock,
3327
+ timeoutMs: this.#parts.transactionTimeoutMs,
3328
+ work: (transaction) => runInScope({ transaction }, () => work(transaction))
3329
+ });
3330
+ }
3331
+ /**
3332
+ * The transaction `publish` would join right now: the one of the enclosing `bus.transaction`
3333
+ * (or `@RelayBusTransaction()` method, or transactional handler), else the one
3334
+ * `transactionResolver` finds. Repositories use it to write in the same transaction.
3335
+ */
3336
+ currentTransaction() {
3337
+ return getCurrentScope()?.transaction ?? this.#parts.transactionResolver?.();
2003
3338
  }
2004
3339
  /**
2005
3340
  * Stores an event received from a broker and schedules it for local handlers.
@@ -2007,27 +3342,8 @@ var RelayBus = class {
2007
3342
  * only after it resolves. Throws {@link InvalidEnvelopeError} for malformed
2008
3343
  * events, which transports should dead-letter instead of redelivering.
2009
3344
  */
2010
- async receive(envelope) {
2011
- const normalized = normalizeEnvelope(envelope);
2012
- const registrations = this.#registry.forEventType(normalized.type).filter((registration) => !registration.forwardsToTransport);
2013
- if (registrations.length === 0) {
2014
- this.#notifier.notify("onReceived", { envelope: normalized, status: "ignored" });
2015
- return "ignored";
2016
- }
2017
- const now = this.#clock.now();
2018
- const entry = {
2019
- event: normalized,
2020
- deliveries: registrations.map((registration) => this.#createDelivery(registration, now))
2021
- };
2022
- const result = await this.#store.transaction(
2023
- (transaction) => this.#store.append(transaction, [entry], now)
2024
- );
2025
- const status = result.duplicateEventIds.length > 0 ? "duplicate" : "accepted";
2026
- this.#notifier.notify("onReceived", { envelope: normalized, status });
2027
- if (result.appendedDeliveryCount > 0) {
2028
- this.#scheduleWake();
2029
- }
2030
- return status;
3345
+ receive(envelope) {
3346
+ return this.#parts.receiver.receive(envelope);
2031
3347
  }
2032
3348
  /**
2033
3349
  * Creates the store schema, connects transports, subscribes and starts
@@ -2035,53 +3351,50 @@ var RelayBus = class {
2035
3351
  * stopped for good, because its transports are closed; create a new instance.
2036
3352
  */
2037
3353
  async start() {
2038
- if (this.#state !== "idle") {
2039
- throw new RelayBusError(
2040
- this.#state === "stopped" ? "A stopped relaybus cannot be started again; create a new instance" : `Cannot start a relaybus that is ${this.#state}`
2041
- );
2042
- }
2043
- assertCatalogsCovered(this.#catalogRequirements, (type) => this.#registrationsOf(type));
2044
- this.#state = "starting";
2045
- this.#starting = this.#startComponents();
3354
+ this.#lifecycle.assertStartable();
3355
+ assertCatalogsCovered(this.#parts.catalogRequirements, (type) => this.#registrationsOf(type));
3356
+ const starting = this.#lifecycle.beginStart(() => this.#startComponents());
2046
3357
  try {
2047
- await this.#starting;
3358
+ await starting;
2048
3359
  } finally {
2049
- this.#starting = void 0;
3360
+ this.#lifecycle.endStart();
2050
3361
  }
2051
3362
  }
2052
3363
  /**
2053
- * Stops dispatching, waits for running handlers, closes transports and the
2054
- * store. Waits for a start in progress first. Safe to call more than once.
3364
+ * Stops claiming, waits for running handlers, hands back leases, closes transports and the store,
3365
+ * all within `options.timeout` (default `dispatcher.shutdownTimeout`). When the deadline passes it
3366
+ * aborts the handlers still running, force-closes what relaybus opened itself (never pools or
3367
+ * clients the application passed in) and resolves anyway: it never rejects because of the
3368
+ * deadline. Abandoned deliveries stay leased until their lease expires and then run again
3369
+ * (at-least-once). Waits for a start in progress first. Safe to call more than once: later calls
3370
+ * get the result of the first.
2055
3371
  */
2056
- async stop() {
2057
- if (this.#stopping !== void 0) {
2058
- return this.#stopping;
2059
- }
2060
- if (this.#state === "starting") {
2061
- await this.#starting.catch(() => void 0);
2062
- }
2063
- if (this.#state === "idle") {
2064
- await this.#dispatcher.stop();
2065
- return;
2066
- }
2067
- if (this.#state !== "running") {
2068
- return;
3372
+ async stop(options = {}) {
3373
+ const timeoutMs = options.timeout === void 0 ? this.#parts.shutdownTimeoutMs : Milliseconds.forTimer(options.timeout, "timeout");
3374
+ const { stopping, first } = this.#lifecycle.stop(timeoutMs);
3375
+ if (!first) {
3376
+ return stopping;
3377
+ }
3378
+ const result = await stopping;
3379
+ if (!result.graceful) {
3380
+ this.#parts.logger.warn(
3381
+ `[RelayBus](stop) Shutdown deadline passed, forced close | abandoned=${result.abandonedDeliveries} | timeoutMs=${timeoutMs}`
3382
+ );
2069
3383
  }
2070
- this.#stopping = this.#stopComponents();
2071
- return this.#stopping;
3384
+ return result;
2072
3385
  }
2073
3386
  /**
2074
3387
  * Claims and runs one batch of due deliveries, resolving when they settle.
2075
3388
  * Returns how many ran. Intended for tests and scripts.
2076
3389
  */
2077
3390
  async runOnce() {
2078
- return this.#dispatcher.processBatch();
3391
+ return this.#parts.dispatcher.processBatch();
2079
3392
  }
2080
3393
  /**
2081
3394
  * Runs batches until nothing is due. Deliveries scheduled in the future
2082
3395
  * (retries, delays) are not waited for. Intended for tests.
2083
3396
  */
2084
- async drain(maxBatches = 1e3) {
3397
+ async drain(maxBatches = DEFAULT_DRAIN_BATCHES) {
2085
3398
  let total = 0;
2086
3399
  for (let batch = 0; batch < maxBatches; batch += 1) {
2087
3400
  const processed = await this.runOnce();
@@ -2090,333 +3403,107 @@ var RelayBus = class {
2090
3403
  }
2091
3404
  total += processed;
2092
3405
  }
2093
- throw new RelayBusError(`drain did not finish within ${maxBatches} batches`);
3406
+ throw new DrainIncompleteError(`drain did not finish within ${maxBatches} batches`);
2094
3407
  }
2095
3408
  listDeliveries(query = {}) {
2096
- return this.#store.listDeliveries(query);
3409
+ return this.#parts.store.listDeliveries(query);
2097
3410
  }
2098
3411
  /** Moves dead deliveries back to pending with a fresh attempt budget. */
2099
3412
  async requeue(deliveryIds) {
2100
- const moved = await this.#store.requeue(deliveryIds, this.#clock.now());
3413
+ const moved = await this.#parts.store.requeue(deliveryIds, this.#parts.clock.now());
2101
3414
  if (moved > 0) {
2102
- this.#dispatcher.wake();
3415
+ this.#parts.dispatcher.wake();
2103
3416
  }
2104
3417
  return moved;
2105
3418
  }
2106
3419
  statistics() {
2107
- return this.#store.statistics(this.#clock.now());
3420
+ return this.#parts.store.statistics(this.#parts.clock.now());
2108
3421
  }
2109
3422
  /** Applies the retention policy once. Runs automatically while the bus is running. */
2110
- async purgeExpired() {
2111
- const now = this.#clock.now().getTime();
2112
- const { completedMs, deadMs } = this.#retention;
2113
- try {
2114
- const result = await this.#store.purge({
2115
- ...completedMs === null ? {} : { completedBefore: new Date(now - completedMs) },
2116
- ...deadMs === null ? {} : { deadBefore: new Date(now - deadMs) }
2117
- });
2118
- this.#notifier.notify("onPurged", { result });
2119
- return result;
2120
- } catch (error) {
2121
- this.#notifier.failure("purge", error);
2122
- throw error;
2123
- }
3423
+ purgeExpired() {
3424
+ return this.#parts.maintenance.purgeExpired();
2124
3425
  }
3426
+ // The awaits stay in this method, as many as before: with no store setup and no transport a
3427
+ // start completes synchronously, so the bus is running as soon as start() returns.
2125
3428
  async #startComponents() {
3429
+ const { setupStore, store, connections } = this.#parts;
2126
3430
  try {
2127
- if (this.#setupStore) {
2128
- await this.#store.setup?.();
3431
+ if (setupStore) {
3432
+ await store.setup?.();
2129
3433
  }
2130
- for (const transport of this.#transports.values()) {
3434
+ for (const transport of connections.transports) {
2131
3435
  await transport.connect?.();
2132
3436
  }
2133
- for (const subscription of this.#subscriptions) {
2134
- const transport = this.#transports.get(subscription.transport);
2135
- await transport.subscribe(subscription.destinations, async (envelope) => {
2136
- await this.receive(envelope);
2137
- });
3437
+ for (const subscription of connections.subscriptions) {
3438
+ await connections.subscribe(
3439
+ subscription,
3440
+ (envelope, origin) => this.#parts.receiver.receive(envelope, origin)
3441
+ );
2138
3442
  }
2139
3443
  } catch (error) {
2140
- await this.#closeTransports();
2141
- this.#state = "stopped";
3444
+ await this.#closeAfterFailedStart();
3445
+ this.#lifecycle.markStopped();
2142
3446
  throw error;
2143
3447
  }
2144
- if (this.#dispatcherEnabled) {
2145
- this.#dispatcher.start();
3448
+ if (this.#lifecycle.state !== "starting") {
3449
+ throw new LifecycleError("relaybus was stopped before it finished starting");
2146
3450
  }
2147
- if (this.#retention.completedMs !== null || this.#retention.deadMs !== null) {
2148
- this.#retentionTimer = setInterval(() => {
2149
- this.purgeExpired().catch(() => void 0);
2150
- }, this.#retention.intervalMs);
2151
- this.#retentionTimer.unref();
3451
+ if (this.#parts.dispatcherEnabled) {
3452
+ this.#parts.dispatcher.start();
2152
3453
  }
2153
- this.#state = "running";
2154
- this.#logger.log(
2155
- `[RelayBus](start) relaybus started | handlers=${this.#registry.all().length} dispatcher=${this.#dispatcherEnabled}`
3454
+ this.#parts.maintenance.start();
3455
+ this.#lifecycle.markRunning();
3456
+ this.#parts.logger.log(
3457
+ `[RelayBus](startComponents) relaybus started | handlers=${this.#parts.registry.all().length} dispatcher=${this.#parts.dispatcherEnabled}`
2156
3458
  );
2157
3459
  }
2158
- async #stopComponents() {
2159
- this.#state = "stopping";
2160
- clearInterval(this.#retentionTimer);
2161
- try {
2162
- await this.#dispatcher.stop();
2163
- await this.#closeTransports();
2164
- await this.#store.close?.();
2165
- } finally {
2166
- this.#state = "stopped";
2167
- }
2168
- }
2169
- async #publish(events, options, id) {
2170
- if (events.length === 0) {
2171
- return [];
2172
- }
2173
- const now = this.#clock.now();
2174
- const delayMs = options.delay === void 0 ? 0 : toMilliseconds(options.delay, "delay");
2175
- const availableAt = new Date(now.getTime() + delayMs);
2176
- const scope = getCurrentScope();
2177
- const entries = [];
2178
- let correlationId = options.correlationId ?? scope?.event?.correlationId ?? scope?.event?.id;
2179
- for (const event of events) {
2180
- const envelope = await this.#createEnvelope(event, options, id, scope, now, correlationId);
2181
- correlationId = envelope.correlationId;
2182
- const registrations = this.#registry.forEventType(envelope.type);
2183
- if (registrations.length === 0) {
2184
- this.#reportUnrouted(envelope);
2185
- }
2186
- entries.push({
2187
- event: envelope,
2188
- deliveries: registrations.map(
2189
- (registration) => this.#createDelivery(registration, availableAt)
2190
- )
2191
- });
2192
- }
2193
- const deliverable = entries.filter((entry) => entry.deliveries.length > 0);
2194
- if (deliverable.length > 0) {
2195
- const result = await this.#appendInTransaction(deliverable, options, scope, now);
2196
- const appended = new Set(result.appendedEventIds);
2197
- const published = deliverable.map((entry) => entry.event).filter((envelope) => appended.has(envelope.id));
2198
- if (published.length > 0) {
2199
- this.#notifier.notify("onPublished", {
2200
- envelopes: published,
2201
- deliveries: result.appendedDeliveryCount
2202
- });
2203
- if (delayMs === 0) {
2204
- this.#scheduleWake();
2205
- }
3460
+ /** Closes the transports a failed start may have connected, within the shutdown timeout. */
3461
+ #closeAfterFailedStart() {
3462
+ return withinDeadline(
3463
+ { clock: this.#parts.clock, timeoutMs: this.#parts.shutdownTimeoutMs },
3464
+ async (deadline) => {
3465
+ await this.#closeTransports(deadline);
3466
+ return 0;
2206
3467
  }
2207
- }
2208
- return entries.map((entry) => entry.event);
3468
+ );
2209
3469
  }
2210
3470
  #reportUnrouted(envelope) {
2211
3471
  const message = `No handler or route for event type, event not stored | type=${envelope.type} id=${envelope.id}`;
2212
- if (this.#unroutedEvents === "throw") {
3472
+ if (this.#parts.unroutedEvents === "throw") {
2213
3473
  throw new ConfigurationError(message);
2214
3474
  }
2215
- this.#logger.warn(`[RelayBus](publish) ${message}`);
2216
- }
2217
- async #appendInTransaction(entries, options, scope, now) {
2218
- const transaction = options.transaction ?? scope?.transaction ?? this.#transactionResolver?.();
2219
- if (transaction !== void 0) {
2220
- return this.#store.append(transaction, entries, now);
2221
- }
2222
- if (this.#transactionMode === "required") {
2223
- throw new TransactionRequiredError(
2224
- "publish found no transaction. Pass { transaction }, publish inside bus.transaction(), configure transactionResolver, or set transactionMode to optional"
2225
- );
2226
- }
2227
- return this.#store.transaction(
2228
- (ownTransaction) => this.#store.append(ownTransaction, entries, now)
2229
- );
2230
- }
2231
- async #createEnvelope(event, options, id, scope, now, correlationId) {
2232
- if (typeof event.type !== "string") {
2233
- throw new ConfigurationError(
2234
- "publish expects an instance of a class created with defineEvent"
2235
- );
2236
- }
2237
- assertEventType(event.type);
2238
- const schema = event.constructor.schema;
2239
- const payload = await validatePayload(event.type, schema, event.payload);
2240
- if (options.key !== void 0 && options.key.length === 0) {
2241
- throw new ConfigurationError("key must not be empty");
2242
- }
2243
- const eventId = id ?? this.#generateIdentifier();
2244
- return {
2245
- id: eventId,
2246
- type: event.type,
2247
- source: this.#source,
2248
- key: options.key ?? null,
2249
- payload,
2250
- headers: { ...options.headers },
2251
- correlationId: correlationId ?? eventId,
2252
- causationId: scope?.event?.id ?? null,
2253
- occurredAt: now
2254
- };
2255
- }
2256
- #createDelivery(registration, availableAt) {
2257
- return { id: this.#generateIdentifier(), handler: registration.id, availableAt };
3475
+ this.#parts.logger.warn(`[RelayBus](reportUnrouted) ${message}`);
2258
3476
  }
2259
3477
  #registrationsOf(eventType) {
2260
- const registrations = this.#registry.forEventType(eventType);
3478
+ const registrations = this.#parts.registry.forEventType(eventType);
2261
3479
  const routes = registrations.filter((registration) => registration.forwardsToTransport).length;
2262
3480
  return { handlers: registrations.length - routes, routes };
2263
3481
  }
2264
- #registerRoute(route) {
2265
- const transport = this.#requireTransport(route.transport);
2266
- const { type } = describeEvent(route.event);
2267
- const destination = route.destination ?? type;
2268
- this.#registry.register(
2269
- {
2270
- eventType: type,
2271
- schema: void 0,
2272
- handle: (envelope) => transport.publish(envelope, destination),
2273
- options: {
2274
- id: `${TRANSPORT_HANDLER_PREFIX}:${transport.name}:${destination}:${type}`,
2275
- ...route.retry === void 0 ? {} : { retry: route.retry },
2276
- ...route.timeout === void 0 ? {} : { timeout: route.timeout }
2277
- },
2278
- forwardsToTransport: true
2279
- },
2280
- { retry: DEFAULT_ROUTE_RETRY_POLICY, timeoutMs: DEFAULTS.routeTimeoutMs }
2281
- );
2282
- }
2283
- #requireTransport(name) {
2284
- const transport = this.#transports.get(name);
2285
- if (transport === void 0) {
2286
- throw new ConfigurationError(`No transport named '${name}' is configured`);
2287
- }
2288
- return transport;
2289
- }
2290
3482
  #scheduleWake() {
2291
- if (this.#state !== "running" || this.#dispatcher.watchesNewDeliveries) {
3483
+ if (this.#lifecycle.state !== "running" || this.#parts.dispatcher.watchesNewDeliveries) {
2292
3484
  return;
2293
3485
  }
2294
- setTimeout(() => this.#dispatcher.wake(), WAKE_DELAY_MS).unref();
3486
+ schedule(this.#parts.clock, {
3487
+ run: () => this.#parts.dispatcher.wake(),
3488
+ delayMs: WAKE_DELAY_MS,
3489
+ unref: true
3490
+ });
2295
3491
  }
2296
- async #closeTransports() {
2297
- for (const transport of this.#transports.values()) {
3492
+ /** Closes every transport, each at most until the deadline; past it they are asked to force. */
3493
+ async #closeTransports(deadline) {
3494
+ const options = { signal: deadline.signal };
3495
+ for (const transport of this.#parts.connections.transports) {
2298
3496
  try {
2299
- await transport.close();
3497
+ await deadline.race(transport.close(options));
2300
3498
  } catch (error) {
2301
- this.#logger.error(
3499
+ this.#parts.logger.error(
2302
3500
  `[RelayBus](closeTransports) Failed to close transport | transport=${transport.name} error=${toErrorInfo(error).message}`
2303
3501
  );
2304
3502
  }
2305
3503
  }
2306
3504
  }
2307
3505
  };
2308
- function toDescriptor(registration) {
2309
- return {
2310
- id: registration.id,
2311
- eventType: registration.eventType,
2312
- retry: registration.retry,
2313
- timeoutMs: registration.timeoutMs,
2314
- concurrency: registration.concurrency,
2315
- transactional: registration.transactional,
2316
- forwardsToTransport: registration.forwardsToTransport
2317
- };
2318
- }
2319
- function describeEvent(event) {
2320
- if (typeof event === "string") {
2321
- assertEventType(event);
2322
- return { type: event, schema: void 0 };
2323
- }
2324
- assertEventType(event.type);
2325
- return { type: event.type, schema: event.schema };
2326
- }
2327
- function normalizeEnvelope(envelope) {
2328
- if (typeof envelope.id !== "string" || envelope.id.length === 0) {
2329
- throw new InvalidEnvelopeError("Received event has no id");
2330
- }
2331
- if (typeof envelope.type !== "string") {
2332
- throw new InvalidEnvelopeError(`Received event ${envelope.id} has no type`);
2333
- }
2334
- try {
2335
- assertEventType(envelope.type);
2336
- } catch (error) {
2337
- throw new InvalidEnvelopeError(toErrorInfo(error).message);
2338
- }
2339
- const occurredAt = toDate(envelope.occurredAt);
2340
- if (occurredAt === void 0) {
2341
- throw new InvalidEnvelopeError(`Received event ${envelope.id} has an invalid occurredAt`);
2342
- }
2343
- return {
2344
- id: envelope.id,
2345
- type: envelope.type,
2346
- source: typeof envelope.source === "string" ? envelope.source : "unknown",
2347
- key: optionalText(envelope.key),
2348
- payload: envelope.payload,
2349
- headers: { ...envelope.headers },
2350
- correlationId: optionalText(envelope.correlationId),
2351
- causationId: optionalText(envelope.causationId),
2352
- occurredAt
2353
- };
2354
- }
2355
- function toDate(value) {
2356
- const date = typeof value === "string" ? new Date(value) : value;
2357
- return date instanceof Date && !Number.isNaN(date.getTime()) ? date : void 0;
2358
- }
2359
- function optionalText(value) {
2360
- return typeof value === "string" && value.length > 0 ? value : null;
2361
- }
2362
- function oneOf(value, allowed, name) {
2363
- if (!allowed.includes(value)) {
2364
- throw new ConfigurationError(
2365
- `${name} must be one of ${allowed.map((option) => `'${option}'`).join(", ")}, received '${String(value)}'`
2366
- );
2367
- }
2368
- return value;
2369
- }
2370
- function positiveInteger(value, name) {
2371
- if (!Number.isInteger(value) || value < 1) {
2372
- throw new ConfigurationError(`${name} must be a positive integer, received ${value}`);
2373
- }
2374
- return value;
2375
- }
2376
- function createDispatcherSettings(options, generateIdentifier) {
2377
- const concurrency = positiveInteger(
2378
- options?.concurrency ?? DEFAULTS.concurrency,
2379
- "dispatcher.concurrency"
2380
- );
2381
- return {
2382
- owner: `${hostname()}:${process.pid}:${generateIdentifier()}`,
2383
- concurrency,
2384
- batchSize: positiveInteger(options?.batchSize ?? concurrency, "dispatcher.batchSize"),
2385
- pollIntervalMs: toTimerMilliseconds(
2386
- options?.pollInterval ?? DEFAULTS.pollInterval,
2387
- "dispatcher.pollInterval"
2388
- ),
2389
- leaseDurationMs: toTimerMilliseconds(
2390
- options?.leaseDuration ?? DEFAULTS.leaseDuration,
2391
- "dispatcher.leaseDuration"
2392
- ),
2393
- shutdownTimeoutMs: toTimerMilliseconds(
2394
- options?.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
2395
- "dispatcher.shutdownTimeout"
2396
- ),
2397
- completionBatchWindowMs: toMilliseconds(
2398
- options?.completionBatchWindow ?? DEFAULTS.completionBatchWindow,
2399
- "dispatcher.completionBatchWindow"
2400
- ),
2401
- completionBatchSize: positiveInteger(
2402
- options?.completionBatchSize ?? DEFAULTS.completionBatchSize,
2403
- "dispatcher.completionBatchSize"
2404
- )
2405
- };
2406
- }
2407
- function createRetentionSettings(options) {
2408
- const completed = options?.completed ?? DEFAULTS.completedRetention;
2409
- const dead = options?.dead ?? false;
2410
- return {
2411
- completedMs: completed === false ? null : toMilliseconds(completed, "retention.completed"),
2412
- deadMs: dead === false ? null : toMilliseconds(dead, "retention.dead"),
2413
- intervalMs: toTimerMilliseconds(
2414
- options?.interval ?? DEFAULTS.retentionInterval,
2415
- "retention.interval"
2416
- )
2417
- };
2418
- }
2419
3506
 
2420
- export { CatalogPublisher, ConfigurationError, DEFAULT_RETRY_POLICY, DEFAULT_ROUTE_RETRY_POLICY, DELIVERY_STATUSES, DispatcherStoppedError, EventValidationError, HandlerTimeoutError, InMemoryEventStore, InMemoryTransaction, InvalidEnvelopeError, LeaseLostError, MAX_TIMER_DELAY_MS, NonRetryableError, RelayBus, RelayBusError, TransactionRequiredError, assertEventType, computeRetryDelay, consume, createRetryPolicy, createUuidV7, defineCatalog, defineEvent, defineHandlers, describeCatalog, isRetryableError, produce, systemClock, toErrorInfo, toMilliseconds, validatePayload };
3507
+ export { CatalogPublisher, DEFAULT_RETRY_POLICY, DEFAULT_ROUTE_RETRY_POLICY, DELIVERY_STATUSES, InMemoryEventStore, InMemoryTransaction, RelayBus, TRACEPARENT_HEADER, TRACESTATE_HEADER, computeRetryDelay, consume, createRetryPolicy, createUuidV7, defineCatalog, defineHandlers, describeCatalog, isRetryableError, produce, systemClock, traceContextOf };
2421
3508
  //# sourceMappingURL=index.js.map
2422
3509
  //# sourceMappingURL=index.js.map