zeitlich 0.2.44 → 0.2.46

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 (99) hide show
  1. package/README.md +78 -10
  2. package/dist/{activities-CPIB2v2C.d.ts → activities-Bm4TLTid.d.ts} +24 -4
  3. package/dist/{activities-DnmNOnq4.d.cts → activities-CyeiqK_f.d.cts} +24 -4
  4. package/dist/adapters/sandbox/daytona/index.d.cts +2 -2
  5. package/dist/adapters/sandbox/daytona/index.d.ts +2 -2
  6. package/dist/adapters/sandbox/daytona/workflow.d.cts +1 -1
  7. package/dist/adapters/sandbox/daytona/workflow.d.ts +1 -1
  8. package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
  9. package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
  10. package/dist/adapters/thread/anthropic/index.cjs +171 -65
  11. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  12. package/dist/adapters/thread/anthropic/index.d.cts +19 -4
  13. package/dist/adapters/thread/anthropic/index.d.ts +19 -4
  14. package/dist/adapters/thread/anthropic/index.js +171 -65
  15. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  16. package/dist/adapters/thread/anthropic/workflow.cjs +3 -1
  17. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  18. package/dist/adapters/thread/anthropic/workflow.d.cts +4 -4
  19. package/dist/adapters/thread/anthropic/workflow.d.ts +4 -4
  20. package/dist/adapters/thread/anthropic/workflow.js +3 -1
  21. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  22. package/dist/adapters/thread/google-genai/index.cjs +171 -69
  23. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  24. package/dist/adapters/thread/google-genai/index.d.cts +5 -4
  25. package/dist/adapters/thread/google-genai/index.d.ts +5 -4
  26. package/dist/adapters/thread/google-genai/index.js +171 -69
  27. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  28. package/dist/adapters/thread/google-genai/workflow.cjs +3 -1
  29. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  30. package/dist/adapters/thread/google-genai/workflow.d.cts +5 -4
  31. package/dist/adapters/thread/google-genai/workflow.d.ts +5 -4
  32. package/dist/adapters/thread/google-genai/workflow.js +3 -1
  33. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  34. package/dist/adapters/thread/langchain/index.cjs +181 -77
  35. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  36. package/dist/adapters/thread/langchain/index.d.cts +18 -4
  37. package/dist/adapters/thread/langchain/index.d.ts +18 -4
  38. package/dist/adapters/thread/langchain/index.js +182 -74
  39. package/dist/adapters/thread/langchain/index.js.map +1 -1
  40. package/dist/adapters/thread/langchain/workflow.cjs +3 -1
  41. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  42. package/dist/adapters/thread/langchain/workflow.d.cts +4 -4
  43. package/dist/adapters/thread/langchain/workflow.d.ts +4 -4
  44. package/dist/adapters/thread/langchain/workflow.js +3 -1
  45. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  46. package/dist/cold-store-BC5L5Z8A.d.cts +117 -0
  47. package/dist/cold-store-CFHwemBJ.d.ts +117 -0
  48. package/dist/index.cjs +252 -53
  49. package/dist/index.cjs.map +1 -1
  50. package/dist/index.d.cts +138 -8
  51. package/dist/index.d.ts +138 -8
  52. package/dist/index.js +247 -54
  53. package/dist/index.js.map +1 -1
  54. package/dist/{proxy-DTnc5rqT.d.cts → proxy-BxFyd6cg.d.cts} +1 -1
  55. package/dist/{proxy-B7Xi1znZ.d.ts → proxy-Cskmj4Yx.d.ts} +1 -1
  56. package/dist/{thread-manager-BlX2TwRN.d.cts → thread-manager-9tezUcLW.d.cts} +9 -3
  57. package/dist/{thread-manager-BAv340mi.d.ts → thread-manager-B-zy3xrs.d.ts} +9 -3
  58. package/dist/{thread-manager-D2xorI-J.d.ts → thread-manager-D33SUmZa.d.cts} +10 -4
  59. package/dist/{thread-manager-BWv6ZXI3.d.cts → thread-manager-DduoSkvJ.d.ts} +10 -4
  60. package/dist/{types-C90VoEpt.d.cts → types-CjY93AWZ.d.cts} +1 -1
  61. package/dist/{types-4Wmk-wRq.d.cts → types-CnuN9T6t.d.cts} +23 -1
  62. package/dist/{types-DKsCdAtQ.d.ts → types-CwN6_tAL.d.ts} +23 -1
  63. package/dist/{types-Clhqautb.d.ts → types-L5bvbF-n.d.ts} +17 -1
  64. package/dist/{types-DpFD8ofR.d.ts → types-gVa5XCWD.d.ts} +1 -1
  65. package/dist/{types-DRJt1TMi.d.cts → types-oxt8GN97.d.cts} +17 -1
  66. package/dist/{workflow-D32TRMr-.d.ts → workflow-B1TOcHbt.d.ts} +33 -2
  67. package/dist/{workflow-XVt0ww8K.d.cts → workflow-DIaIV7L2.d.cts} +33 -2
  68. package/dist/workflow.cjs +29 -19
  69. package/dist/workflow.cjs.map +1 -1
  70. package/dist/workflow.d.cts +2 -2
  71. package/dist/workflow.d.ts +2 -2
  72. package/dist/workflow.js +29 -19
  73. package/dist/workflow.js.map +1 -1
  74. package/package.json +6 -1
  75. package/src/adapters/thread/anthropic/activities.ts +72 -36
  76. package/src/adapters/thread/anthropic/thread-manager.ts +9 -1
  77. package/src/adapters/thread/google-genai/activities.ts +64 -40
  78. package/src/adapters/thread/google-genai/thread-manager.ts +9 -1
  79. package/src/adapters/thread/langchain/activities.ts +63 -36
  80. package/src/adapters/thread/langchain/thread-manager.ts +9 -1
  81. package/src/index.ts +20 -1
  82. package/src/lib/session/session-edge-cases.integration.test.ts +12 -0
  83. package/src/lib/session/session.integration.test.ts +138 -0
  84. package/src/lib/session/session.ts +47 -22
  85. package/src/lib/session/types.ts +22 -0
  86. package/src/lib/thread/cold-store.test.ts +193 -0
  87. package/src/lib/thread/cold-store.ts +250 -0
  88. package/src/lib/thread/index.ts +32 -0
  89. package/src/lib/thread/keys.ts +20 -0
  90. package/src/lib/thread/manager.ts +16 -27
  91. package/src/lib/thread/proxy.ts +2 -0
  92. package/src/lib/thread/snapshot.test.ts +443 -0
  93. package/src/lib/thread/snapshot.ts +163 -0
  94. package/src/lib/thread/test-utils.ts +228 -0
  95. package/src/lib/thread/tiered.test.ts +281 -0
  96. package/src/lib/thread/tiered.ts +135 -0
  97. package/src/lib/thread/types.ts +16 -0
  98. package/src/lib/.env +0 -1
  99. package/src/tools/bash/.env +0 -1
