okengine 0.23.1 → 0.23.2

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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +3 -3
  3. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  4. package/site/content/docs/reference/configuration.mdx +16 -0
  5. package/site/content/docs/reference/errors.mdx +31 -19
  6. package/src/compiler/aot.test.ts +3 -2
  7. package/src/compiler/aot.ts +45 -1
  8. package/src/compiler/dynamic.ts +1 -1
  9. package/src/compiler/http-parse.test.ts +101 -0
  10. package/src/compiler/http-parse.ts +188 -9
  11. package/src/compiler/interpret.ts +25 -2
  12. package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  33. package/src/drivers/journal-postgres.test.ts +84 -1
  34. package/src/drivers/journal-postgres.ts +282 -32
  35. package/src/drivers/postgres.test.ts +60 -0
  36. package/src/drivers/postgres.ts +79 -5
  37. package/src/drivers/signal-compete.test.ts +6 -2
  38. package/src/drivers/signal-postgres.test.ts +170 -0
  39. package/src/drivers/signal-postgres.ts +449 -196
  40. package/src/drivers/signal-redis.test.ts +224 -0
  41. package/src/drivers/signal-redis.ts +835 -150
  42. package/src/elements/ai/approval.test.ts +15 -3
  43. package/src/elements/ai/approval.ts +15 -9
  44. package/src/elements/channel/runtime.ts +31 -4
  45. package/src/elements/channel/sql-ledger.test.ts +404 -0
  46. package/src/elements/channel/sql-ledger.ts +177 -33
  47. package/src/elements/clock/chaos-child.ts +12 -2
  48. package/src/elements/clock/durable.ts +11 -0
  49. package/src/elements/store/cache-bus.test.ts +121 -0
  50. package/src/elements/store/cache-bus.ts +278 -0
  51. package/src/elements/store/cache.test.ts +59 -0
  52. package/src/elements/store/cache.ts +157 -12
  53. package/src/elements/store/runtime.ts +44 -2
  54. package/src/elements/store/sql-errors.test.ts +131 -0
  55. package/src/elements/store/sql-errors.ts +39 -16
  56. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  57. package/src/elements/store/sql-session.ts +183 -3
  58. package/src/kernel/app.ts +176 -28
  59. package/src/kernel/auto-registry.test.ts +5 -1
  60. package/src/kernel/boot-bind/signal.ts +13 -2
  61. package/src/kernel/boot-bind/store.ts +16 -1
  62. package/src/kernel/boot.test.ts +2 -2
  63. package/src/kernel/boot.ts +41 -11
  64. package/src/kernel/boundary-contract.ts +7 -0
  65. package/src/kernel/builtin-errors.ts +2 -0
  66. package/src/kernel/call.test.ts +1 -0
  67. package/src/kernel/fx-sql-handle.ts +7 -1
  68. package/src/kernel/horizontal-child.ts +54 -1
  69. package/src/kernel/horizontal.integration.test.ts +131 -7
  70. package/src/kernel/idempotency.test.ts +5 -6
  71. package/src/kernel/journal-boot.test.ts +83 -6
  72. package/src/kernel/journal.test.ts +44 -0
  73. package/src/kernel/journal.ts +171 -26
  74. package/src/mcp/authorization.ts +5 -11
  75. package/src/mcp/confirmation.ts +203 -38
  76. package/src/mcp/docs-server.ts +7 -2
  77. package/src/mcp/index.ts +4 -0
  78. package/src/mcp/mcp.test.ts +321 -33
  79. package/src/mcp/server.ts +8 -3
  80. package/src/mcp/session.ts +7 -0
  81. package/src/mcp/tools.ts +68 -19
  82. package/src/runtime/bun.ts +5 -0
  83. package/src/runtime/types.ts +5 -0
@@ -4,16 +4,18 @@
4
4
  * Emit still enrols in a transactional outbox (semantics never regress).
5
5
  * After commit, a relay pushes to Redis Streams (`once`) or pub/sub
6
6
  * (`broadcast` / `live`). With `compete: true`, `once` delivery is
7
- * `XREADGROUP` / `XACK` (pending reclaim on the next drain). Broadcast
8
- * and live stay on the outbox plus pub/sub.
7
+ * `XREADGROUP` / `XACK`, with `XAUTOCLAIM` reclaim on the same read.
8
+ * Broadcast and live stay on the outbox plus pub/sub.
9
9
  *
10
10
  * Production bind: {@link createBunSignalRedisClient} (typed `publish` /
11
11
  * `subscribe` / `xadd` / `xreadgroup` / `xack` / `xgroup` on Bun ≥1.4).
12
12
  */
13
13
 
14
14
  import { createSignalEngine } from "./signal-engine.ts";
