@horizon-republic/nestjs-jetstream 2.11.0 → 2.12.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
@@ -144,7 +144,7 @@ var DEFAULT_ORDERED_STREAM_CONFIG = {
144
144
  };
145
145
  var DEFAULT_DLQ_STREAM_CONFIG = {
146
146
  ...baseStreamConfig,
147
- retention: RetentionPolicy.Workqueue,
147
+ retention: RetentionPolicy.Limits,
148
148
  allow_rollup_hdrs: false,
149
149
  max_consumers: 100,
150
150
  max_msg_size: 10 * MB,
@@ -208,6 +208,7 @@ var RESERVED_HEADERS = /* @__PURE__ */ new Set([
208
208
  "x-reply-to" /* ReplyTo */,
209
209
  "x-error" /* Error */
210
210
  ]);
211
+ var NATS_CONTROL_HEADER_PREFIX = "nats-";
211
212
  var internalName = (name) => `${name}__microservice`;
212
213
  var buildSubject = (serviceName, kind, pattern) => `${internalName(serviceName)}.${kind}.${pattern}`;
213
214
  var buildBroadcastSubject = (pattern) => `broadcast.${pattern}`;
@@ -260,6 +261,9 @@ var ATTR_JETSTREAM_RPC_REPLY_ERROR_CODE = "jetstream.rpc.reply.error.code";
260
261
  var ATTR_JETSTREAM_PROVISIONING_ENTITY = "jetstream.provisioning.entity";
261
262
  var ATTR_JETSTREAM_PROVISIONING_ACTION = "jetstream.provisioning.action";
262
263
  var ATTR_JETSTREAM_PROVISIONING_NAME = "jetstream.provisioning.name";
264
+ var ATTR_JETSTREAM_PROVISIONING_MAX_BYTES = "jetstream.provisioning.max_bytes";
265
+ var ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS = "jetstream.provisioning.num_replicas";
266
+ var ATTR_JETSTREAM_PROVISIONING_RESERVATION = "jetstream.provisioning.reservation_bytes";
263
267
  var ATTR_JETSTREAM_SELF_HEALING_REASON = "jetstream.self_healing.reason";
264
268
  var ATTR_JETSTREAM_MIGRATION_REASON = "jetstream.migration.reason";
265
269
  var ATTR_JETSTREAM_DEAD_LETTER_REASON = "jetstream.dead_letter.reason";
@@ -446,6 +450,8 @@ var resolveCaptureBody = (option) => {
446
450
  };
447
451
  };
