@horizon-republic/nestjs-jetstream 2.8.0 → 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";
@@ -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,12 +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, schedule) {
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;
205
234
  this.schedule = schedule;
235
+ this.ttl = ttl;
206
236
  }
207
237
  };
208
238
  var JetstreamRecordBuilder = class {
@@ -211,6 +241,7 @@ var JetstreamRecordBuilder = class {
211
241
  timeout;
212
242
  messageId;
213
243
  scheduleOptions;
244
+ ttlDuration;
214
245
  constructor(data) {
215
246
  this.data = data;
216
247
  }
@@ -302,6 +333,33 @@ var JetstreamRecordBuilder = class {
302
333
  this.scheduleOptions = { at: new Date(ts) };
303
334
  return this;
304
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
+ }
305
363
  /**
306
364
  * Build the immutable {@link JetstreamRecord}.
307
365
  *
@@ -314,7 +372,8 @@ var JetstreamRecordBuilder = class {
314
372
  new Map(this.headers),
315
373
  this.timeout,
316
374
  this.messageId,
317
- schedule
375
+ schedule,
376
+ this.ttlDuration
318
377
  );
319
378
  }
320
379
  /** Validate that a header key is not reserved. */
@@ -326,6 +385,17 @@ var JetstreamRecordBuilder = class {
326
385
  }
327
386
  }
328
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
+ };
329
399
 
330
400
  // src/client/jetstream.client.ts