package/src/index.ts CHANGED
@@ -33,11 +33,30 @@ export * from "./workflow";
33
33
  export { FileSystemSkillProvider } from "./lib/skills/fs-provider";
34
34
 
35
35
  // Thread manager (generic, framework-agnostic)
36
- export { createThreadManager } from "./lib/thread";
36
+ export {
37
+ createThreadManager,
38
+ createTieredThreadManager,
39
+ createS3ColdStore,
40
+ encodeSnapshot,
41
+ applySnapshot,
42
+ clearHotTier,
43
+ getThreadStateKey,
44
+ getThreadDedupKey,
45
+ } from "./lib/thread";
37
46
  export type {
38
47
  BaseThreadManager,
39
48
  ProviderThreadManager,
40
49
  ThreadManagerConfig,
50
+ TieredThreadManager,
51
+ TieredThreadManagerConfig,
52
+ FlushOptions,
53
+ ColdThreadStore,
54
+ ThreadSnapshot,
55
+ S3LikeClient,
56
+ S3ColdStoreConfig,
57
+ EncodeSnapshotConfig,
58
+ ApplySnapshotConfig,
59
+ ClearHotTierConfig,
41
60
  } from "./lib/thread";
42
61
 
43
62
  // Model invoker contract (framework-agnostic)
@@ -129,6 +129,12 @@ function createMockThreadOps() {
129
129
  log.push({ op: "saveThreadState", args: [threadId, state] });
130
130
  stateStore.set(threadId, state);
131
131
  },
132
+ hydrateThread: async (threadId) => {
133
+ log.push({ op: "hydrateThread", args: [threadId] });
134
+ },
135
+ flushThread: async (threadId) => {
136
+ log.push({ op: "flushThread", args: [threadId] });
137
+ },
132
138
  });
