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
@@ -0,0 +1,228 @@
1
+ /**
2
+ * In-memory fakes used by the thread-storage unit tests.
3
+ *
4
+ * Lives outside `*.test.ts` so multiple test files can share the
5
+ * same fake without copy/pasting. Not exported from the package — kept
6
+ * inside `src/lib/thread` so type imports work and the test runner
7
+ * picks it up directly.
8
+ */
9
+
10
+ import type Redis from "ioredis";
11
+ import type { ColdThreadStore, ThreadSnapshot } from "./cold-store";
12
+
13
+ type Value = string | string[];
14
+
15
+ /**
16
+ * Minimal in-memory Redis stub covering the commands the thread
17
+ * 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.
21
+ */
22
+ export function createFakeRedis(): Redis & {
23
+ _store: Map<string, Value>;
24
+ _ttls: Map<string, number>;
25
+ } {
26
+ const store = new Map<string, Value>();
27
+ const ttls = new Map<string, number>();
28
+
29
+ const isList = (k: string): boolean => Array.isArray(store.get(k));
30
+ const ensureList = (k: string): string[] => {
31
+ const v = store.get(k);
32
+ if (v === undefined) {
33
+ const fresh: string[] = [];
34
+ store.set(k, fresh);
35
+ return fresh;
36
+ }
37
+ if (!Array.isArray(v)) throw new Error(`WRONGTYPE: ${k} is not a list`);
38
+ return v;
39
+ };
40
+
41
+ const fake = {
42
+ async get(key: string): Promise<string | null> {
43
+ const v = store.get(key);
44
+ if (v === undefined) return null;
45
+ if (Array.isArray(v)) throw new Error(`WRONGTYPE: ${key} is a list`);
46
+ return v;
47
+ },
48
+ async set(
49
+ key: string,
50
+ value: string,
51
+ ..._rest: (string | number)[]
52
+ ): Promise<"OK"> {
53
+ // NX guard: when the args contain "NX" and the key already exists,
54
+ // 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";
59
+ }
60
+ 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);
64
+ }
65
+ return "OK";
66
+ },
67
+ async del(...keys: string[]): Promise<number> {
68
+ let removed = 0;
69
+ for (const k of keys) {
70
+ if (store.delete(k)) removed++;
71
+ ttls.delete(k);
72
+ }
73
+ return removed;
74
+ },
75
+ async exists(...keys: string[]): Promise<number> {
76
+ return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
77
+ },
78
+ async expire(key: string, ttl: number): Promise<number> {
79
+ if (!store.has(key)) return 0;
80
+ ttls.set(key, ttl);
81
+ return 1;
82
+ },
83
+ async lrange(key: string, start: number, end: number): Promise<string[]> {
84
+ if (!store.has(key)) return [];
85
+ if (!isList(key)) return [];
86
+ const list = store.get(key) as string[];
87
+ const last = end === -1 ? list.length - 1 : end;
88
+ return list.slice(start, last + 1);
89
+ },
90
+ async rpush(key: string, ...values: string[]): Promise<number> {
91
+ const list = ensureList(key);
92
+ list.push(...values);
93
+ return list.length;
94
+ },
95
+ async llen(key: string): Promise<number> {
96
+ if (!store.has(key)) return 0;
97
+ const list = store.get(key) as string[];
98
+ return list.length;
99
+ },
100
+ async ltrim(key: string, start: number, end: number): Promise<"OK"> {
101
+ if (!store.has(key)) return "OK";
102
+ const list = store.get(key) as string[];
103
+ const last = end === -1 ? list.length - 1 : end;
104
+ store.set(key, list.slice(start, last + 1));
105
+ return "OK";
106
+ },
107
+ async eval(
108
+ _script: string,
109
+ numKeys: number,
110
+ ...args: (string | number)[]
111
+ ): Promise<number> {
112
+ // 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[];
115
+ const dedupKey = keys[0];
116
+ const listKey = keys[1];
117
+ const ttl = Number(argv[0]);
118
+ const messages = argv.slice(1);
119
+ if (dedupKey === undefined || listKey === undefined) {
120
+ throw new Error("eval stub: missing keys");
121
+ }
122
+ if (store.has(dedupKey)) return 0;
123
+ const list = ensureList(listKey);
124
+ list.push(...messages);
125
+ ttls.set(listKey, ttl);
126
+ store.set(dedupKey, "1");
127
+ ttls.set(dedupKey, ttl);
128
+ return 1;
129
+ },
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 {
136
+ 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>;
140
+ expire: (key: string, ttl: number) => Promise<number>;
141
+ };
142
+ 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));
147
+ return chain;
148
+ },
149
+ del: (...keys) => {
150
+ ops.push(() => impl.del(...keys));
151
+ return chain;
152
+ },
153
+ rpush: (key, ...values) => {
154
+ ops.push(() => impl.rpush(key, ...values));
155
+ return chain;
156
+ },
157
+ expire: (key, ttl) => {
158
+ ops.push(() => impl.expire(key, ttl));
159
+ return chain;
160
+ },
161
+ exec: async () => {
162
+ const results: Array<[Error | null, unknown]> = [];
163
+ for (const op of ops) {
164
+ try {
165
+ results.push([null, await op()]);
166
+ } catch (e) {
167
+ results.push([e as Error, null]);
168
+ }
169
+ }
170
+ return results;
171
+ },
172
+ };
173
+ return chain;
174
+ },
175
+ _store: store,
176
+ _ttls: ttls,
177
+ } as unknown as Redis & {
178
+ _store: Map<string, Value>;
179
+ _ttls: Map<string, number>;
180
+ };
181
+
182
+ return fake;
183
+ }
184
+
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]>>;
192
+ }
193
+
194
+ /**
195
+ * In-memory `ColdThreadStore` used by the tiered manager tests. Spies
196
+ * on read/write/delete call counts so tests can assert idempotency
197
+ * and call sequencing.
198
+ */
199
+ export function createMemoryColdStore(): ColdThreadStore & {
200
+ _snapshots: Map<string, ThreadSnapshot>;
201
+ _calls: { read: number; write: number; delete: number };
202
+ } {
203
+ const snapshots = new Map<string, ThreadSnapshot>();
204
+ const calls = { read: 0, write: 0, delete: 0 };
205
+ const compositeKey = (threadKey: string, threadId: string): string =>
206
+ `${threadKey}::${threadId}`;
207
+ return {
208
+ async read(threadKey: string, threadId: string) {
209
+ calls.read++;
210
+ return snapshots.get(compositeKey(threadKey, threadId)) ?? null;
211
+ },
212
+ async write(threadKey: string, threadId: string, snapshot: ThreadSnapshot) {
213
+ calls.write++;
214
+ // Clone to mirror real-world serialization (S3 round-trips
215
+ // through JSON).
216
+ snapshots.set(
217
+ compositeKey(threadKey, threadId),
218
+ JSON.parse(JSON.stringify(snapshot)) as ThreadSnapshot
219
+ );
220
+ },
221
+ async delete(threadKey: string, threadId: string) {
222
+ calls.delete++;
223
+ snapshots.delete(compositeKey(threadKey, threadId));
224
+ },
225
+ _snapshots: snapshots,
226
+ _calls: calls,
227
+ };
228
+ }
@@ -0,0 +1,281 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import type Redis from "ioredis";
3
+ import { createTieredThreadManager } from "./tiered";
4
+ import { createThreadManager } from "./manager";
5
+ import {
6
+ getThreadDedupKey,
7
+ getThreadListKey,
8
+ getThreadMetaKey,
9
+ getThreadStateKey,
10
+ } from "./keys";
11
+ import type { PersistedThreadState } from "../state/types";
12
+ import { createFakeRedis, createMemoryColdStore } from "./test-utils";
13
+
14
+ interface TestMsg {
15
+ id: string;
16
+ text: string;
17
+ }
18
+
19
+ const sampleState: PersistedThreadState = {
20
+ tasks: [
21
+ [
22
+ "t1",
23
+ {
24
+ id: "t1",
25
+ subject: "do thing",
26
+ description: "do it",
27
+ activeForm: "doing",
28
+ status: "pending",
29
+ metadata: {},
30
+ blockedBy: [],
31
+ blocks: [],
32
+ },
33
+ ],
34
+ ],
35
+ custom: { counter: 7 },
36
+ };
37
+
38
+ function makeTiered(
39
+ redis: Redis,
40
+ threadId: string,
41
+ coldStore?: ReturnType<typeof createMemoryColdStore>,
42
+ ttlSeconds?: number
43
+ ) {
44
+ return createTieredThreadManager<TestMsg>({
45
+ redis,
46
+ threadId,
47
+ idOf: (m) => m.id,
48
+ ...(coldStore && { coldStore }),
49
+ ...(ttlSeconds !== undefined && { ttlSeconds }),
50
+ });
51
+ }
52
+
53
+ describe("createTieredThreadManager — no cold store", () => {
54
+ it("hydrate is a no-op when no cold store is wired", async () => {
55
+ const redis = createFakeRedis();
56
+ const tm = makeTiered(redis, "t-1");
57
+ await tm.hydrate();
58
+ expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
59
+ });
60
+
61
+ it("flush is a no-op when no cold store is wired", async () => {
62
+ const redis = createFakeRedis();
63
+ const tm = makeTiered(redis, "t-1");
64
+ await tm.initialize();
65
+ await tm.append([{ id: "m1", text: "hello" }]);
66
+ await tm.flush();
67
+ expect(await tm.load()).toEqual([{ id: "m1", text: "hello" }]);
68
+ });
69
+
70
+ it("delegates all BaseThreadManager ops to the underlying Redis manager", async () => {
71
+ const redis = createFakeRedis();
72
+ const tm = makeTiered(redis, "t-1");
73
+ await tm.initialize();
74
+ await tm.append([
75
+ { id: "m1", text: "a" },
76
+ { id: "m2", text: "b" },
77
+ ]);
78
+ await tm.saveState(sampleState);
79
+
80
+ expect(await tm.length()).toBe(2);
81
+ expect(await tm.loadState()).toEqual(sampleState);
82
+ expect(await tm.load()).toEqual([
83
+ { id: "m1", text: "a" },
84
+ { id: "m2", text: "b" },
85
+ ]);
86
+
87
+ await tm.truncateFromId("m2");
88
+ expect(await tm.load()).toEqual([{ id: "m1", text: "a" }]);
89
+ });
90
+ });
91
+
92
+ describe("createTieredThreadManager — with cold store", () => {
93
+ let redis: Redis;
94
+ let cold: ReturnType<typeof createMemoryColdStore>;
95
+
96
+ beforeEach(() => {
97
+ redis = createFakeRedis();
98
+ cold = createMemoryColdStore();
99
+ });
100
+
101
+ it("flush writes messages + state + dedupIds to the cold store", async () => {
102
+ const tm = makeTiered(redis, "t-1", cold);
103
+ await tm.initialize();
104
+ await tm.append([{ id: "m1", text: "hello" }]);
105
+ await tm.append([{ id: "m2", text: "world" }]);
106
+ await tm.saveState(sampleState);
107
+
108
+ await tm.flush({ deleteHot: false });
109
+
110
+ const snap = cold._snapshots.get("messages::t-1");
111
+ expect(snap).toBeDefined();
112
+ if (!snap) throw new Error("expected snapshot");
113
+ expect(snap.v).toBe(1);
114
+ expect(snap.messages.map((raw) => JSON.parse(raw) as TestMsg)).toEqual([
115
+ { id: "m1", text: "hello" },
116
+ { id: "m2", text: "world" },
117
+ ]);
118
+ expect(snap.state).toEqual(sampleState);
119
+ expect(snap.dedupIds).toEqual(["m1", "m2"]);
120
+ });
121
+
122
+ it("flush is a no-op when the thread is cold (no hot tier present)", async () => {
123
+ const tm = makeTiered(redis, "t-1", cold);
124
+ await tm.flush();
125
+ expect(cold._calls.write).toBe(0);
126
+ expect(cold._snapshots.size).toBe(0);
127
+ });
128
+
129
+ it("defaults to deleting the hot tier after a successful flush", async () => {
130
+ const tm = makeTiered(redis, "t-1", cold);
131
+ await tm.initialize();
132
+ await tm.append([{ id: "m1", text: "hello" }]);
133
+ await tm.saveState(sampleState);
134
+
135
+ await tm.flush();
136
+
137
+ expect(await redis.exists(getThreadListKey("messages", "t-1"))).toBe(0);
138
+ expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
139
+ expect(await redis.exists(getThreadStateKey("messages", "t-1"))).toBe(0);
140
+ expect(await redis.exists(getThreadDedupKey("t-1", "m1"))).toBe(0);
141
+ });
142
+
143
+ it("flush({ deleteHot: false }) leaves the hot tier intact", async () => {
144
+ const tm = makeTiered(redis, "t-1", cold);
145
+ await tm.initialize();
146
+ await tm.append([{ id: "m1", text: "hello" }]);
147
+
148
+ await tm.flush({ deleteHot: false });
149
+
150
+ expect(await tm.load()).toEqual([{ id: "m1", text: "hello" }]);
151
+ });
152
+
153
+ it("hydrate restores messages + state + dedup markers from the cold store", async () => {
154
+ // Seed the cold store via a separate manager instance.
155
+ const seed = makeTiered(redis, "t-1", cold);
156
+ await seed.initialize();
157
+ await seed.append([
158
+ { id: "m1", text: "a" },
159
+ { id: "m2", text: "b" },
160
+ ]);
161
+ await seed.saveState(sampleState);
162
+ await seed.flush(); // archives + drops hot tier
163
+
164
+ expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
165
+
166
+ // Restore into a fresh manager on the same Redis.
167
+ const restored = makeTiered(redis, "t-1", cold);
168
+ await restored.hydrate();
169
+
170
+ expect(await restored.load()).toEqual([
171
+ { id: "m1", text: "a" },
172
+ { id: "m2", text: "b" },
173
+ ]);
174
+ expect(await restored.loadState()).toEqual(sampleState);
175
+
176
+ // Re-append m1 — should be skipped because dedup keys were re-primed.
177
+ await restored.append([{ id: "m1", text: "a" }]);
178
+ expect(await restored.length()).toBe(2);
179
+ });
180
+
181
+ it("hydrate is idempotent — no-op when the thread is already hot", async () => {
182
+ const tm = makeTiered(redis, "t-1", cold);
183
+ await tm.initialize();
184
+ await tm.append([{ id: "existing", text: "in redis" }]);
185
+
186
+ // Pre-seed the cold store with different content.
187
+ await cold.write("messages", "t-1", {
188
+ v: 1,
189
+ messages: [JSON.stringify({ id: "from-cold", text: "different" })],
190
+ state: sampleState,
191
+ dedupIds: ["from-cold"],
192
+ });
193
+
194
+ await tm.hydrate();
195
+
196
+ expect(await tm.load()).toEqual([{ id: "existing", text: "in redis" }]);
197
+ });
198
+
199
+ it("hydrate is a no-op when nothing is archived in the cold store", async () => {
200
+ const tm = makeTiered(redis, "t-1", cold);
201
+ await tm.hydrate();
202
+ expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
203
+ expect(cold._calls.read).toBe(1);
204
+ });
205
+
206
+ it("flush → hydrate is a full round-trip for an empty thread", async () => {
207
+ const tm = makeTiered(redis, "t-1", cold);
208
+ await tm.initialize();
209
+ await tm.flush();
210
+
211
+ const restored = makeTiered(redis, "t-1", cold);
212
+ await restored.hydrate();
213
+ expect(await restored.load()).toEqual([]);
214
+ expect(await restored.length()).toBe(0);
215
+ });
216
+
217
+ it("repeated flushes converge (idempotent cold writes)", async () => {
218
+ const tm = makeTiered(redis, "t-1", cold);
219
+ await tm.initialize();
220
+ await tm.append([{ id: "m1", text: "hello" }]);
221
+ await tm.flush({ deleteHot: false });
222
+ await tm.flush({ deleteHot: false });
223
+ expect(cold._calls.write).toBe(2);
224
+ const snap = cold._snapshots.get("messages::t-1");
225
+ if (!snap) throw new Error("expected snapshot");
226
+ expect(snap.messages).toHaveLength(1);
227
+ });
228
+
229
+ it("repeated hydrates converge (only the first one writes Redis)", async () => {
230
+ await cold.write("messages", "t-1", {
231
+ v: 1,
232
+ messages: [JSON.stringify({ id: "m1", text: "hello" })],
233
+ state: null,
234
+ dedupIds: ["m1"],
235
+ });
236
+ const tm = makeTiered(redis, "t-1", cold);
237
+ await tm.hydrate();
238
+ await tm.hydrate();
239
+ expect(await tm.load()).toEqual([{ id: "m1", text: "hello" }]);
240
+ });
241
+
242
+ it("fork from a hydrated source preserves messages in the new thread", async () => {
243
+ // Archive a source thread into cold storage and drop its hot tier.
244
+ const seed = makeTiered(redis, "src", cold);
245
+ await seed.initialize();
246
+ await seed.append([{ id: "m1", text: "a" }]);
247
+ await seed.append([{ id: "m2", text: "b" }]);
248
+ await seed.flush();
249
+
250
+ // Hydrate source (mirrors what session.ts does for mode:"fork")
251
+ // then fork into a new thread on the same Redis.
252
+ const src = makeTiered(redis, "src", cold);
253
+ await src.hydrate();
254
+ await src.fork("forked");
255
+
256
+ const target = createThreadManager<TestMsg>({
257
+ redis,
258
+ threadId: "forked",
259
+ idOf: (m) => m.id,
260
+ });
261
+ expect(await target.load()).toEqual([
262
+ { id: "m1", text: "a" },
263
+ { id: "m2", text: "b" },
264
+ ]);
265
+ });
266
+
267
+ it("honors a custom ttlSeconds when restoring", async () => {
268
+ await cold.write("messages", "t-1", {
269
+ v: 1,
270
+ messages: [JSON.stringify({ id: "m1", text: "hi" })],
271
+ state: null,
272
+ dedupIds: ["m1"],
273
+ });
274
+ const tm = makeTiered(redis, "t-1", cold, 60);
275
+ await tm.hydrate();
276
+ const ttls = (redis as unknown as { _ttls: Map<string, number> })._ttls;
277
+ expect(ttls.get(getThreadMetaKey("messages", "t-1"))).toBe(60);
278
+ expect(ttls.get(getThreadListKey("messages", "t-1"))).toBe(60);
279
+ expect(ttls.get(getThreadDedupKey("t-1", "m1"))).toBe(60);
280
+ });
281
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tiered thread manager: Redis hot tier + pluggable cold tier.
3
+ *
4
+ * Wraps {@link createThreadManager} (Redis-only) and adds two
5
+ * session-boundary operations:
6
+ *
7
+ * - `hydrate()` — when the thread is cold (no meta key in Redis),
8
+ * restore the latest {@link ThreadSnapshot} from the cold store.
9
+ * Idempotent; no-op when the thread is already hot or when no
10
+ * snapshot exists.
11
+ * - `flush({ deleteHot })` — write the current Redis state out to the
12
+ * cold store as one snapshot, then (by default) `DEL` the hot-tier
13
+ * keys so idle threads don't sit in Redis memory.
14
+ *
15
+ * All other operations (`append`, `load`, `fork`, `replaceAll`,
16
+ * `truncateFromId`, state I/O) delegate unchanged to the underlying
17
+ * Redis manager, so adapters and tests that use the
18
+ * `BaseThreadManager<T>` interface keep working with zero changes.
19
+ */
20
+
21
+ import { createThreadManager } from "./manager";
22
+ import { THREAD_TTL_SECONDS } from "./keys";
23
+ import type { BaseThreadManager, ThreadManagerConfig } from "./types";
24
+ import type { ColdThreadStore } from "./cold-store";
25
+ import {
26
+ applySnapshot,
27
+ clearHotTier,
28
+ encodeSnapshot,
29
+ } from "./snapshot";
30
+
31
+ /** Configuration for {@link createTieredThreadManager}. */
32
+ export interface TieredThreadManagerConfig<T> extends ThreadManagerConfig<T> {
33
+ /**
34
+ * Cold-tier archive. When omitted, `hydrate()` and `flush()` are
35
+ * no-ops and the manager behaves identically to
36
+ * {@link createThreadManager}.
37
+ */
38
+ coldStore?: ColdThreadStore;
39
+ }
40
+
41
+ /** Options for {@link TieredThreadManager.flush}. */
42
+ export interface FlushOptions {
43
+ /**
44
+ * Delete the hot-tier Redis keys after a successful cold-tier
45
+ * write. Defaults to `true` when a cold store is configured —
46
+ * Redis is just a cache and a future continue/fork will
47
+ * re-hydrate in a single round-trip.
48
+ *
49
+ * Set to `false` to keep the hot tier warm (useful for tests or
50
+ * for "hot-after-flush" use cases where another session is expected
51
+ * to pick the thread up immediately).
52
+ */
53
+ deleteHot?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Extension of {@link BaseThreadManager} with the two cold-tier
58
+ * lifecycle methods.
59
+ */
60
+ export interface TieredThreadManager<T> extends BaseThreadManager<T> {
61
+ /**
62
+ * Restore the latest cold-tier snapshot into Redis when the thread
63
+ * is cold. Idempotent — safe to call from a retried activity.
64
+ */
65
+ hydrate(): Promise<void>;
66
+ /**
67
+ * Write the current Redis state to the cold tier and (optionally)
68
+ * drop the hot-tier keys. Idempotent — last-writer-wins on the
69
+ * cold side.
70
+ */
71
+ flush(opts?: FlushOptions): Promise<void>;
72
+ }
73
+
74
+ /**
75
+ * Build a thread manager backed by Redis (hot) and an optional
76
+ * pluggable cold store. See module docstring for the lifecycle
77
+ * semantics.
78
+ */
79
+ export function createTieredThreadManager<T>(
80
+ config: TieredThreadManagerConfig<T>
81
+ ): TieredThreadManager<T> {
82
+ const {
83
+ redis,
84
+ threadId,
85
+ key = "messages",
86
+ coldStore,
87
+ idOf,
88
+ deserialize = (raw: string): T => JSON.parse(raw) as T,
89
+ ttlSeconds = THREAD_TTL_SECONDS,
90
+ } = config;
91
+
92
+ const base = createThreadManager<T>(config);
93
+
94
+ // Snapshot-time `idOf` operates on raw Redis strings — we deserialize
95
+ // here and forward to the configured (deserialized) `idOf`.
96
+ const rawIdOf = idOf
97
+ ? (raw: string): string => idOf(deserialize(raw))
98
+ : undefined;
99
+
100
+ return Object.assign(base, {
101
+ async hydrate(): Promise<void> {
102
+ if (!coldStore) return;
103
+ const snapshot = await coldStore.read(key, threadId);
104
+ if (!snapshot) return;
105
+ await applySnapshot({
106
+ redis,
107
+ threadKey: key,
108
+ threadId,
109
+ snapshot,
110
+ ttlSeconds,
111
+ });
112
+ },
113
+
114
+ async flush(opts?: FlushOptions): Promise<void> {
115
+ if (!coldStore) return;
116
+ const snapshot = await encodeSnapshot({
117
+ redis,
118
+ threadKey: key,
119
+ threadId,
120
+ ...(rawIdOf ? { idOf: rawIdOf } : {}),
121
+ });
122
+ if (!snapshot) return;
123
+ await coldStore.write(key, threadId, snapshot);
124
+ const deleteHot = opts?.deleteHot ?? true;
125
+ if (deleteHot) {
126
+ await clearHotTier({
127
+ redis,
128
+ threadKey: key,
129
+ threadId,
130
+ dedupIds: snapshot.dedupIds,
131
+ });
132
+ }
133
+ },
134
+ });
135
+ }
@@ -14,6 +14,17 @@ export interface ThreadManagerConfig<T> {
14
14
  * When provided, `append` uses an atomic Lua script to skip duplicate writes.
15
15
  */
16
16
  idOf?: (message: T) => string;
17
+ /**
18
+ * TTL (in seconds) applied to every Redis key the manager writes
19
+ * (the list, the meta marker, the state slice, and dedup markers).
20
+ *
21
+ * Defaults to {@link THREAD_TTL_SECONDS} (90 days) for back-compat.
22
+ * When the consumer pairs the thread manager with a durable cold
23
+ * tier (see `createTieredThreadManager`), a much shorter TTL — e.g.
24
+ * a few hours — is usually more appropriate since the cold tier is
25
+ * the source of truth and Redis is just a hot cache.
26
+ */
27
+ ttlSeconds?: number;
17
28
  }
18
29
 
19
30
  /** Generic thread manager for any message type */
@@ -26,6 +37,11 @@ export interface BaseThreadManager<T> {
26
37
  * Append messages to the thread.
27
38
  * When `idOf` is configured, appends are idempotent — retries with the
28
39
  * same message ids are atomically skipped via a Redis Lua script.
40
+ *
41
+ * Caveat with tiered storage: multi-message batches write one composite
42
+ * dedup key (`"m1:m2"`); snapshots only persist per-message keys, so a
43
+ * batch retried after `flush` → `hydrate` will not be deduped. Adapter
44
+ * helpers all single-append and are unaffected.
29
45
  */
30
46
  append(messages: T[]): Promise<void>;
31
47
  /**