zeitlich 0.2.48 → 0.2.50

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 (126) hide show
  1. package/README.md +26 -23
  2. package/dist/{activities-DCaIPQBT.d.ts → activities-IuOIvPHO.d.ts} +6 -6
  3. package/dist/{activities-BlQR5gX4.d.cts → activities-cIlq1y1y.d.cts} +6 -6
  4. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  5. package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
  6. package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
  7. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  8. package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
  9. package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
  10. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  11. package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
  12. package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
  13. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  14. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  15. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  16. package/dist/adapters/thread/anthropic/index.cjs +45 -42
  17. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  18. package/dist/adapters/thread/anthropic/index.d.cts +10 -10
  19. package/dist/adapters/thread/anthropic/index.d.ts +10 -10
  20. package/dist/adapters/thread/anthropic/index.js +45 -42
  21. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  22. package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
  23. package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
  24. package/dist/adapters/thread/google-genai/index.cjs +117 -54
  25. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  26. package/dist/adapters/thread/google-genai/index.d.cts +27 -23
  27. package/dist/adapters/thread/google-genai/index.d.ts +27 -23
  28. package/dist/adapters/thread/google-genai/index.js +117 -54
  29. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  30. package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
  31. package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
  32. package/dist/adapters/thread/langchain/index.cjs +45 -42
  33. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  34. package/dist/adapters/thread/langchain/index.d.cts +10 -10
  35. package/dist/adapters/thread/langchain/index.d.ts +10 -10
  36. package/dist/adapters/thread/langchain/index.js +45 -42
  37. package/dist/adapters/thread/langchain/index.js.map +1 -1
  38. package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
  39. package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
  40. package/dist/{cold-store-UL13Sstw.d.cts → cold-store-C0uvYTSi.d.cts} +1 -1
  41. package/dist/{cold-store-aD4TSKlU.d.ts → cold-store-CCnZYWjx.d.ts} +1 -1
  42. package/dist/index.cjs +15063 -405
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +79 -83
  45. package/dist/index.d.ts +79 -83
  46. package/dist/index.js +15064 -402
  47. package/dist/index.js.map +1 -1
  48. package/dist/{proxy-BAty3CWM.d.cts → proxy-BVznA2_p.d.cts} +1 -1
  49. package/dist/{proxy-mbnwBhHw.d.ts → proxy-C4J1pNUk.d.ts} +1 -1
  50. package/dist/{thread-manager-CICj68PI.d.ts → thread-manager-BqjzWsP7.d.ts} +4 -4
  51. package/dist/{thread-manager-R6c3lnJy.d.cts → thread-manager-CzIs47uG.d.cts} +4 -4
  52. package/dist/{thread-manager-DsXvJ5cJ.d.cts → thread-manager-Dzl1fHhV.d.cts} +4 -4
  53. package/dist/{thread-manager-DtEtbUkp.d.ts → thread-manager-SkSWRPRc.d.ts} +4 -4
  54. package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
  55. package/dist/{types-DF4wzWQG.d.ts → types-CbPnU4RM.d.ts} +3 -3
  56. package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.cts} +3 -3
  57. package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.ts} +3 -3
  58. package/dist/{types-DwBYd0ij.d.ts → types-DZnUqCAP.d.cts} +709 -686
  59. package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
  60. package/dist/{types-DWeyCTYK.d.cts → types-YNesmGKV.d.ts} +709 -686
  61. package/dist/{types-DDLPnxBh.d.cts → types-d2RvEP6v.d.cts} +3 -3
  62. package/dist/{workflow-DdaU7_j4.d.ts → workflow-B3oTe2_D.d.cts} +34 -3
  63. package/dist/{workflow-DVNPR7eX.d.cts → workflow-Bkzg0cjB.d.ts} +34 -3
  64. package/dist/workflow.cjs +15021 -362
  65. package/dist/workflow.cjs.map +1 -1
  66. package/dist/workflow.d.cts +3 -3
  67. package/dist/workflow.d.ts +3 -3
  68. package/dist/workflow.js +15022 -359
  69. package/dist/workflow.js.map +1 -1
  70. package/package.json +10 -37
  71. package/src/adapters/thread/anthropic/activities.ts +1 -1
  72. package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
  73. package/src/adapters/thread/anthropic/model-invoker.test.ts +4 -3
  74. package/src/adapters/thread/anthropic/model-invoker.ts +1 -1
  75. package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
  76. package/src/adapters/thread/anthropic/thread-manager.ts +1 -1
  77. package/src/adapters/thread/google-genai/activities.ts +1 -1
  78. package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
  79. package/src/adapters/thread/google-genai/model-invoker.test.ts +337 -0
  80. package/src/adapters/thread/google-genai/model-invoker.ts +107 -23
  81. package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
  82. package/src/adapters/thread/google-genai/thread-manager.ts +1 -1
  83. package/src/adapters/thread/langchain/activities.ts +1 -1
  84. package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
  85. package/src/adapters/thread/langchain/model-invoker.ts +1 -1
  86. package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
  87. package/src/adapters/thread/langchain/thread-manager.ts +1 -1
  88. package/src/index.ts +2 -2
  89. package/src/lib/sandbox/capability-types.test.ts +2 -2
  90. package/src/lib/sandbox/manager.ts +2 -6
  91. package/src/lib/sandbox/sandbox.test.ts +1 -1
  92. package/src/lib/sandbox/types.ts +2 -2
  93. package/src/lib/session/session.integration.test.ts +92 -0
  94. package/src/lib/session/session.ts +23 -0
  95. package/src/lib/subagent/handler.ts +23 -0
  96. package/src/lib/subagent/subagent.integration.test.ts +198 -0
  97. package/src/lib/thread/keys.test.ts +9 -9
  98. package/src/lib/thread/keys.ts +1 -1
  99. package/src/lib/thread/manager.test.ts +24 -14
  100. package/src/lib/thread/manager.ts +19 -23
  101. package/src/lib/thread/snapshot.test.ts +51 -43
  102. package/src/lib/thread/snapshot.ts +54 -32
  103. package/src/lib/thread/test-utils.ts +106 -59
  104. package/src/lib/thread/tiered.test.ts +1 -1
  105. package/src/lib/thread/types.ts +2 -2
  106. package/src/lib/tool-router/router.integration.test.ts +44 -0
  107. package/src/lib/tool-router/router.ts +149 -33
  108. package/src/lib/tool-router/types.ts +23 -0
  109. package/src/lib/workflow.ts +49 -0
  110. package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
  111. package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
  112. package/src/tools/bash/bash.test.ts +1 -1
  113. package/src/tools/edit/handler.test.ts +1 -1
  114. package/tsup.config.ts +2 -4
  115. package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
  116. package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
  117. package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
  118. package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
  119. package/dist/adapters/sandbox/inmemory/index.js +0 -211
  120. package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
  121. package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
  122. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
  123. package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
  124. package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
  125. package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
  126. package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, beforeEach } from "vitest";
