zeitlich 0.2.49 → 0.2.51

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 (127) hide show
  1. package/README.md +26 -23
  2. package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
  3. package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
  4. package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
  5. package/dist/adapters/sandbox/daytona/index.js.map +1 -1
  6. package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
  7. package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
  8. package/dist/adapters/sandbox/e2b/index.cjs.map +1 -1
  9. package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
  10. package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
  11. package/dist/adapters/sandbox/e2b/index.js.map +1 -1
  12. package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
  13. package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
  14. package/dist/adapters/thread/anthropic/index.cjs +60 -55
  15. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  16. package/dist/adapters/thread/anthropic/index.d.cts +20 -15
  17. package/dist/adapters/thread/anthropic/index.d.ts +20 -15
  18. package/dist/adapters/thread/anthropic/index.js +60 -55
  19. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  20. package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
  21. package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
  22. package/dist/adapters/thread/google-genai/index.cjs +135 -66
  23. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  24. package/dist/adapters/thread/google-genai/index.d.cts +200 -26
  25. package/dist/adapters/thread/google-genai/index.d.ts +200 -26
  26. package/dist/adapters/thread/google-genai/index.js +135 -66
  27. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  28. package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
  29. package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
  30. package/dist/adapters/thread/langchain/index.cjs +67 -55
  31. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  32. package/dist/adapters/thread/langchain/index.d.cts +20 -15
  33. package/dist/adapters/thread/langchain/index.d.ts +20 -15
  34. package/dist/adapters/thread/langchain/index.js +67 -55
  35. package/dist/adapters/thread/langchain/index.js.map +1 -1
  36. package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
  37. package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
  38. package/dist/{cold-store-DKMAO1Dd.d.ts → cold-store-DyHodfAB.d.ts} +1 -1
  39. package/dist/{cold-store-CkWoNtMh.d.cts → cold-store-YOx9nmgR.d.cts} +1 -1
  40. package/dist/index.cjs +15050 -420
  41. package/dist/index.cjs.map +1 -1
  42. package/dist/index.d.cts +79 -83
  43. package/dist/index.d.ts +79 -83
  44. package/dist/index.js +15051 -417
  45. package/dist/index.js.map +1 -1
  46. package/dist/{proxy-B7CWEV-T.d.cts → proxy-2htgGQrc.d.cts} +1 -1
  47. package/dist/{proxy-ByFHMVRX.d.ts → proxy-CmiTP4pp.d.ts} +1 -1
  48. package/dist/{thread-manager-nK-WcFzM.d.ts → thread-manager-BJ5pz5Cx.d.cts} +6 -7
  49. package/dist/{thread-manager-7AW4rhfu.d.ts → thread-manager-BQAbrYXH.d.cts} +6 -7
  50. package/dist/{thread-manager-Cibe0X5m.d.cts → thread-manager-CcvltOuq.d.ts} +6 -7
  51. package/dist/{thread-manager-B9rtMEVn.d.cts → thread-manager-DHAbncHX.d.ts} +6 -7
  52. package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
  53. package/dist/{types-XUUFvrJ9.d.cts → types-BjdqxKYp.d.cts} +709 -709
  54. package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.cts} +3 -3
  55. package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.ts} +3 -3
  56. package/dist/{types-DO4Tkwxo.d.ts → types-DEbkLA06.d.ts} +3 -3
  57. package/dist/{types-DeVNWqlb.d.ts → types-DiI7mZhI.d.ts} +709 -709
  58. package/dist/{types-BR-k7h0e.d.cts → types-N_LTWe4b.d.cts} +3 -3
  59. package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
  60. package/dist/{workflow-uhOIj9D-.d.ts → workflow-CcgD6EUB.d.cts} +34 -3
  61. package/dist/{workflow-KbGsxpfh.d.cts → workflow-DBjPOKBr.d.ts} +34 -3
  62. package/dist/workflow.cjs +15008 -377
  63. package/dist/workflow.cjs.map +1 -1
  64. package/dist/workflow.d.cts +3 -3
  65. package/dist/workflow.d.ts +3 -3
  66. package/dist/workflow.js +15009 -374
  67. package/dist/workflow.js.map +1 -1
  68. package/package.json +10 -37
  69. package/src/adapters/thread/anthropic/activities.test.ts +115 -0
  70. package/src/adapters/thread/anthropic/activities.ts +11 -19
  71. package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
  72. package/src/adapters/thread/anthropic/model-invoker.test.ts +54 -3
  73. package/src/adapters/thread/anthropic/model-invoker.ts +11 -1
  74. package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
  75. package/src/adapters/thread/anthropic/thread-manager.ts +3 -4
  76. package/src/adapters/thread/google-genai/activities.test.ts +162 -0
  77. package/src/adapters/thread/google-genai/activities.ts +38 -15
  78. package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
  79. package/src/adapters/thread/google-genai/model-invoker.test.ts +386 -0
  80. package/src/adapters/thread/google-genai/model-invoker.ts +118 -23
  81. package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
  82. package/src/adapters/thread/google-genai/thread-manager.ts +3 -4
  83. package/src/adapters/thread/langchain/activities.test.ts +88 -0
  84. package/src/adapters/thread/langchain/activities.ts +15 -12
  85. package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
  86. package/src/adapters/thread/langchain/model-invoker.test.ts +74 -0
  87. package/src/adapters/thread/langchain/model-invoker.ts +16 -3
  88. package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
  89. package/src/adapters/thread/langchain/thread-manager.ts +3 -4
  90. package/src/index.ts +2 -2
  91. package/src/lib/sandbox/capability-types.test.ts +2 -2
  92. package/src/lib/sandbox/manager.ts +2 -6
  93. package/src/lib/sandbox/sandbox.test.ts +1 -1
  94. package/src/lib/sandbox/types.ts +2 -2
  95. package/src/lib/session/session.integration.test.ts +92 -0
  96. package/src/lib/session/session.ts +23 -11
  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 +140 -32
  108. package/src/lib/workflow.ts +49 -0
  109. package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
  110. package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
  111. package/src/tools/bash/bash.test.ts +1 -1
  112. package/src/tools/edit/handler.test.ts +1 -1
  113. package/tsup.config.ts +2 -4
  114. package/dist/activities-7OcT_vdR.d.cts +0 -162
  115. package/dist/activities-zG_FBoY2.d.ts +0 -162
  116. package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
  117. package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
  118. package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
  119. package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
  120. package/dist/adapters/sandbox/inmemory/index.js +0 -211
  121. package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
  122. package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
  123. package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
  124. package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
  125. package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
  126. package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
  127. package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