133
139
  return { ops, log, stateStore };
134
140
  }
@@ -824,6 +830,12 @@ describe("createSession edge cases", () => {
824
830
  saveThreadState: async (threadId, state) => {
825
831
  log.push({ op: "saveThreadState", args: [threadId, state] });
826
832
  },
833
+ hydrateThread: async (threadId) => {
834
+ log.push({ op: "hydrateThread", args: [threadId] });
835
+ },
836
+ flushThread: async (threadId) => {
837
+ log.push({ op: "flushThread", args: [threadId] });
838
+ },
827
839
  });
828
840
 
829
841
  const session = await createSession<
@@ -132,6 +132,12 @@ function createMockThreadOps() {
132
132
  log.push({ op: "saveThreadState", args: [threadId, state] });
133
133
  stateStore.set(threadId, state);
134
134
  },
135
+ hydrateThread: async (threadId) => {
136
+ log.push({ op: "hydrateThread", args: [threadId] });
137
+ },
138
+ flushThread: async (threadId) => {
139
+ log.push({ op: "flushThread", args: [threadId] });
140
+ },
135
141
  });
136
142
 
137
143
  return { ops, log, stateStore };
@@ -1336,4 +1342,136 @@ describe("createSession integration", () => {
1336
1342
  const newThreadSlice = stateStore.get(result.threadId);
1337
1343
  expect(newThreadSlice?.tasks).toHaveLength(1);
1338
1344
  });
