@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,373 @@
1
+ import { Pool, type PoolConfig } from "pg";
2
+ import {
3
+ assertValidCreateChannelReplyJobInput,
4
+ normalizeChannelReplyJobListLimit,
5
+ type ChannelReplyJob,
6
+ type ChannelReplyJobStore,
7
+ type CreateChannelReplyJobInput,
8
+ } from "@axiom-lattice/protocols";
9
+ import { MigrationManager } from "../migrations/migration";
10
+ import { createChannelReplyJobsTable } from "../migrations/channel_reply_job_migration";
11
+
12
+ /** Options accepted by {@link PostgreSQLChannelReplyJobStore}. */
13
+ export interface PostgreSQLChannelReplyJobStoreOptions {
14
+ /** Externally managed pool. When supplied, migrations are not run here. */
15
+ pool?: Pool;
16
+ /** Connection string or pool config used to create a pool. */
17
+ poolConfig?: string | PoolConfig;
18
+ /** Set to `false` to skip running this store's migration automatically. */
19
+ autoMigrate?: boolean;
20
+ }
21
+
22
+ interface ChannelReplyJobRow {
23
+ run_id: string;
24
+ tenant_id: string;
25
+ assistant_id: string;
26
+ thread_id: string;
27
+ adapter_channel: string;
28
+ channel_installation_id: string;
29
+ reply_target: Record<string, unknown>;
30
+ input_message_id: string;
31
+ stream_cursor: string | null;
32
+ status: string;
33
+ attempts: number | string;
34
+ available_at: number | string;
35
+ lease_owner: string | null;
36
+ lease_expires_at: number | string | null;
37
+ last_error: string | null;
38
+ output_message_id: string | null;
39
+ created_at: number | string;
40
+ updated_at: number | string;
41
+ delivered_at: number | string | null;
42
+ }
43
+
44
+ /** Epoch-millisecond database clock, so replicas never share `Date.now()` skew. */
45
+ const DB_CLOCK_MS =
46
+ "FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint";
47
+ const COLUMNS = `run_id, tenant_id, assistant_id, thread_id, adapter_channel,
48
+ channel_installation_id, reply_target, input_message_id, stream_cursor, status,
49
+ attempts, available_at, lease_owner, lease_expires_at, last_error,
50
+ output_message_id, created_at, updated_at, delivered_at`;
51
+
52
+ function toNumber(value: number | string): number {
53
+ if (typeof value === "number") return value;
54
+ if (typeof value === "string") return Number(value);
55
+ throw new Error("Invalid channel reply job numeric column");
56
+ }
57
+
58
+ function toNullableNumber(value: number | string | null): number | null {
59
+ return value === null ? null : toNumber(value);
60
+ }
61
+
62
+ function toStatus(value: string): ChannelReplyJob["status"] {
63
+ if (value === "pending" || value === "delivered" || value === "failed") {
64
+ return value;
65
+ }
66
+ throw new Error(`Invalid channel reply job status: ${value}`);
67
+ }
68
+
69
+ function mapRow(row: ChannelReplyJobRow): ChannelReplyJob {
70
+ return {
71
+ runId: row.run_id,
72
+ tenantId: row.tenant_id,
73
+ assistantId: row.assistant_id,
74
+ threadId: row.thread_id,
75
+ adapterChannel: row.adapter_channel,
76
+ channelInstallationId: row.channel_installation_id,
77
+ replyTarget: row.reply_target,
78
+ inputMessageId: row.input_message_id,
79
+ streamCursor: row.stream_cursor,
80
+ status: toStatus(row.status),
81
+ attempts: toNumber(row.attempts),
82
+ availableAt: toNumber(row.available_at),
83
+ leaseOwner: row.lease_owner,
84
+ leaseExpiresAt: toNullableNumber(row.lease_expires_at),
85
+ lastError: row.last_error,
86
+ outputMessageId: row.output_message_id,
87
+ createdAt: toNumber(row.created_at),
88
+ updatedAt: toNumber(row.updated_at),
89
+ deliveredAt: toNullableNumber(row.delivered_at),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * PostgreSQL {@link ChannelReplyJobStore}.
95
+ *
96
+ * @remarks Claims are a single conditional `UPDATE` (CAS) guarded by status,
97
+ * `available_at`, and lease expiry, so competing replicas cannot both win.
98
+ * Lease/created/updated/delivered timestamps use the database clock; only the
99
+ * caller-supplied `availableAt` epoch is stored as-is.
100
+ */
101
+ export class PostgreSQLChannelReplyJobStore implements ChannelReplyJobStore {
102
+ private pool: Pool;
103
+ private ownsPool = true;
104
+ private initialized = false;
105
+ private initPromise: Promise<void> | null = null;
106
+ private migrationManager!: MigrationManager;
107
+
108
+ /** @param options An externally managed pool, or a pool config/connection URL. */
109
+ constructor(options: PostgreSQLChannelReplyJobStoreOptions) {
110
+ if (options.pool) {
111
+ this.pool = options.pool;
112
+ this.ownsPool = false;
113
+ this.initialized = true;
114
+ return;
115
+ }
116
+
117
+ if (typeof options.poolConfig === "string") {
118
+ this.pool = new Pool({ connectionString: options.poolConfig });
119
+ } else if (options.poolConfig) {
120
+ this.pool = new Pool(options.poolConfig as PoolConfig);
121
+ } else {
122
+ throw new Error("Either pool or poolConfig must be provided");
123
+ }
124
+
125
+ this.migrationManager = new MigrationManager(this.pool);
126
+ this.migrationManager.register(createChannelReplyJobsTable);
127
+
128
+ if (options.autoMigrate !== false) {
129
+ this.initialize().catch((error) => {
130
+ console.error("Failed to initialize PostgreSQLChannelReplyJobStore:", error);
131
+ throw error;
132
+ });
133
+ }
134
+ }
135
+
136
+ /** Runs pending migrations once. */
137
+ async initialize(): Promise<void> {
138
+ if (this.initialized) return;
139
+ if (this.initPromise) return this.initPromise;
140
+
141
+ this.initPromise = (async () => {
142
+ try {
143
+ await this.migrationManager.migrate();
144
+ this.initialized = true;
145
+ } finally {
146
+ this.initPromise = null;
147
+ }
148
+ })();
149
+ return this.initPromise;
150
+ }
151
+
152
+ /** Ends the pool when this store owns it. */
153
+ async dispose(): Promise<void> {
154
+ if (this.ownsPool) await this.pool.end();
155
+ }
156
+
157
+ /** Creates a pending job with database-clock created/updated timestamps. */
158
+ async create(input: CreateChannelReplyJobInput): Promise<ChannelReplyJob> {
159
+ assertValidCreateChannelReplyJobInput(input);
160
+ const result = await this.pool.query<ChannelReplyJobRow>(
161
+ `WITH clock AS (SELECT ${DB_CLOCK_MS} AS now_ms)
162
+ INSERT INTO lattice_channel_reply_jobs (
163
+ run_id, tenant_id, assistant_id, thread_id, adapter_channel,
164
+ channel_installation_id, reply_target, input_message_id, stream_cursor,
165
+ status, attempts, available_at, lease_owner, lease_expires_at,
166
+ last_error, output_message_id, created_at, updated_at, delivered_at
167
+ )
168
+ SELECT
169
+ $1, $2, $3, $4, $5,
170
+ $6, $7::jsonb, $8, $9,
171
+ 'pending', 0, $10, NULL, NULL,
172
+ NULL, NULL, clock.now_ms, clock.now_ms, NULL
173
+ FROM clock
174
+ RETURNING ${COLUMNS}`,
175
+ [
176
+ input.runId,
177
+ input.tenantId,
178
+ input.assistantId,
179
+ input.threadId,
180
+ input.adapterChannel,
181
+ input.channelInstallationId,
182
+ JSON.stringify(input.replyTarget),
183
+ input.inputMessageId,
184
+ input.streamCursor ?? null,
185
+ input.availableAt,
186
+ ],
187
+ );
188
+ return mapRow(result.rows[0]);
189
+ }
190
+
191
+ /** Reads one job within a tenant scope. */
192
+ async get(tenantId: string, runId: string): Promise<ChannelReplyJob | null> {
193
+ const result = await this.pool.query<ChannelReplyJobRow>(
194
+ `SELECT ${COLUMNS}
195
+ FROM lattice_channel_reply_jobs
196
+ WHERE tenant_id = $1 AND run_id = $2`,
197
+ [tenantId, runId],
198
+ );
199
+ return result.rowCount === 0 ? null : mapRow(result.rows[0]);
200
+ }
201
+
202
+ /** Lists claimable pending jobs that are due at `now`, oldest first. */
203
+ async listPending(now: number, limit?: number): Promise<ChannelReplyJob[]> {
204
+ const result = await this.pool.query<ChannelReplyJobRow>(
205
+ `SELECT ${COLUMNS}
206
+ FROM lattice_channel_reply_jobs
207
+ WHERE status = 'pending'
208
+ AND available_at <= $1
209
+ AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at <= $1)
210
+ ORDER BY available_at ASC, created_at ASC, run_id ASC
211
+ LIMIT $2`,
212
+ [now, normalizeChannelReplyJobListLimit(limit)],
213
+ );
214
+ return result.rows.map(mapRow);
215
+ }
216
+
217
+ /**
218
+ * Atomically leases a claimable job.
219
+ *
220
+ * @param now Accepted for interface symmetry; PostgreSQL uses the database clock.
221
+ */
222
+ async claim(
223
+ tenantId: string,
224
+ runId: string,
225
+ owner: string,
226
+ leaseMs: number,
227
+ now?: number,
228
+ ): Promise<ChannelReplyJob | null> {
229
+ void now;
230
+ const result = await this.pool.query<ChannelReplyJobRow>(
231
+ `WITH clock AS (SELECT ${DB_CLOCK_MS} AS now_ms)
232
+ UPDATE lattice_channel_reply_jobs AS j
233
+ SET lease_owner = $3,
234
+ lease_expires_at = clock.now_ms + $4,
235
+ updated_at = clock.now_ms
236
+ FROM clock
237
+ WHERE j.tenant_id = $1
238
+ AND j.run_id = $2
239
+ AND j.status = 'pending'
240
+ AND j.available_at <= clock.now_ms
241
+ AND (j.lease_owner IS NULL OR j.lease_expires_at IS NULL OR j.lease_expires_at <= clock.now_ms)
242
+ RETURNING ${COLUMNS}`,
243
+ [tenantId, runId, owner, leaseMs],
244
+ );
245
+ return result.rowCount === 0 ? null : mapRow(result.rows[0]);
246
+ }
247
+
248
+ /**
249
+ * Marks a job delivered when `owner` still holds the lease.
250
+ *
251
+ * @param now Accepted for interface symmetry; PostgreSQL uses the database clock.
252
+ */
253
+ async markDelivered(
254
+ tenantId: string,
255
+ runId: string,
256
+ owner: string,
257
+ outputMessageId: string | null = null,
258
+ now?: number,
259
+ ): Promise<ChannelReplyJob | null> {
260
+ void now;
261
+ const result = await this.pool.query<ChannelReplyJobRow>(
262
+ `WITH clock AS (SELECT ${DB_CLOCK_MS} AS now_ms)
263
+ UPDATE lattice_channel_reply_jobs AS j
264
+ SET status = 'delivered',
265
+ output_message_id = $4,
266
+ lease_owner = NULL,
267
+ lease_expires_at = NULL,
268
+ delivered_at = clock.now_ms,
269
+ updated_at = clock.now_ms
270
+ FROM clock
271
+ WHERE j.tenant_id = $1
272
+ AND j.run_id = $2
273
+ AND j.lease_owner = $3
274
+ AND j.status = 'pending'
275
+ RETURNING ${COLUMNS}`,
276
+ [tenantId, runId, owner, outputMessageId],
277
+ );
278
+ return result.rowCount === 0 ? null : mapRow(result.rows[0]);
279
+ }
280
+
281
+ /**
282
+ * Returns a job to pending with an incremented attempt count.
283
+ *
284
+ * @param now Accepted for interface symmetry; PostgreSQL uses the database clock.
285
+ */
286
+ async reschedule(
287
+ tenantId: string,
288
+ runId: string,
289
+ owner: string,
290
+ error: string,
291
+ availableAt: number,
292
+ now?: number,
293
+ ): Promise<ChannelReplyJob | null> {
294
+ void now;
295
+ const result = await this.pool.query<ChannelReplyJobRow>(
296
+ `WITH clock AS (SELECT ${DB_CLOCK_MS} AS now_ms)
297
+ UPDATE lattice_channel_reply_jobs AS j
298
+ SET status = 'pending',
299
+ attempts = attempts + 1,
300
+ lease_owner = NULL,
301
+ lease_expires_at = NULL,
302
+ last_error = $4,
303
+ available_at = $5,
304
+ updated_at = clock.now_ms
305
+ FROM clock
306
+ WHERE j.tenant_id = $1
307
+ AND j.run_id = $2
308
+ AND j.lease_owner = $3
309
+ AND j.status = 'pending'
310
+ RETURNING ${COLUMNS}`,
311
+ [tenantId, runId, owner, error, availableAt],
312
+ );
313
+ return result.rowCount === 0 ? null : mapRow(result.rows[0]);
314
+ }
315
+
316
+ /**
317
+ * Marks a job terminally failed when `owner` still holds the lease.
318
+ *
319
+ * @param now Accepted for interface symmetry; PostgreSQL uses the database clock.
320
+ */
321
+ async markFailed(
322
+ tenantId: string,
323
+ runId: string,
324
+ owner: string,
325
+ error: string,
326
+ now?: number,
327
+ ): Promise<ChannelReplyJob | null> {
328
+ void now;
329
+ const result = await this.pool.query<ChannelReplyJobRow>(
330
+ `WITH clock AS (SELECT ${DB_CLOCK_MS} AS now_ms)
331
+ UPDATE lattice_channel_reply_jobs AS j
332
+ SET status = 'failed',
333
+ last_error = $4,
334
+ lease_owner = NULL,
335
+ lease_expires_at = NULL,
336
+ updated_at = clock.now_ms
337
+ FROM clock
338
+ WHERE j.tenant_id = $1
339
+ AND j.run_id = $2
340
+ AND j.lease_owner = $3
341
+ AND j.status = 'pending'
342
+ RETURNING ${COLUMNS}`,
343
+ [tenantId, runId, owner, error],
344
+ );
345
+ return result.rowCount === 0 ? null : mapRow(result.rows[0]);
346
+ }
347
+
348
+ /** Deletes one job. Returns whether a row was removed. */
349
+ async delete(tenantId: string, runId: string): Promise<boolean> {
350
+ const result = await this.pool.query(
351
+ `DELETE FROM lattice_channel_reply_jobs WHERE tenant_id = $1 AND run_id = $2`,
352
+ [tenantId, runId],
353
+ );
354
+ return (result.rowCount ?? 0) > 0;
355
+ }
356
+
357
+ /** Deletes terminal jobs whose last update is older than `before`. */
358
+ async deleteTerminalBefore(before: number, limit?: number): Promise<number> {
359
+ const result = await this.pool.query(
360
+ `DELETE FROM lattice_channel_reply_jobs
361
+ WHERE run_id IN (
362
+ SELECT run_id
363
+ FROM lattice_channel_reply_jobs
364
+ WHERE status IN ('delivered', 'failed')
365
+ AND updated_at < $1
366
+ ORDER BY updated_at ASC, run_id ASC
367
+ LIMIT $2
368
+ )`,
369
+ [before, normalizeChannelReplyJobListLimit(limit)],
370
+ );
371
+ return result.rowCount ?? 0;
372
+ }
373
+ }