@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.cjs CHANGED
@@ -29,12 +29,17 @@ var __decorateParam = (index, decorator) => (target, key) => decorator(target, k
29
29
  // src/index.ts
30
30
  var index_exports = {};
31
31
  __export(index_exports, {
32
+ DEFAULT_METADATA_BUCKET: () => DEFAULT_METADATA_BUCKET,
33
+ DEFAULT_METADATA_HISTORY: () => DEFAULT_METADATA_HISTORY,
34
+ DEFAULT_METADATA_REPLICAS: () => DEFAULT_METADATA_REPLICAS,
35
+ DEFAULT_METADATA_TTL: () => DEFAULT_METADATA_TTL,
32
36
  EventBus: () => EventBus,
33
37
  JETSTREAM_CODEC: () => JETSTREAM_CODEC,
34
38
  JETSTREAM_CONNECTION: () => JETSTREAM_CONNECTION,
35
39
  JETSTREAM_EVENT_BUS: () => JETSTREAM_EVENT_BUS,
36
40
  JETSTREAM_OPTIONS: () => JETSTREAM_OPTIONS,
37
41
  JetstreamClient: () => JetstreamClient,
42
+ JetstreamDlqHeader: () => JetstreamDlqHeader,
38
43
  JetstreamHeader: () => JetstreamHeader,
39
44
  JetstreamHealthIndicator: () => JetstreamHealthIndicator,
40
45
  JetstreamModule: () => JetstreamModule,
@@ -42,29 +47,34 @@ __export(index_exports, {
42
47
  JetstreamRecordBuilder: () => JetstreamRecordBuilder,
43
48
  JetstreamStrategy: () => JetstreamStrategy,
44
49
  JsonCodec: () => JsonCodec,
50
+ MIN_METADATA_TTL: () => MIN_METADATA_TTL,
45
51
  MessageKind: () => MessageKind,
46
52
  PatternPrefix: () => PatternPrefix,
47
53
  RpcContext: () => RpcContext,
48
54
  StreamKind: () => StreamKind,
49
55
  TransportEvent: () => TransportEvent,
56
+ buildBroadcastSubject: () => buildBroadcastSubject,
50
57
  buildSubject: () => buildSubject,
51
58
  consumerName: () => consumerName,
59
+ dlqStreamName: () => dlqStreamName,
52
60
  getClientToken: () => getClientToken,
53
61
  internalName: () => internalName,
54
62
  isCoreRpcMode: () => isCoreRpcMode,
55
63
  isJetStreamRpcMode: () => isJetStreamRpcMode,
64
+ metadataKey: () => metadataKey,
56
65
  streamName: () => streamName,
57
66
  toNanos: () => toNanos
58
67
  });
59
68
  module.exports = __toCommonJS(index_exports);
60
69
 
61
70
  // src/jetstream.module.ts
62
- var import_common12 = require("@nestjs/common");
71
+ var import_common14 = require("@nestjs/common");
63
72
 
64
73
  // src/client/jetstream.client.ts
65
74
  var import_common = require("@nestjs/common");
66
75
  var import_microservices = require("@nestjs/microservices");
67
- var import_nats2 = require("nats");
76
+ var import_transport_node = require("@nats-io/transport-node");
77
+ var import_nuid = require("@nats-io/nuid");
68
78
 
69
79
  // src/interfaces/hooks.interface.ts
70
80
  var MessageKind = /* @__PURE__ */ ((MessageKind2) => {
@@ -95,7 +105,7 @@ var StreamKind = /* @__PURE__ */ ((StreamKind2) => {
95
105
  })(StreamKind || {});
96
106
 
97
107
  // src/jetstream.constants.ts
98
- var import_nats = require("nats");
108
+ var import_jetstream = require("@nats-io/jetstream");
99
109
  var JETSTREAM_OPTIONS = /* @__PURE__ */ Symbol("JETSTREAM_OPTIONS");
100
110
  var JETSTREAM_CONNECTION = /* @__PURE__ */ Symbol("JETSTREAM_CONNECTION");
101
111
  var JETSTREAM_CODEC = /* @__PURE__ */ Symbol("JETSTREAM_CODEC");
@@ -113,12 +123,12 @@ var NANOS_PER = {
113
123
  };
114
124
  var toNanos = (value, unit) => value * NANOS_PER[unit];
115
125
  var baseStreamConfig = {
116
- retention: import_nats.RetentionPolicy.Workqueue,
117
- storage: import_nats.StorageType.File,
126
+ retention: import_jetstream.RetentionPolicy.Workqueue,
127
+ storage: import_jetstream.StorageType.File,
118
128
  num_replicas: 1,
119
- discard: import_nats.DiscardPolicy.Old,
129
+ discard: import_jetstream.DiscardPolicy.Old,
120
130
  allow_direct: true,
121
- compression: import_nats.StoreCompression.S2
131
+ compression: import_jetstream.StoreCompression.S2
122
132
  };
123
133
  var DEFAULT_EVENT_STREAM_CONFIG = {
124
134
  ...baseStreamConfig,
@@ -144,19 +154,19 @@ var DEFAULT_COMMAND_STREAM_CONFIG = {
144
154
  };
145
155
  var DEFAULT_BROADCAST_STREAM_CONFIG = {
146
156
  ...baseStreamConfig,
147
- retention: import_nats.RetentionPolicy.Limits,
157
+ retention: import_jetstream.RetentionPolicy.Limits,
148
158
  allow_rollup_hdrs: true,
149
159
  max_consumers: 200,
150
160
  max_msg_size: 10 * MB,
151
161
  max_msgs_per_subject: 1e6,
152
162
  max_msgs: 1e7,
153
163
  max_bytes: 2 * GB,
154
- max_age: toNanos(1, "days"),
164
+ max_age: toNanos(1, "hours"),
155
165
  duplicate_window: toNanos(2, "minutes")
156
166
  };
157
167
  var DEFAULT_ORDERED_STREAM_CONFIG = {
158
168
  ...baseStreamConfig,
159
- retention: import_nats.RetentionPolicy.Limits,
169
+ retention: import_jetstream.RetentionPolicy.Limits,
160
170
  allow_rollup_hdrs: false,
161
171
  max_consumers: 100,
162
172
  max_msg_size: 10 * MB,
@@ -166,33 +176,51 @@ var DEFAULT_ORDERED_STREAM_CONFIG = {
166
176
  max_age: toNanos(1, "days"),
167
177
  duplicate_window: toNanos(2, "minutes")
168
178
  };
179
+ var DEFAULT_DLQ_STREAM_CONFIG = {
180
+ ...baseStreamConfig,
181
+ retention: import_jetstream.RetentionPolicy.Workqueue,
182
+ allow_rollup_hdrs: false,
183
+ max_consumers: 100,
184
+ max_msg_size: 10 * MB,
185
+ max_msgs_per_subject: 5e6,
186
+ max_msgs: 5e7,
187
+ max_bytes: 5 * GB,
188
+ max_age: toNanos(30, "days"),
189
+ duplicate_window: toNanos(2, "minutes")
190
+ };
169
191
  var DEFAULT_EVENT_CONSUMER_CONFIG = {
170
192
  ack_wait: toNanos(10, "seconds"),
171
193
  max_deliver: 3,
172
194
  max_ack_pending: 100,
173
- ack_policy: import_nats.AckPolicy.Explicit,
174
- deliver_policy: import_nats.DeliverPolicy.All,
175
- replay_policy: import_nats.ReplayPolicy.Instant
195
+ ack_policy: import_jetstream.AckPolicy.Explicit,
196
+ deliver_policy: import_jetstream.DeliverPolicy.All,
197
+ replay_policy: import_jetstream.ReplayPolicy.Instant
176
198
  };
177
199
  var DEFAULT_COMMAND_CONSUMER_CONFIG = {
178
200
  ack_wait: toNanos(5, "minutes"),
179
201
  max_deliver: 1,
180
202
  max_ack_pending: 100,
181
- ack_policy: import_nats.AckPolicy.Explicit,
182
- deliver_policy: import_nats.DeliverPolicy.All,
183
- replay_policy: import_nats.ReplayPolicy.Instant
203
+ ack_policy: import_jetstream.AckPolicy.Explicit,
204
+ deliver_policy: import_jetstream.DeliverPolicy.All,
205
+ replay_policy: import_jetstream.ReplayPolicy.Instant
184
206
  };
185
207
  var DEFAULT_BROADCAST_CONSUMER_CONFIG = {
186
208
  ack_wait: toNanos(10, "seconds"),
187
209
  max_deliver: 3,
188
210
  max_ack_pending: 100,
189
- ack_policy: import_nats.AckPolicy.Explicit,
190
- deliver_policy: import_nats.DeliverPolicy.All,
191
- replay_policy: import_nats.ReplayPolicy.Instant
211
+ ack_policy: import_jetstream.AckPolicy.Explicit,
212
+ deliver_policy: import_jetstream.DeliverPolicy.All,
213
+ replay_policy: import_jetstream.ReplayPolicy.Instant
192
214
  };
193
215
  var DEFAULT_RPC_TIMEOUT = 3e4;
194
216
  var DEFAULT_JETSTREAM_RPC_TIMEOUT = 18e4;
195
217
  var DEFAULT_SHUTDOWN_TIMEOUT = 1e4;
218
+ var DEFAULT_METADATA_BUCKET = "handler_registry";
219
+ var DEFAULT_METADATA_REPLICAS = 1;
220
+ var DEFAULT_METADATA_HISTORY = 1;
221
+ var DEFAULT_METADATA_TTL = 3e4;
222
+ var MIN_METADATA_TTL = 5e3;
223
+ var metadataKey = (serviceName, kind, pattern) => `${serviceName}.${kind}.${pattern}`;
196
224
  var JetstreamHeader = /* @__PURE__ */ ((JetstreamHeader2) => {
197
225
  JetstreamHeader2["CorrelationId"] = "x-correlation-id";
198
226
  JetstreamHeader2["ReplyTo"] = "x-reply-to";
@@ -201,6 +229,14 @@ var JetstreamHeader = /* @__PURE__ */ ((JetstreamHeader2) => {
201
229
  JetstreamHeader2["Error"] = "x-error";
202
230
  return JetstreamHeader2;
203
231
  })(JetstreamHeader || {});
232
+ var JetstreamDlqHeader = /* @__PURE__ */ ((JetstreamDlqHeader2) => {
233
+ JetstreamDlqHeader2["DeadLetterReason"] = "x-dead-letter-reason";
234
+ JetstreamDlqHeader2["OriginalSubject"] = "x-original-subject";
235
+ JetstreamDlqHeader2["OriginalStream"] = "x-original-stream";
236
+ JetstreamDlqHeader2["FailedAt"] = "x-failed-at";
237
+ JetstreamDlqHeader2["DeliveryCount"] = "x-delivery-count";
238
+ return JetstreamDlqHeader2;
239
+ })(JetstreamDlqHeader || {});
204
240
  var RESERVED_HEADERS = /* @__PURE__ */ new Set([
205
241
  "x-correlation-id" /* CorrelationId */,
206
242
  "x-reply-to" /* ReplyTo */,
@@ -213,6 +249,9 @@ var streamName = (serviceName, kind) => {
213
249
  if (kind === "broadcast" /* Broadcast */) return "broadcast-stream";
214
250
  return `${internalName(serviceName)}_${kind}-stream`;
215
251
  };
252
+ var dlqStreamName = (serviceName) => {
253
+ return `${internalName(serviceName)}_dlq-stream`;
254
+ };
216
255
  var consumerName = (serviceName, kind) => {
217
256
  if (kind === "broadcast" /* Broadcast */) return `${internalName(serviceName)}_broadcast-consumer`;
218
257
  return `${internalName(serviceName)}_${kind}-consumer`;
@@ -227,11 +266,13 @@ var isCoreRpcMode = (rpc) => !rpc || rpc.mode === "core";
227
266
 
228
267
  // src/client/jetstream.record.ts
229
268
  var JetstreamRecord = class {
230
- constructor(data, headers2, timeout, messageId) {
269
+ constructor(data, headers2, timeout, messageId, schedule, ttl) {
231
270
  this.data = data;
232
271
  this.headers = headers2;
233
272
  this.timeout = timeout;
234
273
  this.messageId = messageId;
274
+ this.schedule = schedule;
275
+ this.ttl = ttl;
235
276
  }
236
277
  };
237
278
  var JetstreamRecordBuilder = class {
@@ -239,6 +280,8 @@ var JetstreamRecordBuilder = class {
239
280
  headers = /* @__PURE__ */ new Map();
240
281
  timeout;
241
282
  messageId;
283
+ scheduleOptions;
284
+ ttlDuration;
242
285
  constructor(data) {
243
286
  this.data = data;
244
287
  }
@@ -306,17 +349,71 @@ var JetstreamRecordBuilder = class {
306
349
  this.timeout = ms;
307
350
  return this;
308
351
  }
352
+ /**
353
+ * Schedule one-shot delayed delivery.
354
+ *
355
+ * The message is held by NATS and delivered to the event consumer
356
+ * at the specified time. Requires NATS >= 2.12 and `allow_msg_schedules: true`
357
+ * on the event stream (via `events: { stream: { allow_msg_schedules: true } }`).
358
+ *
359
+ * Only meaningful for events (`client.emit()`). If used with RPC
360
+ * (`client.send()`), a warning is logged and the schedule is ignored.
361
+ *
362
+ * @param date - Delivery time. Must be in the future.
363
+ * @throws Error if the date is not in the future.
364
+ */
365
+ scheduleAt(date) {
366
+ const ts = date.getTime();
367
+ if (Number.isNaN(ts)) {
368
+ throw new Error("Schedule date is invalid");
369
+ }
370
+ if (ts <= Date.now()) {
371
+ throw new Error("Schedule date must be in the future");
372
+ }
373
+ this.scheduleOptions = { at: new Date(ts) };
374
+ return this;
375
+ }
376
+ /**
377
+ * Set per-message TTL (time-to-live).
378
+ *
379
+ * The message expires individually after the specified duration,
380
+ * independent of the stream's `max_age`. Requires NATS >= 2.11 and
381
+ * `allow_msg_ttl: true` on the stream.
382
+ *
383
+ * Only meaningful for events (`client.emit()`). If used with RPC
384
+ * (`client.send()`), a warning is logged and the TTL is ignored.
385
+ *
386
+ * @param nanos - TTL in nanoseconds. Use {@link toNanos} for human-readable values.
387
+ *
388
+ * @example
389
+ * ```typescript
390
+ * import { toNanos } from '@horizon-republic/nestjs-jetstream';
391
+ *
392
+ * new JetstreamRecordBuilder(payload).ttl(toNanos(30, 'minutes')).build();
393
+ * new JetstreamRecordBuilder(payload).ttl(toNanos(24, 'hours')).build();
394
+ * ```
395
+ */
396
+ ttl(nanos) {
397
+ if (!Number.isFinite(nanos) || nanos <= 0) {
398
+ throw new Error("TTL must be a positive finite value");
399
+ }
400
+ this.ttlDuration = nanosToGoDuration(nanos);
401
+ return this;
402
+ }
309
403
  /**
310
404
  * Build the immutable {@link JetstreamRecord}.
311
405
  *
312
406
  * @returns A frozen record ready to pass to `client.send()` or `client.emit()`.
313
407
  */
314
408
  build() {
409
+ const schedule = this.scheduleOptions ? { at: new Date(this.scheduleOptions.at.getTime()) } : void 0;
315
410
  return new JetstreamRecord(
316
411
  this.data,
317
412
  new Map(this.headers),
318
413
  this.timeout,
319
- this.messageId
414
+ this.messageId,
415
+ schedule,
416
+ this.ttlDuration
320
417
  );
321
418
  }
322
419
  /** Validate that a header key is not reserved. */
@@ -328,6 +425,17 @@ var JetstreamRecordBuilder = class {
328
425
  }
329
426
  }
330
427
  };
428
+ var NS_PER_MS = 1e6;
429
+ var NS_PER_S = 1e9;
430
+ var NS_PER_M = 60 * NS_PER_S;
431
+ var NS_PER_H = 60 * NS_PER_M;
432
+ var nanosToGoDuration = (nanos) => {
433
+ if (nanos >= NS_PER_H && nanos % NS_PER_H === 0) return `${nanos / NS_PER_H}h`;
434
+ if (nanos >= NS_PER_M && nanos % NS_PER_M === 0) return `${nanos / NS_PER_M}m`;
435
+ if (nanos >= NS_PER_S && nanos % NS_PER_S === 0) return `${nanos / NS_PER_S}s`;
436
+ if (nanos >= NS_PER_MS && nanos % NS_PER_MS === 0) return `${nanos / NS_PER_MS}ms`;
437
+ return `${nanos}ns`;
438
+ };
331
439
 
332
440
  // src/client/jetstream.client.ts
333
441
  var JetstreamClient = class extends import_microservices.ClientProxy {
@@ -368,7 +476,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
368
476
  this.setupInbox(nc);
369
477
  }
370
478
  this.statusSubscription ??= this.connection.status$.subscribe((status) => {
371
- if (status.type === import_nats2.Events.Disconnect) {
479
+ if (status.type === "disconnect") {
372
480
  this.handleDisconnect();
373
481
  }
374
482
  });
@@ -396,19 +504,40 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
396
504
  * Publish a fire-and-forget event to JetStream.
397
505
  *
398
506
  * Events are published to either the workqueue stream or broadcast stream
399
- * depending on the subject prefix.
507
+ * depending on the subject prefix. When a schedule is present the message
508
+ * is published to a `_sch` subject within the same stream, with the target
509
+ * set to the original event subject.
400
510
  */
401
511
  async dispatchEvent(packet) {
402
512
  await this.connect();
403
- const { data, hdrs, messageId } = this.extractRecordData(packet.data);
404
- const subject = this.buildEventSubject(packet.pattern);
405
- const msgHeaders = this.buildHeaders(hdrs, { subject });
406
- const ack = await this.connection.getJetStreamClient().publish(subject, this.codec.encode(data), {
407
- headers: msgHeaders,
408
- msgID: messageId ?? crypto.randomUUID()
409
- });
410
- if (ack.duplicate) {
411
- this.logger.warn(`Duplicate event publish detected: ${subject} (seq: ${ack.seq})`);
513
+ const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
514
+ const eventSubject = this.buildEventSubject(packet.pattern);
515
+ const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
516
+ if (schedule) {
517
+ const scheduleSubject = this.buildScheduleSubject(eventSubject);
518
+ const ack = await this.connection.getJetStreamClient().publish(scheduleSubject, this.codec.encode(data), {
519
+ headers: msgHeaders,
520
+ msgID: messageId ?? import_nuid.nuid.next(),
521
+ ttl,
522
+ schedule: {
523
+ specification: schedule.at,
524
+ target: eventSubject
525
+ }
526
+ });
527
+ if (ack.duplicate) {
528
+ this.logger.warn(
529
+ `Duplicate scheduled publish detected: ${scheduleSubject} (seq: ${ack.seq})`
530
+ );
531
+ }
532
+ } else {
533
+ const ack = await this.connection.getJetStreamClient().publish(eventSubject, this.codec.encode(data), {
534
+ headers: msgHeaders,
535
+ msgID: messageId ?? import_nuid.nuid.next(),
536
+ ttl
537
+ });
538
+ if (ack.duplicate) {
539
+ this.logger.warn(`Duplicate event publish detected: ${eventSubject} (seq: ${ack.seq})`);
540
+ }
412
541
  }
413
542
  return void 0;
414
543
  }
@@ -420,7 +549,17 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
420
549
  */
421
550
  publish(packet, callback) {
422
551
  const subject = buildSubject(this.targetName, "cmd" /* Command */, packet.pattern);
423
- const { data, hdrs, timeout, messageId } = this.extractRecordData(packet.data);
552
+ const { data, hdrs, timeout, messageId, schedule, ttl } = this.extractRecordData(packet.data);
553
+ if (schedule) {
554
+ this.logger.warn(
555
+ "scheduleAt() is ignored for RPC (client.send()). Use client.emit() for scheduled events."
556
+ );
557
+ }
558
+ if (ttl) {
559
+ this.logger.warn(
560
+ "ttl() is ignored for RPC (client.send()). Use client.emit() for events with TTL."
561
+ );
562
+ }
424
563
  const onUnhandled = (err) => {
425
564
  this.logger.error("Unhandled publish error:", err);
426
565
  callback({ err: new Error("Internal transport error"), response: null, isDisposed: true });
@@ -429,7 +568,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
429
568
  if (isCoreRpcMode(this.rootOptions.rpc)) {
430
569
  this.publishCoreRpc(subject, data, hdrs, timeout, callback).catch(onUnhandled);
431
570
  } else {
432
- jetStreamCorrelationId = crypto.randomUUID();
571
+ jetStreamCorrelationId = import_nuid.nuid.next();
433
572
  this.publishJetStreamRpc(subject, data, callback, {
434
573
  headers: hdrs,
435
574
  timeout,
@@ -504,7 +643,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
504
643
  });
505
644
  await this.connection.getJetStreamClient().publish(subject, this.codec.encode(data), {
506
645
  headers: hdrs,
507
- msgID: messageId ?? crypto.randomUUID()
646
+ msgID: messageId ?? import_nuid.nuid.next()
508
647
  });
509
648
  } catch (err) {
510
649
  const existingTimeout = this.pendingTimeouts.get(correlationId);
@@ -536,10 +675,11 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
536
675
  this.pendingTimeouts.clear();
537
676
  this.inboxSubscription?.unsubscribe();
538
677
  this.inboxSubscription = null;
678
+ this.inbox = null;
539
679
  }
540
680
  /** Setup shared inbox subscription for JetStream RPC responses. */
541
681
  setupInbox(nc) {
542
- this.inbox = (0, import_nats2.createInbox)(internalName(this.rootOptions.name));
682
+ this.inbox = (0, import_transport_node.createInbox)(internalName(this.rootOptions.name));
543
683
  this.inboxSubscription = nc.subscribe(this.inbox, {
544
684
  callback: (err, msg) => {
545
685
  if (err) {
@@ -601,7 +741,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
601
741
  }
602
742
  /** Build NATS headers merging custom headers with transport headers. */
603
743
  buildHeaders(customHeaders, transport) {
604
- const hdrs = (0, import_nats2.headers)();
744
+ const hdrs = (0, import_transport_node.headers)();
605
745
  hdrs.set("x-subject" /* Subject */, transport.subject);
606
746
  hdrs.set("x-caller-name" /* CallerName */, this.callerName);
607
747
  if (transport.correlationId) {
@@ -617,17 +757,53 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
617
757
  }
618
758
  return hdrs;
619
759
  }
620
- /** Extract data, headers, and timeout from raw packet data or JetstreamRecord. */
760
+ /** Extract data, headers, timeout, and schedule from raw packet data or JetstreamRecord. */
621
761
  extractRecordData(rawData) {
622
762
  if (rawData instanceof JetstreamRecord) {
623
763
  return {
624
764
  data: rawData.data,
625
765
  hdrs: rawData.headers.size > 0 ? new Map(rawData.headers) : null,
626
766
  timeout: rawData.timeout,
627
- messageId: rawData.messageId
767
+ messageId: rawData.messageId,
768
+ schedule: rawData.schedule,
769
+ ttl: rawData.ttl
628
770
  };
629
771
  }
630
- return { data: rawData, hdrs: null, timeout: void 0, messageId: void 0 };
772
+ return {
773
+ data: rawData,
774
+ hdrs: null,
775
+ timeout: void 0,
776
+ messageId: void 0,
777
+ schedule: void 0,
778
+ ttl: void 0
779
+ };
780
+ }
781
+ /**
782
+ * Build a schedule-holder subject for NATS message scheduling.
783
+ *
784
+ * The schedule-holder subject resides in the same stream as the target but
785
+ * uses a separate `_sch` namespace that is NOT matched by any consumer filter.
786
+ * NATS holds the message and publishes it to the target subject after the delay.
787
+ *
788
+ * Examples:
789
+ * - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder`
790
+ * - `broadcast.config.updated` → `broadcast._sch.config.updated`
791
+ */
792
+ buildScheduleSubject(eventSubject) {
793
+ if (eventSubject.startsWith("broadcast.")) {
794
+ return eventSubject.replace("broadcast.", "broadcast._sch.");
795
+ }
796
+ const targetPrefix = `${internalName(this.targetName)}.`;
797
+ if (!eventSubject.startsWith(targetPrefix)) {
798
+ throw new Error(`Unexpected event subject format: ${eventSubject}`);
799
+ }
800
+ const withoutPrefix = eventSubject.slice(targetPrefix.length);
801
+ const dotIndex = withoutPrefix.indexOf(".");
802
+ if (dotIndex === -1) {
803
+ throw new Error(`Event subject missing pattern segment: ${eventSubject}`);
804
+ }
805
+ const pattern = withoutPrefix.slice(dotIndex + 1);
806
+ return `${targetPrefix}_sch.${pattern}`;
631
807
  }
632
808
  getRpcTimeout() {
633
809
  if (!this.rootOptions.rpc) return DEFAULT_RPC_TIMEOUT;
@@ -637,20 +813,21 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
637
813
  };
638
814
 
639
815
  // src/codec/json.codec.ts
640
- var import_nats3 = require("nats");
816
+ var encoder = new TextEncoder();
817
+ var decoder = new TextDecoder();
641
818
  var JsonCodec = class {
642
- inner = (0, import_nats3.JSONCodec)();
643
819
  encode(data) {
644
- return this.inner.encode(data);
820
+ return encoder.encode(JSON.stringify(data));
645
821
  }
646
822
  decode(data) {
647
- return this.inner.decode(data);
823
+ return JSON.parse(decoder.decode(data));
648
824
  }
649
825
  };
650
826
 
651
827
  // src/connection/connection.provider.ts
652
828
  var import_common2 = require("@nestjs/common");
653
- var import_nats4 = require("nats");
829
+ var import_transport_node2 = require("@nats-io/transport-node");
830
+ var import_jetstream7 = require("@nats-io/jetstream");
654
831
  var import_rxjs = require("rxjs");
655
832
  var DEFAULT_OPTIONS = {
656
833
  maxReconnectAttempts: -1,
@@ -704,14 +881,7 @@ var ConnectionProvider = class {
704
881
  async getJetStreamManager() {
705
882
  if (this.jsmInstance) return this.jsmInstance;
706
883
  if (this.jsmPromise) return this.jsmPromise;
707
- this.jsmPromise = (async () => {
708
- const nc = await this.getConnection();
709
- this.jsmInstance = await nc.jetstreamManager();
710
- this.logger.log("JetStream manager initialized");
711
- return this.jsmInstance;
712
- })().finally(() => {
713
- this.jsmPromise = null;
714
- });
884
+ this.jsmPromise = this.initJetStreamManager();
715
885
  return this.jsmPromise;
716
886
  }
717
887
  /**
@@ -727,7 +897,7 @@ var ConnectionProvider = class {
727
897
  if (!this.connection || this.connection.isClosed()) {
728
898
  throw new Error("Not connected \u2014 call getConnection() before getJetStreamClient()");
729
899
  }
730
- this.jsClient ??= this.connection.jetstream();
900
+ this.jsClient ??= (0, import_jetstream7.jetstream)(this.connection);
731
901
  return this.jsClient;
732
902
  }
733
903
  /** Direct access to the raw NATS connection, or `null` if not yet connected. */
@@ -763,11 +933,21 @@ var ConnectionProvider = class {
763
933
  this.jsmPromise = null;
764
934
  }
765
935
  }
936
+ async initJetStreamManager() {
937
+ try {
938
+ const nc = await this.getConnection();
939
+ this.jsmInstance = await (0, import_jetstream7.jetstreamManager)(nc);
940
+ this.logger.log("JetStream manager initialized");
941
+ return this.jsmInstance;
942
+ } finally {
943
+ this.jsmPromise = null;
944
+ }
945
+ }
766
946
  /** Internal: establish the physical connection with reconnect monitoring. */
767
947
  async establish() {
768
948
  const name = internalName(this.options.name);
769
949
  try {
770
- const nc = await (0, import_nats4.connect)({
950
+ const nc = await (0, import_transport_node2.connect)({
771
951
  ...DEFAULT_OPTIONS,
772
952
  ...this.options.connectionOptions,
773
953
  servers: this.options.servers,
@@ -779,7 +959,7 @@ var ConnectionProvider = class {
779
959
  this.monitorStatus(nc);
780
960
  return nc;
781
961
  } catch (err) {
782
- if (err instanceof import_nats4.NatsError && err.code === "CONNECTION_REFUSED") {
962
+ if (err instanceof Error && err.message.includes("REFUSED")) {
783
963
  throw new Error(`NATS connection refused: ${this.options.servers.join(", ")}`);
784
964
  }
785
965
  throw err;
@@ -787,27 +967,33 @@ var ConnectionProvider = class {
787
967
  }
788
968
  /** Subscribe to connection status events and emit hooks. */
789
969
  monitorStatus(nc) {
790
- (async () => {
970
+ void (async () => {
791
971
  for await (const status of nc.status()) {
792
972
  switch (status.type) {
793
- case import_nats4.Events.Disconnect:
973
+ case "disconnect":
794
974
  this.eventBus.emit("disconnect" /* Disconnect */);
795
975
  break;
796
- case import_nats4.Events.Reconnect:
976
+ case "reconnect":
797
977
  this.jsClient = null;
798
978
  this.jsmInstance = null;
799
979
  this.jsmPromise = null;
800
980
  this.eventBus.emit("reconnect" /* Reconnect */, nc.getServer());
801
981
  break;
802
- case import_nats4.Events.Error:
803
- this.eventBus.emit("error" /* Error */, new Error(String(status.data)), "connection");
982
+ case "error":
983
+ this.eventBus.emit(
984
+ "error" /* Error */,
985
+ status.error,
986
+ "connection"
987
+ );
804
988
  break;
805
- case import_nats4.Events.Update:
806
- case import_nats4.Events.LDM:
807
- case import_nats4.DebugEvents.Reconnecting:
808
- case import_nats4.DebugEvents.PingTimer:
809
- case import_nats4.DebugEvents.StaleConnection:
810
- case import_nats4.DebugEvents.ClientInitiatedReconnect:
989
+ case "update":
990
+ case "ldm":
991
+ case "reconnecting":
992
+ case "ping":
993
+ case "staleConnection":
994
+ case "forceReconnect":
995
+ case "slowConsumer":
996
+ case "close":
811
997
  break;
812
998
  }
813
999
  }
@@ -904,9 +1090,14 @@ var JetstreamHealthIndicator = class {
904
1090
  * Returns `{ [key]: { status: 'up', ... } }` on success.
905
1091
  * Throws an error with `{ [key]: { status: 'down', ... } }` on failure.
906
1092
  *
1093
+ * The thrown error sets `isHealthCheckError: true` and `causes` — the
1094
+ * duck-type contract that Terminus `HealthCheckExecutor` uses to distinguish
1095
+ * health failures from unexpected exceptions. Works with both Terminus v10
1096
+ * (`instanceof HealthCheckError`) and v11+ (`error?.isHealthCheckError`).
1097
+ *
907
1098
  * @param key - Health indicator key (default: `'jetstream'`).
908
1099
  * @returns Object with status, server, and latency under the given key.
909
- * @throws Error with `{ [key]: { status: 'down' } }` when disconnected.
1100
+ * @throws Error with `isHealthCheckError`, `causes`, and `{ [key]: { status: 'down' } }`.
910
1101
  */
911
1102
  async isHealthy(key = "jetstream") {
912
1103
  const status = await this.check();
@@ -916,8 +1107,10 @@ var JetstreamHealthIndicator = class {
916
1107
  latency: status.latency
917
1108
  };
918
1109
  if (!status.connected) {
1110
+ const causes = { [key]: details };
919
1111
  throw Object.assign(new Error("Jetstream health check failed"), {
920
- [key]: details
1112
+ causes,
1113
+ isHealthCheckError: true
921
1114
  });
922
1115
  }
923
1116
  return { [key]: details };
@@ -930,7 +1123,7 @@ JetstreamHealthIndicator = __decorateClass([
930
1123
  // src/server/strategy.ts
931
1124
  var import_microservices2 = require("@nestjs/microservices");
932
1125
  var JetstreamStrategy = class extends import_microservices2.Server {
933
- constructor(options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap = /* @__PURE__ */ new Map()) {
1126
+ constructor(options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap = /* @__PURE__ */ new Map(), metadataProvider) {
934
1127
  super();
935
1128
  this.options = options;
936
1129
  this.connection = connection;
@@ -942,6 +1135,7 @@ var JetstreamStrategy = class extends import_microservices2.Server {
942
1135
  this.rpcRouter = rpcRouter;
943
1136
  this.coreRpcServer = coreRpcServer;
944
1137
  this.ackWaitMap = ackWaitMap;
1138
+ this.metadataProvider = metadataProvider;
945
1139
  }
946
1140
  transportId = /* @__PURE__ */ Symbol("jetstream-transport");
947
1141
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -986,10 +1180,14 @@ var JetstreamStrategy = class extends import_microservices2.Server {
986
1180
  if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
987
1181
  await this.coreRpcServer.start();
988
1182
  }
1183
+ if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
1184
+ await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
1185
+ }
989
1186
  callback();
990
1187
  }
991
- /** Stop all consumers, routers, and subscriptions. Called during shutdown. */
1188
+ /** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
992
1189
  close() {
1190
+ this.metadataProvider?.destroy();
993
1191
  this.eventRouter.destroy();
994
1192
  this.rpcRouter.destroy();
995
1193
  this.coreRpcServer.stop();
@@ -1069,7 +1267,7 @@ var JetstreamStrategy = class extends import_microservices2.Server {
1069
1267
 
1070
1268
  // src/server/core-rpc.server.ts
1071
1269
  var import_common4 = require("@nestjs/common");
1072
- var import_nats5 = require("nats");
1270
+ var import_transport_node3 = require("@nats-io/transport-node");
1073
1271
 
1074
1272
  // src/context/rpc.context.ts
1075
1273
  var import_microservices3 = require("@nestjs/microservices");
@@ -1355,7 +1553,7 @@ var CoreRpcServer = class {
1355
1553
  /** Send an error response back to the caller with x-error header. */
1356
1554
  respondWithError(msg, error) {
1357
1555
  try {
1358
- const hdrs = (0, import_nats5.headers)();
1556
+ const hdrs = (0, import_transport_node3.headers)();
1359
1557
  hdrs.set("x-error" /* Error */, "true");
1360
1558
  msg.respond(this.codec.encode(serializeError(error)), { headers: hdrs });
1361
1559
  } catch {
@@ -1365,24 +1563,172 @@ var CoreRpcServer = class {
1365
1563
  };
1366
1564
 
1367
1565
  // src/server/infrastructure/stream.provider.ts
1566
+ var import_common6 = require("@nestjs/common");
1567
+ var import_jetstream14 = require("@nats-io/jetstream");
1568
+
1569
+ // src/server/infrastructure/stream-config-diff.ts
1570
+ var TRANSPORT_CONTROLLED_PROPERTIES = /* @__PURE__ */ new Set([
1571
+ "retention"
1572
+ ]);
1573
+ var IMMUTABLE_PROPERTIES = /* @__PURE__ */ new Set([
1574
+ "storage"
1575
+ ]);
1576
+ var ENABLE_ONLY_PROPERTIES = /* @__PURE__ */ new Set([
1577
+ "allow_msg_schedules",
1578
+ "allow_msg_ttl",
1579
+ "deny_delete",
1580
+ "deny_purge"
1581
+ ]);
1582
+ var compareStreamConfig = (current, desired) => {
1583
+ const changes = [];
1584
+ for (const key of Object.keys(desired)) {
1585
+ const currentVal = current[key];
1586
+ const desiredVal = desired[key];
1587
+ if (isEqual(currentVal, desiredVal)) continue;
1588
+ changes.push({
1589
+ property: key,
1590
+ current: currentVal,
1591
+ desired: desiredVal,
1592
+ mutability: classifyMutability(key, currentVal, desiredVal)
1593
+ });
1594
+ }
1595
+ const hasImmutableChanges = changes.some((c) => c.mutability === "immutable");
1596
+ const hasMutableChanges = changes.some(
1597
+ (c) => c.mutability === "mutable" || c.mutability === "enable-only"
1598
+ );
1599
+ const hasTransportControlledConflicts = changes.some(
1600
+ (c) => c.mutability === "transport-controlled"
1601
+ );
1602
+ return {
1603
+ hasChanges: changes.length > 0,
1604
+ hasMutableChanges,
1605
+ hasImmutableChanges,
1606
+ hasTransportControlledConflicts,
1607
+ changes
1608
+ };
1609
+ };
1610
+ var classifyMutability = (key, current, desired) => {
1611
+ if (TRANSPORT_CONTROLLED_PROPERTIES.has(key)) return "transport-controlled";
1612
+ if (IMMUTABLE_PROPERTIES.has(key)) return "immutable";
1613
+ if (ENABLE_ONLY_PROPERTIES.has(key)) {
1614
+ return current === true && desired === false ? "immutable" : "enable-only";
1615
+ }
1616
+ return "mutable";
1617
+ };
1618
+ var isEqual = (a, b) => {
1619
+ if (a === b) return true;
1620
+ if (a == null && b == null) return true;
1621
+ return JSON.stringify(a) === JSON.stringify(b);
1622
+ };
1623
+
1624
+ // src/server/infrastructure/stream-migration.ts
1368
1625
  var import_common5 = require("@nestjs/common");
1369
- var import_nats6 = require("nats");
1370
- var STREAM_NOT_FOUND = 10059;
1626
+ var import_jetstream13 = require("@nats-io/jetstream");
1627
+ var MIGRATION_BACKUP_SUFFIX = "__migration_backup";
1628
+ var DEFAULT_SOURCING_TIMEOUT_MS = 3e4;
1629
+ var SOURCING_POLL_INTERVAL_MS = 100;
1630
+ var StreamMigration = class {
1631
+ constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS) {
1632
+ this.sourcingTimeoutMs = sourcingTimeoutMs;
1633
+ }
1634
+ logger = new import_common5.Logger("Jetstream:Stream");
1635
+ async migrate(jsm, streamName2, newConfig) {
1636
+ const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
1637
+ const startTime = Date.now();
1638
+ const currentInfo = await jsm.streams.info(streamName2);
1639
+ await this.cleanupOrphanedBackup(jsm, backupName);
1640
+ const messageCount = currentInfo.state.messages;
1641
+ this.logger.log(`Stream ${streamName2}: destructive migration started`);
1642
+ let originalDeleted = false;
1643
+ try {
1644
+ if (messageCount > 0) {
1645
+ this.logger.log(` Phase 1/4: Backing up ${messageCount} messages \u2192 ${backupName}`);
1646
+ await jsm.streams.add({
1647
+ ...currentInfo.config,
1648
+ name: backupName,
1649
+ subjects: [],
1650
+ sources: [{ name: streamName2 }]
1651
+ });
1652
+ await this.waitForSourcing(jsm, backupName, messageCount);
1653
+ }
1654
+ this.logger.log(` Phase 2/4: Deleting old stream`);
1655
+ await jsm.streams.delete(streamName2);
1656
+ originalDeleted = true;
1657
+ this.logger.log(` Phase 3/4: Creating stream with new config`);
1658
+ await jsm.streams.add(newConfig);
1659
+ if (messageCount > 0) {
1660
+ const backupInfo = await jsm.streams.info(backupName);
1661
+ await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
1662
+ this.logger.log(` Phase 4/4: Restoring ${messageCount} messages from backup`);
1663
+ await jsm.streams.update(streamName2, {
1664
+ ...newConfig,
1665
+ sources: [{ name: backupName }]
1666
+ });
1667
+ await this.waitForSourcing(jsm, streamName2, messageCount);
1668
+ await jsm.streams.update(streamName2, { ...newConfig, sources: [] });
1669
+ await jsm.streams.delete(backupName);
1670
+ }
1671
+ } catch (err) {
1672
+ if (originalDeleted && messageCount > 0) {
1673
+ this.logger.error(
1674
+ `Migration failed after deleting original stream. Backup ${backupName} preserved for manual recovery.`
1675
+ );
1676
+ } else {
1677
+ await this.cleanupOrphanedBackup(jsm, backupName);
1678
+ }
1679
+ throw err;
1680
+ }
1681
+ const durationMs = Date.now() - startTime;
1682
+ this.logger.log(
1683
+ `Stream ${streamName2}: migration complete (${messageCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
1684
+ );
1685
+ }
1686
+ async waitForSourcing(jsm, streamName2, expectedCount) {
1687
+ const deadline = Date.now() + this.sourcingTimeoutMs;
1688
+ while (Date.now() < deadline) {
1689
+ const info = await jsm.streams.info(streamName2);
1690
+ if (info.state.messages >= expectedCount) return;
1691
+ await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
1692
+ }
1693
+ throw new Error(
1694
+ `Stream sourcing timeout: ${streamName2} has not reached ${expectedCount} messages within ${this.sourcingTimeoutMs / 1e3}s`
1695
+ );
1696
+ }
1697
+ async cleanupOrphanedBackup(jsm, backupName) {
1698
+ try {
1699
+ await jsm.streams.info(backupName);
1700
+ this.logger.warn(`Found orphaned migration backup stream: ${backupName}, cleaning up`);
1701
+ await jsm.streams.delete(backupName);
1702
+ } catch (err) {
1703
+ if (err instanceof import_jetstream13.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
1704
+ return;
1705
+ }
1706
+ throw err;
1707
+ }
1708
+ }
1709
+ };
1710
+
1711
+ // src/server/infrastructure/stream.provider.ts
1371
1712
  var StreamProvider = class {
1372
1713
  constructor(options, connection) {
1373
1714
  this.options = options;
1374
1715
  this.connection = connection;
1375
1716
  }
1376
- logger = new import_common5.Logger("Jetstream:Stream");
1717
+ logger = new import_common6.Logger("Jetstream:Stream");
1718
+ migration = new StreamMigration();
1377
1719
  /**
1378
1720
  * Ensure all required streams exist with correct configuration.
1379
1721
  *
1380
1722
  * @param kinds Which stream kinds to create. Determined by the module based
1381
1723
  * on RPC mode and registered handler patterns.
1724
+ * If the dlq option is enabled, also ensures the DLQ stream exists.
1382
1725
  */
1383
1726
  async ensureStreams(kinds) {
1384
1727
  const jsm = await this.connection.getJetStreamManager();
1385
1728
  await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
1729
+ if (this.options.dlq) {
1730
+ await this.ensureDlqStream(jsm);
1731
+ }
1386
1732
  }
1387
1733
  /** Get the stream name for a given kind. */
1388
1734
  getStreamName(kind) {
@@ -1392,12 +1738,22 @@ var StreamProvider = class {
1392
1738
  getSubjects(kind) {
1393
1739
  const name = internalName(this.options.name);
1394
1740
  switch (kind) {
1395
- case "ev" /* Event */:
1396
- return [`${name}.${"ev" /* Event */}.>`];
1741
+ case "ev" /* Event */: {
1742
+ const subjects = [`${name}.${"ev" /* Event */}.>`];
1743
+ if (this.isSchedulingEnabled(kind)) {
1744
+ subjects.push(`${name}._sch.>`);
1745
+ }
1746
+ return subjects;
1747
+ }
1397
1748
  case "cmd" /* Command */:
1398
1749
  return [`${name}.${"cmd" /* Command */}.>`];
1399
- case "broadcast" /* Broadcast */:
1400
- return ["broadcast.>"];
1750
+ case "broadcast" /* Broadcast */: {
1751
+ const subjects = ["broadcast.>"];
1752
+ if (this.isSchedulingEnabled(kind)) {
1753
+ subjects.push("broadcast._sch.>");
1754
+ }
1755
+ return subjects;
1756
+ }
1401
1757
  case "ordered" /* Ordered */:
1402
1758
  return [`${name}.${"ordered" /* Ordered */}.>`];
1403
1759
  }
@@ -1407,17 +1763,85 @@ var StreamProvider = class {
1407
1763
  const config = this.buildConfig(kind);
1408
1764
  this.logger.log(`Ensuring stream: ${config.name}`);
1409
1765
  try {
1410
- await jsm.streams.info(config.name);
1411
- this.logger.debug(`Stream exists, updating: ${config.name}`);
1412
- return await jsm.streams.update(config.name, config);
1766
+ const currentInfo = await jsm.streams.info(config.name);
1767
+ return await this.handleExistingStream(jsm, currentInfo, config);
1413
1768
  } catch (err) {
1414
- if (err instanceof import_nats6.NatsError && err.api_error?.err_code === STREAM_NOT_FOUND) {
1769
+ if (err instanceof import_jetstream14.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
1415
1770
  this.logger.log(`Creating stream: ${config.name}`);
1416
1771
  return await jsm.streams.add(config);
1417
1772
  }
1418
1773
  throw err;
1419
1774
  }
1420
1775
  }
1776
+ /** Ensure a dead-letter queue stream exists, creating or updating as needed. */
1777
+ async ensureDlqStream(jsm) {
1778
+ const config = this.buildDlqConfig();
1779
+ this.logger.log(`Ensuring DLQ stream: ${config.name}`);
1780
+ try {
1781
+ const currentInfo = await jsm.streams.info(config.name);
1782
+ return await this.handleExistingStream(jsm, currentInfo, config);
1783
+ } catch (err) {
1784
+ if (err instanceof import_jetstream14.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
1785
+ this.logger.log(`Creating DLQ stream: ${config.name}`);
1786
+ return await jsm.streams.add(config);
1787
+ }
1788
+ throw err;
1789
+ }
1790
+ }
1791
+ async handleExistingStream(jsm, currentInfo, config) {
1792
+ const diff = compareStreamConfig(currentInfo.config, config);
1793
+ if (!diff.hasChanges) {
1794
+ this.logger.debug(`Stream ${config.name}: no config changes`);
1795
+ return currentInfo;
1796
+ }
1797
+ this.logChanges(config.name, diff, !!this.options.allowDestructiveMigration);
1798
+ if (diff.hasTransportControlledConflicts) {
1799
+ const conflicts = diff.changes.filter((c) => c.mutability === "transport-controlled").map((c) => `${c.property}: ${JSON.stringify(c.current)} \u2192 ${JSON.stringify(c.desired)}`).join(", ");
1800
+ throw new Error(
1801
+ `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.`
1802
+ );
1803
+ }
1804
+ if (!diff.hasImmutableChanges) {
1805
+ this.logger.debug(`Stream exists, updating: ${config.name}`);
1806
+ return await jsm.streams.update(config.name, config);
1807
+ }
1808
+ if (!this.options.allowDestructiveMigration) {
1809
+ this.logger.warn(
1810
+ `Stream ${config.name} has immutable config conflicts. Enable allowDestructiveMigration to recreate the stream.`
1811
+ );
1812
+ if (diff.hasMutableChanges) {
1813
+ const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
1814
+ return await jsm.streams.update(config.name, mutableConfig);
1815
+ }
1816
+ return currentInfo;
1817
+ }
1818
+ await this.migration.migrate(jsm, config.name, config);
1819
+ return await jsm.streams.info(config.name);
1820
+ }
1821
+ buildMutableOnlyConfig(config, currentConfig, diff) {
1822
+ const nonMutableKeys = new Set(
1823
+ diff.changes.filter((c) => c.mutability === "immutable" || c.mutability === "transport-controlled").map((c) => c.property)
1824
+ );
1825
+ const filtered = { ...config };
1826
+ for (const key of nonMutableKeys) {
1827
+ filtered[key] = currentConfig[key];
1828
+ }
1829
+ return filtered;
1830
+ }
1831
+ logChanges(streamName2, diff, migrationEnabled) {
1832
+ for (const c of diff.changes) {
1833
+ const detail = `${c.property}: ${JSON.stringify(c.current)} \u2192 ${JSON.stringify(c.desired)}`;
1834
+ if (c.mutability === "transport-controlled") {
1835
+ this.logger.error(
1836
+ `Stream ${streamName2}: ${detail} \u2014 transport-controlled, cannot be changed`
1837
+ );
1838
+ } else if (c.mutability === "immutable" && !migrationEnabled) {
1839
+ this.logger.warn(`Stream ${streamName2}: ${detail} \u2014 requires allowDestructiveMigration`);
1840
+ } else {
1841
+ this.logger.log(`Stream ${streamName2}: ${detail}`);
1842
+ }
1843
+ }
1844
+ }
1421
1845
  /** Build the full stream config by merging defaults with user overrides. */
1422
1846
  buildConfig(kind) {
1423
1847
  const name = this.getStreamName(kind);
@@ -1433,6 +1857,26 @@ var StreamProvider = class {
1433
1857
  description
1434
1858
  };
1435
1859
  }
1860
+ /**
1861
+ * Build the stream configuration for the Dead-Letter Queue (DLQ).
1862
+ *
1863
+ * Merges the library default DLQ config with user-provided overrides.
1864
+ * Ensures transport-controlled settings like retention are safely decoupled.
1865
+ */
1866
+ buildDlqConfig() {
1867
+ const name = dlqStreamName(this.options.name);
1868
+ const subjects = [name];
1869
+ const description = `JetStream DLQ stream for ${this.options.name}`;
1870
+ const overrides = this.options.dlq?.stream ?? {};
1871
+ const safeOverrides = this.stripTransportControlled(overrides);
1872
+ return {
1873
+ ...DEFAULT_DLQ_STREAM_CONFIG,
1874
+ ...safeOverrides,
1875
+ name,
1876
+ subjects,
1877
+ description
1878
+ };
1879
+ }
1436
1880
  /** Get default config for a stream kind. */
1437
1881
  getDefaults(kind) {
1438
1882
  switch (kind) {
@@ -1446,25 +1890,49 @@ var StreamProvider = class {
1446
1890
  return DEFAULT_ORDERED_STREAM_CONFIG;
1447
1891
  }
1448
1892
  }
1449
- /** Get user-provided overrides for a stream kind. */
1893
+ /** Check if scheduling is enabled for a stream kind via `allow_msg_schedules` override. */
1894
+ isSchedulingEnabled(kind) {
1895
+ const overrides = this.getOverrides(kind);
1896
+ return overrides.allow_msg_schedules === true;
1897
+ }
1898
+ /** Get user-provided overrides for a stream kind, stripping transport-controlled properties. */
1450
1899
  getOverrides(kind) {
1900
+ let overrides;
1451
1901
  switch (kind) {
1452
1902
  case "ev" /* Event */:
1453
- return this.options.events?.stream ?? {};
1903
+ overrides = this.options.events?.stream ?? {};
1904
+ break;
1454
1905
  case "cmd" /* Command */:
1455
- return this.options.rpc?.mode === "jetstream" ? this.options.rpc.stream ?? {} : {};
1906
+ overrides = this.options.rpc?.mode === "jetstream" ? this.options.rpc.stream ?? {} : {};
1907
+ break;
1456
1908
  case "broadcast" /* Broadcast */:
1457
- return this.options.broadcast?.stream ?? {};
1909
+ overrides = this.options.broadcast?.stream ?? {};
1910
+ break;
1458
1911
  case "ordered" /* Ordered */:
1459
- return this.options.ordered?.stream ?? {};
1912
+ overrides = this.options.ordered?.stream ?? {};
1913
+ break;
1460
1914
  }
1915
+ return this.stripTransportControlled(overrides);
1916
+ }
1917
+ /**
1918
+ * Remove transport-controlled properties from user overrides.
1919
+ * `retention` is managed by the transport (Workqueue/Limits per stream kind)
1920
+ * and silently stripped to protect users from misconfiguration.
1921
+ */
1922
+ stripTransportControlled(overrides) {
1923
+ if (!("retention" in overrides)) return overrides;
1924
+ this.logger.debug(
1925
+ "Stripping user-provided retention override \u2014 retention is managed by the transport"
1926
+ );
1927
+ const cleaned = { ...overrides };
1928
+ delete cleaned.retention;
1929
+ return cleaned;
1461
1930
  }
1462
1931
  };
1463
1932
 
1464
1933
  // src/server/infrastructure/consumer.provider.ts
1465
- var import_common6 = require("@nestjs/common");
1466
- var import_nats7 = require("nats");
1467
- var CONSUMER_NOT_FOUND = 10014;
1934
+ var import_common7 = require("@nestjs/common");
1935
+ var import_jetstream16 = require("@nats-io/jetstream");
1468
1936
  var ConsumerProvider = class {
1469
1937
  constructor(options, connection, streamProvider, patternRegistry) {
1470
1938
  this.options = options;
@@ -1472,7 +1940,7 @@ var ConsumerProvider = class {
1472
1940
  this.streamProvider = streamProvider;
1473
1941
  this.patternRegistry = patternRegistry;
1474
1942
  }
1475
- logger = new import_common6.Logger("Jetstream:Consumer");
1943
+ logger = new import_common7.Logger("Jetstream:Consumer");
1476
1944
  /**
1477
1945
  * Ensure consumers exist for the specified kinds.
1478
1946
  *
@@ -1493,7 +1961,11 @@ var ConsumerProvider = class {
1493
1961
  getConsumerName(kind) {
1494
1962
  return consumerName(this.options.name, kind);
1495
1963
  }
1496
- /** Ensure a single consumer exists, creating if needed. */
1964
+ /**
1965
+ * Ensure a single consumer exists with the desired config.
1966
+ * Used at **startup** — creates or updates the consumer to match
1967
+ * the current pod's configuration.
1968
+ */
1497
1969
  async ensureConsumer(jsm, kind) {
1498
1970
  const stream = this.streamProvider.getStreamName(kind);
1499
1971
  const config = this.buildConfig(kind);
@@ -1504,13 +1976,74 @@ var ConsumerProvider = class {
1504
1976
  this.logger.debug(`Consumer exists, updating: ${name}`);
1505
1977
  return await jsm.consumers.update(stream, name, config);
1506
1978
  } catch (err) {
1507
- if (err instanceof import_nats7.NatsError && err.api_error?.err_code === CONSUMER_NOT_FOUND) {
1508
- this.logger.log(`Creating consumer: ${name}`);
1509
- return await jsm.consumers.add(stream, config);
1979
+ if (!(err instanceof import_jetstream16.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
1980
+ throw err;
1981
+ }
1982
+ return await this.createConsumer(jsm, stream, name, config);
1983
+ }
1984
+ }
1985
+ /**
1986
+ * Recover a consumer that disappeared during runtime.
1987
+ * Used by **self-healing** — creates if missing, but NEVER updates config.
1988
+ *
1989
+ * If a migration backup stream exists, another pod is mid-migration — we
1990
+ * throw so the self-healing retry loop waits with backoff until migration
1991
+ * completes and the backup is cleaned up.
1992
+ *
1993
+ * This prevents old pods from:
1994
+ * - Overwriting a newer pod's consumer config during rolling updates
1995
+ * - Creating consumers during migration (which would consume and delete
1996
+ * workqueue messages while they're being restored)
1997
+ */
1998
+ async recoverConsumer(jsm, kind) {
1999
+ const stream = this.streamProvider.getStreamName(kind);
2000
+ const config = this.buildConfig(kind);
2001
+ const name = config.durable_name;
2002
+ this.logger.log(`Recovering consumer: ${name} on stream: ${stream}`);
2003
+ await this.assertNoMigrationInProgress(jsm, stream);
2004
+ try {
2005
+ return await jsm.consumers.info(stream, name);
2006
+ } catch (err) {
2007
+ if (!(err instanceof import_jetstream16.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
2008
+ throw err;
2009
+ }
2010
+ return await this.createConsumer(jsm, stream, name, config);
2011
+ }
2012
+ }
2013
+ /**
2014
+ * Throw if a migration backup stream exists for this stream.
2015
+ * The self-healing retry loop catches the error and retries with backoff,
2016
+ * naturally waiting until the migrating pod finishes and cleans up the backup.
2017
+ */
2018
+ async assertNoMigrationInProgress(jsm, stream) {
2019
+ const backupName = `${stream}${MIGRATION_BACKUP_SUFFIX}`;
2020
+ try {
2021
+ await jsm.streams.info(backupName);
2022
+ throw new Error(
2023
+ `Stream ${stream} is being migrated (backup ${backupName} exists). Waiting for migration to complete before recovering consumer.`
2024
+ );
2025
+ } catch (err) {
2026
+ if (err instanceof import_jetstream16.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
2027
+ return;
1510
2028
  }
1511
2029
  throw err;
1512
2030
  }
1513
2031
  }
2032
+ /**
2033
+ * Create a consumer, handling the race where another pod creates it first.
2034
+ */
2035
+ async createConsumer(jsm, stream, name, config) {
2036
+ this.logger.log(`Creating consumer: ${name}`);
2037
+ try {
2038
+ return await jsm.consumers.add(stream, config);
2039
+ } catch (addErr) {
2040
+ if (addErr instanceof import_jetstream16.JetStreamApiError && addErr.apiError().err_code === 10148 /* ConsumerAlreadyExists */) {
2041
+ this.logger.debug(`Consumer ${name} created by another pod, using existing`);
2042
+ return await jsm.consumers.info(stream, name);
2043
+ }
2044
+ throw addErr;
2045
+ }
2046
+ }
1514
2047
  /** Build consumer config by merging defaults with user overrides. */
1515
2048
  // eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
1516
2049
  buildConfig(kind) {
@@ -1563,6 +2096,11 @@ var ConsumerProvider = class {
1563
2096
  return DEFAULT_BROADCAST_CONSUMER_CONFIG;
1564
2097
  case "ordered" /* Ordered */:
1565
2098
  throw new Error("Ordered consumers are ephemeral and should not use durable config");
2099
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
2100
+ default: {
2101
+ const _exhaustive = kind;
2102
+ throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
2103
+ }
1566
2104
  }
1567
2105
  }
1568
2106
  /** Get user-provided overrides for a consumer kind. */
@@ -1576,21 +2114,27 @@ var ConsumerProvider = class {
1576
2114
  return this.options.broadcast?.consumer ?? {};
1577
2115
  case "ordered" /* Ordered */:
1578
2116
  throw new Error("Ordered consumers are ephemeral and should not use durable config");
2117
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
2118
+ default: {
2119
+ const _exhaustive = kind;
2120
+ throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
2121
+ }
1579
2122
  }
1580
2123
  }
1581
2124
  };
1582
2125
 
1583
2126
  // src/server/infrastructure/message.provider.ts
1584
- var import_common7 = require("@nestjs/common");
1585
- var import_nats8 = require("nats");
2127
+ var import_common8 = require("@nestjs/common");
2128
+ var import_jetstream18 = require("@nats-io/jetstream");
1586
2129
  var import_rxjs3 = require("rxjs");
1587
2130
  var MessageProvider = class {
1588
- constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map()) {
2131
+ constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map(), consumerRecoveryFn) {
1589
2132
  this.connection = connection;
1590
2133
  this.eventBus = eventBus;
1591
2134
  this.consumeOptionsMap = consumeOptionsMap;
2135
+ this.consumerRecoveryFn = consumerRecoveryFn;
1592
2136
  }
1593
- logger = new import_common7.Logger("Jetstream:Message");
2137
+ logger = new import_common8.Logger("Jetstream:Message");
1594
2138
  activeIterators = /* @__PURE__ */ new Set();
1595
2139
  orderedReadyResolve = null;
1596
2140
  orderedReadyReject = null;
@@ -1641,8 +2185,8 @@ var MessageProvider = class {
1641
2185
  * @param orderedConfig - Optional overrides for ordered consumer options.
1642
2186
  */
1643
2187
  async startOrdered(streamName2, filterSubjects, orderedConfig) {
1644
- const consumerOpts = { filterSubjects };
1645
- if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !== import_nats8.DeliverPolicy.All) {
2188
+ const consumerOpts = { filter_subjects: filterSubjects };
2189
+ if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !== import_jetstream18.DeliverPolicy.All) {
1646
2190
  consumerOpts.deliver_policy = orderedConfig.deliverPolicy;
1647
2191
  }
1648
2192
  if (orderedConfig?.optStartSeq !== void 0) {
@@ -1693,12 +2237,26 @@ var MessageProvider = class {
1693
2237
  /** Single iteration: get consumer -> pull messages -> emit to subject. */
1694
2238
  async consumeOnce(kind, info, target$) {
1695
2239
  const js = this.connection.getJetStreamClient();
1696
- const consumer = await js.consumers.get(info.stream_name, info.name);
2240
+ let consumer;
2241
+ let consumerName2 = info.name;
2242
+ try {
2243
+ consumer = await js.consumers.get(info.stream_name, info.name);
2244
+ } catch (err) {
2245
+ if (this.isConsumerNotFound(err) && this.consumerRecoveryFn) {
2246
+ this.logger.warn(`Consumer ${info.name} not found, recreating...`);
2247
+ const recovered = await this.consumerRecoveryFn(kind);
2248
+ consumerName2 = recovered.name;
2249
+ this.logger.log(`Consumer ${consumerName2} recreated, resuming consumption`);
2250
+ consumer = await js.consumers.get(recovered.stream_name, consumerName2);
2251
+ } else {
2252
+ throw err;
2253
+ }
2254
+ }
1697
2255
  const defaults = { idle_heartbeat: 5e3 };
1698
2256
  const userOptions = this.consumeOptionsMap.get(kind) ?? {};
1699
2257
  const messages = await consumer.consume({ ...defaults, ...userOptions });
1700
2258
  this.activeIterators.add(messages);
1701
- this.monitorConsumerHealth(messages, info.name);
2259
+ this.monitorConsumerHealth(messages, consumerName2);
1702
2260
  try {
1703
2261
  for await (const msg of messages) {
1704
2262
  target$.next(msg);
@@ -1707,6 +2265,17 @@ var MessageProvider = class {
1707
2265
  this.activeIterators.delete(messages);
1708
2266
  }
1709
2267
  }
2268
+ /**
2269
+ * Detect "consumer not found" errors from `js.consumers.get()`.
2270
+ *
2271
+ * Unlike JetStream Manager calls (which throw `JetStreamApiError`),
2272
+ * the JetStream client's `consumers.get()` throws a plain `Error`
2273
+ * with the error code embedded in the message text.
2274
+ */
2275
+ isConsumerNotFound(err) {
2276
+ if (!(err instanceof Error)) return false;
2277
+ return err.message.includes("consumer not found") || err.message.includes(String(10014 /* ConsumerNotFound */));
2278
+ }
1710
2279
  /** Get the target subject for a consumer kind. */
1711
2280
  getTargetSubject(kind) {
1712
2281
  switch (kind) {
@@ -1718,6 +2287,7 @@ var MessageProvider = class {
1718
2287
  return this.broadcastMessages$;
1719
2288
  case "ordered" /* Ordered */:
1720
2289
  return this.orderedMessages$;
2290
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
1721
2291
  default: {
1722
2292
  const _exhaustive = kind;
1723
2293
  throw new Error(`Unknown stream kind: ${_exhaustive}`);
@@ -1726,10 +2296,10 @@ var MessageProvider = class {
1726
2296
  }
1727
2297
  /** Monitor heartbeats and restart the consumer iterator on prolonged silence. */
1728
2298
  monitorConsumerHealth(messages, name) {
1729
- (async () => {
1730
- for await (const status of await messages.status()) {
1731
- if (status.type === import_nats8.ConsumerEvents.HeartbeatsMissed && status.data >= 2) {
1732
- this.logger.warn(`Consumer ${name}: ${status.data} heartbeats missed, restarting`);
2299
+ void (async () => {
2300
+ for await (const status of messages.status()) {
2301
+ if (status.type === "heartbeats_missed" && status.count >= 2) {
2302
+ this.logger.warn(`Consumer ${name}: ${status.count} heartbeats missed, restarting`);
1733
2303
  messages.stop();
1734
2304
  break;
1735
2305
  }
@@ -1803,8 +2373,110 @@ var MessageProvider = class {
1803
2373
  }
1804
2374
  };
1805
2375
 
2376
+ // src/server/infrastructure/metadata.provider.ts
2377
+ var import_common9 = require("@nestjs/common");
2378
+ var import_kv = require("@nats-io/kv");
2379
+ var MetadataProvider = class {
2380
+ constructor(options, connection) {
2381
+ this.connection = connection;
2382
+ this.bucketName = options.metadata?.bucket ?? DEFAULT_METADATA_BUCKET;
2383
+ this.replicas = options.metadata?.replicas ?? DEFAULT_METADATA_REPLICAS;
2384
+ this.ttl = Math.max(options.metadata?.ttl ?? DEFAULT_METADATA_TTL, MIN_METADATA_TTL);
2385
+ }
2386
+ logger = new import_common9.Logger("Jetstream:Metadata");
2387
+ bucketName;
2388
+ replicas;
2389
+ ttl;
2390
+ currentEntries;
2391
+ heartbeatTimer;
2392
+ cachedKv;
2393
+ /**
2394
+ * Write handler metadata entries to the KV bucket and start heartbeat.
2395
+ *
2396
+ * Creates the bucket if it doesn't exist (idempotent).
2397
+ * Skips silently when entries map is empty.
2398
+ * Starts a heartbeat interval that refreshes entries every `ttl / 2`
2399
+ * to prevent TTL expiry while the pod is alive.
2400
+ *
2401
+ * Non-critical — errors are logged but do not prevent transport startup.
2402
+ *
2403
+ * @param entries Map of KV key → metadata object.
2404
+ */
2405
+ async publish(entries) {
2406
+ if (entries.size === 0) return;
2407
+ try {
2408
+ const kv = await this.openBucket();
2409
+ await this.writeEntries(kv, entries);
2410
+ this.currentEntries = entries;
2411
+ this.startHeartbeat();
2412
+ this.logger.log(
2413
+ `Published ${entries.size} handler metadata entries to KV bucket "${this.bucketName}"`
2414
+ );
2415
+ } catch (err) {
2416
+ this.logger.error("Failed to publish handler metadata to KV", err);
2417
+ }
2418
+ }
2419
+ /**
2420
+ * Stop the heartbeat timer.
2421
+ *
2422
+ * After this call, entries will expire via TTL once the heartbeat window passes.
2423
+ * Called during transport shutdown (strategy.close()).
2424
+ */
2425
+ destroy() {
2426
+ if (this.heartbeatTimer) {
2427
+ clearInterval(this.heartbeatTimer);
2428
+ this.heartbeatTimer = void 0;
2429
+ }
2430
+ this.currentEntries = void 0;
2431
+ this.cachedKv = void 0;
2432
+ }
2433
+ /** Write entries to KV with per-entry error handling. */
2434
+ async writeEntries(kv, entries) {
2435
+ for (const [key, meta] of entries) {
2436
+ try {
2437
+ await kv.put(key, JSON.stringify(meta));
2438
+ } catch (err) {
2439
+ this.logger.error(`Failed to write metadata entry "${key}"`, err);
2440
+ }
2441
+ }
2442
+ }
2443
+ /** Start heartbeat interval that refreshes entries every ttl/2. */
2444
+ startHeartbeat() {
2445
+ if (this.heartbeatTimer) {
2446
+ clearInterval(this.heartbeatTimer);
2447
+ }
2448
+ const interval = Math.floor(this.ttl / 2);
2449
+ this.heartbeatTimer = setInterval(() => {
2450
+ void this.refreshEntries();
2451
+ }, interval);
2452
+ this.heartbeatTimer.unref();
2453
+ }
2454
+ /** Refresh all current entries in KV (heartbeat tick). */
2455
+ async refreshEntries() {
2456
+ if (!this.currentEntries || this.currentEntries.size === 0) return;
2457
+ try {
2458
+ const kv = await this.openBucket();
2459
+ await this.writeEntries(kv, this.currentEntries);
2460
+ } catch (err) {
2461
+ this.logger.error("Failed to refresh handler metadata in KV", err);
2462
+ }
2463
+ }
2464
+ /** Create or open the KV bucket (cached after first call). */
2465
+ async openBucket() {
2466
+ if (this.cachedKv) return this.cachedKv;
2467
+ const js = this.connection.getJetStreamClient();
2468
+ const kvm = new import_kv.Kvm(js);
2469
+ this.cachedKv = await kvm.create(this.bucketName, {
2470
+ history: DEFAULT_METADATA_HISTORY,
2471
+ replicas: this.replicas,
2472
+ ttl: this.ttl
2473
+ });
2474
+ return this.cachedKv;
2475
+ }
2476
+ };
2477
+
1806
2478
  // src/server/routing/pattern-registry.ts
1807
- var import_common8 = require("@nestjs/common");
2479
+ var import_common10 = require("@nestjs/common");
1808
2480
  var HANDLER_LABELS = {
1809
2481
  ["broadcast" /* Broadcast */]: "broadcast" /* Broadcast */,
1810
2482
  ["ordered" /* Ordered */]: "ordered" /* Ordered */,
@@ -1815,7 +2487,7 @@ var PatternRegistry = class {
1815
2487
  constructor(options) {
1816
2488
  this.options = options;
1817
2489
  }
1818
- logger = new import_common8.Logger("Jetstream:PatternRegistry");
2490
+ logger = new import_common10.Logger("Jetstream:PatternRegistry");
1819
2491
  registry = /* @__PURE__ */ new Map();
1820
2492
  // Cached after registerHandlers() — the registry is immutable from that point
1821
2493
  cachedPatterns = null;
@@ -1823,6 +2495,7 @@ var PatternRegistry = class {
1823
2495
  _hasCommands = false;
1824
2496
  _hasBroadcasts = false;
1825
2497
  _hasOrdered = false;
2498
+ _hasMetadata = false;
1826
2499
  /**
1827
2500
  * Register all handlers from the NestJS strategy.
1828
2501
  *
@@ -1835,6 +2508,7 @@ var PatternRegistry = class {
1835
2508
  const isEvent = handler.isEventHandler ?? false;
1836
2509
  const isBroadcast = !!extras?.broadcast;
1837
2510
  const isOrdered = !!extras?.ordered;
2511
+ const meta = extras?.meta;
1838
2512
  if (isBroadcast && isOrdered) {
1839
2513
  throw new Error(
1840
2514
  `Handler "${pattern}" cannot be both broadcast and ordered. Use one or the other.`
@@ -1851,7 +2525,8 @@ var PatternRegistry = class {
1851
2525
  pattern,
1852
2526
  isEvent: isEvent && !isOrdered,
1853
2527
  isBroadcast,
1854
- isOrdered
2528
+ isOrdered,
2529
+ meta
1855
2530
  });
1856
2531
  this.logger.debug(`Registered ${HANDLER_LABELS[kind]}: ${pattern} -> ${fullSubject}`);
1857
2532
  }
@@ -1860,6 +2535,7 @@ var PatternRegistry = class {
1860
2535
  this._hasCommands = this.cachedPatterns.commands.length > 0;
1861
2536
  this._hasBroadcasts = this.cachedPatterns.broadcasts.length > 0;
1862
2537
  this._hasOrdered = this.cachedPatterns.ordered.length > 0;
2538
+ this._hasMetadata = [...this.registry.values()].some((entry) => entry.meta !== void 0);
1863
2539
  this.logSummary();
1864
2540
  }
1865
2541
  /** Find handler for a full NATS subject. */
@@ -1888,6 +2564,26 @@ var PatternRegistry = class {
1888
2564
  (p) => buildSubject(this.options.name, "ordered" /* Ordered */, p)
1889
2565
  );
1890
2566
  }
2567
+ /** Check if any registered handler has metadata. */
2568
+ hasMetadata() {
2569
+ return this._hasMetadata;
2570
+ }
2571
+ /**
2572
+ * Get handler metadata entries for KV publishing.
2573
+ *
2574
+ * Returns a map of KV key -> metadata object for all handlers that have `meta`.
2575
+ * Key format: `{serviceName}.{kind}.{pattern}`.
2576
+ */
2577
+ getMetadataEntries() {
2578
+ const entries = /* @__PURE__ */ new Map();
2579
+ for (const entry of this.registry.values()) {
2580
+ if (!entry.meta) continue;
2581
+ const kind = this.resolveStreamKind(entry);
2582
+ const key = metadataKey(this.options.name, kind, entry.pattern);
2583
+ entries.set(key, entry.meta);
2584
+ }
2585
+ return entries;
2586
+ }
1891
2587
  /** Get patterns grouped by kind (cached after registration). */
1892
2588
  getPatternsByKind() {
1893
2589
  const patterns = this.cachedPatterns ?? this.buildPatternsByKind();
@@ -1927,6 +2623,12 @@ var PatternRegistry = class {
1927
2623
  }
1928
2624
  return { events, commands, broadcasts, ordered };
1929
2625
  }
2626
+ resolveStreamKind(entry) {
2627
+ if (entry.isBroadcast) return "broadcast" /* Broadcast */;
2628
+ if (entry.isOrdered) return "ordered" /* Ordered */;
2629
+ if (entry.isEvent) return "ev" /* Event */;
2630
+ return "cmd" /* Command */;
2631
+ }
1930
2632
  logSummary() {
1931
2633
  const { events, commands, broadcasts, ordered } = this.getPatternsByKind();
1932
2634
  const parts = [
@@ -1942,10 +2644,11 @@ var PatternRegistry = class {
1942
2644
  };
1943
2645
 
1944
2646
  // src/server/routing/event.router.ts
1945
- var import_common9 = require("@nestjs/common");
2647
+ var import_common11 = require("@nestjs/common");
1946
2648
  var import_rxjs4 = require("rxjs");
2649
+ var import_transport_node4 = require("@nats-io/transport-node");
1947
2650
  var EventRouter = class {
1948
- constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap) {
2651
+ constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap, connection, options) {
1949
2652
  this.messageProvider = messageProvider;
1950
2653
  this.patternRegistry = patternRegistry;
1951
2654
  this.codec = codec;
@@ -1953,8 +2656,10 @@ var EventRouter = class {
1953
2656
  this.deadLetterConfig = deadLetterConfig;
1954
2657
  this.processingConfig = processingConfig;
1955
2658
  this.ackWaitMap = ackWaitMap;
2659
+ this.connection = connection;
2660
+ this.options = options;
1956
2661
  }
1957
- logger = new import_common9.Logger("Jetstream:EventRouter");
2662
+ logger = new import_common11.Logger("Jetstream:EventRouter");
1958
2663
  subscriptions = [];
1959
2664
  /**
1960
2665
  * Update the max_deliver thresholds from actual NATS consumer configs.
@@ -2081,6 +2786,93 @@ var EventRouter = class {
2081
2786
  return msg.info.deliveryCount >= maxDeliver;
2082
2787
  }
2083
2788
  /** Handle a dead letter: invoke callback, then term or nak based on result. */
2789
+ /**
2790
+ * Fallback execution for a dead letter when DLQ is disabled, or when
2791
+ * publishing to the DLQ stream fails (due to network or NATS errors).
2792
+ *
2793
+ * Triggers the user-provided `onDeadLetter` hook for logging/alerting.
2794
+ * On success, terminates the message. On error, leaves it unacknowledged (nak)
2795
+ * so NATS can retry the delivery on the next cycle.
2796
+ */
2797
+ async fallbackToOnDeadLetterCallback(info, msg) {
2798
+ if (!this.deadLetterConfig) {
2799
+ msg.term("Dead letter config unavailable");
2800
+ return;
2801
+ }
2802
+ try {
2803
+ await this.deadLetterConfig.onDeadLetter(info);
2804
+ msg.term("Dead letter processed via fallback callback");
2805
+ } catch (hookErr) {
2806
+ this.logger.error(
2807
+ `Fallback onDeadLetter callback failed for ${msg.subject}, nak for retry:`,
2808
+ hookErr
2809
+ );
2810
+ msg.nak();
2811
+ }
2812
+ }
2813
+ /**
2814
+ * Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
2815
+ *
2816
+ * Appends diagnostic metadata headers to the original message and preserves
2817
+ * the primary payload. If publishing succeeds, it notifies the standard
2818
+ * `onDeadLetter` callback and terminates the message. If it fails, it falls
2819
+ * back to the callback entirely to prevent silent data loss.
2820
+ */
2821
+ async publishToDlq(msg, info, error) {
2822
+ const serviceName = this.options?.name;
2823
+ if (!this.connection || !serviceName) {
2824
+ this.logger.error(
2825
+ `Cannot publish to DLQ for ${msg.subject}: Connection or Module Options unavailable`
2826
+ );
2827
+ await this.fallbackToOnDeadLetterCallback(info, msg);
2828
+ return;
2829
+ }
2830
+ const destinationSubject = dlqStreamName(serviceName);
2831
+ const hdrs = (0, import_transport_node4.headers)();
2832
+ if (msg.headers) {
2833
+ for (const [k, v] of msg.headers) {
2834
+ for (const val of v) {
2835
+ hdrs.append(k, val);
2836
+ }
2837
+ }
2838
+ }
2839
+ let reason = String(error);
2840
+ if (error instanceof Error) {
2841
+ reason = error.message;
2842
+ } else if (typeof error === "object" && error !== null && "message" in error) {
2843
+ reason = String(error.message);
2844
+ }
2845
+ hdrs.set("x-dead-letter-reason" /* DeadLetterReason */, reason);
2846
+ hdrs.set("x-original-subject" /* OriginalSubject */, msg.subject);
2847
+ hdrs.set("x-original-stream" /* OriginalStream */, msg.info.stream);
2848
+ hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
2849
+ hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
2850
+ try {
2851
+ const js = this.connection.getJetStreamClient();
2852
+ await js.publish(destinationSubject, msg.data, { headers: hdrs });
2853
+ this.logger.log(`Message sent to DLQ: ${msg.subject}`);
2854
+ if (this.deadLetterConfig?.onDeadLetter) {
2855
+ try {
2856
+ await this.deadLetterConfig.onDeadLetter(info);
2857
+ } catch (hookErr) {
2858
+ this.logger.warn(
2859
+ `onDeadLetter callback failed after successful DLQ publish for ${msg.subject}`,
2860
+ hookErr
2861
+ );
2862
+ }
2863
+ }
2864
+ msg.term("Moved to DLQ stream");
2865
+ } catch (publishErr) {
2866
+ this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
2867
+ await this.fallbackToOnDeadLetterCallback(info, msg);
2868
+ }
2869
+ }
2870
+ /**
2871
+ * Orchestrates the handling of a message that has exhausted delivery limits.
2872
+ *
2873
+ * Emits a system event and delegates either to the robust DLQ stream publisher
2874
+ * or directly to the fallback callback based on the active module configuration.
2875
+ */
2084
2876
  async handleDeadLetter(msg, data, error) {
2085
2877
  const info = {
2086
2878
  subject: msg.subject,
@@ -2093,23 +2885,17 @@ var EventRouter = class {
2093
2885
  timestamp: new Date(msg.info.timestampNanos / 1e6).toISOString()
2094
2886
  };
2095
2887
  this.eventBus.emit("deadLetter" /* DeadLetter */, info);
2096
- if (!this.deadLetterConfig) {
2097
- msg.term("Dead letter config unavailable");
2098
- return;
2099
- }
2100
- try {
2101
- await this.deadLetterConfig.onDeadLetter(info);
2102
- msg.term("Dead letter processed");
2103
- } catch (hookErr) {
2104
- this.logger.error(`onDeadLetter callback failed for ${msg.subject}, nak for retry:`, hookErr);
2105
- msg.nak();
2888
+ if (!this.options?.dlq) {
2889
+ await this.fallbackToOnDeadLetterCallback(info, msg);
2890
+ } else {
2891
+ await this.publishToDlq(msg, info, error);
2106
2892
  }
2107
2893
  }
2108
2894
  };
2109
2895
 
2110
2896
  // src/server/routing/rpc.router.ts
2111
- var import_common10 = require("@nestjs/common");
2112
- var import_nats9 = require("nats");
2897
+ var import_common12 = require("@nestjs/common");
2898
+ var import_transport_node5 = require("@nats-io/transport-node");
2113
2899
  var import_rxjs5 = require("rxjs");
2114
2900
  var RpcRouter = class {
2115
2901
  constructor(messageProvider, patternRegistry, connection, codec, eventBus, rpcOptions, ackWaitMap) {
@@ -2123,7 +2909,7 @@ var RpcRouter = class {
2123
2909
  this.timeout = rpcOptions?.timeout ?? DEFAULT_JETSTREAM_RPC_TIMEOUT;
2124
2910
  this.concurrency = rpcOptions?.concurrency;
2125
2911
  }
2126
- logger = new import_common10.Logger("Jetstream:RpcRouter");
2912
+ logger = new import_common12.Logger("Jetstream:RpcRouter");
2127
2913
  timeout;
2128
2914
  concurrency;
2129
2915
  resolvedAckExtensionInterval;
@@ -2201,7 +2987,7 @@ var RpcRouter = class {
2201
2987
  stopAckExtension?.();
2202
2988
  msg.ack();
2203
2989
  try {
2204
- const hdrs = (0, import_nats9.headers)();
2990
+ const hdrs = (0, import_transport_node5.headers)();
2205
2991
  hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2206
2992
  nc.publish(replyTo, this.codec.encode(result), { headers: hdrs });
2207
2993
  } catch (publishErr) {
@@ -2213,7 +2999,7 @@ var RpcRouter = class {
2213
2999
  clearTimeout(timeoutId);
2214
3000
  stopAckExtension?.();
2215
3001
  try {
2216
- const hdrs = (0, import_nats9.headers)();
3002
+ const hdrs = (0, import_transport_node5.headers)();
2217
3003
  hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
2218
3004
  hdrs.set("x-error" /* Error */, "true");
2219
3005
  nc.publish(replyTo, this.codec.encode(serializeError(err)), { headers: hdrs });
@@ -2226,20 +3012,27 @@ var RpcRouter = class {
2226
3012
  };
2227
3013
 
2228
3014
  // src/shutdown/shutdown.manager.ts
2229
- var import_common11 = require("@nestjs/common");
3015
+ var import_common13 = require("@nestjs/common");
2230
3016
  var ShutdownManager = class {
2231
3017
  constructor(connection, eventBus, timeout) {
2232
3018
  this.connection = connection;
2233
3019
  this.eventBus = eventBus;
2234
3020
  this.timeout = timeout;
2235
3021
  }
2236
- logger = new import_common11.Logger("Jetstream:Shutdown");
3022
+ logger = new import_common13.Logger("Jetstream:Shutdown");
3023
+ shutdownPromise;
2237
3024
  /**
2238
3025
  * Execute the full shutdown sequence.
2239
3026
  *
3027
+ * Idempotent — concurrent or repeated calls return the same promise.
3028
+ *
2240
3029
  * @param strategy Optional stoppable to close (stops consumers and subscriptions).
2241
3030
  */
2242
3031
  async shutdown(strategy) {
3032
+ this.shutdownPromise ??= this.doShutdown(strategy);
3033
+ return this.shutdownPromise;
3034
+ }
3035
+ async doShutdown(strategy) {
2243
3036
  this.eventBus.emit("shutdownStart" /* ShutdownStart */);
2244
3037
  this.logger.log(`Graceful shutdown started (timeout: ${this.timeout}ms)`);
2245
3038
  strategy?.close();
@@ -2374,7 +3167,7 @@ var JetstreamModule = class {
2374
3167
  provide: JETSTREAM_EVENT_BUS,
2375
3168
  inject: [JETSTREAM_OPTIONS],
2376
3169
  useFactory: (options) => {
2377
- const logger = new import_common12.Logger("Jetstream:Module");
3170
+ const logger = new import_common14.Logger("Jetstream:Module");
2378
3171
  return new EventBus(logger, options.hooks);
2379
3172
  }
2380
3173
  },
@@ -2453,8 +3246,8 @@ var JetstreamModule = class {
2453
3246
  // MessageProvider — pull-based message consumption
2454
3247
  {
2455
3248
  provide: MessageProvider,
2456
- inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS],
2457
- useFactory: (options, connection, eventBus) => {
3249
+ inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS, ConsumerProvider],
3250
+ useFactory: (options, connection, eventBus, consumerProvider) => {
2458
3251
  if (options.consumer === false) return null;
2459
3252
  const consumeOptionsMap = /* @__PURE__ */ new Map();
2460
3253
  if (options.events?.consume)
@@ -2464,7 +3257,11 @@ var JetstreamModule = class {
2464
3257
  if (options.rpc?.mode === "jetstream" && options.rpc.consume) {
2465
3258
  consumeOptionsMap.set("cmd" /* Command */, options.rpc.consume);
2466
3259
  }
2467
- return new MessageProvider(connection, eventBus, consumeOptionsMap);
3260
+ const consumerRecoveryFn = consumerProvider ? async (kind) => {
3261
+ const jsm = await connection.getJetStreamManager();
3262
+ return consumerProvider.recoverConsumer(jsm, kind);
3263
+ } : void 0;
3264
+ return new MessageProvider(connection, eventBus, consumeOptionsMap, consumerRecoveryFn);
2468
3265
  }
2469
3266
  },
2470
3267
  // EventRouter — routes event and broadcast messages to handlers
@@ -2476,9 +3273,10 @@ var JetstreamModule = class {
2476
3273
  PatternRegistry,
2477
3274
  JETSTREAM_CODEC,
2478
3275
  JETSTREAM_EVENT_BUS,
2479
- JETSTREAM_ACK_WAIT_MAP
3276
+ JETSTREAM_ACK_WAIT_MAP,
3277
+ JETSTREAM_CONNECTION
2480
3278
  ],
2481
- useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap) => {
3279
+ useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
2482
3280
  if (options.consumer === false) return null;
2483
3281
  const deadLetterConfig = options.onDeadLetter ? {
2484
3282
  maxDeliverByStream: /* @__PURE__ */ new Map(),
@@ -2501,7 +3299,9 @@ var JetstreamModule = class {
2501
3299
  eventBus,
2502
3300
  deadLetterConfig,
2503
3301
  processingConfig,
2504
- ackWaitMap
3302
+ ackWaitMap,
3303
+ connection,
3304
+ options
2505
3305
  );
2506
3306
  }
2507
3307
  },
@@ -2550,6 +3350,15 @@ var JetstreamModule = class {
2550
3350
  return new CoreRpcServer(options, connection, patternRegistry, codec, eventBus);
2551
3351
  }
2552
3352
  },
3353
+ // MetadataProvider — handler metadata KV registry (decoupled from stream/consumer infra)
3354
+ {
3355
+ provide: MetadataProvider,
3356
+ inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION],
3357
+ useFactory: (options, connection) => {
3358
+ if (options.consumer === false) return null;
3359
+ return new MetadataProvider(options, connection);
3360
+ }
3361
+ },
2553
3362
  // JetstreamStrategy — server-side transport (only when consumer enabled)
2554
3363
  {
2555
3364
  provide: JetstreamStrategy,
@@ -2563,9 +3372,10 @@ var JetstreamModule = class {
2563
3372
  EventRouter,
2564
3373
  RpcRouter,
2565
3374
  CoreRpcServer,
2566
- JETSTREAM_ACK_WAIT_MAP
3375
+ JETSTREAM_ACK_WAIT_MAP,
3376
+ MetadataProvider
2567
3377
  ],
2568
- useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap) => {
3378
+ useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap, metadataProvider) => {
2569
3379
  if (options.consumer === false) return null;
2570
3380
  return new JetstreamStrategy(
2571
3381
  options,
@@ -2577,7 +3387,8 @@ var JetstreamModule = class {
2577
3387
  eventRouter,
2578
3388
  rpcRouter,
2579
3389
  coreRpcServer,
2580
- ackWaitMap
3390
+ ackWaitMap,
3391
+ metadataProvider
2581
3392
  );
2582
3393
  }
2583
3394
  }
@@ -2636,21 +3447,26 @@ var JetstreamModule = class {
2636
3447
  }
2637
3448
  };
2638
3449
  JetstreamModule = __decorateClass([
2639
- (0, import_common12.Global)(),
2640
- (0, import_common12.Module)({}),
2641
- __decorateParam(0, (0, import_common12.Optional)()),
2642
- __decorateParam(0, (0, import_common12.Inject)(ShutdownManager)),
2643
- __decorateParam(1, (0, import_common12.Optional)()),
2644
- __decorateParam(1, (0, import_common12.Inject)(JetstreamStrategy))
3450
+ (0, import_common14.Global)(),
3451
+ (0, import_common14.Module)({}),
3452
+ __decorateParam(0, (0, import_common14.Optional)()),
3453
+ __decorateParam(0, (0, import_common14.Inject)(ShutdownManager)),
3454
+ __decorateParam(1, (0, import_common14.Optional)()),
3455
+ __decorateParam(1, (0, import_common14.Inject)(JetstreamStrategy))
2645
3456
  ], JetstreamModule);
2646
3457
  // Annotate the CommonJS export names for ESM import in node:
2647
3458
  0 && (module.exports = {
3459
+ DEFAULT_METADATA_BUCKET,
3460
+ DEFAULT_METADATA_HISTORY,
3461
+ DEFAULT_METADATA_REPLICAS,
3462
+ DEFAULT_METADATA_TTL,
2648
3463
  EventBus,
2649
3464
  JETSTREAM_CODEC,
2650
3465
  JETSTREAM_CONNECTION,
2651
3466
  JETSTREAM_EVENT_BUS,
2652
3467
  JETSTREAM_OPTIONS,
2653
3468
  JetstreamClient,
3469
+ JetstreamDlqHeader,
2654
3470
  JetstreamHeader,
2655
3471
  JetstreamHealthIndicator,
2656
3472
  JetstreamModule,
@@ -2658,17 +3474,21 @@ JetstreamModule = __decorateClass([
2658
3474
  JetstreamRecordBuilder,
2659
3475
  JetstreamStrategy,
2660
3476
  JsonCodec,
3477
+ MIN_METADATA_TTL,
2661
3478
  MessageKind,
2662
3479
  PatternPrefix,
2663
3480
  RpcContext,
2664
3481
  StreamKind,
2665
3482
  TransportEvent,
3483
+ buildBroadcastSubject,
2666
3484
  buildSubject,
2667
3485
  consumerName,
3486
+ dlqStreamName,
2668
3487
  getClientToken,
2669
3488
  internalName,
2670
3489
  isCoreRpcMode,
2671
3490
  isJetStreamRpcMode,
3491
+ metadataKey,
2672
3492
  streamName,
2673
3493
  toNanos
2674
3494
  });