1345
+
1346
+ // --- Cold-tier lifecycle (hydrateThread + flushThread) ---
1347
+
1348
+ it("skips hydrateThread for mode:'new' threads but still flushes on exit", async () => {
1349
+ const { ops, log } = createMockThreadOps();
1350
+
1351
+ const session = await createSession({
1352
+ agentName: "TestAgent",
1353
+ thread: { mode: "new", threadId: "fresh-thread" },
1354
+ runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
1355
+ threadOps: ops,
1356
+ buildContextMessage: () => "hi",
1357
+ });
1358
+
1359
+ const stateManager = createAgentStateManager({
1360
+ initialState: { systemPrompt: "system" },
1361
+ });
1362
+ await session.runSession({ stateManager });
1363
+
1364
+ expect(log.filter((l) => l.op === "hydrateThread")).toHaveLength(0);
1365
+ const flushes = log.filter((l) => l.op === "flushThread");
1366
+ expect(flushes).toHaveLength(1);
1367
+ expect(at(flushes, 0).args[0]).toBe("fresh-thread");
1368
+ });
1369
+
1370
+ it("hydrates the target thread before continuing it", async () => {
1371
+ const { ops, log, stateStore } = createMockThreadOps();
1372
+ stateStore.set("existing-thread", {
1373
+ tasks: [],
1374
+ custom: { previous: true },
1375
+ });
1376
+
1377
+ const session = await createSession({
1378
+ agentName: "TestAgent",
1379
+ thread: { mode: "continue", threadId: "existing-thread" },
1380
+ runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
1381
+ threadOps: ops,
1382
+ buildContextMessage: () => "follow-up",
1383
+ });
1384
+
1385
+ const stateManager = createAgentStateManager({
1386
+ initialState: { systemPrompt: "system" },
1387
+ });
1388
+ await session.runSession({ stateManager });
1389
+
1390
+ const hydrates = log.filter((l) => l.op === "hydrateThread");
1391
+ expect(hydrates).toHaveLength(1);
1392
+ expect(at(hydrates, 0).args[0]).toBe("existing-thread");
1393
+
1394
+ // hydrate runs strictly before loadThreadState so the load sees
1395
+ // fresh data when a cold tier is wired.
1396
+ const hydrateIdx = log.findIndex((l) => l.op === "hydrateThread");
1397
+ const loadIdx = log.findIndex((l) => l.op === "loadThreadState");
1398
+ expect(hydrateIdx).toBeLessThan(loadIdx);
1399
+ });
1400
+
1401
+ it("hydrates the *source* thread (not the new one) on mode:'fork'", async () => {
1402
+ const { ops, log, stateStore } = createMockThreadOps();
1403
+ stateStore.set("src-thread", { tasks: [], custom: {} });
1404
+
1405
+ const session = await createSession({
1406
+ agentName: "TestAgent",
1407
+ thread: { mode: "fork", threadId: "src-thread" },
1408
+ runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
1409
+ threadOps: ops,
1410
+ buildContextMessage: () => "fork me",
1411
+ });
1412
+
1413
+ const stateManager = createAgentStateManager({
1414
+ initialState: { systemPrompt: "system" },
1415
+ });
1416
+ const result = await session.runSession({ stateManager });
1417
+
1418
+ const hydrates = log.filter((l) => l.op === "hydrateThread");
1419
+ expect(hydrates).toHaveLength(1);
1420
+ expect(at(hydrates, 0).args[0]).toBe("src-thread");
1421
+
1422
+ // hydrate runs before forkThread so the source is hot before
1423
+ // the in-Redis fork copy.
1424
+ const hydrateIdx = log.findIndex((l) => l.op === "hydrateThread");
1425
+ const forkIdx = log.findIndex((l) => l.op === "forkThread");
1426
+ expect(hydrateIdx).toBeLessThan(forkIdx);
1427
+
1428
+ // Flush still targets the *new* thread.
1429
+ const flushes = log.filter((l) => l.op === "flushThread");
1430
+ expect(flushes).toHaveLength(1);
1431
+ expect(at(flushes, 0).args[0]).toBe(result.threadId);
1432
+ expect(at(flushes, 0).args[0]).not.toBe("src-thread");
1433
+ });
1434
+
1435
+ it("flushThread runs even when the session fails", async () => {
1436
+ const { ops, log } = createMockThreadOps();
1437
+
1438
+ const session = await createSession({
1439
+ agentName: "TestAgent",
1440
+ thread: { mode: "new", threadId: "fail-thread" },
1441
+ runAgent: async () => {
1442
+ throw new Error("boom");
1443
+ },
1444
+ threadOps: ops,
1445
+ buildContextMessage: () => "hi",
1446
+ });
1447
+
1448
+ const stateManager = createAgentStateManager({
1449
+ initialState: { systemPrompt: "system" },
1450
+ });
1451
+ await expect(session.runSession({ stateManager })).rejects.toThrow(/boom/);
1452
+
1453
+ const flushes = log.filter((l) => l.op === "flushThread");
1454
+ expect(flushes).toHaveLength(1);
1455
+ expect(at(flushes, 0).args[0]).toBe("fail-thread");
1456
+ });
1457
+
1458
+ it("flushThread runs after saveThreadState", async () => {
1459
+ const { ops, log } = createMockThreadOps();
1460
+ const session = await createSession({
1461
+ agentName: "TestAgent",
1462
+ thread: { mode: "new", threadId: "ordered-thread" },
1463
+ runAgent: createScriptedRunAgent([{ message: "ok", toolCalls: [] }]),
1464
+ threadOps: ops,
1465
+ buildContextMessage: () => "hi",
1466
+ });
1467
+ const stateManager = createAgentStateManager({
1468
+ initialState: { systemPrompt: "system" },
1469
+ });
1470
+ await session.runSession({ stateManager });
1471
+
1472
+ const saveIdx = log.findIndex((l) => l.op === "saveThreadState");
1473
+ const flushIdx = log.findIndex((l) => l.op === "flushThread");
1474
+ expect(saveIdx).toBeGreaterThanOrEqual(0);
1475
+ expect(flushIdx).toBeGreaterThan(saveIdx);
1476
+ });
1339
1477
  });
@@ -163,8 +163,7 @@ export async function createSession<
163
163
  unknown,
164
164
  SandboxCapability
165
165
  >;
166
- const wideOps = (): WideSandboxOps =>
167
- sandboxOps as unknown as WideSandboxOps;
166
+ const wideOps = (): WideSandboxOps => sandboxOps as unknown as WideSandboxOps;
168
167
  // ---------------------------------------------------------------------------
169
168
  // Thread resolution
170
169
  // ---------------------------------------------------------------------------
@@ -199,6 +198,8 @@ export async function createSession<
199
198
  forkThread,
200
199
  loadThreadState,
201
200
  saveThreadState,
201
+ hydrateThread,
202
+ flushThread,
202
203
  } = threadOps;
203
204
 
204
205
  const plugins: ToolMap[string][] = [];
@@ -276,10 +277,7 @@ export async function createSession<
276
277
  // defaults are. Both surfaces consult `resolveSessionLifecycle`
