@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,333 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+ import type { CreateChannelReplyJobInput } from "@axiom-lattice/protocols";
4
+ import { PostgreSQLChannelReplyJobStore } from "../stores/PostgreSQLChannelReplyJobStore";
5
+
6
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
7
+ const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
8
+ const SCHEMA = `channel_reply_job_test_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
9
+
10
+ function input(
11
+ overrides: Partial<CreateChannelReplyJobInput> = {},
12
+ ): CreateChannelReplyJobInput {
13
+ return {
14
+ runId: randomUUID(),
15
+ tenantId: randomUUID(),
16
+ assistantId: "assistant-1",
17
+ threadId: "thread-1",
18
+ adapterChannel: "lark",
19
+ channelInstallationId: "install-1",
20
+ replyTarget: { chatId: "chat-1" },
21
+ inputMessageId: "input-1",
22
+ availableAt: Date.now() - 1_000,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ maybeDescribe("PostgreSQLChannelReplyJobStore (PostgreSQL)", () => {
28
+ let store: PostgreSQLChannelReplyJobStore;
29
+ let pool: Pool;
30
+
31
+ beforeAll(async () => {
32
+ const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
33
+ await admin.query(`CREATE SCHEMA ${SCHEMA}`);
34
+ await admin.end();
35
+
36
+ const poolConfig = {
37
+ connectionString: TEST_DATABASE_URL!,
38
+ options: `-c search_path=${SCHEMA}`,
39
+ };
40
+ store = new PostgreSQLChannelReplyJobStore({ poolConfig });
41
+ await store.initialize();
42
+ pool = new Pool(poolConfig);
43
+ });
44
+
45
+ beforeEach(async () => {
46
+ await pool.query("DELETE FROM lattice_channel_reply_jobs");
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await pool.end();
51
+ await store.dispose();
52
+
53
+ const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
54
+ await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
55
+ await admin.end();
56
+ });
57
+
58
+ it("creates the table, indexes and required column types through its migration", async () => {
59
+ const table = await pool.query(
60
+ "SELECT to_regclass('lattice_channel_reply_jobs') AS name",
61
+ );
62
+ expect(table.rows[0].name).toBe("lattice_channel_reply_jobs");
63
+
64
+ const indexes = await pool.query(
65
+ `SELECT indexname FROM pg_indexes
66
+ WHERE schemaname = $1 AND tablename = 'lattice_channel_reply_jobs'`,
67
+ [SCHEMA],
68
+ );
69
+ const names = indexes.rows.map((row) => row.indexname);
70
+ expect(names).toEqual(
71
+ expect.arrayContaining([
72
+ "idx_lattice_channel_reply_jobs_due",
73
+ "idx_lattice_channel_reply_jobs_tenant_thread",
74
+ ]),
75
+ );
76
+
77
+ const columns = await pool.query(
78
+ `SELECT column_name, data_type, is_nullable
79
+ FROM information_schema.columns
80
+ WHERE table_schema = $1 AND table_name = 'lattice_channel_reply_jobs'`,
81
+ [SCHEMA],
82
+ );
83
+ const byName = Object.fromEntries(
84
+ columns.rows.map((row) => [row.column_name, row]),
85
+ );
86
+ expect(byName.run_id.data_type).toBe("text");
87
+ expect(byName.tenant_id.data_type).toBe("character varying");
88
+ for (const column of [
89
+ "adapter_channel",
90
+ "channel_installation_id",
91
+ "input_message_id",
92
+ "created_at",
93
+ "updated_at",
94
+ ]) {
95
+ expect(byName[column].is_nullable).toBe("NO");
96
+ }
97
+ });
98
+
99
+ it("creates a pending job with database-clock timestamps and reads it back scoped by tenant", async () => {
100
+ const created = input();
101
+ const before = Date.now();
102
+ const job = await store.create(created);
103
+
104
+ expect(job).toMatchObject({
105
+ ...created,
106
+ streamCursor: null,
107
+ status: "pending",
108
+ attempts: 0,
109
+ leaseOwner: null,
110
+ leaseExpiresAt: null,
111
+ lastError: null,
112
+ outputMessageId: null,
113
+ deliveredAt: null,
114
+ });
115
+ expect(job.createdAt).toBeGreaterThanOrEqual(before - 1_000);
116
+ expect(job.updatedAt).toBe(job.createdAt);
117
+ await expect(store.get(created.tenantId, created.runId)).resolves.toEqual(job);
118
+ await expect(store.get(randomUUID(), created.runId)).resolves.toBeNull();
119
+ });
120
+
121
+ it("supports non-UUID tenant and run ids", async () => {
122
+ const created = await store.create(
123
+ input({ tenantId: "default", runId: "run-default-1" }),
124
+ );
125
+
126
+ expect(created.tenantId).toBe("default");
127
+ expect(created.runId).toBe("run-default-1");
128
+ await expect(store.get("default", "run-default-1")).resolves.toMatchObject({
129
+ tenantId: "default",
130
+ runId: "run-default-1",
131
+ });
132
+ await expect(
133
+ store.claim("default", "run-default-1", "owner-a", 1_000),
134
+ ).resolves.not.toBeNull();
135
+ });
136
+
137
+ it("round-trips nested reply targets through JSONB", async () => {
138
+ const replyTarget = {
139
+ chatId: "chat-1",
140
+ frame: { id: "frame-1", nested: [1, 2, { ok: true }] },
141
+ text: "héllo",
142
+ };
143
+ const created = await store.create(input({ replyTarget }));
144
+
145
+ const loaded = await store.get(created.tenantId, created.runId);
146
+ expect(loaded?.replyTarget).toEqual(replyTarget);
147
+ });
148
+
149
+ it("rejects duplicate run ids and missing required fields", async () => {
150
+ const created = await store.create(input());
151
+
152
+ await expect(
153
+ store.create(input({ tenantId: created.tenantId, runId: created.runId })),
154
+ ).rejects.toThrow();
155
+ await expect(
156
+ store.create(input({ inputMessageId: undefined as unknown as string })),
157
+ ).rejects.toThrow(/inputMessageId/);
158
+ await expect(
159
+ store.create(input({ adapterChannel: "" })),
160
+ ).rejects.toThrow(/adapterChannel/);
161
+ });
162
+
163
+ it("lets exactly one concurrent claim win using the database clock", async () => {
164
+ const created = await store.create(input());
165
+ const before = Date.now();
166
+
167
+ const claims = await Promise.all([
168
+ store.claim(created.tenantId, created.runId, "owner-a", 1_000, 1),
169
+ store.claim(created.tenantId, created.runId, "owner-b", 1_000, 1),
170
+ ]);
171
+
172
+ const winners = claims.filter((job) => job !== null);
173
+ expect(winners).toHaveLength(1);
174
+ const winner = winners[0]!;
175
+ expect(winner.leaseExpiresAt).toBeGreaterThanOrEqual(before + 500);
176
+ expect((await store.get(created.tenantId, created.runId))?.leaseOwner).toBe(
177
+ winner.leaseOwner,
178
+ );
179
+ });
180
+
181
+ it("does not claim a job before it is available", async () => {
182
+ const created = await store.create(input({ availableAt: Date.now() + 60_000 }));
183
+
184
+ await expect(
185
+ store.claim(created.tenantId, created.runId, "owner-a", 1_000),
186
+ ).resolves.toBeNull();
187
+ });
188
+
189
+ it("reschedules for the lease owner and then allows a later claim", async () => {
190
+ const created = await store.create(input());
191
+ await store.claim(created.tenantId, created.runId, "owner-a", 1_000, 1);
192
+
193
+ await expect(
194
+ store.reschedule(created.tenantId, created.runId, "owner-b", "nope", Date.now(), 1),
195
+ ).resolves.toBeNull();
196
+
197
+ const availableAt = Date.now() - 1;
198
+ const rescheduled = await store.reschedule(
199
+ created.tenantId,
200
+ created.runId,
201
+ "owner-a",
202
+ "temporary failure",
203
+ availableAt,
204
+ 1,
205
+ );
206
+ expect(rescheduled).toMatchObject({
207
+ status: "pending",
208
+ attempts: 1,
209
+ leaseOwner: null,
210
+ leaseExpiresAt: null,
211
+ lastError: "temporary failure",
212
+ availableAt,
213
+ });
214
+ expect(rescheduled!.updatedAt).toBeGreaterThan(1_000);
215
+
216
+ await expect(
217
+ store.claim(created.tenantId, created.runId, "owner-b", 1_000),
218
+ ).resolves.not.toBeNull();
219
+ });
220
+
221
+ it("marks a job delivered only for the lease owner", async () => {
222
+ const created = await store.create(input());
223
+ await store.claim(created.tenantId, created.runId, "owner-a", 1_000, 1);
224
+
225
+ await expect(
226
+ store.markDelivered(created.tenantId, created.runId, "owner-b", "out-1", 1),
227
+ ).resolves.toBeNull();
228
+
229
+ const delivered = await store.markDelivered(
230
+ created.tenantId,
231
+ created.runId,
232
+ "owner-a",
233
+ "out-1",
234
+ 1,
235
+ );
236
+ expect(delivered).toMatchObject({
237
+ status: "delivered",
238
+ outputMessageId: "out-1",
239
+ leaseOwner: null,
240
+ leaseExpiresAt: null,
241
+ });
242
+ expect(delivered!.deliveredAt).toBeGreaterThan(1_000);
243
+ expect(delivered!.updatedAt).toBe(delivered!.deliveredAt);
244
+ });
245
+
246
+ it("marks a job failed and deletes it by tenant scope", async () => {
247
+ const created = await store.create(input());
248
+ await store.claim(created.tenantId, created.runId, "owner-a", 1_000, 1);
249
+
250
+ await expect(
251
+ store.markFailed(created.tenantId, created.runId, "owner-b", "nope", 1),
252
+ ).resolves.toBeNull();
253
+
254
+ const failed = await store.markFailed(
255
+ created.tenantId,
256
+ created.runId,
257
+ "owner-a",
258
+ "permanent failure",
259
+ 1,
260
+ );
261
+ expect(failed).toMatchObject({
262
+ status: "failed",
263
+ leaseOwner: null,
264
+ leaseExpiresAt: null,
265
+ lastError: "permanent failure",
266
+ deliveredAt: null,
267
+ });
268
+ expect(failed!.updatedAt).toBeGreaterThan(1_000);
269
+
270
+ await expect(store.delete(randomUUID(), created.runId)).resolves.toBe(false);
271
+ await expect(store.delete(created.tenantId, created.runId)).resolves.toBe(true);
272
+ await expect(store.get(created.tenantId, created.runId)).resolves.toBeNull();
273
+ await expect(store.delete(created.tenantId, created.runId)).resolves.toBe(false);
274
+ });
275
+
276
+ it("prunes delivered and failed jobs older than the retention threshold", async () => {
277
+ const delivered = await store.create(input());
278
+ await store.claim(delivered.tenantId, delivered.runId, "owner-a", 1_000, 1);
279
+ await store.markDelivered(delivered.tenantId, delivered.runId, "owner-a", "run-1", 1_000);
280
+
281
+ const failed = await store.create(input());
282
+ await store.claim(failed.tenantId, failed.runId, "owner-a", 1_000, 1);
283
+ await store.markFailed(failed.tenantId, failed.runId, "owner-a", "dead", 1_000);
284
+
285
+ const fresh = await store.create(input());
286
+ await store.claim(fresh.tenantId, fresh.runId, "owner-a", 1_000, 1);
287
+ await store.markDelivered(fresh.tenantId, fresh.runId, "owner-a", "run-2", 5_000);
288
+
289
+ const pending = await store.create(input());
290
+
291
+ // PostgreSQL uses the database clock for updates; pin timestamps so the
292
+ // retention boundary is deterministic.
293
+ await pool.query(
294
+ "UPDATE lattice_channel_reply_jobs SET updated_at = 1000 WHERE run_id = ANY($1)",
295
+ [[delivered.runId, failed.runId]],
296
+ );
297
+ await pool.query(
298
+ "UPDATE lattice_channel_reply_jobs SET updated_at = 5000 WHERE run_id = $1",
299
+ [fresh.runId],
300
+ );
301
+
302
+ await expect(store.deleteTerminalBefore(2_000, 10)).resolves.toBe(2);
303
+ await expect(store.get(delivered.tenantId, delivered.runId)).resolves.toBeNull();
304
+ await expect(store.get(failed.tenantId, failed.runId)).resolves.toBeNull();
305
+ await expect(store.get(fresh.tenantId, fresh.runId)).resolves.toMatchObject({ status: "delivered" });
306
+ await expect(store.get(pending.tenantId, pending.runId)).resolves.toMatchObject({ status: "pending" });
307
+ await expect(store.deleteTerminalBefore(9_000, 10)).resolves.toBe(1);
308
+ });
309
+
310
+ it("lists due pending jobs with consistent limit clamping", async () => {
311
+ const now = Date.now();
312
+ const first = await store.create(input({ availableAt: now - 3_000 }));
313
+ const second = await store.create(input({ availableAt: now - 2_000 }));
314
+ const third = await store.create(input({ availableAt: now - 1_000 }));
315
+ await store.create(input({ availableAt: now + 60_000 }));
316
+
317
+ expect((await store.listPending(now, 0)).map((job) => job.runId)).toEqual([
318
+ first.runId,
319
+ ]);
320
+ expect((await store.listPending(now, -5)).map((job) => job.runId)).toEqual([
321
+ first.runId,
322
+ ]);
323
+ expect((await store.listPending(now, 2)).map((job) => job.runId)).toEqual([
324
+ first.runId,
325
+ second.runId,
326
+ ]);
327
+ expect((await store.listPending(now, 1_000)).map((job) => job.runId)).toEqual([
328
+ first.runId,
329
+ second.runId,
330
+ third.runId,
331
+ ]);
332
+ });
333
+ });