@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,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
|
+
}
|