15
+ import { createLiveIterable } from "./signal-live-iter.ts";
15
16
  import type {
16
17
  DeadLetter,
18
+ LiveEvent,
17
19
  SignalBus,
18
20
  SignalDriver,
19
21
  SignalFailureReason,
@@ -23,101 +25,516 @@ import type {
23
25
  SignalRedisClientLike,
24
26
  } from "./signal-types.ts";
25
27
 
28
+ /**
29
+ * Minimum idle time before `XAUTOCLAIM` steals a pending `once` entry.
30
+ * A handler that is still running refreshes its lease about every third of this window.
31
+ */
32
+ export const SIGNAL_REDIS_MIN_IDLE_MS = 30_000;
33
+
34
+ /** Drop group consumers idle at least this long when their PEL is empty. */
35
+ const CONSUMER_IDLE_RETENTION_MS = 86_400_000;
36
+
37
+ const STREAM_GROUP = "oke";
38
+
26
39
  /** Bun 1.4 typed stream methods — `@types/bun` may lag the runtime. */
27
40
  interface BunRedisStreams {
28
41
  xadd(key: string, ...args: (string | number)[]): Promise<unknown>;
29
42
  xreadgroup(...args: (string | number)[]): Promise<unknown>;
43
+ xautoclaim(
44
+ key: string,
45
+ group: string,
46
+ consumer: string,
47
+ minIdleTime: number,
48
+ start: string,
49
+ ...options: (string | number)[]
50
+ ): Promise<unknown>;
51
+ xclaim(
52
+ key: string,
53
+ group: string,
54
+ consumer: string,
55
+ minIdleTime: number,
56
+ id: string,
57
+ ...rest: (string | number)[]
58
+ ): Promise<unknown>;
30
59
  xack(key: string, group: string, id: string): Promise<unknown>;
31
60
  xgroup(subcommand: string, ...args: (string | number)[]): Promise<unknown>;
61
+ xrange(key: string, start: string, end: string): Promise<unknown>;
62
+ xinfo(subcommand: string, ...args: (string | number)[]): Promise<unknown>;
63
+ hincrby(key: string, field: string, increment: number): Promise<number>;
64
+ hdel(key: string, field: string): Promise<number>;
65
+ }
66
+
67
+ interface StreamEntry {
68
+ id: string;
69
+ fields: Record<string, string>;
70
+ }
71
+
72
+ /** One row from `XINFO CONSUMERS`. */
73
+ interface RedisConsumerInfo {
74
+ readonly name: string;
75
+ readonly pending: number;
76
+ readonly idle: number;
77
+ }
78
+
79
+ /**
80
+ * Extra Redis commands the signal bus uses on top of {@link SignalRedisClientLike}.
81
+ * Implemented by {@link createBunSignalRedisClient} and {@link createSignalRedisFake}.
82
+ */
83
+ interface SignalRedisWire extends SignalRedisClientLike {
84
+ xclaim(
85
+ key: string,
86
+ group: string,
87
+ consumer: string,
88
+ minIdleMs: number,
89
+ ids: readonly string[],
90
+ ): Promise<void>;
91
+ hincrby(key: string, field: string, increment: number): Promise<number>;
92
+ hdel(key: string, field: string): Promise<number>;
93
+ xrange(key: string): Promise<StreamEntry[]>;
94
+ xinfoConsumers(key: string, group: string): Promise<RedisConsumerInfo[]>;
95
+ xgroupDelConsumer(key: string, group: string, consumer: string): Promise<void>;
96
+ close(): Promise<void>;
97
+ }
98
+
99
+ interface InflightBatch {
100
+ readonly key: string;
101
+ readonly group: string;
102
+ readonly consumer: string;
103
+ readonly ids: Set<string>;
104
+ }
105
+
106
+ interface PendingEntry {
107
+ id: string;
108
+ fields: Record<string, string>;
109
+ consumer: string;
110
+ claimedAt: number;
111
+ }
112
+
113
+ /** Pub/sub body published for `broadcast` / `live`. */
114
+ interface SignalPubEnvelope {
115
+ readonly __oke_env: 1;
116
+ readonly id: string;
117
+ readonly origin: string;
118
+ readonly payload: unknown;
119
+ }
120
+
121
+ const inflightByClient = new WeakMap<object, Map<string, InflightBatch>>();
122
+ const heartbeats = new WeakMap<object, Set<ReturnType<typeof setInterval>>>();
123
+
124
+ function bunStreams(redis: Bun.RedisClient): BunRedisStreams {
125
+ return redis as unknown as BunRedisStreams;
126
+ }
127
+
128
+ function asWire(client: SignalRedisClientLike): SignalRedisWire {
129
+ return client as SignalRedisWire;
130
+ }
131
+
132
+ function streamKey(name: string): string {
133
+ return `oke:signal:${name}`;
134
+ }
135
+
136
+ function attemptsKey(name: string): string {
137
+ return `oke:signal:attempts:${name}`;
138
+ }
139
+
140
+ function deadKey(name: string): string {
141
+ return `oke:signal:dead:${name}`;
142
+ }
143
+
144
+ function bcastChannel(name: string): string {
145
+ return `oke:signal:bcast:${name}`;
146
+ }
147
+
148
+ function liveChannel(name: string): string {
149
+ return `oke:signal:live:${name}`;
150
+ }
151
+
152
+ function inflightSlot(key: string, consumer: string): string {
153
+ return `${key}\0${consumer}`;
154
+ }
155
+
156
+ function markInflight(
157
+ client: object,
158
+ key: string,
159
+ group: string,
160
+ consumer: string,
161
+ id: string,
162
+ ): void {
163
+ let batches = inflightByClient.get(client);
164
+ if (!batches) {
165
+ batches = new Map();
166
+ inflightByClient.set(client, batches);
167
+ }
168
+ const slot = inflightSlot(key, consumer);
169
+ let batch = batches.get(slot);
170
+ if (!batch) {
171
+ batch = { key, group, consumer, ids: new Set() };
172
+ batches.set(slot, batch);
173
+ }
174
+ batch.ids.add(id);
175
+ }
176
+
177
+ function unmarkInflight(client: object, key: string, consumer: string, id: string): void {
178
+ inflightByClient.get(client)?.get(inflightSlot(key, consumer))?.ids.delete(id);
179
+ }
180
+
181
+ function isInflight(client: object, key: string, consumer: string, id: string): boolean {
182
+ return inflightByClient.get(client)?.get(inflightSlot(key, consumer))?.ids.has(id) ?? false;
183
+ }
184
+
185
+ /**
186
+ * `XCLAIM ... JUSTID` for every id this process is still handling.
187
+ *
188
+ * @param client - Redis client that owns the in-flight set
189
+ */
190
+ async function refreshInflight(client: object): Promise<void> {
191
+ const batches = inflightByClient.get(client);
192
+ if (!batches) return;
193
+ const ops = client as SignalRedisWire;
194
+ for (const batch of batches.values()) {
195
+ if (batch.ids.size === 0) continue;
196
+ try {
197
+ await ops.xclaim(batch.key, batch.group, batch.consumer, 0, [...batch.ids]);
198
+ } catch {
199
+ /* Lease refresh is best-effort. */
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Start the idle-reset timer for one bus. Call the returned function on shutdown.
206
+ *
207
+ * @param client - Redis client whose in-flight ids should be refreshed
208
+ */
209
+ function retainHeartbeat(client: object): () => void {
210
+ const timer = setInterval(
211
+ () => {
212
+ void refreshInflight(client);
213
+ },
214
+ Math.floor(SIGNAL_REDIS_MIN_IDLE_MS / 3),
215
+ );
216
+ if (typeof timer.unref === "function") timer.unref();
217
+ let timers = heartbeats.get(client);
218
+ if (!timers) {
219
+ timers = new Set();
220
+ heartbeats.set(client, timers);
221
+ }
222
+ const bucket = timers;
223
+ bucket.add(timer);
224
+ return () => {
225
+ clearInterval(timer);
226
+ bucket.delete(timer);
227
+ };
228
+ }
229
+
230
+ function clearHeartbeats(client: object): void {
231
+ const timers = heartbeats.get(client);
232
+ if (!timers) return;
233
+ for (const timer of timers) clearInterval(timer);
234
+ timers.clear();
235
+ }
236
+
237
+ function parseFields(flat: unknown): Record<string, string> {
238
+ const fields: Record<string, string> = {};
239
+ if (!Array.isArray(flat)) return fields;
240
+ for (let i = 0; i + 1 < flat.length; i += 2) {
241
+ const key = flat[i];
242
+ const value = flat[i + 1];
243
+ if (key === undefined || value === undefined) continue;
244
+ fields[String(key)] = String(value);
245
+ }
246
+ return fields;
247
+ }
248
+
249
+ function parseEntry(entry: unknown): StreamEntry | undefined {
250
+ if (!Array.isArray(entry) || entry.length < 2) return undefined;
251
+ const id = entry[0];
252
+ if (id === undefined || typeof id === "object") return undefined;
253
+ return { id: String(id), fields: parseFields(entry[1]) };
32
254
  }
33
255
 
34
- function typedStreams(redis: Bun.RedisClient): Bun.RedisClient & BunRedisStreams {
35
- return redis as Bun.RedisClient & BunRedisStreams;
256
+ function parseEntryList(entries: unknown): StreamEntry[] {
257
+ if (!Array.isArray(entries)) return [];
258
+ const out: StreamEntry[] = [];
259
+ for (const entry of entries) {
260
+ const parsed = parseEntry(entry);
261
+ if (parsed) out.push(parsed);
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /** True when `value` is a list of stream entries (`[id, fields][]`), including empty. */
267
+ function isEntryList(value: unknown): boolean {
268
+ if (!Array.isArray(value)) return false;
269
+ if (value.length === 0) return true;
270
+ return Array.isArray(value[0]);
271
+ }
272
+
273
+ function mergeEntries(
274
+ groups: readonly (readonly StreamEntry[])[],
275
+ skip: (id: string) => boolean,
276
+ count: number,
277
+ ): StreamEntry[] {
278
+ const out: StreamEntry[] = [];
279
+ const seen = new Set<string>();
280
+ for (const group of groups) {
281
+ for (const entry of group) {
282
+ if (seen.has(entry.id) || skip(entry.id)) continue;
283
+ seen.add(entry.id);
284
+ out.push(entry);
285
+ if (out.length >= count) return out;
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /**
292
+ * Parse Redis `XREADGROUP` and `XAUTOCLAIM` replies into field maps.
293
+ *
294
+ * Bun returns a stream map `{ "<stream>": [[id, [field, value, ...]], ...] }`
295
+ * for both commands. RESP nested arrays are accepted too: `XREADGROUP`
296
+ * `[[stream, entries], ...]` and `XAUTOCLAIM` `[cursor, entries, deletedIds]`.
297
+ *
298
+ * @param reply - Raw command result
299
+ */
300
+ export function parseXreadgroupReply(reply: unknown): StreamEntry[] {
301
+ if (reply == null) return [];
302
+ if (typeof reply === "object" && !Array.isArray(reply)) {
303
+ const out: StreamEntry[] = [];
304
+ for (const entries of Object.values(reply)) {
305
+ out.push(...parseEntryList(entries));
306
+ }
307
+ return out;
308
+ }
309
+ if (!Array.isArray(reply) || reply.length === 0) return [];
310
+
311
+ // XAUTOCLAIM: [nextCursor, [[id, fields], ...], deleted?]
312
+ if (typeof reply[0] === "string") return parseEntryList(reply[1]);
313
+
314
+ const head = reply[0];
315
+ if (!Array.isArray(head) || head.length < 2) return [];
316
+ // Bare [[id, [field, value, ...]], ...] — the second slot is a field list, not entries.
317
+ if (!isEntryList(head[1])) return parseEntryList(reply);
318
+
319
+ const out: StreamEntry[] = [];
320
+ for (const stream of reply) {
321
+ if (!Array.isArray(stream) || stream.length < 2) continue;
322
+ out.push(...parseEntryList(stream[1]));
323
+ }
324
+ return out;
325
+ }
326
+
327
+ function parseXinfoConsumers(reply: unknown): RedisConsumerInfo[] {
328
+ if (!Array.isArray(reply)) return [];
329
+ const out: RedisConsumerInfo[] = [];
330
+ for (const row of reply) {
331
+ if (row !== null && typeof row === "object" && !Array.isArray(row)) {
332
+ const rec = row as Record<string, unknown>;
333
+ if (typeof rec.name !== "string") continue;
334
+ out.push({
335
+ name: rec.name,
336
+ pending: Number(rec.pending ?? 0),
337
+ idle: Number(rec.idle ?? 0),
338
+ });
339
+ continue;
340
+ }
341
+ if (!Array.isArray(row)) continue;
342
+ const fields: Record<string, string> = {};
343
+ for (let i = 0; i + 1 < row.length; i += 2) {
344
+ const key = row[i];
345
+ const value = row[i + 1];
346
+ if (key === undefined || value === undefined) continue;
347
+ fields[String(key)] = String(value);
348
+ }
349
+ const name = fields.name;
350
+ if (name === undefined) continue;
351
+ out.push({
352
+ name,
353
+ pending: Number(fields.pending ?? 0),
354
+ idle: Number(fields.idle ?? 0),
355
+ });
356
+ }
357
+ return out;
358
+ }
359
+
360
+ function isPubEnvelope(value: unknown): value is SignalPubEnvelope {
361
+ return (
362
+ value !== null && typeof value === "object" && !Array.isArray(value) && "__oke_env" in value
363
+ );
364
+ }
365
+
366
+ /**
367
+ * Decode a pub/sub body.
368
+ *
369
+ * Own-origin envelopes are skipped (the local outbox already delivered them).
370
+ * A body with no `__oke_env` is a legacy raw payload.
371
+ *
372
+ * @param raw - Published string
373
+ * @param origin - This instance id
374
+ */
375
+ function decodePublished(
376
+ raw: string,
377
+ origin: string,
378
+ ): { id: string; payload: unknown } | undefined {
379
+ let parsed: unknown;
380
+ try {
381
+ parsed = JSON.parse(raw);
382
+ } catch {
383
+ return undefined;
384
+ }
385
+ if (isPubEnvelope(parsed)) {
386
+ if (parsed.origin === origin) return undefined;
387
+ const id =
388
+ typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : crypto.randomUUID();
389
+ return { id, payload: parsed.payload ?? null };
390
+ }
391
+ return { id: crypto.randomUUID(), payload: parsed };
392
+ }
393
+
394
+ function pubBody(id: string, origin: string, payload: unknown): string {
395
+ const envelope: SignalPubEnvelope = {
396
+ __oke_env: 1,
397
+ id,
398
+ origin,
399
+ payload: payload ?? null,
400
+ };
401
+ return JSON.stringify(envelope);
402
+ }
403
+
404
+ function parseFailures(raw: string | undefined): SignalFailureReason[] {
405
+ if (!raw) return [];
406
+ try {
407
+ const parsed: unknown = JSON.parse(raw);
408
+ if (!Array.isArray(parsed)) return [];
409
+ const out: SignalFailureReason[] = [];
410
+ for (const item of parsed) {
411
+ if (item === null || typeof item !== "object") continue;
412
+ const rec = item as Record<string, unknown>;
413
+ if (typeof rec.code !== "string" || typeof rec.message !== "string") continue;
414
+ if (typeof rec.at !== "number" || typeof rec.attempt !== "number") continue;
415
+ out.push({ code: rec.code, message: rec.message, at: rec.at, attempt: rec.attempt });
416
+ }
417
+ return out;
418
+ } catch {
419
+ return [];
420
+ }
36
421
  }
37
422
 
38
423
  /**
39
424
  * Bind {@link Bun.RedisClient} to {@link SignalRedisClientLike}.
40
425
  *
41
- * Streams use typed `xadd` / `xgroup` / `xreadgroup` / `xack` (Bun ≥1.4).
42
- * Pub/sub uses typed `publish` / `subscribe`.
426
+ * Streams use typed `xadd` / `xgroup` / `xreadgroup` / `xautoclaim` / `xack`
427
+ * (Bun ≥1.4). Pub/sub uses typed `publish` / `subscribe`. `close` stops the
428
+ * idle-reset timer and closes connections this helper opened.
43
429
  *
44
- * @param url - Optional Redis URL
430
+ * @param url - Optional Redis URL. Omit to use the shared `Bun.redis` client.
45
431
  */
46
432
  export function createBunSignalRedisClient(url?: string): SignalRedisClientLike {
47
- const redis = url !== undefined ? new Bun.RedisClient(url) : Bun.redis;
433
+ const dedicated = url !== undefined;
434
+ const redis = dedicated ? new Bun.RedisClient(url) : Bun.redis;
48
435
  /** Subscribe blocks a connection — keep a dedicated client. */
49
436
  let sub: InstanceType<typeof Bun.RedisClient> | undefined;
50
437
 
51
438
  function subClient(): InstanceType<typeof Bun.RedisClient> {
52
439
  if (!sub) {
53
- sub = url !== undefined ? new Bun.RedisClient(url) : new Bun.RedisClient();
440
+ sub = dedicated ? new Bun.RedisClient(url) : new Bun.RedisClient();
54
441
  }
55
442
  return sub;
56
443
  }
57
444
 
58
- return {
59
- async xadd(key, id, fields) {
445
+ const streams = () => bunStreams(redis);
446
+
447
+ const api = {
448
+ async xadd(key: string, id: string, fields: Record<string, string>): Promise<string> {
60
449
  const pairs: string[] = [];
61
450
  for (const [k, v] of Object.entries(fields)) {
62
451
  pairs.push(k, v);
63
452
  }
64
- return String(await typedStreams(redis).xadd(key, id, ...pairs));
453
+ return String(await streams().xadd(key, id, ...pairs));
65
454
  },
66
- async xgroupCreate(key, group, id, opts) {
455
+ async xgroupCreate(
456
+ key: string,
457
+ group: string,
458
+ id: string,
459
+ opts?: { mkstream?: boolean },
460
+ ): Promise<void> {
67
461
  const extra = opts?.mkstream ? (["MKSTREAM"] as const) : [];
68
462
  try {
69
- await typedStreams(redis).xgroup("CREATE", key, group, id, ...extra);
463
+ await streams().xgroup("CREATE", key, group, id, ...extra);
70
464
  } catch (err) {
71
465
  const msg = err instanceof Error ? err.message : String(err);
72
466
  if (!/BUSYGROUP/i.test(msg)) throw err;
73
467
  }
74
468
  },
75
- async xreadgroup(group, consumer, key, count) {
469
+ async xreadgroup(
470
+ group: string,
471
+ consumer: string,
472
+ key: string,
473
+ count: number,
474
+ ): Promise<StreamEntry[]> {
475
+ const skip = (id: string) => isInflight(api, key, consumer, id);
476
+ let claimed: StreamEntry[] = [];
76
477
  try {
77
- await redis.send("XAUTOCLAIM", [
478
+ const reply = await streams().xautoclaim(
78
479
  key,
79
480
  group,
80
481
  consumer,
81
- "5000",
482
+ SIGNAL_REDIS_MIN_IDLE_MS,
82
483
  "0-0",
83
484
  "COUNT",
84
- String(count),
85
- ]);
485
+ count,
486
+ );
487
+ claimed = parseXreadgroupReply(reply);
86
488
  } catch {
87
489
  /* Idle reclaim is best-effort; same-consumer pending still returns below. */
88
490
  }
89
491
  const pending = parseXreadgroupReply(
90
- await typedStreams(redis).xreadgroup(
91
- "GROUP",
92
- group,
93
- consumer,
94
- "COUNT",
95
- count,
96
- "STREAMS",
97
- key,
98
- "0",
99
- ),
492
+ await streams().xreadgroup("GROUP", group, consumer, "COUNT", count, "STREAMS", key, "0"),
100
493
  );
101
- if (pending.length > 0) return pending;
102
- const reply = await typedStreams(redis).xreadgroup(
103
- "GROUP",
104
- group,
105
- consumer,
106
- "COUNT",
107
- count,
108
- "STREAMS",
109
- key,
110
- ">",
494
+ const owned = mergeEntries([claimed, pending], skip, count);
495
+ if (owned.length > 0) return owned;
496
+ const fresh = parseXreadgroupReply(
497
+ await streams().xreadgroup("GROUP", group, consumer, "COUNT", count, "STREAMS", key, ">"),
111
498
  );
112
- return parseXreadgroupReply(reply);
499
+ return mergeEntries([fresh], skip, count);
500
+ },
501
+ async xack(key: string, group: string, id: string): Promise<number> {
502
+ return Number(await streams().xack(key, group, id));
503
+ },
504
+ async xclaim(
505
+ key: string,
506
+ group: string,
507
+ consumer: string,
508
+ minIdleMs: number,
509
+ ids: readonly string[],
510
+ ): Promise<void> {
511
+ const first = ids[0];
512
+ if (first === undefined) return;
513
+ await streams().xclaim(key, group, consumer, minIdleMs, first, ...ids.slice(1), "JUSTID");
514
+ },
515
+ async hincrby(key: string, field: string, increment: number): Promise<number> {
516
+ return Number(await streams().hincrby(key, field, increment));
113
517
  },
114
- async xack(key, group, id) {
115
- return Number(await typedStreams(redis).xack(key, group, id));
518
+ async hdel(key: string, field: string): Promise<number> {
519
+ return Number(await streams().hdel(key, field));
116
520
  },
117
- async publish(channel, message) {
521
+ async xrange(key: string): Promise<StreamEntry[]> {
522
+ return parseEntryList(await streams().xrange(key, "-", "+"));
523
+ },
524
+ async xinfoConsumers(key: string, group: string): Promise<RedisConsumerInfo[]> {
525
+ try {
526
+ return parseXinfoConsumers(await streams().xinfo("CONSUMERS", key, group));
527
+ } catch {
528
+ return [];
529
+ }
530
+ },
531
+ async xgroupDelConsumer(key: string, group: string, consumer: string): Promise<void> {
532
+ await streams().xgroup("DELCONSUMER", key, group, consumer);
533
+ },
534
+ async publish(channel: string, message: string): Promise<number> {
118
535
  return redis.publish(channel, message);
119
536
  },
120
- async subscribe(channel, listener) {
537
+ async subscribe(channel: string, listener: (message: string) => void): Promise<() => void> {
121
538
  const client = subClient();
122
539
  await client.subscribe(channel, (message) => {
123
540
  listener(message);
@@ -126,61 +543,66 @@ export function createBunSignalRedisClient(url?: string): SignalRedisClientLike
126
543
  void client.unsubscribe(channel);
127
544
  };
128
545
  },
546
+ async close(): Promise<void> {
547
+ clearHeartbeats(api);
548
+ sub?.close();
549
+ sub = undefined;
550
+ if (dedicated) redis.close();
551
+ },
129
552
  };
130
- }
131
553
 
132
- /**
133
- * Parse Redis `XREADGROUP` nested-array reply into field maps.
134
- *
135
- * @param reply - Raw `send` result
136
- */
137
- export function parseXreadgroupReply(
138
- reply: unknown,
139
- ): Array<{ id: string; fields: Record<string, string> }> {
140
- if (reply == null || !Array.isArray(reply)) return [];
141
- const out: Array<{ id: string; fields: Record<string, string> }> = [];
142
- for (const stream of reply) {
143
- if (!Array.isArray(stream) || stream.length < 2) continue;
144
- const entries = stream[1];
145
- if (!Array.isArray(entries)) continue;
146
- for (const entry of entries) {
147
- if (!Array.isArray(entry) || entry.length < 2) continue;
148
- const id = String(entry[0]);
149
- const flat = entry[1];
150
- const fields: Record<string, string> = {};
151
- if (Array.isArray(flat)) {
152
- for (let i = 0; i + 1 < flat.length; i += 2) {
153
- fields[String(flat[i])] = String(flat[i + 1]);
154
- }
155
- }
156
- out.push({ id, fields });
157
- }
554
+ if (dedicated) {
555
+ const previousOnClose = redis.onclose;
556
+ redis.onclose = function (this: Bun.RedisClient, error: Error): void {
557
+ clearHeartbeats(api);
558
+ previousOnClose?.call(this, error);
559
+ };
158
560
  }
159
- return out;
561
+
562
+ return api;
160
563
  }
161
564
 
162
565
  /**
163
566
  * In-memory redis-protocol fake for signal streams + pub/sub.
567
+ *
568
+ * Pending entries carry a claim clock (`now`, default `Date.now`). Another
569
+ * consumer reclaims one only after {@link SIGNAL_REDIS_MIN_IDLE_MS}.
570
+ * `flushIdle` runs the same batched `XCLAIM ... JUSTID` the idle-reset timer uses.
571
+ *
572
+ * @param options - Optional clock for idle / reclaim tests
164
573
  */
165
- export function createSignalRedisFake(): SignalRedisClientLike & {
574
+ export function createSignalRedisFake(options?: { now?: () => number }): SignalRedisClientLike & {
166
575
  readonly streams: Map<string, Array<{ id: string; fields: Record<string, string> }>>;
167
576
  readonly published: Array<{ channel: string; message: string }>;
577
+ flushIdle(): Promise<void>;
168
578
  } {
579
+ const clock = options?.now ?? (() => Date.now());
169
580
  const streams = new Map<string, Array<{ id: string; fields: Record<string, string> }>>();
170
581
  const groups = new Map<string, Set<string>>();
171
582
  const claimed = new Map<string, Set<string>>();
172
- const pending = new Map<
173
- string,
174
- Array<{ id: string; fields: Record<string, string>; consumer: string }>
175
- >();
176
- const subs = new Map<string, Set<(message: string) => void>>();
583
+ const pending = new Map<string, PendingEntry[]>();
584
+ const consumerSeen = new Map<string, Map<string, number>>();
585
+ const hashes = new Map<string, Map<string, number>>();
586
+ const subs = new Map<string, Set<(message: string) => void | Promise<void>>>();
177
587
  const published: Array<{ channel: string; message: string }> = [];
178
588
  let seq = 0;
179
589
 
180
- return {
590
+ function touchConsumer(gkey: string, consumer: string): void {
591
+ let names = consumerSeen.get(gkey);
592
+ if (!names) {
593
+ names = new Map();
594
+ consumerSeen.set(gkey, names);
595
+ }
596
+ names.set(consumer, clock());
597
+ }
598
+
599
+ const api = {
181
600
  streams,
182
601
  published,
183
- async xadd(key, id, fields) {
602
+ async flushIdle(): Promise<void> {
603
+ await refreshInflight(api);
604
+ },
605
+ async xadd(key: string, id: string, fields: Record<string, string>): Promise<string> {
184
606
  let list = streams.get(key);
185
607
  if (!list) {
186
608
  list = [];
@@ -190,43 +612,80 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
190
612
  list.push({ id: realId, fields: { ...fields } });
191
613
  return realId;
192
614
  },
193
- async xgroupCreate(key, group, _id, opts) {
615
+ async xgroupCreate(
616
+ key: string,
617
+ group: string,
618
+ _id: string,
619
+ opts?: { mkstream?: boolean },
620
+ ): Promise<void> {
194
621
  if (opts?.mkstream && !streams.has(key)) streams.set(key, []);
195
622
  const gkey = `${key}::${group}`;
196
623
  if (!groups.has(gkey)) groups.set(gkey, new Set());
197
624
  if (!claimed.has(gkey)) claimed.set(gkey, new Set());
198
625
  if (!pending.has(gkey)) pending.set(gkey, []);
199
626
  },
200
- async xreadgroup(group, consumer, key, count) {
627
+ async xreadgroup(
628
+ group: string,
629
+ consumer: string,
630
+ key: string,
631
+ count: number,
632
+ ): Promise<StreamEntry[]> {
201
633
  const gkey = `${key}::${group}`;
202
634
  if (!groups.has(gkey)) {
203
- await this.xgroupCreate(key, group, "0", { mkstream: true });
635
+ await api.xgroupCreate(key, group, "0", { mkstream: true });
204
636
  }
205
- const seen = claimed.get(gkey)!;
206
- const held = pending.get(gkey)!;
637
+ touchConsumer(gkey, consumer);
638
+ const seen = claimed.get(gkey);
639
+ const held = pending.get(gkey);
640
+ if (!seen || !held) return [];
207
641
  const list = streams.get(key) ?? [];
208
- const out: Array<{ id: string; fields: Record<string, string> }> = [];
209
- for (const entry of held) {
210
- if (entry.consumer !== consumer) continue;
642
+ const t = clock();
643
+ const out: StreamEntry[] = [];
644
+
645
+ const push = (entry: PendingEntry): boolean => {
646
+ if (isInflight(api, key, consumer, entry.id)) return false;
647
+ if (out.some((row) => row.id === entry.id)) return false;
211
648
  out.push({ id: entry.id, fields: { ...entry.fields } });
212
- if (out.length >= count) return out;
649
+ return out.length >= count;
650
+ };
651
+
652
+ let full = false;
653
+ for (const entry of held) {
654
+ if (entry.consumer === consumer) continue;
655
+ if (t - entry.claimedAt < SIGNAL_REDIS_MIN_IDLE_MS) continue;
656
+ entry.consumer = consumer;
657
+ entry.claimedAt = t;
658
+ if (push(entry)) {
659
+ full = true;
660
+ break;
661
+ }
213
662
  }
663
+ if (!full) {
664
+ for (const entry of held) {
665
+ if (entry.consumer !== consumer) continue;
666
+ if (push(entry)) {
667
+ full = true;
668
+ break;
669
+ }
670
+ }
671
+ }
672
+ if (out.length > 0) return out;
673
+
214
674
  for (const entry of list) {
215
675
  if (seen.has(entry.id)) continue;
216
676
  seen.add(entry.id);
217
- held.push({ id: entry.id, fields: { ...entry.fields }, consumer });
218
- out.push({ id: entry.id, fields: { ...entry.fields } });
219
- if (out.length >= count) return out;
220
- }
221
- for (const entry of held) {
222
- if (entry.consumer === consumer) continue;
223
- entry.consumer = consumer;
224
- out.push({ id: entry.id, fields: { ...entry.fields } });
225
- if (out.length >= count) break;
677
+ const claimedEntry: PendingEntry = {
678
+ id: entry.id,
679
+ fields: { ...entry.fields },
680
+ consumer,
681
+ claimedAt: t,
682
+ };
683
+ held.push(claimedEntry);
684
+ if (push(claimedEntry)) break;
226
685
  }
227
686
  return out;
228
687
  },
229
- async xack(key, group, id) {
688
+ async xack(key: string, group: string, id: string): Promise<number> {
230
689
  const gkey = `${key}::${group}`;
231
690
  const held = pending.get(gkey);
232
691
  if (!held) return 0;
@@ -234,14 +693,80 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
234
693
  pending.set(gkey, next);
235
694
  return next.length === held.length ? 0 : 1;
236
695
  },
237
- async publish(channel, message) {
696
+ async xclaim(
697
+ key: string,
698
+ group: string,
699
+ consumer: string,
700
+ minIdleMs: number,
701
+ ids: readonly string[],
702
+ ): Promise<void> {
703
+ const held = pending.get(`${key}::${group}`);
704
+ if (!held || ids.length === 0) return;
705
+ const want = new Set(ids);
706
+ const t = clock();
707
+ for (const entry of held) {
708
+ if (!want.has(entry.id)) continue;
709
+ if (t - entry.claimedAt < minIdleMs) continue;
710
+ entry.consumer = consumer;
711
+ entry.claimedAt = t;
712
+ }
713
+ },
714
+ async hincrby(key: string, field: string, increment: number): Promise<number> {
715
+ let hash = hashes.get(key);
716
+ if (!hash) {
717
+ hash = new Map();
718
+ hashes.set(key, hash);
719
+ }
720
+ const next = (hash.get(field) ?? 0) + increment;
721
+ hash.set(field, next);
722
+ return next;
723
+ },
724
+ async hdel(key: string, field: string): Promise<number> {
725
+ const hash = hashes.get(key);
726
+ if (!hash || !hash.has(field)) return 0;
727
+ hash.delete(field);
728
+ return 1;
729
+ },
730
+ async xrange(key: string): Promise<StreamEntry[]> {
731
+ const list = streams.get(key) ?? [];
732
+ return list.map((entry) => ({ id: entry.id, fields: { ...entry.fields } }));
733
+ },
734
+ async xinfoConsumers(key: string, group: string): Promise<RedisConsumerInfo[]> {
735
+ const gkey = `${key}::${group}`;
736
+ const names = consumerSeen.get(gkey);
737
+ if (!names) return [];
738
+ const held = pending.get(gkey) ?? [];
739
+ const t = clock();
740
+ const out: RedisConsumerInfo[] = [];
741
+ for (const [name, seenAt] of names) {
742
+ let pendingCount = 0;
743
+ for (const entry of held) {
744
+ if (entry.consumer === name) pendingCount += 1;
745
+ }
746
+ out.push({ name, pending: pendingCount, idle: Math.max(0, t - seenAt) });
747
+ }
748
+ return out;
749
+ },
750
+ async xgroupDelConsumer(key: string, group: string, consumer: string): Promise<void> {
751
+ const gkey = `${key}::${group}`;
752
+ consumerSeen.get(gkey)?.delete(consumer);
753
+ const held = pending.get(gkey);
754
+ if (!held) return;
755
+ pending.set(
756
+ gkey,
757
+ held.filter((entry) => entry.consumer !== consumer),
758
+ );
759
+ },
760
+ async publish(channel: string, message: string): Promise<number> {
238
761
  published.push({ channel, message });
239
762
  const set = subs.get(channel);
240
763
  if (!set) return 0;
241
- for (const fn of set) fn(message);
764
+ const listeners: Array<(message: string) => void | Promise<void>> = [];
765
+ for (const fn of set) listeners.push(fn);
766
+ for (const fn of listeners) await fn(message);
242
767
  return set.size;
243
768
  },
244
- async subscribe(channel, listener) {
769
+ async subscribe(channel: string, listener: (message: string) => void): Promise<() => void> {
245
770
  let set = subs.get(channel);
246
771
  if (!set) {
247
772
  set = new Set();
@@ -249,10 +774,15 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
249
774
  }
250
775
  set.add(listener);
251
776
  return () => {
252
- set!.delete(listener);
777
+ set.delete(listener);
253
778
  };
254
779
  },
780
+ async close(): Promise<void> {
781
+ clearHeartbeats(api);
782
+ },
255
783
  };
784
+
785
+ return api;
256
786
  }
257
787
 
258
788
  /**
@@ -262,23 +792,35 @@ export function createSignalRedisFake(): SignalRedisClientLike & {
262
792
  */
263
793
  export async function openRedisSignal(options: SignalOpenOptions): Promise<SignalBus> {
264
794
  // Same DI shape as postgres/redis/s3: inject a client in tests; production
265
- // binds Bun.redis (Streams via send until Bun ships typed xadd).
795
+ // binds Bun.redis (Streams via the typed client).
266
796
  const redis = options.redis ?? createBunSignalRedisClient();
797
+ const wire = asWire(redis);
798
+ const ownsClient = options.redis === undefined;
267
799
  const outbox = await createSignalEngine("redis", options);
268
800
  const compete = options.compete === true;
269
801
  const onceHandlers = new Map<string, SignalHandler[]>();
270
- const streamDead: DeadLetter[] = [];
271
- const attemptById = new Map<string, number>();
802
+ const remoteHandlers = new Map<string, SignalHandler[]>();
803
+ const liveEmitters = new Map<string, Set<(event: LiveEvent) => void>>();
272
804
  const now = options.now ?? (() => Date.now());
805
+ const origin = options.consumerId ?? crypto.randomUUID();
806
+ const channelUnsubs: Array<() => void | Promise<void>> = [];
273
807
 
274
808
  if (compete) {
275
809
  for (const [name, decl] of options.signals) {
276
810
  if (decl.delivery !== "once") continue;
277
- outbox.subscribe(name, "oke-relay-ack", async () => {});
278
- onceHandlers.set(name, []);
811
+ await outbox.subscribe(name, "oke-relay-ack", async () => {});
279
812
  }
280
813
  }
281
814
 
815
+ function liveSet(name: string): Set<(event: LiveEvent) => void> {
816
+ let set = liveEmitters.get(name);
817
+ if (!set) {
818
+ set = new Set();
819
+ liveEmitters.set(name, set);
820
+ }
821
+ return set;
822
+ }
823
+
282
824
  function streamMessage(
283
825
  name: string,
284
826
  id: string,
@@ -299,29 +841,125 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
299
841
  };
300
842
  }
301
843
 
844
+ async function deliverPub(
845
+ signalName: string,
846
+ delivery: "broadcast" | "live",
847
+ raw: string,
848
+ ): Promise<void> {
849
+ const incoming = decodePublished(raw, origin);
850
+ if (!incoming) return;
851
+ if (delivery === "live") {
852
+ const emitters = liveEmitters.get(signalName);
853
+ if (!emitters) return;
854
+ for (const emit of emitters) {
855
+ emit({ id: incoming.id, payload: incoming.payload });
856
+ }
857
+ return;
858
+ }
859
+ const at = now();
860
+ const handlers = remoteHandlers.get(signalName) ?? [];
861
+ for (const handler of handlers) {
862
+ try {
863
+ await handler({
864
+ id: incoming.id,
865
+ signal: signalName,
866
+ payload: incoming.payload,
867
+ delivery: "broadcast",
868
+ attempts: 1,
869
+ failures: [],
870
+ createdAt: at,
871
+ availableAt: at,
872
+ status: "inflight",
873
+ });
874
+ } catch {
875
+ /* One subscriber's error must not drop the relay. */
876
+ }
877
+ }
878
+ }
879
+
880
+ for (const [name, decl] of options.signals) {
881
+ if (decl.delivery === "broadcast") {
882
+ channelUnsubs.push(
883
+ await redis.subscribe(bcastChannel(name), (message) => {
884
+ return deliverPub(name, "broadcast", message);
885
+ }),
886
+ );
887
+ } else if (decl.delivery === "live") {
888
+ channelUnsubs.push(
889
+ await redis.subscribe(liveChannel(name), (message) => {
890
+ return deliverPub(name, "live", message);
891
+ }),
892
+ );
893
+ }
894
+ }
895
+
896
+ const stopHeartbeat = retainHeartbeat(redis);
897
+
898
+ async function reapIdleConsumers(key: string, selfName: string): Promise<void> {
899
+ try {
900
+ const rows = await wire.xinfoConsumers(key, STREAM_GROUP);
901
+ for (const row of rows) {
902
+ if (row.name === selfName) continue;
903
+ if (row.pending !== 0) continue;
904
+ if (row.idle < CONSUMER_IDLE_RETENTION_MS) continue;
905
+ await wire.xgroupDelConsumer(key, STREAM_GROUP, row.name);
906
+ }
907
+ } catch {
908
+ /* Consumer cleanup is best-effort. */
909
+ }
910
+ }
911
+
912
+ async function readDeadLetters(name: string): Promise<DeadLetter[]> {
913
+ const rows = await wire.xrange(deadKey(name));
914
+ const out: DeadLetter[] = [];
915
+ for (const row of rows) {
916
+ let payload: unknown = null;
917
+ try {
918
+ payload = JSON.parse(row.fields.payload ?? "null");
919
+ } catch {
920
+ payload = null;
921
+ }
922
+ const at = Number(row.fields.createdAt ?? now());
923
+ out.push({
924
+ id: row.fields.id ?? row.id,
925
+ signal: name,
926
+ payload,
927
+ delivery: "once",
928
+ attempts: Number(row.fields.attempts ?? 0),
929
+ failures: parseFailures(row.fields.failures),
930
+ createdAt: at,
931
+ availableAt: at,
932
+ status: "dead",
933
+ });
934
+ }
935
+ return out;
936
+ }
937
+
302
938
  async function drainOnceStreams(): Promise<void> {
303
- const group = "oke";
304
939
  const consumer = options.consumerId ?? "local";
305
940
  for (const [name, decl] of options.signals) {
306
941
  if (decl.delivery !== "once") continue;
307
- const key = `oke:signal:${name}`;
308
- await redis.xgroupCreate(key, group, "0", { mkstream: true });
309
- const rows = await redis.xreadgroup(group, consumer, key, 32);
942
+ const key = streamKey(name);
943
+ await wire.xgroupCreate(key, STREAM_GROUP, "0", { mkstream: true });
944
+ const rows = await wire.xreadgroup(STREAM_GROUP, consumer, key, 32);
945
+ const seen = new Set<string>();
310
946
  for (const row of rows) {
947
+ if (seen.has(row.id) || isInflight(redis, key, consumer, row.id)) continue;
948
+ seen.add(row.id);
311
949
  const handler = onceHandlers.get(name)?.[0];
950
+ if (!handler) continue;
312
951
  let payload: unknown = null;
313
952
  try {
314
953
  payload = JSON.parse(row.fields.payload ?? "null");
315
954
  } catch {
316
955
  payload = null;
317
956
  }
318
- const attempt = (attemptById.get(row.id) ?? 0) + 1;
319
- attemptById.set(row.id, attempt);
320
- if (!handler) continue;
957
+ markInflight(redis, key, STREAM_GROUP, consumer, row.id);
958
+ const attempt = await wire.hincrby(attemptsKey(name), row.id, 1);
321
959
  try {
322
960
  await handler(streamMessage(name, row.id, payload, attempt));
323
- await redis.xack(key, group, row.id);
324
- attemptById.delete(row.id);
961
+ await wire.xack(key, STREAM_GROUP, row.id);
962
+ await wire.hdel(attemptsKey(name), row.id);
325
963
  } catch (err) {
326
964
  const retries = decl.retries ?? 3;
327
965
  if (attempt > retries) {
@@ -332,46 +970,47 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
332
970
  attempt,
333
971
  };
334
972
  if (decl.deadLetter) {
335
- const at = now();
336
- streamDead.push({
973
+ await wire.xadd(deadKey(name), "*", {
337
974
  id: row.id,
338
975
  signal: name,
339
- payload,
340
- delivery: "once",
341
- attempts: attempt,
342
- failures: [failure],
343
- createdAt: at,
344
- availableAt: at,
345
- status: "dead",
976
+ payload: JSON.stringify(payload ?? null),
977
+ attempts: String(attempt),
978
+ failures: JSON.stringify([failure]),
979
+ createdAt: String(now()),
346
980
  });
347
981
  }
348
- await redis.xack(key, group, row.id);
349
- attemptById.delete(row.id);
982
+ await wire.xack(key, STREAM_GROUP, row.id);
983
+ await wire.hdel(attemptsKey(name), row.id);
350
984
  }
985
+ } finally {
986
+ unmarkInflight(redis, key, consumer, row.id);
351
987
  }
352
988
  }
989
+ await reapIdleConsumers(key, consumer);
353
990
  }
354
991
  }
355
992
 
356
993
  async function relayToRedis(signal: string, payload: unknown): Promise<void> {
357
994
  const decl = options.signals.get(signal);
358
995
  if (!decl) return;
359
- const body = JSON.stringify(payload ?? null);
360
996
  if (decl.delivery === "once") {
361
- const key = `oke:signal:${signal}`;
362
- await redis.xgroupCreate(key, "oke", "0", { mkstream: true });
363
- await redis.xadd(key, "*", { payload: body, signal });
364
- } else if (decl.delivery === "broadcast") {
365
- await redis.publish(`oke:signal:bcast:${signal}`, body);
997
+ const key = streamKey(signal);
998
+ await wire.xgroupCreate(key, STREAM_GROUP, "0", { mkstream: true });
999
+ await wire.xadd(key, "*", { payload: JSON.stringify(payload ?? null), signal });
1000
+ return;
1001
+ }
1002
+ const body = pubBody(crypto.randomUUID(), origin, payload);
1003
+ if (decl.delivery === "broadcast") {
1004
+ await wire.publish(bcastChannel(signal), body);
366
1005
  } else {
367
- await redis.publish(`oke:signal:live:${signal}`, body);
1006
+ await wire.publish(liveChannel(signal), body);
368
1007
  }
369
1008
  }
370
1009
 
371
1010
  return {
372
1011
  driverId: "redis",
373
- async emit(signal, payload, options) {
374
- await outbox.emit(signal, payload, options);
1012
+ async emit(signal, payload, emitOptions) {
1013
+ await outbox.emit(signal, payload, emitOptions);
375
1014
  await relayToRedis(signal, payload);
376
1015
  },
377
1016
  async begin() {
@@ -379,9 +1018,9 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
379
1018
  const tx = await outbox.begin();
380
1019
  return {
381
1020
  write: (k, v) => tx.write(k, v),
382
- async emit(signal, payload, options) {
1021
+ async emit(signal, payload, emitOptions) {
383
1022
  staged.push({ signal, payload });
384
- await tx.emit(signal, payload, options);
1023
+ await tx.emit(signal, payload, emitOptions);
385
1024
  },
386
1025
  async commit() {
387
1026
  await tx.commit();
@@ -390,34 +1029,80 @@ export async function openRedisSignal(options: SignalOpenOptions): Promise<Signa
390
1029
  rollback: () => tx.rollback(),
391
1030
  };
392
1031
  },
393
- subscribe(signal, subscriberId, handler) {
1032
+ async subscribe(signal, subscriberId, handler) {
394
1033
  if (compete && options.signals.get(signal)?.delivery === "once") {
395
1034
  const list = onceHandlers.get(signal) ?? [];
396
1035
  list.push(handler);
397
1036
  onceHandlers.set(signal, list);
398
- return Promise.resolve(() => {
399
- const next = (onceHandlers.get(signal) ?? []).filter((h) => h !== handler);
1037
+ return () => {
1038
+ const next = (onceHandlers.get(signal) ?? []).filter((item) => item !== handler);
400
1039
  onceHandlers.set(signal, next);
401
- });
1040
+ };
1041
+ }
1042
+ if (options.signals.get(signal)?.delivery === "broadcast") {
1043
+ const list = remoteHandlers.get(signal) ?? [];
1044
+ list.push(handler);
1045
+ remoteHandlers.set(signal, list);
1046
+ const unsub = await outbox.subscribe(signal, subscriberId, handler);
1047
+ return () => {
1048
+ const next = (remoteHandlers.get(signal) ?? []).filter((item) => item !== handler);
1049
+ if (next.length === 0) remoteHandlers.delete(signal);
1050
+ else remoteHandlers.set(signal, next);
1051
+ return unsub();
1052
+ };
402
1053
  }
403
1054
  return outbox.subscribe(signal, subscriberId, handler);
404
1055
  },
405
- live: (signal, opts) => outbox.live(signal, opts),
1056
+ live(signal, opts) {
1057
+ return createLiveIterable(async (emit) => {
1058
+ if (opts?.afterId !== undefined && opts.afterId.length > 0) {
1059
+ await outbox.checkLiveResume(signal, opts.afterId);
1060
+ }
1061
+ const set = liveSet(signal);
1062
+ set.add(emit);
1063
+ const iterator = outbox.live(signal, opts)[Symbol.asyncIterator]();
1064
+ let stopped = false;
1065
+ const pump = async (): Promise<void> => {
1066
+ try {
1067
+ for (;;) {
1068
+ if (stopped) break;
1069
+ const next = await iterator.next();
1070
+ if (stopped || next.done) break;
1071
+ emit(next.value);
1072
+ }
1073
+ } catch {
1074
+ /* Iterator closed. */
1075
+ }
1076
+ };
1077
+ void pump();
1078
+ return () => {
1079
+ stopped = true;
1080
+ set.delete(emit);
1081
+ if (set.size === 0) liveEmitters.delete(signal);
1082
+ void iterator.return?.();
1083
+ };
1084
+ });
1085
+ },
406
1086
  checkLiveResume: (signal, afterId) => outbox.checkLiveResume(signal, afterId),
407
1087
  async drain() {
408
1088
  if (compete) await drainOnceStreams();
409
1089
  await outbox.drain();
410
1090
  },
411
- async deadLetters(s) {
412
- const local = streamDead.filter((entry) => entry.signal === s);
413
- const rest = await outbox.deadLetters(s);
414
- return [...rest, ...local];
1091
+ async deadLetters(signalName) {
1092
+ const rest = await outbox.deadLetters(signalName);
1093
+ const fromStream = await readDeadLetters(signalName);
1094
+ return [...rest, ...fromStream];
415
1095
  },
416
- inspect: (s) => outbox.inspect(s),
1096
+ inspect: (signalName) => outbox.inspect(signalName),
417
1097
  replay: (opts) => outbox.replay(opts),
418
1098
  discard: (opts) => outbox.discard(opts),
419
1099
  getWrite: (k) => outbox.getWrite(k),
420
- close: () => outbox.close(),
1100
+ async close() {
1101
+ stopHeartbeat();
1102
+ for (const unsub of channelUnsubs) await unsub();
1103
+ if (ownsClient) await wire.close();
1104
+ await outbox.close();
1105
+ },
421
1106
  };
422
1107
  }
423
1108