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