@mastra/redis-streams 0.3.0 → 0.3.1-alpha.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
@@ -1,621 +1,577 @@
1
- import { randomUUID } from 'crypto';
2
- import { PubSub } from '@mastra/core/events';
3
- import { createClient } from 'redis';
4
-
5
- // src/index.ts
1
+ import { randomUUID } from "crypto";
2
+ import { PubSub } from "@mastra/core/events";
3
+ import { createClient } from "redis";
4
+ //#region src/index.ts
5
+ /**
6
+ * Flatten an error into searchable text. node-redis MULTI failures throw a
7
+ * `MultiErrorReply` whose own message is just "N commands failed…" — the real
8
+ * per-command errors (e.g. BUSYGROUP) live in `err.replies`, so those are
9
+ * folded in too.
10
+ */
6
11
  function errorText(err) {
7
- const parts = [err instanceof Error ? err.message : String(err)];
8
- if (err instanceof Error && "replies" in err && Array.isArray(err.replies)) {
9
- parts.push(...err.replies.map((r) => String(r)));
10
- }
11
- return parts.join("; ");
12
+ const parts = [err instanceof Error ? err.message : String(err)];
13
+ if (err instanceof Error && "replies" in err && Array.isArray(err.replies)) parts.push(...err.replies.map((r) => String(r)));
14
+ return parts.join("; ");
12
15
  }
