zeitlich 0.2.45 → 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 (89) hide show
  1. package/README.md +78 -10
  2. package/dist/{activities-CrN-ghLo.d.ts → activities-Bm4TLTid.d.ts} +22 -2
  3. package/dist/{activities-Coafq5zr.d.cts → activities-CyeiqK_f.d.cts} +22 -2
  4. package/dist/adapters/thread/anthropic/index.cjs +171 -65
  5. package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
  6. package/dist/adapters/thread/anthropic/index.d.cts +19 -4
  7. package/dist/adapters/thread/anthropic/index.d.ts +19 -4
  8. package/dist/adapters/thread/anthropic/index.js +171 -65
  9. package/dist/adapters/thread/anthropic/index.js.map +1 -1
  10. package/dist/adapters/thread/anthropic/workflow.cjs +3 -1
  11. package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
  12. package/dist/adapters/thread/anthropic/workflow.d.cts +4 -4
  13. package/dist/adapters/thread/anthropic/workflow.d.ts +4 -4
  14. package/dist/adapters/thread/anthropic/workflow.js +3 -1
  15. package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
  16. package/dist/adapters/thread/google-genai/index.cjs +171 -69
  17. package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
  18. package/dist/adapters/thread/google-genai/index.d.cts +5 -4
  19. package/dist/adapters/thread/google-genai/index.d.ts +5 -4
  20. package/dist/adapters/thread/google-genai/index.js +171 -69
  21. package/dist/adapters/thread/google-genai/index.js.map +1 -1
  22. package/dist/adapters/thread/google-genai/workflow.cjs +3 -1
  23. package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
  24. package/dist/adapters/thread/google-genai/workflow.d.cts +5 -4
  25. package/dist/adapters/thread/google-genai/workflow.d.ts +5 -4
  26. package/dist/adapters/thread/google-genai/workflow.js +3 -1
  27. package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
  28. package/dist/adapters/thread/langchain/index.cjs +170 -66
  29. package/dist/adapters/thread/langchain/index.cjs.map +1 -1
  30. package/dist/adapters/thread/langchain/index.d.cts +18 -4
  31. package/dist/adapters/thread/langchain/index.d.ts +18 -4
  32. package/dist/adapters/thread/langchain/index.js +170 -66
  33. package/dist/adapters/thread/langchain/index.js.map +1 -1
  34. package/dist/adapters/thread/langchain/workflow.cjs +3 -1
  35. package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
  36. package/dist/adapters/thread/langchain/workflow.d.cts +4 -4
  37. package/dist/adapters/thread/langchain/workflow.d.ts +4 -4
  38. package/dist/adapters/thread/langchain/workflow.js +3 -1
  39. package/dist/adapters/thread/langchain/workflow.js.map +1 -1
  40. package/dist/cold-store-BC5L5Z8A.d.cts +117 -0
  41. package/dist/cold-store-CFHwemBJ.d.ts +117 -0
  42. package/dist/index.cjs +226 -27
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +138 -8
  45. package/dist/index.d.ts +138 -8
  46. package/dist/index.js +220 -28
  47. package/dist/index.js.map +1 -1
  48. package/dist/{proxy-Bf7uI-Hw.d.cts → proxy-BxFyd6cg.d.cts} +1 -1
  49. package/dist/{proxy-COqA95FW.d.ts → proxy-Cskmj4Yx.d.ts} +1 -1
  50. package/dist/{thread-manager-BsLO3Fgc.d.cts → thread-manager-9tezUcLW.d.cts} +8 -2
  51. package/dist/{thread-manager-Bi1XlbpJ.d.ts → thread-manager-B-zy3xrs.d.ts} +8 -2
  52. package/dist/{thread-manager-wRVVBFgj.d.cts → thread-manager-D33SUmZa.d.cts} +8 -2
  53. package/dist/{thread-manager-BhkOyQ1I.d.ts → thread-manager-DduoSkvJ.d.ts} +8 -2
  54. package/dist/{types-CdALEF3z.d.cts → types-CnuN9T6t.d.cts} +22 -0
  55. package/dist/{types-ChAy_jSP.d.ts → types-CwN6_tAL.d.ts} +22 -0
  56. package/dist/{types-BkX4HLzi.d.ts → types-L5bvbF-n.d.ts} +17 -1
  57. package/dist/{types-C66-BVBr.d.cts → types-oxt8GN97.d.cts} +17 -1
  58. package/dist/{workflow-BwT5EybR.d.ts → workflow-B1TOcHbt.d.ts} +33 -2
  59. package/dist/{workflow-DMmiaw6w.d.cts → workflow-DIaIV7L2.d.cts} +33 -2
  60. package/dist/workflow.cjs +14 -1
  61. package/dist/workflow.cjs.map +1 -1
  62. package/dist/workflow.d.cts +2 -2
  63. package/dist/workflow.d.ts +2 -2
  64. package/dist/workflow.js +14 -1
  65. package/dist/workflow.js.map +1 -1
  66. package/package.json +6 -1
  67. package/src/adapters/thread/anthropic/activities.ts +72 -36
  68. package/src/adapters/thread/anthropic/thread-manager.ts +9 -1
  69. package/src/adapters/thread/google-genai/activities.ts +64 -40
  70. package/src/adapters/thread/google-genai/thread-manager.ts +9 -1
  71. package/src/adapters/thread/langchain/activities.ts +63 -36
  72. package/src/adapters/thread/langchain/thread-manager.ts +9 -1
  73. package/src/index.ts +20 -1
  74. package/src/lib/session/session-edge-cases.integration.test.ts +12 -0
  75. package/src/lib/session/session.integration.test.ts +138 -0
  76. package/src/lib/session/session.ts +29 -0
  77. package/src/lib/session/types.ts +22 -0
  78. package/src/lib/thread/cold-store.test.ts +193 -0
  79. package/src/lib/thread/cold-store.ts +250 -0
  80. package/src/lib/thread/index.ts +32 -0
  81. package/src/lib/thread/keys.ts +20 -0
  82. package/src/lib/thread/manager.ts +16 -27
  83. package/src/lib/thread/proxy.ts +2 -0
  84. package/src/lib/thread/snapshot.test.ts +443 -0
  85. package/src/lib/thread/snapshot.ts +163 -0
  86. package/src/lib/thread/test-utils.ts +228 -0
  87. package/src/lib/thread/tiered.test.ts +281 -0
  88. package/src/lib/thread/tiered.ts +135 -0
  89. package/src/lib/thread/types.ts +16 -0
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
  });
@@ -198,6 +198,8 @@ export async function createSession<
198
198
  forkThread,
199
199
  loadThreadState,
200
200
  saveThreadState,
201
+ hydrateThread,
202
+ flushThread,
201
203
  } = threadOps;
202
204
 
203
205
  const plugins: ToolMap[string][] = [];
@@ -391,10 +393,19 @@ export async function createSession<
391
393
  };
392
394
 
393
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);
394
401
  await forkThread(sourceThreadId, threadId, threadKey);
395
402
  const forkedSlice = await loadThreadState(threadId, threadKey);
396
403
  if (forkedSlice) rehydrateFromSlice(forkedSlice);
397
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);
398
409
  // "continue" — thread already exists, just append the new message
399
410
  const continuedSlice = await loadThreadState(threadId, threadKey);
400
411
  if (continuedSlice) rehydrateFromSlice(continuedSlice);
@@ -640,6 +651,24 @@ export async function createSession<
640
651
  });
641
652
  }
642
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
+
643
672
  await callSessionEnd(exitReason, stateManager.getTurns());
644
673
 
645
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
+ });