@axiom-lattice/pg-stores 3.3.12 → 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 (44) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +187 -0
  3. package/dist/index.d.mts +353 -3
  4. package/dist/index.d.ts +353 -3
  5. package/dist/index.js +2323 -93
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2329 -92
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/PGVectorStoreProvider.ts +19 -13
  11. package/src/__tests__/AgentRunRetention.migrations.test.ts +41 -0
  12. package/src/__tests__/PostgreSQLAgentRunLedger.test.ts +305 -0
  13. package/src/__tests__/PostgreSQLAgentRunRetention.test.ts +126 -0
  14. package/src/__tests__/PostgreSQLAgentRunTaskQueue.test.ts +1375 -0
  15. package/src/__tests__/PostgreSQLChannelReplyJobStore.test.ts +333 -0
  16. package/src/__tests__/PostgreSQLCheckpointStorage.deleteAfter.test.ts +473 -0
  17. package/src/__tests__/PostgreSQLCheckpointStorage.test.ts +538 -0
  18. package/src/__tests__/PostgreSQLStore.test.ts +173 -0
  19. package/src/__tests__/PostgreSQLThreadStore.test.ts +80 -0
  20. package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +21 -0
  21. package/src/__tests__/ThreadMessageQueueStore.test.ts +8 -3
  22. package/src/__tests__/createPgStoreConfig.test.ts +38 -0
  23. package/src/createPgStoreConfig.ts +36 -1
  24. package/src/index.ts +7 -0
  25. package/src/migrations/agent_run_base_checkpoint_captured_migration.ts +34 -0
  26. package/src/migrations/agent_run_capability_revision_migration.ts +24 -0
  27. package/src/migrations/agent_run_ledger_migration.ts +51 -0
  28. package/src/migrations/agent_run_project_task_migration.ts +34 -0
  29. package/src/migrations/agent_run_project_task_running_index_migration.ts +33 -0
  30. package/src/migrations/agent_run_retention_migration.ts +25 -0
  31. package/src/migrations/agent_run_rollback_failure_migration.ts +28 -0
  32. package/src/migrations/agent_run_running_index_source_migration.ts +31 -0
  33. package/src/migrations/agent_run_task_queue_migration.ts +65 -0
  34. package/src/migrations/channel_reply_job_migration.ts +55 -0
  35. package/src/migrations/model_provider_migrations.ts +27 -0
  36. package/src/migrations/store_migrations.ts +50 -0
  37. package/src/migrations/thread_migrations.ts +25 -0
  38. package/src/stores/PostgreSQLAgentRunLedger.ts +1210 -0
  39. package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
  40. package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
  41. package/src/stores/PostgreSQLModelProviderStore.ts +25 -7
  42. package/src/stores/PostgreSQLStore.ts +167 -0
  43. package/src/stores/PostgreSQLThreadStore.ts +49 -10
  44. 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
