@axiom-lattice/pg-stores 3.3.13 → 3.4.0

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 (41) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +170 -0
  3. package/dist/index.d.mts +353 -3
  4. package/dist/index.d.ts +353 -3
  5. package/dist/index.js +2288 -83
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2294 -82
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/AgentRunRetention.migrations.test.ts +41 -0
  11. package/src/__tests__/PostgreSQLAgentRunLedger.test.ts +305 -0
  12. package/src/__tests__/PostgreSQLAgentRunRetention.test.ts +126 -0
  13. package/src/__tests__/PostgreSQLAgentRunTaskQueue.test.ts +1375 -0
  14. package/src/__tests__/PostgreSQLChannelReplyJobStore.test.ts +333 -0
  15. package/src/__tests__/PostgreSQLCheckpointStorage.deleteAfter.test.ts +473 -0
  16. package/src/__tests__/PostgreSQLCheckpointStorage.test.ts +538 -0
  17. package/src/__tests__/PostgreSQLStore.test.ts +173 -0
  18. package/src/__tests__/PostgreSQLThreadStore.test.ts +80 -0
  19. package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +21 -0
  20. package/src/__tests__/ThreadMessageQueueStore.test.ts +8 -3
  21. package/src/__tests__/createPgStoreConfig.test.ts +38 -0
  22. package/src/createPgStoreConfig.ts +35 -2
  23. package/src/index.ts +7 -0
  24. package/src/migrations/agent_run_base_checkpoint_captured_migration.ts +34 -0
  25. package/src/migrations/agent_run_capability_revision_migration.ts +24 -0
  26. package/src/migrations/agent_run_ledger_migration.ts +51 -0
  27. package/src/migrations/agent_run_project_task_migration.ts +34 -0
  28. package/src/migrations/agent_run_project_task_running_index_migration.ts +33 -0
  29. package/src/migrations/agent_run_retention_migration.ts +25 -0
  30. package/src/migrations/agent_run_rollback_failure_migration.ts +28 -0
  31. package/src/migrations/agent_run_running_index_source_migration.ts +31 -0
  32. package/src/migrations/agent_run_task_queue_migration.ts +65 -0
  33. package/src/migrations/channel_reply_job_migration.ts +55 -0
  34. package/src/migrations/store_migrations.ts +50 -0
  35. package/src/migrations/thread_migrations.ts +25 -0
  36. package/src/stores/PostgreSQLAgentRunLedger.ts +1210 -0
  37. package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
  38. package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
  39. package/src/stores/PostgreSQLStore.ts +167 -0
  40. package/src/stores/PostgreSQLThreadStore.ts +49 -10
  41. package/src/stores/ThreadMessageQueueStore.ts +10 -5