2
- import type Redis from "ioredis";
2
+ import type { RedisClientType } from "redis";
3
3
  import {
4
4
  applySnapshot,
5
5
  clearHotTier,
@@ -14,7 +14,7 @@ import {
14
14
  } from "./keys";
15
15
  import type { PersistedThreadState } from "../state/types";
16
16
  import type { ThreadSnapshot } from "./cold-store";
17
- import { createFakeRedis } from "./test-utils";
17
+ import { createFakeRedis, makeMultiError } from "./test-utils";
18
18
 
19
19
  const sampleState: PersistedThreadState = {
20
20
  tasks: [
@@ -36,7 +36,7 @@ const sampleState: PersistedThreadState = {
36
36
  };
37
37
 
38
38
  describe("encodeSnapshot", () => {
39
- let redis: Redis & { _store: Map<string, unknown> };
39
+ let redis: RedisClientType & { _store: Map<string, unknown> };
40
40
 
41
41
  beforeEach(() => {
42
42
  redis = createFakeRedis();
@@ -97,7 +97,7 @@ describe("encodeSnapshot", () => {
97
97
  });
98
98
 
99
99
  describe("applySnapshot", () => {
100
- let redis: Redis & { _store: Map<string, unknown> };
100
+ let redis: RedisClientType & { _store: Map<string, unknown> };
101
101
 
102
102
  beforeEach(() => {
103
103
  redis = createFakeRedis();
@@ -178,7 +178,7 @@ describe("applySnapshot", () => {
178
178
  const metaKey = getThreadMetaKey("messages", "t-1");
179
179
  expect(await redis.exists(metaKey)).toBe(1);
180
180
  expect(
181
- await redis.lrange(getThreadListKey("messages", "t-1"), 0, -1)
181
+ await redis.lRange(getThreadListKey("messages", "t-1"), 0, -1)
182
182
  ).toEqual([]);
183
183
  });
184
184
 
@@ -190,7 +190,7 @@ describe("applySnapshot", () => {
190
190
  const wrapped = new Proxy(redis, {
191
191
  get(target, prop, receiver): unknown {
192
192
  if (prop === "del") {
193
- return async (..._keys: string[]): Promise<number> => {
193
+ return async (_keys: string | string[]): Promise<number> => {
194
194
  throw new Error("NOPERM: DEL denied by ACL");
195
195
  };
196
196
  }
@@ -206,7 +206,7 @@ describe("applySnapshot", () => {
206
206
  };
207
207
  await expect(
208
208
  applySnapshot({
209
- redis: wrapped as unknown as Redis,
209
+ redis: wrapped as unknown as RedisClientType,
210
210
  threadKey: "messages",
211
211
  threadId: "t-del-fail",
212
212
  snapshot: snap,
@@ -230,42 +230,44 @@ describe("applySnapshot", () => {
230
230
  });
231
231
 
232
232
  it("clears list/state/dedup residue when a pipelined write fails partway", async () => {
233
- // Pipeline stub that *applies* non-failing commands to the
234
- // underlying fake (so residue is observable) and errors on rpush
233
+ // `multi()` stub that *applies* non-failing commands to the
234
+ // underlying fake (so residue is observable) and errors on rPush
235
235
  // — mimicking a partial OOM where the list write fails but the
236
236
  // dedup SETs queued after it still land. Without compensating
237
237
  // cleanup, those stale dedup keys could silently skip a future
238
- // append with the same id.
238
+ // append with the same id. node-redis rejects `execAsPipeline`
239
+ // with a `MultiErrorReply` carrying per-command errors.
239
240
  const wrapped = new Proxy(redis, {
240
241
  get(target, prop, receiver): unknown {
241
- if (prop === "pipeline") {
242
+ if (prop === "multi") {
242
243
  return (): Record<string, unknown> => {
243
244
  type Op = { method: string; args: unknown[] };
244
245
  const ops: Op[] = [];
245
246
  const chain: Record<string, unknown> = {};
246
- for (const m of ["set", "del", "rpush", "expire"]) {
247
+ for (const m of ["set", "del", "rPush", "expire"]) {
247
248
  chain[m] = (...args: unknown[]): Record<string, unknown> => {
248
249
  ops.push({ method: m, args });
249
250
  return chain;
250
251
  };
251
252
  }
252
- chain.exec = async (): Promise<Array<[Error | null, unknown]>> => {
253
- const out: Array<[Error | null, unknown]> = [];
253
+ chain.execAsPipeline = async (): Promise<unknown[]> => {
254
+ const replies: unknown[] = [];
255
+ const errorIndexes: number[] = [];
254
256
  const callable = target as unknown as Record<
255
257
  string,
256
258
  (...a: unknown[]) => Promise<unknown>
257
259
  >;
258
- for (const op of ops) {
259
- if (op.method === "rpush") {
260
- out.push([new Error("OOM"), null]);
260
+ for (const [i, op] of ops.entries()) {
261
+ if (op.method === "rPush") {
262
+ replies.push(new Error("OOM"));
263
+ errorIndexes.push(i);
261
264
  continue;
262
265
  }
263
266
  const fn = callable[op.method];
264
267
  if (!fn) throw new Error(`stub: unknown ${op.method}`);
265
- const result = await fn(...op.args);
266
- out.push([null, result]);
268
+ replies.push(await fn(...op.args));
267
269
  }
268
- return out;
270
+ throw makeMultiError(replies, errorIndexes);
269
271
  };
270
272
  return chain;
271
273
  };
@@ -282,7 +284,7 @@ describe("applySnapshot", () => {
282
284
  };
283
285
  await expect(
284
286
  applySnapshot({
285
- redis: wrapped as unknown as Redis,
287
+ redis: wrapped as unknown as RedisClientType,
286
288
  threadKey: "messages",
287
289
  threadId: "t-residue",
288
290
  snapshot: snap,
@@ -305,30 +307,36 @@ describe("applySnapshot", () => {
305
307
  });
306
308
 
307
309
  it("throws and leaves the meta key unset when a pipelined command fails", async () => {
308
- // Wrap the fake's `pipeline()` so `exec()` returns a tuple list
309
- // containing a per-command error — mimicking ioredis's behaviour
310
- // when Redis runtime errors (OOM, ACL, WRONGTYPE) occur inside a
311
- // pipeline. The top-level promise resolves; the error lives in the
312
- // result tuple, and applySnapshot must surface it.
310
+ // Wrap the fake's `multi()` so `execAsPipeline()` rejects with a
311
+ // `MultiErrorReply` — mimicking node-redis's behaviour when Redis
312
+ // runtime errors (OOM, ACL, WRONGTYPE) occur inside a pipeline.
313
+ // `applySnapshot` must surface the underlying error.
313
314
  const wrapped = new Proxy(redis, {
314
315
  get(target, prop, receiver): unknown {
315
- if (prop === "pipeline") {
316
+ if (prop === "multi") {
316
317
  return (): Record<string, unknown> => {
317
318
  type Op = { method: string; args: unknown[] };
318
319
  const ops: Op[] = [];
319
320
  const chain: Record<string, unknown> = {};
320
- for (const m of ["set", "del", "rpush", "expire"]) {
321
+ for (const m of ["set", "del", "rPush", "expire"]) {
321
322
  chain[m] = (...args: unknown[]): Record<string, unknown> => {
322
323
  ops.push({ method: m, args });
323
324
  return chain;
324
325
  };
325
326
  }
326
- chain.exec = async (): Promise<Array<[Error | null, unknown]>> =>
327
- ops.map((op, i) =>
328
- op.method === "rpush"
329
- ? [new Error("OOM command not allowed"), null]
330
- : [null, i]
331
- );
327
+ chain.execAsPipeline = async (): Promise<unknown[]> => {
328
+ const replies: unknown[] = [];
329
+ const errorIndexes: number[] = [];
330
+ ops.forEach((op, i) => {
331
+ if (op.method === "rPush") {
332
+ replies.push(new Error("OOM command not allowed"));
333
+ errorIndexes.push(i);
334
+ } else {
335
+ replies.push(i);
336
+ }
337
+ });
338
+ throw makeMultiError(replies, errorIndexes);
339
+ };
332
340
  return chain;
333
341
  };
334
342
  }
@@ -344,7 +352,7 @@ describe("applySnapshot", () => {
344
352
  };
345
353
  await expect(
346
354
  applySnapshot({
347
- redis: wrapped as unknown as Redis,
355
+ redis: wrapped as unknown as RedisClientType,
348
356
  threadKey: "messages",
349
357
  threadId: "t-fail",
350
358
  snapshot: snap,
@@ -354,14 +362,14 @@ describe("applySnapshot", () => {
354
362
  expect(await redis.exists(getThreadMetaKey("messages", "t-fail"))).toBe(0);
355
363
  });
356
364
 
357
- it("issues a single pipeline.exec() rather than per-key writes", async () => {
358
- let pipelineCalls = 0;
365
+ it("issues a single multi().execAsPipeline() rather than per-key writes", async () => {
366
+ let multiCalls = 0;
359
367
  const wrapped = new Proxy(redis, {
360
368
  get(target, prop, receiver): unknown {
361
- if (prop === "pipeline") {
369
+ if (prop === "multi") {
362
370
  return (): unknown => {
363
- pipelineCalls++;
364
- return (target as unknown as { pipeline: () => unknown }).pipeline();
371
+ multiCalls++;
372
+ return (target as unknown as { multi: () => unknown }).multi();
365
373
  };
366
374
  }
367
375
  return Reflect.get(target, prop, receiver) as unknown;
@@ -377,19 +385,19 @@ describe("applySnapshot", () => {
377
385
  dedupIds: Array.from({ length: 10 }, (_, i) => `m${i}`),
378
386
  };
379
387
  await applySnapshot({
380
- redis: wrapped as unknown as Redis,
388
+ redis: wrapped as unknown as RedisClientType,
381
389
  threadKey: "messages",
382
390
  threadId: "t-1",
383
391
  snapshot: snap,
384
392
  });
385
393
 
386
- expect(pipelineCalls).toBe(1);
394
+ expect(multiCalls).toBe(1);
387
395
  });
388
396
 
389
397
  it("clears any partial residue from a previous failed restore", async () => {
390
398
  const listKey = getThreadListKey("messages", "t-1");
391
399
  const stateKey = getThreadStateKey("messages", "t-1");
392
- await redis.rpush(listKey, "stale-message");
400
+ await redis.rPush(listKey, "stale-message");
393
401
  await redis.set(stateKey, JSON.stringify({ stale: true }));
394
402
  // Note: meta is intentionally absent — simulates a half-written restore.
395
403
 
@@ -8,7 +8,7 @@
8
8
  * tiered thread manager in `tiered.ts` is the only consumer.
9
9
  */
10
10
 
11
- import type Redis from "ioredis";
11
+ import type { RedisClientType } from "redis";
12
12
  import type { PersistedThreadState } from "../state/types";
13
13
  import type { ThreadSnapshot } from "./cold-store";
14
14
  import {
@@ -21,7 +21,7 @@ import {
21
21
 
22
22
  /** Inputs shared by every snapshot operation. */
23
23
  interface SnapshotCommon {
24
- redis: Redis;
24
+ redis: RedisClientType;
25
25
  threadKey: string;
26
26
  threadId: string;
27
27
  }
@@ -53,7 +53,7 @@ export async function encodeSnapshot(
53
53
  }
54
54
  const listKey = getThreadListKey(threadKey, threadId);
55
55
  const stateKey = getThreadStateKey(threadKey, threadId);
56
- const messages = await redis.lrange(listKey, 0, -1);
56
+ const messages = await redis.lRange(listKey, 0, -1);
57
57
  const stateRaw = await redis.get(stateKey);
58
58
  const state =
59
59
  stateRaw == null ? null : (JSON.parse(stateRaw) as PersistedThreadState);
@@ -99,43 +99,65 @@ export async function applySnapshot(
99
99
  // CROSSSLOT, …) short-circuits before any writes hit the wire —
100
100
  // pipelines are non-atomic, so a queued DEL wouldn't stop later
101
101
  // commands from accumulating data behind a missing meta marker.
102
- await redis.del(listKey, stateKey);
102
+ await redis.del([listKey, stateKey]);
103
103
 
104
- // Pipeline the data writes (list/state/dedup) in one round-trip.
105
- // Meta is written separately, only after every queued command
106
- // succeeded, preserving the "meta-last" crash-safety invariant
107
- // a partial restore must leave meta absent so the next hydrate
108
- // retries cleanly.
109
- const pipeline = redis.pipeline();
104
+ // Pipeline the data writes (list/state/dedup) in one round-trip via a
105
+ // non-transactional `MULTI` (`execAsPipeline`). Meta is written
106
+ // separately, only after every queued command succeeded, preserving
107
+ // the "meta-last" crash-safety invariant a partial restore must
108
+ // leave meta absent so the next hydrate retries cleanly.
109
+ const pipeline = redis.multi();
110
110
  if (snapshot.messages.length > 0) {
111
- pipeline.rpush(listKey, ...snapshot.messages);
111
+ pipeline.rPush(listKey, snapshot.messages);
112
112
  pipeline.expire(listKey, ttlSeconds);
113
113
  }
114
114
  if (snapshot.state != null) {
115
- pipeline.set(stateKey, JSON.stringify(snapshot.state), "EX", ttlSeconds);
115
+ pipeline.set(stateKey, JSON.stringify(snapshot.state), { EX: ttlSeconds });
116
116
  }
117
117
  for (const id of snapshot.dedupIds) {
118
- pipeline.set(getThreadDedupKey(threadId, id), "1", "EX", ttlSeconds);
118
+ pipeline.set(getThreadDedupKey(threadId, id), "1", { EX: ttlSeconds });
119
119
  }
120
- const results = await pipeline.exec();
121
- if (results) {
122
- const firstErr = results.find(([err]) => err)?.[0] ?? null;
123
- if (firstErr) {
124
- // Compensate: pipelines are non-atomic, so writes queued after
125
- // a failing command (notably dedup SETs) may have landed. Best-
126
- // effort clear every key we touched so a leftover dedup marker
127
- // can't silently skip a future append with the same id.
128
- await redis
129
- .del(
130
- listKey,
131
- stateKey,
132
- ...snapshot.dedupIds.map((id) => getThreadDedupKey(threadId, id))
133
- )
134
- .catch(() => undefined);
135
- throw firstErr;
136
- }
120
+ try {
121
+ await pipeline.execAsPipeline();
122
+ } catch (err) {
123
+ // Compensate: pipelines are non-atomic, so writes queued after a
124
+ // failing command (notably dedup SETs) may have landed. Best-effort
125
+ // clear every key we touched so a leftover dedup marker can't
126
+ // silently skip a future append with the same id. node-redis
127
+ // surfaces per-command failures by rejecting `execAsPipeline` with a
128
+ // `MultiErrorReply`; we unwrap it to rethrow the first real error.
129
+ await redis
130
+ .del([
131
+ listKey,
132
+ stateKey,
133
+ ...snapshot.dedupIds.map((id) => getThreadDedupKey(threadId, id)),
134
+ ])
135
+ .catch(() => undefined);
136
+ throw firstPipelineError(err);
137
137
  }
138
- await redis.set(metaKey, "1", "EX", ttlSeconds);
138
+ await redis.set(metaKey, "1", { EX: ttlSeconds });
139
+ }
140
+
141
+ /**
142
+ * Unwrap node-redis's `MultiErrorReply` (thrown by `execAsPipeline` when
143
+ * one or more queued commands fail) to the first underlying error so
144
+ * callers see the actual Redis error (OOM, WRONGTYPE, …) rather than the
145
+ * generic aggregate wrapper. The structural check avoids a hard runtime
146
+ * dependency on the `redis` error class.
147
+ */
148
+ function firstPipelineError(err: unknown): unknown {
149
+ if (
150
+ err != null &&
151
+ typeof err === "object" &&
152
+ "replies" in err &&
153
+ Array.isArray((err as { replies: unknown }).replies)
154
+ ) {
155
+ const firstErr = (err as { replies: unknown[] }).replies.find(
156
+ (r): r is Error => r instanceof Error
157
+ );
158
+ if (firstErr) return firstErr;
159
+ }
160
+ return err;
139
161
  }
140
162
 
141
163
  /** Configuration for {@link clearHotTier}. */
@@ -159,5 +181,5 @@ export async function clearHotTier(
159
181
  getThreadStateKey(threadKey, threadId),
160
182
  ...dedupIds.map((id) => getThreadDedupKey(threadId, id)),
161
183
  ];
162
- await redis.del(...keys);
184
+ await redis.del(keys);
163
185
  }
@@ -7,19 +7,35 @@
7
7
  * picks it up directly.
8
8
  */
9
9
 
10
- import type Redis from "ioredis";
10
+ import type { RedisClientType } from "redis";
11
11
  import type { ColdThreadStore, ThreadSnapshot } from "./cold-store";
12
12
 
13
13
  type Value = string | string[];
14
14
 
15
+ /** node-redis `SetOptions` subset the stub understands. */
16
+ interface FakeSetOptions {
17
+ EX?: number;
18
+ NX?: boolean;
19
+ expiration?: { type: "EX" | "PX" | "EXAT" | "PXAT"; value: number } | "KEEPTTL";
20
+ condition?: "NX" | "XX";
21
+ }
22
+
23
+ /** node-redis accepts a single key or an array (`RedisVariadicArgument`). */
24
+ type Keys = string | string[];
25
+ const toKeys = (keys: Keys): string[] => (Array.isArray(keys) ? keys : [keys]);
26
+
15
27
  /**
16
- * Minimal in-memory Redis stub covering the commands the thread
28
+ * Minimal in-memory node-redis stub covering the commands the thread
17
29
  * manager + snapshot helpers use: get/set/del/exists/expire,
18
- * lrange/rpush/llen/ltrim, and the `eval`-based idempotent-append Lua
19
- * script. Behaviour matches Redis closely enough for unit tests; TTLs
20
- * are stored but never expire automatically.
30
+ * lRange/rPush/lLen/lTrim, and the `eval`-based idempotent-append Lua
31
+ * script. Mirrors the node-redis (`redis`) v4+ API surface camelCase
32
+ * commands, an options object for `set`, variadic-or-array keys for
33
+ * `del`/`exists`, and a `multi().execAsPipeline()` pipeline that rejects
34
+ * with a `MultiErrorReply`-shaped error when a queued command fails.
35
+ * Behaviour matches Redis closely enough for unit tests; TTLs are stored
36
+ * but never expire automatically.
21
37
  */
22
- export function createFakeRedis(): Redis & {
38
+ export function createFakeRedis(): RedisClientType & {
23
39
  _store: Map<string, Value>;
24
40
  _ttls: Map<string, number>;
25
41
  } {
@@ -48,56 +64,60 @@ export function createFakeRedis(): Redis & {
48
64
  async set(
49
65
  key: string,
50
66
  value: string,
51
- ..._rest: (string | number)[]
52
- ): Promise<"OK"> {
53
- // NX guard: when the args contain "NX" and the key already exists,
67
+ options?: FakeSetOptions
68
+ ): Promise<"OK" | null> {
69
+ // NX guard: when the condition is NX and the key already exists,
54
70
  // Redis returns null. We follow the same contract for tests that
55
- // need it; existing call sites use this for compare-and-set.
56
- const rest = _rest.map((x) => (typeof x === "string" ? x.toUpperCase() : x));
57
- if (rest.includes("NX") && store.has(key)) {
58
- return null as unknown as "OK";
71
+ // need it.
72
+ const nx = options?.NX === true || options?.condition === "NX";
73
+ if (nx && store.has(key)) {
74
+ return null;
59
75
  }
60
76
  store.set(key, String(value));
61
- const exIdx = rest.indexOf("EX");
62
- if (exIdx >= 0 && typeof _rest[exIdx + 1] === "number") {
63
- ttls.set(key, _rest[exIdx + 1] as number);
77
+ const ttl =
78
+ options?.EX ??
79
+ (options?.expiration && options.expiration !== "KEEPTTL"
80
+ ? options.expiration.value
81
+ : undefined);
82
+ if (typeof ttl === "number") {
83
+ ttls.set(key, ttl);
64
84
  }
65
85
  return "OK";
66
86
  },
67
- async del(...keys: string[]): Promise<number> {
87
+ async del(keys: Keys): Promise<number> {
68
88
  let removed = 0;
69
- for (const k of keys) {
89
+ for (const k of toKeys(keys)) {
70
90
  if (store.delete(k)) removed++;
71
91
  ttls.delete(k);
72
92
  }
73
93
  return removed;
74
94
  },
75
- async exists(...keys: string[]): Promise<number> {
76
- return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
95
+ async exists(keys: Keys): Promise<number> {
96
+ return toKeys(keys).reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
77
97
  },
78
98
  async expire(key: string, ttl: number): Promise<number> {
79
99
  if (!store.has(key)) return 0;
80
100
  ttls.set(key, ttl);
81
101
  return 1;
82
102
  },
83
- async lrange(key: string, start: number, end: number): Promise<string[]> {
103
+ async lRange(key: string, start: number, end: number): Promise<string[]> {
84
104
  if (!store.has(key)) return [];
85
105
  if (!isList(key)) return [];
86
106
  const list = store.get(key) as string[];
87
107
  const last = end === -1 ? list.length - 1 : end;
88
108
  return list.slice(start, last + 1);
89
109
  },
90
- async rpush(key: string, ...values: string[]): Promise<number> {
110
+ async rPush(key: string, element: Keys): Promise<number> {
91
111
  const list = ensureList(key);
92
- list.push(...values);
112
+ list.push(...toKeys(element));
93
113
  return list.length;
94
114
  },
95
- async llen(key: string): Promise<number> {
115
+ async lLen(key: string): Promise<number> {
96
116
  if (!store.has(key)) return 0;
97
117
  const list = store.get(key) as string[];
98
118
  return list.length;
99
119
  },
100
- async ltrim(key: string, start: number, end: number): Promise<"OK"> {
120
+ async lTrim(key: string, start: number, end: number): Promise<"OK"> {
101
121
  if (!store.has(key)) return "OK";
102
122
  const list = store.get(key) as string[];
103
123
  const last = end === -1 ? list.length - 1 : end;
@@ -106,12 +126,11 @@ export function createFakeRedis(): Redis & {
106
126
  },
107
127
  async eval(
108
128
  _script: string,
109
- numKeys: number,
110
- ...args: (string | number)[]
129
+ options: { keys?: string[]; arguments?: string[] }
111
130
  ): Promise<number> {
112
131
  // Mirrors APPEND_IDEMPOTENT_SCRIPT in src/lib/thread/manager.ts.
113
- const keys = args.slice(0, numKeys) as string[];
114
- const argv = args.slice(numKeys) as string[];
132
+ const keys = options.keys ?? [];
133
+ const argv = options.arguments ?? [];
115
134
  const dedupKey = keys[0];
116
135
  const listKey = keys[1];
117
136
  const ttl = Number(argv[0]);
@@ -127,54 +146,64 @@ export function createFakeRedis(): Redis & {
127
146
  ttls.set(dedupKey, ttl);
128
147
  return 1;
129
148
  },
130
- // Chainable pipeline stub. Defers each command to the underlying
131
- // sync fake methods on `.exec()`, so TTL tracking and store
132
- // semantics stay identical to the non-pipelined path. `fake` is
133
- // typed as `Redis` after the cast below, so we narrow it back to
134
- // the concrete impl shape here to avoid Redis's callback overloads.
135
- pipeline(): FakePipeline {
149
+ // Chainable `multi()` stub. Defers each command to the underlying
150
+ // sync fake methods on `.execAsPipeline()`, so TTL tracking and store
151
+ // semantics stay identical to the non-pipelined path. Mirrors
152
+ // node-redis: per-command failures reject the pipeline with a
153
+ // `MultiErrorReply`-shaped error (`{ replies, errorIndexes }`).
154
+ multi(): FakeMulti {
136
155
  const impl = fake as unknown as {
137
- set: (key: string, value: string, ...rest: (string | number)[]) => Promise<"OK">;
138
- del: (...keys: string[]) => Promise<number>;
139
- rpush: (key: string, ...values: string[]) => Promise<number>;
156
+ set: (
157
+ key: string,
158
+ value: string,
159
+ options?: FakeSetOptions
160
+ ) => Promise<"OK" | null>;
161
+ del: (keys: Keys) => Promise<number>;
162
+ rPush: (key: string, element: Keys) => Promise<number>;
140
163
  expire: (key: string, ttl: number) => Promise<number>;
141
164
  };
142
165
  const ops: Array<() => Promise<unknown>> = [];
143
- const chain: FakePipeline = {
144
- set: (...args) => {
145
- const [key, value, ...rest] = args as [string, string, ...(string | number)[]];
146
- ops.push(() => impl.set(key, value, ...rest));
166
+ const chain: FakeMulti = {
167
+ set: (key, value, options) => {
168
+ ops.push(() => impl.set(key, value, options));
147
169
  return chain;
148
170
  },
149
- del: (...keys) => {
150
- ops.push(() => impl.del(...keys));
171
+ del: (keys) => {
172
+ ops.push(() => impl.del(keys));
151
173
  return chain;
152
174
  },
153
- rpush: (key, ...values) => {
154
- ops.push(() => impl.rpush(key, ...values));
175
+ rPush: (key, element) => {
176
+ ops.push(() => impl.rPush(key, element));
155
177
  return chain;
156
178
  },
157
179
  expire: (key, ttl) => {
158
180
  ops.push(() => impl.expire(key, ttl));
159
181
  return chain;
160
182
  },
161
- exec: async () => {
162
- const results: Array<[Error | null, unknown]> = [];
183
+ execAsPipeline: async () => {
184
+ const replies: unknown[] = [];
185
+ const errorIndexes: number[] = [];
186
+ let i = 0;
163
187
  for (const op of ops) {
164
188
  try {
165
- results.push([null, await op()]);
189
+ replies.push(await op());
166
190
  } catch (e) {
167
- results.push([e as Error, null]);
191
+ replies.push(e);
192
+ errorIndexes.push(i);
168
193
  }
194
+ i++;
195
+ }
196
+ if (errorIndexes.length > 0) {
197
+ throw makeMultiError(replies, errorIndexes);
169
198
  }
170
- return results;
199
+ return replies;
171
200
  },
172
201
  };
173
202
  return chain;
174
203
  },
175
204
  _store: store,
176
205
  _ttls: ttls,
177
- } as unknown as Redis & {
206
+ } as unknown as RedisClientType & {
178
207
  _store: Map<string, Value>;
179
208
  _ttls: Map<string, number>;
180
209
  };
@@ -182,13 +211,31 @@ export function createFakeRedis(): Redis & {
182
211
  return fake;
183
212
  }
184
213
 
185
- /** Minimal chainable surface used by the fake-redis pipeline stub. */
186
- interface FakePipeline {
187
- set: (...args: (string | number)[]) => FakePipeline;
188
- del: (...keys: string[]) => FakePipeline;
189
- rpush: (key: string, ...values: string[]) => FakePipeline;
190
- expire: (key: string, ttl: number) => FakePipeline;
191
- exec: () => Promise<Array<[Error | null, unknown]>>;
214
+ /** Minimal chainable surface used by the fake-redis `multi()` stub. */
215
+ interface FakeMulti {
216
+ set: (key: string, value: string, options?: FakeSetOptions) => FakeMulti;
217
+ del: (keys: Keys) => FakeMulti;
218
+ rPush: (key: string, element: Keys) => FakeMulti;
219
+ expire: (key: string, ttl: number) => FakeMulti;
220
+ execAsPipeline: () => Promise<unknown[]>;
221
+ }
222
+
223
+ /**
224
+ * Build a node-redis `MultiErrorReply`-shaped error: an `Error` carrying
225
+ * `replies` (per-command results, with failures as `Error`s) and
226
+ * `errorIndexes`. `applySnapshot` unwraps this to surface the first real
227
+ * error.
228
+ */
229
+ export function makeMultiError(
230
+ replies: unknown[],
231
+ errorIndexes: number[]
232
+ ): Error & { replies: unknown[]; errorIndexes: number[] } {
233
+ return Object.assign(
234
+ new Error(
235
+ `${errorIndexes.length} commands failed, see .replies and .errorIndexes for more information`
236
+ ),
237
+ { replies, errorIndexes }
238
+ );
192
239
  }
193
240
 
194
241
  /**
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, beforeEach } from "vitest";
2
- import type Redis from "ioredis";
2
+ import type { RedisClientType as Redis } from "redis";
3
3
  import { createTieredThreadManager } from "./tiered";
4
4
  import { createThreadManager } from "./manager";
5
5
  import {
@@ -1,7 +1,7 @@
1
- import type Redis from "ioredis";
1
+ import type { RedisClientType } from "redis";
2
2
  import type { JsonValue, PersistedThreadState } from "../state/types";
3
3
  export interface ThreadManagerConfig<T> {
4
- redis: Redis;
4
+ redis: RedisClientType;
5
5
  threadId: string;
6
6
  /** Thread key, defaults to 'messages' */
7
7
  key?: string;