@axiom-lattice/pg-stores 3.3.13 → 4.0.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 (46) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +208 -0
  3. package/dist/index.d.mts +357 -3
  4. package/dist/index.d.ts +357 -3
  5. package/dist/index.js +2337 -77
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2342 -76
  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 +335 -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/__tests__/eval-task-id.test.ts +14 -3
  23. package/src/createPgStoreConfig.ts +35 -2
  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/eval_migrations.ts +19 -0
  36. package/src/migrations/store_migrations.ts +50 -0
  37. package/src/migrations/task_migration.ts +17 -0
  38. package/src/migrations/thread_migrations.ts +25 -0
  39. package/src/stores/PostgreSQLAgentRunLedger.ts +1221 -0
  40. package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
  41. package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
  42. package/src/stores/PostgreSQLEvalStore.ts +17 -4
  43. package/src/stores/PostgreSQLStore.ts +167 -0
  44. package/src/stores/PostgreSQLTaskStore.ts +4 -0
  45. package/src/stores/PostgreSQLThreadStore.ts +49 -10
  46. package/src/stores/ThreadMessageQueueStore.ts +10 -5
@@ -0,0 +1,167 @@
1
+ import type { Pool } from "pg";
2
+ import {
3
+ assertStoreIdentity,
4
+ assertStoreNamespacePrefix,
5
+ storeExpiresAt,
6
+ storePaginationValue,
7
+ type StoreItem,
8
+ type StoreProtocol,
9
+ type StorePutInput,
10
+ type StoreSearchInput,
11
+ } from "@axiom-lattice/protocols";
12
+
13
+ interface StoreRow {
14
+ tenant_id: string;
15
+ namespace: string[];
16
+ key: string;
17
+ value: Record<string, unknown> | null;
18
+ created_at: Date;
19
+ updated_at: Date;
20
+ expires_at: Date | null;
21
+ }
22
+
23
+ function mapRow(row: StoreRow): StoreItem {
24
+ return {
25
+ namespace: row.namespace,
26
+ key: row.key,
27
+ value: row.value ?? {},
28
+ createdAt: row.created_at,
29
+ updatedAt: row.updated_at,
30
+ expiresAt: row.expires_at ?? undefined,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * PostgreSQL tenant-scoped namespaced key-value store.
36
+ *
37
+ * @remarks
38
+ * - `namespace` is a `TEXT[]`; prefix search compares `namespace[1:n]`.
39
+ * - `filter` uses JSONB containment (`value @> $filter`).
40
+ * - `query` is a case-insensitive substring over `value::text`.
41
+ * - Expired items are excluded from reads/searches and hard-deleted by
42
+ * {@link deleteExpired}.
43
+ */
44
+ export class PostgreSQLStore implements StoreProtocol {
45
+ /**
46
+ * @param pool - Pool shared with the rest of the store config.
47
+ */
48
+ constructor(private readonly pool: Pool) {}
49
+
50
+ async put(tenantId: string, input: StorePutInput): Promise<StoreItem> {
51
+ assertStoreIdentity(input.namespace, input.key);
52
+ const now = new Date();
53
+ const expiresAt = storeExpiresAt(input.ttlMinutes, now) ?? null;
54
+ const result = await this.pool.query<StoreRow>(
55
+ `INSERT INTO lattice_store_items
56
+ (tenant_id, namespace, key, value, created_at, updated_at, expires_at)
57
+ VALUES ($1, $2, $3, $4::jsonb, $5, $5, $6)
58
+ ON CONFLICT (tenant_id, namespace, key) DO UPDATE SET
59
+ value = EXCLUDED.value,
60
+ updated_at = EXCLUDED.updated_at,
61
+ expires_at = EXCLUDED.expires_at
62
+ RETURNING tenant_id, namespace, key, value, created_at, updated_at, expires_at`,
63
+ [tenantId, input.namespace, input.key, JSON.stringify(input.value), now, expiresAt],
64
+ );
65
+ return mapRow(result.rows[0]);
66
+ }
67
+
68
+ async get(tenantId: string, namespace: string[], key: string): Promise<StoreItem | null> {
69
+ assertStoreIdentity(namespace, key);
70
+ const result = await this.pool.query<StoreRow>(
71
+ `SELECT tenant_id, namespace, key, value, created_at, updated_at, expires_at
72
+ FROM lattice_store_items
73
+ WHERE tenant_id = $1 AND namespace = $2 AND key = $3
74
+ AND (expires_at IS NULL OR expires_at > $4)`,
75
+ [tenantId, namespace, key, new Date()],
76
+ );
77
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
78
+ }
79
+
80
+ async delete(tenantId: string, namespace: string[], key: string): Promise<boolean> {
81
+ assertStoreIdentity(namespace, key);
82
+ const result = await this.pool.query(
83
+ `DELETE FROM lattice_store_items
84
+ WHERE tenant_id = $1 AND namespace = $2 AND key = $3`,
85
+ [tenantId, namespace, key],
86
+ );
87
+ return (result.rowCount ?? 0) > 0;
88
+ }
89
+
90
+ async search(tenantId: string, input: StoreSearchInput): Promise<StoreItem[]> {
91
+ const conditions = [
92
+ "tenant_id = $1",
93
+ "(expires_at IS NULL OR expires_at > $2)",
94
+ ];
95
+ const params: unknown[] = [tenantId, new Date()];
96
+ if (input.namespacePrefix) {
97
+ assertStoreNamespacePrefix(input.namespacePrefix);
98
+ }
99
+ if (input.namespacePrefix && input.namespacePrefix.length > 0) {
100
+ params.push(input.namespacePrefix);
101
+ conditions.push(`namespace[1:array_length($${params.length}::text[], 1)] = $${params.length}`);
102
+ }
103
+ if (input.filter !== undefined) {
104
+ params.push(JSON.stringify(input.filter));
105
+ conditions.push(`value @> $${params.length}::jsonb`);
106
+ }
107
+ if (input.query !== undefined && input.query !== "") {
108
+ params.push(input.query.replace(/[\\%_]/g, "\\$&"));
109
+ conditions.push(`value::text ILIKE '%' || $${params.length} || '%' ESCAPE '\\'`);
110
+ }
111
+ params.push(storePaginationValue(input.limit, 10));
112
+ const limitParam = params.length;
113
+ params.push(storePaginationValue(input.offset, 0));
114
+ const offsetParam = params.length;
115
+ const result = await this.pool.query<StoreRow>(
116
+ `SELECT tenant_id, namespace, key, value, created_at, updated_at, expires_at
117
+ FROM lattice_store_items
118
+ WHERE ${conditions.join(" AND ")}
119
+ ORDER BY updated_at DESC, namespace, key
120
+ LIMIT $${limitParam} OFFSET $${offsetParam}`,
121
+ params,
122
+ );
123
+ return result.rows.map(mapRow);
124
+ }
125
+
126
+ async listNamespaces(
127
+ tenantId: string,
128
+ prefix: string[] = [],
129
+ limit = 100,
130
+ ): Promise<string[][]> {
131
+ assertStoreNamespacePrefix(prefix);
132
+ const conditions = [
133
+ "tenant_id = $1",
134
+ "(expires_at IS NULL OR expires_at > $2)",
135
+ ];
136
+ const params: unknown[] = [tenantId, new Date()];
137
+ if (prefix.length > 0) {
138
+ params.push(prefix);
139
+ conditions.push(`namespace[1:array_length($${params.length}::text[], 1)] = $${params.length}`);
140
+ }
141
+ params.push(storePaginationValue(limit, 100));
142
+ const result = await this.pool.query<{ namespace: string[] }>(
143
+ `SELECT DISTINCT namespace
144
+ FROM lattice_store_items
145
+ WHERE ${conditions.join(" AND ")}
146
+ ORDER BY namespace
147
+ LIMIT $${params.length}`,
148
+ params,
149
+ );
150
+ return result.rows.map((row) => row.namespace);
151
+ }
152
+
153
+ async deleteExpired(now: Date, limit: number): Promise<number> {
154
+ const bounded = storePaginationValue(limit, 0);
155
+ const result = await this.pool.query(
156
+ `WITH expired AS (
157
+ SELECT ctid FROM lattice_store_items
158
+ WHERE expires_at IS NOT NULL AND expires_at <= $1
159
+ ORDER BY expires_at
160
+ LIMIT $2
161
+ )
162
+ DELETE FROM lattice_store_items WHERE ctid IN (SELECT ctid FROM expired)`,
163
+ [now, bounded],
164
+ );
165
+ return result.rowCount ?? 0;
166
+ }
167
+ }
@@ -283,6 +283,10 @@ export class PostgreSQLTaskStore implements TaskStore {
283
283
  conditions.push(`priority = $${paramIndex++}`);
284
284
  params.push(filter.priority);
285
285
  }
286
+ if (filter.threadId) {
287
+ conditions.push(`context->>'thread_id' = $${paramIndex++}`);
288
+ params.push(filter.threadId);
289
+ }
286
290
  if (filter.workspaceId) {
287
291
  conditions.push(`workspace_id = $${paramIndex++}`);
288
292
  params.push(filter.workspaceId);
@@ -10,7 +10,7 @@ import {
10
10
  CreateThreadRequest,
11
11
  } from "@axiom-lattice/protocols";
12
12
  import { MigrationManager } from "../migrations/migration";
13
- import { createThreadsTable } from "../migrations/thread_migrations";
13
+ import { addThreadExpiresAt, createThreadsTable } from "../migrations/thread_migrations";
14
14
  import { addThreadTenantId } from "../migrations/thread_tenant_migration";
15
15
  import { changeThreadPrimaryKey } from "../migrations/thread_pk_migration";
16
16
 
@@ -72,6 +72,7 @@ export class PostgreSQLThreadStore implements ThreadStore {
72
72
  this.migrationManager.register(createThreadsTable);
73
73
  this.migrationManager.register(addThreadTenantId);
74
74
  this.migrationManager.register(changeThreadPrimaryKey);
75
+ this.migrationManager.register(addThreadExpiresAt);
75
76
 
76
77
  // Auto-migrate by default
77
78
  if (options.autoMigrate !== false) {
@@ -134,9 +135,10 @@ export class PostgreSQLThreadStore implements ThreadStore {
134
135
  metadata: any;
135
136
  created_at: Date;
136
137
  updated_at: Date;
138
+ expires_at: Date | null;
137
139
  }>(
138
140
  `
139
- SELECT id, tenant_id, assistant_id, metadata, created_at, updated_at
141
+ SELECT id, tenant_id, assistant_id, metadata, created_at, updated_at, expires_at
140
142
  FROM lattice_threads
141
143
  WHERE tenant_id = $1 AND assistant_id = $2
142
144
  ORDER BY created_at DESC
@@ -174,9 +176,10 @@ export class PostgreSQLThreadStore implements ThreadStore {
174
176
  metadata: any;
175
177
  created_at: Date;
176
178
  updated_at: Date;
179
+ expires_at: Date | null;
177
180
  }>(
178
181
  `
179
- SELECT id, tenant_id, assistant_id, metadata, created_at, updated_at
182
+ SELECT id, tenant_id, assistant_id, metadata, created_at, updated_at, expires_at
180
183
  FROM lattice_threads
181
184
  WHERE tenant_id = $1 AND id = $2
182
185
  `,
@@ -206,14 +209,15 @@ export class PostgreSQLThreadStore implements ThreadStore {
206
209
 
207
210
  await this.pool.query(
208
211
  `
209
- INSERT INTO lattice_threads (id, tenant_id, assistant_id, metadata, created_at, updated_at)
210
- VALUES ($1, $2, $3, $4, $5, $6)
212
+ INSERT INTO lattice_threads (id, tenant_id, assistant_id, metadata, created_at, updated_at, expires_at)
213
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
211
214
  ON CONFLICT (id, tenant_id) DO UPDATE SET
212
215
  assistant_id = EXCLUDED.assistant_id,
213
216
  metadata = EXCLUDED.metadata,
214
- updated_at = EXCLUDED.updated_at
217
+ updated_at = EXCLUDED.updated_at,
218
+ expires_at = EXCLUDED.expires_at
215
219
  `,
216
- [threadId, tenantId, assistantId, JSON.stringify(metadata), now, now]
220
+ [threadId, tenantId, assistantId, JSON.stringify(metadata), now, now, data.expiresAt ?? null]
217
221
  );
218
222
 
219
223
  return {
@@ -223,6 +227,7 @@ export class PostgreSQLThreadStore implements ThreadStore {
223
227
  metadata,
224
228
  createdAt: now,
225
229
  updatedAt: now,
230
+ expiresAt: data.expiresAt,
226
231
  };
227
232
  }
228
233
 
@@ -249,20 +254,24 @@ export class PostgreSQLThreadStore implements ThreadStore {
249
254
  };
250
255
 
251
256
  const now = new Date();
257
+ const expiresAt = updates.expiresAt !== undefined
258
+ ? updates.expiresAt
259
+ : existing.expiresAt ?? null;
252
260
 
253
261
  await this.pool.query(
254
262
  `
255
263
  UPDATE lattice_threads
256
- SET metadata = $1, updated_at = $2
257
- WHERE tenant_id = $3 AND id = $4
264
+ SET metadata = $1, updated_at = $2, expires_at = $3
265
+ WHERE tenant_id = $4 AND id = $5
258
266
  `,
259
- [JSON.stringify(updatedMetadata), now, tenantId, threadId]
267
+ [JSON.stringify(updatedMetadata), now, expiresAt, tenantId, threadId]
260
268
  );
261
269
 
262
270
  return {
263
271
  ...existing,
264
272
  metadata: updatedMetadata,
265
273
  updatedAt: now,
274
+ expiresAt: expiresAt ?? undefined,
266
275
  };
267
276
  }
268
277
 
@@ -301,6 +310,34 @@ export class PostgreSQLThreadStore implements ThreadStore {
301
310
  return result.rows.length > 0;
302
311
  }
303
312
 
313
+ /**
314
+ * List expired threads across all tenants, oldest expiry first.
315
+ */
316
+ async listExpiredThreads(now: Date, limit: number): Promise<Thread[]> {
317
+ await this.ensureInitialized();
318
+
319
+ const result = await this.pool.query<{
320
+ id: string;
321
+ tenant_id: string;
322
+ assistant_id: string;
323
+ metadata: any;
324
+ created_at: Date;
325
+ updated_at: Date;
326
+ expires_at: Date | null;
327
+ }>(
328
+ `
329
+ SELECT id, tenant_id, assistant_id, metadata, created_at, updated_at, expires_at
330
+ FROM lattice_threads
331
+ WHERE expires_at IS NOT NULL AND expires_at <= $1
332
+ ORDER BY expires_at ASC
333
+ LIMIT $2
334
+ `,
335
+ [now, Math.max(0, limit)]
336
+ );
337
+
338
+ return result.rows.map(this.mapRowToThread);
339
+ }
340
+
304
341
  /**
305
342
  * Ensure store is initialized
306
343
  */
@@ -320,6 +357,7 @@ export class PostgreSQLThreadStore implements ThreadStore {
320
357
  metadata: any;
321
358
  created_at: Date;
322
359
  updated_at: Date;
360
+ expires_at: Date | null;
323
361
  }): Thread {
324
362
  return {
325
363
  id: row.id,
@@ -331,6 +369,7 @@ export class PostgreSQLThreadStore implements ThreadStore {
331
369
  : row.metadata || {},
332
370
  createdAt: row.created_at,
333
371
  updatedAt: row.updated_at,
372
+ expiresAt: row.expires_at ?? undefined,
334
373
  };
335
374
  }
336
375
  }
@@ -277,21 +277,26 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
277
277
 
278
278
  /**
279
279
  * Get all threads with pending or processing messages
280
+ *
281
+ * @returns One entry per full scope, with `hasPending` distinguishing
282
+ * processing-only threads (no pending row) from threads with work to drain.
280
283
  */
281
284
  async getThreadsWithPendingMessages(): Promise<ThreadInfo[]> {
282
285
  const result = await this.pool.query(
283
- `SELECT DISTINCT ON (tenant_id, assistant_id, workspace_id, project_id, thread_id) tenant_id, assistant_id, thread_id, workspace_id, project_id
284
- FROM lattice_thread_message_queue
285
- WHERE status IN ('pending', 'processing')
286
- ORDER BY tenant_id, assistant_id, workspace_id, project_id, thread_id`
286
+ `SELECT tenant_id, assistant_id, thread_id, workspace_id, project_id,
287
+ bool_or(status = 'pending') AS has_pending
288
+ FROM lattice_thread_message_queue
289
+ WHERE status IN ('pending', 'processing')
290
+ GROUP BY tenant_id, assistant_id, thread_id, workspace_id, project_id`
287
291
  );
288
292
 
289
- return result.rows.map(row => ({
293
+ return result.rows.map((row) => ({
290
294
  tenantId: row.tenant_id,
291
295
  assistantId: row.assistant_id,
292
296
  threadId: row.thread_id,
293
297
  workspaceId: row.workspace_id,
294
298
  projectId: row.project_id,
299
+ hasPending: row.has_pending,
295
300
  }));
296
301
  }
297
302