+ }
@@ -12,6 +12,7 @@ import {
12
12
  ModelProviderWireProtocol,
13
13
  ModelProviderApiStyle,
14
14
  ModelProviderLlmProvider,
15
+ ModelKind,
15
16
  CreateModelProviderRequest,
16
17
  UpdateModelProviderRequest,
17
18
  ProviderModelInput,
@@ -24,6 +25,7 @@ import {
24
25
  addModelProviderModelStale,
25
26
  addModelProviderModelExtra,
26
27
  addModelProviderModelDefault,
28
+ addModelProviderModelKind,
27
29
  } from '../migrations/model_provider_migrations';
28
30
  import { encrypt, safeDecrypt } from '@axiom-lattice/core';
29
31
 
@@ -81,6 +83,7 @@ interface ModelRow {
81
83
  enabled: boolean;
82
84
  stale: boolean;
83
85
  extra: Record<string, unknown> | null;
86
+ kind: string;
84
87
  is_default: boolean;
85
88
  discovered_at: Date;
86
89
  }
@@ -144,6 +147,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
144
147
  this.migrationManager.register(addModelProviderModelStale); // v184
145
148
  this.migrationManager.register(addModelProviderModelExtra); // v191
146
149
  this.migrationManager.register(addModelProviderModelDefault); // v192
150
+ this.migrationManager.register(addModelProviderModelKind); // v193
147
151
 
148
152
  // Auto-migrate by default
149
153
  if (options.autoMigrate !== false) {
@@ -469,7 +473,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
469
473
  ? await this.pool.query<ModelRow>(
470
474
  `
471
475
  SELECT id, tenant_id, provider_id, model_id, display_name, owned_by,
472
- upstream_created, enabled, stale, extra, is_default, discovered_at
476
+ upstream_created, enabled, stale, extra, kind, is_default, discovered_at
473
477
  FROM lattice_model_provider_models
474
478
  WHERE tenant_id = $1 AND provider_id = $2
475
479
  ORDER BY model_id ASC
@@ -479,7 +483,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
479
483
  : await this.pool.query<ModelRow>(
480
484
  `
481
485
  SELECT id, tenant_id, provider_id, model_id, display_name, owned_by,
482
- upstream_created, enabled, stale, extra, is_default, discovered_at
486
+ upstream_created, enabled, stale, extra, kind, is_default, discovered_at
483
487
  FROM lattice_model_provider_models
484
488
  WHERE tenant_id = $1
485
489
  ORDER BY model_id ASC
@@ -505,7 +509,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
505
509
  const result = await this.pool.query<ModelRow>(
506
510
  `
507
511
  SELECT id, tenant_id, provider_id, model_id, display_name, owned_by,
508
- upstream_created, enabled, stale, extra, is_default, discovered_at
512
+ upstream_created, enabled, stale, extra, kind, is_default, discovered_at
509
513
  FROM lattice_model_provider_models
510
514
  WHERE tenant_id = $1 AND provider_id = $2 AND model_id = $3
511
515
  `,
@@ -553,10 +557,11 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
553
557
  model_id: string;
554
558
  enabled: boolean;
555
559
  extra: Record<string, unknown> | null;
560
+ kind: string;
556
561
  is_default: boolean;
557
562
  }>(
558
563
  `
559
- SELECT model_id, enabled, extra, is_default FROM lattice_model_provider_models
564
+ SELECT model_id, enabled, extra, kind, is_default FROM lattice_model_provider_models
560
565
  WHERE tenant_id = $1 AND provider_id = $2
561
566
  `,
562
567
  [tenantId, providerId]
@@ -567,6 +572,9 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
567
572
  const currentExtra = new Map(
568
573
  current.rows.map((row) => [row.model_id, row.extra])
569
574
  );
575
+ const currentKind = new Map(
576
+ current.rows.map((row) => [row.model_id, row.kind])
577
+ );
570
578
  const currentIsDefault = new Map(
571
579
  current.rows.map((row) => [row.model_id, row.is_default])
572
580
  );
@@ -595,6 +603,13 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
595
603
  (model) => model.extra ?? currentExtra.get(model.id) ?? {}
596
604
  );
597
605
 
606
+ // Same rule as `extra`: an omitted `kind` keeps the row's stored value,
607
+ // and a row created by discovery — which carries no kind at all — starts
608
+ // as chat. Nothing here can silently reclassify a model the tenant set.
609
+ const effectiveKind = rows.map(
610
+ (model) => model.kind ?? currentKind.get(model.id) ?? "chat"
611
+ );
612
+
598
613
  // Whether this write is naming a default at all.
599
614
  //
600
615
  // The unique index spans the *tenant*, while this method writes one
@@ -661,15 +676,15 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
661
676
  `
662
677
  INSERT INTO lattice_model_provider_models (
663
678
  id, tenant_id, provider_id, model_id, display_name, owned_by,
664
- upstream_created, raw, enabled, stale, extra, is_default, discovered_at
679
+ upstream_created, raw, enabled, stale, extra, kind, is_default, discovered_at
665
680
  )
666
681
  SELECT * FROM unnest(
667
682
  $1::text[], $2::text[], $3::text[], $4::text[], $5::text[],
668
683
  $6::text[], $7::timestamp[], $8::jsonb[], $9::boolean[], $10::boolean[],
669
- $11::jsonb[], $12::boolean[], $13::timestamp[]
684
+ $11::jsonb[], $12::text[], $13::boolean[], $14::timestamp[]
670
685
  ) AS t(
671
686
  id, tenant_id, provider_id, model_id, display_name, owned_by,
672
- upstream_created, raw, enabled, stale, extra, is_default, discovered_at
687
+ upstream_created, raw, enabled, stale, extra, kind, is_default, discovered_at
673
688
  )
674
689
  ON CONFLICT (tenant_id, provider_id, model_id) DO UPDATE SET
675
690
  display_name = EXCLUDED.display_name,
@@ -679,6 +694,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
679
694
  enabled = EXCLUDED.enabled,
680
695
  stale = EXCLUDED.stale,
681
696
  extra = EXCLUDED.extra,
697
+ kind = EXCLUDED.kind,
682
698
  is_default = EXCLUDED.is_default,
683
699
  discovered_at = EXCLUDED.discovered_at
684
700
  `,
@@ -698,6 +714,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
698
714
  effectiveEnabled,
699
715
  rows.map((model) => model.stale ?? false),
700
716
  effectiveExtra.map((extra) => JSON.stringify(extra)),
717
+ effectiveKind,
701
718
  effectiveIsDefault,
702
719
  rows.map(() => new Date().toISOString()),
703
720
  ]
@@ -827,6 +844,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
827
844
  enabled: row.enabled,
828
845
  stale: row.stale,
829
846
  extra: extraOrUndefined(row.extra),
847
+ kind: (row.kind as ModelKind) || "chat",
830
848
  isDefault: row.is_default,
831
849
  discoveredAt: row.discovered_at,
832
850
  };