@zdavison/matador 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/core/fanout.d.ts +51 -0
  2. package/dist/core/fanout.d.ts.map +1 -1
  3. package/dist/core/fanout.js +141 -28
  4. package/dist/core/fanout.test.js +533 -7
  5. package/dist/core/matador.d.ts +33 -0
  6. package/dist/core/matador.d.ts.map +1 -1
  7. package/dist/core/matador.js +9 -2
  8. package/dist/core/matador.test.js +336 -7
  9. package/dist/errors/index.d.ts +1 -1
  10. package/dist/errors/index.d.ts.map +1 -1
  11. package/dist/errors/index.js +2 -2
  12. package/dist/errors/matador-errors.d.ts +10 -8
  13. package/dist/errors/matador-errors.d.ts.map +1 -1
  14. package/dist/errors/matador-errors.js +18 -14
  15. package/dist/index.cjs +326 -92
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/pipeline/pipeline.d.ts +30 -2
  23. package/dist/pipeline/pipeline.d.ts.map +1 -1
  24. package/dist/pipeline/pipeline.js +61 -9
  25. package/dist/pipeline/pipeline.test.js +123 -0
  26. package/dist/retry/index.d.ts +1 -1
  27. package/dist/retry/index.d.ts.map +1 -1
  28. package/dist/retry/policy.d.ts +25 -0
  29. package/dist/retry/policy.d.ts.map +1 -1
  30. package/dist/retry/standard-policy.d.ts +19 -1
  31. package/dist/retry/standard-policy.d.ts.map +1 -1
  32. package/dist/retry/standard-policy.js +39 -2
  33. package/dist/retry/standard-policy.test.js +65 -0
  34. package/dist/topology/builder.test.js +7 -0
  35. package/dist/topology/types.d.ts +14 -0
  36. package/dist/topology/types.d.ts.map +1 -1
  37. package/dist/transport/local/local-transport.d.ts +6 -0
  38. package/dist/transport/local/local-transport.d.ts.map +1 -1
  39. package/dist/transport/local/local-transport.js +35 -18
  40. package/dist/transport/local/local-transport.test.js +40 -4
  41. package/dist/transport/multi/multi-transport.d.ts.map +1 -1
  42. package/dist/transport/multi/multi-transport.js +3 -0
  43. package/dist/transport/multi/multi-transport.test.js +82 -0
  44. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
  45. package/dist/transport/rabbitmq/rabbitmq-transport.js +34 -27
  46. package/dist/transport/rabbitmq/rabbitmq-transport.test.js +28 -0
  47. package/dist/transport/transport.d.ts +6 -0
  48. package/dist/transport/transport.d.ts.map +1 -1
  49. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -202,6 +202,16 @@ var DelayedMessagesNotSupportedError = class extends MatadorError {
202
202
  transportName;
203
203
  description = "Delayed messages were requested but the transport does not support them. ACTION: For RabbitMQ, install the rabbitmq_delayed_message_exchange plugin. Run: rabbitmq-plugins enable rabbitmq_delayed_message_exchange Then restart RabbitMQ and reconnect. Alternatively, remove delayMs from your event options if delays are not required.";
204
204
  };