@@ -1,45 +1,53 @@
1
1
  import { describe, expect, it, beforeEach } from "vitest";
2
- import type Redis from "ioredis";
2
+ import type { RedisClientType } from "redis";
3
3
  import { createThreadManager } from "./manager";
4
4
  import type { PersistedThreadState } from "../state/types";
5
5
 
6
+ type Keys = string | string[];
7
+ const toKeys = (keys: Keys): string[] => (Array.isArray(keys) ? keys : [keys]);
8
+
6
9
  /**
7
- * Minimal in-memory Redis stub exposing just the commands used by
10
+ * Minimal in-memory node-redis stub exposing just the commands used by
8
11
  * `createThreadManager`'s state methods (get/set/del/exists/expire) plus
9
- * the list helpers needed for `initialize`.
12
+ * the list helpers needed for `initialize`/`fork`. Uses the node-redis
13
+ * (`redis`) camelCase API and array-or-variadic keys.
10
14
  */
11
- function createFakeRedis(): Redis {
15
+ function createFakeRedis(): RedisClientType {
12
16
  const store = new Map<string, string>();
13
17
 
14
18
  const redis = {
15
19
  async get(key: string): Promise<string | null> {
16
20
  return store.has(key) ? (store.get(key) as string) : null;
17
21
  },
18
- async set(key: string, value: string): Promise<"OK"> {
22
+ async set(
23
+ key: string,
24
+ value: string,
25
+ _options?: { EX?: number }
26
+ ): Promise<"OK"> {
19
27
  store.set(key, String(value));
20
28
  return "OK";
21
29
  },
22
- async del(...keys: string[]): Promise<number> {
30
+ async del(keys: Keys): Promise<number> {
23
31
  let removed = 0;
24
- for (const k of keys) {
32
+ for (const k of toKeys(keys)) {
25
33
  if (store.delete(k)) removed++;
26
34
  }
27
35
  return removed;
28
36
  },
29
- async exists(...keys: string[]): Promise<number> {
30
- return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
37
+ async exists(keys: Keys): Promise<number> {
38
+ return toKeys(keys).reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
31
39
  },
32
40
  async expire(_key: string, _ttl: number): Promise<number> {
33
41
  return 1;
34
42
  },
35
- async lrange(): Promise<string[]> {
43
+ async lRange(): Promise<string[]> {
36
44
  return [];
37
45
  },
38
- async rpush(): Promise<number> {
46
+ async rPush(): Promise<number> {
39
47
  return 0;
40
48
  },
41
49
  _store: store,
42
- } as unknown as Redis & { _store: Map<string, string> };
50
+ } as unknown as RedisClientType & { _store: Map<string, string> };
43
51
 
44
52
  return redis;
45
53
  }
@@ -64,10 +72,12 @@ const baseSlice: PersistedThreadState = {
64
72
  };
65
73
 
66
74
  describe("createThreadManager state persistence", () => {
67
- let redis: Redis & { _store: Map<string, string> };
75
+ let redis: RedisClientType & { _store: Map<string, string> };
68
76
 
69
77
  beforeEach(() => {
70
- redis = createFakeRedis() as Redis & { _store: Map<string, string> };
78
+ redis = createFakeRedis() as RedisClientType & {
79
+ _store: Map<string, string>;
80
+ };
71
81
  });
72
82
 
73
83
  async function initThread(threadId: string): Promise<void> {
@@ -60,12 +60,12 @@ export function createThreadManager<T>(
60
60
  return {
61
61
  async initialize(): Promise<void> {
62
62
  await redis.del(redisKey);
63
- await redis.set(metaKey, "1", "EX", ttlSeconds);
63
+ await redis.set(metaKey, "1", { EX: ttlSeconds });
64
64
  },
65
65
 
66
66
  async load(): Promise<T[]> {
67
67
  await assertThreadExists();
68
- const data = await redis.lrange(redisKey, 0, -1);
68
+ const data = await redis.lRange(redisKey, 0, -1);
69
69
  return data.map(deserialize);
70
70
  },
71
71
 
@@ -75,23 +75,19 @@ export function createThreadManager<T>(
75
75
 
76
76
  if (idOf) {
77
77
  const dedupId = messages.map(idOf).join(":");
78
- await redis.eval(
79
- APPEND_IDEMPOTENT_SCRIPT,
80
- 2,
81
- dedupKey(dedupId),
82
- redisKey,
83
- String(ttlSeconds),
84
- ...messages.map(serialize)
85
- );
78
+ await redis.eval(APPEND_IDEMPOTENT_SCRIPT, {
79
+ keys: [dedupKey(dedupId), redisKey],
80
+ arguments: [String(ttlSeconds), ...messages.map(serialize)],
81
+ });
86
82
  } else {
87
- await redis.rpush(redisKey, ...messages.map(serialize));
83
+ await redis.rPush(redisKey, messages.map(serialize));
88
84
  await redis.expire(redisKey, ttlSeconds);
89
85
  }
90
86
  },
91
87
 
92
88
  async fork(newThreadId: string): Promise<BaseThreadManager<T>> {
93
89
  await assertThreadExists();
94
- const data = await redis.lrange(redisKey, 0, -1);
90
+ const data = await redis.lRange(redisKey, 0, -1);
95
91
  const stateRaw = await redis.get(stateKey);
96
92
  const forked = createThreadManager({
97
93
  ...config,
@@ -100,12 +96,12 @@ export function createThreadManager<T>(
100
96
  await forked.initialize();
101
97
  if (data.length > 0) {
102
98
  const newKey = getThreadListKey(key, newThreadId);
103
- await redis.rpush(newKey, ...data);
99
+ await redis.rPush(newKey, data);
104
100
  await redis.expire(newKey, ttlSeconds);
105
101
  }
106
102
  if (stateRaw != null) {
107
103
  const newStateKey = getThreadStateKey(key, newThreadId);
108
- await redis.set(newStateKey, stateRaw, "EX", ttlSeconds);
104
+ await redis.set(newStateKey, stateRaw, { EX: ttlSeconds });
109
105
  }
110
106
  return forked;
111
107
  },
@@ -117,23 +113,23 @@ export function createThreadManager<T>(
117
113
  "replaceAll requires the thread manager to be configured with `idOf`"
118
114
  );
119
115
  }
120
- const existing = await redis.lrange(redisKey, 0, -1);
116
+ const existing = await redis.lRange(redisKey, 0, -1);
121
117
  const existingIds = existing
122
118
  .map((raw) => idOf(deserialize(raw)))
123
119
  .filter((id): id is string => typeof id === "string");
124
120
  await redis.del(redisKey);
125
121
  if (existingIds.length > 0) {
126
- await redis.del(...existingIds.map(dedupKey));
122
+ await redis.del(existingIds.map(dedupKey));
127
123
  }
128
124
  if (messages.length > 0) {
129
- await redis.rpush(redisKey, ...messages.map(serialize));
125
+ await redis.rPush(redisKey, messages.map(serialize));
130
126
  await redis.expire(redisKey, ttlSeconds);
131
127
  }
132
128
  await redis.expire(metaKey, ttlSeconds);
133
129
  },
134
130
 
135
131
  async delete(): Promise<void> {
136
- await redis.del(redisKey, metaKey, stateKey);
132
+ await redis.del([redisKey, metaKey, stateKey]);
137
133
  },
138
134
 
139
135
  async loadState(): Promise<PersistedThreadState | null> {
@@ -144,7 +140,7 @@ export function createThreadManager<T>(
144
140
 
145
141
  async saveState(state: PersistedThreadState): Promise<void> {
146
142
  await assertThreadExists();
147
- await redis.set(stateKey, JSON.stringify(state), "EX", ttlSeconds);
143
+ await redis.set(stateKey, JSON.stringify(state), { EX: ttlSeconds });
148
144
  },
149
145
 
150
146
  async deleteState(): Promise<void> {
@@ -153,7 +149,7 @@ export function createThreadManager<T>(
153
149
 
154
150
  async length(): Promise<number> {
155
151
  await assertThreadExists();
156
- return redis.llen(redisKey);
152
+ return redis.lLen(redisKey);
157
153
  },
158
154
 
159
155
  async truncateFromId(messageId: string): Promise<void> {
@@ -163,7 +159,7 @@ export function createThreadManager<T>(
163
159
  "truncateFromId requires the thread manager to be configured with `idOf`"
164
160
  );
165
161
  }
166
- const data = await redis.lrange(redisKey, 0, -1);
162
+ const data = await redis.lRange(redisKey, 0, -1);
167
163
  let idx = -1;
168
164
  const removedIds: string[] = [];
169
165
  for (let i = 0; i < data.length; i++) {
@@ -178,7 +174,7 @@ export function createThreadManager<T>(
178
174
  await redis.del(redisKey);
179
175
  await redis.expire(metaKey, ttlSeconds);
180
176
  } else {
181
- await redis.ltrim(redisKey, 0, idx - 1);
177
+ await redis.lTrim(redisKey, 0, idx - 1);
182
178
  await redis.expire(redisKey, ttlSeconds);
183
179
  }
184
180
  // Clear dedup markers for the removed messages so that a rewind
@@ -186,7 +182,7 @@ export function createThreadManager<T>(
186
182
  // re-append without the idempotent-append Lua script treating it
187
183
  // as a duplicate.
188
184
  if (removedIds.length > 0) {
189
- await redis.del(...removedIds.map(dedupKey));
185
+ await redis.del(removedIds.map(dedupKey));
190
186
  }
191
187
  },
192
188
  };
@@ -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
  }