@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,548 @@
1
+ import type { Pool, PoolClient } from "pg";
2
+ import type {
3
+ CheckpointMaintenance,
4
+ CheckpointStorage,
5
+ ThreadCheckpointStorage,
6
+ } from "@axiom-lattice/protocols";
7
+
8
+ /**
9
+ * Raised when a checkpoint copy targets a thread that already owns rows.
10
+ */
11
+ export class CheckpointTargetNotEmptyError extends Error {
12
+ /** Machine-readable conflict code. */
13
+ readonly code = "CHECKPOINT_TARGET_NOT_EMPTY" as const;
14
+
15
+ /**
16
+ * @param threadId - Non-empty target thread id.
17
+ */
18
+ constructor(threadId: string) {
19
+ super(`Checkpoint target thread ${threadId} is not empty`);
20
+ this.name = "CheckpointTargetNotEmptyError";
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Raised when a checkpoint rewind targets a base checkpoint that does not exist.
26
+ */
27
+ export class CheckpointBaseNotFoundError extends Error {
28
+ /** Machine-readable failure code. */
29
+ readonly code = "CHECKPOINT_BASE_NOT_FOUND" as const;
30
+ /** Thread owning the missing base checkpoint. */
31
+ readonly threadId: string;
32
+ /** Base checkpoint id that was not found. */
33
+ readonly baseCheckpointId: string;
34
+
35
+ /**
36
+ * @param threadId - Thread owning the missing base checkpoint.
37
+ * @param baseCheckpointId - Base checkpoint id that was not found.
38
+ */
39
+ constructor(threadId: string, baseCheckpointId: string) {
40
+ super(`Checkpoint ${baseCheckpointId} does not exist on thread ${threadId}`);
41
+ this.name = "CheckpointBaseNotFoundError";
42
+ this.threadId = threadId;
43
+ this.baseCheckpointId = baseCheckpointId;
44
+ }
45
+ }
46
+
47
+ async function rollbackQuietly(client: PoolClient): Promise<void> {
48
+ try {
49
+ await client.query("ROLLBACK");
50
+ } catch {
51
+ // Preserve the original failure.
52
+ }
53
+ }
54
+
55
+ function isUniqueViolation(error: unknown): boolean {
56
+ return typeof error === "object"
57
+ && error !== null
58
+ && "code" in error
59
+ && (error as { code?: unknown }).code === "23505";
60
+ }
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function checkpointKey(checkpointNs: string, checkpointId: string): string {
67
+ return `${checkpointNs}\u0000${checkpointId}`;
68
+ }
69
+
70
+ function blobKey(checkpointNs: string, channel: string, version: string): string {
71
+ return `${checkpointNs}\u0000${channel}\u0000${version}`;
72
+ }
73
+
74
+ function checkpointRecord(value: unknown): Record<string, unknown> | undefined {
75
+ if (typeof value === "string") {
76
+ try {
77
+ const parsed: unknown = JSON.parse(value);
78
+ return isRecord(parsed) ? parsed : undefined;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ return isRecord(value) ? value : undefined;
84
+ }
85
+
86
+ function channelVersions(value: unknown): Record<string, unknown> | undefined {
87
+ const checkpoint = checkpointRecord(value);
88
+ const versions = checkpoint?.channel_versions;
89
+ return isRecord(versions) ? versions : undefined;
90
+ }
91
+
92
+ interface RetainedCheckpointRow {
93
+ checkpoint_ns: string;
94
+ checkpoint_id: string;
95
+ checkpoint: unknown;
96
+ }
97
+
98
+ interface CheckpointKeyRow {
99
+ checkpoint_ns: string;
100
+ checkpoint_id: string;
101
+ }
102
+
103
+ interface BlobKeyRow {
104
+ checkpoint_ns: string;
105
+ channel: string;
106
+ version: string;
107
+ }
108
+
109
+ interface WriteKeyRow {
110
+ checkpoint_ns: string;
111
+ checkpoint_id: string;
112
+ task_id: string;
113
+ idx: number;
114
+ }
115
+
116
+ const DELETE_CHUNK_SIZE = 100;
117
+
118
+ function chunk<T>(items: T[], size: number): T[][] {
119
+ const chunks: T[][] = [];
120
+ for (let index = 0; index < items.length; index += size) {
121
+ chunks.push(items.slice(index, index + size));
122
+ }
123
+ return chunks;
124
+ }
125
+
126
+ interface CheckpointStorageRow {
127
+ checkpoint_count: number | string | null;
128
+ checkpoint_bytes: number | string | null;
129
+ blob_count: number | string | null;
130
+ blob_bytes: number | string | null;
131
+ write_count: number | string | null;
132
+ write_bytes: number | string | null;
133
+ }
134
+
135
+ function toCount(value: number | string | null | undefined): number {
136
+ const parsed = Number(value ?? 0);
137
+ return Number.isFinite(parsed) ? parsed : 0;
138
+ }
139
+
140
+ /**
141
+ * Checkpoint accounting and maintenance over LangGraph's built-in
142
+ * `PostgresSaver` tables.
143
+ *
144
+ * `get` is a read-only aggregate. `copyThread`, `deleteThread`, `deleteAfter`,
145
+ * and `pruneThread` are the explicit maintenance operations; each runs in one
146
+ * transaction and touches only the `checkpoints`, `checkpoint_blobs`, and
147
+ * `checkpoint_writes` tables created by `PostgresSaver.setup()`. No migration
148
+ * is added.
149
+ */
150
+ export class PostgreSQLCheckpointStorage implements CheckpointStorage, CheckpointMaintenance {
151
+ /**
152
+ * @param pool - PostgreSQL pool shared with the rest of the store config.
153
+ */
154
+ constructor(private readonly pool: Pool) {}
155
+
156
+ /**
157
+ * Measure the checkpoint storage used by one thread.
158
+ *
159
+ * @param threadId - Thread id whose checkpoint rows should be measured.
160
+ * @returns Counts and byte sizes grouped by checkpoint storage category.
161
+ */
162
+ async get(threadId: string): Promise<ThreadCheckpointStorage> {
163
+ const result = await this.pool.query<CheckpointStorageRow>(
164
+ `SELECT
165
+ (SELECT count(*)::bigint FROM checkpoints WHERE thread_id=$1) AS checkpoint_count,
166
+ (SELECT coalesce(sum(pg_column_size(checkpoint) + pg_column_size(metadata)),0)::bigint FROM checkpoints WHERE thread_id=$1) AS checkpoint_bytes,
167
+ (SELECT count(*)::bigint FROM checkpoint_blobs WHERE thread_id=$1) AS blob_count,
168
+ (SELECT coalesce(sum(coalesce(octet_length(blob),0)),0)::bigint FROM checkpoint_blobs WHERE thread_id=$1) AS blob_bytes,
169
+ (SELECT count(*)::bigint FROM checkpoint_writes WHERE thread_id=$1) AS write_count,
170
+ (SELECT coalesce(sum(octet_length(blob)),0)::bigint FROM checkpoint_writes WHERE thread_id=$1) AS write_bytes`,
171
+ [threadId],
172
+ );
173
+
174
+ const row = result.rows[0];
175
+ const checkpointCount = toCount(row?.checkpoint_count);
176
+ const checkpointBytes = toCount(row?.checkpoint_bytes);
177
+ const blobCount = toCount(row?.blob_count);
178
+ const blobBytes = toCount(row?.blob_bytes);
179
+ const writeCount = toCount(row?.write_count);
180
+ const writeBytes = toCount(row?.write_bytes);
181
+
182
+ return {
183
+ threadId,
184
+ totalBytes: checkpointBytes + blobBytes + writeBytes,
185
+ checkpoints: { count: checkpointCount, bytes: checkpointBytes },
186
+ channelValues: { count: blobCount, bytes: blobBytes },
187
+ pendingWrites: { count: writeCount, bytes: writeBytes },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Copy every checkpoint row from a source thread to an empty target thread.
193
+ *
194
+ * @param sourceThreadId - Thread whose rows are copied.
195
+ * @param targetThreadId - Empty thread that receives the rows.
196
+ * @returns Resolves when the copy commits; an empty source is a no-op.
197
+ * @throws {CheckpointTargetNotEmptyError} When the target already owns rows.
198
+ */
199
+ async copyThread(sourceThreadId: string, targetThreadId: string): Promise<void> {
200
+ const client = await this.pool.connect();
201
+ try {
202
+ await client.query("BEGIN");
203
+ const target = await client.query<{ not_empty: boolean }>(
204
+ `SELECT (
205
+ EXISTS (SELECT 1 FROM checkpoints WHERE thread_id=$1)
206
+ OR EXISTS (SELECT 1 FROM checkpoint_blobs WHERE thread_id=$1)
207
+ OR EXISTS (SELECT 1 FROM checkpoint_writes WHERE thread_id=$1)
208
+ ) AS not_empty`,
209
+ [targetThreadId],
210
+ );
211
+ if (target.rows[0]?.not_empty) {
212
+ throw new CheckpointTargetNotEmptyError(targetThreadId);
213
+ }
214
+
215
+ await client.query(
216
+ `INSERT INTO checkpoints (
217
+ thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
218
+ )
219
+ SELECT $2, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint,
220
+ jsonb_set(coalesce(metadata, '{}'::jsonb), '{thread_id}', to_jsonb($2::text), true)
221
+ FROM checkpoints WHERE thread_id=$1`,
222
+ [sourceThreadId, targetThreadId],
223
+ );
224
+ await client.query(
225
+ `INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, type, blob)
226
+ SELECT $2, checkpoint_ns, channel, version, type, blob
227
+ FROM checkpoint_blobs WHERE thread_id=$1`,
228
+ [sourceThreadId, targetThreadId],
229
+ );
230
+ await client.query(
231
+ `INSERT INTO checkpoint_writes (
232
+ thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob
233
+ )
234
+ SELECT $2, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, blob
235
+ FROM checkpoint_writes WHERE thread_id=$1`,
236
+ [sourceThreadId, targetThreadId],
237
+ );
238
+ await client.query("COMMIT");
239
+ } catch (error) {
240
+ await rollbackQuietly(client);
241
+ if (isUniqueViolation(error)) {
242
+ throw new CheckpointTargetNotEmptyError(targetThreadId);
243
+ }
244
+ throw error;
245
+ } finally {
246
+ client.release();
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Retain only the latest N checkpoints per checkpoint namespace.
252
+ *
253
+ * @param threadId - Thread whose checkpoint history is pruned.
254
+ * @param options - Retention options.
255
+ * @param options.keepLatest - Number of latest checkpoints to retain per namespace.
256
+ * @returns Counts of deleted checkpoint, blob, and write rows.
257
+ */
258
+ async pruneThread(
259
+ threadId: string,
260
+ options: { keepLatest: number },
261
+ ): Promise<{ checkpoints: number; blobs: number; writes: number }> {
262
+ if (!Number.isSafeInteger(options.keepLatest) || options.keepLatest < 1) {
263
+ throw new RangeError("keepLatest must be an integer >= 1");
264
+ }
265
+ const keepLatest = options.keepLatest;
266
+ const client = await this.pool.connect();
267
+ try {
268
+ await client.query("BEGIN");
269
+
270
+ const retainedResult = await client.query<RetainedCheckpointRow>(
271
+ `SELECT checkpoint_ns, checkpoint_id, checkpoint FROM (
272
+ SELECT checkpoint_ns, checkpoint_id, checkpoint,
273
+ row_number() OVER (
274
+ PARTITION BY checkpoint_ns ORDER BY checkpoint_id DESC
275
+ ) AS rn
276
+ FROM checkpoints WHERE thread_id=$1
277
+ ) t WHERE rn <= $2`,
278
+ [threadId, keepLatest],
279
+ );
280
+
281
+ const retained = retainedResult.rows;
282
+ const retainedCheckpoints = new Set(
283
+ retained.map((row) => checkpointKey(row.checkpoint_ns, row.checkpoint_id)),
284
+ );
285
+ const fallbackNamespaces = new Set<string>();
286
+ const retainedBlobs = new Set<string>();
287
+ for (const row of retained) {
288
+ const versions = channelVersions(row.checkpoint);
289
+ if (!versions) {
290
+ fallbackNamespaces.add(row.checkpoint_ns);
291
+ continue;
292
+ }
293
+ for (const [channel, version] of Object.entries(versions)) {
294
+ retainedBlobs.add(blobKey(row.checkpoint_ns, channel, String(version)));
295
+ }
296
+ }
297
+
298
+ const writeRows = await client.query<WriteKeyRow>(
299
+ `SELECT checkpoint_ns, checkpoint_id, task_id, idx
300
+ FROM checkpoint_writes WHERE thread_id=$1`,
301
+ [threadId],
302
+ );
303
+ const staleWrites = writeRows.rows.filter(
304
+ (row) => !retainedCheckpoints.has(checkpointKey(row.checkpoint_ns, row.checkpoint_id)),
305
+ );
306
+ let writes = 0;
307
+ for (const batch of chunk(staleWrites, DELETE_CHUNK_SIZE)) {
308
+ const params: unknown[] = [threadId];
309
+ const tuples = batch.map((row) => {
310
+ params.push(row.checkpoint_ns, row.checkpoint_id, row.task_id, row.idx);
311
+ return `($${params.length - 3}, $${params.length - 2}, $${params.length - 1}, $${params.length})`;
312
+ });
313
+ const deleted = await client.query(
314
+ `DELETE FROM checkpoint_writes
315
+ WHERE thread_id=$1
316
+ AND (checkpoint_ns, checkpoint_id, task_id, idx) IN (${tuples.join(", ")})`,
317
+ params,
318
+ );
319
+ writes += deleted.rowCount ?? 0;
320
+ }
321
+
322
+ const checkpointRows = await client.query<CheckpointKeyRow>(
323
+ `SELECT checkpoint_ns, checkpoint_id FROM checkpoints WHERE thread_id=$1`,
324
+ [threadId],
325
+ );
326
+ const staleCheckpoints = checkpointRows.rows.filter(
327
+ (row) => !retainedCheckpoints.has(checkpointKey(row.checkpoint_ns, row.checkpoint_id)),
328
+ );
329
+ let checkpoints = 0;
330
+ for (const batch of chunk(staleCheckpoints, DELETE_CHUNK_SIZE)) {
331
+ const params: unknown[] = [threadId];
332
+ const tuples = batch.map((row) => {
333
+ params.push(row.checkpoint_ns, row.checkpoint_id);
334
+ return `($${params.length - 1}, $${params.length})`;
335
+ });
336
+ const deleted = await client.query(
337
+ `DELETE FROM checkpoints
338
+ WHERE thread_id=$1
339
+ AND (checkpoint_ns, checkpoint_id) IN (${tuples.join(", ")})`,
340
+ params,
341
+ );
342
+ checkpoints += deleted.rowCount ?? 0;
343
+ }
344
+
345
+ const blobRows = await client.query<BlobKeyRow>(
346
+ `SELECT checkpoint_ns, channel, version FROM checkpoint_blobs WHERE thread_id=$1`,
347
+ [threadId],
348
+ );
349
+ const staleBlobs = blobRows.rows.filter(
350
+ (row) => !fallbackNamespaces.has(row.checkpoint_ns)
351
+ && !retainedBlobs.has(blobKey(row.checkpoint_ns, row.channel, row.version)),
352
+ );
353
+ let blobs = 0;
354
+ for (const batch of chunk(staleBlobs, DELETE_CHUNK_SIZE)) {
355
+ const params: unknown[] = [threadId];
356
+ const tuples = batch.map((row) => {
357
+ params.push(row.checkpoint_ns, row.channel, row.version);
358
+ return `($${params.length - 2}, $${params.length - 1}, $${params.length})`;
359
+ });
360
+ const deleted = await client.query(
361
+ `DELETE FROM checkpoint_blobs
362
+ WHERE thread_id=$1
363
+ AND (checkpoint_ns, channel, version) IN (${tuples.join(", ")})`,
364
+ params,
365
+ );
366
+ blobs += deleted.rowCount ?? 0;
367
+ }
368
+
369
+ await client.query("COMMIT");
370
+ return { checkpoints, blobs, writes };
371
+ } catch (error) {
372
+ await rollbackQuietly(client);
373
+ throw error;
374
+ } finally {
375
+ client.release();
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Delete the checkpoints that descend from a base checkpoint.
381
+ *
382
+ * Descendants are discovered by following both same-namespace
383
+ * `parent_checkpoint_id` edges and cross-namespace `metadata.parents` edges
384
+ * (LangGraph's `{ "<parent checkpoint_ns>": "<parent checkpoint id>" }` map on
385
+ * subgraph checkpoints), recursively across every namespace. Out-of-order or
386
+ * clock-skewed ids do not affect the result. Writes keyed to the base
387
+ * checkpoint belong to the rolled-back run's first superstep and are deleted
388
+ * too. Blobs referenced only by deleted checkpoints are removed.
389
+ *
390
+ * @param threadId - Thread whose later checkpoint history is deleted.
391
+ * @param baseCheckpointId - Checkpoint to retain as the new tip, or `null` to
392
+ * delete every checkpoint for the thread.
393
+ * @returns Resolves when the deletion commits; no descendants is a no-op.
394
+ * @throws {RangeError} When `baseCheckpointId` is an empty string.
395
+ * @throws {CheckpointBaseNotFoundError} When the base checkpoint does not
396
+ * exist on the thread.
397
+ */
398
+ async deleteAfter(threadId: string, baseCheckpointId: string | null): Promise<void> {
399
+ if (baseCheckpointId !== null && baseCheckpointId.length === 0) {
400
+ throw new RangeError("baseCheckpointId must be null or a non-empty checkpoint id");
401
+ }
402
+ const client = await this.pool.connect();
403
+ try {
404
+ await client.query("BEGIN");
405
+
406
+ if (baseCheckpointId === null) {
407
+ await client.query("DELETE FROM checkpoint_writes WHERE thread_id=$1", [threadId]);
408
+ await client.query("DELETE FROM checkpoint_blobs WHERE thread_id=$1", [threadId]);
409
+ await client.query("DELETE FROM checkpoints WHERE thread_id=$1", [threadId]);
410
+ await client.query("COMMIT");
411
+ return;
412
+ }
413
+
414
+ const base = await client.query<{ checkpoint_ns: string }>(
415
+ `SELECT checkpoint_ns
416
+ FROM checkpoints
417
+ WHERE thread_id=$1 AND checkpoint_id=$2`,
418
+ [threadId, baseCheckpointId],
419
+ );
420
+ if (base.rows.length === 0) {
421
+ throw new CheckpointBaseNotFoundError(threadId, baseCheckpointId);
422
+ }
423
+ const baseCheckpointNs = base.rows[0]!.checkpoint_ns;
424
+
425
+ const descendantResult = await client.query<CheckpointKeyRow>(
426
+ `WITH RECURSIVE lineage(checkpoint_ns, checkpoint_id) AS (
427
+ SELECT $2::text, $3::text
428
+ UNION
429
+ SELECT c.checkpoint_ns, c.checkpoint_id
430
+ FROM checkpoints c
431
+ JOIN lineage l ON (
432
+ (c.checkpoint_ns = l.checkpoint_ns
433
+ AND c.parent_checkpoint_id = l.checkpoint_id)
434
+ OR (c.metadata->'parents'->>l.checkpoint_ns = l.checkpoint_id)
435
+ )
436
+ WHERE c.thread_id=$1
437
+ ),
438
+ roots AS (
439
+ SELECT $2::text AS checkpoint_ns, $3::text AS checkpoint_id
440
+ )
441
+ SELECT l.checkpoint_ns, l.checkpoint_id
442
+ FROM lineage l
443
+ WHERE NOT EXISTS (
444
+ SELECT 1 FROM roots r
445
+ WHERE r.checkpoint_ns = l.checkpoint_ns
446
+ AND r.checkpoint_id = l.checkpoint_id
447
+ )`,
448
+ [threadId, baseCheckpointNs, baseCheckpointId],
449
+ );
450
+ const descendants = descendantResult.rows;
451
+
452
+ await client.query(
453
+ `DELETE FROM checkpoint_writes
454
+ WHERE thread_id=$1 AND checkpoint_ns=$2 AND checkpoint_id=$3`,
455
+ [threadId, baseCheckpointNs, baseCheckpointId],
456
+ );
457
+
458
+ for (const batch of chunk(descendants, DELETE_CHUNK_SIZE)) {
459
+ const params: unknown[] = [threadId];
460
+ const tuples = batch.map((row) => {
461
+ params.push(row.checkpoint_ns, row.checkpoint_id);
462
+ return `($${params.length - 1}, $${params.length})`;
463
+ });
464
+ await client.query(
465
+ `DELETE FROM checkpoint_writes
466
+ WHERE thread_id=$1
467
+ AND (checkpoint_ns, checkpoint_id) IN (${tuples.join(", ")})`,
468
+ params,
469
+ );
470
+ await client.query(
471
+ `DELETE FROM checkpoints
472
+ WHERE thread_id=$1
473
+ AND (checkpoint_ns, checkpoint_id) IN (${tuples.join(", ")})`,
474
+ params,
475
+ );
476
+ }
477
+
478
+ const retainedResult = await client.query<RetainedCheckpointRow>(
479
+ `SELECT checkpoint_ns, checkpoint_id, checkpoint
480
+ FROM checkpoints WHERE thread_id=$1`,
481
+ [threadId],
482
+ );
483
+ const fallbackNamespaces = new Set<string>();
484
+ const retainedBlobs = new Set<string>();
485
+ for (const row of retainedResult.rows) {
486
+ const versions = channelVersions(row.checkpoint);
487
+ if (!versions) {
488
+ fallbackNamespaces.add(row.checkpoint_ns);
489
+ continue;
490
+ }
491
+ for (const [channel, version] of Object.entries(versions)) {
492
+ retainedBlobs.add(blobKey(row.checkpoint_ns, channel, String(version)));
493
+ }
494
+ }
495
+
496
+ const blobRows = await client.query<BlobKeyRow>(
497
+ `SELECT checkpoint_ns, channel, version FROM checkpoint_blobs WHERE thread_id=$1`,
498
+ [threadId],
499
+ );
500
+ const staleBlobs = blobRows.rows.filter(
501
+ (row) => !fallbackNamespaces.has(row.checkpoint_ns)
502
+ && !retainedBlobs.has(blobKey(row.checkpoint_ns, row.channel, row.version)),
503
+ );
504
+ for (const batch of chunk(staleBlobs, DELETE_CHUNK_SIZE)) {
505
+ const params: unknown[] = [threadId];
506
+ const tuples = batch.map((row) => {
507
+ params.push(row.checkpoint_ns, row.channel, row.version);
508
+ return `($${params.length - 2}, $${params.length - 1}, $${params.length})`;
509
+ });
510
+ await client.query(
511
+ `DELETE FROM checkpoint_blobs
512
+ WHERE thread_id=$1
513
+ AND (checkpoint_ns, channel, version) IN (${tuples.join(", ")})`,
514
+ params,
515
+ );
516
+ }
517
+
518
+ await client.query("COMMIT");
519
+ } catch (error) {
520
+ await rollbackQuietly(client);
521
+ throw error;
522
+ } finally {
523
+ client.release();
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Delete every checkpoint row for a thread.
529
+ *
530
+ * @param threadId - Thread whose checkpoint rows are deleted.
531
+ * @returns Resolves when the deletion commits.
532
+ */
533
+ async deleteThread(threadId: string): Promise<void> {
534
+ const client = await this.pool.connect();
535
+ try {
536
+ await client.query("BEGIN");
537
+ await client.query("DELETE FROM checkpoint_writes WHERE thread_id=$1", [threadId]);
538
+ await client.query("DELETE FROM checkpoint_blobs WHERE thread_id=$1", [threadId]);
539
+ await client.query("DELETE FROM checkpoints WHERE thread_id=$1", [threadId]);
540
+ await client.query("COMMIT");
541
+ } catch (error) {
542
+ await rollbackQuietly(client);
543
+ throw error;
544
+ } finally {
545
+ client.release();
546
+ }
547
+ }
548
+ }