277
278
  // (or its type-level equivalent) before checking individual
278
279
  // mode/shutdown values.
279
- const lifecycle = resolveSessionLifecycle(
280
- sandboxInit,
281
- sandboxShutdown
282
- );
280
+ const lifecycle = resolveSessionLifecycle(sandboxInit, sandboxShutdown);
283
281
  const sandboxMode: SandboxInit["mode"] | undefined = lifecycle.mode;
284
282
  const resolvedShutdown: SubagentSandboxShutdown = lifecycle.shutdown;
285
283
  let sandboxId: string | undefined;
@@ -383,22 +381,6 @@ export async function createSession<
383
381
  });
384
382
  }
385
383
 
386
- if (hooks.onSessionStart) {
387
- await hooks.onSessionStart({
388
- threadId,
389
- agentName,
390
- metadata,
391
- });
392
- }
393
-
394
- log.info("session started", {
395
- agentName,
396
- threadId,
397
- threadMode,
398
- maxTurns,
399
- ...(sandboxId && { sandboxId }),
400
- });
401
-
402
384
  const sessionStartMs = Date.now();
403
385
  const systemPrompt = stateManager.getSystemPrompt();
404
386
 
@@ -411,10 +393,19 @@ export async function createSession<
411
393
  };
412
394
 
413
395
  if (threadMode === "fork" && sourceThreadId) {
396
+ // Pull the source thread from the cold tier into Redis (if a
397
+ // cold store is configured for this adapter) before the
398
+ // in-Redis fork copy runs. Hydrate is a no-op when the source
399
+ // is already hot or when no cold tier is wired.
400
+ await hydrateThread(sourceThreadId, threadKey);
414
401
  await forkThread(sourceThreadId, threadId, threadKey);
415
402
  const forkedSlice = await loadThreadState(threadId, threadKey);
416
403
  if (forkedSlice) rehydrateFromSlice(forkedSlice);
417
404
  } else if (threadMode === "continue") {
405
+ // Pull the thread from the cold tier into Redis before any
406
+ // appends. No-op when the thread is already hot or when no
407
+ // cold tier is wired.
408
+ await hydrateThread(threadId, threadKey);
418
409
  // "continue" — thread already exists, just append the new message
419
410
  const continuedSlice = await loadThreadState(threadId, threadKey);
420
411
  if (continuedSlice) rehydrateFromSlice(continuedSlice);
@@ -490,6 +481,22 @@ export async function createSession<
490
481
  let exitReason: SessionExitReason = "completed";
491
482
  let finalMessage: M | null = null;
492
483
 
484
+ if (hooks.onSessionStart) {
485
+ await hooks.onSessionStart({
486
+ threadId,
487
+ agentName,
488
+ metadata,
489
+ });
490
+ }
491
+
492
+ log.info("session started", {
493
+ agentName,
494
+ threadId,
495
+ threadMode,
496
+ maxTurns,
497
+ ...(sandboxId && { sandboxId }),
498
+ });
499
+
493
500
  try {
494
501
  // Per-turn assistant message id. Pre-generated in the workflow
495
502
  // so the runAgent activity can truncate the thread from this id
@@ -644,6 +651,24 @@ export async function createSession<
644
651
  });
645
652
  }
646
653
 
654
+ // Archive the thread to the durable cold tier (S3 / R2 / etc.)
655
+ // after the state slice has been written. No-op when no cold
656
+ // store is wired into the adapter. Best-effort — failures
657
+ // must not mask the original exit reason and the thread is
658
+ // still readable from Redis via its TTL window.
659
+ try {
660
+ await flushThread(threadId, threadKey);
661
+ } catch (flushError) {
662
+ log.warn("failed to flush thread to cold tier", {
663
+ agentName,
664
+ threadId,
665
+ error:
666
+ flushError instanceof Error
667
+ ? flushError.message
668
+ : String(flushError),
669
+ });
670
+ }
671
+
647
672
  await callSessionEnd(exitReason, stateManager.getTurns());
648
673
 
