@horizon-republic/nestjs-jetstream 2.7.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __decorateParam = (index, decorator) => (target, key) => decorator(target, k
14
14
  import {
15
15
  Global,
16
16
  Inject,
17
- Logger as Logger12,
17
+ Logger as Logger14,
18
18
  Module,
19
19
  Optional
20
20
  } from "@nestjs/common";
@@ -24,9 +24,9 @@ import { Logger } from "@nestjs/common";
24
24
  import { ClientProxy } from "@nestjs/microservices";
25
25
  import {
26
26
  createInbox,
27
- Events,
28
27
  headers as natsHeaders
29
- } from "nats";
28
+ } from "@nats-io/transport-node";
29
+ import { nuid } from "@nats-io/nuid";
30
30
 
31
31
  // src/interfaces/hooks.interface.ts
32
32
  var MessageKind = /* @__PURE__ */ ((MessageKind2) => {
@@ -65,7 +65,7 @@ import {
65
65
  RetentionPolicy,
66
66
  StorageType,
67
67
  StoreCompression
68
- } from "nats";
68
+ } from "@nats-io/jetstream";
69
69
  var JETSTREAM_OPTIONS = /* @__PURE__ */ Symbol("JETSTREAM_OPTIONS");
70
70
  var JETSTREAM_CONNECTION = /* @__PURE__ */ Symbol("JETSTREAM_CONNECTION");
71
71
  var JETSTREAM_CODEC = /* @__PURE__ */ Symbol("JETSTREAM_CODEC");
@@ -121,7 +121,7 @@ var DEFAULT_BROADCAST_STREAM_CONFIG = {
121
121
  max_msgs_per_subject: 1e6,
122
122
  max_msgs: 1e7,
123
123
  max_bytes: 2 * GB,
124
- max_age: toNanos(1, "days"),
124
+ max_age: toNanos(1, "hours"),
125
125
  duplicate_window: toNanos(2, "minutes")
126
126
  };
127
127
  var DEFAULT_ORDERED_STREAM_CONFIG = {
@@ -136,6 +136,18 @@ var DEFAULT_ORDERED_STREAM_CONFIG = {
136
136
  max_age: toNanos(1, "days"),
137
137
  duplicate_window: toNanos(2, "minutes")
138
138
  };
139
+ var DEFAULT_DLQ_STREAM_CONFIG = {
140
+ ...baseStreamConfig,
141
+ retention: RetentionPolicy.Workqueue,
142
+ allow_rollup_hdrs: false,
143
+ max_consumers: 100,
144
+ max_msg_size: 10 * MB,
145
+ max_msgs_per_subject: 5e6,
146
+ max_msgs: 5e7,
147
+ max_bytes: 5 * GB,
148
+ max_age: toNanos(30, "days"),
149
+ duplicate_window: toNanos(2, "minutes")
150
+ };
139
151
  var DEFAULT_EVENT_CONSUMER_CONFIG = {
140
152
  ack_wait: toNanos(10, "seconds"),
141
153
  max_deliver: 3,
@@ -163,6 +175,12 @@ var DEFAULT_BROADCAST_CONSUMER_CONFIG = {
163
175
  var DEFAULT_RPC_TIMEOUT = 3e4;
164
176
  var DEFAULT_JETSTREAM_RPC_TIMEOUT = 18e4;
165
177
  var DEFAULT_SHUTDOWN_TIMEOUT = 1e4;
178
+ var DEFAULT_METADATA_BUCKET = "handler_registry";
179
+ var DEFAULT_METADATA_REPLICAS = 1;
180
+ var DEFAULT_METADATA_HISTORY = 1;
181
+ var DEFAULT_METADATA_TTL = 3e4;
182
+ var MIN_METADATA_TTL = 5e3;
183
+ var metadataKey = (serviceName, kind, pattern) => `${serviceName}.${kind}.${pattern}`;
166
184
  var JetstreamHeader = /* @__PURE__ */ ((JetstreamHeader2) => {
167
185
  JetstreamHeader2["CorrelationId"] = "x-correlation-id";
168
186
  JetstreamHeader2["ReplyTo"] = "x-reply-to";
@@ -171,6 +189,14 @@ var JetstreamHeader = /* @__PURE__ */ ((JetstreamHeader2) => {
171
189
  JetstreamHeader2["Error"] = "x-error";
172
190
  return JetstreamHeader2;
173
191
  })(JetstreamHeader || {});
192
+ var JetstreamDlqHeader = /* @__PURE__ */ ((JetstreamDlqHeader2) => {
193
+ JetstreamDlqHeader2["DeadLetterReason"] = "x-dead-letter-reason";
194
+ JetstreamDlqHeader2["OriginalSubject"] = "x-original-subject";
195
+ JetstreamDlqHeader2["OriginalStream"] = "x-original-stream";
196
+ JetstreamDlqHeader2["FailedAt"] = "x-failed-at";
197
+ JetstreamDlqHeader2["DeliveryCount"] = "x-delivery-count";
198
+ return JetstreamDlqHeader2;
199
+ })(JetstreamDlqHeader || {});
174
200
  var RESERVED_HEADERS = /* @__PURE__ */ new Set([
175
201
  "x-correlation-id" /* CorrelationId */,
176
202
  "x-reply-to" /* ReplyTo */,
@@ -183,6 +209,9 @@ var streamName = (serviceName, kind) => {
183
209
  if (kind === "broadcast" /* Broadcast */) return "broadcast-stream";
184
210
  return `${internalName(serviceName)}_${kind}-stream`;
185
211
  };
212
+ var dlqStreamName = (serviceName) => {
213
+ return `${internalName(serviceName)}_dlq-stream`;
214
+ };
186
215
  var consumerName = (serviceName, kind) => {
187
216
  if (kind === "broadcast" /* Broadcast */) return `${internalName(serviceName)}_broadcast-consumer`;
188
217
  return `${internalName(serviceName)}_${kind}-consumer`;
@@ -197,11 +226,13 @@ var isCoreRpcMode = (rpc) => !rpc || rpc.mode === "core";
197
226
 
198
227
  // src/client/jetstream.record.ts
199
228
  var JetstreamRecord = class {
200
- constructor(data, headers2, timeout, messageId) {
229
+ constructor(data, headers2, timeout, messageId, schedule, ttl) {
201
230
  this.data = data;
202
231
  this.headers = headers2;
203
232
  this.timeout = timeout;
204
233
  this.messageId = messageId;
234
+ this.schedule = schedule;
235
+ this.ttl = ttl;
205
236
  }
206
237
  };
207
238
  var JetstreamRecordBuilder = class {
@@ -209,6 +240,8 @@ var JetstreamRecordBuilder = class {
209
240
  headers = /* @__PURE__ */ new Map();
210
241
  timeout;
211
242
  messageId;
243
+ scheduleOptions;
244
+ ttlDuration;
212
245
  constructor(data) {
213
246
  this.data = data;
214
247
  }
@@ -276,17 +309,71 @@ var JetstreamRecordBuilder = class {
276
309
  this.timeout = ms;
277
310
  return this;
278
311
  }
312
+ /**
313
+ * Schedule one-shot delayed delivery.
314
+ *
315
+ * The message is held by NATS and delivered to the event consumer
316
+ * at the specified time. Requires NATS >= 2.12 and `allow_msg_schedules: true`
317
+ * on the event stream (via `events: { stream: { allow_msg_schedules: true } }`).
318
+ *
319
+ * Only meaningful for events (`client.emit()`). If used with RPC
320
+ * (`client.send()`), a warning is logged and the schedule is ignored.
321
+ *
322
+ * @param date - Delivery time. Must be in the future.
323
+ * @throws Error if the date is not in the future.
324
+ */
325
+ scheduleAt(date) {
326
+ const ts = date.getTime();
327
+ if (Number.isNaN(ts)) {
328
+ throw new Error("Schedule date is invalid");
329
+ }
330
+ if (ts <= Date.now()) {
331
+ throw new Error("Schedule date must be in the future");
332
+ }
333
+ this.scheduleOptions = { at: new Date(ts) };
334
+ return this;
335
+ }
336
+ /**
337
+ * Set per-message TTL (time-to-live).
338
+ *
339
+ * The message expires individually after the specified duration,
340
+ * independent of the stream's `max_age`. Requires NATS >= 2.11 and
341
+ * `allow_msg_ttl: true` on the stream.
342
+ *
343
+ * Only meaningful for events (`client.emit()`). If used with RPC
344
+ * (`client.send()`), a warning is logged and the TTL is ignored.
345
+ *
346
+ * @param nanos - TTL in nanoseconds. Use {@link toNanos} for human-readable values.
347
+ *
348
+ * @example
349
+ * ```typescript
350
+ * import { toNanos } from '@horizon-republic/nestjs-jetstream';
351
+ *
352
+ * new JetstreamRecordBuilder(payload).ttl(toNanos(30, 'minutes')).build();
353
+ * new JetstreamRecordBuilder(payload).ttl(toNanos(24, 'hours')).build();
354
+ * ```
355
+ */
356
+ ttl(nanos) {
357
+ if (!Number.isFinite(nanos) || nanos <= 0) {
358
+ throw new Error("TTL must be a positive finite value");
359
+ }
360
+ this.ttlDuration = nanosToGoDuration(nanos);
361
+ return this;
362
+ }
279
363
  /**
280
364
  * Build the immutable {@link JetstreamRecord}.
281
365
  *
282
366
  * @returns A frozen record ready to pass to `client.send()` or `client.emit()`.
283
367
  */
284
368
  build() {
369
+ const schedule = this.scheduleOptions ? { at: new Date(this.scheduleOptions.at.getTime()) } : void 0;
285
370
  return new JetstreamRecord(
286
371
  this.data,
287
372
  new Map(this.headers),
288
373
  this.timeout,
289
- this.messageId
374
+ this.messageId,
375
+ schedule,
376
+ this.ttlDuration
290
377
  );
291
378
  }
292
379
  /** Validate that a header key is not reserved. */
@@ -298,6 +385,17 @@ var JetstreamRecordBuilder = class {
298
385
  }
299
386
  }
300
387
  };
388
+ var NS_PER_MS = 1e6;
389
+ var NS_PER_S = 1e9;
390
+ var NS_PER_M = 60 * NS_PER_S;
391
+ var NS_PER_H = 60 * NS_PER_M;
392
+ var nanosToGoDuration = (nanos) => {
393
+ if (nanos >= NS_PER_H && nanos % NS_PER_H === 0) return `${nanos / NS_PER_H}h`;
394
+ if (nanos >= NS_PER_M && nanos % NS_PER_M === 0) return `${nanos / NS_PER_M}m`;
395
+ if (nanos >= NS_PER_S && nanos % NS_PER_S === 0) return `${nanos / NS_PER_S}s`;
396
+ if (nanos >= NS_PER_MS && nanos % NS_PER_MS === 0) return `${nanos / NS_PER_MS}ms`;
397
+ return `${nanos}ns`;
398
+ };
301
399
 
302
400
  // src/client/jetstream.client.ts
303
401
  var JetstreamClient = class extends ClientProxy {
@@ -338,7 +436,7 @@ var JetstreamClient = class extends ClientProxy {
338
436
  this.setupInbox(nc);
339
437
  }
340
438
  this.statusSubscription ??= this.connection.status$.subscribe((status) => {
341
- if (status.type === Events.Disconnect) {
439
+ if (status.type === "disconnect") {
342
440
  this.handleDisconnect();
343
441
  }
344
442
  });
@@ -366,19 +464,40 @@ var JetstreamClient = class extends ClientProxy {
366
464
  * Publish a fire-and-forget event to JetStream.
367
465
  *
368
466
  * Events are published to either the workqueue stream or broadcast stream
369
- * depending on the subject prefix.
467
+ * depending on the subject prefix. When a schedule is present the message
468
+ * is published to a `_sch` subject within the same stream, with the target
469
+ * set to the original event subject.
370
470
  */
371
471
  async dispatchEvent(packet) {
372
472
  await this.connect();
373
- const { data, hdrs, messageId } = this.extractRecordData(packet.data);
374
- const subject = this.buildEventSubject(packet.pattern);
375
- const msgHeaders = this.buildHeaders(hdrs, { subject });
376
- const ack = await this.connection.getJetStreamClient().publish(subject, this.codec.encode(data), {
377
- headers: msgHeaders,
378
- msgID: messageId ?? crypto.randomUUID()
379
- });
380
- if (ack.duplicate) {
381
- this.logger.warn(`Duplicate event publish detected: ${subject} (seq: ${ack.seq})`);
473
+ const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
474
+ const eventSubject = this.buildEventSubject(packet.pattern);
475
+ const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
476
+ if (schedule) {
477
+ const scheduleSubject = this.buildScheduleSubject(eventSubject);
478
+ const ack = await this.connection.getJetStreamClient().publish(scheduleSubject, this.codec.encode(data), {
479
+ headers: msgHeaders,
480
+ msgID: messageId ?? nuid.next(),
481
+ ttl,
482
+ schedule: {
483
+ specification: schedule.at,
484
+ target: eventSubject
485
+ }
486
+ });
487
+ if (ack.duplicate) {
488
+ this.logger.warn(
489
+ `Duplicate scheduled publish detected: ${scheduleSubject} (seq: ${ack.seq})`
490
+ );
491
+ }
492
+ } else {
493
+ const ack = await this.connection.getJetStreamClient().publish(eventSubject, this.codec.encode(data), {
494
+ headers: msgHeaders,
495
+ msgID: messageId ?? nuid.next(),
496
+ ttl
497
+ });
498
+ if (ack.duplicate) {
499
+ this.logger.warn(`Duplicate event publish detected: ${eventSubject} (seq: ${ack.seq})`);
500
+ }
382
501
  }
383
502
  return void 0;
384
503
  }
@@ -390,7 +509,17 @@ var JetstreamClient = class extends ClientProxy {
390
509
  */
391
510
  publish(packet, callback) {
392
511
  const subject = buildSubject(this.targetName, "cmd" /* Command */, packet.pattern);
393
- const { data, hdrs, timeout, messageId } = this.extractRecordData(packet.data);
512
+ const { data, hdrs, timeout, messageId, schedule, ttl } = this.extractRecordData(packet.data);
513
+ if (schedule) {
514
+ this.logger.warn(
515
+ "scheduleAt() is ignored for RPC (client.send()). Use client.emit() for scheduled events."
516
+ );
517
+ }
518
+ if (ttl) {
519
+ this.logger.warn(
520
+ "ttl() is ignored for RPC (client.send()). Use client.emit() for events with TTL."
521
+ );
522
+ }
394
523
  const onUnhandled = (err) => {
395
524
  this.logger.error("Unhandled publish error:", err);
396
525
  callback({ err: new Error("Internal transport error"), response: null, isDisposed: true });
@@ -399,7 +528,7 @@ var JetstreamClient = class extends ClientProxy {
399
528
  if (isCoreRpcMode(this.rootOptions.rpc)) {
400
529
  this.publishCoreRpc(subject, data, hdrs, timeout, callback).catch(onUnhandled);
401
530
  } else {
402
- jetStreamCorrelationId = crypto.randomUUID();
531
+ jetStreamCorrelationId = nuid.next();
403
532
  this.publishJetStreamRpc(subject, data, callback, {
404
533
  headers: hdrs,
405
534
  timeout,
@@ -474,7 +603,7 @@ var JetstreamClient = class extends ClientProxy {
474
603
  });
475
604
  await this.connection.getJetStreamClient().publish(subject, this.codec.encode(data), {
476
605
  headers: hdrs,
477
- msgID: messageId ?? crypto.randomUUID()
606
+ msgID: messageId ?? nuid.next()
478
607
  });
479
608
  } catch (err) {
480
609
  const existingTimeout = this.pendingTimeouts.get(correlationId);
@@ -506,6 +635,7 @@ var JetstreamClient = class extends ClientProxy {
506
635
  this.pendingTimeouts.clear();
507
636
  this.inboxSubscription?.unsubscribe();
508
637
  this.inboxSubscription = null;
638
+ this.inbox = null;
509
639
  }
510
640
  /** Setup shared inbox subscription for JetStream RPC responses. */
511
641
  setupInbox(nc) {
@@ -587,17 +717,53 @@ var JetstreamClient = class extends ClientProxy {
587
717
  }
588
718
  return hdrs;
589
719
  }
590
- /** Extract data, headers, and timeout from raw packet data or JetstreamRecord. */
720
+ /** Extract data, headers, timeout, and schedule from raw packet data or JetstreamRecord. */
591
721
  extractRecordData(rawData) {
592
722
  if (rawData instanceof JetstreamRecord) {
593
723
  return {
594
724
  data: rawData.data,
595
725
  hdrs: rawData.headers.size > 0 ? new Map(rawData.headers) : null,
596
726
  timeout: rawData.timeout,
597
- messageId: rawData.messageId
727
+ messageId: rawData.messageId,
728
+ schedule: rawData.schedule,
729
+ ttl: rawData.ttl
598
730
  };
599
731
  }
600
- return { data: rawData, hdrs: null, timeout: void 0, messageId: void 0 };
732
+ return {
733
+ data: rawData,
734
+ hdrs: null,
735
+ timeout: void 0,
736
+ messageId: void 0,
737
+ schedule: void 0,
738
+ ttl: void 0
739
+ };
740
+ }
741
+ /**
742
+ * Build a schedule-holder subject for NATS message scheduling.
743
+ *
744
+ * The schedule-holder subject resides in the same stream as the target but
745
+ * uses a separate `_sch` namespace that is NOT matched by any consumer filter.
746
+ * NATS holds the message and publishes it to the target subject after the delay.
747
+ *
748
+ * Examples:
749
+ * - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder`
750
+ * - `broadcast.config.updated` → `broadcast._sch.config.updated`
751
+ */
752
+ buildScheduleSubject(eventSubject) {
753
+ if (eventSubject.startsWith("broadcast.")) {
754
+ return eventSubject.replace("broadcast.", "broadcast._sch.");
755
+ }
756
+ const targetPrefix = `${internalName(this.targetName)}.`;
757
+ if (!eventSubject.startsWith(targetPrefix)) {
758
+ throw new Error(`Unexpected event subject format: ${eventSubject}`);
759
+ }
760
+ const withoutPrefix = eventSubject.slice(targetPrefix.length);
761
+ const dotIndex = withoutPrefix.indexOf(".");
762
+ if (dotIndex === -1) {
763
+ throw new Error(`Event subject missing pattern segment: ${eventSubject}`);
764
+ }
765
+ const pattern = withoutPrefix.slice(dotIndex + 1);
766
+ return `${targetPrefix}_sch.${pattern}`;
601
767
  }
602
768
  getRpcTimeout() {
603
769
  if (!this.rootOptions.rpc) return DEFAULT_RPC_TIMEOUT;
@@ -607,25 +773,26 @@ var JetstreamClient = class extends ClientProxy {
607
773
  };
608
774
 
609
775
  // src/codec/json.codec.ts
610
- import { JSONCodec as NatsJSONCodec } from "nats";
776
+ var encoder = new TextEncoder();
777
+ var decoder = new TextDecoder();
611
778
  var JsonCodec = class {
612
- inner = NatsJSONCodec();
613
779
  encode(data) {
614
- return this.inner.encode(data);
780
+ return encoder.encode(JSON.stringify(data));
615
781
  }
616
782
  decode(data) {
617
- return this.inner.decode(data);
783
+ return JSON.parse(decoder.decode(data));
618
784
  }
619
785
  };
620
786
 
621
787
  // src/connection/connection.provider.ts
622
788
  import { Logger as Logger2 } from "@nestjs/common";
623
789
  import {
624
- connect,
625
- DebugEvents,
626
- Events as Events2,
627
- NatsError
628
- } from "nats";
790
+ connect
791
+ } from "@nats-io/transport-node";
792
+ import {
793
+ jetstream,
794
+ jetstreamManager
795
+ } from "@nats-io/jetstream";
629
796
  import { defer, from, share, shareReplay, switchMap } from "rxjs";
630
797
  var DEFAULT_OPTIONS = {
631
798
  maxReconnectAttempts: -1,
@@ -679,14 +846,7 @@ var ConnectionProvider = class {
679
846
  async getJetStreamManager() {
680
847
  if (this.jsmInstance) return this.jsmInstance;
681
848
  if (this.jsmPromise) return this.jsmPromise;
682
- this.jsmPromise = (async () => {
683
- const nc = await this.getConnection();
684
- this.jsmInstance = await nc.jetstreamManager();
685
- this.logger.log("JetStream manager initialized");
686
- return this.jsmInstance;
687
- })().finally(() => {
688
- this.jsmPromise = null;
689
- });
849
+ this.jsmPromise = this.initJetStreamManager();
690
850
  return this.jsmPromise;
691
851
  }
692
852
  /**
@@ -702,7 +862,7 @@ var ConnectionProvider = class {
702
862
  if (!this.connection || this.connection.isClosed()) {
703
863
  throw new Error("Not connected \u2014 call getConnection() before getJetStreamClient()");
704
864
  }
705
- this.jsClient ??= this.connection.jetstream();
865
+ this.jsClient ??= jetstream(this.connection);
706
866
  return this.jsClient;
707
867
  }
708
868
  /** Direct access to the raw NATS connection, or `null` if not yet connected. */
@@ -738,6 +898,16 @@ var ConnectionProvider = class {
738
898
  this.jsmPromise = null;
739
899
  }
740
900
  }
901
+ async initJetStreamManager() {
902
+ try {
903
+ const nc = await this.getConnection();
904
+ this.jsmInstance = await jetstreamManager(nc);
905
+ this.logger.log("JetStream manager initialized");
906
+ return this.jsmInstance;
907
+ } finally {
908
+ this.jsmPromise = null;
909
+ }
910
+ }
741
911
  /** Internal: establish the physical connection with reconnect monitoring. */
742
912
  async establish() {
743
913
  const name = internalName(this.options.name);
@@ -754,7 +924,7 @@ var ConnectionProvider = class {
754
924
  this.monitorStatus(nc);
755
925
  return nc;
756
926
  } catch (err) {
757
- if (err instanceof NatsError && err.code === "CONNECTION_REFUSED") {
927
+ if (err instanceof Error && err.message.includes("REFUSED")) {
758
928
  throw new Error(`NATS connection refused: ${this.options.servers.join(", ")}`);
759
929
  }
760
930
  throw err;
@@ -762,27 +932,33 @@ var ConnectionProvider = class {
762
932
  }
763
933
  /** Subscribe to connection status events and emit hooks. */
764
934
  monitorStatus(nc) {
765
- (async () => {
935
+ void (async () => {
766
936
  for await (const status of nc.status()) {
767
937
  switch (status.type) {
768
- case Events2.Disconnect:
938
+ case "disconnect":
769
939
  this.eventBus.emit("disconnect" /* Disconnect */);
770
940
  break;
771
- case Events2.Reconnect:
941
+ case "reconnect":
772
942
  this.jsClient = null;
773
943
  this.jsmInstance = null;
774
944
  this.jsmPromise = null;
775
945
  this.eventBus.emit("reconnect" /* Reconnect */, nc.getServer());
776
946
  break;
777
- case Events2.Error:
778
- this.eventBus.emit("error" /* Error */, new Error(String(status.data)), "connection");
947
+ case "error":
948
+ this.eventBus.emit(
949
+ "error" /* Error */,
950
+ status.error,
951
+ "connection"
952
+ );
779
953
  break;
780
- case Events2.Update:
781
- case Events2.LDM:
782
- case DebugEvents.Reconnecting:
783
- case DebugEvents.PingTimer:
784
- case DebugEvents.StaleConnection:
785
- case DebugEvents.ClientInitiatedReconnect:
954
+ case "update":
955
+ case "ldm":
956
+ case "reconnecting":
957
+ case "ping":
958
+ case "staleConnection":
959
+ case "forceReconnect":
960
+ case "slowConsumer":
961
+ case "close":
786
962
  break;
787
963
  }
788
964
  }
@@ -879,9 +1055,14 @@ var JetstreamHealthIndicator = class {
879
1055
  * Returns `{ [key]: { status: 'up', ... } }` on success.
880
1056
  * Throws an error with `{ [key]: { status: 'down', ... } }` on failure.
881
1057
  *
1058
+ * The thrown error sets `isHealthCheckError: true` and `causes` — the
1059
+ * duck-type contract that Terminus `HealthCheckExecutor` uses to distinguish
1060
+ * health failures from unexpected exceptions. Works with both Terminus v10
1061
+ * (`instanceof HealthCheckError`) and v11+ (`error?.isHealthCheckError`).
1062
+ *
882
1063
  * @param key - Health indicator key (default: `'jetstream'`).
883
1064
  * @returns Object with status, server, and latency under the given key.
884
- * @throws Error with `{ [key]: { status: 'down' } }` when disconnected.
1065
+ * @throws Error with `isHealthCheckError`, `causes`, and `{ [key]: { status: 'down' } }`.
885
1066
  */
886
1067
  async isHealthy(key = "jetstream") {
887
1068
  const status = await this.check();
@@ -891,8 +1072,10 @@ var JetstreamHealthIndicator = class {
891
1072
  latency: status.latency
892
1073
  };
893
1074
  if (!status.connected) {
1075
+ const causes = { [key]: details };
894
1076
  throw Object.assign(new Error("Jetstream health check failed"), {
895
- [key]: details
1077
+ causes,
1078
+ isHealthCheckError: true
896
1079
  });
897
1080
  }
898
1081
  return { [key]: details };
@@ -905,7 +1088,7 @@ JetstreamHealthIndicator = __decorateClass([
905
1088
  // src/server/strategy.ts
906
1089
  import { Server } from "@nestjs/microservices";
907
1090
  var JetstreamStrategy = class extends Server {
908
- constructor(options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap = /* @__PURE__ */ new Map()) {
1091
+ constructor(options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap = /* @__PURE__ */ new Map(), metadataProvider) {
909
1092
  super();
910
1093
  this.options = options;
911
1094
  this.connection = connection;
@@ -917,6 +1100,7 @@ var JetstreamStrategy = class extends Server {
917
1100
  this.rpcRouter = rpcRouter;
918
1101
  this.coreRpcServer = coreRpcServer;
919
1102
  this.ackWaitMap = ackWaitMap;
1103
+ this.metadataProvider = metadataProvider;
920
1104
  }
921
1105
  transportId = /* @__PURE__ */ Symbol("jetstream-transport");
922
1106
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -961,10 +1145,14 @@ var JetstreamStrategy = class extends Server {
961
1145
  if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
962
1146
  await this.coreRpcServer.start();
963
1147
  }
1148
+ if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
1149
+ await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
1150
+ }
964
1151
  callback();
965
1152
  }
966
- /** Stop all consumers, routers, and subscriptions. Called during shutdown. */
1153
+ /** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
967
1154
  close() {
1155
+ this.metadataProvider?.destroy();
968
1156
  this.eventRouter.destroy();
969
1157
  this.rpcRouter.destroy();
970
1158
  this.coreRpcServer.stop();
@@ -1044,7 +1232,7 @@ var JetstreamStrategy = class extends Server {
1044
1232
 
1045
1233
  // src/server/core-rpc.server.ts
1046
1234
  import { Logger as Logger4 } from "@nestjs/common";
1047
- import { headers as natsHeaders2 } from "nats";
1235
+ import { headers as natsHeaders2 } from "@nats-io/transport-node";
1048
1236
 
1049
1237
  // src/context/rpc.context.ts
1050
1238
  import { BaseRpcContext } from "@nestjs/microservices";
@@ -1340,24 +1528,172 @@ var CoreRpcServer = class {
1340
1528
  };
1341
1529
 
1342
1530
  // src/server/infrastructure/stream.provider.ts
1531
+ import { Logger as Logger6 } from "@nestjs/common";
1532
+ import { JetStreamApiError as JetStreamApiError2 } from "@nats-io/jetstream";
1533
+
1534
+ // src/server/infrastructure/stream-config-diff.ts
1535
+ var TRANSPORT_CONTROLLED_PROPERTIES = /* @__PURE__ */ new Set([
1536
+ "retention"
1537
+ ]);
1538
+ var IMMUTABLE_PROPERTIES = /* @__PURE__ */ new Set([
1539
+ "storage"
1540
+ ]);
1541
+ var ENABLE_ONLY_PROPERTIES = /* @__PURE__ */ new Set([
1542
+ "allow_msg_schedules",
1543
+ "allow_msg_ttl",
1544
+ "deny_delete",
1545
+ "deny_purge"
1546
+ ]);
1547
+ var compareStreamConfig = (current, desired) => {
1548
+ const changes = [];
1549
+ for (const key of Object.keys(desired)) {
1550
+ const currentVal = current[key];
1551
+ const desiredVal = desired[key];
1552
+ if (isEqual(currentVal, desiredVal)) continue;
1553
+ changes.push({
1554
+ property: key,
1555
+ current: currentVal,
1556
+ desired: desiredVal,
1557
+ mutability: classifyMutability(key, currentVal, desiredVal)
1558
+ });
1559
+ }
1560
+ const hasImmutableChanges = changes.some((c) => c.mutability === "immutable");
1561
+ const hasMutableChanges = changes.some(
1562
+ (c) => c.mutability === "mutable" || c.mutability === "enable-only"
1563
+ );
1564
+ const hasTransportControlledConflicts = changes.some(
1565
+ (c) => c.mutability === "transport-controlled"
1566
+ );
1567
+ return {
1568
+ hasChanges: changes.length > 0,
1569
+ hasMutableChanges,
1570
+ hasImmutableChanges,
1571
+ hasTransportControlledConflicts,
1572
+ changes
1573
+ };
1574
+ };
1575
+ var classifyMutability = (key, current, desired) => {
1576
+ if (TRANSPORT_CONTROLLED_PROPERTIES.has(key)) return "transport-controlled";
1577
+ if (IMMUTABLE_PROPERTIES.has(key)) return "immutable";
1578
+ if (ENABLE_ONLY_PROPERTIES.has(key)) {
1579
+ return current === true && desired === false ? "immutable" : "enable-only";
1580
+ }
1581
+ return "mutable";
1582
+ };
1583
+ var isEqual = (a, b) => {
1584
+ if (a === b) return true;
1585
+ if (a == null && b == null) return true;
1586
+ return JSON.stringify(a) === JSON.stringify(b);
1587
+ };
1588
+
1589
+ // src/server/infrastructure/stream-migration.ts
1343
1590
  import { Logger as Logger5 } from "@nestjs/common";
1344
- import { NatsError as NatsError2 } from "nats";
1345
- var STREAM_NOT_FOUND = 10059;
1591
+ import { JetStreamApiError } from "@nats-io/jetstream";
1592
+ var MIGRATION_BACKUP_SUFFIX = "__migration_backup";
1593
+ var DEFAULT_SOURCING_TIMEOUT_MS = 3e4;
1594
+ var SOURCING_POLL_INTERVAL_MS = 100;
1595
+ var StreamMigration = class {
1596
+ constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS) {
1597
+ this.sourcingTimeoutMs = sourcingTimeoutMs;
1598
+ }
1599
+ logger = new Logger5("Jetstream:Stream");
1600
+ async migrate(jsm, streamName2, newConfig) {
1601
+ const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
1602
+ const startTime = Date.now();
1603
+ const currentInfo = await jsm.streams.info(streamName2);
1604
+ await this.cleanupOrphanedBackup(jsm, backupName);
1605
+ const messageCount = currentInfo.state.messages;
1606
+ this.logger.log(`Stream ${streamName2}: destructive migration started`);
1607
+ let originalDeleted = false;
1608
+ try {
1609
+ if (messageCount > 0) {
1610
+ this.logger.log(` Phase 1/4: Backing up ${messageCount} messages \u2192 ${backupName}`);
1611
+ await jsm.streams.add({
1612
+ ...currentInfo.config,
1613
+ name: backupName,
1614
+ subjects: [],
1615
+ sources: [{ name: streamName2 }]
1616
+ });
1617
+ await this.waitForSourcing(jsm, backupName, messageCount);
1618
+ }
1619
+ this.logger.log(` Phase 2/4: Deleting old stream`);
1620
+ await jsm.streams.delete(streamName2);
1621
+ originalDeleted = true;
1622
+ this.logger.log(` Phase 3/4: Creating stream with new config`);
1623
+ await jsm.streams.add(newConfig);
1624
+ if (messageCount > 0) {
1625
+ const backupInfo = await jsm.streams.info(backupName);
1626
+ await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
1627
+ this.logger.log(` Phase 4/4: Restoring ${messageCount} messages from backup`);
1628
+ await jsm.streams.update(streamName2, {
1629
+ ...newConfig,
1630
+ sources: [{ name: backupName }]
1631
+ });
1632
+ await this.waitForSourcing(jsm, streamName2, messageCount);
1633
+ await jsm.streams.update(streamName2, { ...newConfig, sources: [] });
1634
+ await jsm.streams.delete(backupName);
1635
+ }
1636
+ } catch (err) {
1637
+ if (originalDeleted && messageCount > 0) {
1638
+ this.logger.error(
1639
+ `Migration failed after deleting original stream. Backup ${backupName} preserved for manual recovery.`
1640
+ );
1641
+ } else {
1642
+ await this.cleanupOrphanedBackup(jsm, backupName);
1643
+ }
1644
+ throw err;
1645
+ }
1646
+ const durationMs = Date.now() - startTime;
1647
+ this.logger.log(
1648
+ `Stream ${streamName2}: migration complete (${messageCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
1649
+ );
1650
+ }
1651
+ async waitForSourcing(jsm, streamName2, expectedCount) {
1652
+ const deadline = Date.now() + this.sourcingTimeoutMs;
1653
+ while (Date.now() < deadline) {
1654
+ const info = await jsm.streams.info(streamName2);
1655
+ if (info.state.messages >= expectedCount) return;
1656
+ await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
1657
+ }
1658
+ throw new Error(
1659
+ `Stream sourcing timeout: ${streamName2} has not reached ${expectedCount} messages within ${this.sourcingTimeoutMs / 1e3}s`
1660
+ );
1661
+ }
1662
+ async cleanupOrphanedBackup(jsm, backupName) {
1663
+ try {
1664
+ await jsm.streams.info(backupName);
1665
+ this.logger.warn(`Found orphaned migration backup stream: ${backupName}, cleaning up`);
1666
+ await jsm.streams.delete(backupName);
1667
+ } catch (err) {
1668
+ if (err instanceof JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
1669
+ return;
1670
+ }
1671
+ throw err;
1672
+ }
1673
+ }
1674
+ };
1675
+
1676
+ // src/server/infrastructure/stream.provider.ts
1346
1677
  var StreamProvider = class {
1347
1678
  constructor(options, connection) {
1348
1679
  this.options = options;
1349
1680
  this.connection = connection;
1350
1681
  }
1351
- logger = new Logger5("Jetstream:Stream");
1682
+ logger = new Logger6("Jetstream:Stream");
1683
+ migration = new StreamMigration();
1352
1684
  /**
1353
1685
  * Ensure all required streams exist with correct configuration.
1354
1686
  *
1355
1687
  * @param kinds Which stream kinds to create. Determined by the module based
1356
1688
  * on RPC mode and registered handler patterns.
1689
+ * If the dlq option is enabled, also ensures the DLQ stream exists.
1357
1690
  */
1358
1691
  async ensureStreams(kinds) {
1359
1692
  const jsm = await this.connection.getJetStreamManager();
1360
1693
  await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
1694
+ if (this.options.dlq) {
1695
+ await this.ensureDlqStream(jsm);
1696
+ }
1361
1697
  }
1362
1698
  /** Get the stream name for a given kind. */
1363
1699
  getStreamName(kind) {
@@ -1367,12 +1703,22 @@ var StreamProvider = class {
1367
1703
  getSubjects(kind) {
1368
1704
  const name = internalName(this.options.name);
1369
1705
  switch (kind) {
1370
- case "ev" /* Event */:
1371
- return [`${name}.${"ev" /* Event */}.>`];
1706
+ case "ev" /* Event */: {
1707
+ const subjects = [`${name}.${"ev" /* Event */}.>`];
1708
+ if (this.isSchedulingEnabled(kind)) {
1709
+ subjects.push(`${name}._sch.>`);
1710
+ }
1711
+ return subjects;
1712
+ }
1372
1713
  case "cmd" /* Command */:
1373
1714
  return [`${name}.${"cmd" /* Command */}.>`];
1374
- case "broadcast" /* Broadcast */:
1375
- return ["broadcast.>"];
1715
+ case "broadcast" /* Broadcast */: {
1716
+ const subjects = ["broadcast.>"];
1717
+ if (this.isSchedulingEnabled(kind)) {
1718
+ subjects.push("broadcast._sch.>");
1719
+ }
1720
+ return subjects;
1721
+ }
1376
1722
  case "ordered" /* Ordered */:
1377
1723
  return [`${name}.${"ordered" /* Ordered */}.>`];
1378
1724
  }
@@ -1382,17 +1728,85 @@ var StreamProvider = class {
1382
1728
  const config = this.buildConfig(kind);
1383
1729
  this.logger.log(`Ensuring stream: ${config.name}`);
1384
1730
  try {
1385
- await jsm.streams.info(config.name);
1386
- this.logger.debug(`Stream exists, updating: ${config.name}`);
1387
- return await jsm.streams.update(config.name, config);
1731
+ const currentInfo = await jsm.streams.info(config.name);
1732
+ return await this.handleExistingStream(jsm, currentInfo, config);
1388
1733
  } catch (err) {
1389
- if (err instanceof NatsError2 && err.api_error?.err_code === STREAM_NOT_FOUND) {
1734
+ if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
1390
1735
  this.logger.log(`Creating stream: ${config.name}`);
1391
1736
  return await jsm.streams.add(config);
1392
1737
  }
1393
1738
  throw err;
1394
1739
  }
1395
1740
  }
1741
+ /** Ensure a dead-letter queue stream exists, creating or updating as needed. */
1742
+ async ensureDlqStream(jsm) {
1743
+ const config = this.buildDlqConfig();
1744
+ this.logger.log(`Ensuring DLQ stream: ${config.name}`);
1745
+ try {
1746
+ const currentInfo = await jsm.streams.info(config.name);
1747
+ return await this.handleExistingStream(jsm, currentInfo, config);
1748
+ } catch (err) {
1749
+ if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
1750
+ this.logger.log(`Creating DLQ stream: ${config.name}`);
1751
+ return await jsm.streams.add(config);
1752
+ }
1753
+ throw err;
1754
+ }
1755
+ }
1756
+ async handleExistingStream(jsm, currentInfo, config) {
1757
+ const diff = compareStreamConfig(currentInfo.config, config);
1758
+ if (!diff.hasChanges) {
1759
+ this.logger.debug(`Stream ${config.name}: no config changes`);
1760
+ return currentInfo;
1761
+ }
1762
+ this.logChanges(config.name, diff, !!this.options.allowDestructiveMigration);
1763
+ if (diff.hasTransportControlledConflicts) {
1764
+ const conflicts = diff.changes.filter((c) => c.mutability === "transport-controlled").map((c) => `${c.property}: ${JSON.stringify(c.current)} \u2192 ${JSON.stringify(c.desired)}`).join(", ");
1765
+ throw new Error(
1766
+ `Stream ${config.name} has transport-controlled config conflicts that cannot be migrated: ${conflicts}. The retention policy is managed by the transport and must match the stream kind.`
1767
+ );
1768
+ }
1769
+ if (!diff.hasImmutableChanges) {
1770
+ this.logger.debug(`Stream exists, updating: ${config.name}`);
1771
+ return await jsm.streams.update(config.name, config);
1772
+ }
1773
+ if (!this.options.allowDestructiveMigration) {
1774
+ this.logger.warn(
1775
+ `Stream ${config.name} has immutable config conflicts. Enable allowDestructiveMigration to recreate the stream.`
1776
+ );
1777
+ if (diff.hasMutableChanges) {
1778
+ const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
1779
+ return await jsm.streams.update(config.name, mutableConfig);
1780
+ }
1781
+ return currentInfo;
1782
+ }
1783
+ await this.migration.migrate(jsm, config.name, config);
1784
+ return await jsm.streams.info(config.name);
1785
+ }
1786
+ buildMutableOnlyConfig(config, currentConfig, diff) {
1787
+ const nonMutableKeys = new Set(
1788
+ diff.changes.filter((c) => c.mutability === "immutable" || c.mutability === "transport-controlled").map((c) => c.property)
1789
+ );
1790
+ const filtered = { ...config };
1791
+ for (const key of nonMutableKeys) {
1792
+ filtered[key] = currentConfig[key];
1793
+ }
1794
+ return filtered;
1795
+ }
1796
+ logChanges(streamName2, diff, migrationEnabled) {
1797
+ for (const c of diff.changes) {
1798
+ const detail = `${c.property}: ${JSON.stringify(c.current)} \u2192 ${JSON.stringify(c.desired)}`;
1799
+ if (c.mutability === "transport-controlled") {
1800
+ this.logger.error(
1801
+ `Stream ${streamName2}: ${detail} \u2014 transport-controlled, cannot be changed`
1802
+ );
1803
+ } else if (c.mutability === "immutable" && !migrationEnabled) {
1804
+ this.logger.warn(`Stream ${streamName2}: ${detail} \u2014 requires allowDestructiveMigration`);
1805
+ } else {
1806
+ this.logger.log(`Stream ${streamName2}: ${detail}`);
1807
+ }
1808
+ }
1809
+ }
1396
1810
  /** Build the full stream config by merging defaults with user overrides. */
1397
1811
  buildConfig(kind) {
1398
1812
  const name = this.getStreamName(kind);
@@ -1408,6 +1822,26 @@ var StreamProvider = class {
1408
1822
  description
1409
1823
  };
1410
1824
  }
1825
+ /**
1826
+ * Build the stream configuration for the Dead-Letter Queue (DLQ).
1827
+ *
1828
+ * Merges the library default DLQ config with user-provided overrides.
1829
+ * Ensures transport-controlled settings like retention are safely decoupled.
1830
+ */
1831
+ buildDlqConfig() {
1832
+ const name = dlqStreamName(this.options.name);
1833
+ const subjects = [name];
1834
+ const description = `JetStream DLQ stream for ${this.options.name}`;
1835
+ const overrides = this.options.dlq?.stream ?? {};
1836
+ const safeOverrides = this.stripTransportControlled(overrides);
1837
+ return {
1838
+ ...DEFAULT_DLQ_STREAM_CONFIG,
1839
+ ...safeOverrides,
1840
+ name,
1841
+ subjects,
1842
+ description
1843
+ };
1844
+ }
1411
1845
  /** Get default config for a stream kind. */
1412
1846
  getDefaults(kind) {
1413
1847
  switch (kind) {
@@ -1421,25 +1855,49 @@ var StreamProvider = class {
1421
1855
  return DEFAULT_ORDERED_STREAM_CONFIG;
1422
1856
  }
1423
1857
  }
1424
- /** Get user-provided overrides for a stream kind. */
1858
+ /** Check if scheduling is enabled for a stream kind via `allow_msg_schedules` override. */
1859
+ isSchedulingEnabled(kind) {
1860
+ const overrides = this.getOverrides(kind);
1861
+ return overrides.allow_msg_schedules === true;
1862
+ }
1863
+ /** Get user-provided overrides for a stream kind, stripping transport-controlled properties. */
1425
1864
  getOverrides(kind) {
1865
+ let overrides;
1426
1866
  switch (kind) {
1427
1867
  case "ev" /* Event */:
1428
- return this.options.events?.stream ?? {};
1868
+ overrides = this.options.events?.stream ?? {};
1869
+ break;
1429
1870
  case "cmd" /* Command */:
1430
- return this.options.rpc?.mode === "jetstream" ? this.options.rpc.stream ?? {} : {};
1871
+ overrides = this.options.rpc?.mode === "jetstream" ? this.options.rpc.stream ?? {} : {};
1872
+ break;
1431
1873
  case "broadcast" /* Broadcast */:
1432
- return this.options.broadcast?.stream ?? {};
1874
+ overrides = this.options.broadcast?.stream ?? {};
1875
+ break;
1433
1876
  case "ordered" /* Ordered */:
1434
- return this.options.ordered?.stream ?? {};
1877
+ overrides = this.options.ordered?.stream ?? {};
1878
+ break;
1435
1879
  }
1880
+ return this.stripTransportControlled(overrides);
1881
+ }
1882
+ /**
1883
+ * Remove transport-controlled properties from user overrides.
1884
+ * `retention` is managed by the transport (Workqueue/Limits per stream kind)
1885
+ * and silently stripped to protect users from misconfiguration.
1886
+ */
1887
+ stripTransportControlled(overrides) {
1888
+ if (!("retention" in overrides)) return overrides;
1889
+ this.logger.debug(
1890
+ "Stripping user-provided retention override \u2014 retention is managed by the transport"
1891
+ );
1892
+ const cleaned = { ...overrides };
1893
+ delete cleaned.retention;
1894
+ return cleaned;
1436
1895
  }
1437
1896
  };
1438
1897
 
1439
1898
  // src/server/infrastructure/consumer.provider.ts
1440
- import { Logger as Logger6 } from "@nestjs/common";
1441
- import { NatsError as NatsError3 } from "nats";
1442
- var CONSUMER_NOT_FOUND = 10014;
1899
+ import { Logger as Logger7 } from "@nestjs/common";
1900
+ import { JetStreamApiError as JetStreamApiError3 } from "@nats-io/jetstream";
1443
1901
  var ConsumerProvider = class {
1444
1902
  constructor(options, connection, streamProvider, patternRegistry) {
1445
1903
  this.options = options;
@@ -1447,7 +1905,7 @@ var ConsumerProvider = class {
1447
1905
  this.streamProvider = streamProvider;
1448
1906
  this.patternRegistry = patternRegistry;
1449
1907
  }
1450
- logger = new Logger6("Jetstream:Consumer");
1908
+ logger = new Logger7("Jetstream:Consumer");
1451
1909
  /**
1452
1910
  * Ensure consumers exist for the specified kinds.
1453
1911
  *
@@ -1468,7 +1926,11 @@ var ConsumerProvider = class {
1468
1926
  getConsumerName(kind) {
1469
1927
  return consumerName(this.options.name, kind);
1470
1928
  }
1471
- /** Ensure a single consumer exists, creating if needed. */
1929
+ /**
1930
+ * Ensure a single consumer exists with the desired config.
1931
+ * Used at **startup** — creates or updates the consumer to match
1932
+ * the current pod's configuration.
1933
+ */
1472
1934
  async ensureConsumer(jsm, kind) {
1473
1935
  const stream = this.streamProvider.getStreamName(kind);
1474
1936
  const config = this.buildConfig(kind);
@@ -1479,13 +1941,74 @@ var ConsumerProvider = class {
1479
1941
  this.logger.debug(`Consumer exists, updating: ${name}`);
1480
1942
  return await jsm.consumers.update(stream, name, config);
1481
1943
  } catch (err) {
1482
- if (err instanceof NatsError3 && err.api_error?.err_code === CONSUMER_NOT_FOUND) {
1483
- this.logger.log(`Creating consumer: ${name}`);
1484
- return await jsm.consumers.add(stream, config);
1944
+ if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
1945
+ throw err;
1946
+ }
1947
+ return await this.createConsumer(jsm, stream, name, config);
1948
+ }
1949
+ }
1950
+ /**
1951
+ * Recover a consumer that disappeared during runtime.
1952
+ * Used by **self-healing** — creates if missing, but NEVER updates config.
1953
+ *
1954
+ * If a migration backup stream exists, another pod is mid-migration — we
1955
+ * throw so the self-healing retry loop waits with backoff until migration
1956
+ * completes and the backup is cleaned up.
1957
+ *
1958
+ * This prevents old pods from:
1959
+ * - Overwriting a newer pod's consumer config during rolling updates
1960
+ * - Creating consumers during migration (which would consume and delete
1961
+ * workqueue messages while they're being restored)
1962
+ */
1963
+ async recoverConsumer(jsm, kind) {
1964
+ const stream = this.streamProvider.getStreamName(kind);
1965
+ const config = this.buildConfig(kind);
1966
+ const name = config.durable_name;
1967
+ this.logger.log(`Recovering consumer: ${name} on stream: ${stream}`);
1968
+ await this.assertNoMigrationInProgress(jsm, stream);
1969
+ try {
1970
+ return await jsm.consumers.info(stream, name);
1971
+ } catch (err) {
1972
+ if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
1973
+ throw err;
1974
+ }
1975
+ return await this.createConsumer(jsm, stream, name, config);
1976
+ }
1977
+ }
1978
+ /**
1979
+ * Throw if a migration backup stream exists for this stream.
1980
+ * The self-healing retry loop catches the error and retries with backoff,
1981
+ * naturally waiting until the migrating pod finishes and cleans up the backup.
1982
+ */
1983
+ async assertNoMigrationInProgress(jsm, stream) {
1984
+ const backupName = `${stream}${MIGRATION_BACKUP_SUFFIX}`;
1985
+ try {
1986
+ await jsm.streams.info(backupName);
1987
+ throw new Error(
1988
+ `Stream ${stream} is being migrated (backup ${backupName} exists). Waiting for migration to complete before recovering consumer.`
1989
+ );
1990
+ } catch (err) {
1991
+ if (err instanceof JetStreamApiError3 && err.apiError().err_code === 10059 /* StreamNotFound */) {
1992
+ return;
1485
1993
  }
1486
1994
  throw err;
1487
1995
  }
1488
1996
  }
1997
+ /**
1998
+ * Create a consumer, handling the race where another pod creates it first.
1999
+ */
2000
+ async createConsumer(jsm, stream, name, config) {
2001
+ this.logger.log(`Creating consumer: ${name}`);
2002
+ try {
2003
+ return await jsm.consumers.add(stream, config);
2004
+ } catch (addErr) {
2005
+ if (addErr instanceof JetStreamApiError3 && addErr.apiError().err_code === 10148 /* ConsumerAlreadyExists */) {
2006
+ this.logger.debug(`Consumer ${name} created by another pod, using existing`);
2007
+ return await jsm.consumers.info(stream, name);
2008
+ }
2009
+ throw addErr;
2010
+ }
2011
+ }
1489
2012
  /** Build consumer config by merging defaults with user overrides. */
1490
2013
  // eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
1491
2014
  buildConfig(kind) {
@@ -1538,6 +2061,11 @@ var ConsumerProvider = class {
1538
2061
  return DEFAULT_BROADCAST_CONSUMER_CONFIG;
1539
2062
  case "ordered" /* Ordered */:
1540
2063
  throw new Error("Ordered consumers are ephemeral and should not use durable config");
2064
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
2065
+ default: {
2066
+ const _exhaustive = kind;
2067
+ throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
2068
+ }
1541
2069
  }
1542
2070
  }
1543
2071
  /** Get user-provided overrides for a consumer kind. */
@@ -1551,16 +2079,18 @@ var ConsumerProvider = class {
1551
2079
  return this.options.broadcast?.consumer ?? {};
1552
2080
  case "ordered" /* Ordered */:
1553
2081
  throw new Error("Ordered consumers are ephemeral and should not use durable config");
2082
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
2083
+ default: {
2084
+ const _exhaustive = kind;
2085
+ throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
2086
+ }
1554
2087
  }
1555
2088
  }
1556
2089
  };
1557
2090
 
1558
2091
  // src/server/infrastructure/message.provider.ts
1559
- import { Logger as Logger7 } from "@nestjs/common";
1560
- import {
1561
- ConsumerEvents,
1562
- DeliverPolicy as DeliverPolicy2
1563
- } from "nats";
2092
+ import { Logger as Logger8 } from "@nestjs/common";
2093
+ import { DeliverPolicy as DeliverPolicy2 } from "@nats-io/jetstream";
1564
2094
  import {
1565
2095
  catchError,
1566
2096
  defer as defer2,
@@ -1573,12 +2103,13 @@ import {
1573
2103
  timer
1574
2104
  } from "rxjs";
1575
2105
  var MessageProvider = class {
1576
- constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map()) {
2106
+ constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map(), consumerRecoveryFn) {
1577
2107
  this.connection = connection;
1578
2108
  this.eventBus = eventBus;
1579
2109
  this.consumeOptionsMap = consumeOptionsMap;
2110
+ this.consumerRecoveryFn = consumerRecoveryFn;
1580
2111
  }
1581
- logger = new Logger7("Jetstream:Message");
2112
+ logger = new Logger8("Jetstream:Message");
1582
2113
  activeIterators = /* @__PURE__ */ new Set();
1583
2114
  orderedReadyResolve = null;
1584
2115
  orderedReadyReject = null;
@@ -1629,7 +2160,7 @@ var MessageProvider = class {
1629
2160
  * @param orderedConfig - Optional overrides for ordered consumer options.
1630
2161
  */
1631
2162
  async startOrdered(streamName2, filterSubjects, orderedConfig) {
1632
- const consumerOpts = { filterSubjects };
2163
+ const consumerOpts = { filter_subjects: filterSubjects };
1633
2164
  if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !== DeliverPolicy2.All) {
1634
2165
  consumerOpts.deliver_policy = orderedConfig.deliverPolicy;
1635
2166
  }
@@ -1681,12 +2212,26 @@ var MessageProvider = class {
1681
2212
  /** Single iteration: get consumer -> pull messages -> emit to subject. */
1682
2213
  async consumeOnce(kind, info, target$) {
1683
2214
  const js = this.connection.getJetStreamClient();
1684
- const consumer = await js.consumers.get(info.stream_name, info.name);
2215
+ let consumer;
2216
+ let consumerName2 = info.name;
2217
+ try {
2218
+ consumer = await js.consumers.get(info.stream_name, info.name);
2219
+ } catch (err) {
2220
+ if (this.isConsumerNotFound(err) && this.consumerRecoveryFn) {
2221
+ this.logger.warn(`Consumer ${info.name} not found, recreating...`);
2222
+ const recovered = await this.consumerRecoveryFn(kind);
2223
+ consumerName2 = recovered.name;
2224
+ this.logger.log(`Consumer ${consumerName2} recreated, resuming consumption`);
2225
+ consumer = await js.consumers.get(recovered.stream_name, consumerName2);
2226
+ } else {
2227
+ throw err;
2228
+ }
2229
+ }
1685
2230
  const defaults = { idle_heartbeat: 5e3 };
1686
2231
  const userOptions = this.consumeOptionsMap.get(kind) ?? {};
1687
2232
  const messages = await consumer.consume({ ...defaults, ...userOptions });
1688
2233
  this.activeIterators.add(messages);
1689
- this.monitorConsumerHealth(messages, info.name);
2234
+ this.monitorConsumerHealth(messages, consumerName2);
1690
2235
  try {
1691
2236
  for await (const msg of messages) {
1692
2237
  target$.next(msg);
@@ -1695,6 +2240,17 @@ var MessageProvider = class {
1695
2240
  this.activeIterators.delete(messages);
1696
2241
  }
1697
2242
  }
2243
+ /**
2244
+ * Detect "consumer not found" errors from `js.consumers.get()`.
2245
+ *
2246
+ * Unlike JetStream Manager calls (which throw `JetStreamApiError`),
2247
+ * the JetStream client's `consumers.get()` throws a plain `Error`
2248
+ * with the error code embedded in the message text.
2249
+ */
2250
+ isConsumerNotFound(err) {
2251
+ if (!(err instanceof Error)) return false;
2252
+ return err.message.includes("consumer not found") || err.message.includes(String(10014 /* ConsumerNotFound */));
2253
+ }
1698
2254
  /** Get the target subject for a consumer kind. */
1699
2255
  getTargetSubject(kind) {
1700
2256
  switch (kind) {
@@ -1706,6 +2262,7 @@ var MessageProvider = class {
1706
2262
  return this.broadcastMessages$;
1707
2263
  case "ordered" /* Ordered */:
1708
2264
  return this.orderedMessages$;
2265
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
1709
2266
  default: {
1710
2267
  const _exhaustive = kind;
1711
2268
  throw new Error(`Unknown stream kind: ${_exhaustive}`);
@@ -1714,10 +2271,10 @@ var MessageProvider = class {
1714
2271
  }
1715
2272
  /** Monitor heartbeats and restart the consumer iterator on prolonged silence. */
1716
2273
  monitorConsumerHealth(messages, name) {
1717
- (async () => {
1718
- for await (const status of await messages.status()) {
1719
- if (status.type === ConsumerEvents.HeartbeatsMissed && status.data >= 2) {
1720
- this.logger.warn(`Consumer ${name}: ${status.data} heartbeats missed, restarting`);
2274
+ void (async () => {
2275
+ for await (const status of messages.status()) {
2276
+ if (status.type === "heartbeats_missed" && status.count >= 2) {
2277
+ this.logger.warn(`Consumer ${name}: ${status.count} heartbeats missed, restarting`);
1721
2278
  messages.stop();
1722
2279
  break;
1723
2280
  }
@@ -1791,8 +2348,110 @@ var MessageProvider = class {
1791
2348
  }
1792
2349
  };
1793
2350
 
2351
+ // src/server/infrastructure/metadata.provider.ts
2352
+ import { Logger as Logger9 } from "@nestjs/common";
2353
+ import { Kvm } from "@nats-io/kv";
2354
+ var MetadataProvider = class {
2355
+ constructor(options, connection) {
2356
+ this.connection = connection;
2357
+ this.bucketName = options.metadata?.bucket ?? DEFAULT_METADATA_BUCKET;
2358
+ this.replicas = options.metadata?.replicas ?? DEFAULT_METADATA_REPLICAS;
2359
+ this.ttl = Math.max(options.metadata?.ttl ?? DEFAULT_METADATA_TTL, MIN_METADATA_TTL);
2360
+ }
2361
+ logger = new Logger9("Jetstream:Metadata");
2362
+ bucketName;
2363
+ replicas;
2364
+ ttl;
2365
+ currentEntries;
2366
+ heartbeatTimer;
2367
+ cachedKv;
2368
+ /**
2369
+ * Write handler metadata entries to the KV bucket and start heartbeat.
2370
+ *
2371
+ * Creates the bucket if it doesn't exist (idempotent).
2372
+ * Skips silently when entries map is empty.
2373
+ * Starts a heartbeat interval that refreshes entries every `ttl / 2`
2374
+ * to prevent TTL expiry while the pod is alive.
2375
+ *
2376
+ * Non-critical — errors are logged but do not prevent transport startup.
2377
+ *
2378
+ * @param entries Map of KV key → metadata object.
2379
+ */
2380
+ async publish(entries) {
2381
+ if (entries.size === 0) return;
2382
+ try {
2383
+ const kv = await this.openBucket();
2384
+ await this.writeEntries(kv, entries);
2385
+ this.currentEntries = entries;
2386
+ this.startHeartbeat();
2387
+ this.logger.log(
2388
+ `Published ${entries.size} handler metadata entries to KV bucket "${this.bucketName}"`
2389
+ );
2390
+ } catch (err) {
2391
+ this.logger.error("Failed to publish handler metadata to KV", err);
2392
+ }
2393
+ }
2394
+ /**
2395
+ * Stop the heartbeat timer.
2396
+ *
2397
+ * After this call, entries will expire via TTL once the heartbeat window passes.
2398
+ * Called during transport shutdown (strategy.close()).
2399
+ */
2400
+ destroy() {
2401
+ if (this.heartbeatTimer) {
2402
+ clearInterval(this.heartbeatTimer);
2403
+ this.heartbeatTimer = void 0;
2404
+ }
2405
+ this.currentEntries = void 0;
2406
+ this.cachedKv = void 0;
2407
+ }
2408
+ /** Write entries to KV with per-entry error handling. */
2409
+ async writeEntries(kv, entries) {
2410
+ for (const [key, meta] of entries) {
2411
+ try {
2412
+ await kv.put(key, JSON.stringify(meta));
2413
+ } catch (err) {
2414
+ this.logger.error(`Failed to write metadata entry "${key}"`, err);
2415
+ }
2416
+ }
2417
+ }
2418
+ /** Start heartbeat interval that refreshes entries every ttl/2. */
2419
+ startHeartbeat() {
2420
+ if (this.heartbeatTimer) {
2421
+ clearInterval(this.heartbeatTimer);
2422
+ }
2423
+ const interval = Math.floor(this.ttl / 2);
2424
+ this.heartbeatTimer = setInterval(() => {
2425
+ void this.refreshEntries();
2426
+ }, interval);
2427
+ this.heartbeatTimer.unref();
2428
+ }
2429
+ /** Refresh all current entries in KV (heartbeat tick). */
2430
+ async refreshEntries() {
2431
+ if (!this.currentEntries || this.currentEntries.size === 0) return;
2432
+ try {
2433
+ const kv = await this.openBucket();
2434
+ await this.writeEntries(kv, this.currentEntries);
2435
+ } catch (err) {
2436
+ this.logger.error("Failed to refresh handler metadata in KV", err);
2437
+ }
2438
+ }
2439
+ /** Create or open the KV bucket (cached after first call). */
2440
+ async openBucket() {
2441
+ if (this.cachedKv) return this.cachedKv;
2442
+ const js = this.connection.getJetStreamClient();
2443
+ const kvm = new Kvm(js);
2444
+ this.cachedKv = await kvm.create(this.bucketName, {
2445
+ history: DEFAULT_METADATA_HISTORY,
2446
+ replicas: this.replicas,
2447
+ ttl: this.ttl
2448
+ });
2449
+ return this.cachedKv;
2450
+ }
2451
+ };
2452
+
1794
2453
  // src/server/routing/pattern-registry.ts
1795
- import { Logger as Logger8 } from "@nestjs/common";
2454
+ import { Logger as Logger10 } from "@nestjs/common";
1796
2455
  var HANDLER_LABELS = {
1797
2456
  ["broadcast" /* Broadcast */]: "broadcast" /* Broadcast */,
1798
2457
  ["ordered" /* Ordered */]: "ordered" /* Ordered */,
@@ -1803,7 +2462,7 @@ var PatternRegistry = class {
1803
2462
  constructor(options) {
1804
2463
  this.options = options;
1805
2464
  }
1806
- logger = new Logger8("Jetstream:PatternRegistry");
2465
+ logger = new Logger10("Jetstream:PatternRegistry");
1807
2466
  registry = /* @__PURE__ */ new Map();
1808
2467
  // Cached after registerHandlers() — the registry is immutable from that point
1809
2468
  cachedPatterns = null;
@@ -1811,6 +2470,7 @@ var PatternRegistry = class {
1811
2470
  _hasCommands = false;
1812
2471
  _hasBroadcasts = false;
1813
2472
  _hasOrdered = false;
2473
+ _hasMetadata = false;
1814
2474
  /**
1815
2475
  * Register all handlers from the NestJS strategy.
1816
2476
  *
@@ -1823,6 +2483,7 @@ var PatternRegistry = class {
1823
2483
  const isEvent = handler.isEventHandler ?? false;
1824
2484
  const isBroadcast = !!extras?.broadcast;
1825
2485
  const isOrdered = !!extras?.ordered;
2486
+ const meta = extras?.meta;
1826
2487
  if (isBroadcast && isOrdered) {
1827
2488
  throw new Error(
1828
2489
  `Handler "${pattern}" cannot be both broadcast and ordered. Use one or the other.`
@@ -1839,7 +2500,8 @@ var PatternRegistry = class {
1839
2500
  pattern,
1840
2501
  isEvent: isEvent && !isOrdered,
1841
2502
  isBroadcast,
1842
- isOrdered
2503
+ isOrdered,
2504
+ meta
1843
2505
  });
1844
2506
  this.logger.debug(`Registered ${HANDLER_LABELS[kind]}: ${pattern} -> ${fullSubject}`);
1845
2507
  }
@@ -1848,6 +2510,7 @@ var PatternRegistry = class {
1848
2510
  this._hasCommands = this.cachedPatterns.commands.length > 0;
1849
2511
  this._hasBroadcasts = this.cachedPatterns.broadcasts.length > 0;
1850
2512
  this._hasOrdered = this.cachedPatterns.ordered.length > 0;
2513
+ this._hasMetadata = [...this.registry.values()].some((entry) => entry.meta !== void 0);
1851
2514
  this.logSummary();
1852
2515
  }
1853
2516
  /** Find handler for a full NATS subject. */
@@ -1876,6 +2539,26 @@ var PatternRegistry = class {
1876
2539
  (p) => buildSubject(this.options.name, "ordered" /* Ordered */, p)
1877
2540
  );
1878
2541
  }
2542
+ /** Check if any registered handler has metadata. */
2543
+ hasMetadata() {
2544
+ return this._hasMetadata;
2545
+ }
2546
+ /**
2547
+ * Get handler metadata entries for KV publishing.
2548
+ *
2549
+ * Returns a map of KV key -> metadata object for all handlers that have `meta`.
2550
+ * Key format: `{serviceName}.{kind}.{pattern}`.
2551
+ */
2552
+ getMetadataEntries() {
2553
+ const entries = /* @__PURE__ */ new Map();
2554
+ for (const entry of this.registry.values()) {
2555
+ if (!entry.meta) continue;
2556
+ const kind = this.resolveStreamKind(entry);
2557
+ const key = metadataKey(this.options.name, kind, entry.pattern);
2558
+ entries.set(key, entry.meta);
2559
+ }
2560
+ return entries;
2561
+ }
1879
2562
  /** Get patterns grouped by kind (cached after registration). */
1880
2563
  getPatternsByKind() {
1881
2564
  const patterns = this.cachedPatterns ?? this.buildPatternsByKind();
@@ -1915,6 +2598,12 @@ var PatternRegistry = class {
1915
2598
  }
1916
2599
  return { events, commands, broadcasts, ordered };
1917
2600
  }
2601
+ resolveStreamKind(entry) {
2602
+ if (entry.isBroadcast) return "broadcast" /* Broadcast */;
2603
+ if (entry.isOrdered) return "ordered" /* Ordered */;
2604
+ if (entry.isEvent) return "ev" /* Event */;
2605
+ return "cmd" /* Command */;
2606
+ }
1918
2607
  logSummary() {
1919
2608
  const { events, commands, broadcasts, ordered } = this.getPatternsByKind();
1920
2609
  const parts = [
@@ -1930,10 +2619,11 @@ var PatternRegistry = class {
1930
2619
  };
1931
2620
 
1932
2621
  // src/server/routing/event.router.ts
1933
- import { Logger as Logger9 } from "@nestjs/common";
2622
+ import { Logger as Logger11 } from "@nestjs/common";
1934
2623
  import { concatMap, from as from2, mergeMap } from "rxjs";
2624
+ import { headers as natsHeaders3 } from "@nats-io/transport-node";
1935
2625
  var EventRouter = class {
1936
- constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap) {
2626
+ constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap, connection, options) {
1937
2627
  this.messageProvider = messageProvider;
1938
2628
  this.patternRegistry = patternRegistry;
1939
2629
  this.codec = codec;
@@ -1941,8 +2631,10 @@ var EventRouter = class {
1941
2631
  this.deadLetterConfig = deadLetterConfig;
1942
2632
  this.processingConfig = processingConfig;
1943
2633
  this.ackWaitMap = ackWaitMap;
2634
+ this.connection = connection;
2635
+ this.options = options;
1944
2636
  }
1945
- logger = new Logger9("Jetstream:EventRouter");
2637
+ logger = new Logger11("Jetstream:EventRouter");
1946
2638
  subscriptions = [];
1947
2639
  /**
1948
2640
  * Update the max_deliver thresholds from actual NATS consumer configs.
@@ -2069,6 +2761,93 @@ var EventRouter = class {
2069
2761
  return msg.info.deliveryCount >= maxDeliver;
2070
2762
  }
2071
2763
  /** Handle a dead letter: invoke callback, then term or nak based on result. */
2764
+ /**
2765
+ * Fallback execution for a dead letter when DLQ is disabled, or when
2766
+ * publishing to the DLQ stream fails (due to network or NATS errors).
2767
+ *
2768
+ * Triggers the user-provided `onDeadLetter` hook for logging/alerting.
2769
+ * On success, terminates the message. On error, leaves it unacknowledged (nak)
2770
+ * so NATS can retry the delivery on the next cycle.
2771
+ */
2772
+ async fallbackToOnDeadLetterCallback(info, msg) {
2773
+ if (!this.deadLetterConfig) {
2774
+ msg.term("Dead letter config unavailable");
2775
+ return;
2776
+ }
2777
+ try {
2778
+ await this.deadLetterConfig.onDeadLetter(info);
2779
+ msg.term("Dead letter processed via fallback callback");
2780
+ } catch (hookErr) {
2781
+ this.logger.error(
2782
+ `Fallback onDeadLetter callback failed for ${msg.subject}, nak for retry:`,
2783
+ hookErr
2784
+ );
2785
+ msg.nak();
2786
+ }
2787
+ }
2788
+ /**
2789
+ * Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
2790
+ *
2791
+ * Appends diagnostic metadata headers to the original message and preserves
2792
+ * the primary payload. If publishing succeeds, it notifies the standard
2793
+ * `onDeadLetter` callback and terminates the message. If it fails, it falls
2794
+ * back to the callback entirely to prevent silent data loss.
2795
+ */
2796
+ async publishToDlq(msg, info, error) {
2797
+ const serviceName = this.options?.name;
2798
+ if (!this.connection || !serviceName) {
2799
+ this.logger.error(
2800
+ `Cannot publish to DLQ for ${msg.subject}: Connection or Module Options unavailable`
2801
+ );
2802
+ await this.fallbackToOnDeadLetterCallback(info, msg);
2803
+ return;
2804
+ }
2805
+ const destinationSubject = dlqStreamName(serviceName);
2806
+ const hdrs = natsHeaders3();
2807
+ if (msg.headers) {
2808
+ for (const [k, v] of msg.headers) {
2809
+ for (const val of v) {
2810
+ hdrs.append(k, val);
2811
+ }
2812
+ }
2813
+ }
2814
+ let reason = String(error);
2815
+ if (error instanceof Error) {
2816
+ reason = error.message;
2817
+ } else if (typeof error === "object" && error !== null && "message" in error) {
2818
+ reason = String(error.message);
2819
+ }
2820
+ hdrs.set("x-dead-letter-reason" /* DeadLetterReason */, reason);
2821
+ hdrs.set("x-original-subject" /* OriginalSubject */, msg.subject);
2822
+ hdrs.set("x-original-stream" /* OriginalStream */, msg.info.stream);
2823
+ hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
2824
+ hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
2825
+ try {
2826
+ const js = this.connection.getJetStreamClient();
2827
+ await js.publish(destinationSubject, msg.data, { headers: hdrs });
2828
+ this.logger.log(`Message sent to DLQ: ${msg.subject}`);
2829
+ if (this.deadLetterConfig?.onDeadLetter) {
2830
+ try {
2831
+ await this.deadLetterConfig.onDeadLetter(info);
2832
+ } catch (hookErr) {
2833
+ this.logger.warn(
2834
+ `onDeadLetter callback failed after successful DLQ publish for ${msg.subject}`,
2835
+ hookErr
2836
+ );
2837
+ }
2838
+ }
2839
+ msg.term("Moved to DLQ stream");
2840
+ } catch (publishErr) {
2841
+ this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
2842
+ await this.fallbackToOnDeadLetterCallback(info, msg);
2843
+ }
2844
+ }
2845
+ /**
2846
+ * Orchestrates the handling of a message that has exhausted delivery limits.
2847
+ *
2848
+ * Emits a system event and delegates either to the robust DLQ stream publisher
2849
+ * or directly to the fallback callback based on the active module configuration.
2850
+ */
2072
2851
  async handleDeadLetter(msg, data, error) {
2073
2852
  const info = {
2074
2853
  subject: msg.subject,
@@ -2081,23 +2860,17 @@ var EventRouter = class {
2081
2860
  timestamp: new Date(msg.info.timestampNanos / 1e6).toISOString()
2082
2861
  };
2083
2862
  this.eventBus.emit("deadLetter" /* DeadLetter */, info);
2084
- if (!this.deadLetterConfig) {
2085
- msg.term("Dead letter config unavailable");
2086
- return;
2087
- }
2088
- try {
2089
- await this.deadLetterConfig.onDeadLetter(info);
2090
- msg.term("Dead letter processed");
2091
- } catch (hookErr) {
2092
- this.logger.error(`onDeadLetter callback failed for ${msg.subject}, nak for retry:`, hookErr);
2093
- msg.nak();
2863
+ if (!this.options?.dlq) {
2864
+ await this.fallbackToOnDeadLetterCallback(info, msg);
2865
+ } else {
2866
+ await this.publishToDlq(msg, info, error);
2094
2867
  }
2095
2868
  }
2096
2869
  };
2097
2870
 
2098
2871
  // src/server/routing/rpc.router.ts
2099
- import { Logger as Logger10 } from "@nestjs/common";
2100
- import { headers } from "nats";
2872
+ import { Logger as Logger12 } from "@nestjs/common";
2873
+ import { headers } from "@nats-io/transport-node";
2101
2874
  import { from as from3, mergeMap as mergeMap2 } from "rxjs";
2102
2875
  var RpcRouter = class {
2103
2876
  constructor(messageProvider, patternRegistry, connection, codec, eventBus, rpcOptions, ackWaitMap) {
@@ -2111,7 +2884,7 @@ var RpcRouter = class {
2111
2884
  this.timeout = rpcOptions?.timeout ?? DEFAULT_JETSTREAM_RPC_TIMEOUT;
2112
2885
  this.concurrency = rpcOptions?.concurrency;
2113
2886
  }
2114
- logger = new Logger10("Jetstream:RpcRouter");
2887
+ logger = new Logger12("Jetstream:RpcRouter");
2115
2888
  timeout;
2116
2889
  concurrency;
2117
2890
  resolvedAckExtensionInterval;
@@ -2214,20 +2987,27 @@ var RpcRouter = class {
2214
2987
  };
2215
2988
 
2216
2989
  // src/shutdown/shutdown.manager.ts
2217
- import { Logger as Logger11 } from "@nestjs/common";
2990
+ import { Logger as Logger13 } from "@nestjs/common";
2218
2991
  var ShutdownManager = class {
2219
2992
  constructor(connection, eventBus, timeout) {
2220
2993
  this.connection = connection;
2221
2994
  this.eventBus = eventBus;
2222
2995
  this.timeout = timeout;
2223
2996
  }
2224
- logger = new Logger11("Jetstream:Shutdown");
2997
+ logger = new Logger13("Jetstream:Shutdown");
2998
+ shutdownPromise;
2225
2999
  /**
2226
3000
  * Execute the full shutdown sequence.
2227
3001
  *
3002
+ * Idempotent — concurrent or repeated calls return the same promise.
3003
+ *
2228
3004
  * @param strategy Optional stoppable to close (stops consumers and subscriptions).
2229
3005
  */
2230
3006
  async shutdown(strategy) {
3007
+ this.shutdownPromise ??= this.doShutdown(strategy);
3008
+ return this.shutdownPromise;
3009
+ }
3010
+ async doShutdown(strategy) {
2231
3011
  this.eventBus.emit("shutdownStart" /* ShutdownStart */);
2232
3012
  this.logger.log(`Graceful shutdown started (timeout: ${this.timeout}ms)`);
2233
3013
  strategy?.close();
@@ -2362,7 +3142,7 @@ var JetstreamModule = class {
2362
3142
  provide: JETSTREAM_EVENT_BUS,
2363
3143
  inject: [JETSTREAM_OPTIONS],
2364
3144
  useFactory: (options) => {
2365
- const logger = new Logger12("Jetstream:Module");
3145
+ const logger = new Logger14("Jetstream:Module");
2366
3146
  return new EventBus(logger, options.hooks);
2367
3147
  }
2368
3148
  },
@@ -2441,8 +3221,8 @@ var JetstreamModule = class {
2441
3221
  // MessageProvider — pull-based message consumption
2442
3222
  {
2443
3223
  provide: MessageProvider,
2444
- inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS],
2445
- useFactory: (options, connection, eventBus) => {
3224
+ inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS, ConsumerProvider],
3225
+ useFactory: (options, connection, eventBus, consumerProvider) => {
2446
3226
  if (options.consumer === false) return null;
2447
3227
  const consumeOptionsMap = /* @__PURE__ */ new Map();
2448
3228
  if (options.events?.consume)
@@ -2452,7 +3232,11 @@ var JetstreamModule = class {
2452
3232
  if (options.rpc?.mode === "jetstream" && options.rpc.consume) {
2453
3233
  consumeOptionsMap.set("cmd" /* Command */, options.rpc.consume);
2454
3234
  }
2455
- return new MessageProvider(connection, eventBus, consumeOptionsMap);
3235
+ const consumerRecoveryFn = consumerProvider ? async (kind) => {
3236
+ const jsm = await connection.getJetStreamManager();
3237
+ return consumerProvider.recoverConsumer(jsm, kind);
3238
+ } : void 0;
3239
+ return new MessageProvider(connection, eventBus, consumeOptionsMap, consumerRecoveryFn);
2456
3240
  }
2457
3241
  },
2458
3242
  // EventRouter — routes event and broadcast messages to handlers
@@ -2464,9 +3248,10 @@ var JetstreamModule = class {
2464
3248
  PatternRegistry,
2465
3249
  JETSTREAM_CODEC,
2466
3250
  JETSTREAM_EVENT_BUS,
2467
- JETSTREAM_ACK_WAIT_MAP
3251
+ JETSTREAM_ACK_WAIT_MAP,
3252
+ JETSTREAM_CONNECTION
2468
3253
  ],
2469
- useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap) => {
3254
+ useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
2470
3255
  if (options.consumer === false) return null;
2471
3256
  const deadLetterConfig = options.onDeadLetter ? {
2472
3257
  maxDeliverByStream: /* @__PURE__ */ new Map(),
@@ -2489,7 +3274,9 @@ var JetstreamModule = class {
2489
3274
  eventBus,
2490
3275
  deadLetterConfig,
2491
3276
  processingConfig,
2492
- ackWaitMap
3277
+ ackWaitMap,
3278
+ connection,
3279
+ options
2493
3280
  );
2494
3281
  }
2495
3282
  },
@@ -2538,6 +3325,15 @@ var JetstreamModule = class {
2538
3325
  return new CoreRpcServer(options, connection, patternRegistry, codec, eventBus);
2539
3326
  }
2540
3327
  },
3328
+ // MetadataProvider — handler metadata KV registry (decoupled from stream/consumer infra)
3329
+ {
3330
+ provide: MetadataProvider,
3331
+ inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION],
3332
+ useFactory: (options, connection) => {
3333
+ if (options.consumer === false) return null;
3334
+ return new MetadataProvider(options, connection);
3335
+ }
3336
+ },
2541
3337
  // JetstreamStrategy — server-side transport (only when consumer enabled)
2542
3338
  {
2543
3339
  provide: JetstreamStrategy,
@@ -2551,9 +3347,10 @@ var JetstreamModule = class {
2551
3347
  EventRouter,
2552
3348
  RpcRouter,
2553
3349
  CoreRpcServer,
2554
- JETSTREAM_ACK_WAIT_MAP
3350
+ JETSTREAM_ACK_WAIT_MAP,
3351
+ MetadataProvider
2555
3352
  ],
2556
- useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap) => {
3353
+ useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap, metadataProvider) => {
2557
3354
  if (options.consumer === false) return null;
2558
3355
  return new JetstreamStrategy(
2559
3356
  options,
@@ -2565,7 +3362,8 @@ var JetstreamModule = class {
2565
3362
  eventRouter,
2566
3363
  rpcRouter,
2567
3364
  coreRpcServer,
2568
- ackWaitMap
3365
+ ackWaitMap,
3366
+ metadataProvider
2569
3367
  );
2570
3368
  }
2571
3369
  }
@@ -2632,12 +3430,17 @@ JetstreamModule = __decorateClass([
2632
3430
  __decorateParam(1, Inject(JetstreamStrategy))
2633
3431
  ], JetstreamModule);
2634
3432
  export {
3433
+ DEFAULT_METADATA_BUCKET,
3434
+ DEFAULT_METADATA_HISTORY,
3435
+ DEFAULT_METADATA_REPLICAS,
3436
+ DEFAULT_METADATA_TTL,
2635
3437
  EventBus,
2636
3438
  JETSTREAM_CODEC,
2637
3439
  JETSTREAM_CONNECTION,
2638
3440
  JETSTREAM_EVENT_BUS,
2639
3441
  JETSTREAM_OPTIONS,
2640
3442
  JetstreamClient,
3443
+ JetstreamDlqHeader,
2641
3444
  JetstreamHeader,
2642
3445
  JetstreamHealthIndicator,
2643
3446
  JetstreamModule,
@@ -2645,17 +3448,21 @@ export {
2645
3448
  JetstreamRecordBuilder,
2646
3449
  JetstreamStrategy,
2647
3450
  JsonCodec,
3451
+ MIN_METADATA_TTL,
2648
3452
  MessageKind,
2649
3453
  PatternPrefix,
2650
3454
  RpcContext,
2651
3455
  StreamKind,
2652
3456
  TransportEvent,
3457
+ buildBroadcastSubject,
2653
3458
  buildSubject,
2654
3459
  consumerName,
3460
+ dlqStreamName,
2655
3461
  getClientToken,
2656
3462
  internalName,
2657
3463
  isCoreRpcMode,
2658
3464
  isJetStreamRpcMode,
3465
+ metadataKey,
2659
3466
  streamName,
2660
3467
  toNanos
2661
3468
  };