205
+ var LocalTransportNoActiveSubscriberError = class extends MatadorError {
206
+ constructor(queue) {
207
+ super(
208
+ `LocalTransport has no active subscriber for queue "${queue}"; message cannot be delivered.`
209
+ );
210
+ this.queue = queue;
211
+ }
212
+ queue;
213
+ description = "The LocalTransport has no active subscriber for this queue in the current process, so it cannot guarantee delivery. ACTION: If the subscriber is a SubscriberStub (implemented in another service), the local transport can never deliver it locally \u2014 a distributed transport like RabbitMQ is required. If the subscriber should run in this process, ensure matador.start() has subscribed to this queue before sending.";
214
+ };
205
215
  var EventNotRegisteredError = class extends MatadorError {
206
216
  constructor(eventKey) {
207
217
  super(
@@ -252,16 +262,6 @@ var SubscriberIsStubError = class extends MatadorError {
252
262
  subscriberName;
253
263
  description = "A SubscriberStub was registered in a consuming schema. ACTION: SubscriberStubs should only be used in producer schemas to declare that a subscriber exists in another service. In the consumer service, provide a full Subscriber with a callback function. Remove the stub from the consumer schema and add the actual implementation.";
254
264
  };
255
- var LocalTransportCannotProcessStubError = class extends MatadorError {
256
- constructor(subscriberName) {
257
- super(
258
- `LocalTransport cannot process stub subscriber "${subscriberName}". Stub subscribers require a distributed transport like RabbitMQ.`
259
- );
260
- this.subscriberName = subscriberName;
261
- }
262
- subscriberName;
263
- description = "The LocalTransport cannot process events for SubscriberStubs. ACTION: SubscriberStubs represent remote implementations that only RabbitMQ can route. If using LocalTransport for testing, provide mock implementations instead of stubs. For production fallback scenarios, be aware that stub-targeted events will be dropped.";
264
- };
265
265
  var UnknownQueueReferenceError = class extends MatadorError {
266
266
  constructor(queueName) {
267
267
  super(
@@ -801,6 +801,7 @@ function createSubscriberStub(input) {
801
801
  }
802
802
 
803
803
  // src/core/fanout.ts
804
+ var DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES = 10;
804
805
  var FanoutEngine = class {
805
806
  transport;
806
807
  schema;
@@ -808,9 +809,13 @@ var FanoutEngine = class {
808
809
  topology;
809
810
  defaultQueue;
810
811
  enqueuingCount = 0;
812
+ flushInFlightCount = 0;
811
813
  retryBuffer = [];
812
814
  maxRetryBufferSize;
815
+ maxRetryAttempts;
816
+ maxConsecutiveFlushFailures;
813
817
  disposeOnConnected;
818
+ retryTimer;
814
819
  constructor(config) {
815
820
  this.transport = config.transport;
816
821
  this.schema = config.schema;
@@ -818,18 +823,29 @@ var FanoutEngine = class {
818
823
  this.topology = config.topology;
819
824
  this.defaultQueue = config.defaultQueue;
820
825
  this.maxRetryBufferSize = config.maxRetryBufferSize ?? 5e3;
826
+ this.maxRetryAttempts = config.maxRetryAttempts;
827
+ const maxConsecutiveFlushFailures = config.maxConsecutiveFlushFailures ?? DEFAULT_MAX_CONSECUTIVE_FLUSH_FAILURES;
828
+ this.maxConsecutiveFlushFailures = maxConsecutiveFlushFailures > 0 ? maxConsecutiveFlushFailures : Number.POSITIVE_INFINITY;
821
829
  this.disposeOnConnected = this.transport.onConnected?.(() => {
822
830
  void this.flushRetryBuffer();
823
831
  });
832
+ const retryIntervalMs = config.retryIntervalMs ?? 3e4;
833
+ if (retryIntervalMs > 0) {
834
+ this.retryTimer = setInterval(() => {
835
+ void this.flushRetryBuffer();
836
+ }, retryIntervalMs);
837
+ this.retryTimer.unref?.();
838
+ }
824
839
  }
825
840
  dispose() {
826
841
  this.disposeOnConnected?.();
842
+ clearInterval(this.retryTimer);
827
843
  }
828
844
  /**
829
845
  * Current count of events being enqueued.
830
846
  */
831
847
  get eventsBeingEnqueuedCount() {
832
- return this.enqueuingCount;
848
+ return this.enqueuingCount + this.flushInFlightCount;
833
849
  }
834
850
  /**
835
851
  * Sends an event to all registered subscribers.
@@ -883,10 +899,17 @@ var FanoutEngine = class {
883
899
  queue: qualifiedQueue,
884
900
  envelope,
885
901
  sendOptions,
886
- subscriberName: subscriber.name
902
+ subscriberName: subscriber.name,
903
+ attempts: 0
887
904
  });
888
905
  this.hooks.logger.warn(
889
- `[Matador] \u{1F7E1} Message for '${subscriber.name}' buffered for retry on reconnect (buffer: ${this.retryBuffer.length}/${this.maxRetryBufferSize}).`
906
+ `[Matador] \u{1F7E1} Message for '${subscriber.name}' buffered for retry on reconnect.`,
907
+ {
908
+ queue: qualifiedQueue,
909
+ subscriberName: subscriber.name,
910
+ bufferSize: this.retryBuffer.length,
911
+ maxBufferSize: this.maxRetryBufferSize
912
+ }
890
913
  );
891
914
  if (options.throwOnBufferedFailure) {
892
915
  const err = new TransportSendError(qualifiedQueue, cause);
@@ -930,6 +953,31 @@ var FanoutEngine = class {
930
953
  errors
931
954
  };
932
955
  }
956
+ /**
957
+ * Attempts a single buffered item during a flush pass
958
+ * @returns true if the attempt succeeded; false on failure
959
+ */
960
+ async attemptFlushItem(item) {
961
+ this.enqueuingCount++;
962
+ try {
963
+ const usedTransport = await this.transport.send(
964
+ item.queue,
965
+ item.envelope,
966
+ item.sendOptions
967
+ );
968
+ await this.hooks.onEnqueueSuccess({
969
+ envelope: item.envelope,
970
+ queue: item.queue,
971
+ transport: usedTransport
972
+ });
973
+ return true;
974
+ } catch (error) {
975
+ await this.handleFlushFailure(item, error);
976
+ return false;
977
+ } finally {
978
+ this.enqueuingCount--;
979
+ }
980
+ }
933
981
  /**
934
982
  * Flushes the retry buffer
935
983
  *
@@ -941,39 +989,36 @@ var FanoutEngine = class {
941
989
  `[Matador] \u23F3 Flushing ${this.retryBuffer.length} buffered message(s)...`
942
990
  );
943
991
  const toFlush = this.retryBuffer.splice(0);
944
- for (const item of toFlush) {
945
- this.enqueuingCount++;
946
- try {
947
- const usedTransport = await this.transport.send(
948
- item.queue,
949
- item.envelope,
950
- item.sendOptions
951
- );
952
- await this.hooks.onEnqueueSuccess({
953
- envelope: item.envelope,
954
- queue: item.queue,
955
- transport: usedTransport
956
- });
957
- } catch (error) {
958
- if (this.retryBuffer.length < this.maxRetryBufferSize) {
959
- this.retryBuffer.push(item);
960
- } else {
961
- const cause = error instanceof Error ? error : new Error(String(error));
962
- const err = new TransportSendError(item.queue, cause);
963
- await this.hooks.onEnqueueError({
964
- envelope: item.envelope,
965
- error: err,
966
- transport: this.transport.name
967
- });
992
+ this.flushInFlightCount += toFlush.length;
993
+ let consecutiveFailures = 0;
994
+ try {
995
+ for (let i = 0; i < toFlush.length; i++) {
996
+ const item = toFlush[i];
997
+ if (!item) {
998
+ this.flushInFlightCount--;
999
+ continue;
1000
+ }
1001
+ const succeeded = await this.attemptFlushItem(item);
1002
+ this.flushInFlightCount--;
1003
+ const randomMs = Math.random() * 10;
1004
+ await new Promise((resolve) => setTimeout(resolve, randomMs));
1005
+ consecutiveFailures = succeeded ? 0 : consecutiveFailures + 1;
1006
+ if (consecutiveFailures >= this.maxConsecutiveFlushFailures) {
1007
+ const untried = toFlush.slice(i + 1);
1008
+ this.flushInFlightCount -= untried.length;
1009
+ if (untried.length > 0) {
1010
+ await this.rebufferUntried(untried, consecutiveFailures);
1011
+ }
1012
+ break;
968
1013
  }
969
- } finally {
970
- this.enqueuingCount--;
971
1014
  }
1015
+ } finally {
1016
+ this.flushInFlightCount = 0;
972
1017
  }
973
1018
  const remaining = this.retryBuffer.length;
974
1019
  if (remaining > 0) {
975
1020
  this.hooks.logger.warn(
976
- `[Matador] \u{1F7E1} ${remaining} buffered message(s) could not be flushed; will retry on next reconnect.`
1021
+ `[Matador] \u{1F7E1} ${remaining} buffered message(s) could not be flushed; will retry later.`
977
1022
  );
978
1023
  } else {
979
1024
  this.hooks.logger.info(
@@ -981,6 +1026,67 @@ var FanoutEngine = class {
981
1026
  );
982
1027
  }
983
1028
  }
1029
+ /**
1030
+ * Re-buffers messages that were never attempted because a flush pass
1031
+ * stopped out early. Respects maxRetryBufferSize: concurrent sends can have
1032
+ * refilled the buffer while this flush was running, so anything beyond
1033
+ * remaining capacity is dropped and reported instead of silently growing
1034
+ * the buffer past its cap.
1035
+ */
1036
+ async rebufferUntried(untried, consecutiveFailures) {
1037
+ const capacity = Math.max(
1038
+ 0,
1039
+ this.maxRetryBufferSize - this.retryBuffer.length
1040
+ );
1041
+ const toRebuffer = untried.slice(0, capacity);
1042
+ const dropped = untried.slice(capacity);
1043
+ if (toRebuffer.length > 0) {
1044
+ this.retryBuffer.unshift(...toRebuffer);
1045
+ }
1046
+ const droppedSuffix = dropped.length > 0 ? `, ${dropped.length} dropped (buffer full).` : ".";
1047
+ this.hooks.logger.warn(
1048
+ `[Matador] \u{1F7E1} Stopping this flush pass after ${consecutiveFailures} consecutive failures; ${toRebuffer.length} untried message(s) re-buffered for the next attempt${droppedSuffix}`
1049
+ );
1050
+ for (const item of dropped) {
1051
+ this.hooks.logger.error(
1052
+ `[Matador] \u{1F534} Retry buffer full (${this.maxRetryBufferSize}). Message for '${item.subscriberName}' dropped and will not be retried.`
1053
+ );
1054
+ const err = new TransportSendError(
1055
+ item.queue,
1056
+ new Error("Retry buffer full")
1057
+ );
1058
+ await this.hooks.onEnqueueError({
1059
+ envelope: item.envelope,
1060
+ error: err,
1061
+ transport: this.transport.name
1062
+ });
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Handles a failed flush attempt for a single buffered item: drops it once
1067
+ * maxRetryAttempts is exceeded, otherwise re-buffers it (unless the buffer
1068
+ * is full, in which case it's dropped too).
1069
+ */
1070
+ async handleFlushFailure(item, error) {
1071
+ item.attempts++;
1072
+ const cause = error instanceof Error ? error : new Error(String(error));
1073
+ const exceededAttempts = this.maxRetryAttempts !== void 0 && item.attempts >= this.maxRetryAttempts;
1074
+ if (!exceededAttempts && this.retryBuffer.length < this.maxRetryBufferSize) {
1075
+ this.retryBuffer.push(item);
1076
+ return;
1077
+ }
1078
+ if (exceededAttempts) {
1079
+ this.hooks.logger.error(
1080
+ `[Matador] \u{1F534} Message for '${item.subscriberName}' exceeded max retry attempts (${this.maxRetryAttempts}) and will not be retried further.`
1081
+ );
1082
+ }
1083
+ const err = new TransportSendError(item.queue, cause);
1084
+ await this.hooks.onEnqueueError({
1085
+ envelope: item.envelope,
1086
+ error: err,
1087
+ transport: this.transport.name
1088
+ });
1089
+ }
984
1090
  async isSubscriberEnabled(subscriber) {
985
1091
  if (!subscriber.enabled) {
986
1092
  return true;
@@ -1787,10 +1893,20 @@ var ProcessingPipeline = class {
1787
1893
  durationMs: performance.now() - startTime
1788
1894
  };
1789
1895
  }
1896
+ const isResumable = isResumableSubscriber(subscriber);
1897
+ const shouldProcessResult = await this.handleShouldProcess(
1898
+ envelope,
1899
+ subscriberDef,
1900
+ isResumable,
1901
+ receipt,
1902
+ startTime
1903
+ );
1904
+ if (!shouldProcessResult.success) {
1905
+ return shouldProcessResult;
1906
+ }
1790
1907
  let result;
1791
1908
  let error;
1792
1909
  let context;
1793
- const isResumable = isResumableSubscriber(subscriber);
1794
1910
  let existingCheckpoint;
1795
1911
  if (isResumable) {
1796
1912
  existingCheckpoint = await this.checkpointStore.get(envelope.id);
@@ -1866,10 +1982,65 @@ var ProcessingPipeline = class {
1866
1982
  subscriber: subscriberDef,
1867
1983
  receipt
1868
1984
  });
1985
+ return await this.handleFailure(
1986
+ error,
1987
+ decision,
1988
+ envelope,
1989
+ subscriberDef,
1990
+ isResumable,
1991
+ receipt,
1992
+ durationMs
1993
+ );
1994
+ }
1995
+ /**
1996
+ * Runs the retry policy's pre-processing check.
1997
+ *
1998
+ * This returns a terminal `ProcessResult` if the message should be skipped (dead-lettered/discarded) without ever invoking the callback,
1999
+ * or a success result if shouldProcess passes and we should continue processing the message.
2000
+ * @param envelope - The message envelope
2001
+ * @param subscriberDef - The subscriber definition
2002
+ * @param isResumable - Whether the subscriber is resumable
2003
+ * @param receipt - The message receipt
2004
+ * @param startTime - The start time of the processing
2005
+ * @returns A `ProcessResult` indicating if we should continue with processing the message, or fail it preemptively
2006
+ */
2007
+ async handleShouldProcess(envelope, subscriberDef, isResumable, receipt, startTime) {
2008
+ const decision = this.retryPolicy.shouldProcess({ envelope, receipt });
2009
+ if (decision.action === "process") {
2010
+ return {
2011
+ success: true,
2012
+ durationMs: 0
2013
+ };
2014
+ }
2015
+ const durationMs = performance.now() - startTime;
2016
+ const error = new Error(decision.reason);
2017
+ return await this.handleFailure(
2018
+ error,
2019
+ decision,
2020
+ envelope,
2021
+ subscriberDef,
2022
+ isResumable,
2023
+ receipt,
2024
+ durationMs
2025
+ );
2026
+ }
2027
+ /**
2028
+ * Performs failure handling and cleanup based on retry decision (e.g. dead-letter).
2029
+ *
2030
+ * @param error - The error from the processing attempt
2031
+ * @param decision - The decision from executing the appropriate retry policy check
2032
+ * @param envelope - The message envelope
2033
+ * @param subscriberDef - The subscriber definition
2034
+ * @param isResumable - Whether the subscriber is resumable
2035
+ * @param receipt - The message receipt
2036
+ * @param durationMs - The duration of processing
2037
+ * @returns A `ProcessResult` indicating failure
2038
+ */
2039
+ async handleFailure(error, decision, envelope, subscriberDef, isResumable, receipt, durationMs) {
1869
2040
  envelope.docket.lastError = error.message;
1870
2041
  envelope.docket.firstError ??= error.message;
1871
- if (decision.action === "dead-letter" && context) {
1872
- await context.clear();
2042
+ if (decision.action === "dead-letter" && isResumable) {
2043
+ await this.checkpointStore.delete(envelope.id);
1873
2044
  await this.hooks.onCheckpointCleared?.({
1874
2045
  envelope,
1875
2046
  subscriber: subscriberDef,
@@ -1975,15 +2146,31 @@ var StandardRetryPolicy = class {
1975
2146
  constructor(config = {}) {
1976
2147
  this.config = { ...defaultRetryConfig, ...config };
1977
2148
  }
2149
+ /**
2150
+ * Check if the message should be dead-lettered before the subscriber callback is invoked
2151
+ *
2152
+ * Uses the same delivery-count threshold as `shouldRetry` poison check, so an
2153
+ * already-poisoned message is dead-lettered without ever running the callback again.
2154
+ *
2155
+ * @param context - The shouldProcess context.
2156
+ * @returns A 'process' decision if the message is not poisoned; a dead-letter decision otherwise.
2157
+ */
2158
+ shouldProcess(context) {
2159
+ const poisonError = this.checkPoisoned(context.envelope, context.receipt);
2160
+ if (poisonError) {
2161
+ return {
2162
+ action: "dead-letter",
2163
+ queue: "undeliverable",
2164
+ reason: poisonError.message
2165
+ };
2166
+ }
2167
+ return { action: "process" };
2168
+ }
1978
2169
  shouldRetry(context) {
1979
2170
  const { envelope, error, subscriber, receipt } = context;
1980
2171
  const errorMessage = error.message;
1981
- if (receipt.deliveryCount >= this.config.maxDeliveries) {
1982
- const poisonError = new MessageMaybePoisonedError(
1983
- envelope.id,
1984
- receipt.deliveryCount,
1985
- this.config.maxDeliveries
1986
- );
2172
+ const poisonError = this.checkPoisoned(envelope, receipt);
2173
+ if (poisonError) {
1987
2174
  return {
1988
2175
  action: "dead-letter",
1989
2176
  queue: "undeliverable",
@@ -2045,6 +2232,23 @@ var StandardRetryPolicy = class {
2045
2232
  const delay = this.config.baseDelay * this.config.backoffMultiplier ** (attempt - 1);
2046
2233
  return Math.min(delay, this.config.maxDelay);
2047
2234
  }
2235
+ /**
2236
+ * Checks the native delivery count against the poison threshold
2237
+ *
2238
+ * @param envelope - The message envelope.
2239
+ * @param receipt - The message receipt.
2240
+ * @returns A MessageMaybePoisonedError if the message is poisoned; null otherwise
2241
+ */
2242
+ checkPoisoned(envelope, receipt) {
2243
+ if (receipt.deliveryCount >= this.config.maxDeliveries) {
2244
+ return new MessageMaybePoisonedError(
2245
+ envelope.id,
2246
+ receipt.deliveryCount,
2247
+ this.config.maxDeliveries
2248
+ );
2249
+ }
2250
+ return null;
2251
+ }
2048
2252
  };
2049
2253
 
2050
2254
  // src/schema/types.ts
@@ -2273,7 +2477,11 @@ var Matador = class {
2273
2477
  schema: this.schema,
2274
2478
  hooks: this.hooks,
2275
2479
  topology: this.topology,
2276
- defaultQueue
2480
+ defaultQueue,
2481
+ maxRetryBufferSize: config.maxRetryBufferSize,
2482
+ maxRetryAttempts: config.maxRetryAttempts,
2483
+ retryIntervalMs: config.retryIntervalMs,
2484
+ maxConsecutiveFlushFailures: config.maxConsecutiveFlushFailures
2277
2485
  });
2278
2486
  this.shutdownManager = new ShutdownManager(
2279
2487
  () => this.fanout.eventsBeingEnqueuedCount,
@@ -2351,6 +2559,7 @@ var Matador = class {
2351
2559
  "[Matador] \u{1F7E1} Worker not subscribing to any queues (consumeFrom is empty)."
2352
2560
  );
2353
2561
  }
2562
+ this.started = true;
2354
2563
  for (const queueName of this.consumeFrom) {
2355
2564
  const qualifiedName = resolveTargetQueueName(this.topology, queueName);
2356
2565
  const queueDef = findQueueDefinition(this.topology, queueName);
@@ -2369,7 +2578,6 @@ var Matador = class {
2369
2578
  );
2370
2579
  this.subscriptions.push(subscription);
2371
2580
  }
2372
- this.started = true;
2373
2581
  }
2374
2582
  async send(eventOrClass, dataOrOptions, maybeOptions) {
2375
2583
  if (!this.started) {
@@ -2459,7 +2667,7 @@ var Matador = class {
2459
2667
  }
2460
2668
  async unsubscribeAll() {
2461
2669
  for (const subscription of this.subscriptions) {
2462
- await subscription.unsubscribe();
2670
+ await (subscription.pauseForShutdown ?? subscription.unsubscribe)();
2463
2671
  }
2464
2672
  this.subscriptions.length = 0;
2465
2673
  }
@@ -2621,6 +2829,7 @@ var localCapabilities = {
2621
2829
  ordering: "queue",
2622
2830
  priorities: false
2623
2831
  };
2832
+ var MAX_COMPLETED_MESSAGES = 1e3;
2624
2833
  var LocalTransport = class {
2625
2834
  name = "local";
2626
2835
  capabilities = localCapabilities;
@@ -2664,7 +2873,7 @@ var LocalTransport = class {
2664
2873
  topology.prefix
2665
2874
  );
2666
2875
  if (!this.queues.has(queueName)) {
2667
- this.queues.set(queueName, []);
2876
+ this.queues.set(queueName, /* @__PURE__ */ new Map());
2668
2877
  }
2669
2878
  }
2670
2879
  }
@@ -2696,6 +2905,18 @@ var LocalTransport = class {
2696
2905
  this.delayedTimers.add(timer);
2697
2906
  }
2698
2907
  async enqueue(queue, envelope) {
2908
+ const subs = this.subscriptions.get(queue);
2909
+ if (!subs || subs.length === 0) {
2910
+ throw new LocalTransportNoActiveSubscriberError(queue);
2911
+ }
2912
+ await this.storeAndDeliver(queue, envelope);
2913
+ }
2914
+ /**
2915
+ * Stores a message and delivers it to any active subscribers, without
2916
+ * requiring one to be present. Dead-letter queues are meant to hold
2917
+ * messages for later manual inspection, so they don't need a live consumer.
2918
+ */
2919
+ async storeAndDeliver(queue, envelope) {
2699
2920
  const messages = this.getOrCreateQueue(queue);
2700
2921
  const messageId = `${++this.messageIdCounter}`;
2701
2922
  const queuedMessage = {
@@ -2703,17 +2924,11 @@ var LocalTransport = class {
2703
2924
  id: messageId,
2704
2925
  completed: false
2705
2926
  };
2706
- messages.push(queuedMessage);
2927
+ messages.set(messageId, queuedMessage);
2707
2928
  await this.deliverToSubscribers(queue, queuedMessage);
2708
2929
  }
2709
2930
  async deliverToSubscribers(queue, message) {
2710
- const subs = this.subscriptions.get(queue);
2711
- if (!subs) {
2712
- this.logger.warn(
2713
- `[Matador][LocalTransport] \u{1F7E1} No subscriptions found for queue '${queue}', message will be lost.`
2714
- );
2715
- return;
2716
- }
2931
+ const subs = this.subscriptions.get(queue) ?? [];
2717
2932
  for (const sub of subs) {
2718
2933
  if (!sub.active || message.completed) continue;
2719
2934
  const receipt = {
@@ -2746,7 +2961,7 @@ var LocalTransport = class {
2746
2961
  const subs = this.subscriptions.get(queue) ?? [];
2747
2962
  subs.push(subscription);
2748
2963
  this.subscriptions.set(queue, subs);
2749
- const messages = this.queues.get(queue) ?? [];
2964
+ const messages = this.queues.get(queue)?.values() ?? [];
2750
2965
  for (const message of messages) {
2751
2966
  if (message.completed) continue;
2752
2967
  await this.deliverToSubscribers(queue, message);
@@ -2761,6 +2976,11 @@ var LocalTransport = class {
2761
2976
  this.subscriptions.delete(queue);
2762
2977
  }
2763
2978
  },
2979
+ // No-op: LocalTransport is only ever fed from within the process
2980
+ // So, when shutting down, it needs to continue and handle new messages until the end
2981
+ // As, an ongoing message (before shutdown) could create new (local) messages
2982
+ pauseForShutdown: async () => {
2983
+ },
2764
2984
  get isActive() {
2765
2985
  return subscription.active;
2766
2986
  }
@@ -2770,10 +2990,14 @@ var LocalTransport = class {
2770
2990
  const message = receipt.handle;
2771
2991
  message.completed = true;
2772
2992
  this.completedMessages.push(receipt);
2993
+ if (this.completedMessages.length > MAX_COMPLETED_MESSAGES) {
2994
+ this.completedMessages.shift();
2995
+ }
2996
+ this.queues.get(receipt.sourceQueue)?.delete(message.id);
2773
2997
  }
2774
2998
  async sendToDeadLetter(receipt, dlqName, envelope, _reason) {
2775
2999
  const dlqQueueName = `${receipt.sourceQueue}.${dlqName}`;
2776
- await this.enqueue(dlqQueueName, envelope);
3000
+ await this.storeAndDeliver(dlqQueueName, envelope);
2777
3001
  await this.complete(receipt);
2778
3002
  }
2779
3003
  // Test helpers
@@ -2781,9 +3005,7 @@ var LocalTransport = class {
2781
3005
  * Gets the current size of a queue.
2782
3006
  */
2783
3007
  getQueueSize(queue) {
2784
- const messages = this.queues.get(queue);
2785
- if (!messages) return 0;
2786
- return messages.filter((m) => !m.completed).length;
3008
+ return this.queues.get(queue)?.size ?? 0;
2787
3009
  }
2788
3010
  /**
2789
3011
  * Gets all completed message receipts.
@@ -2797,7 +3019,7 @@ var LocalTransport = class {
2797
3019
  getPendingMessages(queue) {
2798
3020
  const messages = this.queues.get(queue);
2799
3021
  if (!messages) return [];
2800
- return messages.filter((m) => !m.completed).map((m) => m.envelope);
3022
+ return Array.from(messages.values()).map((m) => m.envelope);
2801
3023
  }
2802
3024
  /**
2803
3025
  * Clears all state (for test isolation).
@@ -2819,7 +3041,7 @@ var LocalTransport = class {
2819
3041
  async receiveOne(queue) {
2820
3042
  const messages = this.queues.get(queue);
2821
3043
  if (!messages) return null;
2822
- const pending = messages.find((m) => !m.completed);
3044
+ const pending = messages.values().next().value;
2823
3045
  if (!pending) return null;
2824
3046
  const receipt = {
2825
3047
  handle: pending,
@@ -2834,7 +3056,7 @@ var LocalTransport = class {
2834
3056
  getOrCreateQueue(queue) {
2835
3057
  let messages = this.queues.get(queue);
2836
3058
  if (!messages) {
2837
- messages = [];
3059
+ messages = /* @__PURE__ */ new Map();
2838
3060
  this.queues.set(queue, messages);
2839
3061
  }
2840
3062
  return messages;
@@ -2977,6 +3199,11 @@ var MultiTransport = class {
2977
3199
  unsubscribe: async () => {
2978
3200
  await Promise.all(subscriptions.map((s) => s.unsubscribe()));
2979
3201
  },
3202
+ pauseForShutdown: async () => {
3203
+ await Promise.all(
3204
+ subscriptions.map((s) => (s.pauseForShutdown ?? s.unsubscribe)())
3205
+ );
3206
+ },
2980
3207
  get isActive() {
2981
3208
  return subscriptions.some((s) => s.isActive);
2982
3209
  }
@@ -3153,33 +3380,37 @@ var RabbitMQTransport = class {
3153
3380
  };
3154
3381
  this.subscriptionIntents.push(intent);
3155
3382
  await this.activateIntent(intent);
3156
- return {
3157
- unsubscribe: async () => {
3158
- intent.active = false;
3159
- const idx = this.subscriptionIntents.indexOf(intent);
3160
- if (idx !== -1) this.subscriptionIntents.splice(idx, 1);
3161
- const consumer = intent.currentConsumer;
3162
- if (consumer) {
3163
- consumer.active = false;
3164
- const queueChannel = this.queueChannels.get(queue);
3165
- if (queueChannel) {
3383
+ const unsubscribe = async () => {
3384
+ intent.active = false;
3385
+ const idx = this.subscriptionIntents.indexOf(intent);
3386
+ if (idx !== -1) this.subscriptionIntents.splice(idx, 1);
3387
+ const consumer = intent.currentConsumer;
3388
+ if (consumer) {
3389
+ consumer.active = false;
3390
+ const queueChannel = this.queueChannels.get(queue);
3391
+ if (queueChannel) {
3392
+ try {
3393
+ await queueChannel.channel.cancel(consumer.consumerTag);
3394
+ } catch {
3395
+ }
3396
+ const cIdx = queueChannel.consumers.indexOf(consumer);
3397
+ if (cIdx !== -1) queueChannel.consumers.splice(cIdx, 1);
3398
+ if (queueChannel.consumers.length === 0) {
3166
3399
  try {
3167
- await queueChannel.channel.cancel(consumer.consumerTag);
3400
+ await queueChannel.channel.close();
3168
3401
  } catch {
3169
3402
  }
3170
- const cIdx = queueChannel.consumers.indexOf(consumer);
3171
- if (cIdx !== -1) queueChannel.consumers.splice(cIdx, 1);
3172
- if (queueChannel.consumers.length === 0) {
3173
- try {
3174
- await queueChannel.channel.close();
3175
- } catch {
3176
- }
3177
- this.queueChannels.delete(queue);
3178
- }
3403
+ this.queueChannels.delete(queue);
3179
3404
  }
3180
- intent.currentConsumer = null;
3181
3405
  }
3182
- },
3406
+ intent.currentConsumer = null;
3407
+ }
3408
+ };
3409
+ return {
3410
+ unsubscribe,
3411
+ // Messages on this transport comes from outside (RabbitMQ), so, when shutting down, we need to unsubscribe directly
3412
+ // Otherwise, we could receive traffic forever, and an idle state would never be reached.
3413
+ pauseForShutdown: unsubscribe,
3183
3414
  get isActive() {
3184
3415
  return intent.active;
3185
3416
  }
@@ -3493,6 +3724,9 @@ var RabbitMQTransport = class {
3493
3724
  if (queueDef.consumerTimeout) {
3494
3725
  queueOptions.arguments["x-consumer-timeout"] = queueDef.consumerTimeout;
3495
3726
  }
3727
+ if (queueDef.singleActiveConsumer) {
3728
+ queueOptions.arguments["x-single-active-consumer"] = true;
3729
+ }
3496
3730
  return queueOptions;
3497
3731
  }
3498
3732
  async assertWorkQueue(channel, topology, queueDef) {
@@ -3634,7 +3868,7 @@ exports.InvalidEventError = InvalidEventError;
3634
3868
  exports.InvalidSchemaError = InvalidSchemaError;
3635
3869
  exports.JsonCodec = JsonCodec;
3636
3870
  exports.LocalTransport = LocalTransport;
3637
- exports.LocalTransportCannotProcessStubError = LocalTransportCannotProcessStubError;
3871
+ exports.LocalTransportNoActiveSubscriberError = LocalTransportNoActiveSubscriberError;
3638
3872
  exports.Matador = Matador;
3639
3873
  exports.MatadorError = MatadorError;
3640
3874
  exports.MatadorEvent = MatadorEvent;