@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.cjs +778 -78
- package/dist/index.d.cts +344 -17
- package/dist/index.d.ts +344 -17
- package/dist/index.js +758 -67
- package/package.json +14 -14
package/dist/index.cjs
CHANGED
|
@@ -29,12 +29,17 @@ var __decorateParam = (index, decorator) => (target, key) => decorator(target, k
|
|
|
29
29
|
// src/index.ts
|
|
30
30
|
var index_exports = {};
|
|
31
31
|
__export(index_exports, {
|
|
32
|
+
DEFAULT_METADATA_BUCKET: () => DEFAULT_METADATA_BUCKET,
|
|
33
|
+
DEFAULT_METADATA_HISTORY: () => DEFAULT_METADATA_HISTORY,
|
|
34
|
+
DEFAULT_METADATA_REPLICAS: () => DEFAULT_METADATA_REPLICAS,
|
|
35
|
+
DEFAULT_METADATA_TTL: () => DEFAULT_METADATA_TTL,
|
|
32
36
|
EventBus: () => EventBus,
|
|
33
37
|
JETSTREAM_CODEC: () => JETSTREAM_CODEC,
|
|
34
38
|
JETSTREAM_CONNECTION: () => JETSTREAM_CONNECTION,
|
|
35
39
|
JETSTREAM_EVENT_BUS: () => JETSTREAM_EVENT_BUS,
|
|
36
40
|
JETSTREAM_OPTIONS: () => JETSTREAM_OPTIONS,
|
|
37
41
|
JetstreamClient: () => JetstreamClient,
|
|
42
|
+
JetstreamDlqHeader: () => JetstreamDlqHeader,
|
|
38
43
|
JetstreamHeader: () => JetstreamHeader,
|
|
39
44
|
JetstreamHealthIndicator: () => JetstreamHealthIndicator,
|
|
40
45
|
JetstreamModule: () => JetstreamModule,
|
|
@@ -42,24 +47,28 @@ __export(index_exports, {
|
|
|
42
47
|
JetstreamRecordBuilder: () => JetstreamRecordBuilder,
|
|
43
48
|
JetstreamStrategy: () => JetstreamStrategy,
|
|
44
49
|
JsonCodec: () => JsonCodec,
|
|
50
|
+
MIN_METADATA_TTL: () => MIN_METADATA_TTL,
|
|
45
51
|
MessageKind: () => MessageKind,
|
|
46
52
|
PatternPrefix: () => PatternPrefix,
|
|
47
53
|
RpcContext: () => RpcContext,
|
|
48
54
|
StreamKind: () => StreamKind,
|
|
49
55
|
TransportEvent: () => TransportEvent,
|
|
56
|
+
buildBroadcastSubject: () => buildBroadcastSubject,
|
|
50
57
|
buildSubject: () => buildSubject,
|
|
51
58
|
consumerName: () => consumerName,
|
|
59
|
+
dlqStreamName: () => dlqStreamName,
|
|
52
60
|
getClientToken: () => getClientToken,
|
|
53
61
|
internalName: () => internalName,
|
|
54
62
|
isCoreRpcMode: () => isCoreRpcMode,
|
|
55
63
|
isJetStreamRpcMode: () => isJetStreamRpcMode,
|
|
64
|
+
metadataKey: () => metadataKey,
|
|
56
65
|
streamName: () => streamName,
|
|
57
66
|
toNanos: () => toNanos
|
|
58
67
|
});
|
|
59
68
|
module.exports = __toCommonJS(index_exports);
|
|
60
69
|
|
|
61
70
|
// src/jetstream.module.ts
|
|
62
|
-
var
|
|
71
|
+
var import_common14 = require("@nestjs/common");
|
|
63
72
|
|
|
64
73
|
// src/client/jetstream.client.ts
|
|
65
74
|
var import_common = require("@nestjs/common");
|
|
@@ -152,7 +161,7 @@ var DEFAULT_BROADCAST_STREAM_CONFIG = {
|
|
|
152
161
|
max_msgs_per_subject: 1e6,
|
|
153
162
|
max_msgs: 1e7,
|
|
154
163
|
max_bytes: 2 * GB,
|
|
155
|
-
max_age: toNanos(1, "
|
|
164
|
+
max_age: toNanos(1, "hours"),
|
|
156
165
|
duplicate_window: toNanos(2, "minutes")
|
|
157
166
|
};
|
|
158
167
|
var DEFAULT_ORDERED_STREAM_CONFIG = {
|
|
@@ -167,6 +176,18 @@ var DEFAULT_ORDERED_STREAM_CONFIG = {
|
|
|
167
176
|
max_age: toNanos(1, "days"),
|
|
168
177
|
duplicate_window: toNanos(2, "minutes")
|
|
169
178
|
};
|
|
179
|
+
var DEFAULT_DLQ_STREAM_CONFIG = {
|
|
180
|
+
...baseStreamConfig,
|
|
181
|
+
retention: import_jetstream.RetentionPolicy.Workqueue,
|
|
182
|
+
allow_rollup_hdrs: false,
|
|
183
|
+
max_consumers: 100,
|
|
184
|
+
max_msg_size: 10 * MB,
|
|
185
|
+
max_msgs_per_subject: 5e6,
|
|
186
|
+
max_msgs: 5e7,
|
|
187
|
+
max_bytes: 5 * GB,
|
|
188
|
+
max_age: toNanos(30, "days"),
|
|
189
|
+
duplicate_window: toNanos(2, "minutes")
|
|
190
|
+
};
|
|
170
191
|
var DEFAULT_EVENT_CONSUMER_CONFIG = {
|
|
171
192
|
ack_wait: toNanos(10, "seconds"),
|
|
172
193
|
max_deliver: 3,
|
|
@@ -194,6 +215,12 @@ var DEFAULT_BROADCAST_CONSUMER_CONFIG = {
|
|
|
194
215
|
var DEFAULT_RPC_TIMEOUT = 3e4;
|
|
195
216
|
var DEFAULT_JETSTREAM_RPC_TIMEOUT = 18e4;
|
|
196
217
|
var DEFAULT_SHUTDOWN_TIMEOUT = 1e4;
|
|
218
|
+
var DEFAULT_METADATA_BUCKET = "handler_registry";
|
|
219
|
+
var DEFAULT_METADATA_REPLICAS = 1;
|
|
220
|
+
var DEFAULT_METADATA_HISTORY = 1;
|
|
221
|
+
var DEFAULT_METADATA_TTL = 3e4;
|
|
222
|
+
var MIN_METADATA_TTL = 5e3;
|
|
223
|
+
var metadataKey = (serviceName, kind, pattern) => `${serviceName}.${kind}.${pattern}`;
|
|
197
224
|
var JetstreamHeader = /* @__PURE__ */ ((JetstreamHeader2) => {
|
|
198
225
|
JetstreamHeader2["CorrelationId"] = "x-correlation-id";
|
|
199
226
|
JetstreamHeader2["ReplyTo"] = "x-reply-to";
|
|
@@ -202,6 +229,14 @@ var JetstreamHeader = /* @__PURE__ */ ((JetstreamHeader2) => {
|
|
|
202
229
|
JetstreamHeader2["Error"] = "x-error";
|
|
203
230
|
return JetstreamHeader2;
|
|
204
231
|
})(JetstreamHeader || {});
|
|
232
|
+
var JetstreamDlqHeader = /* @__PURE__ */ ((JetstreamDlqHeader2) => {
|
|
233
|
+
JetstreamDlqHeader2["DeadLetterReason"] = "x-dead-letter-reason";
|
|
234
|
+
JetstreamDlqHeader2["OriginalSubject"] = "x-original-subject";
|
|
235
|
+
JetstreamDlqHeader2["OriginalStream"] = "x-original-stream";
|
|
236
|
+
JetstreamDlqHeader2["FailedAt"] = "x-failed-at";
|
|
237
|
+
JetstreamDlqHeader2["DeliveryCount"] = "x-delivery-count";
|
|
238
|
+
return JetstreamDlqHeader2;
|
|
239
|
+
})(JetstreamDlqHeader || {});
|
|
205
240
|
var RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
206
241
|
"x-correlation-id" /* CorrelationId */,
|
|
207
242
|
"x-reply-to" /* ReplyTo */,
|
|
@@ -214,6 +249,9 @@ var streamName = (serviceName, kind) => {
|
|
|
214
249
|
if (kind === "broadcast" /* Broadcast */) return "broadcast-stream";
|
|
215
250
|
return `${internalName(serviceName)}_${kind}-stream`;
|
|
216
251
|
};
|
|
252
|
+
var dlqStreamName = (serviceName) => {
|
|
253
|
+
return `${internalName(serviceName)}_dlq-stream`;
|
|
254
|
+
};
|
|
217
255
|
var consumerName = (serviceName, kind) => {
|
|
218
256
|
if (kind === "broadcast" /* Broadcast */) return `${internalName(serviceName)}_broadcast-consumer`;
|
|
219
257
|
return `${internalName(serviceName)}_${kind}-consumer`;
|
|
@@ -228,12 +266,13 @@ var isCoreRpcMode = (rpc) => !rpc || rpc.mode === "core";
|
|
|
228
266
|
|
|
229
267
|
// src/client/jetstream.record.ts
|
|
230
268
|
var JetstreamRecord = class {
|
|
231
|
-
constructor(data, headers2, timeout, messageId, schedule) {
|
|
269
|
+
constructor(data, headers2, timeout, messageId, schedule, ttl) {
|
|
232
270
|
this.data = data;
|
|
233
271
|
this.headers = headers2;
|
|
234
272
|
this.timeout = timeout;
|
|
235
273
|
this.messageId = messageId;
|
|
236
274
|
this.schedule = schedule;
|
|
275
|
+
this.ttl = ttl;
|
|
237
276
|
}
|
|
238
277
|
};
|
|
239
278
|
var JetstreamRecordBuilder = class {
|
|
@@ -242,6 +281,7 @@ var JetstreamRecordBuilder = class {
|
|
|
242
281
|
timeout;
|
|
243
282
|
messageId;
|
|
244
283
|
scheduleOptions;
|
|
284
|
+
ttlDuration;
|
|
245
285
|
constructor(data) {
|
|
246
286
|
this.data = data;
|
|
247
287
|
}
|
|
@@ -333,6 +373,33 @@ var JetstreamRecordBuilder = class {
|
|
|
333
373
|
this.scheduleOptions = { at: new Date(ts) };
|
|
334
374
|
return this;
|
|
335
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Set per-message TTL (time-to-live).
|
|
378
|
+
*
|
|
379
|
+
* The message expires individually after the specified duration,
|
|
380
|
+
* independent of the stream's `max_age`. Requires NATS >= 2.11 and
|
|
381
|
+
* `allow_msg_ttl: true` on the stream.
|
|
382
|
+
*
|
|
383
|
+
* Only meaningful for events (`client.emit()`). If used with RPC
|
|
384
|
+
* (`client.send()`), a warning is logged and the TTL is ignored.
|
|
385
|
+
*
|
|
386
|
+
* @param nanos - TTL in nanoseconds. Use {@link toNanos} for human-readable values.
|
|
387
|
+
*
|
|
388
|
+
* @example
|
|
389
|
+
* ```typescript
|
|
390
|
+
* import { toNanos } from '@horizon-republic/nestjs-jetstream';
|
|
391
|
+
*
|
|
392
|
+
* new JetstreamRecordBuilder(payload).ttl(toNanos(30, 'minutes')).build();
|
|
393
|
+
* new JetstreamRecordBuilder(payload).ttl(toNanos(24, 'hours')).build();
|
|
394
|
+
* ```
|
|
395
|
+
*/
|
|
396
|
+
ttl(nanos) {
|
|
397
|
+
if (!Number.isFinite(nanos) || nanos <= 0) {
|
|
398
|
+
throw new Error("TTL must be a positive finite value");
|
|
399
|
+
}
|
|
400
|
+
this.ttlDuration = nanosToGoDuration(nanos);
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
336
403
|
/**
|
|
337
404
|
* Build the immutable {@link JetstreamRecord}.
|
|
338
405
|
*
|
|
@@ -345,7 +412,8 @@ var JetstreamRecordBuilder = class {
|
|
|
345
412
|
new Map(this.headers),
|
|
346
413
|
this.timeout,
|
|
347
414
|
this.messageId,
|
|
348
|
-
schedule
|
|
415
|
+
schedule,
|
|
416
|
+
this.ttlDuration
|
|
349
417
|
);
|
|
350
418
|
}
|
|
351
419
|
/** Validate that a header key is not reserved. */
|
|
@@ -357,6 +425,17 @@ var JetstreamRecordBuilder = class {
|
|
|
357
425
|
}
|
|
358
426
|
}
|
|
359
427
|
};
|
|
428
|
+
var NS_PER_MS = 1e6;
|
|
429
|
+
var NS_PER_S = 1e9;
|
|
430
|
+
var NS_PER_M = 60 * NS_PER_S;
|
|
431
|
+
var NS_PER_H = 60 * NS_PER_M;
|
|
432
|
+
var nanosToGoDuration = (nanos) => {
|
|
433
|
+
if (nanos >= NS_PER_H && nanos % NS_PER_H === 0) return `${nanos / NS_PER_H}h`;
|
|
434
|
+
if (nanos >= NS_PER_M && nanos % NS_PER_M === 0) return `${nanos / NS_PER_M}m`;
|
|
435
|
+
if (nanos >= NS_PER_S && nanos % NS_PER_S === 0) return `${nanos / NS_PER_S}s`;
|
|
436
|
+
if (nanos >= NS_PER_MS && nanos % NS_PER_MS === 0) return `${nanos / NS_PER_MS}ms`;
|
|
437
|
+
return `${nanos}ns`;
|
|
438
|
+
};
|
|
360
439
|
|
|
361
440
|
// src/client/jetstream.client.ts
|
|
362
441
|
var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
@@ -431,7 +510,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
431
510
|
*/
|
|
432
511
|
async dispatchEvent(packet) {
|
|
433
512
|
await this.connect();
|
|
434
|
-
const { data, hdrs, messageId, schedule } = this.extractRecordData(packet.data);
|
|
513
|
+
const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
|
|
435
514
|
const eventSubject = this.buildEventSubject(packet.pattern);
|
|
436
515
|
const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
|
|
437
516
|
if (schedule) {
|
|
@@ -439,6 +518,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
439
518
|
const ack = await this.connection.getJetStreamClient().publish(scheduleSubject, this.codec.encode(data), {
|
|
440
519
|
headers: msgHeaders,
|
|
441
520
|
msgID: messageId ?? import_nuid.nuid.next(),
|
|
521
|
+
ttl,
|
|
442
522
|
schedule: {
|
|
443
523
|
specification: schedule.at,
|
|
444
524
|
target: eventSubject
|
|
@@ -452,7 +532,8 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
452
532
|
} else {
|
|
453
533
|
const ack = await this.connection.getJetStreamClient().publish(eventSubject, this.codec.encode(data), {
|
|
454
534
|
headers: msgHeaders,
|
|
455
|
-
msgID: messageId ?? import_nuid.nuid.next()
|
|
535
|
+
msgID: messageId ?? import_nuid.nuid.next(),
|
|
536
|
+
ttl
|
|
456
537
|
});
|
|
457
538
|
if (ack.duplicate) {
|
|
458
539
|
this.logger.warn(`Duplicate event publish detected: ${eventSubject} (seq: ${ack.seq})`);
|
|
@@ -468,12 +549,17 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
468
549
|
*/
|
|
469
550
|
publish(packet, callback) {
|
|
470
551
|
const subject = buildSubject(this.targetName, "cmd" /* Command */, packet.pattern);
|
|
471
|
-
const { data, hdrs, timeout, messageId, schedule } = this.extractRecordData(packet.data);
|
|
552
|
+
const { data, hdrs, timeout, messageId, schedule, ttl } = this.extractRecordData(packet.data);
|
|
472
553
|
if (schedule) {
|
|
473
554
|
this.logger.warn(
|
|
474
555
|
"scheduleAt() is ignored for RPC (client.send()). Use client.emit() for scheduled events."
|
|
475
556
|
);
|
|
476
557
|
}
|
|
558
|
+
if (ttl) {
|
|
559
|
+
this.logger.warn(
|
|
560
|
+
"ttl() is ignored for RPC (client.send()). Use client.emit() for events with TTL."
|
|
561
|
+
);
|
|
562
|
+
}
|
|
477
563
|
const onUnhandled = (err) => {
|
|
478
564
|
this.logger.error("Unhandled publish error:", err);
|
|
479
565
|
callback({ err: new Error("Internal transport error"), response: null, isDisposed: true });
|
|
@@ -589,6 +675,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
589
675
|
this.pendingTimeouts.clear();
|
|
590
676
|
this.inboxSubscription?.unsubscribe();
|
|
591
677
|
this.inboxSubscription = null;
|
|
678
|
+
this.inbox = null;
|
|
592
679
|
}
|
|
593
680
|
/** Setup shared inbox subscription for JetStream RPC responses. */
|
|
594
681
|
setupInbox(nc) {
|
|
@@ -678,7 +765,8 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
678
765
|
hdrs: rawData.headers.size > 0 ? new Map(rawData.headers) : null,
|
|
679
766
|
timeout: rawData.timeout,
|
|
680
767
|
messageId: rawData.messageId,
|
|
681
|
-
schedule: rawData.schedule
|
|
768
|
+
schedule: rawData.schedule,
|
|
769
|
+
ttl: rawData.ttl
|
|
682
770
|
};
|
|
683
771
|
}
|
|
684
772
|
return {
|
|
@@ -686,7 +774,8 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
686
774
|
hdrs: null,
|
|
687
775
|
timeout: void 0,
|
|
688
776
|
messageId: void 0,
|
|
689
|
-
schedule: void 0
|
|
777
|
+
schedule: void 0,
|
|
778
|
+
ttl: void 0
|
|
690
779
|
};
|
|
691
780
|
}
|
|
692
781
|
/**
|
|
@@ -1001,9 +1090,14 @@ var JetstreamHealthIndicator = class {
|
|
|
1001
1090
|
* Returns `{ [key]: { status: 'up', ... } }` on success.
|
|
1002
1091
|
* Throws an error with `{ [key]: { status: 'down', ... } }` on failure.
|
|
1003
1092
|
*
|
|
1093
|
+
* The thrown error sets `isHealthCheckError: true` and `causes` — the
|
|
1094
|
+
* duck-type contract that Terminus `HealthCheckExecutor` uses to distinguish
|
|
1095
|
+
* health failures from unexpected exceptions. Works with both Terminus v10
|
|
1096
|
+
* (`instanceof HealthCheckError`) and v11+ (`error?.isHealthCheckError`).
|
|
1097
|
+
*
|
|
1004
1098
|
* @param key - Health indicator key (default: `'jetstream'`).
|
|
1005
1099
|
* @returns Object with status, server, and latency under the given key.
|
|
1006
|
-
* @throws Error with `{ [key]: { status: 'down' } }
|
|
1100
|
+
* @throws Error with `isHealthCheckError`, `causes`, and `{ [key]: { status: 'down' } }`.
|
|
1007
1101
|
*/
|
|
1008
1102
|
async isHealthy(key = "jetstream") {
|
|
1009
1103
|
const status = await this.check();
|
|
@@ -1013,8 +1107,10 @@ var JetstreamHealthIndicator = class {
|
|
|
1013
1107
|
latency: status.latency
|
|
1014
1108
|
};
|
|
1015
1109
|
if (!status.connected) {
|
|
1110
|
+
const causes = { [key]: details };
|
|
1016
1111
|
throw Object.assign(new Error("Jetstream health check failed"), {
|
|
1017
|
-
|
|
1112
|
+
causes,
|
|
1113
|
+
isHealthCheckError: true
|
|
1018
1114
|
});
|
|
1019
1115
|
}
|
|
1020
1116
|
return { [key]: details };
|
|
@@ -1027,7 +1123,7 @@ JetstreamHealthIndicator = __decorateClass([
|
|
|
1027
1123
|
// src/server/strategy.ts
|
|
1028
1124
|
var import_microservices2 = require("@nestjs/microservices");
|
|
1029
1125
|
var JetstreamStrategy = class extends import_microservices2.Server {
|
|
1030
|
-
constructor(options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap = /* @__PURE__ */ new Map()) {
|
|
1126
|
+
constructor(options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap = /* @__PURE__ */ new Map(), metadataProvider) {
|
|
1031
1127
|
super();
|
|
1032
1128
|
this.options = options;
|
|
1033
1129
|
this.connection = connection;
|
|
@@ -1039,6 +1135,7 @@ var JetstreamStrategy = class extends import_microservices2.Server {
|
|
|
1039
1135
|
this.rpcRouter = rpcRouter;
|
|
1040
1136
|
this.coreRpcServer = coreRpcServer;
|
|
1041
1137
|
this.ackWaitMap = ackWaitMap;
|
|
1138
|
+
this.metadataProvider = metadataProvider;
|
|
1042
1139
|
}
|
|
1043
1140
|
transportId = /* @__PURE__ */ Symbol("jetstream-transport");
|
|
1044
1141
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
@@ -1083,10 +1180,14 @@ var JetstreamStrategy = class extends import_microservices2.Server {
|
|
|
1083
1180
|
if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
1084
1181
|
await this.coreRpcServer.start();
|
|
1085
1182
|
}
|
|
1183
|
+
if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
|
|
1184
|
+
await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
|
|
1185
|
+
}
|
|
1086
1186
|
callback();
|
|
1087
1187
|
}
|
|
1088
|
-
/** Stop all consumers, routers, and
|
|
1188
|
+
/** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
|
|
1089
1189
|
close() {
|
|
1190
|
+
this.metadataProvider?.destroy();
|
|
1090
1191
|
this.eventRouter.destroy();
|
|
1091
1192
|
this.rpcRouter.destroy();
|
|
1092
1193
|
this.coreRpcServer.stop();
|
|
@@ -1462,24 +1563,172 @@ var CoreRpcServer = class {
|
|
|
1462
1563
|
};
|
|
1463
1564
|
|
|
1464
1565
|
// src/server/infrastructure/stream.provider.ts
|
|
1566
|
+
var import_common6 = require("@nestjs/common");
|
|
1567
|
+
var import_jetstream14 = require("@nats-io/jetstream");
|
|
1568
|
+
|
|
1569
|
+
// src/server/infrastructure/stream-config-diff.ts
|
|
1570
|
+
var TRANSPORT_CONTROLLED_PROPERTIES = /* @__PURE__ */ new Set([
|
|
1571
|
+
"retention"
|
|
1572
|
+
]);
|
|
1573
|
+
var IMMUTABLE_PROPERTIES = /* @__PURE__ */ new Set([
|
|
1574
|
+
"storage"
|
|
1575
|
+
]);
|
|
1576
|
+
var ENABLE_ONLY_PROPERTIES = /* @__PURE__ */ new Set([
|
|
1577
|
+
"allow_msg_schedules",
|
|
1578
|
+
"allow_msg_ttl",
|
|
1579
|
+
"deny_delete",
|
|
1580
|
+
"deny_purge"
|
|
1581
|
+
]);
|
|
1582
|
+
var compareStreamConfig = (current, desired) => {
|
|
1583
|
+
const changes = [];
|
|
1584
|
+
for (const key of Object.keys(desired)) {
|
|
1585
|
+
const currentVal = current[key];
|
|
1586
|
+
const desiredVal = desired[key];
|
|
1587
|
+
if (isEqual(currentVal, desiredVal)) continue;
|
|
1588
|
+
changes.push({
|
|
1589
|
+
property: key,
|
|
1590
|
+
current: currentVal,
|
|
1591
|
+
desired: desiredVal,
|
|
1592
|
+
mutability: classifyMutability(key, currentVal, desiredVal)
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
const hasImmutableChanges = changes.some((c) => c.mutability === "immutable");
|
|
1596
|
+
const hasMutableChanges = changes.some(
|
|
1597
|
+
(c) => c.mutability === "mutable" || c.mutability === "enable-only"
|
|
1598
|
+
);
|
|
1599
|
+
const hasTransportControlledConflicts = changes.some(
|
|
1600
|
+
(c) => c.mutability === "transport-controlled"
|
|
1601
|
+
);
|
|
1602
|
+
return {
|
|
1603
|
+
hasChanges: changes.length > 0,
|
|
1604
|
+
hasMutableChanges,
|
|
1605
|
+
hasImmutableChanges,
|
|
1606
|
+
hasTransportControlledConflicts,
|
|
1607
|
+
changes
|
|
1608
|
+
};
|
|
1609
|
+
};
|
|
1610
|
+
var classifyMutability = (key, current, desired) => {
|
|
1611
|
+
if (TRANSPORT_CONTROLLED_PROPERTIES.has(key)) return "transport-controlled";
|
|
1612
|
+
if (IMMUTABLE_PROPERTIES.has(key)) return "immutable";
|
|
1613
|
+
if (ENABLE_ONLY_PROPERTIES.has(key)) {
|
|
1614
|
+
return current === true && desired === false ? "immutable" : "enable-only";
|
|
1615
|
+
}
|
|
1616
|
+
return "mutable";
|
|
1617
|
+
};
|
|
1618
|
+
var isEqual = (a, b) => {
|
|
1619
|
+
if (a === b) return true;
|
|
1620
|
+
if (a == null && b == null) return true;
|
|
1621
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
// src/server/infrastructure/stream-migration.ts
|
|
1465
1625
|
var import_common5 = require("@nestjs/common");
|
|
1466
1626
|
var import_jetstream13 = require("@nats-io/jetstream");
|
|
1467
|
-
var
|
|
1627
|
+
var MIGRATION_BACKUP_SUFFIX = "__migration_backup";
|
|
1628
|
+
var DEFAULT_SOURCING_TIMEOUT_MS = 3e4;
|
|
1629
|
+
var SOURCING_POLL_INTERVAL_MS = 100;
|
|
1630
|
+
var StreamMigration = class {
|
|
1631
|
+
constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS) {
|
|
1632
|
+
this.sourcingTimeoutMs = sourcingTimeoutMs;
|
|
1633
|
+
}
|
|
1634
|
+
logger = new import_common5.Logger("Jetstream:Stream");
|
|
1635
|
+
async migrate(jsm, streamName2, newConfig) {
|
|
1636
|
+
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
1637
|
+
const startTime = Date.now();
|
|
1638
|
+
const currentInfo = await jsm.streams.info(streamName2);
|
|
1639
|
+
await this.cleanupOrphanedBackup(jsm, backupName);
|
|
1640
|
+
const messageCount = currentInfo.state.messages;
|
|
1641
|
+
this.logger.log(`Stream ${streamName2}: destructive migration started`);
|
|
1642
|
+
let originalDeleted = false;
|
|
1643
|
+
try {
|
|
1644
|
+
if (messageCount > 0) {
|
|
1645
|
+
this.logger.log(` Phase 1/4: Backing up ${messageCount} messages \u2192 ${backupName}`);
|
|
1646
|
+
await jsm.streams.add({
|
|
1647
|
+
...currentInfo.config,
|
|
1648
|
+
name: backupName,
|
|
1649
|
+
subjects: [],
|
|
1650
|
+
sources: [{ name: streamName2 }]
|
|
1651
|
+
});
|
|
1652
|
+
await this.waitForSourcing(jsm, backupName, messageCount);
|
|
1653
|
+
}
|
|
1654
|
+
this.logger.log(` Phase 2/4: Deleting old stream`);
|
|
1655
|
+
await jsm.streams.delete(streamName2);
|
|
1656
|
+
originalDeleted = true;
|
|
1657
|
+
this.logger.log(` Phase 3/4: Creating stream with new config`);
|
|
1658
|
+
await jsm.streams.add(newConfig);
|
|
1659
|
+
if (messageCount > 0) {
|
|
1660
|
+
const backupInfo = await jsm.streams.info(backupName);
|
|
1661
|
+
await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
|
|
1662
|
+
this.logger.log(` Phase 4/4: Restoring ${messageCount} messages from backup`);
|
|
1663
|
+
await jsm.streams.update(streamName2, {
|
|
1664
|
+
...newConfig,
|
|
1665
|
+
sources: [{ name: backupName }]
|
|
1666
|
+
});
|
|
1667
|
+
await this.waitForSourcing(jsm, streamName2, messageCount);
|
|
1668
|
+
await jsm.streams.update(streamName2, { ...newConfig, sources: [] });
|
|
1669
|
+
await jsm.streams.delete(backupName);
|
|
1670
|
+
}
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
if (originalDeleted && messageCount > 0) {
|
|
1673
|
+
this.logger.error(
|
|
1674
|
+
`Migration failed after deleting original stream. Backup ${backupName} preserved for manual recovery.`
|
|
1675
|
+
);
|
|
1676
|
+
} else {
|
|
1677
|
+
await this.cleanupOrphanedBackup(jsm, backupName);
|
|
1678
|
+
}
|
|
1679
|
+
throw err;
|
|
1680
|
+
}
|
|
1681
|
+
const durationMs = Date.now() - startTime;
|
|
1682
|
+
this.logger.log(
|
|
1683
|
+
`Stream ${streamName2}: migration complete (${messageCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
async waitForSourcing(jsm, streamName2, expectedCount) {
|
|
1687
|
+
const deadline = Date.now() + this.sourcingTimeoutMs;
|
|
1688
|
+
while (Date.now() < deadline) {
|
|
1689
|
+
const info = await jsm.streams.info(streamName2);
|
|
1690
|
+
if (info.state.messages >= expectedCount) return;
|
|
1691
|
+
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
|
|
1692
|
+
}
|
|
1693
|
+
throw new Error(
|
|
1694
|
+
`Stream sourcing timeout: ${streamName2} has not reached ${expectedCount} messages within ${this.sourcingTimeoutMs / 1e3}s`
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
async cleanupOrphanedBackup(jsm, backupName) {
|
|
1698
|
+
try {
|
|
1699
|
+
await jsm.streams.info(backupName);
|
|
1700
|
+
this.logger.warn(`Found orphaned migration backup stream: ${backupName}, cleaning up`);
|
|
1701
|
+
await jsm.streams.delete(backupName);
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
if (err instanceof import_jetstream13.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
throw err;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
// src/server/infrastructure/stream.provider.ts
|
|
1468
1712
|
var StreamProvider = class {
|
|
1469
1713
|
constructor(options, connection) {
|
|
1470
1714
|
this.options = options;
|
|
1471
1715
|
this.connection = connection;
|
|
1472
1716
|
}
|
|
1473
|
-
logger = new
|
|
1717
|
+
logger = new import_common6.Logger("Jetstream:Stream");
|
|
1718
|
+
migration = new StreamMigration();
|
|
1474
1719
|
/**
|
|
1475
1720
|
* Ensure all required streams exist with correct configuration.
|
|
1476
1721
|
*
|
|
1477
1722
|
* @param kinds Which stream kinds to create. Determined by the module based
|
|
1478
1723
|
* on RPC mode and registered handler patterns.
|
|
1724
|
+
* If the dlq option is enabled, also ensures the DLQ stream exists.
|
|
1479
1725
|
*/
|
|
1480
1726
|
async ensureStreams(kinds) {
|
|
1481
1727
|
const jsm = await this.connection.getJetStreamManager();
|
|
1482
1728
|
await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
|
|
1729
|
+
if (this.options.dlq) {
|
|
1730
|
+
await this.ensureDlqStream(jsm);
|
|
1731
|
+
}
|
|
1483
1732
|
}
|
|
1484
1733
|
/** Get the stream name for a given kind. */
|
|
1485
1734
|
getStreamName(kind) {
|
|
@@ -1514,17 +1763,85 @@ var StreamProvider = class {
|
|
|
1514
1763
|
const config = this.buildConfig(kind);
|
|
1515
1764
|
this.logger.log(`Ensuring stream: ${config.name}`);
|
|
1516
1765
|
try {
|
|
1517
|
-
await jsm.streams.info(config.name);
|
|
1518
|
-
this.
|
|
1519
|
-
return await jsm.streams.update(config.name, config);
|
|
1766
|
+
const currentInfo = await jsm.streams.info(config.name);
|
|
1767
|
+
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
1520
1768
|
} catch (err) {
|
|
1521
|
-
if (err instanceof
|
|
1769
|
+
if (err instanceof import_jetstream14.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
1522
1770
|
this.logger.log(`Creating stream: ${config.name}`);
|
|
1523
1771
|
return await jsm.streams.add(config);
|
|
1524
1772
|
}
|
|
1525
1773
|
throw err;
|
|
1526
1774
|
}
|
|
1527
1775
|
}
|
|
1776
|
+
/** Ensure a dead-letter queue stream exists, creating or updating as needed. */
|
|
1777
|
+
async ensureDlqStream(jsm) {
|
|
1778
|
+
const config = this.buildDlqConfig();
|
|
1779
|
+
this.logger.log(`Ensuring DLQ stream: ${config.name}`);
|
|
1780
|
+
try {
|
|
1781
|
+
const currentInfo = await jsm.streams.info(config.name);
|
|
1782
|
+
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
if (err instanceof import_jetstream14.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
1785
|
+
this.logger.log(`Creating DLQ stream: ${config.name}`);
|
|
1786
|
+
return await jsm.streams.add(config);
|
|
1787
|
+
}
|
|
1788
|
+
throw err;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
async handleExistingStream(jsm, currentInfo, config) {
|
|
1792
|
+
const diff = compareStreamConfig(currentInfo.config, config);
|
|
1793
|
+
if (!diff.hasChanges) {
|
|
1794
|
+
this.logger.debug(`Stream ${config.name}: no config changes`);
|
|
1795
|
+
return currentInfo;
|
|
1796
|
+
}
|
|
1797
|
+
this.logChanges(config.name, diff, !!this.options.allowDestructiveMigration);
|
|
1798
|
+
if (diff.hasTransportControlledConflicts) {
|
|
1799
|
+
const conflicts = diff.changes.filter((c) => c.mutability === "transport-controlled").map((c) => `${c.property}: ${JSON.stringify(c.current)} \u2192 ${JSON.stringify(c.desired)}`).join(", ");
|
|
1800
|
+
throw new Error(
|
|
1801
|
+
`Stream ${config.name} has transport-controlled config conflicts that cannot be migrated: ${conflicts}. The retention policy is managed by the transport and must match the stream kind.`
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
if (!diff.hasImmutableChanges) {
|
|
1805
|
+
this.logger.debug(`Stream exists, updating: ${config.name}`);
|
|
1806
|
+
return await jsm.streams.update(config.name, config);
|
|
1807
|
+
}
|
|
1808
|
+
if (!this.options.allowDestructiveMigration) {
|
|
1809
|
+
this.logger.warn(
|
|
1810
|
+
`Stream ${config.name} has immutable config conflicts. Enable allowDestructiveMigration to recreate the stream.`
|
|
1811
|
+
);
|
|
1812
|
+
if (diff.hasMutableChanges) {
|
|
1813
|
+
const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
|
|
1814
|
+
return await jsm.streams.update(config.name, mutableConfig);
|
|
1815
|
+
}
|
|
1816
|
+
return currentInfo;
|
|
1817
|
+
}
|
|
1818
|
+
await this.migration.migrate(jsm, config.name, config);
|
|
1819
|
+
return await jsm.streams.info(config.name);
|
|
1820
|
+
}
|
|
1821
|
+
buildMutableOnlyConfig(config, currentConfig, diff) {
|
|
1822
|
+
const nonMutableKeys = new Set(
|
|
1823
|
+
diff.changes.filter((c) => c.mutability === "immutable" || c.mutability === "transport-controlled").map((c) => c.property)
|
|
1824
|
+
);
|
|
1825
|
+
const filtered = { ...config };
|
|
1826
|
+
for (const key of nonMutableKeys) {
|
|
1827
|
+
filtered[key] = currentConfig[key];
|
|
1828
|
+
}
|
|
1829
|
+
return filtered;
|
|
1830
|
+
}
|
|
1831
|
+
logChanges(streamName2, diff, migrationEnabled) {
|
|
1832
|
+
for (const c of diff.changes) {
|
|
1833
|
+
const detail = `${c.property}: ${JSON.stringify(c.current)} \u2192 ${JSON.stringify(c.desired)}`;
|
|
1834
|
+
if (c.mutability === "transport-controlled") {
|
|
1835
|
+
this.logger.error(
|
|
1836
|
+
`Stream ${streamName2}: ${detail} \u2014 transport-controlled, cannot be changed`
|
|
1837
|
+
);
|
|
1838
|
+
} else if (c.mutability === "immutable" && !migrationEnabled) {
|
|
1839
|
+
this.logger.warn(`Stream ${streamName2}: ${detail} \u2014 requires allowDestructiveMigration`);
|
|
1840
|
+
} else {
|
|
1841
|
+
this.logger.log(`Stream ${streamName2}: ${detail}`);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1528
1845
|
/** Build the full stream config by merging defaults with user overrides. */
|
|
1529
1846
|
buildConfig(kind) {
|
|
1530
1847
|
const name = this.getStreamName(kind);
|
|
@@ -1540,6 +1857,26 @@ var StreamProvider = class {
|
|
|
1540
1857
|
description
|
|
1541
1858
|
};
|
|
1542
1859
|
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Build the stream configuration for the Dead-Letter Queue (DLQ).
|
|
1862
|
+
*
|
|
1863
|
+
* Merges the library default DLQ config with user-provided overrides.
|
|
1864
|
+
* Ensures transport-controlled settings like retention are safely decoupled.
|
|
1865
|
+
*/
|
|
1866
|
+
buildDlqConfig() {
|
|
1867
|
+
const name = dlqStreamName(this.options.name);
|
|
1868
|
+
const subjects = [name];
|
|
1869
|
+
const description = `JetStream DLQ stream for ${this.options.name}`;
|
|
1870
|
+
const overrides = this.options.dlq?.stream ?? {};
|
|
1871
|
+
const safeOverrides = this.stripTransportControlled(overrides);
|
|
1872
|
+
return {
|
|
1873
|
+
...DEFAULT_DLQ_STREAM_CONFIG,
|
|
1874
|
+
...safeOverrides,
|
|
1875
|
+
name,
|
|
1876
|
+
subjects,
|
|
1877
|
+
description
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1543
1880
|
/** Get default config for a stream kind. */
|
|
1544
1881
|
getDefaults(kind) {
|
|
1545
1882
|
switch (kind) {
|
|
@@ -1558,25 +1895,44 @@ var StreamProvider = class {
|
|
|
1558
1895
|
const overrides = this.getOverrides(kind);
|
|
1559
1896
|
return overrides.allow_msg_schedules === true;
|
|
1560
1897
|
}
|
|
1561
|
-
/** Get user-provided overrides for a stream kind. */
|
|
1898
|
+
/** Get user-provided overrides for a stream kind, stripping transport-controlled properties. */
|
|
1562
1899
|
getOverrides(kind) {
|
|
1900
|
+
let overrides;
|
|
1563
1901
|
switch (kind) {
|
|
1564
1902
|
case "ev" /* Event */:
|
|
1565
|
-
|
|
1903
|
+
overrides = this.options.events?.stream ?? {};
|
|
1904
|
+
break;
|
|
1566
1905
|
case "cmd" /* Command */:
|
|
1567
|
-
|
|
1906
|
+
overrides = this.options.rpc?.mode === "jetstream" ? this.options.rpc.stream ?? {} : {};
|
|
1907
|
+
break;
|
|
1568
1908
|
case "broadcast" /* Broadcast */:
|
|
1569
|
-
|
|
1909
|
+
overrides = this.options.broadcast?.stream ?? {};
|
|
1910
|
+
break;
|
|
1570
1911
|
case "ordered" /* Ordered */:
|
|
1571
|
-
|
|
1912
|
+
overrides = this.options.ordered?.stream ?? {};
|
|
1913
|
+
break;
|
|
1572
1914
|
}
|
|
1915
|
+
return this.stripTransportControlled(overrides);
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Remove transport-controlled properties from user overrides.
|
|
1919
|
+
* `retention` is managed by the transport (Workqueue/Limits per stream kind)
|
|
1920
|
+
* and silently stripped to protect users from misconfiguration.
|
|
1921
|
+
*/
|
|
1922
|
+
stripTransportControlled(overrides) {
|
|
1923
|
+
if (!("retention" in overrides)) return overrides;
|
|
1924
|
+
this.logger.debug(
|
|
1925
|
+
"Stripping user-provided retention override \u2014 retention is managed by the transport"
|
|
1926
|
+
);
|
|
1927
|
+
const cleaned = { ...overrides };
|
|
1928
|
+
delete cleaned.retention;
|
|
1929
|
+
return cleaned;
|
|
1573
1930
|
}
|
|
1574
1931
|
};
|
|
1575
1932
|
|
|
1576
1933
|
// src/server/infrastructure/consumer.provider.ts
|
|
1577
|
-
var
|
|
1578
|
-
var
|
|
1579
|
-
var CONSUMER_NOT_FOUND = 10014;
|
|
1934
|
+
var import_common7 = require("@nestjs/common");
|
|
1935
|
+
var import_jetstream16 = require("@nats-io/jetstream");
|
|
1580
1936
|
var ConsumerProvider = class {
|
|
1581
1937
|
constructor(options, connection, streamProvider, patternRegistry) {
|
|
1582
1938
|
this.options = options;
|
|
@@ -1584,7 +1940,7 @@ var ConsumerProvider = class {
|
|
|
1584
1940
|
this.streamProvider = streamProvider;
|
|
1585
1941
|
this.patternRegistry = patternRegistry;
|
|
1586
1942
|
}
|
|
1587
|
-
logger = new
|
|
1943
|
+
logger = new import_common7.Logger("Jetstream:Consumer");
|
|
1588
1944
|
/**
|
|
1589
1945
|
* Ensure consumers exist for the specified kinds.
|
|
1590
1946
|
*
|
|
@@ -1605,7 +1961,11 @@ var ConsumerProvider = class {
|
|
|
1605
1961
|
getConsumerName(kind) {
|
|
1606
1962
|
return consumerName(this.options.name, kind);
|
|
1607
1963
|
}
|
|
1608
|
-
/**
|
|
1964
|
+
/**
|
|
1965
|
+
* Ensure a single consumer exists with the desired config.
|
|
1966
|
+
* Used at **startup** — creates or updates the consumer to match
|
|
1967
|
+
* the current pod's configuration.
|
|
1968
|
+
*/
|
|
1609
1969
|
async ensureConsumer(jsm, kind) {
|
|
1610
1970
|
const stream = this.streamProvider.getStreamName(kind);
|
|
1611
1971
|
const config = this.buildConfig(kind);
|
|
@@ -1616,13 +1976,74 @@ var ConsumerProvider = class {
|
|
|
1616
1976
|
this.logger.debug(`Consumer exists, updating: ${name}`);
|
|
1617
1977
|
return await jsm.consumers.update(stream, name, config);
|
|
1618
1978
|
} catch (err) {
|
|
1619
|
-
if (err instanceof
|
|
1620
|
-
|
|
1621
|
-
|
|
1979
|
+
if (!(err instanceof import_jetstream16.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
1980
|
+
throw err;
|
|
1981
|
+
}
|
|
1982
|
+
return await this.createConsumer(jsm, stream, name, config);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Recover a consumer that disappeared during runtime.
|
|
1987
|
+
* Used by **self-healing** — creates if missing, but NEVER updates config.
|
|
1988
|
+
*
|
|
1989
|
+
* If a migration backup stream exists, another pod is mid-migration — we
|
|
1990
|
+
* throw so the self-healing retry loop waits with backoff until migration
|
|
1991
|
+
* completes and the backup is cleaned up.
|
|
1992
|
+
*
|
|
1993
|
+
* This prevents old pods from:
|
|
1994
|
+
* - Overwriting a newer pod's consumer config during rolling updates
|
|
1995
|
+
* - Creating consumers during migration (which would consume and delete
|
|
1996
|
+
* workqueue messages while they're being restored)
|
|
1997
|
+
*/
|
|
1998
|
+
async recoverConsumer(jsm, kind) {
|
|
1999
|
+
const stream = this.streamProvider.getStreamName(kind);
|
|
2000
|
+
const config = this.buildConfig(kind);
|
|
2001
|
+
const name = config.durable_name;
|
|
2002
|
+
this.logger.log(`Recovering consumer: ${name} on stream: ${stream}`);
|
|
2003
|
+
await this.assertNoMigrationInProgress(jsm, stream);
|
|
2004
|
+
try {
|
|
2005
|
+
return await jsm.consumers.info(stream, name);
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
if (!(err instanceof import_jetstream16.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
2008
|
+
throw err;
|
|
2009
|
+
}
|
|
2010
|
+
return await this.createConsumer(jsm, stream, name, config);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Throw if a migration backup stream exists for this stream.
|
|
2015
|
+
* The self-healing retry loop catches the error and retries with backoff,
|
|
2016
|
+
* naturally waiting until the migrating pod finishes and cleans up the backup.
|
|
2017
|
+
*/
|
|
2018
|
+
async assertNoMigrationInProgress(jsm, stream) {
|
|
2019
|
+
const backupName = `${stream}${MIGRATION_BACKUP_SUFFIX}`;
|
|
2020
|
+
try {
|
|
2021
|
+
await jsm.streams.info(backupName);
|
|
2022
|
+
throw new Error(
|
|
2023
|
+
`Stream ${stream} is being migrated (backup ${backupName} exists). Waiting for migration to complete before recovering consumer.`
|
|
2024
|
+
);
|
|
2025
|
+
} catch (err) {
|
|
2026
|
+
if (err instanceof import_jetstream16.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
2027
|
+
return;
|
|
1622
2028
|
}
|
|
1623
2029
|
throw err;
|
|
1624
2030
|
}
|
|
1625
2031
|
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Create a consumer, handling the race where another pod creates it first.
|
|
2034
|
+
*/
|
|
2035
|
+
async createConsumer(jsm, stream, name, config) {
|
|
2036
|
+
this.logger.log(`Creating consumer: ${name}`);
|
|
2037
|
+
try {
|
|
2038
|
+
return await jsm.consumers.add(stream, config);
|
|
2039
|
+
} catch (addErr) {
|
|
2040
|
+
if (addErr instanceof import_jetstream16.JetStreamApiError && addErr.apiError().err_code === 10148 /* ConsumerAlreadyExists */) {
|
|
2041
|
+
this.logger.debug(`Consumer ${name} created by another pod, using existing`);
|
|
2042
|
+
return await jsm.consumers.info(stream, name);
|
|
2043
|
+
}
|
|
2044
|
+
throw addErr;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
1626
2047
|
/** Build consumer config by merging defaults with user overrides. */
|
|
1627
2048
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
|
|
1628
2049
|
buildConfig(kind) {
|
|
@@ -1675,6 +2096,7 @@ var ConsumerProvider = class {
|
|
|
1675
2096
|
return DEFAULT_BROADCAST_CONSUMER_CONFIG;
|
|
1676
2097
|
case "ordered" /* Ordered */:
|
|
1677
2098
|
throw new Error("Ordered consumers are ephemeral and should not use durable config");
|
|
2099
|
+
/* v8 ignore next 5 -- exhaustive switch guard, unreachable */
|
|
1678
2100
|
default: {
|
|
1679
2101
|
const _exhaustive = kind;
|
|
1680
2102
|
throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
|
|
@@ -1692,6 +2114,7 @@ var ConsumerProvider = class {
|
|
|
1692
2114
|
return this.options.broadcast?.consumer ?? {};
|
|
1693
2115
|
case "ordered" /* Ordered */:
|
|
1694
2116
|
throw new Error("Ordered consumers are ephemeral and should not use durable config");
|
|
2117
|
+
/* v8 ignore next 5 -- exhaustive switch guard, unreachable */
|
|
1695
2118
|
default: {
|
|
1696
2119
|
const _exhaustive = kind;
|
|
1697
2120
|
throw new Error(`Unexpected StreamKind: ${_exhaustive}`);
|
|
@@ -1701,16 +2124,17 @@ var ConsumerProvider = class {
|
|
|
1701
2124
|
};
|
|
1702
2125
|
|
|
1703
2126
|
// src/server/infrastructure/message.provider.ts
|
|
1704
|
-
var
|
|
1705
|
-
var
|
|
2127
|
+
var import_common8 = require("@nestjs/common");
|
|
2128
|
+
var import_jetstream18 = require("@nats-io/jetstream");
|
|
1706
2129
|
var import_rxjs3 = require("rxjs");
|
|
1707
2130
|
var MessageProvider = class {
|
|
1708
|
-
constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map()) {
|
|
2131
|
+
constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map(), consumerRecoveryFn) {
|
|
1709
2132
|
this.connection = connection;
|
|
1710
2133
|
this.eventBus = eventBus;
|
|
1711
2134
|
this.consumeOptionsMap = consumeOptionsMap;
|
|
2135
|
+
this.consumerRecoveryFn = consumerRecoveryFn;
|
|
1712
2136
|
}
|
|
1713
|
-
logger = new
|
|
2137
|
+
logger = new import_common8.Logger("Jetstream:Message");
|
|
1714
2138
|
activeIterators = /* @__PURE__ */ new Set();
|
|
1715
2139
|
orderedReadyResolve = null;
|
|
1716
2140
|
orderedReadyReject = null;
|
|
@@ -1762,7 +2186,7 @@ var MessageProvider = class {
|
|
|
1762
2186
|
*/
|
|
1763
2187
|
async startOrdered(streamName2, filterSubjects, orderedConfig) {
|
|
1764
2188
|
const consumerOpts = { filter_subjects: filterSubjects };
|
|
1765
|
-
if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !==
|
|
2189
|
+
if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !== import_jetstream18.DeliverPolicy.All) {
|
|
1766
2190
|
consumerOpts.deliver_policy = orderedConfig.deliverPolicy;
|
|
1767
2191
|
}
|
|
1768
2192
|
if (orderedConfig?.optStartSeq !== void 0) {
|
|
@@ -1813,12 +2237,26 @@ var MessageProvider = class {
|
|
|
1813
2237
|
/** Single iteration: get consumer -> pull messages -> emit to subject. */
|
|
1814
2238
|
async consumeOnce(kind, info, target$) {
|
|
1815
2239
|
const js = this.connection.getJetStreamClient();
|
|
1816
|
-
|
|
2240
|
+
let consumer;
|
|
2241
|
+
let consumerName2 = info.name;
|
|
2242
|
+
try {
|
|
2243
|
+
consumer = await js.consumers.get(info.stream_name, info.name);
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
if (this.isConsumerNotFound(err) && this.consumerRecoveryFn) {
|
|
2246
|
+
this.logger.warn(`Consumer ${info.name} not found, recreating...`);
|
|
2247
|
+
const recovered = await this.consumerRecoveryFn(kind);
|
|
2248
|
+
consumerName2 = recovered.name;
|
|
2249
|
+
this.logger.log(`Consumer ${consumerName2} recreated, resuming consumption`);
|
|
2250
|
+
consumer = await js.consumers.get(recovered.stream_name, consumerName2);
|
|
2251
|
+
} else {
|
|
2252
|
+
throw err;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
1817
2255
|
const defaults = { idle_heartbeat: 5e3 };
|
|
1818
2256
|
const userOptions = this.consumeOptionsMap.get(kind) ?? {};
|
|
1819
2257
|
const messages = await consumer.consume({ ...defaults, ...userOptions });
|
|
1820
2258
|
this.activeIterators.add(messages);
|
|
1821
|
-
this.monitorConsumerHealth(messages,
|
|
2259
|
+
this.monitorConsumerHealth(messages, consumerName2);
|
|
1822
2260
|
try {
|
|
1823
2261
|
for await (const msg of messages) {
|
|
1824
2262
|
target$.next(msg);
|
|
@@ -1827,6 +2265,17 @@ var MessageProvider = class {
|
|
|
1827
2265
|
this.activeIterators.delete(messages);
|
|
1828
2266
|
}
|
|
1829
2267
|
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Detect "consumer not found" errors from `js.consumers.get()`.
|
|
2270
|
+
*
|
|
2271
|
+
* Unlike JetStream Manager calls (which throw `JetStreamApiError`),
|
|
2272
|
+
* the JetStream client's `consumers.get()` throws a plain `Error`
|
|
2273
|
+
* with the error code embedded in the message text.
|
|
2274
|
+
*/
|
|
2275
|
+
isConsumerNotFound(err) {
|
|
2276
|
+
if (!(err instanceof Error)) return false;
|
|
2277
|
+
return err.message.includes("consumer not found") || err.message.includes(String(10014 /* ConsumerNotFound */));
|
|
2278
|
+
}
|
|
1830
2279
|
/** Get the target subject for a consumer kind. */
|
|
1831
2280
|
getTargetSubject(kind) {
|
|
1832
2281
|
switch (kind) {
|
|
@@ -1838,6 +2287,7 @@ var MessageProvider = class {
|
|
|
1838
2287
|
return this.broadcastMessages$;
|
|
1839
2288
|
case "ordered" /* Ordered */:
|
|
1840
2289
|
return this.orderedMessages$;
|
|
2290
|
+
/* v8 ignore next 5 -- exhaustive switch guard, unreachable */
|
|
1841
2291
|
default: {
|
|
1842
2292
|
const _exhaustive = kind;
|
|
1843
2293
|
throw new Error(`Unknown stream kind: ${_exhaustive}`);
|
|
@@ -1923,8 +2373,110 @@ var MessageProvider = class {
|
|
|
1923
2373
|
}
|
|
1924
2374
|
};
|
|
1925
2375
|
|
|
2376
|
+
// src/server/infrastructure/metadata.provider.ts
|
|
2377
|
+
var import_common9 = require("@nestjs/common");
|
|
2378
|
+
var import_kv = require("@nats-io/kv");
|
|
2379
|
+
var MetadataProvider = class {
|
|
2380
|
+
constructor(options, connection) {
|
|
2381
|
+
this.connection = connection;
|
|
2382
|
+
this.bucketName = options.metadata?.bucket ?? DEFAULT_METADATA_BUCKET;
|
|
2383
|
+
this.replicas = options.metadata?.replicas ?? DEFAULT_METADATA_REPLICAS;
|
|
2384
|
+
this.ttl = Math.max(options.metadata?.ttl ?? DEFAULT_METADATA_TTL, MIN_METADATA_TTL);
|
|
2385
|
+
}
|
|
2386
|
+
logger = new import_common9.Logger("Jetstream:Metadata");
|
|
2387
|
+
bucketName;
|
|
2388
|
+
replicas;
|
|
2389
|
+
ttl;
|
|
2390
|
+
currentEntries;
|
|
2391
|
+
heartbeatTimer;
|
|
2392
|
+
cachedKv;
|
|
2393
|
+
/**
|
|
2394
|
+
* Write handler metadata entries to the KV bucket and start heartbeat.
|
|
2395
|
+
*
|
|
2396
|
+
* Creates the bucket if it doesn't exist (idempotent).
|
|
2397
|
+
* Skips silently when entries map is empty.
|
|
2398
|
+
* Starts a heartbeat interval that refreshes entries every `ttl / 2`
|
|
2399
|
+
* to prevent TTL expiry while the pod is alive.
|
|
2400
|
+
*
|
|
2401
|
+
* Non-critical — errors are logged but do not prevent transport startup.
|
|
2402
|
+
*
|
|
2403
|
+
* @param entries Map of KV key → metadata object.
|
|
2404
|
+
*/
|
|
2405
|
+
async publish(entries) {
|
|
2406
|
+
if (entries.size === 0) return;
|
|
2407
|
+
try {
|
|
2408
|
+
const kv = await this.openBucket();
|
|
2409
|
+
await this.writeEntries(kv, entries);
|
|
2410
|
+
this.currentEntries = entries;
|
|
2411
|
+
this.startHeartbeat();
|
|
2412
|
+
this.logger.log(
|
|
2413
|
+
`Published ${entries.size} handler metadata entries to KV bucket "${this.bucketName}"`
|
|
2414
|
+
);
|
|
2415
|
+
} catch (err) {
|
|
2416
|
+
this.logger.error("Failed to publish handler metadata to KV", err);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Stop the heartbeat timer.
|
|
2421
|
+
*
|
|
2422
|
+
* After this call, entries will expire via TTL once the heartbeat window passes.
|
|
2423
|
+
* Called during transport shutdown (strategy.close()).
|
|
2424
|
+
*/
|
|
2425
|
+
destroy() {
|
|
2426
|
+
if (this.heartbeatTimer) {
|
|
2427
|
+
clearInterval(this.heartbeatTimer);
|
|
2428
|
+
this.heartbeatTimer = void 0;
|
|
2429
|
+
}
|
|
2430
|
+
this.currentEntries = void 0;
|
|
2431
|
+
this.cachedKv = void 0;
|
|
2432
|
+
}
|
|
2433
|
+
/** Write entries to KV with per-entry error handling. */
|
|
2434
|
+
async writeEntries(kv, entries) {
|
|
2435
|
+
for (const [key, meta] of entries) {
|
|
2436
|
+
try {
|
|
2437
|
+
await kv.put(key, JSON.stringify(meta));
|
|
2438
|
+
} catch (err) {
|
|
2439
|
+
this.logger.error(`Failed to write metadata entry "${key}"`, err);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
/** Start heartbeat interval that refreshes entries every ttl/2. */
|
|
2444
|
+
startHeartbeat() {
|
|
2445
|
+
if (this.heartbeatTimer) {
|
|
2446
|
+
clearInterval(this.heartbeatTimer);
|
|
2447
|
+
}
|
|
2448
|
+
const interval = Math.floor(this.ttl / 2);
|
|
2449
|
+
this.heartbeatTimer = setInterval(() => {
|
|
2450
|
+
void this.refreshEntries();
|
|
2451
|
+
}, interval);
|
|
2452
|
+
this.heartbeatTimer.unref();
|
|
2453
|
+
}
|
|
2454
|
+
/** Refresh all current entries in KV (heartbeat tick). */
|
|
2455
|
+
async refreshEntries() {
|
|
2456
|
+
if (!this.currentEntries || this.currentEntries.size === 0) return;
|
|
2457
|
+
try {
|
|
2458
|
+
const kv = await this.openBucket();
|
|
2459
|
+
await this.writeEntries(kv, this.currentEntries);
|
|
2460
|
+
} catch (err) {
|
|
2461
|
+
this.logger.error("Failed to refresh handler metadata in KV", err);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
/** Create or open the KV bucket (cached after first call). */
|
|
2465
|
+
async openBucket() {
|
|
2466
|
+
if (this.cachedKv) return this.cachedKv;
|
|
2467
|
+
const js = this.connection.getJetStreamClient();
|
|
2468
|
+
const kvm = new import_kv.Kvm(js);
|
|
2469
|
+
this.cachedKv = await kvm.create(this.bucketName, {
|
|
2470
|
+
history: DEFAULT_METADATA_HISTORY,
|
|
2471
|
+
replicas: this.replicas,
|
|
2472
|
+
ttl: this.ttl
|
|
2473
|
+
});
|
|
2474
|
+
return this.cachedKv;
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
2477
|
+
|
|
1926
2478
|
// src/server/routing/pattern-registry.ts
|
|
1927
|
-
var
|
|
2479
|
+
var import_common10 = require("@nestjs/common");
|
|
1928
2480
|
var HANDLER_LABELS = {
|
|
1929
2481
|
["broadcast" /* Broadcast */]: "broadcast" /* Broadcast */,
|
|
1930
2482
|
["ordered" /* Ordered */]: "ordered" /* Ordered */,
|
|
@@ -1935,7 +2487,7 @@ var PatternRegistry = class {
|
|
|
1935
2487
|
constructor(options) {
|
|
1936
2488
|
this.options = options;
|
|
1937
2489
|
}
|
|
1938
|
-
logger = new
|
|
2490
|
+
logger = new import_common10.Logger("Jetstream:PatternRegistry");
|
|
1939
2491
|
registry = /* @__PURE__ */ new Map();
|
|
1940
2492
|
// Cached after registerHandlers() — the registry is immutable from that point
|
|
1941
2493
|
cachedPatterns = null;
|
|
@@ -1943,6 +2495,7 @@ var PatternRegistry = class {
|
|
|
1943
2495
|
_hasCommands = false;
|
|
1944
2496
|
_hasBroadcasts = false;
|
|
1945
2497
|
_hasOrdered = false;
|
|
2498
|
+
_hasMetadata = false;
|
|
1946
2499
|
/**
|
|
1947
2500
|
* Register all handlers from the NestJS strategy.
|
|
1948
2501
|
*
|
|
@@ -1955,6 +2508,7 @@ var PatternRegistry = class {
|
|
|
1955
2508
|
const isEvent = handler.isEventHandler ?? false;
|
|
1956
2509
|
const isBroadcast = !!extras?.broadcast;
|
|
1957
2510
|
const isOrdered = !!extras?.ordered;
|
|
2511
|
+
const meta = extras?.meta;
|
|
1958
2512
|
if (isBroadcast && isOrdered) {
|
|
1959
2513
|
throw new Error(
|
|
1960
2514
|
`Handler "${pattern}" cannot be both broadcast and ordered. Use one or the other.`
|
|
@@ -1971,7 +2525,8 @@ var PatternRegistry = class {
|
|
|
1971
2525
|
pattern,
|
|
1972
2526
|
isEvent: isEvent && !isOrdered,
|
|
1973
2527
|
isBroadcast,
|
|
1974
|
-
isOrdered
|
|
2528
|
+
isOrdered,
|
|
2529
|
+
meta
|
|
1975
2530
|
});
|
|
1976
2531
|
this.logger.debug(`Registered ${HANDLER_LABELS[kind]}: ${pattern} -> ${fullSubject}`);
|
|
1977
2532
|
}
|
|
@@ -1980,6 +2535,7 @@ var PatternRegistry = class {
|
|
|
1980
2535
|
this._hasCommands = this.cachedPatterns.commands.length > 0;
|
|
1981
2536
|
this._hasBroadcasts = this.cachedPatterns.broadcasts.length > 0;
|
|
1982
2537
|
this._hasOrdered = this.cachedPatterns.ordered.length > 0;
|
|
2538
|
+
this._hasMetadata = [...this.registry.values()].some((entry) => entry.meta !== void 0);
|
|
1983
2539
|
this.logSummary();
|
|
1984
2540
|
}
|
|
1985
2541
|
/** Find handler for a full NATS subject. */
|
|
@@ -2008,6 +2564,26 @@ var PatternRegistry = class {
|
|
|
2008
2564
|
(p) => buildSubject(this.options.name, "ordered" /* Ordered */, p)
|
|
2009
2565
|
);
|
|
2010
2566
|
}
|
|
2567
|
+
/** Check if any registered handler has metadata. */
|
|
2568
|
+
hasMetadata() {
|
|
2569
|
+
return this._hasMetadata;
|
|
2570
|
+
}
|
|
2571
|
+
/**
|
|
2572
|
+
* Get handler metadata entries for KV publishing.
|
|
2573
|
+
*
|
|
2574
|
+
* Returns a map of KV key -> metadata object for all handlers that have `meta`.
|
|
2575
|
+
* Key format: `{serviceName}.{kind}.{pattern}`.
|
|
2576
|
+
*/
|
|
2577
|
+
getMetadataEntries() {
|
|
2578
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2579
|
+
for (const entry of this.registry.values()) {
|
|
2580
|
+
if (!entry.meta) continue;
|
|
2581
|
+
const kind = this.resolveStreamKind(entry);
|
|
2582
|
+
const key = metadataKey(this.options.name, kind, entry.pattern);
|
|
2583
|
+
entries.set(key, entry.meta);
|
|
2584
|
+
}
|
|
2585
|
+
return entries;
|
|
2586
|
+
}
|
|
2011
2587
|
/** Get patterns grouped by kind (cached after registration). */
|
|
2012
2588
|
getPatternsByKind() {
|
|
2013
2589
|
const patterns = this.cachedPatterns ?? this.buildPatternsByKind();
|
|
@@ -2047,6 +2623,12 @@ var PatternRegistry = class {
|
|
|
2047
2623
|
}
|
|
2048
2624
|
return { events, commands, broadcasts, ordered };
|
|
2049
2625
|
}
|
|
2626
|
+
resolveStreamKind(entry) {
|
|
2627
|
+
if (entry.isBroadcast) return "broadcast" /* Broadcast */;
|
|
2628
|
+
if (entry.isOrdered) return "ordered" /* Ordered */;
|
|
2629
|
+
if (entry.isEvent) return "ev" /* Event */;
|
|
2630
|
+
return "cmd" /* Command */;
|
|
2631
|
+
}
|
|
2050
2632
|
logSummary() {
|
|
2051
2633
|
const { events, commands, broadcasts, ordered } = this.getPatternsByKind();
|
|
2052
2634
|
const parts = [
|
|
@@ -2062,10 +2644,11 @@ var PatternRegistry = class {
|
|
|
2062
2644
|
};
|
|
2063
2645
|
|
|
2064
2646
|
// src/server/routing/event.router.ts
|
|
2065
|
-
var
|
|
2647
|
+
var import_common11 = require("@nestjs/common");
|
|
2066
2648
|
var import_rxjs4 = require("rxjs");
|
|
2649
|
+
var import_transport_node4 = require("@nats-io/transport-node");
|
|
2067
2650
|
var EventRouter = class {
|
|
2068
|
-
constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap) {
|
|
2651
|
+
constructor(messageProvider, patternRegistry, codec, eventBus, deadLetterConfig, processingConfig, ackWaitMap, connection, options) {
|
|
2069
2652
|
this.messageProvider = messageProvider;
|
|
2070
2653
|
this.patternRegistry = patternRegistry;
|
|
2071
2654
|
this.codec = codec;
|
|
@@ -2073,8 +2656,10 @@ var EventRouter = class {
|
|
|
2073
2656
|
this.deadLetterConfig = deadLetterConfig;
|
|
2074
2657
|
this.processingConfig = processingConfig;
|
|
2075
2658
|
this.ackWaitMap = ackWaitMap;
|
|
2659
|
+
this.connection = connection;
|
|
2660
|
+
this.options = options;
|
|
2076
2661
|
}
|
|
2077
|
-
logger = new
|
|
2662
|
+
logger = new import_common11.Logger("Jetstream:EventRouter");
|
|
2078
2663
|
subscriptions = [];
|
|
2079
2664
|
/**
|
|
2080
2665
|
* Update the max_deliver thresholds from actual NATS consumer configs.
|
|
@@ -2201,6 +2786,93 @@ var EventRouter = class {
|
|
|
2201
2786
|
return msg.info.deliveryCount >= maxDeliver;
|
|
2202
2787
|
}
|
|
2203
2788
|
/** Handle a dead letter: invoke callback, then term or nak based on result. */
|
|
2789
|
+
/**
|
|
2790
|
+
* Fallback execution for a dead letter when DLQ is disabled, or when
|
|
2791
|
+
* publishing to the DLQ stream fails (due to network or NATS errors).
|
|
2792
|
+
*
|
|
2793
|
+
* Triggers the user-provided `onDeadLetter` hook for logging/alerting.
|
|
2794
|
+
* On success, terminates the message. On error, leaves it unacknowledged (nak)
|
|
2795
|
+
* so NATS can retry the delivery on the next cycle.
|
|
2796
|
+
*/
|
|
2797
|
+
async fallbackToOnDeadLetterCallback(info, msg) {
|
|
2798
|
+
if (!this.deadLetterConfig) {
|
|
2799
|
+
msg.term("Dead letter config unavailable");
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
try {
|
|
2803
|
+
await this.deadLetterConfig.onDeadLetter(info);
|
|
2804
|
+
msg.term("Dead letter processed via fallback callback");
|
|
2805
|
+
} catch (hookErr) {
|
|
2806
|
+
this.logger.error(
|
|
2807
|
+
`Fallback onDeadLetter callback failed for ${msg.subject}, nak for retry:`,
|
|
2808
|
+
hookErr
|
|
2809
|
+
);
|
|
2810
|
+
msg.nak();
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
|
|
2815
|
+
*
|
|
2816
|
+
* Appends diagnostic metadata headers to the original message and preserves
|
|
2817
|
+
* the primary payload. If publishing succeeds, it notifies the standard
|
|
2818
|
+
* `onDeadLetter` callback and terminates the message. If it fails, it falls
|
|
2819
|
+
* back to the callback entirely to prevent silent data loss.
|
|
2820
|
+
*/
|
|
2821
|
+
async publishToDlq(msg, info, error) {
|
|
2822
|
+
const serviceName = this.options?.name;
|
|
2823
|
+
if (!this.connection || !serviceName) {
|
|
2824
|
+
this.logger.error(
|
|
2825
|
+
`Cannot publish to DLQ for ${msg.subject}: Connection or Module Options unavailable`
|
|
2826
|
+
);
|
|
2827
|
+
await this.fallbackToOnDeadLetterCallback(info, msg);
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
const destinationSubject = dlqStreamName(serviceName);
|
|
2831
|
+
const hdrs = (0, import_transport_node4.headers)();
|
|
2832
|
+
if (msg.headers) {
|
|
2833
|
+
for (const [k, v] of msg.headers) {
|
|
2834
|
+
for (const val of v) {
|
|
2835
|
+
hdrs.append(k, val);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
let reason = String(error);
|
|
2840
|
+
if (error instanceof Error) {
|
|
2841
|
+
reason = error.message;
|
|
2842
|
+
} else if (typeof error === "object" && error !== null && "message" in error) {
|
|
2843
|
+
reason = String(error.message);
|
|
2844
|
+
}
|
|
2845
|
+
hdrs.set("x-dead-letter-reason" /* DeadLetterReason */, reason);
|
|
2846
|
+
hdrs.set("x-original-subject" /* OriginalSubject */, msg.subject);
|
|
2847
|
+
hdrs.set("x-original-stream" /* OriginalStream */, msg.info.stream);
|
|
2848
|
+
hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
|
|
2849
|
+
hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
|
|
2850
|
+
try {
|
|
2851
|
+
const js = this.connection.getJetStreamClient();
|
|
2852
|
+
await js.publish(destinationSubject, msg.data, { headers: hdrs });
|
|
2853
|
+
this.logger.log(`Message sent to DLQ: ${msg.subject}`);
|
|
2854
|
+
if (this.deadLetterConfig?.onDeadLetter) {
|
|
2855
|
+
try {
|
|
2856
|
+
await this.deadLetterConfig.onDeadLetter(info);
|
|
2857
|
+
} catch (hookErr) {
|
|
2858
|
+
this.logger.warn(
|
|
2859
|
+
`onDeadLetter callback failed after successful DLQ publish for ${msg.subject}`,
|
|
2860
|
+
hookErr
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
msg.term("Moved to DLQ stream");
|
|
2865
|
+
} catch (publishErr) {
|
|
2866
|
+
this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
|
|
2867
|
+
await this.fallbackToOnDeadLetterCallback(info, msg);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Orchestrates the handling of a message that has exhausted delivery limits.
|
|
2872
|
+
*
|
|
2873
|
+
* Emits a system event and delegates either to the robust DLQ stream publisher
|
|
2874
|
+
* or directly to the fallback callback based on the active module configuration.
|
|
2875
|
+
*/
|
|
2204
2876
|
async handleDeadLetter(msg, data, error) {
|
|
2205
2877
|
const info = {
|
|
2206
2878
|
subject: msg.subject,
|
|
@@ -2213,23 +2885,17 @@ var EventRouter = class {
|
|
|
2213
2885
|
timestamp: new Date(msg.info.timestampNanos / 1e6).toISOString()
|
|
2214
2886
|
};
|
|
2215
2887
|
this.eventBus.emit("deadLetter" /* DeadLetter */, info);
|
|
2216
|
-
if (!this.
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
try {
|
|
2221
|
-
await this.deadLetterConfig.onDeadLetter(info);
|
|
2222
|
-
msg.term("Dead letter processed");
|
|
2223
|
-
} catch (hookErr) {
|
|
2224
|
-
this.logger.error(`onDeadLetter callback failed for ${msg.subject}, nak for retry:`, hookErr);
|
|
2225
|
-
msg.nak();
|
|
2888
|
+
if (!this.options?.dlq) {
|
|
2889
|
+
await this.fallbackToOnDeadLetterCallback(info, msg);
|
|
2890
|
+
} else {
|
|
2891
|
+
await this.publishToDlq(msg, info, error);
|
|
2226
2892
|
}
|
|
2227
2893
|
}
|
|
2228
2894
|
};
|
|
2229
2895
|
|
|
2230
2896
|
// src/server/routing/rpc.router.ts
|
|
2231
|
-
var
|
|
2232
|
-
var
|
|
2897
|
+
var import_common12 = require("@nestjs/common");
|
|
2898
|
+
var import_transport_node5 = require("@nats-io/transport-node");
|
|
2233
2899
|
var import_rxjs5 = require("rxjs");
|
|
2234
2900
|
var RpcRouter = class {
|
|
2235
2901
|
constructor(messageProvider, patternRegistry, connection, codec, eventBus, rpcOptions, ackWaitMap) {
|
|
@@ -2243,7 +2909,7 @@ var RpcRouter = class {
|
|
|
2243
2909
|
this.timeout = rpcOptions?.timeout ?? DEFAULT_JETSTREAM_RPC_TIMEOUT;
|
|
2244
2910
|
this.concurrency = rpcOptions?.concurrency;
|
|
2245
2911
|
}
|
|
2246
|
-
logger = new
|
|
2912
|
+
logger = new import_common12.Logger("Jetstream:RpcRouter");
|
|
2247
2913
|
timeout;
|
|
2248
2914
|
concurrency;
|
|
2249
2915
|
resolvedAckExtensionInterval;
|
|
@@ -2321,7 +2987,7 @@ var RpcRouter = class {
|
|
|
2321
2987
|
stopAckExtension?.();
|
|
2322
2988
|
msg.ack();
|
|
2323
2989
|
try {
|
|
2324
|
-
const hdrs = (0,
|
|
2990
|
+
const hdrs = (0, import_transport_node5.headers)();
|
|
2325
2991
|
hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
|
|
2326
2992
|
nc.publish(replyTo, this.codec.encode(result), { headers: hdrs });
|
|
2327
2993
|
} catch (publishErr) {
|
|
@@ -2333,7 +2999,7 @@ var RpcRouter = class {
|
|
|
2333
2999
|
clearTimeout(timeoutId);
|
|
2334
3000
|
stopAckExtension?.();
|
|
2335
3001
|
try {
|
|
2336
|
-
const hdrs = (0,
|
|
3002
|
+
const hdrs = (0, import_transport_node5.headers)();
|
|
2337
3003
|
hdrs.set("x-correlation-id" /* CorrelationId */, correlationId);
|
|
2338
3004
|
hdrs.set("x-error" /* Error */, "true");
|
|
2339
3005
|
nc.publish(replyTo, this.codec.encode(serializeError(err)), { headers: hdrs });
|
|
@@ -2346,20 +3012,27 @@ var RpcRouter = class {
|
|
|
2346
3012
|
};
|
|
2347
3013
|
|
|
2348
3014
|
// src/shutdown/shutdown.manager.ts
|
|
2349
|
-
var
|
|
3015
|
+
var import_common13 = require("@nestjs/common");
|
|
2350
3016
|
var ShutdownManager = class {
|
|
2351
3017
|
constructor(connection, eventBus, timeout) {
|
|
2352
3018
|
this.connection = connection;
|
|
2353
3019
|
this.eventBus = eventBus;
|
|
2354
3020
|
this.timeout = timeout;
|
|
2355
3021
|
}
|
|
2356
|
-
logger = new
|
|
3022
|
+
logger = new import_common13.Logger("Jetstream:Shutdown");
|
|
3023
|
+
shutdownPromise;
|
|
2357
3024
|
/**
|
|
2358
3025
|
* Execute the full shutdown sequence.
|
|
2359
3026
|
*
|
|
3027
|
+
* Idempotent — concurrent or repeated calls return the same promise.
|
|
3028
|
+
*
|
|
2360
3029
|
* @param strategy Optional stoppable to close (stops consumers and subscriptions).
|
|
2361
3030
|
*/
|
|
2362
3031
|
async shutdown(strategy) {
|
|
3032
|
+
this.shutdownPromise ??= this.doShutdown(strategy);
|
|
3033
|
+
return this.shutdownPromise;
|
|
3034
|
+
}
|
|
3035
|
+
async doShutdown(strategy) {
|
|
2363
3036
|
this.eventBus.emit("shutdownStart" /* ShutdownStart */);
|
|
2364
3037
|
this.logger.log(`Graceful shutdown started (timeout: ${this.timeout}ms)`);
|
|
2365
3038
|
strategy?.close();
|
|
@@ -2494,7 +3167,7 @@ var JetstreamModule = class {
|
|
|
2494
3167
|
provide: JETSTREAM_EVENT_BUS,
|
|
2495
3168
|
inject: [JETSTREAM_OPTIONS],
|
|
2496
3169
|
useFactory: (options) => {
|
|
2497
|
-
const logger = new
|
|
3170
|
+
const logger = new import_common14.Logger("Jetstream:Module");
|
|
2498
3171
|
return new EventBus(logger, options.hooks);
|
|
2499
3172
|
}
|
|
2500
3173
|
},
|
|
@@ -2573,8 +3246,8 @@ var JetstreamModule = class {
|
|
|
2573
3246
|
// MessageProvider — pull-based message consumption
|
|
2574
3247
|
{
|
|
2575
3248
|
provide: MessageProvider,
|
|
2576
|
-
inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS],
|
|
2577
|
-
useFactory: (options, connection, eventBus) => {
|
|
3249
|
+
inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION, JETSTREAM_EVENT_BUS, ConsumerProvider],
|
|
3250
|
+
useFactory: (options, connection, eventBus, consumerProvider) => {
|
|
2578
3251
|
if (options.consumer === false) return null;
|
|
2579
3252
|
const consumeOptionsMap = /* @__PURE__ */ new Map();
|
|
2580
3253
|
if (options.events?.consume)
|
|
@@ -2584,7 +3257,11 @@ var JetstreamModule = class {
|
|
|
2584
3257
|
if (options.rpc?.mode === "jetstream" && options.rpc.consume) {
|
|
2585
3258
|
consumeOptionsMap.set("cmd" /* Command */, options.rpc.consume);
|
|
2586
3259
|
}
|
|
2587
|
-
|
|
3260
|
+
const consumerRecoveryFn = consumerProvider ? async (kind) => {
|
|
3261
|
+
const jsm = await connection.getJetStreamManager();
|
|
3262
|
+
return consumerProvider.recoverConsumer(jsm, kind);
|
|
3263
|
+
} : void 0;
|
|
3264
|
+
return new MessageProvider(connection, eventBus, consumeOptionsMap, consumerRecoveryFn);
|
|
2588
3265
|
}
|
|
2589
3266
|
},
|
|
2590
3267
|
// EventRouter — routes event and broadcast messages to handlers
|
|
@@ -2596,9 +3273,10 @@ var JetstreamModule = class {
|
|
|
2596
3273
|
PatternRegistry,
|
|
2597
3274
|
JETSTREAM_CODEC,
|
|
2598
3275
|
JETSTREAM_EVENT_BUS,
|
|
2599
|
-
JETSTREAM_ACK_WAIT_MAP
|
|
3276
|
+
JETSTREAM_ACK_WAIT_MAP,
|
|
3277
|
+
JETSTREAM_CONNECTION
|
|
2600
3278
|
],
|
|
2601
|
-
useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap) => {
|
|
3279
|
+
useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
|
|
2602
3280
|
if (options.consumer === false) return null;
|
|
2603
3281
|
const deadLetterConfig = options.onDeadLetter ? {
|
|
2604
3282
|
maxDeliverByStream: /* @__PURE__ */ new Map(),
|
|
@@ -2621,7 +3299,9 @@ var JetstreamModule = class {
|
|
|
2621
3299
|
eventBus,
|
|
2622
3300
|
deadLetterConfig,
|
|
2623
3301
|
processingConfig,
|
|
2624
|
-
ackWaitMap
|
|
3302
|
+
ackWaitMap,
|
|
3303
|
+
connection,
|
|
3304
|
+
options
|
|
2625
3305
|
);
|
|
2626
3306
|
}
|
|
2627
3307
|
},
|
|
@@ -2670,6 +3350,15 @@ var JetstreamModule = class {
|
|
|
2670
3350
|
return new CoreRpcServer(options, connection, patternRegistry, codec, eventBus);
|
|
2671
3351
|
}
|
|
2672
3352
|
},
|
|
3353
|
+
// MetadataProvider — handler metadata KV registry (decoupled from stream/consumer infra)
|
|
3354
|
+
{
|
|
3355
|
+
provide: MetadataProvider,
|
|
3356
|
+
inject: [JETSTREAM_OPTIONS, JETSTREAM_CONNECTION],
|
|
3357
|
+
useFactory: (options, connection) => {
|
|
3358
|
+
if (options.consumer === false) return null;
|
|
3359
|
+
return new MetadataProvider(options, connection);
|
|
3360
|
+
}
|
|
3361
|
+
},
|
|
2673
3362
|
// JetstreamStrategy — server-side transport (only when consumer enabled)
|
|
2674
3363
|
{
|
|
2675
3364
|
provide: JetstreamStrategy,
|
|
@@ -2683,9 +3372,10 @@ var JetstreamModule = class {
|
|
|
2683
3372
|
EventRouter,
|
|
2684
3373
|
RpcRouter,
|
|
2685
3374
|
CoreRpcServer,
|
|
2686
|
-
JETSTREAM_ACK_WAIT_MAP
|
|
3375
|
+
JETSTREAM_ACK_WAIT_MAP,
|
|
3376
|
+
MetadataProvider
|
|
2687
3377
|
],
|
|
2688
|
-
useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap) => {
|
|
3378
|
+
useFactory: (options, connection, patternRegistry, streamProvider, consumerProvider, messageProvider, eventRouter, rpcRouter, coreRpcServer, ackWaitMap, metadataProvider) => {
|
|
2689
3379
|
if (options.consumer === false) return null;
|
|
2690
3380
|
return new JetstreamStrategy(
|
|
2691
3381
|
options,
|
|
@@ -2697,7 +3387,8 @@ var JetstreamModule = class {
|
|
|
2697
3387
|
eventRouter,
|
|
2698
3388
|
rpcRouter,
|
|
2699
3389
|
coreRpcServer,
|
|
2700
|
-
ackWaitMap
|
|
3390
|
+
ackWaitMap,
|
|
3391
|
+
metadataProvider
|
|
2701
3392
|
);
|
|
2702
3393
|
}
|
|
2703
3394
|
}
|
|
@@ -2756,21 +3447,26 @@ var JetstreamModule = class {
|
|
|
2756
3447
|
}
|
|
2757
3448
|
};
|
|
2758
3449
|
JetstreamModule = __decorateClass([
|
|
2759
|
-
(0,
|
|
2760
|
-
(0,
|
|
2761
|
-
__decorateParam(0, (0,
|
|
2762
|
-
__decorateParam(0, (0,
|
|
2763
|
-
__decorateParam(1, (0,
|
|
2764
|
-
__decorateParam(1, (0,
|
|
3450
|
+
(0, import_common14.Global)(),
|
|
3451
|
+
(0, import_common14.Module)({}),
|
|
3452
|
+
__decorateParam(0, (0, import_common14.Optional)()),
|
|
3453
|
+
__decorateParam(0, (0, import_common14.Inject)(ShutdownManager)),
|
|
3454
|
+
__decorateParam(1, (0, import_common14.Optional)()),
|
|
3455
|
+
__decorateParam(1, (0, import_common14.Inject)(JetstreamStrategy))
|
|
2765
3456
|
], JetstreamModule);
|
|
2766
3457
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2767
3458
|
0 && (module.exports = {
|
|
3459
|
+
DEFAULT_METADATA_BUCKET,
|
|
3460
|
+
DEFAULT_METADATA_HISTORY,
|
|
3461
|
+
DEFAULT_METADATA_REPLICAS,
|
|
3462
|
+
DEFAULT_METADATA_TTL,
|
|
2768
3463
|
EventBus,
|
|
2769
3464
|
JETSTREAM_CODEC,
|
|
2770
3465
|
JETSTREAM_CONNECTION,
|
|
2771
3466
|
JETSTREAM_EVENT_BUS,
|
|
2772
3467
|
JETSTREAM_OPTIONS,
|
|
2773
3468
|
JetstreamClient,
|
|
3469
|
+
JetstreamDlqHeader,
|
|
2774
3470
|
JetstreamHeader,
|
|
2775
3471
|
JetstreamHealthIndicator,
|
|
2776
3472
|
JetstreamModule,
|
|
@@ -2778,17 +3474,21 @@ JetstreamModule = __decorateClass([
|
|
|
2778
3474
|
JetstreamRecordBuilder,
|
|
2779
3475
|
JetstreamStrategy,
|
|
2780
3476
|
JsonCodec,
|
|
3477
|
+
MIN_METADATA_TTL,
|
|
2781
3478
|
MessageKind,
|
|
2782
3479
|
PatternPrefix,
|
|
2783
3480
|
RpcContext,
|
|
2784
3481
|
StreamKind,
|
|
2785
3482
|
TransportEvent,
|
|
3483
|
+
buildBroadcastSubject,
|
|
2786
3484
|
buildSubject,
|
|
2787
3485
|
consumerName,
|
|
3486
|
+
dlqStreamName,
|
|
2788
3487
|
getClientToken,
|
|
2789
3488
|
internalName,
|
|
2790
3489
|
isCoreRpcMode,
|
|
2791
3490
|
isJetStreamRpcMode,
|
|
3491
|
+
metadataKey,
|
|
2792
3492
|
streamName,
|
|
2793
3493
|
toNanos
|
|
2794
3494
|
});
|