13
16
  var RedisStreamsPubSub = class extends PubSub {
14
- // Redis Streams is a pull transport: consumers issue XREADGROUP to read
15
- // events. Mastra reads this to know an OrchestrationWorker is required.
16
- get supportedModes() {
17
- return ["pull"];
18
- }
19
- #writeClient;
20
- #connectOptions;
21
- #keyPrefix;
22
- #blockMs;
23
- #maxStreamLength;
24
- #streamIdleTtlMs;
25
- #reclaimIntervalMs;
26
- #reclaimIdleMs;
27
- #maxDeliveryAttempts;
28
- #logger;
29
- // Keyed by `${topic}::${cbId}` so the same callback can be subscribed to
30
- // multiple topics independently. Without the topic in the key,
31
- // unsubscribe(otherTopic, cb) would tear down the wrong subscription.
32
- #subscriptions = /* @__PURE__ */ new Map();
33
- #cbIds = /* @__PURE__ */ new WeakMap();
34
- #pendingPublishes = /* @__PURE__ */ new Set();
35
- // `localOnly` publishes bypass Redis entirely so values carrying live
36
- // methods (e.g. `MastraModelOutput` returned from an evented agent run via
37
- // `workflows-finish`) survive intact. Mirrors the same contract honored by
38
- // `UnixSocketPubSub` so consumers like `Mastra.__registerInternalWorkflow`
39
- // get consistent semantics across transports.
40
- #localCallbacks = /* @__PURE__ */ new Map();
41
- #closed = false;
42
- constructor(options = {}) {
43
- super();
44
- const url = options.url ?? options.redisOptions?.url ?? "redis://localhost:6379";
45
- this.#connectOptions = { ...options.redisOptions, url };
46
- this.#writeClient = createClient(this.#connectOptions);
47
- this.#logger = options.logger;
48
- this.#attachErrorLogger(this.#writeClient, "write");
49
- this.#keyPrefix = options.keyPrefix ?? "mastra:topic";
50
- this.#blockMs = options.blockMs ?? 1e3;
51
- this.#maxStreamLength = options.maxStreamLength ?? 1e4;
52
- const ttl = options.streamIdleTtlMs ?? 0;
53
- if (!Number.isInteger(ttl) || ttl < 0) {
54
- throw new Error(`redis-streams: streamIdleTtlMs must be a non-negative integer (milliseconds), got ${ttl}`);
55
- }
56
- this.#streamIdleTtlMs = ttl;
57
- this.#reclaimIntervalMs = options.reclaimIntervalMs ?? 3e4;
58
- this.#reclaimIdleMs = options.reclaimIdleMs ?? 6e4;
59
- const cap = options.maxDeliveryAttempts ?? 5;
60
- if (cap === 0) {
61
- options.logger?.warn?.(
62
- "redis-streams: maxDeliveryAttempts=0 is treated as Infinity for back-compat; pass Infinity to disable the cap explicitly."
63
- );
64
- this.#maxDeliveryAttempts = Infinity;
65
- } else if (cap < 0 || Number.isNaN(cap)) {
66
- throw new Error(`redis-streams: maxDeliveryAttempts must be >= 1 or Infinity, got ${cap}`);
67
- } else {
68
- this.#maxDeliveryAttempts = cap;
69
- }
70
- }
71
- /**
72
- * Attach the `'error'` listener node-redis requires on every client. Without
73
- * one, a mid-life socket close makes the client emit an unhandled `'error'`,
74
- * which (per EventEmitter semantics) throws from inside RedisSocket's error
75
- * handler BEFORE the built-in reconnect is scheduled — the process gets an
76
- * uncaughtException and the client is left permanently un-reconnected
77
- * (isOpen=true, isReady=false), hanging every subsequent command. Merely
78
- * having a listener lets node-redis's own reconnect run.
79
- *
80
- * Logged at warn so operators can see connection churn, throttled to one line
81
- * per 30s per client: node-redis re-emits `'error'` roughly every 500ms while
82
- * a server is unreachable, and with one reader client per subscription an
83
- * outage would otherwise flood the logs with thousands of identical lines.
84
- */
85
- #attachErrorLogger(client, role, extra = {}) {
86
- let lastWarnAt = 0;
87
- client.on("error", (err) => {
88
- const now = Date.now();
89
- if (now - lastWarnAt < 3e4) return;
90
- lastWarnAt = now;
91
- this.#logger?.warn?.(`redis-streams: ${role} client connection error (node-redis will reconnect)`, {
92
- ...extra,
93
- err: err instanceof Error ? err.message : err
94
- });
95
- });
96
- }
97
- #subKey(topic, cb) {
98
- let cbId = this.#cbIds.get(cb);
99
- if (!cbId) {
100
- cbId = randomUUID();
101
- this.#cbIds.set(cb, cbId);
102
- }
103
- return `${topic}::${cbId}`;
104
- }
105
- /** Lazily connect the shared writer client. Idempotent. */
106
- async #ensureWriterConnected() {
107
- if (this.#writeClient.isOpen) return;
108
- await this.#writeClient.connect();
109
- }
110
- #streamKey(topic) {
111
- return `${this.#keyPrefix}:${topic}`;
112
- }
113
- async publish(topic, event, options) {
114
- if (this.#closed) throw new Error("RedisStreamsPubSub: cannot publish on closed client");
115
- if (options?.localOnly) {
116
- const localEvent = {
117
- ...event,
118
- id: randomUUID(),
119
- createdAt: /* @__PURE__ */ new Date(),
120
- deliveryAttempt: event.deliveryAttempt ?? 1
121
- };
122
- this.#deliverLocal(topic, localEvent);
123
- return;
124
- }
125
- await this.#ensureWriterConnected();
126
- const id = randomUUID();
127
- const createdAt = /* @__PURE__ */ new Date();
128
- const payload = {
129
- ...event,
130
- id,
131
- createdAt,
132
- deliveryAttempt: event.deliveryAttempt ?? 1
133
- };
134
- const xaddOptions = {};
135
- if (this.#maxStreamLength > 0) {
136
- xaddOptions.TRIM = {
137
- strategy: "MAXLEN",
138
- strategyModifier: "~",
139
- threshold: this.#maxStreamLength
140
- };
141
- }
142
- const streamKey = this.#streamKey(topic);
143
- const promise = this.#streamIdleTtlMs > 0 ? this.#writeClient.multi().xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions).pExpire(streamKey, this.#streamIdleTtlMs).exec().then((replies) => {
144
- if (Number(replies[1]) !== 1) {
145
- this.#logger?.warn?.("redis-streams: PEXPIRE inside publish MULTI did not apply", {
146
- streamKey,
147
- reply: String(replies[1])
148
- });
149
- }
150
- }) : this.#writeClient.xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions);
151
- this.#pendingPublishes.add(promise);
152
- try {
153
- await promise;
154
- } finally {
155
- this.#pendingPublishes.delete(promise);
156
- }
157
- }
158
- async subscribe(topic, cb, options) {
159
- if (this.#closed) throw new Error("RedisStreamsPubSub: cannot subscribe on closed client");
160
- const key = this.#subKey(topic, cb);
161
- if (this.#subscriptions.has(key)) return;
162
- let localBucket = this.#localCallbacks.get(topic);
163
- if (!localBucket) {
164
- localBucket = /* @__PURE__ */ new Set();
165
- this.#localCallbacks.set(topic, localBucket);
166
- }
167
- localBucket.add(cb);
168
- await this.#ensureWriterConnected();
169
- const isGrouped = !!options?.group;
170
- const group = options?.group ?? `__fanout-${randomUUID()}`;
171
- const consumer = `${group}-${randomUUID()}`;
172
- const streamKey = this.#streamKey(topic);
173
- try {
174
- await this.#writeClient.xGroupCreate(streamKey, group, "0", { MKSTREAM: true });
175
- } catch (err) {
176
- const msg = err instanceof Error ? err.message : String(err);
177
- if (!msg.includes("BUSYGROUP")) throw err;
178
- this.#logger?.debug?.("redis-streams: consumer group already exists", { topic, group });
179
- }
180
- const readClient = createClient(this.#connectOptions);
181
- this.#attachErrorLogger(readClient, "read", { topic });
182
- await readClient.connect();
183
- const sub = {
184
- cb,
185
- topic,
186
- streamKey,
187
- group,
188
- consumer,
189
- isGrouped,
190
- readClient,
191
- stopped: false,
192
- loop: void 0,
193
- reclaimTimer: void 0
194
- };
195
- this.#subscriptions.set(key, sub);
196
- sub.loop = this.#runReadLoop(sub);
197
- this.#startReclaimLoop(sub);
198
- }
199
- /**
200
- * Periodically run XAUTOCLAIM against this subscription's group so that
201
- * messages a crashed/stuck consumer read but never acked get redelivered to
202
- * a live sibling. Runs only for grouped subscriptions — fan-out groups are
203
- * private to one consumer, so there's no sibling to claim from.
204
- */
205
- #startReclaimLoop(sub) {
206
- if (this.#reclaimIntervalMs <= 0) return;
207
- if (!sub.isGrouped) return;
208
- const tick = async () => {
209
- if (sub.stopped || this.#closed) return;
210
- try {
211
- const reply = await this.#writeClient.xAutoClaim(
212
- sub.streamKey,
213
- sub.group,
214
- sub.consumer,
215
- this.#reclaimIdleMs,
216
- "0-0",
217
- { COUNT: 100 }
218
- );
219
- const messages = reply?.messages ?? [];
220
- for (const entry of messages) {
221
- if (sub.stopped || this.#closed) return;
222
- if (!entry) continue;
223
- await this.#deliverMessage(sub, entry.id, entry.message);
224
- }
225
- } catch (err) {
226
- this.#logger?.debug?.("redis-streams: XAUTOCLAIM failed", {
227
- topic: sub.topic,
228
- group: sub.group,
229
- err: err instanceof Error ? err.message : err
230
- });
231
- }
232
- if (sub.stopped || this.#closed) return;
233
- sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
234
- };
235
- sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
236
- }
237
- async unsubscribe(topic, cb) {
238
- const localBucket = this.#localCallbacks.get(topic);
239
- if (localBucket) {
240
- localBucket.delete(cb);
241
- if (localBucket.size === 0) this.#localCallbacks.delete(topic);
242
- }
243
- const key = this.#subKey(topic, cb);
244
- const sub = this.#subscriptions.get(key);
245
- if (!sub) return;
246
- this.#subscriptions.delete(key);
247
- sub.stopped = true;
248
- if (sub.reclaimTimer) {
249
- clearTimeout(sub.reclaimTimer);
250
- sub.reclaimTimer = void 0;
251
- }
252
- try {
253
- await sub.readClient.quit();
254
- } catch (err) {
255
- this.#logger?.debug?.("redis-streams: reader quit failed", {
256
- topic: sub.topic,
257
- err: err instanceof Error ? err.message : err
258
- });
259
- }
260
- if (sub.loop) {
261
- try {
262
- await sub.loop;
263
- } catch (err) {
264
- this.#logger?.debug?.("redis-streams: read loop exited with error", {
265
- topic: sub.topic,
266
- err: err instanceof Error ? err.message : err
267
- });
268
- }
269
- }
270
- if (!sub.isGrouped) {
271
- try {
272
- await this.#writeClient.xGroupDestroy(sub.streamKey, sub.group);
273
- } catch (err) {
274
- this.#logger?.debug?.("redis-streams: xGroupDestroy failed", {
275
- topic: sub.topic,
276
- group: sub.group,
277
- err: err instanceof Error ? err.message : err
278
- });
279
- }
280
- }
281
- }
282
- async flush() {
283
- if (this.#pendingPublishes.size > 0) {
284
- await Promise.allSettled([...this.#pendingPublishes]);
285
- }
286
- }
287
- /**
288
- * Delete a topic's stream, and with it every consumer group on that stream.
289
- * This is the Redis implementation of the optional `clearTopic` hook that
290
- * consumers (e.g. `DurableAgent`) call to free a topic once its lifecycle is
291
- * over — without it, a persistent backend accumulates every finished run's
292
- * stream until Redis exhausts its memory.
293
- *
294
- * Because it removes events for ALL consumers and destroys their groups, only
295
- * call it once nothing will read the topic again. A subscriber still attached
296
- * when the stream is deleted recovers on its own (the read loop recreates the
297
- * group on NOGROUP) but will have missed the deleted entries.
298
- *
299
- * Best-effort and non-throwing: callers commonly invoke this fire-and-forget
300
- * (`void clearTopic(...)`), so a rejection would surface as an
301
- * unhandledRejection. Failures are swallowed and logged; the `streamIdleTtlMs`
302
- * backstop (when configured) reclaims anything a failed delete leaves behind.
303
- */
304
- async clearTopic(topic) {
305
- if (this.#closed) return;
306
- try {
307
- await this.#ensureWriterConnected();
308
- await this.#writeClient.del(this.#streamKey(topic));
309
- } catch (err) {
310
- this.#logger?.warn?.("redis-streams: clearTopic failed", {
311
- topic,
312
- err: err instanceof Error ? err.message : err
313
- });
314
- }
315
- }
316
- /**
317
- * Lease key used in Redis. Distinct prefix from streams so leases and
318
- * streams can't collide on key namespace.
319
- */
320
- #leaseKey(key) {
321
- return `${this.#keyPrefix}:lease:${key}`;
322
- }
323
- /**
324
- * Atomic claim via SET NX PX. Idempotent for the same owner: if the
325
- * current value is already this owner, we refresh the TTL instead of
326
- * failing. Cross-process callers race here; Redis serializes them.
327
- */
328
- async acquireLease(key, owner, ttlMs) {
329
- if (this.#closed) return { acquired: false };
330
- await this.#ensureWriterConnected();
331
- const redisKey = this.#leaseKey(key);
332
- const result = await this.#writeClient.set(redisKey, owner, { NX: true, PX: ttlMs });
333
- if (result === "OK") return { acquired: true, owner };
334
- const script = `
17
+ get supportedModes() {
18
+ return ["pull"];
19
+ }
20
+ #writeClient;
21
+ #connectOptions;
22
+ #keyPrefix;
23
+ #blockMs;
24
+ #maxStreamLength;
25
+ #streamIdleTtlMs;
26
+ #reclaimIntervalMs;
27
+ #reclaimIdleMs;
28
+ #maxDeliveryAttempts;
29
+ #logger;
30
+ #subscriptions = /* @__PURE__ */ new Map();
31
+ #cbIds = /* @__PURE__ */ new WeakMap();
32
+ #pendingPublishes = /* @__PURE__ */ new Set();
33
+ #localCallbacks = /* @__PURE__ */ new Map();
34
+ #closed = false;
35
+ constructor(options = {}) {
36
+ super();
37
+ const url = options.url ?? options.redisOptions?.url ?? "redis://localhost:6379";
38
+ this.#connectOptions = {
39
+ ...options.redisOptions,
40
+ url
41
+ };
42
+ this.#writeClient = createClient(this.#connectOptions);
43
+ this.#logger = options.logger;
44
+ this.#attachErrorLogger(this.#writeClient, "write");
45
+ this.#keyPrefix = options.keyPrefix ?? "mastra:topic";
46
+ this.#blockMs = options.blockMs ?? 1e3;
47
+ this.#maxStreamLength = options.maxStreamLength ?? 1e4;
48
+ const ttl = options.streamIdleTtlMs ?? 0;
49
+ if (!Number.isInteger(ttl) || ttl < 0) throw new Error(`redis-streams: streamIdleTtlMs must be a non-negative integer (milliseconds), got ${ttl}`);
50
+ this.#streamIdleTtlMs = ttl;
51
+ this.#reclaimIntervalMs = options.reclaimIntervalMs ?? 3e4;
52
+ this.#reclaimIdleMs = options.reclaimIdleMs ?? 6e4;
53
+ const cap = options.maxDeliveryAttempts ?? 5;
54
+ if (cap === 0) {
55
+ options.logger?.warn?.("redis-streams: maxDeliveryAttempts=0 is treated as Infinity for back-compat; pass Infinity to disable the cap explicitly.");
56
+ this.#maxDeliveryAttempts = Infinity;
57
+ } else if (cap < 0 || Number.isNaN(cap)) throw new Error(`redis-streams: maxDeliveryAttempts must be >= 1 or Infinity, got ${cap}`);
58
+ else this.#maxDeliveryAttempts = cap;
59
+ }
60
+ /**
61
+ * Attach the `'error'` listener node-redis requires on every client. Without
62
+ * one, a mid-life socket close makes the client emit an unhandled `'error'`,
63
+ * which (per EventEmitter semantics) throws from inside RedisSocket's error
64
+ * handler BEFORE the built-in reconnect is scheduled — the process gets an
65
+ * uncaughtException and the client is left permanently un-reconnected
66
+ * (isOpen=true, isReady=false), hanging every subsequent command. Merely
67
+ * having a listener lets node-redis's own reconnect run.
68
+ *
69
+ * Logged at warn so operators can see connection churn, throttled to one line
70
+ * per 30s per client: node-redis re-emits `'error'` roughly every 500ms while
71
+ * a server is unreachable, and with one reader client per subscription an
72
+ * outage would otherwise flood the logs with thousands of identical lines.
73
+ */
74
+ #attachErrorLogger(client, role, extra = {}) {
75
+ let lastWarnAt = 0;
76
+ client.on("error", (err) => {
77
+ const now = Date.now();
78
+ if (now - lastWarnAt < 3e4) return;
79
+ lastWarnAt = now;
80
+ this.#logger?.warn?.(`redis-streams: ${role} client connection error (node-redis will reconnect)`, {
81
+ ...extra,
82
+ err: err instanceof Error ? err.message : err
83
+ });
84
+ });
85
+ }
86
+ #subKey(topic, cb) {
87
+ let cbId = this.#cbIds.get(cb);
88
+ if (!cbId) {
89
+ cbId = randomUUID();
90
+ this.#cbIds.set(cb, cbId);
91
+ }
92
+ return `${topic}::${cbId}`;
93
+ }
94
+ /** Lazily connect the shared writer client. Idempotent. */
95
+ async #ensureWriterConnected() {
96
+ if (this.#writeClient.isOpen) return;
97
+ await this.#writeClient.connect();
98
+ }
99
+ #streamKey(topic) {
100
+ return `${this.#keyPrefix}:${topic}`;
101
+ }
102
+ async publish(topic, event, options) {
103
+ if (this.#closed) throw new Error("RedisStreamsPubSub: cannot publish on closed client");
104
+ if (options?.localOnly) {
105
+ const localEvent = {
106
+ ...event,
107
+ id: randomUUID(),
108
+ createdAt: /* @__PURE__ */ new Date(),
109
+ deliveryAttempt: event.deliveryAttempt ?? 1
110
+ };
111
+ this.#deliverLocal(topic, localEvent);
112
+ return;
113
+ }
114
+ await this.#ensureWriterConnected();
115
+ const id = randomUUID();
116
+ const createdAt = /* @__PURE__ */ new Date();
117
+ const payload = {
118
+ ...event,
119
+ id,
120
+ createdAt,
121
+ deliveryAttempt: event.deliveryAttempt ?? 1
122
+ };
123
+ const xaddOptions = {};
124
+ if (this.#maxStreamLength > 0) xaddOptions.TRIM = {
125
+ strategy: "MAXLEN",
126
+ strategyModifier: "~",
127
+ threshold: this.#maxStreamLength
128
+ };
129
+ const streamKey = this.#streamKey(topic);
130
+ const promise = this.#streamIdleTtlMs > 0 ? this.#writeClient.multi().xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions).pExpire(streamKey, this.#streamIdleTtlMs).exec().then((replies) => {
131
+ if (Number(replies[1]) !== 1) this.#logger?.warn?.("redis-streams: PEXPIRE inside publish MULTI did not apply", {
132
+ streamKey,
133
+ reply: String(replies[1])
134
+ });
135
+ }) : this.#writeClient.xAdd(streamKey, "*", { event: JSON.stringify(payload) }, xaddOptions);
136
+ this.#pendingPublishes.add(promise);
137
+ try {
138
+ await promise;
139
+ } finally {
140
+ this.#pendingPublishes.delete(promise);
141
+ }
142
+ }
143
+ async subscribe(topic, cb, options) {
144
+ if (this.#closed) throw new Error("RedisStreamsPubSub: cannot subscribe on closed client");
145
+ const key = this.#subKey(topic, cb);
146
+ if (this.#subscriptions.has(key)) return;
147
+ let localBucket = this.#localCallbacks.get(topic);
148
+ if (!localBucket) {
149
+ localBucket = /* @__PURE__ */ new Set();
150
+ this.#localCallbacks.set(topic, localBucket);
151
+ }
152
+ localBucket.add(cb);
153
+ await this.#ensureWriterConnected();
154
+ const isGrouped = !!options?.group;
155
+ const group = options?.group ?? `__fanout-${randomUUID()}`;
156
+ const consumer = `${group}-${randomUUID()}`;
157
+ const streamKey = this.#streamKey(topic);
158
+ try {
159
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xGroupCreate(streamKey, group, "0", { MKSTREAM: true }).pExpire(streamKey, this.#streamIdleTtlMs).exec();
160
+ else await this.#writeClient.xGroupCreate(streamKey, group, "0", { MKSTREAM: true });
161
+ } catch (err) {
162
+ if (!errorText(err).includes("BUSYGROUP")) throw err;
163
+ this.#logger?.debug?.("redis-streams: consumer group already exists", {
164
+ topic,
165
+ group
166
+ });
167
+ }
168
+ const readClient = createClient(this.#connectOptions);
169
+ this.#attachErrorLogger(readClient, "read", { topic });
170
+ await readClient.connect();
171
+ const sub = {
172
+ cb,
173
+ topic,
174
+ streamKey,
175
+ group,
176
+ consumer,
177
+ isGrouped,
178
+ readClient,
179
+ stopped: false,
180
+ loop: void 0,
181
+ reclaimTimer: void 0
182
+ };
183
+ this.#subscriptions.set(key, sub);
184
+ sub.loop = this.#runReadLoop(sub);
185
+ this.#startReclaimLoop(sub);
186
+ }
187
+ /**
188
+ * Periodically run XAUTOCLAIM against this subscription's group so that
189
+ * messages a crashed/stuck consumer read but never acked get redelivered to
190
+ * a live sibling. Runs only for grouped subscriptions — fan-out groups are
191
+ * private to one consumer, so there's no sibling to claim from.
192
+ */
193
+ #startReclaimLoop(sub) {
194
+ if (this.#reclaimIntervalMs <= 0) return;
195
+ if (!sub.isGrouped) return;
196
+ const tick = async () => {
197
+ if (sub.stopped || this.#closed) return;
198
+ try {
199
+ const messages = (await this.#writeClient.xAutoClaim(sub.streamKey, sub.group, sub.consumer, this.#reclaimIdleMs, "0-0", { COUNT: 100 }))?.messages ?? [];
200
+ for (const entry of messages) {
201
+ if (sub.stopped || this.#closed) return;
202
+ if (!entry) continue;
203
+ await this.#deliverMessage(sub, entry.id, entry.message);
204
+ }
205
+ } catch (err) {
206
+ this.#logger?.debug?.("redis-streams: XAUTOCLAIM failed", {
207
+ topic: sub.topic,
208
+ group: sub.group,
209
+ err: err instanceof Error ? err.message : err
210
+ });
211
+ }
212
+ if (sub.stopped || this.#closed) return;
213
+ sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
214
+ };
215
+ sub.reclaimTimer = setTimeout(tick, this.#reclaimIntervalMs);
216
+ }
217
+ async unsubscribe(topic, cb) {
218
+ const localBucket = this.#localCallbacks.get(topic);
219
+ if (localBucket) {
220
+ localBucket.delete(cb);
221
+ if (localBucket.size === 0) this.#localCallbacks.delete(topic);
222
+ }
223
+ const key = this.#subKey(topic, cb);
224
+ const sub = this.#subscriptions.get(key);
225
+ if (!sub) return;
226
+ this.#subscriptions.delete(key);
227
+ sub.stopped = true;
228
+ if (sub.reclaimTimer) {
229
+ clearTimeout(sub.reclaimTimer);
230
+ sub.reclaimTimer = void 0;
231
+ }
232
+ try {
233
+ await sub.readClient.quit();
234
+ } catch (err) {
235
+ this.#logger?.debug?.("redis-streams: reader quit failed", {
236
+ topic: sub.topic,
237
+ err: err instanceof Error ? err.message : err
238
+ });
239
+ }
240
+ if (sub.loop) try {
241
+ await sub.loop;
242
+ } catch (err) {
243
+ this.#logger?.debug?.("redis-streams: read loop exited with error", {
244
+ topic: sub.topic,
245
+ err: err instanceof Error ? err.message : err
246
+ });
247
+ }
248
+ if (!sub.isGrouped) try {
249
+ await this.#writeClient.xGroupDestroy(sub.streamKey, sub.group);
250
+ } catch (err) {
251
+ this.#logger?.debug?.("redis-streams: xGroupDestroy failed", {
252
+ topic: sub.topic,
253
+ group: sub.group,
254
+ err: err instanceof Error ? err.message : err
255
+ });
256
+ }
257
+ }
258
+ async flush() {
259
+ if (this.#pendingPublishes.size > 0) await Promise.allSettled([...this.#pendingPublishes]);
260
+ }
261
+ /**
262
+ * Delete a topic's stream, and with it every consumer group on that stream.
263
+ * This is the Redis implementation of the optional `clearTopic` hook that
264
+ * consumers (e.g. `DurableAgent`) call to free a topic once its lifecycle is
265
+ * over — without it, a persistent backend accumulates every finished run's
266
+ * stream until Redis exhausts its memory.
267
+ *
268
+ * Because it removes events for ALL consumers and destroys their groups, only
269
+ * call it once nothing will read the topic again. A subscriber still attached
270
+ * when the stream is deleted recovers on its own (the read loop recreates the
271
+ * group on NOGROUP) but will have missed the deleted entries.
272
+ *
273
+ * Best-effort and non-throwing: callers commonly invoke this fire-and-forget
274
+ * (`void clearTopic(...)`), so a rejection would surface as an
275
+ * unhandledRejection. Failures are swallowed and logged; the `streamIdleTtlMs`
276
+ * backstop (when configured) reclaims anything a failed delete leaves behind.
277
+ */
278
+ async clearTopic(topic) {
279
+ if (this.#closed) return;
280
+ try {
281
+ await this.#ensureWriterConnected();
282
+ await this.#writeClient.del(this.#streamKey(topic));
283
+ } catch (err) {
284
+ this.#logger?.warn?.("redis-streams: clearTopic failed", {
285
+ topic,
286
+ err: err instanceof Error ? err.message : err
287
+ });
288
+ }
289
+ }
290
+ /**
291
+ * Lease key used in Redis. Distinct prefix from streams so leases and
292
+ * streams can't collide on key namespace.
293
+ */
294
+ #leaseKey(key) {
295
+ return `${this.#keyPrefix}:lease:${key}`;
296
+ }
297
+ /**
298
+ * Atomic claim via SET NX PX. Idempotent for the same owner: if the
299
+ * current value is already this owner, we refresh the TTL instead of
300
+ * failing. Cross-process callers race here; Redis serializes them.
301
+ */
302
+ async acquireLease(key, owner, ttlMs) {
303
+ if (this.#closed) return { acquired: false };
304
+ await this.#ensureWriterConnected();
305
+ const redisKey = this.#leaseKey(key);
306
+ if (await this.#writeClient.set(redisKey, owner, {
307
+ NX: true,
308
+ PX: ttlMs
309
+ }) === "OK") return {
310
+ acquired: true,
311
+ owner
312
+ };
313
+ if (await this.#writeClient.eval(`
335
314
  local current = redis.call("GET", KEYS[1])
336
315
  if current == ARGV[1] then
337
316
  redis.call("PEXPIRE", KEYS[1], ARGV[2])
338
317
  return 1
339
318
  end
340
319
  return 0
341
- `;
342
- const refreshed = await this.#writeClient.eval(script, {
343
- keys: [redisKey],
344
- arguments: [owner, String(ttlMs)]
345
- });
346
- if (refreshed === 1) return { acquired: true, owner };
347
- return { acquired: false, owner: await this.#writeClient.get(redisKey) ?? void 0 };
348
- }
349
- async getLeaseOwner(key) {
350
- if (this.#closed) return void 0;
351
- await this.#ensureWriterConnected();
352
- const current = await this.#writeClient.get(this.#leaseKey(key));
353
- return current ?? void 0;
354
- }
355
- /**
356
- * Release only if we still own it. Implemented as GET+DEL with a Lua
357
- * script so the check-and-delete is atomic against concurrent renewals
358
- * from other processes.
359
- */
360
- async releaseLease(key, owner) {
361
- if (this.#closed) return;
362
- await this.#ensureWriterConnected();
363
- const script = `
320
+ `, {
321
+ keys: [redisKey],
322
+ arguments: [owner, String(ttlMs)]
323
+ }) === 1) return {
324
+ acquired: true,
325
+ owner
326
+ };
327
+ return {
328
+ acquired: false,
329
+ owner: await this.#writeClient.get(redisKey) ?? void 0
330
+ };
331
+ }
332
+ async getLeaseOwner(key) {
333
+ if (this.#closed) return void 0;
334
+ await this.#ensureWriterConnected();
335
+ return await this.#writeClient.get(this.#leaseKey(key)) ?? void 0;
336
+ }
337
+ /**
338
+ * Release only if we still own it. Implemented as GET+DEL with a Lua
339
+ * script so the check-and-delete is atomic against concurrent renewals
340
+ * from other processes.
341
+ */
342
+ async releaseLease(key, owner) {
343
+ if (this.#closed) return;
344
+ await this.#ensureWriterConnected();
345
+ await this.#writeClient.eval(`
364
346
  if redis.call("GET", KEYS[1]) == ARGV[1] then
365
347
  return redis.call("DEL", KEYS[1])
366
348
  else
367
349
  return 0
368
350
  end
369
- `;
370
- await this.#writeClient.eval(script, {
371
- keys: [this.#leaseKey(key)],
372
- arguments: [owner]
373
- });
374
- }
375
- /**
376
- * Extend the TTL only if we still own the lease. Returns false if the
377
- * lease was lost (expired or another owner took it).
378
- */
379
- async renewLease(key, owner, ttlMs) {
380
- if (this.#closed) return false;
381
- await this.#ensureWriterConnected();
382
- const script = `
351
+ `, {
352
+ keys: [this.#leaseKey(key)],
353
+ arguments: [owner]
354
+ });
355
+ }
356
+ /**
357
+ * Extend the TTL only if we still own the lease. Returns false if the
358
+ * lease was lost (expired or another owner took it).
359
+ */
360
+ async renewLease(key, owner, ttlMs) {
361
+ if (this.#closed) return false;
362
+ await this.#ensureWriterConnected();
363
+ return await this.#writeClient.eval(`
383
364
  if redis.call("GET", KEYS[1]) == ARGV[1] then
384
365
  return redis.call("PEXPIRE", KEYS[1], ARGV[2])
385
366
  else
386
367
  return 0
387
368
  end
388
- `;
389
- const result = await this.#writeClient.eval(script, {
390
- keys: [this.#leaseKey(key)],
391
- arguments: [owner, String(ttlMs)]
392
- });
393
- return result === 1;
394
- }
395
- /**
396
- * Atomically hand the lease from `fromOwner` to `toOwner`, refreshing the
397
- * TTL to the full `ttlMs`, without the key ever going empty.
398
- *
399
- * Implemented as a single Lua script (GET == fromOwner -> SET toOwner PX)
400
- * so a racing process cannot win the key between a release and a re-acquire.
401
- * Returns false if `fromOwner` no longer holds the lease (expired or taken),
402
- * in which case the caller should fall back to a fresh `acquireLease`.
403
- */
404
- async transferLease(key, fromOwner, toOwner, ttlMs) {
405
- if (this.#closed) return false;
406
- await this.#ensureWriterConnected();
407
- const script = `
369
+ `, {
370
+ keys: [this.#leaseKey(key)],
371
+ arguments: [owner, String(ttlMs)]
372
+ }) === 1;
373
+ }
374
+ /**
375
+ * Atomically hand the lease from `fromOwner` to `toOwner`, refreshing the
376
+ * TTL to the full `ttlMs`, without the key ever going empty.
377
+ *
378
+ * Implemented as a single Lua script (GET == fromOwner -> SET toOwner PX)
379
+ * so a racing process cannot win the key between a release and a re-acquire.
380
+ * Returns false if `fromOwner` no longer holds the lease (expired or taken),
381
+ * in which case the caller should fall back to a fresh `acquireLease`.
382
+ */
383
+ async transferLease(key, fromOwner, toOwner, ttlMs) {
384
+ if (this.#closed) return false;
385
+ await this.#ensureWriterConnected();
386
+ return await this.#writeClient.eval(`
408
387
  if redis.call("GET", KEYS[1]) == ARGV[1] then
409
388
  redis.call("SET", KEYS[1], ARGV[2], "PX", ARGV[3])
410
389
  return 1
411
390
  else
412
391
  return 0
413
392
  end
414
- `;
415
- const result = await this.#writeClient.eval(script, {
416
- keys: [this.#leaseKey(key)],
417
- arguments: [fromOwner, toOwner, String(ttlMs)]
418
- });
419
- return result === 1;
420
- }
421
- /**
422
- * Disconnect all clients and stop all subscription loops.
423
- */
424
- async close() {
425
- if (this.#closed) return;
426
- this.#closed = true;
427
- const subs = [...this.#subscriptions.values()];
428
- await Promise.all(subs.map((sub) => this.unsubscribe(sub.topic, sub.cb)));
429
- this.#localCallbacks.clear();
430
- if (this.#writeClient.isOpen) {
431
- try {
432
- await this.#writeClient.quit();
433
- } catch (err) {
434
- this.#logger?.debug?.("redis-streams: writer quit failed", {
435
- err: err instanceof Error ? err.message : err
436
- });
437
- }
438
- }
439
- }
440
- async #runReadLoop(sub) {
441
- while (!sub.stopped) {
442
- let result;
443
- try {
444
- result = await sub.readClient.xReadGroup(sub.group, sub.consumer, [{ key: sub.streamKey, id: ">" }], {
445
- COUNT: 10,
446
- BLOCK: this.#blockMs
447
- });
448
- } catch (err) {
449
- if (sub.stopped) return;
450
- const msg = err instanceof Error ? err.message : String(err);
451
- if (msg.includes("NOGROUP")) {
452
- try {
453
- if (this.#streamIdleTtlMs > 0) {
454
- await this.#writeClient.multi().xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
455
- } else {
456
- await this.#writeClient.xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true });
457
- }
458
- this.#logger?.debug?.("redis-streams: recreated consumer group after NOGROUP", {
459
- topic: sub.topic,
460
- group: sub.group
461
- });
462
- } catch (recreateErr) {
463
- const rmsg = errorText(recreateErr);
464
- if (!rmsg.includes("BUSYGROUP")) {
465
- this.#logger?.debug?.("redis-streams: consumer group re-create failed", { topic: sub.topic, err: rmsg });
466
- }
467
- }
468
- } else {
469
- this.#logger?.debug?.("redis-streams: xReadGroup failed", {
470
- topic: sub.topic,
471
- group: sub.group,
472
- err: msg
473
- });
474
- }
475
- await new Promise((r) => setTimeout(r, 100));
476
- continue;
477
- }
478
- if (!result || result.length === 0) continue;
479
- for (const stream of result) {
480
- for (const entry of stream.messages) {
481
- if (sub.stopped) return;
482
- await this.#deliverMessage(sub, entry.id, entry.message);
483
- }
484
- }
485
- }
486
- }
487
- async #deliverMessage(sub, streamId, fields) {
488
- let event;
489
- try {
490
- event = JSON.parse(fields.event ?? "{}");
491
- if (typeof event.createdAt === "string") {
492
- event.createdAt = new Date(event.createdAt);
493
- }
494
- } catch (err) {
495
- this.#logger?.debug?.("redis-streams: malformed payload, dropping", {
496
- topic: sub.topic,
497
- streamId,
498
- err: err instanceof Error ? err.message : err
499
- });
500
- try {
501
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
502
- } catch (ackErr) {
503
- this.#logger?.debug?.("redis-streams: xAck after malformed payload failed", {
504
- topic: sub.topic,
505
- err: ackErr instanceof Error ? ackErr.message : ackErr
506
- });
507
- }
508
- return;
509
- }
510
- let settled = false;
511
- const ack = async () => {
512
- if (settled) return;
513
- settled = true;
514
- try {
515
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
516
- } catch (err) {
517
- this.#logger?.debug?.("redis-streams: ack cleanup failed", {
518
- topic: sub.topic,
519
- err: err instanceof Error ? err.message : err
520
- });
521
- }
522
- };
523
- const nack = async () => {
524
- if (settled) return;
525
- settled = true;
526
- const attempt = event.deliveryAttempt ?? 1;
527
- if (attempt >= this.#maxDeliveryAttempts) {
528
- this.#logger?.warn?.("redis-streams: dropping event after max delivery attempts", {
529
- topic: sub.topic,
530
- eventType: event.type,
531
- eventId: event.id,
532
- attempt,
533
- max: this.#maxDeliveryAttempts
534
- });
535
- try {
536
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
537
- } catch (err) {
538
- this.#logger?.debug?.("redis-streams: ack on dropped poison message failed", {
539
- topic: sub.topic,
540
- err: err instanceof Error ? err.message : err
541
- });
542
- }
543
- return;
544
- }
545
- const next = {
546
- ...event,
547
- deliveryAttempt: attempt + 1
548
- };
549
- try {
550
- if (this.#streamIdleTtlMs > 0) {
551
- await this.#writeClient.multi().xAdd(sub.streamKey, "*", { event: JSON.stringify(next) }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
552
- } else {
553
- await this.#writeClient.xAdd(sub.streamKey, "*", { event: JSON.stringify(next) });
554
- }
555
- } catch (err) {
556
- this.#logger?.warn?.("redis-streams: nack republish failed; leaving original pending for reclaim", {
557
- topic: sub.topic,
558
- eventId: event.id,
559
- err: err instanceof Error ? err.message : err
560
- });
561
- settled = false;
562
- return;
563
- }
564
- try {
565
- await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
566
- } catch (err) {
567
- this.#logger?.debug?.("redis-streams: xAck after nack failed", {
568
- topic: sub.topic,
569
- err: err instanceof Error ? err.message : err
570
- });
571
- }
572
- };
573
- try {
574
- const result = sub.cb(event, ack, nack);
575
- if (result && typeof result.catch === "function") {
576
- result.catch(async () => {
577
- await nack();
578
- });
579
- }
580
- } catch {
581
- await nack();
582
- }
583
- }
584
- #deliverLocal(topic, event) {
585
- const callbacks = this.#localCallbacks.get(topic);
586
- if (!callbacks || callbacks.size === 0) return;
587
- for (const cb of callbacks) {
588
- this.#invokeLocalCallback(topic, event, cb);
589
- }
590
- }
591
- #invokeLocalCallback(topic, event, cb) {
592
- const ack = async () => {
593
- };
594
- const nack = async () => {
595
- };
596
- try {
597
- const result = cb(
598
- event,
599
- ack,
600
- nack
601
- );
602
- if (result && typeof result.catch === "function") {
603
- result.catch((err) => {
604
- this.#logger?.debug?.("redis-streams: local subscriber rejected", {
605
- topic,
606
- err: err instanceof Error ? err.message : err
607
- });
608
- });
609
- }
610
- } catch (err) {
611
- this.#logger?.debug?.("redis-streams: local subscriber threw", {
612
- topic,
613
- err: err instanceof Error ? err.message : err
614
- });
615
- }
616
- }
393
+ `, {
394
+ keys: [this.#leaseKey(key)],
395
+ arguments: [
396
+ fromOwner,
397
+ toOwner,
398
+ String(ttlMs)
399
+ ]
400
+ }) === 1;
401
+ }
402
+ /**
403
+ * Disconnect all clients and stop all subscription loops.
404
+ */
405
+ async close() {
406
+ if (this.#closed) return;
407
+ this.#closed = true;
408
+ const subs = [...this.#subscriptions.values()];
409
+ await Promise.all(subs.map((sub) => this.unsubscribe(sub.topic, sub.cb)));
410
+ this.#localCallbacks.clear();
411
+ if (this.#writeClient.isOpen) try {
412
+ await this.#writeClient.quit();
413
+ } catch (err) {
414
+ this.#logger?.debug?.("redis-streams: writer quit failed", { err: err instanceof Error ? err.message : err });
415
+ }
416
+ }
417
+ async #runReadLoop(sub) {
418
+ while (!sub.stopped) {
419
+ let result;
420
+ try {
421
+ result = await sub.readClient.xReadGroup(sub.group, sub.consumer, [{
422
+ key: sub.streamKey,
423
+ id: ">"
424
+ }], {
425
+ COUNT: 10,
426
+ BLOCK: this.#blockMs
427
+ });
428
+ } catch (err) {
429
+ if (sub.stopped) return;
430
+ const msg = err instanceof Error ? err.message : String(err);
431
+ if (msg.includes("NOGROUP")) try {
432
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
433
+ else await this.#writeClient.xGroupCreate(sub.streamKey, sub.group, "0", { MKSTREAM: true });
434
+ this.#logger?.debug?.("redis-streams: recreated consumer group after NOGROUP", {
435
+ topic: sub.topic,
436
+ group: sub.group
437
+ });
438
+ } catch (recreateErr) {
439
+ const rmsg = errorText(recreateErr);
440
+ if (!rmsg.includes("BUSYGROUP")) this.#logger?.debug?.("redis-streams: consumer group re-create failed", {
441
+ topic: sub.topic,
442
+ err: rmsg
443
+ });
444
+ }
445
+ else this.#logger?.debug?.("redis-streams: xReadGroup failed", {
446
+ topic: sub.topic,
447
+ group: sub.group,
448
+ err: msg
449
+ });
450
+ await new Promise((r) => setTimeout(r, 100));
451
+ continue;
452
+ }
453
+ if (!result || result.length === 0) continue;
454
+ for (const stream of result) for (const entry of stream.messages) {
455
+ if (sub.stopped) return;
456
+ await this.#deliverMessage(sub, entry.id, entry.message);
457
+ }
458
+ }
459
+ }
460
+ async #deliverMessage(sub, streamId, fields) {
461
+ let event;
462
+ try {
463
+ event = JSON.parse(fields.event ?? "{}");
464
+ if (typeof event.createdAt === "string") event.createdAt = new Date(event.createdAt);
465
+ } catch (err) {
466
+ this.#logger?.debug?.("redis-streams: malformed payload, dropping", {
467
+ topic: sub.topic,
468
+ streamId,
469
+ err: err instanceof Error ? err.message : err
470
+ });
471
+ try {
472
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
473
+ } catch (ackErr) {
474
+ this.#logger?.debug?.("redis-streams: xAck after malformed payload failed", {
475
+ topic: sub.topic,
476
+ err: ackErr instanceof Error ? ackErr.message : ackErr
477
+ });
478
+ }
479
+ return;
480
+ }
481
+ let settled = false;
482
+ const ack = async () => {
483
+ if (settled) return;
484
+ settled = true;
485
+ try {
486
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
487
+ } catch (err) {
488
+ this.#logger?.debug?.("redis-streams: ack cleanup failed", {
489
+ topic: sub.topic,
490
+ err: err instanceof Error ? err.message : err
491
+ });
492
+ }
493
+ };
494
+ const nack = async () => {
495
+ if (settled) return;
496
+ settled = true;
497
+ const attempt = event.deliveryAttempt ?? 1;
498
+ if (attempt >= this.#maxDeliveryAttempts) {
499
+ this.#logger?.warn?.("redis-streams: dropping event after max delivery attempts", {
500
+ topic: sub.topic,
501
+ eventType: event.type,
502
+ eventId: event.id,
503
+ attempt,
504
+ max: this.#maxDeliveryAttempts
505
+ });
506
+ try {
507
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
508
+ } catch (err) {
509
+ this.#logger?.debug?.("redis-streams: ack on dropped poison message failed", {
510
+ topic: sub.topic,
511
+ err: err instanceof Error ? err.message : err
512
+ });
513
+ }
514
+ return;
515
+ }
516
+ const next = {
517
+ ...event,
518
+ deliveryAttempt: attempt + 1
519
+ };
520
+ try {
521
+ if (this.#streamIdleTtlMs > 0) await this.#writeClient.multi().xAdd(sub.streamKey, "*", { event: JSON.stringify(next) }).pExpire(sub.streamKey, this.#streamIdleTtlMs).exec();
522
+ else await this.#writeClient.xAdd(sub.streamKey, "*", { event: JSON.stringify(next) });
523
+ } catch (err) {
524
+ this.#logger?.warn?.("redis-streams: nack republish failed; leaving original pending for reclaim", {
525
+ topic: sub.topic,
526
+ eventId: event.id,
527
+ err: err instanceof Error ? err.message : err
528
+ });
529
+ settled = false;
530
+ return;
531
+ }
532
+ try {
533
+ await this.#writeClient.xAck(sub.streamKey, sub.group, streamId);
534
+ } catch (err) {
535
+ this.#logger?.debug?.("redis-streams: xAck after nack failed", {
536
+ topic: sub.topic,
537
+ err: err instanceof Error ? err.message : err
538
+ });
539
+ }
540
+ };
541
+ try {
542
+ const result = sub.cb(event, ack, nack);
543
+ if (result && typeof result.catch === "function") result.catch(async () => {
544
+ await nack();
545
+ });
546
+ } catch {
547
+ await nack();
548
+ }
549
+ }
550
+ #deliverLocal(topic, event) {
551
+ const callbacks = this.#localCallbacks.get(topic);
552
+ if (!callbacks || callbacks.size === 0) return;
553
+ for (const cb of callbacks) this.#invokeLocalCallback(topic, event, cb);
554
+ }
555
+ #invokeLocalCallback(topic, event, cb) {
556
+ const ack = async () => {};
557
+ const nack = async () => {};
558
+ try {
559
+ const result = cb(event, ack, nack);
560
+ if (result && typeof result.catch === "function") result.catch((err) => {
561
+ this.#logger?.debug?.("redis-streams: local subscriber rejected", {
562
+ topic,
563
+ err: err instanceof Error ? err.message : err
564
+ });
565
+ });
566
+ } catch (err) {
567
+ this.#logger?.debug?.("redis-streams: local subscriber threw", {
568
+ topic,
569
+ err: err instanceof Error ? err.message : err
570
+ });
571
+ }
572
+ }
617
573
  };
618
-
574
+ //#endregion
619
575
  export { RedisStreamsPubSub };
620
- //# sourceMappingURL=index.js.map
576
+
621
577
  //# sourceMappingURL=index.js.map