448
452
  var resolveOtelOptions = (options = {}) => {
453
+ if (options === true) options = {};
454
+ if (options === false) options = { enabled: false };
449
455
  return {
450
456
  enabled: options.enabled ?? true,
451
457
  traces: expandTracesOption(options.traces),
@@ -527,7 +533,7 @@ var extractContext = (ctx, carrier, getter) => propagation.extract(ctx, carrier,
527
533
 
528
534
  // src/otel/tracer.ts
529
535
  import { trace } from "@opentelemetry/api";
530
- var PACKAGE_VERSION = true ? "2.11.0" : "0.0.0";
536
+ var PACKAGE_VERSION = true ? "2.12.0" : "0.0.0";
531
537
  var getTracer = () => trace.getTracer(TRACER_NAME, PACKAGE_VERSION);
532
538
 
533
539
  // src/otel/carrier.ts
@@ -1127,7 +1133,10 @@ var withProvisioningSpan = (config, ctx, op) => wrapInfra(
1127
1133
  {
1128
1134
  [ATTR_JETSTREAM_PROVISIONING_ENTITY]: ctx.entity,
1129
1135
  [ATTR_JETSTREAM_PROVISIONING_ACTION]: ctx.action,
1130
- [ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name
1136
+ [ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name,
1137
+ [ATTR_JETSTREAM_PROVISIONING_MAX_BYTES]: ctx.maxBytes,
1138
+ [ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS]: ctx.numReplicas,
1139
+ [ATTR_JETSTREAM_PROVISIONING_RESERVATION]: ctx.reservation
1131
1140
  },
1132
1141
  op
1133
1142
  );
@@ -1303,7 +1312,13 @@ var JetstreamRecordBuilder = class {
1303
1312
  * lockstep. `RESERVED_HEADERS` is defined as an all-lowercase set.
1304
1313
  */
1305
1314
  validateHeaderKey(key) {
1306
- if (RESERVED_HEADERS.has(key.toLowerCase())) {
1315
+ const normalized = key.toLowerCase();
1316
+ if (normalized.startsWith(NATS_CONTROL_HEADER_PREFIX)) {
1317
+ throw new Error(
1318
+ `Header "${key}" is reserved for the NATS server and cannot be set manually. Use setMessageId() for deduplication, ttl() for per-message expiry, and scheduleAt() for delayed delivery.`
1319
+ );
1320
+ }
1321
+ if (RESERVED_HEADERS.has(normalized)) {
1307
1322
  throw new Error(
1308
1323
  `Header "${key}" is reserved by the JetStream transport and cannot be set manually. Reserved headers: ${[...RESERVED_HEADERS].join(", ")}`
1309
1324
  );
@@ -1449,13 +1464,18 @@ var JetstreamClient = class extends ClientProxy {
1449
1464
  async dispatchEvent(packet) {
1450
1465
  if (!this.readyForPublish) await this.connect();
1451
1466
  const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
1467
+ const publishKind = detectEventKind(packet.pattern);
1468
+ if (schedule && publishKind === "ordered" /* Ordered */) {
1469
+ throw new Error(
1470
+ `scheduleAt() is not supported for ordered events (pattern: ${packet.pattern}). Scheduled delivery is available for workqueue events and broadcasts.`
1471
+ );
1472
+ }
1452
1473
  const eventSubject = this.buildEventSubject(packet.pattern);
1453
1474
  const publishSubject = schedule ? this.buildScheduleSubject(eventSubject) : eventSubject;
1454
1475
  const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
1455
1476
  const encoded = this.codec.encode(data);
1456
1477
  const effectiveMsgId = messageId ?? nuid.next();
1457
1478
  const record = packet.data instanceof JetstreamRecord ? packet.data : new JetstreamRecord(data, /* @__PURE__ */ new Map());
1458
- const publishKind = detectEventKind(packet.pattern);
1459
1479
  const declaredPattern = declaredEventPattern(packet.pattern);
1460
1480
  const streamKind = eventStreamKind(publishKind);
1461
1481
  const startedAt = performance.now();
@@ -1487,10 +1507,10 @@ var JetstreamClient = class extends ClientProxy {
1487
1507
  const ack2 = await this.connection.getJetStreamClient().publish(publishSubject, encoded, {
1488
1508
  headers: msgHeaders,
1489
1509
  msgID: effectiveMsgId,
1490
- ttl,
1491
1510
  schedule: {
1492
1511
  specification: schedule.at,
1493
- target: eventSubject
1512
+ target: eventSubject,
1513
+ ttl
1494
1514
  }
1495
1515
  });
1496
1516
  warnIfDuplicate("scheduled", ack2);
@@ -1680,13 +1700,18 @@ var JetstreamClient = class extends ClientProxy {
1680
1700
  });
1681
1701
  return;
1682
1702
  }
1683
- await context6.with(
1703
+ const ack = await context6.with(
1684
1704
  spanHandle.activeContext,
1685
1705
  () => this.connection.getJetStreamClient().publish(subject, encoded, {
1686
1706
  headers: hdrs,
1687
1707
  msgID: messageId ?? nuid.next()
1688
1708
  })
1689
1709
  );
1710
+ if (ack.duplicate) {
1711
+ throw new Error(
1712
+ `Duplicate RPC publish for ${subject}: the messageId was already used within the stream dedup window, so the reply belongs to the original request`
1713
+ );
1714
+ }
1690
1715
  this.reportPublished(declaredPattern, "cmd" /* Command */, startedAt, "success");
1691
1716
  } catch (err) {
1692
1717
  const existingTimeout = this.pendingTimeouts.get(correlationId);
@@ -1856,13 +1881,17 @@ var JetstreamClient = class extends ClientProxy {
1856
1881
  * uses a separate `_sch` namespace that is NOT matched by any consumer filter.
1857
1882
  * NATS holds the message and publishes it to the target subject after the delay.
1858
1883
  *
1884
+ * A unique per-message suffix is appended because the server stores schedules
1885
+ * as rollup messages — one active schedule per subject (ADR-51). Without it,
1886
+ * concurrent schedules of the same pattern would silently replace each other.
1887
+ *
1859
1888
  * Examples:
1860
- * - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder`
1861
- * - `broadcast.config.updated` → `broadcast._sch.config.updated`
1889
+ * - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder.<nuid>`
1890
+ * - `broadcast.config.updated` → `broadcast._sch.config.updated.<nuid>`
1862
1891
  */
1863
1892
  buildScheduleSubject(eventSubject) {
1864
1893
  if (eventSubject.startsWith("broadcast.")) {
1865
- return eventSubject.replace("broadcast.", "broadcast._sch.");
1894
+ return `${eventSubject.replace("broadcast.", "broadcast._sch.")}.${nuid.next()}`;
1866
1895
  }
1867
1896
  const targetPrefix = `${internalName(this.targetName)}.`;
1868
1897
  if (!eventSubject.startsWith(targetPrefix)) {
@@ -1874,7 +1903,7 @@ var JetstreamClient = class extends ClientProxy {
1874
1903
  throw new Error(`Event subject missing pattern segment: ${eventSubject}`);
1875
1904
  }
1876
1905
  const pattern = withoutPrefix.slice(dotIndex + 1);
1877
- return `${targetPrefix}_sch.${pattern}`;
1906
+ return `${targetPrefix}_sch.${pattern}.${nuid.next()}`;
1878
1907
  }
1879
1908
  };
1880
1909
 
@@ -1886,6 +1915,7 @@ var JsonCodec = class {
1886
1915
  return encoder.encode(JSON.stringify(data));
1887
1916
  }
1888
1917
  decode(data) {
1918
+ if (data.length === 0) return void 0;
1889
1919
  return JSON.parse(decoder.decode(data));
1890
1920
  }
1891
1921
  };
@@ -1899,6 +1929,7 @@ var MsgpackCodec = class {
1899
1929
  return this.packr.pack(data);
1900
1930
  }
1901
1931
  decode(data) {
1932
+ if (data.length === 0) return void 0;
1902
1933
  return this.packr.unpack(data);
1903
1934
  }
1904
1935
  };
@@ -2708,6 +2739,7 @@ var JetstreamMetricsService = class {
2708
2739
  activeServers = /* @__PURE__ */ new Set();
2709
2740
  async onApplicationBootstrap() {
2710
2741
  if (this.metrics !== null) return;
2742
+ if (!this.options.metrics || !this.config || !this.promClient) return;
2711
2743
  if (!this.config.register) {
2712
2744
  throw new Error(
2713
2745
  "JetstreamMetricsService requires a prom-client Registry \u2014 none was resolved by JetstreamMetricsModule."
@@ -2733,7 +2765,7 @@ var JetstreamMetricsService = class {
2733
2765
  }
2734
2766
  /** @internal Visible for tests. `0` disables polling. */
2735
2767
  getEffectivePollInterval() {
2736
- return this.config.pollInterval ?? DEFAULT_POLL_INTERVAL_MS;
2768
+ return this.config?.pollInterval ?? DEFAULT_POLL_INTERVAL_MS;
2737
2769
  }
2738
2770
  /**
2739
2771
  * NATS connects during early bootstrap, before this service subscribes to
@@ -2899,28 +2931,29 @@ var normalizeMetricsConfig = (option, promClient) => {
2899
2931
  };
2900
2932
  };
2901
2933
  var JetstreamMetricsModule = class {
2902
- static forFeature(metricsOption) {
2903
- if (!metricsOption) {
2904
- return { module: JetstreamMetricsModule, providers: [], exports: [] };
2905
- }
2934
+ static forFeature() {
2906
2935
  const promClientProvider = {
2907
2936
  provide: JETSTREAM_METRICS_PROM_CLIENT,
2908
- useFactory: async () => {
2937
+ inject: [JETSTREAM_OPTIONS],
2938
+ useFactory: async (opts) => {
2939
+ if (!opts.metrics) return null;
2909
2940
  const mod = await resolvePromClient();
2910
2941
  return { Counter: mod.Counter, Histogram: mod.Histogram, Gauge: mod.Gauge };
2911
2942
  }
2912
2943
  };
2913
2944
  const configProvider = {
2914
2945
  provide: JETSTREAM_METRICS_CONFIG,
2915
- useFactory: async () => {
2946
+ inject: [JETSTREAM_OPTIONS],
2947
+ useFactory: async (opts) => {
2948
+ if (!opts.metrics) return null;
2916
2949
  const mod = await resolvePromClient();
2917
- return normalizeMetricsConfig(metricsOption, mod);
2950
+ return normalizeMetricsConfig(opts.metrics, mod);
2918
2951
  }
2919
2952
  };
2920
2953
  const registryProvider = {
2921
2954
  provide: JETSTREAM_METRICS_REGISTRY,
2922
2955
  inject: [JETSTREAM_METRICS_CONFIG],
2923
- useFactory: (cfg) => cfg.register
2956
+ useFactory: (cfg) => cfg?.register ?? null
2924
2957
  };
2925
2958
  const serviceProvider = {
2926
2959
  provide: JetstreamMetricsService,
@@ -2977,43 +3010,11 @@ var JetstreamStrategy = class extends Server {
2977
3010
  * Called by NestJS when `connectMicroservice()` is used, or internally by the module.
2978
3011
  */
2979
3012
  async listen(callback) {
2980
- if (this.started) {
2981
- this.logger.warn("listen() called more than once \u2014 ignoring");
2982
- return;
2983
- }
2984
- this.started = true;
2985
- this.patternRegistry.registerHandlers(this.getHandlers());
2986
- const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
2987
- if (streamKinds.length > 0) {
2988
- await this.streamProvider.ensureStreams(streamKinds);
2989
- if (durableKinds.length > 0) {
2990
- const consumers = await this.consumerProvider.ensureConsumers(durableKinds);
2991
- this.populateAckWaitMap(consumers);
2992
- this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
2993
- this.messageProvider.start(consumers);
2994
- }
2995
- if (this.patternRegistry.hasOrderedHandlers()) {
2996
- const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
2997
- await this.messageProvider.startOrdered(
2998
- orderedStreamName,
2999
- this.patternRegistry.getOrderedSubjects(),
3000
- this.options.ordered
3001
- );
3002
- }
3003
- if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
3004
- this.eventRouter.start();
3005
- }
3006
- if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
3007
- await this.rpcRouter.start();
3008
- }
3009
- }
3010
- if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
3011
- await this.coreRpcServer.start();
3012
- }
3013
- if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
3014
- await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
3013
+ try {
3014
+ await this.doListen(callback);
3015
+ } catch (err) {
3016
+ callback(err);
3015
3017
  }
3016
- callback();
3017
3018
  }
3018
3019
  /** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
3019
3020
  close() {
@@ -3072,6 +3073,33 @@ var JetstreamStrategy = class extends Server {
3072
3073
  getPatternRegistry() {
3073
3074
  return this.patternRegistry;
3074
3075
  }
3076
+ async doListen(callback) {
3077
+ if (this.started) {
3078
+ this.logger.warn("listen() called more than once \u2014 ignoring");
3079
+ return;
3080
+ }
3081
+ this.started = true;
3082
+ this.patternRegistry.registerHandlers(this.getHandlers());
3083
+ const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
3084
+ if (streamKinds.length > 0) {
3085
+ await this.streamProvider.ensureStreams(streamKinds);
3086
+ let consumers = null;
3087
+ if (durableKinds.length > 0) {
3088
+ consumers = await this.consumerProvider.ensureConsumers(durableKinds);
3089
+ this.populateAckWaitMap(consumers);
3090
+ this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
3091
+ }
3092
+ await this.startRouters();
3093
+ await this.startConsumption(consumers);
3094
+ }
3095
+ if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
3096
+ await this.coreRpcServer.start();
3097
+ }
3098
+ if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
3099
+ await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
3100
+ }
3101
+ callback();
3102
+ }
3075
3103
  /** Determine which streams and durable consumers are needed. */
3076
3104
  resolveRequiredKinds() {
3077
3105
  const streams = [];
@@ -3093,7 +3121,29 @@ var JetstreamStrategy = class extends Server {
3093
3121
  }
3094
3122
  return { streams, durableConsumers };
3095
3123
  }
3096
- /** Populate the shared ack_wait map from actual NATS consumer configs. */
3124
+ /** Subscribe the event and RPC routers to the message subjects. */
3125
+ async startRouters() {
3126
+ if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
3127
+ this.eventRouter.start();
3128
+ }
3129
+ if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
3130
+ await this.rpcRouter.start();
3131
+ }
3132
+ }
3133
+ /** Begin durable and ordered consumption; routers must already be subscribed. */
3134
+ async startConsumption(consumers) {
3135
+ if (consumers !== null) {
3136
+ this.messageProvider.start(consumers);
3137
+ }
3138
+ if (this.patternRegistry.hasOrderedHandlers()) {
3139
+ const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
3140
+ await this.messageProvider.startOrdered(
3141
+ orderedStreamName,
3142
+ this.patternRegistry.getOrderedSubjects(),
3143
+ this.options.ordered
3144
+ );
3145
+ }
3146
+ }
3097
3147
  populateAckWaitMap(consumers) {
3098
3148
  for (const [kind, info] of consumers) {
3099
3149
  if (info.config.ack_wait) {
@@ -3332,6 +3382,15 @@ var serializeError = (err) => {
3332
3382
  return err;
3333
3383
  };
3334
3384
 
3385
+ // src/utils/settle-quietly.ts
3386
+ var settleQuietly = (logger5, label, action) => {
3387
+ try {
3388
+ action();
3389
+ } catch (err) {
3390
+ logger5.error(label, err);
3391
+ }
3392
+ };
3393
+
3335
3394
  // src/utils/unwrap-result.ts
3336
3395
  import { isObservable } from "rxjs";
3337
3396
  var unwrapResult = (result) => {
@@ -3497,16 +3556,168 @@ var CoreRpcServer = class {
3497
3556
 
3498
3557
  // src/server/infrastructure/stream.provider.ts
3499
3558
  import { Logger as Logger13 } from "@nestjs/common";
3500
- import { JetStreamApiError as JetStreamApiError2 } from "@nats-io/jetstream";
3559
+ import {
3560
+ JetStreamApiError as JetStreamApiError2,
3561
+ RetentionPolicy as RetentionPolicy2,
3562
+ StorageType as StorageType4
3563
+ } from "@nats-io/jetstream";
3501
3564
 
3502
3565
  // src/server/infrastructure/nats-error-codes.ts
3503
3566
  var NatsErrorCode = /* @__PURE__ */ ((NatsErrorCode2) => {
3504
3567
  NatsErrorCode2[NatsErrorCode2["ConsumerNotFound"] = 10014] = "ConsumerNotFound";
3505
3568
  NatsErrorCode2[NatsErrorCode2["ConsumerAlreadyExists"] = 10148] = "ConsumerAlreadyExists";
3506
3569
  NatsErrorCode2[NatsErrorCode2["StreamNotFound"] = 10059] = "StreamNotFound";
3570
+ NatsErrorCode2[NatsErrorCode2["StorageResourcesExceeded"] = 10047] = "StorageResourcesExceeded";
3571
+ NatsErrorCode2[NatsErrorCode2["NoSuitablePeers"] = 10005] = "NoSuitablePeers";
3507
3572
  return NatsErrorCode2;
3508
3573
  })(NatsErrorCode || {});
3509
3574
 
3575
+ // src/server/infrastructure/provisioning-budget.ts
3576
+ import { StorageType as StorageType2 } from "@nats-io/jetstream";
3577
+ var GIB = 1024 ** 3;
3578
+ var fmt = (bytes) => `${(bytes / GIB).toFixed(2)} GiB`;
3579
+ var resolveTierBudget = (info, replicas) => {
3580
+ const tier = info.tiers?.[`R${replicas}`];
3581
+ const limits = tier?.limits ?? info.limits;
3582
+ return {
3583
+ maxStorage: limits?.max_storage ?? 0,
3584
+ reserved: tier?.reserved_storage ?? info.reserved_storage ?? 0,
3585
+ tiered: tier !== void 0
3586
+ };
3587
+ };
3588
+ var groupByReplicas = (reservations) => {
3589
+ const groups = /* @__PURE__ */ new Map();
3590
+ for (const r of reservations) {
3591
+ if (r.storage !== StorageType2.File) continue;
3592
+ const prev = groups.get(r.numReplicas) ?? 0;
3593
+ groups.set(r.numReplicas, prev + r.maxBytes * r.numReplicas);
3594
+ }
3595
+ return groups;
3596
+ };
3597
+ var assertStorageBudget = async (jsm, serviceName, reservations, logger5) => {
3598
+ try {
3599
+ const info = await jsm.getAccountInfo();
3600
+ const groups = groupByReplicas(reservations);
3601
+ let limitNotSetWarned = false;
3602
+ let okReserved = 0;
3603
+ let anyWarned = false;
3604
+ for (const [replicas, incremental] of groups) {
3605
+ const { maxStorage, reserved, tiered } = resolveTierBudget(info, replicas);
3606
+ const tierNote = tiered ? ` (tier R${replicas})` : "";
3607
+ if (maxStorage <= 0) {
3608
+ if (!limitNotSetWarned) {
3609
+ limitNotSetWarned = true;
3610
+ logger5.warn(
3611
+ `Storage preflight for "${serviceName}": account file-storage limit not set (max_storage=${maxStorage}); the server max_file_store cannot be verified from the client.`
3612
+ );
3613
+ }
3614
+ continue;
3615
+ }
3616
+ const remaining = maxStorage - reserved;
3617
+ if (incremental > remaining) {
3618
+ anyWarned = true;
3619
+ logger5.warn(
3620
+ `Storage preflight for "${serviceName}"${tierNote}: needs ~${fmt(incremental)} but only ~${fmt(remaining)} remains (reserved ${fmt(reserved)} / limit ${fmt(maxStorage)}). Provisioning will likely fail with insufficient storage. Lower max_bytes/num_replicas, or raise the account/server storage limit.`
3621
+ );
3622
+ continue;
3623
+ }
3624
+ okReserved += incremental;
3625
+ }
3626
+ if (!anyWarned && !limitNotSetWarned && okReserved > 0) {
3627
+ logger5.log(
3628
+ `Storage preflight for "${serviceName}" OK: reserving ~${fmt(okReserved)} across file-backed streams within account limits.`
3629
+ );
3630
+ }
3631
+ } catch (err) {
3632
+ logger5.debug(`Storage preflight skipped \u2014 account info unavailable: ${String(err)}`);
3633
+ }
3634
+ };
3635
+
3636
+ // src/server/infrastructure/provisioning-error.ts
3637
+ var REMEDIATION = {
3638
+ [10047 /* StorageResourcesExceeded */]: "Aggregate stream reservation exceeds the server `max_file_store` (or account `max_storage`). Lower `max_bytes`/`num_replicas` for this service, or raise `max_file_store` on the NATS servers.",
3639
+ [10005 /* NoSuitablePeers */]: "Fewer healthy peers than `num_replicas`, or no peer has enough reserved storage headroom. Reduce replicas or add/repair cluster nodes."
3640
+ };
3641
+ var GENERIC_REMEDIATION = "Inspect the NATS server logs and JetStream account limits for the underlying cause.";
3642
+ var JetstreamProvisioningError = class _JetstreamProvisioningError extends Error {
3643
+ entity;
3644
+ target;
3645
+ kind;
3646
+ errCode;
3647
+ errDescription;
3648
+ remediation;
3649
+ maxBytes;
3650
+ numReplicas;
3651
+ reservation;
3652
+ constructor(fields) {
3653
+ const reservationNote = fields.reservation !== void 0 ? ` reservation=${fields.reservation}B (max_bytes=${fields.maxBytes}B \xD7 replicas=${fields.numReplicas}).` : "";
3654
+ super(
3655
+ `JetStream ${fields.entity} provisioning failed for "${fields.target}" (kind=${fields.kind}): ${fields.errDescription} [err_code=${fields.errCode}].${reservationNote} ${fields.remediation}`,
3656
+ { cause: fields.cause }
3657
+ );
3658
+ this.name = "JetstreamProvisioningError";
3659
+ this.entity = fields.entity;
3660
+ this.target = fields.target;
3661
+ this.kind = fields.kind;
3662
+ this.errCode = fields.errCode;
3663
+ this.errDescription = fields.errDescription;
3664
+ this.remediation = fields.remediation;
3665
+ this.maxBytes = fields.maxBytes;
3666
+ this.numReplicas = fields.numReplicas;
3667
+ this.reservation = fields.reservation;
3668
+ Object.setPrototypeOf(this, _JetstreamProvisioningError.prototype);
3669
+ }
3670
+ };
3671
+ var mapProvisioningError = (err, ctx) => {
3672
+ const api = err.apiError();
3673
+ const remediation = REMEDIATION[api.err_code] ?? GENERIC_REMEDIATION;
3674
+ const reservation = ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0;
3675
+ return new JetstreamProvisioningError({
3676
+ entity: ctx.entity,
3677
+ target: ctx.name,
3678
+ kind: ctx.kind,
3679
+ errCode: api.err_code,
3680
+ errDescription: api.description,
3681
+ remediation,
3682
+ maxBytes: ctx.maxBytes,
3683
+ numReplicas: ctx.numReplicas,
3684
+ reservation,
3685
+ cause: err
3686
+ });
3687
+ };
3688
+
3689
+ // src/server/infrastructure/provisioning-summary.ts
3690
+ import { StorageType as StorageType3 } from "@nats-io/jetstream";
3691
+ var GIB2 = 1024 ** 3;
3692
+ var NANOS_PER_SECOND = 1e9;
3693
+ var NANOS_PER_HOUR = 3600 * NANOS_PER_SECOND;
3694
+ var NANOS_PER_DAY = 86400 * NANOS_PER_SECOND;
3695
+ var formatBytes = (bytes) => {
3696
+ if (bytes <= 0) return "0 B";
3697
+ return `${(bytes / GIB2).toFixed(2)} GiB`;
3698
+ };
3699
+ var formatAge = (nanos) => {
3700
+ if (nanos <= 0) return "unlimited";
3701
+ if (nanos >= NANOS_PER_DAY) return `${(nanos / NANOS_PER_DAY).toFixed(1)}d`;
3702
+ if (nanos >= NANOS_PER_HOUR) return `${(nanos / NANOS_PER_HOUR).toFixed(1)}h`;
3703
+ return `${(nanos / NANOS_PER_SECOND).toFixed(0)}s`;
3704
+ };
3705
+ var formatProvisioningSummary = (serviceName, reservations) => {
3706
+ const lines = [`Provisioning ${reservations.length} stream(s) for "${serviceName}":`];
3707
+ let totalFileMaxBytes = 0;
3708
+ for (const r of reservations) {
3709
+ if (r.storage === StorageType3.File) totalFileMaxBytes += r.maxBytes;
3710
+ const clusterReservation = r.maxBytes * r.numReplicas;
3711
+ lines.push(
3712
+ ` \u2022 ${r.name} [${r.kind}] storage=${r.storage} replicas=${r.numReplicas} max_bytes=${formatBytes(r.maxBytes)} max_age=${formatAge(r.maxAge)} retention=${r.retention} \u2192 cluster reservation ${formatBytes(clusterReservation)}`
3713
+ );
3714
+ }
3715
+ lines.push(
3716
+ ` \u03A3 per-node file-backed footprint \u2248 ${formatBytes(totalFileMaxBytes)} (sum of max_bytes; worst case replicas = nodes). Ensure the NATS server max_file_store accommodates the sum across ALL services.`
3717
+ );
3718
+ return lines.join("\n");
3719
+ };
3720
+
3510
3721
  // src/server/infrastructure/stream-config-diff.ts
3511
3722
  var TRANSPORT_CONTROLLED_PROPERTIES = /* @__PURE__ */ new Set([
3512
3723
  "retention"
@@ -3564,85 +3775,201 @@ var isEqual = (a, b) => {
3564
3775
 
3565
3776
  // src/server/infrastructure/stream-migration.ts
3566
3777
  import { Logger as Logger12 } from "@nestjs/common";
3567
- import { JetStreamApiError } from "@nats-io/jetstream";
3778
+ import {
3779
+ JetStreamApiError
3780
+ } from "@nats-io/jetstream";
3568
3781
  var MIGRATION_BACKUP_SUFFIX = "__migration_backup";
3569
3782
  var DEFAULT_SOURCING_TIMEOUT_MS = 3e4;
3570
3783
  var SOURCING_POLL_INTERVAL_MS = 100;
3784
+ var DEFAULT_PEER_WAIT_MS = 6e4;
3785
+ var ACTIVE_MIGRATION_GRACE_MS = 9e4;
3786
+ var MIGRATION_STARTED_AT_KEY = "nestjs-jetstream-migration-started-at";
3571
3787
  var StreamMigration = class {
3572
- constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS) {
3788
+ constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS, peerWaitMs = DEFAULT_PEER_WAIT_MS) {
3573
3789
  this.sourcingTimeoutMs = sourcingTimeoutMs;
3790
+ this.peerWaitMs = peerWaitMs;
3574
3791
  }
3575
3792
  logger = new Logger12("Jetstream:Stream");
3576
3793
  async migrate(jsm, streamName2, newConfig) {
3577
3794
  const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
3578
3795
  const startTime = Date.now();
3796
+ const peerFinished = await this.waitOutPeerMigration(jsm, backupName);
3579
3797
  const currentInfo = await jsm.streams.info(streamName2);
3580
- await this.cleanupOrphanedBackup(jsm, backupName);
3581
- const messageCount = currentInfo.state.messages;
3798
+ if (peerFinished && !compareStreamConfig(currentInfo.config, newConfig).hasImmutableChanges) {
3799
+ this.logger.log(`Stream ${streamName2}: migration completed by another instance`);
3800
+ await jsm.streams.update(streamName2, newConfig);
3801
+ return;
3802
+ }
3582
3803
  this.logger.log(`Stream ${streamName2}: destructive migration started`);
3583
3804
  let originalDeleted = false;
3805
+ let drainedCount = 0;
3584
3806
  try {
3585
- if (messageCount > 0) {
3586
- this.logger.log(` Phase 1/4: Backing up ${messageCount} messages \u2192 ${backupName}`);
3807
+ this.logger.log(` Phase 1/4: Quiescing ${streamName2} (publishes rejected during migration)`);
3808
+ await jsm.streams.update(streamName2, { ...currentInfo.config, subjects: [] });
3809
+ drainedCount = (await jsm.streams.info(streamName2)).state.messages;
3810
+ if (drainedCount > 0) {
3811
+ this.logger.log(` Phase 2/4: Backing up ${drainedCount} messages \u2192 ${backupName}`);
3587
3812
  await jsm.streams.add({
3588
3813
  ...currentInfo.config,
3589
3814
  name: backupName,
3590
3815
  subjects: [],
3591
- sources: [{ name: streamName2 }]
3816
+ sources: [{ name: streamName2 }],
3817
+ metadata: { [MIGRATION_STARTED_AT_KEY]: (/* @__PURE__ */ new Date()).toISOString() }
3592
3818
  });
3593
- await this.waitForSourcing(jsm, backupName, messageCount);
3819
+ await this.waitForSourceDrained(jsm, backupName, streamName2, drainedCount);
3594
3820
  }
3595
- this.logger.log(` Phase 2/4: Deleting old stream`);
3821
+ this.logger.log(` Phase 3/4: Recreating ${streamName2} with the new config`);
3596
3822
  await jsm.streams.delete(streamName2);
3597
3823
  originalDeleted = true;
3598
- this.logger.log(` Phase 3/4: Creating stream with new config`);
3599
3824
  await jsm.streams.add(newConfig);
3600
- if (messageCount > 0) {
3601
- const backupInfo = await jsm.streams.info(backupName);
3602
- await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
3603
- this.logger.log(` Phase 4/4: Restoring ${messageCount} messages from backup`);
3604
- await jsm.streams.update(streamName2, {
3605
- ...newConfig,
3606
- sources: [{ name: backupName }]
3607
- });
3608
- await this.waitForSourcing(jsm, streamName2, messageCount);
3609
- await jsm.streams.update(streamName2, { ...newConfig, sources: [] });
3610
- await jsm.streams.delete(backupName);
3825
+ if (drainedCount > 0) {
3826
+ this.logger.log(` Phase 4/4: Restoring ${drainedCount} messages from backup`);
3827
+ await this.restoreFromBackup(jsm, streamName2, newConfig, backupName);
3611
3828
  }
3612
3829
  } catch (err) {
3613
- if (originalDeleted && messageCount > 0) {
3830
+ if (originalDeleted) {
3614
3831
  this.logger.error(
3615
- `Migration failed after deleting original stream. Backup ${backupName} preserved for manual recovery.`
3832
+ `Migration of ${streamName2} failed after the original was deleted. Backup ${backupName} preserved \u2014 restoration resumes on the next startup.`
3616
3833
  );
3617
3834
  } else {
3618
- await this.cleanupOrphanedBackup(jsm, backupName);
3835
+ await this.rollbackBeforeDelete(jsm, streamName2, currentInfo, backupName);
3619
3836
  }
3620
3837
  throw err;
3621
3838
  }
3622
3839
  const durationMs = Date.now() - startTime;
3623
3840
  this.logger.log(
3624
- `Stream ${streamName2}: migration complete (${messageCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
3841
+ `Stream ${streamName2}: migration complete (${drainedCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
3842
+ );
3843
+ }
3844
+ /**
3845
+ * Detect and finish a migration that a previous process left unfinished.
3846
+ * Safe against concurrent instances: a backup fresh enough to belong to a
3847
+ * live migration is left alone.
3848
+ *
3849
+ * @returns true when recovery work was performed.
3850
+ */
3851
+ async recoverInterrupted(jsm, streamName2, desiredConfig) {
3852
+ const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
3853
+ const backupInfo = await this.tryInfo(jsm, backupName);
3854
+ if (backupInfo === null) return false;
3855
+ if (this.isPeerMigrationActive(backupInfo)) return false;
3856
+ const streamInfo = await this.tryInfo(jsm, streamName2);
3857
+ if (streamInfo === null) {
3858
+ this.logger.warn(`Stream ${streamName2}: resuming interrupted migration from ${backupName}`);
3859
+ await jsm.streams.add(desiredConfig);
3860
+ if (backupInfo.state.messages > 0) {
3861
+ await this.restoreFromBackup(jsm, streamName2, desiredConfig, backupName);
3862
+ } else {
3863
+ await jsm.streams.delete(backupName);
3864
+ }
3865
+ return true;
3866
+ }
3867
+ const hasBackupSource = (streamInfo.config.sources ?? []).some((s) => s.name === backupName);
3868
+ if (hasBackupSource) {
3869
+ this.logger.warn(`Stream ${streamName2}: finishing interrupted restore from ${backupName}`);
3870
+ await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
3871
+ await jsm.streams.delete(backupName);
3872
+ await jsm.streams.update(streamName2, { ...streamInfo.config, sources: [] });
3873
+ return true;
3874
+ }
3875
+ if (backupInfo.state.messages === 0) {
3876
+ this.logger.warn(`Removing empty migration backup ${backupName}`);
3877
+ await jsm.streams.delete(backupName);
3878
+ return true;
3879
+ }
3880
+ this.logger.warn(
3881
+ `Stream ${streamName2}: restoring ${backupInfo.state.messages} messages from stale ${backupName}`
3625
3882
  );
3883
+ await this.restoreFromBackup(
3884
+ jsm,
3885
+ streamName2,
3886
+ { ...streamInfo.config, name: streamName2, subjects: streamInfo.config.subjects },
3887
+ backupName
3888
+ );
3889
+ return true;
3890
+ }
3891
+ /** Attach the backup as a source, drain it fully, then clean up. */
3892
+ async restoreFromBackup(jsm, streamName2, streamConfig, backupName) {
3893
+ const backupInfo = await jsm.streams.info(backupName);
3894
+ if ((backupInfo.config.sources ?? []).length > 0) {
3895
+ await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
3896
+ }
3897
+ await jsm.streams.update(streamName2, { ...streamConfig, sources: [{ name: backupName }] });
3898
+ await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
3899
+ await jsm.streams.delete(backupName);
3900
+ await jsm.streams.update(streamName2, { ...streamConfig, sources: [] });
3626
3901
  }
3627
- async waitForSourcing(jsm, streamName2, expectedCount) {
3902
+ /**
3903
+ * Wait until `sourceName` is fully drained into `streamName`. Lag-based, so
3904
+ * concurrent live publishes to the target cannot fake completion the way a
3905
+ * bare message-count comparison could. A freshly attached source reports
3906
+ * `lag: 0, active: -1` before its first sync — `active >= 0` filters that
3907
+ * false positive out (verified against NATS 2.12.6).
3908
+ */
3909
+ async waitForSourceDrained(jsm, streamName2, sourceName, minimumMessages) {
3628
3910
  const deadline = Date.now() + this.sourcingTimeoutMs;
3629
3911
  while (Date.now() < deadline) {
3630
3912
  const info = await jsm.streams.info(streamName2);
3631
- if (info.state.messages >= expectedCount) return;
3913
+ const source = (info.sources ?? []).find((s) => s.name === sourceName);
3914
+ if (source !== void 0 && source.active >= 0 && source.lag === 0 && info.state.messages >= minimumMessages) {
3915
+ return;
3916
+ }
3632
3917
  await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
3633
3918
  }
3634
3919
  throw new Error(
3635
- `Stream sourcing timeout: ${streamName2} has not reached ${expectedCount} messages within ${this.sourcingTimeoutMs / 1e3}s`
3920
+ `Stream sourcing timeout: ${sourceName} has not drained into ${streamName2} within ${this.sourcingTimeoutMs / 1e3}s. The backup is preserved; restoration resumes on the next startup.`
3636
3921
  );
3637
3922
  }
3638
- async cleanupOrphanedBackup(jsm, backupName) {
3923
+ /**
3924
+ * A backup already present when migrate() begins belongs to another
3925
+ * instance migrating right now (rolling deploy) — wait for it to finish.
3926
+ * Stale leftovers are handled by recoverInterrupted() before migrate() runs,
3927
+ * so a timeout here means something is genuinely stuck.
3928
+ *
3929
+ * @returns true when a peer's backup was observed and cleared.
3930
+ */
3931
+ async waitOutPeerMigration(jsm, backupName) {
3932
+ if (await this.tryInfo(jsm, backupName) === null) return false;
3933
+ this.logger.warn(
3934
+ `Migration backup ${backupName} exists \u2014 another instance appears to be migrating; waiting`
3935
+ );
3936
+ const deadline = Date.now() + this.peerWaitMs;
3937
+ while (Date.now() < deadline) {
3938
+ if (await this.tryInfo(jsm, backupName) === null) return true;
3939
+ await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS * 5));
3940
+ }
3941
+ throw new Error(
3942
+ `Migration backup ${backupName} did not clear within ${this.peerWaitMs / 1e3}s. If no other instance is migrating, recover or remove the backup manually.`
3943
+ );
3944
+ }
3945
+ /** Failure before the original was deleted: undo the quiesce, drop our backup. */
3946
+ async rollbackBeforeDelete(jsm, streamName2, originalInfo, backupName) {
3639
3947
  try {
3640
- await jsm.streams.info(backupName);
3641
- this.logger.warn(`Found orphaned migration backup stream: ${backupName}, cleaning up`);
3642
- await jsm.streams.delete(backupName);
3948
+ await jsm.streams.update(streamName2, { ...originalInfo.config });
3949
+ const backupInfo = await this.tryInfo(jsm, backupName);
3950
+ if (backupInfo !== null) {
3951
+ await jsm.streams.delete(backupName);
3952
+ }
3953
+ } catch (rollbackErr) {
3954
+ this.logger.error(
3955
+ `Rollback of ${streamName2} after a failed migration also failed \u2014 the stream may be left quiesced:`,
3956
+ rollbackErr
3957
+ );
3958
+ }
3959
+ }
3960
+ isPeerMigrationActive(backupInfo) {
3961
+ const startedAt = backupInfo.config.metadata?.[MIGRATION_STARTED_AT_KEY];
3962
+ if (!startedAt) return false;
3963
+ const startedMs = Date.parse(startedAt);
3964
+ if (Number.isNaN(startedMs)) return false;
3965
+ return Date.now() - startedMs < ACTIVE_MIGRATION_GRACE_MS;
3966
+ }
3967
+ async tryInfo(jsm, name) {
3968
+ try {
3969
+ return await jsm.streams.info(name);
3643
3970
  } catch (err) {
3644
3971
  if (err instanceof JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
3645
- return;
3972
+ return null;
3646
3973
  }
3647
3974
  throw err;
3648
3975
  }
@@ -3673,6 +4000,15 @@ var StreamProvider = class {
3673
4000
  */
3674
4001
  async ensureStreams(kinds) {
3675
4002
  const jsm = await this.connection.getJetStreamManager();
4003
+ const reservations = kinds.map((kind) => this.buildReservation(kind, this.buildConfig(kind)));
4004
+ if (this.options.dlq) {
4005
+ reservations.push(this.buildReservation("dlq", this.buildDlqConfig()));
4006
+ }
4007
+ this.logger.log(`
4008
+ ${formatProvisioningSummary(this.options.name, reservations)}`);
4009
+ if (this.options.provisioning?.preflightStorageCheck) {
4010
+ await assertStorageBudget(jsm, this.options.name, reservations, this.logger);
4011
+ }
3676
4012
  await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
3677
4013
  if (this.options.dlq) {
3678
4014
  await this.ensureDlqStream(jsm);
@@ -3709,6 +4045,7 @@ var StreamProvider = class {
3709
4045
  /** Ensure a single stream exists, creating or updating as needed. */
3710
4046
  async ensureStream(jsm, kind) {
3711
4047
  const config = this.buildConfig(kind);
4048
+ const ctx = this.errorContext(kind, config);
3712
4049
  return withProvisioningSpan(
3713
4050
  this.otel,
3714
4051
  {
@@ -3716,17 +4053,21 @@ var StreamProvider = class {
3716
4053
  endpoint: this.otelEndpoint,
3717
4054
  entity: "stream",
3718
4055
  name: config.name,
3719
- action: "ensure"
4056
+ action: "ensure",
4057
+ maxBytes: ctx.maxBytes,
4058
+ numReplicas: ctx.numReplicas,
4059
+ reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
3720
4060
  },
3721
4061
  async () => {
3722
4062
  this.logger.log(`Ensuring stream: ${config.name}`);
4063
+ await this.migration.recoverInterrupted(jsm, config.name, config);
3723
4064
  try {
3724
4065
  const currentInfo = await jsm.streams.info(config.name);
3725
- return await this.handleExistingStream(jsm, currentInfo, config);
4066
+ return await this.handleExistingStream(jsm, currentInfo, config, ctx);
3726
4067
  } catch (err) {
3727
4068
  if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
3728
4069
  this.logger.log(`Creating stream: ${config.name}`);
3729
- return await jsm.streams.add(config);
4070
+ return await this.runStreamOp(ctx, () => jsm.streams.add(config));
3730
4071
  }
3731
4072
  throw err;
3732
4073
  }
@@ -3736,6 +4077,7 @@ var StreamProvider = class {
3736
4077
  /** Ensure a dead-letter queue stream exists, creating or updating as needed. */
3737
4078
  async ensureDlqStream(jsm) {
3738
4079
  const config = this.buildDlqConfig();
4080
+ const ctx = this.errorContext("dlq", config);
3739
4081
  return withProvisioningSpan(
3740
4082
  this.otel,
3741
4083
  {
@@ -3743,24 +4085,30 @@ var StreamProvider = class {
3743
4085
  endpoint: this.otelEndpoint,
3744
4086
  entity: "stream",
3745
4087
  name: config.name,
3746
- action: "ensure"
4088
+ action: "ensure",
4089
+ maxBytes: ctx.maxBytes,
4090
+ numReplicas: ctx.numReplicas,
4091
+ reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
3747
4092
  },
3748
4093
  async () => {
3749
4094
  this.logger.log(`Ensuring DLQ stream: ${config.name}`);
3750
4095
  try {
3751
4096
  const currentInfo = await jsm.streams.info(config.name);
3752
- return await this.handleExistingStream(jsm, currentInfo, config);
4097
+ return await this.handleExistingStream(jsm, currentInfo, config, ctx);
3753
4098
  } catch (err) {
3754
4099
  if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
3755
4100
  this.logger.log(`Creating DLQ stream: ${config.name}`);
3756
- return await jsm.streams.add(config);
4101
+ return await this.runStreamOp(ctx, () => jsm.streams.add(config));
3757
4102
  }
3758
4103
  throw err;
3759
4104
  }
3760
4105
  }
3761
4106
  );
3762
4107
  }
3763
- async handleExistingStream(jsm, currentInfo, config) {
4108
+ async handleExistingStream(jsm, currentInfo, config, ctx) {
4109
+ if (this.isSharedStream(config.name)) {
4110
+ config.subjects = [.../* @__PURE__ */ new Set([...config.subjects, ...currentInfo.config.subjects])];
4111
+ }
3764
4112
  const diff = compareStreamConfig(currentInfo.config, config);
3765
4113
  if (!diff.hasChanges) {
3766
4114
  this.logger.debug(`Stream ${config.name}: no config changes`);
@@ -3775,7 +4123,7 @@ var StreamProvider = class {
3775
4123
  }
3776
4124
  if (!diff.hasImmutableChanges) {
3777
4125
  this.logger.debug(`Stream exists, updating: ${config.name}`);
3778
- return await jsm.streams.update(config.name, config);
4126
+ return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, config));
3779
4127
  }
3780
4128
  if (!this.options.allowDestructiveMigration) {
3781
4129
  this.logger.warn(
@@ -3783,10 +4131,15 @@ var StreamProvider = class {
3783
4131
  );
3784
4132
  if (diff.hasMutableChanges) {
3785
4133
  const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
3786
- return await jsm.streams.update(config.name, mutableConfig);
4134
+ return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, mutableConfig));
3787
4135
  }
3788
4136
  return currentInfo;
3789
4137
  }
4138
+ if (this.isSharedStream(config.name)) {
4139
+ throw new Error(
4140
+ `Stream ${config.name} is shared across services and cannot be destructively migrated: recreating it would delete every other service's durable broadcast consumers and replay retained history to them. Coordinate a manual migration instead.`
4141
+ );
4142
+ }
3790
4143
  await withMigrationSpan(
3791
4144
  this.otel,
3792
4145
  {
@@ -3825,11 +4178,47 @@ var StreamProvider = class {
3825
4178
  }
3826
4179
  }
3827
4180
  }
4181
+ buildReservation(kind, config) {
4182
+ const mb = config.max_bytes;
4183
+ return {
4184
+ kind,
4185
+ name: config.name,
4186
+ storage: config.storage ?? StorageType4.File,
4187
+ numReplicas: config.num_replicas ?? 1,
4188
+ maxBytes: mb !== void 0 && mb >= 0 ? mb : 0,
4189
+ // NATS uses -1 for unlimited
4190
+ maxAge: config.max_age ?? 0,
4191
+ retention: config.retention ?? RetentionPolicy2.Limits
4192
+ };
4193
+ }
4194
+ errorContext(kind, config) {
4195
+ return {
4196
+ entity: "stream",
4197
+ name: config.name,
4198
+ kind,
4199
+ maxBytes: config.max_bytes,
4200
+ numReplicas: config.num_replicas ?? 1
4201
+ };
4202
+ }
4203
+ async runStreamOp(ctx, op) {
4204
+ try {
4205
+ return await op();
4206
+ } catch (err) {
4207
+ if (err instanceof JetStreamApiError2) {
4208
+ throw mapProvisioningError(err, ctx);
4209
+ }
4210
+ throw err;
4211
+ }
4212
+ }
4213
+ /** The broadcast stream is global — every service in the cluster shares it. */
4214
+ isSharedStream(name) {
4215
+ return name === this.getStreamName("broadcast" /* Broadcast */);
4216
+ }
3828
4217
  /** Build the full stream config by merging defaults with user overrides. */
3829
4218
  buildConfig(kind) {
3830
4219
  const name = this.getStreamName(kind);
3831
4220
  const subjects = this.getSubjects(kind);
3832
- const description = `JetStream ${kind} stream for ${this.options.name}`;
4221
+ const description = kind === "broadcast" /* Broadcast */ ? "JetStream broadcast stream (shared across services)" : `JetStream ${kind} stream for ${this.options.name}`;
3833
4222
  const defaults = this.getDefaults(kind);
3834
4223
  const overrides = this.getOverrides(kind);
3835
4224
  return {
@@ -3971,15 +4360,16 @@ var ConsumerProvider = class {
3971
4360
  },
3972
4361
  async () => {
3973
4362
  this.logger.log(`Ensuring consumer: ${name} on stream: ${stream}`);
4363
+ const ctx = { entity: "consumer", name, kind };
3974
4364
  try {
3975
4365
  await jsm.consumers.info(stream, name);
3976
4366
  this.logger.debug(`Consumer exists, updating: ${name}`);
3977
- return await jsm.consumers.update(stream, name, config);
4367
+ return await this.runConsumerOp(ctx, () => jsm.consumers.update(stream, name, config));
3978
4368
  } catch (err) {
3979
4369
  if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
3980
4370
  throw err;
3981
4371
  }
3982
- return await this.createConsumer(jsm, stream, name, config);
4372
+ return await this.createConsumer(jsm, stream, name, kind, config);
3983
4373
  }
3984
4374
  }
3985
4375
  );
@@ -4019,7 +4409,7 @@ var ConsumerProvider = class {
4019
4409
  if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
4020
4410
  throw err;
4021
4411
  }
4022
- return await this.createConsumer(jsm, stream, name, config);
4412
+ return await this.createConsumer(jsm, stream, name, kind, config);
4023
4413
  }
4024
4414
  }
4025
4415
  );
@@ -4046,8 +4436,9 @@ var ConsumerProvider = class {
4046
4436
  /**
4047
4437
  * Create a consumer, handling the race where another pod creates it first.
4048
4438
  */
4049
- async createConsumer(jsm, stream, name, config) {
4439
+ async createConsumer(jsm, stream, name, kind, config) {
4050
4440
  this.logger.log(`Creating consumer: ${name}`);
4441
+ const ctx = { entity: "consumer", name, kind };
4051
4442
  try {
4052
4443
  return await jsm.consumers.add(stream, config);
4053
4444
  } catch (addErr) {
@@ -4055,9 +4446,22 @@ var ConsumerProvider = class {
4055
4446
  this.logger.debug(`Consumer ${name} created by another pod, using existing`);
4056
4447
  return await jsm.consumers.info(stream, name);
4057
4448
  }
4449
+ if (addErr instanceof JetStreamApiError3) {
4450
+ throw mapProvisioningError(addErr, ctx);
4451
+ }
4058
4452
  throw addErr;
4059
4453
  }
4060
4454
  }
4455
+ async runConsumerOp(ctx, op) {
4456
+ try {
4457
+ return await op();
4458
+ } catch (err) {
4459
+ if (err instanceof JetStreamApiError3) {
4460
+ throw mapProvisioningError(err, ctx);
4461
+ }
4462
+ throw err;
4463
+ }
4464
+ }
4061
4465
  /** Build consumer config by merging defaults with user overrides. */
4062
4466
  // eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
4063
4467
  buildConfig(kind) {
@@ -4518,6 +4922,7 @@ var MetadataProvider = class {
4518
4922
  // src/server/routing/event.router.ts
4519
4923
  import { Logger as Logger17 } from "@nestjs/common";
4520
4924
  import { headers as natsHeaders3 } from "@nats-io/transport-node";
4925
+ var DLQ_PUBLISH_ATTEMPTS = 3;
4521
4926
  var eventConsumeKindFor = (kind) => {
4522
4927
  if (kind === "broadcast" /* Broadcast */) return "broadcast" /* Broadcast */;
4523
4928
  if (kind === "ordered" /* Ordered */) return "ordered" /* Ordered */;
@@ -4604,33 +5009,80 @@ var EventRouter = class {
4604
5009
  return msg.info.deliveryCount >= maxDeliver;
4605
5010
  };
4606
5011
  const handleDeadLetter = hasDlqCheck ? (msg, data, err) => this.handleDeadLetter(msg, data, err) : null;
4607
- const settleSuccess = (msg, ctx) => {
4608
- if (ctx.shouldTerminate) msg.term(ctx.terminateReason);
4609
- else if (ctx.shouldRetry) msg.nak(ctx.retryDelay);
4610
- else msg.ack();
5012
+ const settleSuccess = (msg, ctx, data) => {
5013
+ if (ctx.shouldTerminate) {
5014
+ settleQuietly(logger5, `Failed to term ${msg.subject}:`, () => {
5015
+ msg.term(ctx.terminateReason);
5016
+ });
5017
+ return void 0;
5018
+ }
5019
+ if (ctx.shouldRetry) {
5020
+ if (handleDeadLetter !== null && isDeadLetter(msg)) {
5021
+ return handleDeadLetter(
5022
+ msg,
5023
+ data,
5024
+ new Error("Retry requested on the final delivery attempt")
5025
+ );
5026
+ }
5027
+ settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
5028
+ msg.nak(ctx.retryDelay);
5029
+ });
5030
+ return void 0;
5031
+ }
5032
+ settleQuietly(logger5, `Failed to ack ${msg.subject}:`, () => {
5033
+ msg.ack();
5034
+ });
5035
+ return void 0;
4611
5036
  };
4612
5037
  const settleFailure = async (msg, data, err) => {
4613
5038
  if (handleDeadLetter !== null && isDeadLetter(msg)) {
4614
5039
  await handleDeadLetter(msg, data, err);
4615
5040
  return;
4616
5041
  }
4617
- msg.nak();
5042
+ settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
5043
+ msg.nak();
5044
+ });
5045
+ };
5046
+ const captureUnroutable = (capture, msg, err) => {
5047
+ let data;
5048
+ try {
5049
+ data = codec.decode(msg.data);
5050
+ } catch {
5051
+ data = void 0;
5052
+ }
5053
+ return capture(msg, data, err).catch((captureErr) => {
5054
+ logger5.error(`Dead-letter capture failed for unroutable ${msg.subject}:`, captureErr);
5055
+ });
4618
5056
  };
4619
5057
  const resolveEvent = (msg) => {
4620
5058
  const subject = msg.subject;
4621
5059
  try {
4622
5060
  const handler = patternRegistry.getHandler(subject);
4623
5061
  if (!handler) {
4624
- msg.term(`No handler for event: ${subject}`);
4625
5062
  logger5.error(`No handler for subject: ${subject}`);
5063
+ if (handleDeadLetter !== null) {
5064
+ return captureUnroutable(
5065
+ handleDeadLetter,
5066
+ msg,
5067
+ new Error(`No handler for event: ${subject}`)
5068
+ );
5069
+ }
5070
+ msg.term(`No handler for event: ${subject}`);
4626
5071
  return null;
4627
5072
  }
4628
5073
  let data;
4629
5074
  try {
4630
5075
  data = codec.decode(msg.data);
4631
5076
  } catch (err) {
4632
- msg.term("Decode error");
4633
5077
  logger5.error(`Decode error for ${subject}:`, err);
5078
+ if (handleDeadLetter !== null) {
5079
+ return captureUnroutable(
5080
+ handleDeadLetter,
5081
+ msg,
5082
+ new Error(`Decode error: ${err instanceof Error ? err.message : String(err)}`)
5083
+ );
5084
+ }
5085
+ msg.term("Decode error");
4634
5086
  return null;
4635
5087
  }
4636
5088
  eventBus.emitMessageRouted(subject, "event" /* Event */);
@@ -4653,6 +5105,7 @@ var EventRouter = class {
4653
5105
  const handleSafe = (msg) => {
4654
5106
  const resolved = resolveEvent(msg);
4655
5107
  if (resolved === null) return void 0;
5108
+ if (isPromiseLike2(resolved)) return resolved;
4656
5109
  const { handler, data } = resolved;
4657
5110
  const ctx = new RpcContext([msg]);
4658
5111
  const stopAckExtension = hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null;
@@ -4685,16 +5138,24 @@ var EventRouter = class {
4685
5138
  });
4686
5139
  }
4687
5140
  if (!isPromiseLike2(pending)) {
4688
- settleSuccess(msg, ctx);
5141
+ const settled = settleSuccess(msg, ctx, data);
4689
5142
  reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
4690
- if (stopAckExtension !== null) stopAckExtension();
4691
- return void 0;
5143
+ if (settled === void 0) {
5144
+ if (stopAckExtension !== null) stopAckExtension();
5145
+ return void 0;
5146
+ }
5147
+ return settled.finally(() => {
5148
+ if (stopAckExtension !== null) stopAckExtension();
5149
+ });
4692
5150
  }
4693
5151
  return pending.then(
4694
- () => {
4695
- settleSuccess(msg, ctx);
4696
- reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
4697
- if (stopAckExtension !== null) stopAckExtension();
5152
+ async () => {
5153
+ try {
5154
+ await settleSuccess(msg, ctx, data);
5155
+ reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
5156
+ } finally {
5157
+ if (stopAckExtension !== null) stopAckExtension();
5158
+ }
4698
5159
  },
4699
5160
  async (err) => {
4700
5161
  eventBus.emit(
@@ -4788,14 +5249,28 @@ var EventRouter = class {
4788
5249
  active--;
4789
5250
  drainBacklog();
4790
5251
  };
5252
+ const routeSafely = (msg) => {
5253
+ try {
5254
+ return route(msg);
5255
+ } catch (err) {
5256
+ logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
5257
+ return void 0;
5258
+ }
5259
+ };
5260
+ const trackAsync = (result, msg) => {
5261
+ void result.catch((err) => {
5262
+ logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
5263
+ }).finally(onAsyncDone);
5264
+ };
4791
5265
  const drainBacklog = () => {
4792
5266
  while (active < maxActive) {
4793
5267
  const next = backlog.shift();
4794
5268
  if (next === void 0) return;
5269
+ next.stopAckExtension?.();
4795
5270
  active++;
4796
- const result = route(next);
5271
+ const result = routeSafely(next.msg);
4797
5272
  if (result !== void 0) {
4798
- void result.finally(onAsyncDone);
5273
+ trackAsync(result, next.msg);
4799
5274
  } else {
4800
5275
  active--;
4801
5276
  }
@@ -4805,7 +5280,10 @@ var EventRouter = class {
4805
5280
  const subscription = stream$.subscribe({
4806
5281
  next: (msg) => {
4807
5282
  if (active >= maxActive) {
4808
- backlog.push(msg);
5283
+ backlog.push({
5284
+ msg,
5285
+ stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
5286
+ });
4809
5287
  if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
4810
5288
  backlogWarned = true;
4811
5289
  logger5.warn(
@@ -4815,9 +5293,9 @@ var EventRouter = class {
4815
5293
  return;
4816
5294
  }
4817
5295
  active++;
4818
- const result = route(msg);
5296
+ const result = routeSafely(msg);
4819
5297
  if (result !== void 0) {
4820
- void result.finally(onAsyncDone);
5298
+ trackAsync(result, msg);
4821
5299
  } else {
4822
5300
  active--;
4823
5301
  if (backlog.length > 0) drainBacklog();
@@ -4827,6 +5305,12 @@ var EventRouter = class {
4827
5305
  logger5.error(`Stream error in ${kind} router`, err);
4828
5306
  }
4829
5307
  });
5308
+ subscription.add(() => {
5309
+ for (const queued of backlog) {
5310
+ queued.stopAckExtension?.();
5311
+ }
5312
+ backlog.length = 0;
5313
+ });
4830
5314
  this.subscriptions.push(subscription);
4831
5315
  }
4832
5316
  getConcurrency(kind) {
@@ -4841,26 +5325,79 @@ var EventRouter = class {
4841
5325
  }
4842
5326
  /**
4843
5327
  * Last-resort path for a dead letter: invoke `onDeadLetter`, then `term` on
4844
- * success or `nak` on hook failure so NATS retries on the next delivery
4845
- * cycle. Used when DLQ stream isn't configured, or when publishing to it
4846
- * failed and we still have to surface the message somewhere observable.
5328
+ * success. On failure the message is nak'd to release it, but the server
5329
+ * never redelivers past `max_deliver` it stays in the stream for manual
5330
+ * recovery. Used when the DLQ stream isn't configured, or when publishing
5331
+ * to it failed and we still have to surface the message somewhere.
4847
5332
  */
4848
5333
  async fallbackToOnDeadLetterCallback(info, msg) {
4849
- if (!this.deadLetterConfig) {
4850
- msg.term("Dead letter config unavailable");
5334
+ const onDeadLetter = this.deadLetterConfig?.onDeadLetter;
5335
+ if (!onDeadLetter) {
5336
+ this.logger.error(
5337
+ `Dead letter for ${msg.subject} could not be captured (DLQ publish failed, no onDeadLetter callback) \u2014 leaving the message in the stream`
5338
+ );
5339
+ settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
5340
+ msg.nak();
5341
+ });
4851
5342
  return;
4852
5343
  }
4853
5344
  try {
4854
- await this.deadLetterConfig.onDeadLetter(info);
4855
- msg.term("Dead letter processed via fallback callback");
5345
+ await onDeadLetter(info);
5346
+ settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
5347
+ msg.term("Dead letter processed via fallback callback");
5348
+ });
4856
5349
  } catch (hookErr) {
4857
5350
  this.logger.error(
4858
- `Fallback onDeadLetter callback failed for ${msg.subject}, nak for retry:`,
5351
+ `Fallback onDeadLetter callback failed for ${msg.subject} \u2014 the message stays in the stream and will not be redelivered (max_deliver exhausted); recover it manually:`,
4859
5352
  hookErr
4860
5353
  );
4861
- msg.nak();
5354
+ settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
5355
+ msg.nak();
5356
+ });
4862
5357
  }
4863
5358
  }
5359
+ /**
5360
+ * Copy the original message headers for the DLQ republish, dropping NATS
5361
+ * server control headers: a copied Nats-TTL expires the DLQ entry (or gets
5362
+ * the publish rejected when the DLQ stream has no allow_msg_ttl), a copied
5363
+ * Nats-Msg-Id collides with the DLQ dedup window.
5364
+ */
5365
+ buildDlqHeaders(msg) {
5366
+ const hdrs = natsHeaders3();
5367
+ if (!msg.headers) return hdrs;
5368
+ for (const [k, v] of msg.headers) {
5369
+ if (k.toLowerCase().startsWith(NATS_CONTROL_HEADER_PREFIX)) continue;
5370
+ for (const val of v) {
5371
+ hdrs.append(k, val);
5372
+ }
5373
+ }
5374
+ return hdrs;
5375
+ }
5376
+ /**
5377
+ * Attempt the DLQ publish up to {@link DLQ_PUBLISH_ATTEMPTS} times.
5378
+ *
5379
+ * Past `max_deliver` the server never redelivers, so an in-process retry is
5380
+ * the only second chance a dead letter gets. There is no artificial delay
5381
+ * between attempts: when the broker is unreachable each publish already
5382
+ * spends its own request timeout, which spaces the attempts naturally.
5383
+ */
5384
+ async publishToDlqWithRetry(connection, subject, data, headers2) {
5385
+ let lastErr;
5386
+ for (let attempt = 1; attempt <= DLQ_PUBLISH_ATTEMPTS; attempt += 1) {
5387
+ try {
5388
+ await connection.getJetStreamClient().publish(subject, data, { headers: headers2 });
5389
+ return;
5390
+ } catch (err) {
5391
+ lastErr = err;
5392
+ if (attempt < DLQ_PUBLISH_ATTEMPTS) {
5393
+ this.logger.warn(
5394
+ `DLQ publish attempt ${attempt}/${DLQ_PUBLISH_ATTEMPTS} failed for ${subject}, retrying`
5395
+ );
5396
+ }
5397
+ }
5398
+ }
5399
+ throw lastErr;
5400
+ }
4864
5401
  /**
4865
5402
  * Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
4866
5403
  *
@@ -4879,14 +5416,7 @@ var EventRouter = class {
4879
5416
  return;
4880
5417
  }
4881
5418
  const destinationSubject = dlqStreamName(serviceName);
4882
- const hdrs = natsHeaders3();
4883
- if (msg.headers) {
4884
- for (const [k, v] of msg.headers) {
4885
- for (const val of v) {
4886
- hdrs.append(k, val);
4887
- }
4888
- }
4889
- }
5419
+ const hdrs = this.buildDlqHeaders(msg);
4890
5420
  let reason = String(error);
4891
5421
  if (error instanceof Error) {
4892
5422
  reason = error.message;
@@ -4899,8 +5429,7 @@ var EventRouter = class {
4899
5429
  hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
4900
5430
  hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
4901
5431
  try {
4902
- const js = this.connection.getJetStreamClient();
4903
- await js.publish(destinationSubject, msg.data, { headers: hdrs });
5432
+ await this.publishToDlqWithRetry(this.connection, destinationSubject, msg.data, hdrs);
4904
5433
  this.logger.log(`Message sent to DLQ: ${msg.subject}`);
4905
5434
  if (this.deadLetterConfig?.onDeadLetter) {
4906
5435
  try {
@@ -4912,7 +5441,9 @@ var EventRouter = class {
4912
5441
  );
4913
5442
  }
4914
5443
  }
4915
- msg.term("Moved to DLQ stream");
5444
+ settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
5445
+ msg.term("Moved to DLQ stream");
5446
+ });
4916
5447
  } catch (publishErr) {
4917
5448
  this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
4918
5449
  await this.fallbackToOnDeadLetterCallback(info, msg);
@@ -5106,7 +5637,9 @@ var RpcRouter = class {
5106
5637
  `rpc-handler:${subject}`
5107
5638
  );
5108
5639
  publishErrorReply(replyTo, correlationId, subject, err);
5109
- msg.term(`Handler error: ${subject}`);
5640
+ settleQuietly(logger5, `Failed to term ${subject}:`, () => {
5641
+ msg.term(`Handler error: ${subject}`);
5642
+ });
5110
5643
  };
5111
5644
  const abortController = new AbortController();
5112
5645
  let pending;
@@ -5134,7 +5667,9 @@ var RpcRouter = class {
5134
5667
  }
5135
5668
  if (!isPromiseLike2(pending)) {
5136
5669
  if (stopAckExtension !== null) stopAckExtension();
5137
- msg.ack();
5670
+ settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
5671
+ msg.ack();
5672
+ });
5138
5673
  publishReply(replyTo, correlationId, pending);
5139
5674
  reportHandlerCompleted(msg, startedAt, "success");
5140
5675
  return void 0;
@@ -5146,7 +5681,9 @@ var RpcRouter = class {
5146
5681
  if (stopAckExtension !== null) stopAckExtension();
5147
5682
  abortController.abort();
5148
5683
  emitRpcTimeout(subject, correlationId);
5149
- msg.term("Handler timeout");
5684
+ settleQuietly(logger5, `Failed to term ${subject}:`, () => {
5685
+ msg.term("Handler timeout");
5686
+ });
5150
5687
  reportHandlerCompleted(msg, startedAt, "terminated");
5151
5688
  }, timeout);
5152
5689
  return pending.then(
@@ -5155,7 +5692,9 @@ var RpcRouter = class {
5155
5692
  settled = true;
5156
5693
  clearTimeout(timeoutId);
5157
5694
  if (stopAckExtension !== null) stopAckExtension();
5158
- msg.ack();
5695
+ settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
5696
+ msg.ack();
5697
+ });
5159
5698
  publishReply(replyTo, correlationId, result);
5160
5699
  reportHandlerCompleted(msg, startedAt, "success");
5161
5700
  },
@@ -5177,14 +5716,28 @@ var RpcRouter = class {
5177
5716
  active--;
5178
5717
  drainBacklog();
5179
5718
  };
5719
+ const routeSafely = (msg) => {
5720
+ try {
5721
+ return handleSafe(msg);
5722
+ } catch (err) {
5723
+ logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
5724
+ return void 0;
5725
+ }
5726
+ };
5727
+ const trackAsync = (result, msg) => {
5728
+ void result.catch((err) => {
5729
+ logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
5730
+ }).finally(onAsyncDone);
5731
+ };
5180
5732
  const drainBacklog = () => {
5181
5733
  while (active < maxActive) {
5182
5734
  const next = backlog.shift();
5183
5735
  if (next === void 0) return;
5736
+ next.stopAckExtension?.();
5184
5737
  active++;
5185
- const result = handleSafe(next);
5738
+ const result = routeSafely(next.msg);
5186
5739
  if (result !== void 0) {
5187
- void result.finally(onAsyncDone);
5740
+ trackAsync(result, next.msg);
5188
5741
  } else {
5189
5742
  active--;
5190
5743
  }
@@ -5194,7 +5747,10 @@ var RpcRouter = class {
5194
5747
  this.subscription = this.messageProvider.commands$.subscribe({
5195
5748
  next: (msg) => {
5196
5749
  if (active >= maxActive) {
5197
- backlog.push(msg);
5750
+ backlog.push({
5751
+ msg,
5752
+ stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
5753
+ });
5198
5754
  if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
5199
5755
  backlogWarned = true;
5200
5756
  logger5.warn(
@@ -5204,9 +5760,9 @@ var RpcRouter = class {
5204
5760
  return;
5205
5761
  }
5206
5762
  active++;
5207
- const result = handleSafe(msg);
5763
+ const result = routeSafely(msg);
5208
5764
  if (result !== void 0) {
5209
- void result.finally(onAsyncDone);
5765
+ trackAsync(result, msg);
5210
5766
  } else {
5211
5767
  active--;
5212
5768
  if (backlog.length > 0) drainBacklog();
@@ -5216,6 +5772,12 @@ var RpcRouter = class {
5216
5772
  logger5.error("Stream error in RPC router", err);
5217
5773
  }
5218
5774
  });
5775
+ this.subscription.add(() => {
5776
+ for (const queued of backlog) {
5777
+ queued.stopAckExtension?.();
5778
+ }
5779
+ backlog.length = 0;
5780
+ });
5219
5781
  }
5220
5782
  /** Stop routing and unsubscribe. */
5221
5783
  destroy() {
@@ -5286,7 +5848,7 @@ var JetstreamModule = class {
5286
5848
  return {
5287
5849
  module: JetstreamModule,
5288
5850
  global: true,
5289
- imports: options.metrics ? [JetstreamMetricsModule.forFeature(options.metrics)] : [],
5851
+ imports: [JetstreamMetricsModule.forFeature()],
5290
5852
  providers,
5291
5853
  exports: [
5292
5854
  JETSTREAM_CONNECTION,
@@ -5312,11 +5874,10 @@ var JetstreamModule = class {
5312
5874
  static forRootAsync(asyncOptions) {
5313
5875
  const asyncProviders = this.createAsyncOptionsProvider(asyncOptions);
5314
5876
  const coreProviders = this.createCoreDependentProviders();
5315
- const metricsImports = asyncOptions.metrics ? [JetstreamMetricsModule.forFeature(asyncOptions.metrics)] : [];
5316
5877
  return {
5317
5878
  module: JetstreamModule,
5318
5879
  global: true,
5319
- imports: [...asyncOptions.imports ?? [], ...metricsImports],
5880
+ imports: [...asyncOptions.imports ?? [], JetstreamMetricsModule.forFeature()],
5320
5881
  providers: [...asyncProviders, ...coreProviders],
5321
5882
  exports: [
5322
5883
  JETSTREAM_CONNECTION,
@@ -5499,7 +6060,7 @@ var JetstreamModule = class {
5499
6060
  ],
5500
6061
  useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
5501
6062
  if (options.consumer === false) return null;
5502
- const deadLetterConfig = options.onDeadLetter ? {
6063
+ const deadLetterConfig = options.onDeadLetter || options.dlq ? {
5503
6064
  maxDeliverByStream: /* @__PURE__ */ new Map(),
5504
6065
  onDeadLetter: options.onDeadLetter
5505
6066
  } : void 0;
@@ -5699,6 +6260,7 @@ export {
5699
6260
  JetstreamHeader,
5700
6261
  JetstreamHealthIndicator,
5701
6262
  JetstreamModule,
6263
+ JetstreamProvisioningError,
5702
6264
  JetstreamRecord,
5703
6265
  JetstreamRecordBuilder,
5704
6266
  JetstreamStrategy,