@@ -0,0 +1,538 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Pool } from "pg";
4
+
5
+ import {
6
+ CheckpointTargetNotEmptyError,
7
+ PostgreSQLCheckpointStorage,
8
+ } from "../stores/PostgreSQLCheckpointStorage";
9
+
10
+ const query = jest.fn();
11
+ const mockPool = { query } as unknown as Pool;
12
+
13
+ describe("PostgreSQLCheckpointStorage SQL contract", () => {
14
+ beforeEach(() => query.mockReset());
15
+
16
+ it("maps the aggregate row into storage categories", async () => {
17
+ query.mockResolvedValueOnce({
18
+ rows: [{
19
+ checkpoint_count: "2",
20
+ checkpoint_bytes: "100",
21
+ blob_count: "3",
22
+ blob_bytes: "60",
23
+ write_count: "4",
24
+ write_bytes: "40",
25
+ }],
26
+ });
27
+ const store = new PostgreSQLCheckpointStorage(mockPool);
28
+
29
+ await expect(store.get("thread-1")).resolves.toEqual({
30
+ threadId: "thread-1",
31
+ totalBytes: 200,
32
+ checkpoints: { count: 2, bytes: 100 },
33
+ channelValues: { count: 3, bytes: 60 },
34
+ pendingWrites: { count: 4, bytes: 40 },
35
+ });
36
+
37
+ const [sql, params] = query.mock.calls[0] as [string, unknown[]];
38
+ expect(sql).toContain("FROM checkpoints");
39
+ expect(sql).toContain("FROM checkpoint_blobs");
40
+ expect(sql).toContain("FROM checkpoint_writes");
41
+ expect(sql).toContain("count(*)::bigint");
42
+ expect(sql).toContain("thread_id=$1");
43
+ expect(params).toEqual(["thread-1"]);
44
+ expect(sql).not.toMatch(/\b(INSERT|UPDATE|DELETE|TRUNCATE)\b/i);
45
+ });
46
+
47
+ it("reports 409 conflicts with a typed error code", () => {
48
+ const error = new CheckpointTargetNotEmptyError("thread-2");
49
+ expect(error.code).toBe("CHECKPOINT_TARGET_NOT_EMPTY");
50
+ expect(error.name).toBe("CheckpointTargetNotEmptyError");
51
+ });
52
+
53
+ it("maps a unique violation during copy to CHECKPOINT_TARGET_NOT_EMPTY", async () => {
54
+ const client = {
55
+ query: jest.fn()
56
+ .mockResolvedValueOnce({ rows: [] })
57
+ .mockResolvedValueOnce({ rows: [{ not_empty: false }] })
58
+ .mockRejectedValueOnce(Object.assign(new Error("duplicate key"), { code: "23505" }))
59
+ .mockResolvedValueOnce({ rows: [] }),
60
+ release: jest.fn(),
61
+ };
62
+ const pool = {
63
+ query: jest.fn(),
64
+ connect: jest.fn().mockResolvedValue(client),
65
+ } as unknown as Pool;
66
+ const store = new PostgreSQLCheckpointStorage(pool);
67
+
68
+ await expect(store.copyThread("source", "target")).rejects.toMatchObject({
69
+ name: "CheckpointTargetNotEmptyError",
70
+ code: "CHECKPOINT_TARGET_NOT_EMPTY",
71
+ });
72
+ expect(client.release).toHaveBeenCalled();
73
+ expect(client.query).toHaveBeenLastCalledWith("ROLLBACK");
74
+ });
75
+
76
+ it("rolls back when a prune delete fails", async () => {
77
+ const client = {
78
+ query: jest.fn()
79
+ .mockResolvedValueOnce({ rows: [] })
80
+ .mockResolvedValueOnce({
81
+ rows: [{ checkpoint_ns: "", checkpoint_id: "cp-2", checkpoint: { channel_versions: {} } }],
82
+ })
83
+ .mockResolvedValueOnce({ rows: [] })
84
+ .mockResolvedValueOnce({
85
+ rows: [
86
+ { checkpoint_ns: "", checkpoint_id: "cp-1" },
87
+ { checkpoint_ns: "", checkpoint_id: "cp-2" },
88
+ ],
89
+ })
90
+ .mockRejectedValueOnce(new Error("delete failed"))
91
+ .mockResolvedValueOnce({ rows: [] }),
92
+ release: jest.fn(),
93
+ };
94
+ const pool = {
95
+ query: jest.fn(),
96
+ connect: jest.fn().mockResolvedValue(client),
97
+ } as unknown as Pool;
98
+ const store = new PostgreSQLCheckpointStorage(pool);
99
+
100
+ await expect(store.pruneThread("thread-1", { keepLatest: 1 }))
101
+ .rejects.toThrow("delete failed");
102
+ expect(client.release).toHaveBeenCalled();
103
+ expect(client.query).toHaveBeenLastCalledWith("ROLLBACK");
104
+ });
105
+
106
+ it("rejects an invalid keepLatest before opening a transaction", async () => {
107
+ const connect = jest.fn();
108
+ const pool = { query: jest.fn(), connect } as unknown as Pool;
109
+ const store = new PostgreSQLCheckpointStorage(pool);
110
+
111
+ for (const keepLatest of [0, -1, 1.5, Number.NaN]) {
112
+ await expect(store.pruneThread("thread-1", { keepLatest }))
113
+ .rejects.toThrow("keepLatest must be an integer >= 1");
114
+ }
115
+ expect(connect).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it("coalesces missing aggregate values to zero", async () => {
119
+ query.mockResolvedValueOnce({
120
+ rows: [{
121
+ checkpoint_count: 0,
122
+ checkpoint_bytes: null,
123
+ blob_count: 0,
124
+ blob_bytes: null,
125
+ write_count: 0,
126
+ write_bytes: null,
127
+ }],
128
+ });
129
+ const store = new PostgreSQLCheckpointStorage(mockPool);
130
+
131
+ await expect(store.get("thread-1")).resolves.toEqual({
132
+ threadId: "thread-1",
133
+ totalBytes: 0,
134
+ checkpoints: { count: 0, bytes: 0 },
135
+ channelValues: { count: 0, bytes: 0 },
136
+ pendingWrites: { count: 0, bytes: 0 },
137
+ });
138
+ });
139
+ });
140
+
141
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
142
+ const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
143
+
144
+ maybeDescribe("PostgreSQLCheckpointStorage (PostgreSQL)", () => {
145
+ const threadId = randomUUID();
146
+ let pool: Pool;
147
+ let store: PostgreSQLCheckpointStorage;
148
+
149
+ beforeAll(async () => {
150
+ pool = new Pool({ connectionString: TEST_DATABASE_URL! });
151
+ // Both checkpoint suites create the same schema and Jest runs test files
152
+ // in parallel. Serialize the DDL so CREATE TABLE IF NOT EXISTS cannot race
153
+ // on pg_type.
154
+ const client = await pool.connect();
155
+ try {
156
+ await client.query("SELECT pg_advisory_lock(94711231)");
157
+ await client.query(`
158
+ CREATE TABLE IF NOT EXISTS checkpoints (
159
+ thread_id TEXT NOT NULL,
160
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
161
+ checkpoint_id TEXT NOT NULL,
162
+ parent_checkpoint_id TEXT,
163
+ type TEXT,
164
+ checkpoint JSONB NOT NULL,
165
+ metadata JSONB NOT NULL DEFAULT '{}',
166
+ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
167
+ );
168
+ CREATE TABLE IF NOT EXISTS checkpoint_blobs (
169
+ thread_id TEXT NOT NULL,
170
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
171
+ channel TEXT NOT NULL,
172
+ version TEXT NOT NULL,
173
+ type TEXT NOT NULL,
174
+ blob BYTEA,
175
+ PRIMARY KEY (thread_id, checkpoint_ns, channel, version)
176
+ );
177
+ CREATE TABLE IF NOT EXISTS checkpoint_writes (
178
+ thread_id TEXT NOT NULL,
179
+ checkpoint_ns TEXT NOT NULL DEFAULT '',
180
+ checkpoint_id TEXT NOT NULL,
181
+ task_id TEXT NOT NULL,
182
+ idx INTEGER NOT NULL,
183
+ channel TEXT NOT NULL,
184
+ type TEXT,
185
+ blob BYTEA NOT NULL,
186
+ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
187
+ );
188
+ `);
189
+ } finally {
190
+ await client.query("SELECT pg_advisory_unlock(94711231)").catch(() => undefined);
191
+ client.release();
192
+ }
193
+ store = new PostgreSQLCheckpointStorage(pool);
194
+ });
195
+
196
+ beforeEach(async () => {
197
+ await pool.query("DELETE FROM checkpoints WHERE thread_id = $1", [threadId]);
198
+ await pool.query("DELETE FROM checkpoint_blobs WHERE thread_id = $1", [threadId]);
199
+ await pool.query("DELETE FROM checkpoint_writes WHERE thread_id = $1", [threadId]);
200
+ });
201
+
202
+ afterAll(async () => {
203
+ await pool.query("DELETE FROM checkpoints WHERE thread_id = $1", [threadId]);
204
+ await pool.query("DELETE FROM checkpoint_blobs WHERE thread_id = $1", [threadId]);
205
+ await pool.query("DELETE FROM checkpoint_writes WHERE thread_id = $1", [threadId]);
206
+ await pool.end();
207
+ });
208
+
209
+ it("reports counts and bytes from the checkpoint tables", async () => {
210
+ await pool.query(
211
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
212
+ VALUES ($1, '', 'cp-1', '{"v":1}'::jsonb, '{"step":1}'::jsonb),
213
+ ($1, '', 'cp-2', '{"v":2}'::jsonb, '{"step":2}'::jsonb)`,
214
+ [threadId],
215
+ );
216
+ await pool.query(
217
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
218
+ VALUES ($1, '', 'messages', '1', 'msgpack', $2),
219
+ ($1, '', 'messages', '2', 'msgpack', $3),
220
+ ($1, '', 'messages', '3', 'msgpack', $4)`,
221
+ [threadId, Buffer.alloc(10), Buffer.alloc(20), Buffer.alloc(30)],
222
+ );
223
+ await pool.query(
224
+ `INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
225
+ VALUES ($1, '', 'cp-1', 'task-1', 0, 'messages', 'msgpack', $2),
226
+ ($1, '', 'cp-1', 'task-1', 1, 'messages', 'msgpack', $3),
227
+ ($1, '', 'cp-2', 'task-2', 0, 'messages', 'msgpack', $4),
228
+ ($1, '', 'cp-2', 'task-2', 1, 'messages', 'msgpack', $5)`,
229
+ [threadId, Buffer.alloc(5), Buffer.alloc(15), Buffer.alloc(7), Buffer.alloc(13)],
230
+ );
231
+
232
+ const storage = await store.get(threadId);
233
+
234
+ expect(storage.threadId).toBe(threadId);
235
+ expect(storage.checkpoints.count).toBe(2);
236
+ expect(storage.checkpoints.bytes).toBeGreaterThan(0);
237
+ expect(storage.channelValues).toEqual({ count: 3, bytes: 60 });
238
+ expect(storage.pendingWrites).toEqual({ count: 4, bytes: 40 });
239
+ expect(storage.totalBytes).toBe(
240
+ storage.checkpoints.bytes + storage.channelValues.bytes + storage.pendingWrites.bytes,
241
+ );
242
+ });
243
+
244
+ async function removeRows(...threadIds: string[]): Promise<void> {
245
+ for (const id of threadIds) {
246
+ await pool.query("DELETE FROM checkpoint_writes WHERE thread_id = $1", [id]);
247
+ await pool.query("DELETE FROM checkpoint_blobs WHERE thread_id = $1", [id]);
248
+ await pool.query("DELETE FROM checkpoints WHERE thread_id = $1", [id]);
249
+ }
250
+ }
251
+
252
+ it("copies checkpoint rows to the target and rewrites metadata thread_id", async () => {
253
+ const source = randomUUID();
254
+ const target = randomUUID();
255
+ try {
256
+ await pool.query(
257
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
258
+ VALUES ($1, '', 'cp-1', '{"v":1}'::jsonb, $2::jsonb)`,
259
+ [source, JSON.stringify({ step: 1, thread_id: source })],
260
+ );
261
+ await pool.query(
262
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
263
+ VALUES ($1, '', 'messages', '1', 'msgpack', $2)`,
264
+ [source, Buffer.from("blob")],
265
+ );
266
+ await pool.query(
267
+ `INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
268
+ VALUES ($1, '', 'cp-1', 'task-1', 0, 'messages', 'msgpack', $2)`,
269
+ [source, Buffer.from("write")],
270
+ );
271
+
272
+ await store.copyThread(source, target);
273
+
274
+ const checkpoints = await pool.query(
275
+ "SELECT checkpoint_id, checkpoint, metadata FROM checkpoints WHERE thread_id = $1",
276
+ [target],
277
+ );
278
+ expect(checkpoints.rows).toHaveLength(1);
279
+ expect(checkpoints.rows[0].checkpoint_id).toBe("cp-1");
280
+ expect(checkpoints.rows[0].checkpoint).toEqual({ v: 1 });
281
+ expect(checkpoints.rows[0].metadata).toEqual({ step: 1, thread_id: target });
282
+
283
+ const blobs = await pool.query(
284
+ "SELECT channel, version, blob FROM checkpoint_blobs WHERE thread_id = $1",
285
+ [target],
286
+ );
287
+ expect(blobs.rows).toHaveLength(1);
288
+ expect(Buffer.from(blobs.rows[0].blob).toString("utf8")).toBe("blob");
289
+
290
+ const writes = await pool.query(
291
+ "SELECT task_id, idx, blob FROM checkpoint_writes WHERE thread_id = $1",
292
+ [target],
293
+ );
294
+ expect(writes.rows).toHaveLength(1);
295
+ expect(writes.rows[0].task_id).toBe("task-1");
296
+ expect(Buffer.from(writes.rows[0].blob).toString("utf8")).toBe("write");
297
+ } finally {
298
+ await removeRows(source, target);
299
+ }
300
+ });
301
+
302
+ it("treats an empty source copy as a no-op", async () => {
303
+ const source = randomUUID();
304
+ const target = randomUUID();
305
+ try {
306
+ await expect(store.copyThread(source, target)).resolves.toBeUndefined();
307
+ const counts = await pool.query(
308
+ `SELECT
309
+ (SELECT count(*)::int FROM checkpoints WHERE thread_id = $1) AS checkpoints,
310
+ (SELECT count(*)::int FROM checkpoint_blobs WHERE thread_id = $1) AS blobs,
311
+ (SELECT count(*)::int FROM checkpoint_writes WHERE thread_id = $1) AS writes`,
312
+ [target],
313
+ );
314
+ expect(counts.rows[0]).toEqual({ checkpoints: 0, blobs: 0, writes: 0 });
315
+ } finally {
316
+ await removeRows(source, target);
317
+ }
318
+ });
319
+
320
+ it("rejects a non-empty target with CHECKPOINT_TARGET_NOT_EMPTY", async () => {
321
+ const source = randomUUID();
322
+ const target = randomUUID();
323
+ try {
324
+ await pool.query(
325
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
326
+ VALUES ($1, '', 'cp-1', '{"v":1}'::jsonb, '{}'::jsonb)`,
327
+ [source],
328
+ );
329
+ await pool.query(
330
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
331
+ VALUES ($1, '', 'messages', '1', 'msgpack', $2)`,
332
+ [target, Buffer.from("existing")],
333
+ );
334
+
335
+ await expect(store.copyThread(source, target)).rejects.toMatchObject({
336
+ name: "CheckpointTargetNotEmptyError",
337
+ code: "CHECKPOINT_TARGET_NOT_EMPTY",
338
+ });
339
+ expect(await store.get(target)).toMatchObject({
340
+ checkpoints: { count: 0 },
341
+ channelValues: { count: 1 },
342
+ });
343
+ } finally {
344
+ await removeRows(source, target);
345
+ }
346
+ });
347
+
348
+ it("keep_latest retains the latest checkpoint per namespace and its blobs/writes", async () => {
349
+ await pool.query(
350
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
351
+ VALUES ($1, '', 'cp-1', '{"v":1,"channel_versions":{"messages":"1"}}'::jsonb, '{}'::jsonb),
352
+ ($1, '', 'cp-2', '{"v":1,"channel_versions":{"messages":"2"}}'::jsonb, '{}'::jsonb),
353
+ ($1, '', 'cp-3', '{"v":1,"channel_versions":{"messages":"3"}}'::jsonb, '{}'::jsonb)`,
354
+ [threadId],
355
+ );
356
+ await pool.query(
357
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
358
+ VALUES ($1, '', 'messages', '1', 'msgpack', $2),
359
+ ($1, '', 'messages', '2', 'msgpack', $3),
360
+ ($1, '', 'messages', '3', 'msgpack', $4)`,
361
+ [threadId, Buffer.from("one"), Buffer.from("two"), Buffer.from("three")],
362
+ );
363
+ await pool.query(
364
+ `INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
365
+ VALUES ($1, '', 'cp-1', 'task-1', 0, 'messages', 'msgpack', $2),
366
+ ($1, '', 'cp-2', 'task-2', 0, 'messages', 'msgpack', $3),
367
+ ($1, '', 'cp-3', 'task-3', 0, 'messages', 'msgpack', $4)`,
368
+ [threadId, Buffer.from("w1"), Buffer.from("w2"), Buffer.from("w3")],
369
+ );
370
+
371
+ const counts = await store.pruneThread(threadId, { keepLatest: 1 });
372
+
373
+ expect(counts).toEqual({ checkpoints: 2, blobs: 2, writes: 2 });
374
+ const checkpoints = await pool.query(
375
+ "SELECT checkpoint_id FROM checkpoints WHERE thread_id = $1 ORDER BY checkpoint_id",
376
+ [threadId],
377
+ );
378
+ expect(checkpoints.rows.map((row) => row.checkpoint_id)).toEqual(["cp-3"]);
379
+ const blobs = await pool.query(
380
+ "SELECT version FROM checkpoint_blobs WHERE thread_id = $1 ORDER BY version",
381
+ [threadId],
382
+ );
383
+ expect(blobs.rows.map((row) => row.version)).toEqual(["3"]);
384
+ const writes = await pool.query(
385
+ "SELECT checkpoint_id FROM checkpoint_writes WHERE thread_id = $1",
386
+ [threadId],
387
+ );
388
+ expect(writes.rows.map((row) => row.checkpoint_id)).toEqual(["cp-3"]);
389
+
390
+ await expect(store.pruneThread(threadId, { keepLatest: 1 }))
391
+ .resolves.toEqual({ checkpoints: 0, blobs: 0, writes: 0 });
392
+ });
393
+
394
+ it("keep_latest unions blobs referenced by all retained checkpoints", async () => {
395
+ await pool.query(
396
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
397
+ VALUES ($1, '', 'cp-1', '{"v":1,"channel_versions":{"messages":"1"}}'::jsonb, '{}'::jsonb),
398
+ ($1, '', 'cp-2', '{"v":1,"channel_versions":{"messages":"2"}}'::jsonb, '{}'::jsonb),
399
+ ($1, '', 'cp-3', '{"v":1,"channel_versions":{"messages":"3"}}'::jsonb, '{}'::jsonb)`,
400
+ [threadId],
401
+ );
402
+ await pool.query(
403
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
404
+ VALUES ($1, '', 'messages', '1', 'msgpack', $2),
405
+ ($1, '', 'messages', '2', 'msgpack', $3),
406
+ ($1, '', 'messages', '3', 'msgpack', $4)`,
407
+ [threadId, Buffer.from("one"), Buffer.from("two"), Buffer.from("three")],
408
+ );
409
+
410
+ const counts = await store.pruneThread(threadId, { keepLatest: 2 });
411
+
412
+ expect(counts).toEqual({ checkpoints: 1, blobs: 1, writes: 0 });
413
+ const blobs = await pool.query(
414
+ "SELECT version FROM checkpoint_blobs WHERE thread_id = $1 ORDER BY version",
415
+ [threadId],
416
+ );
417
+ expect(blobs.rows.map((row) => row.version)).toEqual(["2", "3"]);
418
+ });
419
+
420
+ it("keep_latest retains the latest checkpoint separately per namespace", async () => {
421
+ await pool.query(
422
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
423
+ VALUES ($1, '', 'cp-1', '{"v":1,"channel_versions":{}}'::jsonb, '{}'::jsonb),
424
+ ($1, '', 'cp-2', '{"v":1,"channel_versions":{}}'::jsonb, '{}'::jsonb),
425
+ ($1, 'subgraph', 'cp-1', '{"v":1,"channel_versions":{}}'::jsonb, '{}'::jsonb)`,
426
+ [threadId],
427
+ );
428
+
429
+ const counts = await store.pruneThread(threadId, { keepLatest: 1 });
430
+
431
+ expect(counts).toEqual({ checkpoints: 1, blobs: 0, writes: 0 });
432
+ const retained = await pool.query(
433
+ "SELECT checkpoint_ns, checkpoint_id FROM checkpoints WHERE thread_id = $1 ORDER BY checkpoint_ns",
434
+ [threadId],
435
+ );
436
+ expect(retained.rows).toEqual([
437
+ { checkpoint_ns: "", checkpoint_id: "cp-2" },
438
+ { checkpoint_ns: "subgraph", checkpoint_id: "cp-1" },
439
+ ]);
440
+ });
441
+
442
+ it("keep_latest is a no-op when the limit meets or exceeds the count", async () => {
443
+ await pool.query(
444
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
445
+ VALUES ($1, '', 'cp-1', '{"v":1,"channel_versions":{}}'::jsonb, '{}'::jsonb),
446
+ ($1, '', 'cp-2', '{"v":1,"channel_versions":{}}'::jsonb, '{}'::jsonb)`,
447
+ [threadId],
448
+ );
449
+
450
+ await expect(store.pruneThread(threadId, { keepLatest: 5 }))
451
+ .resolves.toEqual({ checkpoints: 0, blobs: 0, writes: 0 });
452
+ expect((await store.get(threadId)).checkpoints.count).toBe(2);
453
+ });
454
+
455
+ it("keep_latest keeps all namespace blobs when a retained checkpoint lacks channel_versions", async () => {
456
+ await pool.query(
457
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
458
+ VALUES ($1, '', 'cp-1', '{"v":1,"channel_versions":{"messages":"1"}}'::jsonb, '{}'::jsonb),
459
+ ($1, '', 'cp-2', '{"v":1}'::jsonb, '{}'::jsonb)`,
460
+ [threadId],
461
+ );
462
+ await pool.query(
463
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
464
+ VALUES ($1, '', 'messages', '1', 'msgpack', $2),
465
+ ($1, '', 'messages', '2', 'msgpack', $3),
466
+ ($1, '', 'messages', '3', 'msgpack', $4)`,
467
+ [threadId, Buffer.from("one"), Buffer.from("two"), Buffer.from("three")],
468
+ );
469
+ await pool.query(
470
+ `INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
471
+ VALUES ($1, '', 'cp-1', 'task-1', 0, 'messages', 'msgpack', $2),
472
+ ($1, '', 'cp-2', 'task-2', 0, 'messages', 'msgpack', $3)`,
473
+ [threadId, Buffer.from("w1"), Buffer.from("w2")],
474
+ );
475
+
476
+ const counts = await store.pruneThread(threadId, { keepLatest: 1 });
477
+
478
+ expect(counts).toEqual({ checkpoints: 1, blobs: 0, writes: 1 });
479
+ expect((await store.get(threadId)).channelValues.count).toBe(3);
480
+ });
481
+
482
+ it("deletes rows from all three checkpoint tables", async () => {
483
+ const thread = randomUUID();
484
+ try {
485
+ await pool.query(
486
+ `INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, checkpoint, metadata)
487
+ VALUES ($1, '', 'cp-1', '{"v":1}'::jsonb, '{}'::jsonb)`,
488
+ [thread],
489
+ );
490
+ await pool.query(
491
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
492
+ VALUES ($1, '', 'messages', '1', 'msgpack', $2)`,
493
+ [thread, Buffer.from("blob")],
494
+ );
495
+ await pool.query(
496
+ `INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob)
497
+ VALUES ($1, '', 'cp-1', 'task-1', 0, 'messages', 'msgpack', $2)`,
498
+ [thread, Buffer.from("write")],
499
+ );
500
+
501
+ await store.deleteThread(thread);
502
+
503
+ const counts = await pool.query(
504
+ `SELECT
505
+ (SELECT count(*)::int FROM checkpoints WHERE thread_id = $1) AS checkpoints,
506
+ (SELECT count(*)::int FROM checkpoint_blobs WHERE thread_id = $1) AS blobs,
507
+ (SELECT count(*)::int FROM checkpoint_writes WHERE thread_id = $1) AS writes`,
508
+ [thread],
509
+ );
510
+ expect(counts.rows[0]).toEqual({ checkpoints: 0, blobs: 0, writes: 0 });
511
+ } finally {
512
+ await removeRows(thread);
513
+ }
514
+ });
515
+
516
+ it("coalesces a NULL checkpoint blob to zero bytes", async () => {
517
+ await pool.query(
518
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
519
+ VALUES ($1, '', 'messages', '1', 'msgpack', NULL)`,
520
+ [threadId],
521
+ );
522
+
523
+ const storage = await store.get(threadId);
524
+
525
+ expect(storage.channelValues).toEqual({ count: 1, bytes: 0 });
526
+ expect(storage.totalBytes).toBe(0);
527
+ });
528
+
529
+ it("returns a zeroed report for an unknown thread", async () => {
530
+ await expect(store.get(randomUUID())).resolves.toEqual({
531
+ threadId: expect.any(String),
532
+ totalBytes: 0,
533
+ checkpoints: { count: 0, bytes: 0 },
534
+ channelValues: { count: 0, bytes: 0 },
535
+ pendingWrites: { count: 0, bytes: 0 },
536
+ });
537
+ });
538
+ });