649
674
  if (sandboxOwned && sandboxId && sandboxOps) {
@@ -109,6 +109,28 @@ export interface ThreadOps<TContent = string> {
109
109
  state: PersistedThreadState,
110
110
  threadKey?: string
111
111
  ): Promise<void>;
112
+ /**
113
+ * Restore the thread's contents from the durable cold tier (if any)
114
+ * into Redis. Called once on session entry for `mode: "continue"`
115
+ * and `mode: "fork"` so the rest of the loop reads the freshest data.
116
+ *
117
+ * Adapters configured without a cold-store implementation treat this
118
+ * as a no-op. Implementations must be **idempotent** — Temporal
119
+ * retries the activity on transient failures, and a thread that is
120
+ * already hot must not be wiped.
121
+ */
122
+ hydrateThread(threadId: string, threadKey?: string): Promise<void>;
123
+ /**
124
+ * Archive the thread's contents to the durable cold tier and
125
+ * (optionally) drop the hot-tier Redis keys. Called once in the
126
+ * session's `finally` block on every exit path, after
127
+ * `saveThreadState`.
128
+ *
129
+ * Adapters configured without a cold-store implementation treat this
130
+ * as a no-op. Implementations must be **idempotent** — the cold
131
+ * tier is last-writer-wins and a retried flush must converge.
132
+ */
133
+ flushThread(threadId: string, threadKey?: string): Promise<void>;
112
134
  }
113
135
 
