@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +187 -0
- package/dist/index.d.mts +353 -3
- package/dist/index.d.ts +353 -3
- package/dist/index.js +2323 -93
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2329 -92
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/PGVectorStoreProvider.ts +19 -13
- package/src/__tests__/AgentRunRetention.migrations.test.ts +41 -0
- package/src/__tests__/PostgreSQLAgentRunLedger.test.ts +305 -0
- package/src/__tests__/PostgreSQLAgentRunRetention.test.ts +126 -0
- package/src/__tests__/PostgreSQLAgentRunTaskQueue.test.ts +1375 -0
- package/src/__tests__/PostgreSQLChannelReplyJobStore.test.ts +333 -0
- package/src/__tests__/PostgreSQLCheckpointStorage.deleteAfter.test.ts +473 -0
- package/src/__tests__/PostgreSQLCheckpointStorage.test.ts +538 -0
- package/src/__tests__/PostgreSQLStore.test.ts +173 -0
- package/src/__tests__/PostgreSQLThreadStore.test.ts +80 -0
- package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +21 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +8 -3
- package/src/__tests__/createPgStoreConfig.test.ts +38 -0
- package/src/createPgStoreConfig.ts +36 -1
- package/src/index.ts +7 -0
- package/src/migrations/agent_run_base_checkpoint_captured_migration.ts +34 -0
- package/src/migrations/agent_run_capability_revision_migration.ts +24 -0
- package/src/migrations/agent_run_ledger_migration.ts +51 -0
- package/src/migrations/agent_run_project_task_migration.ts +34 -0
- package/src/migrations/agent_run_project_task_running_index_migration.ts +33 -0
- package/src/migrations/agent_run_retention_migration.ts +25 -0
- package/src/migrations/agent_run_rollback_failure_migration.ts +28 -0
- package/src/migrations/agent_run_running_index_source_migration.ts +31 -0
- package/src/migrations/agent_run_task_queue_migration.ts +65 -0
- package/src/migrations/channel_reply_job_migration.ts +55 -0
- package/src/migrations/model_provider_migrations.ts +27 -0
- package/src/migrations/store_migrations.ts +50 -0
- package/src/migrations/thread_migrations.ts +25 -0
- package/src/stores/PostgreSQLAgentRunLedger.ts +1210 -0
- package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
- package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
- package/src/stores/PostgreSQLModelProviderStore.ts +25 -7
- package/src/stores/PostgreSQLStore.ts +167 -0
- package/src/stores/PostgreSQLThreadStore.ts +49 -10
- 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::
|
|
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
|
};
|