331
401
  var JetstreamClient = class extends ClientProxy {
@@ -400,7 +470,7 @@ var JetstreamClient = class extends ClientProxy {
400
470
  */
401
471
  async dispatchEvent(packet) {
402
472
  await this.connect();
403
- const { data, hdrs, messageId, schedule } = this.extractRecordData(packet.data);
473
+ const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
404
474
  const eventSubject = this.buildEventSubject(packet.pattern);
405
475
  const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
406
476
  if (schedule) {
@@ -408,6 +478,7 @@ var JetstreamClient = class extends ClientProxy {
408
478
  const ack = await this.connection.getJetStreamClient().publish(scheduleSubject, this.codec.encode(data), {
409
479
  headers: msgHeaders,
410
480
  msgID: messageId ?? nuid.next(),
481
+ ttl,
411
482
  schedule: {
412
483
  specification: schedule.at,
413
484
  target: eventSubject
@@ -421,7 +492,8 @@ var JetstreamClient = class extends ClientProxy {
421
492
  } else {
422
493
  const ack = await this.connection.getJetStreamClient().publish(eventSubject, this.codec.encode(data), {
423
494
  headers: msgHeaders,
424
- msgID: messageId ?? nuid.next()
495
+ msgID: messageId ?? nuid.next(),
496
+ ttl
425
497
  });
426
498
  if (ack.duplicate) {
427
499
  this.logger.warn(`Duplicate event publish detected: ${eventSubject} (seq: ${ack.seq})`);
@@ -437,12 +509,17 @@ var JetstreamClient = class extends ClientProxy {
437
509
  */
438
510
  publish(packet, callback) {
439
511
  const subject = buildSubject(this.targetName, "cmd" /* Command */, packet.pattern);
440
- const { data, hdrs, timeout, messageId, schedule } = this.extractRecordData(packet.data);
512
+ const { data, hdrs, timeout, messageId, schedule, ttl } = this.extractRecordData(packet.data);
441
513
  if (schedule) {
442
514
  this.logger.warn(
443
515
  "scheduleAt() is ignored for RPC (client.send()). Use client.emit() for scheduled events."
444
516
  );
445
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
+ }
446
523
  const onUnhandled = (err) => {
447
524
  this.logger.error("Unhandled publish error:", err);
448
525
  callback({ err: new Error("Internal transport error"), response: null, isDisposed: true });
@@ -558,6 +635,7 @@ var JetstreamClient = class extends ClientProxy {
558
635
  this.pendingTimeouts.clear();
559
636
  this.inboxSubscription?.unsubscribe();
560
637
  this.inboxSubscription = null;
638
+ this.inbox = null;
561
639
  }
562
640
  /** Setup shared inbox subscription for JetStream RPC responses. */
563
641
  setupInbox(nc) {
@@ -647,7 +725,8 @@ var JetstreamClient = class extends ClientProxy {
647
725
  hdrs: rawData.headers.size > 0 ? new Map(rawData.headers) : null,
648
726
  timeout: rawData.timeout,
649
727
  messageId: rawData.messageId,
650
- schedule: rawData.schedule
728
+ schedule: rawData.schedule,
729
+ ttl: rawData.ttl
651
730
  };
652
731
  }
653
732
  return {
@@ -655,7 +734,8 @@ var JetstreamClient = class extends ClientProxy {
655
734
  hdrs: null,
656
735
  timeout: void 0,
657
736
  messageId: void 0,
658
- schedule: void 0
737
+ schedule: void 0,
738
+ ttl: void 0
659
739
  };
660
740
  }
661
741
  /**
@@ -975,9 +1055,14 @@ var JetstreamHealthIndicator = class {
975
1055
  * Returns `{ [key]: { status: 'up', ... } }` on success.
976
1056
  * Throws an error with `{ [key]: { status: 'down', ... } }` on failure.
977
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
+ *
978
1063
  * @param key - Health indicator key (default: `'jetstream'`).
979
1064
  * @returns Object with status, server, and latency under the given key.
980
- * @throws Error with `{ [key]: { status: 'down' } }` when disconnected.
1065
+ * @throws Error with `isHealthCheckError`, `causes`, and `{ [key]: { status: 'down' } }`.
981
1066
  */
982
1067
  async isHealthy(key = "jetstream") {
983
1068
  const status = await this.check();
@@ -987,8 +1072,10 @@ var JetstreamHealthIndicator = class {
987
1072
  latency: status.latency
988
1073
  };
989
1074
  if (!status.connected) {
1075
+ const causes = { [key]: details };
990
1076
  throw Object.assign(new Error("Jetstream health check failed"), {
991
- [key]: details
1077
+ causes,
1078
+ isHealthCheckError: true
992
1079
  });
993
1080
  }
994
1081
  return { [key]: details };
@@ -1001,7 +1088,7 @@ JetstreamHealthIndicator = __decorateClass([
1001
1088
  // src/server/strategy.ts
1002
1089
  import { Server } from "@nestjs/microservices";
1003
1090
  var JetstreamStrategy = class extends Server {
1004
- 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) {
1005
1092
  super();
1006
1093
  this.options = options;
1007
1094
  this.connection = connection;
@@ -1013,6 +1100,7 @@ var JetstreamStrategy = class extends Server {
1013
1100
  this.rpcRouter = rpcRouter;
1014
1101
  this.coreRpcServer = coreRpcServer;
1015
1102
  this.ackWaitMap = ackWaitMap;
1103
+ this.metadataProvider = metadataProvider;
1016
1104
  }
1017
1105
  transportId = /* @__PURE__ */ Symbol("jetstream-transport");
1018
1106
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
@@ -1057,10 +1145,14 @@ var JetstreamStrategy = class extends Server {
1057
1145
  if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
1058
1146
  await this.coreRpcServer.start();
1059
1147
  }
1148
+ if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
1149
+ await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
1150
+ }
1060
1151
  callback();
1061
1152
  }
1062
- /** Stop all consumers, routers, and subscriptions. Called during shutdown. */
1153
+ /** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
1063
1154
  close() {
1155
+ this.metadataProvider?.destroy();
1064
1156
  this.eventRouter.destroy();
1065
1157
  this.rpcRouter.destroy();
1066
1158
  this.coreRpcServer.stop();
@@ -1436,24 +1528,172 @@ var CoreRpcServer = class {
1436
1528
  };
1437
1529
 
1438
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
1439
1590
  import { Logger as Logger5 } from "@nestjs/common";
1440
1591
  import { JetStreamApiError } from "@nats-io/jetstream";
1441
- var STREAM_NOT_FOUND = 10059;
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
1442
1677
  var StreamProvider = class {
1443
1678
  constructor(options, connection) {
1444
1679
  this.options = options;
1445
1680
  this.connection = connection;
1446
1681
  }
1447
- logger = new Logger5("Jetstream:Stream");
1682
+ logger = new Logger6("Jetstream:Stream");
1683
+ migration = new StreamMigration();
1448
1684
  /**
1449
1685
  * Ensure all required streams exist with correct configuration.
1450
1686
  *
1451
1687
  * @param kinds Which stream kinds to create. Determined by the module based
1452
1688
  * on RPC mode and registered handler patterns.
1689
+ * If the dlq option is enabled, also ensures the DLQ stream exists.
1453
1690
  */
1454
1691
  async ensureStreams(kinds) {
1455
1692
  const jsm = await this.connection.getJetStreamManager();
1456
1693
  await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
1694
+ if (this.options.dlq) {
1695
+ await this.ensureDlqStream(jsm);
1696
+ }
1457
1697
  }
1458
1698
  /** Get the stream name for a given kind. */
1459
1699
  getStreamName(kind) {
@@ -1488,17 +1728,85 @@ var StreamProvider = class {
1488
1728
  const config = this.buildConfig(kind);
1489
1729
  this.logger.log(`Ensuring stream: ${config.name}`);
1490
1730
  try {
1491
- await jsm.streams.info(config.name);
1492
- this.logger.debug(`Stream exists, updating: ${config.name}`);
1493
- 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);
1494
1733
  } catch (err) {
1495
- if (err instanceof JetStreamApiError && err.apiError().err_code === STREAM_NOT_FOUND) {
1734
+ if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
1496
1735
  this.logger.log(`Creating stream: ${config.name}`);
1497
1736
  return await jsm.streams.add(config);
1498
1737
  }
1499
1738
  throw err;
1500
1739
  }
1501
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
+ }
1502
1810
  /** Build the full stream config by merging defaults with user overrides. */
1503
1811
  buildConfig(kind) {
1504
1812
  const name = this.getStreamName(kind);
@@ -1514,6 +1822,26 @@ var StreamProvider = class {
1514
1822
  description
1515
1823
  };
1516
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
+ }
1517
1845
  /** Get default config for a stream kind. */
1518
1846
  getDefaults(kind) {
1519
1847
  switch (kind) {
@@ -1532,25 +1860,44 @@ var StreamProvider = class {
1532
1860
  const overrides = this.getOverrides(kind);
1533
1861
  return overrides.allow_msg_schedules === true;
1534
1862
  }
1535
- /** Get user-provided overrides for a stream kind. */
1863
+ /** Get user-provided overrides for a stream kind, stripping transport-controlled properties. */
1536
1864
  getOverrides(kind) {
1865
+ let overrides;
1537
1866
  switch (kind) {
1538
1867
  case "ev" /* Event */:
1539
- return this.options.events?.stream ?? {};
1868
+ overrides = this.options.events?.stream ?? {};
1869
+ break;
1540
1870
  case "cmd" /* Command */:
1541
- return this.options.rpc?.mode === "jetstream" ? this.options.rpc.stream ?? {} : {};
1871
+ overrides = this.options.rpc?.mode === "jetstream" ? this.options.rpc.stream ?? {} : {};
1872
+ break;
1542
1873
  case "broadcast" /* Broadcast */:
1543
- return this.options.broadcast?.stream ?? {};
1874
+ overrides = this.options.broadcast?.stream ?? {};
1875
+ break;
1544
1876
  case "ordered" /* Ordered */:
1545
- return this.options.ordered?.stream ?? {};
1877
+ overrides = this.options.ordered?.stream ?? {};
1878
+ break;
1546
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;
1547
1895
  }
1548
1896
  };
1549
1897
 
1550
1898
  // src/server/infrastructure/consumer.provider.ts
1551
- import { Logger as Logger6 } from "@nestjs/common";
1552
- import { JetStreamApiError as JetStreamApiError2 } from "@nats-io/jetstream";
1553
- var CONSUMER_NOT_FOUND = 10014;
1899
+ import { Logger as Logger7 } from "@nestjs/common";
1900
+ import { JetStreamApiError as JetStreamApiError3 } from "@nats-io/jetstream";
1554
1901
  var ConsumerProvider = class {
1555
1902
  constructor(options, connection, streamProvider, patternRegistry) {
1556
1903
  this.options = options;
@@ -1558,7 +1905,7 @@ var ConsumerProvider = class {
1558
1905
  this.streamProvider = streamProvider;
1559
1906
  this.patternRegistry = patternRegistry;
1560
1907
  }
1561
- logger = new Logger6("Jetstream:Consumer");
1908
+ logger = new Logger7("Jetstream:Consumer");
1562
1909
  /**
1563
1910
  * Ensure consumers exist for the specified kinds.
1564
1911
  *
@@ -1579,7 +1926,11 @@ var ConsumerProvider = class {
1579
1926
  getConsumerName(kind) {
1580
1927
  return consumerName(this.options.name, kind);
1581
1928
  }
1582
- /** 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
+ */
1583
1934
  async ensureConsumer(jsm, kind) {
1584
1935
  const stream = this.streamProvider.getStreamName(kind);
1585
1936
  const config = this.buildConfig(kind);
@@ -1590,13 +1941,74 @@ var ConsumerProvider = class {
1590
1941
  this.logger.debug(`Consumer exists, updating: ${name}`);
1591
1942
  return await jsm.consumers.update(stream, name, config);
1592
1943
  } catch (err) {
1593
- if (err instanceof JetStreamApiError2 && err.apiError().err_code === CONSUMER_NOT_FOUND) {
1594
- this.logger.log(`Creating consumer: ${name}`);
1595
- 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;
1596
1993
  }
1597
1994
  throw err;
1598
1995
  }
1599
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
+ }
1600
2012
  /** Build consumer config by merging defaults with user overrides. */
1601
2013
  // eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
1602
2014
  buildConfig(kind) {
@@ -1649,6 +2061,7 @@ var ConsumerProvider = class {
1649
2061
  return DEFAULT_BROADCAST_CONSUMER_CONFIG;
1650
2062
  case "ordered" /* Ordered */:
1651
2063
  throw new Error("Ordered consumers are ephemeral and should not use durable config");
2064
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
1652
2065
  default: {
1653
2066
  const _exhaustive = kind;
1654
2067
  throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
@@ -1666,6 +2079,7 @@ var ConsumerProvider = class {
1666
2079
  return this.options.broadcast?.consumer ?? {};
1667
2080
  case "ordered" /* Ordered */:
1668
2081
  throw new Error("Ordered consumers are ephemeral and should not use durable config");
2082
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
1669
2083
  default: {
1670
2084
  const _exhaustive = kind;
1671
2085
  throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
@@ -1675,7 +2089,7 @@ var ConsumerProvider = class {
1675
2089
  };
1676
2090
 
1677
2091
  // src/server/infrastructure/message.provider.ts
1678
- import { Logger as Logger7 } from "@nestjs/common";
2092
+ import { Logger as Logger8 } from "@nestjs/common";
1679
2093
  import { DeliverPolicy as DeliverPolicy2 } from "@nats-io/jetstream";
1680
2094
  import {
1681
2095
  catchError,
@@ -1689,12 +2103,13 @@ import {
1689
2103
  timer
1690
2104
  } from "rxjs";
1691
2105
  var MessageProvider = class {
1692
- constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map()) {
2106
+ constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map(), consumerRecoveryFn) {
1693
2107
  this.connection = connection;
1694
2108
  this.eventBus = eventBus;
1695
2109
  this.consumeOptionsMap = consumeOptionsMap;
2110
+ this.consumerRecoveryFn = consumerRecoveryFn;
1696
2111
  }
1697
- logger = new Logger7("Jetstream:Message");
2112
+ logger = new Logger8("Jetstream:Message");
1698
2113
  activeIterators = /* @__PURE__ */ new Set();
1699
2114
  orderedReadyResolve = null;
1700
2115
  orderedReadyReject = null;
@@ -1797,12 +2212,26 @@ var MessageProvider = class {
1797
2212
  /** Single iteration: get consumer -> pull messages -> emit to subject. */
1798
2213
  async consumeOnce(kind, info, target$) {
1799
2214
  const js = this.connection.getJetStreamClient();
1800
- 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
+ }
1801
2230
  const defaults = { idle_heartbeat: 5e3 };
1802
2231
  const userOptions = this.consumeOptionsMap.get(kind) ?? {};
1803
2232
  const messages = await consumer.consume({ ...defaults, ...userOptions });
1804
2233
  this.activeIterators.add(messages);
1805
- this.monitorConsumerHealth(messages, info.name);
2234
+ this.monitorConsumerHealth(messages, consumerName2);
1806
2235
  try {
1807
2236
  for await (const msg of messages) {
1808
2237
  target$.next(msg);
@@ -1811,6 +2240,17 @@ var MessageProvider = class {
1811
2240
  this.activeIterators.delete(messages);
1812
2241
  }
1813
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
+ }
1814
2254
  /** Get the target subject for a consumer kind. */
1815
2255
  getTargetSubject(kind) {
1816
2256
  switch (kind) {
@@ -1822,6 +2262,7 @@ var MessageProvider = class {
1822
2262
  return this.broadcastMessages$;
1823
2263
  case "ordered" /* Ordered */:
1824
2264
  return this.orderedMessages$;
2265
+ /* v8 ignore next 5 -- exhaustive switch guard, unreachable */
1825
2266
  default: {
1826
2267
  const _exhaustive = kind;
1827
2268
  throw new Error(`Unknown stream kind: ${_exhaustive}`);
@@ -1907,8 +2348,110 @@ var MessageProvider = class {
1907
2348
  }
1908
2349
  };
1909
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
+
1910
2453
  // src/server/routing/pattern-registry.ts
1911
- import { Logger as Logger8 } from "@nestjs/common";
2454
+ import { Logger as Logger10 } from "@nestjs/common";
1912
2455
  var HANDLER_LABELS = {
1913
2456
  ["broadcast" /* Broadcast */]: "broadcast" /* Broadcast */,
1914
2457
  ["ordered" /* Ordered */]: "ordered" /* Ordered */,
@@ -1919,7 +2462,7 @@ var PatternRegistry = class {
1919
2462
  constructor(options) {
1920
2463
  this.options = options;
1921
2464
  }
1922
- logger = new Logger8("Jetstream:PatternRegistry");
2465
+ logger = new Logger10("Jetstream:PatternRegistry");
1923
2466
  registry = /* @__PURE__ */ new Map();
1924
2467
  // Cached after registerHandlers() — the registry is immutable from that point
1925
2468
  cachedPatterns = null;
@@ -1927,6 +2470,7 @@ var PatternRegistry = class {
1927
2470
  _hasCommands = false;
1928
2471
  _hasBroadcasts = false;
1929
2472
  _hasOrdered = false;
2473
+ _hasMetadata = false;
1930
2474
  /**
1931
2475
  * Register all handlers from the NestJS strategy.
1932
2476
  *
@@ -1939,6 +2483,7 @@ var PatternRegistry = class {
1939
2483
  const isEvent = handler.isEventHandler ?? false;
1940
2484
  const isBroadcast = !!extras?.broadcast;
1941
2485
  const isOrdered = !!extras?.ordered;
2486
+ const meta = extras?.meta;
1942
2487
  if (isBroadcast && isOrdered) {
1943
2488
  throw new Error(
1944
2489
  `Handler "${pattern}" cannot be both broadcast and ordered. Use one or the other.`
@@ -1955,7 +2500,8 @@ var PatternRegistry = class {
1955
2500
  pattern,
1956
2501
  isEvent: isEvent && !isOrdered,
1957
2502
  isBroadcast,
1958
- isOrdered
2503
+ isOrdered,
2504
+ meta
1959
2505
  });
1960
2506
  this.logger.debug(`Registered ${HANDLER_LABELS[kind]}: ${pattern} -> ${fullSubject}`);
1961
2507
  }
@@ -1964,6 +2510,7 @@ var PatternRegistry = class {
1964
2510
  this._hasCommands = this.cachedPatterns.commands.length > 0;
1965
2511
  this._hasBroadcasts = this.cachedPatterns.broadcasts.length > 0;
1966
2512
  this._hasOrdered = this.cachedPatterns.ordered.length > 0;
2513
+ this._hasMetadata = [...this.registry.values()].some((entry) => entry.meta !== void 0);
1967
2514
  this.logSummary();
1968
2515
  }
1969
2516
  /** Find handler for a full NATS subject. */
@@ -1992,6 +2539,26 @@ var PatternRegistry = class {
1992
2539
  (p) => buildSubject(this.options.name, "ordered" /* Ordered */, p)
1993
2540
  );
1994
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
+ }
1995
2562
  /** Get patterns grouped by kind (cached after registration). */
1996
2563
  getPatternsByKind() {
1997
2564
  const patterns = this.cachedPatterns ?? this.buildPatternsByKind();
@@ -2031,6 +2598,12 @@ var PatternRegistry = class {
2031
2598
  }
2032
2599
  return { events, commands, broadcasts, ordered };
2033
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
+ }
2034
2607
  logSummary() {
2035
2608
  const { events, commands, broadcasts, ordered } = this.getPatternsByKind();
2036
2609
  const parts = [
@@ -2046,10 +2619,11 @@ var PatternRegistry = class {
2046
2619
  };
2047
2620
 
2048
2621
  // src/server/routing/event.router.ts
2049
- import { Logger as Logger9 } from "@nestjs/common";
2622
+ import { Logger as Logger11 } from "@nestjs/common";
2050
2623
  import { concatMap, from as from2, mergeMap } from "rxjs";
2624
+ import { headers as natsHeaders3 } from "@nats-io/transport-node";
2051
2625
  var EventRouter = class {
2052
- constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap) {
2626
+ constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap, connection, options) {
2053
2627
  this.messageProvider = messageProvider;
2054
2628
  this.patternRegistry = patternRegistry;
2055
2629
  this.codec = codec;
@@ -2057,8 +2631,10 @@ var EventRouter = class {
2057
2631
  this.deadLetterConfig = deadLetterConfig;
2058
2632
  this.processingConfig = processingConfig;
2059
2633
  this.ackWaitMap = ackWaitMap;
2634
+ this.connection = connection;
2635
+ this.options = options;
2060
2636
  }
2061
- logger = new Logger9("Jetstream:EventRouter");
2637
+ logger = new Logger11("Jetstream:EventRouter");
2062
2638
  subscriptions = [];
2063
2639
  /**
2064
2640
  * Update the max_deliver thresholds from actual NATS consumer configs.
@@ -2185,6 +2761,93 @@ var EventRouter = class {
2185
2761
  return msg.info.deliveryCount >= maxDeliver;
2186
2762
  }
2187
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
+ */
2188
2851
  async handleDeadLetter(msg, data, error) {
2189
2852
  const info = {
2190
2853
  subject: msg.subject,
@@ -2197,22 +2860,16 @@ var EventRouter = class {
2197
2860
  timestamp: new Date(msg.info.timestampNanos / 1e6).toISOString()
2198
2861
  };
2199
2862
  this.eventBus.emit("deadLetter" /* DeadLetter */, info);
2200
- if (!this.deadLetterConfig) {
2201
- msg.term("Dead letter config unavailable");
2202
- return;
2203
- }
2204
- try {
2205
- await this.deadLetterConfig.onDeadLetter(info);
2206
- msg.term("Dead letter processed");
2207
- } catch (hookErr) {
2208
- this.logger.error(`onDeadLetter callback failed for ${msg.subject}, nak for retry:`, hookErr);
2209
- msg.nak();
2863
+ if (!this.options?.dlq) {
2864
+ await this.fallbackToOnDeadLetterCallback(info, msg);
2865
+ } else {
2866
+ await this.publishToDlq(msg, info, error);
2210
2867
  }
2211
2868
  }
2212
2869
  };
2213
2870
 
2214
2871
  // src/server/routing/rpc.router.ts
2215
- import { Logger as Logger10 } from "@nestjs/common";
2872
+ import { Logger as Logger12 } from "@nestjs/common";
2216
2873
  import { headers } from "@nats-io/transport-node";
2217
2874
  import { from as from3, mergeMap as mergeMap2 } from "rxjs";
2218
2875
  var RpcRouter = class {
@@ -2227,7 +2884,7 @@ var RpcRouter = class {
2227
2884
  this.timeout = rpcOptions?.timeout ?? DEFAULT_JETSTREAM_RPC_TIMEOUT;
2228
2885
  this.concurrency = rpcOptions?.concurrency;
2229
2886
  }
2230
- logger = new Logger10("Jetstream:RpcRouter");
2887
+ logger = new Logger12("Jetstream:RpcRouter");
2231
2888
  timeout;
2232
2889
  concurrency;
2233
2890
  resolvedAckExtensionInterval;
@@ -2330,20 +2987,27 @@ var RpcRouter = class {
2330
2987
  };
2331
2988
 
2332
2989
  // src/shutdown/shutdown.manager.ts
2333
- import { Logger as Logger11 } from "@nestjs/common";
2990
+ import { Logger as Logger13 } from "@nestjs/common";
2334
2991
  var ShutdownManager = class {
2335
2992
  constructor(connection, eventBus, timeout) {
2336
2993
  this.connection = connection;
2337
2994
  this.eventBus = eventBus;
2338
2995
  this.timeout = timeout;
2339
2996
  }
2340
- logger = new Logger11("Jetstream:Shutdown");
2997
+ logger = new Logger13("Jetstream:Shutdown");
2998
+ shutdownPromise;
2341
2999
  /**
2342
3000
  * Execute the full shutdown sequence.
2343
3001
  *
3002
+ * Idempotent — concurrent or repeated calls return the same promise.
3003
+ *
2344
3004
  * @param strategy Optional stoppable to close (stops consumers and subscriptions).
2345
3005
  */
2346
3006
  async shutdown(strategy) {
3007
+ this.shutdownPromise ??= this.doShutdown(strategy);
3008
+ return this.shutdownPromise;
3009
+ }
3010
+ async doShutdown(strategy) {
2347
3011
  this.eventBus.emit("shutdownStart" /* ShutdownStart */);
2348
3012
  this.logger.log(`Graceful shutdown started (timeout: ${this.timeout}ms)`);
2349
3013
  strategy?.close();
@@ -2478,7 +3142,7 @@ var JetstreamModule = class {
2478
3142
  provide: JETSTREAM_EVENT_BUS,
2479
3143
  inject: [JETSTREAM_OPTIONS],
2480
3144
  useFactory: (options) => {
2481
- const logger = new Logger12("Jetstream:Module");
3145
+ const logger = new Logger14("Jetstream:Module");
2482
3146
  return new EventBus(logger, options.hooks);
2483
3147
  }
2484
3148
  },
@@ -2557,8 +3221,8 @@ var JetstreamModule = class {
2557
3221
  // MessageProvider — pull-based message consumption
2558
3222
  {
2559
3223
  provide: MessageProvider,
2560
- inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS],
2561
- useFactory: (options, connection, eventBus) => {
3224
+ inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS, ConsumerProvider],
3225
+ useFactory: (options, connection, eventBus, consumerProvider) => {
2562
3226
  if (options.consumer === false) return null;
2563
3227
  const consumeOptionsMap = /* @__PURE__ */ new Map();
2564
3228
  if (options.events?.consume)
@@ -2568,7 +3232,11 @@ var JetstreamModule = class {
2568
3232
  if (options.rpc?.mode === "jetstream" && options.rpc.consume) {
2569
3233
  consumeOptionsMap.set("cmd" /* Command */, options.rpc.consume);
2570
3234
  }
2571
- 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);
2572
3240
  }
2573
3241
  },
2574
3242
  // EventRouter — routes event and broadcast messages to handlers
@@ -2580,9 +3248,10 @@ var JetstreamModule = class {
2580
3248
  PatternRegistry,
2581
3249
  JETSTREAM_CODEC,
2582
3250
  JETSTREAM_EVENT_BUS,
2583
- JETSTREAM_ACK_WAIT_MAP
3251
+ JETSTREAM_ACK_WAIT_MAP,
3252
+ JETSTREAM_CONNECTION
2584
3253
  ],
2585
- useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap) => {
3254
+ useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
2586
3255
  if (options.consumer === false) return null;
2587
3256
  const deadLetterConfig = options.onDeadLetter ? {
2588
3257
  maxDeliverByStream: /* @__PURE__ */ new Map(),
@@ -2605,7 +3274,9 @@ var JetstreamModule = class {
2605
3274
  eventBus,
2606
3275
  deadLetterConfig,
2607
3276
  processingConfig,
2608
- ackWaitMap
3277
+ ackWaitMap,
3278
+ connection,
3279
+ options
2609
3280
  );
2610
3281
  }
2611
3282
  },
@@ -2654,6 +3325,15 @@ var JetstreamModule = class {
2654
3325
  return new CoreRpcServer(options, connection, patternRegistry, codec, eventBus);
2655
3326
  }
2656
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
+ },
2657
3337
  // JetstreamStrategy — server-side transport (only when consumer enabled)
2658
3338
  {
2659
3339
  provide: JetstreamStrategy,
@@ -2667,9 +3347,10 @@ var JetstreamModule = class {
2667
3347
  EventRouter,
2668
3348
  RpcRouter,
2669
3349
  CoreRpcServer,
2670
- JETSTREAM_ACK_WAIT_MAP
3350
+ JETSTREAM_ACK_WAIT_MAP,
3351
+ MetadataProvider
2671
3352
  ],
2672
- useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap) => {
3353
+ useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap, metadataProvider) => {
2673
3354
  if (options.consumer === false) return null;
2674
3355
  return new JetstreamStrategy(
2675
3356
  options,
@@ -2681,7 +3362,8 @@ var JetstreamModule = class {
2681
3362
  eventRouter,
2682
3363
  rpcRouter,
2683
3364
  coreRpcServer,
2684
- ackWaitMap
3365
+ ackWaitMap,
3366
+ metadataProvider
2685
3367
  );
2686
3368
  }
2687
3369
  }
@@ -2748,12 +3430,17 @@ JetstreamModule = __decorateClass([
2748
3430
  __decorateParam(1, Inject(JetstreamStrategy))
2749
3431
  ], JetstreamModule);
2750
3432
  export {
3433
+ DEFAULT_METADATA_BUCKET,
3434
+ DEFAULT_METADATA_HISTORY,
3435
+ DEFAULT_METADATA_REPLICAS,
3436
+ DEFAULT_METADATA_TTL,
2751
3437
  EventBus,
2752
3438
  JETSTREAM_CODEC,
2753
3439
  JETSTREAM_CONNECTION,
2754
3440
  JETSTREAM_EVENT_BUS,
2755
3441
  JETSTREAM_OPTIONS,
2756
3442
  JetstreamClient,
3443
+ JetstreamDlqHeader,
2757
3444
  JetstreamHeader,
2758
3445
  JetstreamHealthIndicator,
2759
3446
  JetstreamModule,
@@ -2761,17 +3448,21 @@ export {
2761
3448
  JetstreamRecordBuilder,
2762
3449
  JetstreamStrategy,
2763
3450
  JsonCodec,
3451
+ MIN_METADATA_TTL,
2764
3452
  MessageKind,
2765
3453
  PatternPrefix,
2766
3454
  RpcContext,
2767
3455
  StreamKind,
2768
3456
  TransportEvent,
3457
+ buildBroadcastSubject,
2769
3458
  buildSubject,
2770
3459
  consumerName,
3460
+ dlqStreamName,
2771
3461
  getClientToken,
2772
3462
  internalName,
2773
3463
  isCoreRpcMode,
2774
3464
  isJetStreamRpcMode,
3465
+ metadataKey,
2775
3466
  streamName,
2776
3467
  toNanos
2777
3468
  };