114
136
  /**
@@ -0,0 +1,193 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import { gunzipSync, gzipSync } from "node:zlib";
3
+ import { createS3ColdStore, type S3LikeClient } from "./cold-store";
4
+ import type { ThreadSnapshot } from "./cold-store";
5
+
6
+ interface StoredObject {
7
+ body: Buffer;
8
+ contentType?: string;
9
+ }
10
+
11
+ interface CommandInput {
12
+ Bucket: string;
13
+ Key: string;
14
+ Body?: Buffer | string;
15
+ ContentType?: string;
16
+ }
17
+
18
+ /**
19
+ * Minimal in-memory S3-shaped client. The `send()` method dispatches
20
+ * by the command's constructor name, mirroring the contract that
21
+ * `@aws-sdk/client-s3` exposes.
22
+ */
23
+ function createFakeS3(): {
24
+ s3: S3LikeClient;
25
+ store: Map<string, StoredObject>;
26
+ calls: { get: number; put: number; delete: number };
27
+ } {
28
+ const store = new Map<string, StoredObject>();
29
+ const calls = { get: 0, put: 0, delete: 0 };
30
+
31
+ const compositeKey = (bucket: string, key: string): string =>
32
+ `${bucket}/${key}`;
33
+
34
+ const s3: S3LikeClient = {
35
+ async send<TInput, TOutput>(
36
+ command: { input: TInput } & object
37
+ ): Promise<TOutput> {
38
+ const name = (command as { constructor: { name: string } }).constructor
39
+ .name;
40
+ const input = command.input as unknown as CommandInput;
41
+ const fullKey = compositeKey(input.Bucket, input.Key);
42
+ if (name === "GetObjectCommand") {
43
+ calls.get++;
44
+ const obj = store.get(fullKey);
45
+ if (!obj) {
46
+ const err = new Error("NoSuchKey") as Error & { name: string };
47
+ err.name = "NoSuchKey";
48
+ throw err;
49
+ }
50
+ return { Body: obj.body } as unknown as TOutput;
51
+ }
52
+ if (name === "PutObjectCommand") {
53
+ calls.put++;
54
+ const body =
55
+ typeof input.Body === "string"
56
+ ? Buffer.from(input.Body, "utf8")
57
+ : (input.Body as Buffer);
58
+ store.set(fullKey, {
59
+ body,
60
+ ...(input.ContentType && { contentType: input.ContentType }),
61
+ });
62
+ return {} as TOutput;
63
+ }
64
+ if (name === "DeleteObjectCommand") {
65
+ calls.delete++;
66
+ store.delete(fullKey);
67
+ return {} as TOutput;
68
+ }
69
+ throw new Error(`unknown command: ${name}`);
70
+ },
71
+ };
72
+
73
+ return { s3, store, calls };
74
+ }
75
+
76
+ const sampleSnapshot: ThreadSnapshot = {
77
+ v: 1,
78
+ messages: [JSON.stringify({ id: "m1", text: "hi" })],
79
+ state: null,
80
+ dedupIds: ["m1"],
81
+ };
82
+
83
+ describe("createS3ColdStore", () => {
84
+ let fake: ReturnType<typeof createFakeS3>;
85
+
86
+ beforeEach(() => {
87
+ fake = createFakeS3();
88
+ });
89
+
90
+ it("read returns null when no object exists for the thread", async () => {
91
+ const cold = createS3ColdStore({
92
+ s3: fake.s3,
93
+ bucket: "test-bucket",
94
+ });
95
+ expect(await cold.read("messages", "t-1")).toBeNull();
96
+ });
97
+
98
+ it("write then read round-trips a snapshot (gzip default)", async () => {
99
+ const cold = createS3ColdStore({
100
+ s3: fake.s3,
101
+ bucket: "test-bucket",
102
+ prefix: "prod/threads",
103
+ });
104
+ await cold.write("messages", "t-1", sampleSnapshot);
105
+ expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
106
+
107
+ // Verify the on-disk format is gzip + JSON.
108
+ const stored = fake.store.get("test-bucket/prod/threads/messages/t-1.json.gz");
109
+ expect(stored).toBeDefined();
110
+ if (!stored) throw new Error("expected stored object");
111
+ expect(stored.contentType).toBe("application/gzip");
112
+ const decoded = JSON.parse(gunzipSync(stored.body).toString("utf8"));
113
+ expect(decoded).toEqual(sampleSnapshot);
114
+ });
115
+
116
+ it("supports plain JSON when gzip is disabled", async () => {
117
+ const cold = createS3ColdStore({
118
+ s3: fake.s3,
119
+ bucket: "test-bucket",
120
+ gzip: false,
121
+ });
122
+ await cold.write("messages", "t-1", sampleSnapshot);
123
+
124
+ const stored = fake.store.get("test-bucket/threads/messages/t-1.json");
125
+ expect(stored).toBeDefined();
126
+ if (!stored) throw new Error("expected stored object");
127
+ expect(stored.contentType).toBe("application/json");
128
+ expect(JSON.parse(stored.body.toString("utf8"))).toEqual(sampleSnapshot);
129
+
130
+ expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
131
+ });
132
+
133
+ it("uses 'threads' as the default prefix", async () => {
134
+ const cold = createS3ColdStore({
135
+ s3: fake.s3,
136
+ bucket: "test-bucket",
137
+ });
138
+ await cold.write("messages", "abc", sampleSnapshot);
139
+ expect(fake.store.has("test-bucket/threads/messages/abc.json.gz")).toBe(true);
140
+ });
141
+
142
+ it("delete removes the underlying object", async () => {
143
+ const cold = createS3ColdStore({
144
+ s3: fake.s3,
145
+ bucket: "test-bucket",
146
+ });
147
+ await cold.write("messages", "t-1", sampleSnapshot);
148
+ await cold.delete("messages", "t-1");
149
+ expect(await cold.read("messages", "t-1")).toBeNull();
150
+ expect(fake.store.size).toBe(0);
151
+ });
152
+
153
+ it("read treats 404 errors as null (not throw)", async () => {
154
+ const cold = createS3ColdStore({
155
+ s3: {
156
+ async send() {
157
+ const err = {
158
+ name: "NotFound",
159
+ $metadata: { httpStatusCode: 404 },
160
+ };
161
+ throw err;
162
+ },
163
+ } as unknown as S3LikeClient,
164
+ bucket: "test-bucket",
165
+ });
166
+ expect(await cold.read("messages", "t-1")).toBeNull();
167
+ });
168
+
169
+ it("read rethrows non-404 errors", async () => {
170
+ const cold = createS3ColdStore({
171
+ s3: {
172
+ async send() {
173
+ throw new Error("AccessDenied");
174
+ },
175
+ } as unknown as S3LikeClient,
176
+ bucket: "test-bucket",
177
+ });
178
+ await expect(cold.read("messages", "t-1")).rejects.toThrow("AccessDenied");
179
+ });
180
+
181
+ it("falls back to gunzip when the cold object is gzip-encoded but read with gzip:true", async () => {
182
+ // Pre-seed the fake S3 with a gzip-encoded payload, then read.
183
+ const compressed = gzipSync(Buffer.from(JSON.stringify(sampleSnapshot)));
184
+ fake.store.set("test-bucket/threads/messages/t-1.json.gz", {
185
+ body: compressed,
186
+ });
187
+ const cold = createS3ColdStore({
188
+ s3: fake.s3,
189
+ bucket: "test-bucket",
190
+ });
191
+ expect(await cold.read("messages", "t-1")).toEqual(sampleSnapshot);
192
+ });
193
+ });