@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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +187 -0
  3. package/dist/index.d.mts +353 -3
  4. package/dist/index.d.ts +353 -3
  5. package/dist/index.js +2323 -93
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +2329 -92
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/PGVectorStoreProvider.ts +19 -13
  11. package/src/__tests__/AgentRunRetention.migrations.test.ts +41 -0
  12. package/src/__tests__/PostgreSQLAgentRunLedger.test.ts +305 -0
  13. package/src/__tests__/PostgreSQLAgentRunRetention.test.ts +126 -0
  14. package/src/__tests__/PostgreSQLAgentRunTaskQueue.test.ts +1375 -0
  15. package/src/__tests__/PostgreSQLChannelReplyJobStore.test.ts +333 -0
  16. package/src/__tests__/PostgreSQLCheckpointStorage.deleteAfter.test.ts +473 -0
  17. package/src/__tests__/PostgreSQLCheckpointStorage.test.ts +538 -0
  18. package/src/__tests__/PostgreSQLStore.test.ts +173 -0
  19. package/src/__tests__/PostgreSQLThreadStore.test.ts +80 -0
  20. package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +21 -0
  21. package/src/__tests__/ThreadMessageQueueStore.test.ts +8 -3
  22. package/src/__tests__/createPgStoreConfig.test.ts +38 -0
  23. package/src/createPgStoreConfig.ts +36 -1
  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/model_provider_migrations.ts +27 -0
  36. package/src/migrations/store_migrations.ts +50 -0
  37. package/src/migrations/thread_migrations.ts +25 -0
  38. package/src/stores/PostgreSQLAgentRunLedger.ts +1210 -0
  39. package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
  40. package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
  41. package/src/stores/PostgreSQLModelProviderStore.ts +25 -7
  42. package/src/stores/PostgreSQLStore.ts +167 -0
  43. package/src/stores/PostgreSQLThreadStore.ts +49 -10
  44. package/src/stores/ThreadMessageQueueStore.ts +10 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "3.3.12",
3
+ "version": "3.4.0",
4
4
  "description": "PG stores implementation for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,8 +25,8 @@
25
25
  "@langchain/core": "1.1.30",
26
26
  "pg": "^8.16.3",
27
27
  "uuid": "^9.0.1",
28
- "@axiom-lattice/core": "8.0.2",
29
- "@axiom-lattice/protocols": "4.10.0"
28
+ "@axiom-lattice/core": "9.0.0",
29
+ "@axiom-lattice/protocols": "5.0.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -30,20 +30,26 @@ export class PGVectorStoreProvider implements VectorStoreProvider {
30
30
 
31
31
  async create(params: VectorStoreCreateParams): Promise<any> {
32
32
  const { tableName, embeddingKey } = params;
33
+ const tenantId = params.tenantId ?? "default";
33
34
 
34
- let embeddings;
35
- try {
36
- embeddings = embeddingsLatticeManager.getEmbeddingsClient(embeddingKey);
37
- } catch {
38
- try {
39
- embeddings = embeddingsLatticeManager.getEmbeddingsClient("default");
40
- } catch {
41
- throw new Error(
42
- `No embedding model registered. Register one first:\n` +
43
- ` import { registerEmbeddingsLattice } from "@axiom-lattice/core";\n` +
44
- ` registerEmbeddingsLattice("default", new SomeEmbeddings(...));`
45
- );
46
- }
35
+ // Tenant first, deployment second — the same order every model resolver
36
+ // uses. A tenant model key is `${providerId}/${modelId}` and only resolves
37
+ // in that tenant's namespace.
38
+ const embeddings =
39
+ (await embeddingsLatticeManager.getEmbeddingsClientWithTenant(
40
+ tenantId,
41
+ embeddingKey
42
+ )) ??
43
+ (await embeddingsLatticeManager.getEmbeddingsClientWithTenant(
44
+ tenantId,
45
+ "default"
46
+ ));
47
+
48
+ if (!embeddings) {
49
+ throw new Error(
50
+ `No embedding model "${embeddingKey}" registered for tenant "${tenantId}", ` +
51
+ `and no deployment default. Configure one in the model provider panel.`
52
+ );
47
53
  }
48
54
 
49
55
  return PGVectorStore.initialize(embeddings, {
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+
3
+ const registeredMigrationNames: string[] = [];
4
+
5
+ jest.mock("@axiom-lattice/core", () => ({}));
6
+ jest.mock("pg", () => ({ Pool: class Pool {} }));
7
+ jest.mock("../migrations/migration", () => ({
8
+ MigrationManager: class MigrationManager {
9
+ register(migration: { name: string }): void {
10
+ registeredMigrationNames.push(migration.name);
11
+ }
12
+ async migrate(): Promise<void> {
13
+ throw new Error("stop after migrations");
14
+ }
15
+ },
16
+ }));
17
+
18
+ import { createPgStoreConfig } from "../createPgStoreConfig";
19
+ import { createAgentRunRetentionMigration } from "../migrations/agent_run_retention_migration";
20
+ import { agentRunProjectTaskRunningIndexMigration } from "../migrations/agent_run_project_task_running_index_migration";
21
+
22
+ describe("agent run retention migration", () => {
23
+ it("is centrally registered exactly once", async () => {
24
+ expect(createAgentRunRetentionMigration.version).toBe(201);
25
+ expect(createAgentRunRetentionMigration.name).toBe("add_agent_run_retention_index");
26
+
27
+ registeredMigrationNames.length = 0;
28
+ await expect(createPgStoreConfig("postgres://unused")).rejects.toThrow(
29
+ "stop after migrations",
30
+ );
31
+
32
+ expect(
33
+ registeredMigrationNames.filter(
34
+ (name) => name === createAgentRunRetentionMigration.name,
35
+ ),
36
+ ).toHaveLength(1);
37
+ expect(registeredMigrationNames.at(-1)).toBe(
38
+ agentRunProjectTaskRunningIndexMigration.name,
39
+ );
40
+ });
41
+ });
@@ -0,0 +1,305 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+
4
+ // The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
5
+ // transitive deps), so mock it with the real ledger module.
6
+ jest.mock("@axiom-lattice/core", () => ({
7
+ ...jest.requireActual("../../../core/src/services/AgentRunLedger"),
8
+ }));
9
+
10
+ import { PostgreSQLAgentRunLedger } from "../stores/PostgreSQLAgentRunLedger";
11
+
12
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
13
+ const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
14
+ const TENANT_PREFIX = "pg-agent-run-ledger:";
15
+ // Each Jest worker gets its own schema so global reads (`counts`) and cleanup
16
+ // cannot observe another suite's rows when suites run in parallel.
17
+ const SCHEMA = `agent_run_ledger_test_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
18
+
19
+ function testTenantId(): string {
20
+ return `${TENANT_PREFIX}${randomUUID()}`;
21
+ }
22
+
23
+ maybeDescribe("PostgreSQLAgentRunLedger (PostgreSQL)", () => {
24
+ let store: PostgreSQLAgentRunLedger;
25
+ let cleanupPool: Pool;
26
+
27
+ beforeAll(async () => {
28
+ const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
29
+ await admin.query(`CREATE SCHEMA ${SCHEMA}`);
30
+ await admin.end();
31
+
32
+ const poolConfig = {
33
+ connectionString: TEST_DATABASE_URL!,
34
+ options: `-c search_path=${SCHEMA}`,
35
+ };
36
+ store = new PostgreSQLAgentRunLedger({
37
+ poolConfig,
38
+ autoMigrate: false,
39
+ });
40
+ await store.initialize();
41
+ cleanupPool = new Pool(poolConfig);
42
+ });
43
+
44
+ beforeEach(async () => {
45
+ await cleanupPool.query(
46
+ "DELETE FROM lattice_agent_runs WHERE tenant_id LIKE $1",
47
+ [`${TENANT_PREFIX}%`],
48
+ );
49
+ });
50
+
51
+ afterAll(async () => {
52
+ await cleanupPool.end();
53
+ await store.dispose();
54
+
55
+ const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
56
+ await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
57
+ await admin.end();
58
+ });
59
+
60
+ function input(overrides: Partial<Parameters<typeof store.beginRun>[0]> = {}) {
61
+ const tenantId = testTenantId();
62
+ const threadId = randomUUID();
63
+ return {
64
+ runId: randomUUID(),
65
+ tenantId,
66
+ workspaceId: randomUUID(),
67
+ projectId: randomUUID(),
68
+ assistantId: randomUUID(),
69
+ threadId,
70
+ owner: "replica-a",
71
+ leaseMs: 60_000,
72
+ inputMessageIds: [randomUUID(), randomUUID()],
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ async function expire(runId: string): Promise<void> {
78
+ await cleanupPool.query(
79
+ `UPDATE lattice_agent_runs
80
+ SET lease_expires_at = now() - interval '1 second'
81
+ WHERE run_id = $1`,
82
+ [runId],
83
+ );
84
+ }
85
+
86
+ it("inserts a running run with owner, lease and input message ids", async () => {
87
+ const begin = input();
88
+ const record = await store.beginRun(begin);
89
+
90
+ expect(record.runId).toBe(begin.runId);
91
+ expect(record.tenantId).toBe(begin.tenantId);
92
+ expect(record.workspaceId).toBe(begin.workspaceId);
93
+ expect(record.projectId).toBe(begin.projectId);
94
+ expect(record.assistantId).toBe(begin.assistantId);
95
+ expect(record.threadId).toBe(begin.threadId);
96
+ expect(record.status).toBe("running");
97
+ expect(record.owner).toBe(begin.owner);
98
+ expect(record.inputMessageIds).toEqual(begin.inputMessageIds);
99
+ expect(record.attempts).toBe(1);
100
+ expect(record.error).toBeNull();
101
+ expect(record.cancelRequested).toBe(false);
102
+ expect(record.finishedAt).toBeNull();
103
+ expect(record.leaseExpiresAt).toBeGreaterThan(Date.now());
104
+ expect(typeof record.createdAt).toBe("number");
105
+ expect(typeof record.updatedAt).toBe("number");
106
+ expect(typeof record.startedAt).toBe("number");
107
+ });
108
+
109
+ it("reconciles a stale live running row when a new lease holder begins", async () => {
110
+ const first = input();
111
+ await store.beginRun(first);
112
+
113
+ // The new caller holds the thread-run lease, so the previous row is stale
114
+ // even though its ledger lease has not expired.
115
+ const second = await store.beginRun(
116
+ input({ tenantId: first.tenantId, threadId: first.threadId, owner: "replica-b" }),
117
+ );
118
+
119
+ const reconciled = await store.getRun(first.tenantId, first.runId);
120
+ expect(reconciled?.status).toBe("interrupted");
121
+ expect(reconciled?.error).toBe("superseded by a new run on the same thread");
122
+ expect(reconciled?.finishedAt).not.toBeNull();
123
+ expect(second.status).toBe("running");
124
+ expect((await store.findRunning(first.tenantId, first.threadId))?.runId).toBe(second.runId);
125
+ });
126
+
127
+ it("reaps an expired running row before inserting the next run", async () => {
128
+ const first = input();
129
+ await store.beginRun(first);
130
+ await expire(first.runId);
131
+
132
+ const second = await store.beginRun(
133
+ input({ tenantId: first.tenantId, threadId: first.threadId }),
134
+ );
135
+
136
+ const reaped = await store.getRun(first.tenantId, first.runId);
137
+ expect(reaped?.status).toBe("interrupted");
138
+ expect(reaped?.finishedAt).not.toBeNull();
139
+ expect(second.status).toBe("running");
140
+ expect((await store.findRunning(first.tenantId, first.threadId))?.runId).toBe(second.runId);
141
+ });
142
+
143
+ it("heartbeats only for the current owner", async () => {
144
+ const begin = input();
145
+ const record = await store.beginRun(begin);
146
+
147
+ await expect(store.heartbeatRun(record.runId, "someone-else", 120_000))
148
+ .resolves.toBe(false);
149
+ await expect(store.heartbeatRun(record.runId, begin.owner, 120_000))
150
+ .resolves.toBe(true);
151
+
152
+ const updated = await store.getRun(begin.tenantId, begin.runId);
153
+ expect(updated?.leaseExpiresAt).toBeGreaterThan(record.leaseExpiresAt);
154
+ });
155
+
156
+ it("settles only for the owner and only once", async () => {
157
+ const begin = input();
158
+ await store.beginRun(begin);
159
+
160
+ await expect(store.settleRun(begin.runId, "someone-else", "error", "nope"))
161
+ .resolves.toBe(false);
162
+ await expect(store.settleRun(begin.runId, begin.owner, "error", "boom"))
163
+ .resolves.toBe(true);
164
+ await expect(store.settleRun(begin.runId, begin.owner, "success"))
165
+ .resolves.toBe(false);
166
+
167
+ const settled = await store.getRun(begin.tenantId, begin.runId);
168
+ expect(settled?.status).toBe("error");
169
+ expect(settled?.error).toBe("boom");
170
+ expect(settled?.finishedAt).not.toBeNull();
171
+ });
172
+
173
+ it("reports not_found and terminal cancellations", async () => {
174
+ const tenantId = testTenantId();
175
+ await expect(store.requestCancel(tenantId, randomUUID())).resolves.toBe("not_found");
176
+
177
+ const begin = input({ tenantId });
178
+ await store.beginRun(begin);
179
+ await store.settleRun(begin.runId, begin.owner, "success");
180
+
181
+ await expect(store.requestCancel(tenantId, begin.runId)).resolves.toBe("terminal");
182
+ });
183
+
184
+ it("flags a live running run for cancellation", async () => {
185
+ const begin = input();
186
+ await store.beginRun(begin);
187
+
188
+ await expect(store.requestCancel(begin.tenantId, begin.runId)).resolves.toBe("requested");
189
+
190
+ const record = await store.getRun(begin.tenantId, begin.runId);
191
+ expect(record?.status).toBe("running");
192
+ expect(record?.cancelRequested).toBe(true);
193
+ });
194
+
195
+ it("interrupts an expired running run immediately", async () => {
196
+ const begin = input();
197
+ await store.beginRun(begin);
198
+ await expire(begin.runId);
199
+
200
+ await expect(store.requestCancel(begin.tenantId, begin.runId)).resolves.toBe("cancelled");
201
+
202
+ const record = await store.getRun(begin.tenantId, begin.runId);
203
+ expect(record?.status).toBe("interrupted");
204
+ expect(record?.cancelRequested).toBe(true);
205
+ expect(record?.finishedAt).not.toBeNull();
206
+ });
207
+
208
+ it("reaps expired running rows with their scope", async () => {
209
+ const first = input();
210
+ const second = input();
211
+ await store.beginRun(first);
212
+ await store.beginRun(second);
213
+ await expire(first.runId);
214
+ await expire(second.runId);
215
+
216
+ const reaped = await store.reapLegacyExpired!();
217
+
218
+ expect(reaped).toEqual(
219
+ expect.arrayContaining([
220
+ {
221
+ runId: first.runId,
222
+ tenantId: first.tenantId,
223
+ assistantId: first.assistantId,
224
+ threadId: first.threadId,
225
+ workspaceId: first.workspaceId,
226
+ projectId: first.projectId,
227
+ },
228
+ {
229
+ runId: second.runId,
230
+ tenantId: second.tenantId,
231
+ assistantId: second.assistantId,
232
+ threadId: second.threadId,
233
+ workspaceId: second.workspaceId,
234
+ projectId: second.projectId,
235
+ },
236
+ ]),
237
+ );
238
+
239
+ const record = await store.getRun(first.tenantId, first.runId);
240
+ expect(record?.status).toBe("interrupted");
241
+ expect(record?.error).toBe("run owner lost");
242
+ expect(record?.finishedAt).not.toBeNull();
243
+ });
244
+
245
+ it("lists a thread's runs newest first with clamped pagination", async () => {
246
+ const first = input();
247
+ await store.beginRun(first);
248
+ await store.settleRun(first.runId, first.owner, "success");
249
+
250
+ const second = await store.beginRun(
251
+ input({ tenantId: first.tenantId, threadId: first.threadId }),
252
+ );
253
+
254
+ const runs = await store.listRuns(first.tenantId, first.threadId, { limit: 500 });
255
+ expect(runs.map((run) => run.runId)).toEqual([second.runId, first.runId]);
256
+
257
+ const page = await store.listRuns(first.tenantId, first.threadId, {
258
+ limit: 0,
259
+ offset: 1,
260
+ });
261
+ expect(page.map((run) => run.runId)).toEqual([first.runId]);
262
+ });
263
+
264
+ it("maps SQL NULL workspace/project ids to undefined", async () => {
265
+ const begin = input({ workspaceId: undefined, projectId: undefined });
266
+ await store.beginRun(begin);
267
+
268
+ const record = await store.getRun(begin.tenantId, begin.runId);
269
+ expect(record?.workspaceId).toBeUndefined();
270
+ expect(record?.projectId).toBeUndefined();
271
+ });
272
+
273
+ it("counts runs by status and reports zero for missing statuses", async () => {
274
+ await expect(store.counts()).resolves.toEqual({
275
+ pending: 0,
276
+ running: 0,
277
+ success: 0,
278
+ error: 0,
279
+ interrupted: 0,
280
+ cancelled: 0,
281
+ timeout: 0,
282
+ });
283
+
284
+ const running = input();
285
+ await store.beginRun(running);
286
+
287
+ const success = input();
288
+ await store.beginRun(success);
289
+ await store.settleRun(success.runId, success.owner, "success");
290
+
291
+ const error = input();
292
+ await store.beginRun(error);
293
+ await store.settleRun(error.runId, error.owner, "error");
294
+
295
+ await expect(store.counts()).resolves.toEqual({
296
+ pending: 0,
297
+ running: 1,
298
+ success: 1,
299
+ error: 1,
300
+ interrupted: 0,
301
+ cancelled: 0,
302
+ timeout: 0,
303
+ });
304
+ });
305
+ });
@@ -0,0 +1,126 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+
4
+ jest.mock("@axiom-lattice/core", () => ({
5
+ ...jest.requireActual("../../../core/src/services/AgentRunLedger"),
6
+ }));
7
+
8
+ import { PostgreSQLAgentRunLedger } from "../stores/PostgreSQLAgentRunLedger";
9
+
10
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
11
+ const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
12
+ const TENANT = `pg-agent-run-retention:${randomUUID()}`;
13
+ const SCHEMA = `agent_run_retention_test_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
14
+ const CUTOFF = Date.now() - 30 * 24 * 60 * 60 * 1000;
15
+
16
+ maybeDescribe("PostgreSQLAgentRunLedger.deleteTerminalBefore (PostgreSQL)", () => {
17
+ let store: PostgreSQLAgentRunLedger;
18
+ let cleanupPool: Pool;
19
+
20
+ beforeAll(async () => {
21
+ const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
22
+ await admin.query(`CREATE SCHEMA ${SCHEMA}`);
23
+ await admin.end();
24
+
25
+ const poolConfig = {
26
+ connectionString: TEST_DATABASE_URL!,
27
+ options: `-c search_path=${SCHEMA}`,
28
+ };
29
+ store = new PostgreSQLAgentRunLedger({ poolConfig, autoMigrate: false });
30
+ await store.initialize();
31
+ cleanupPool = new Pool(poolConfig);
32
+ });
33
+
34
+ afterAll(async () => {
35
+ await cleanupPool.end();
36
+ await store.dispose();
37
+
38
+ const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
39
+ await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
40
+ await admin.end();
41
+ });
42
+
43
+ beforeEach(async () => {
44
+ await cleanupPool.query(
45
+ "DELETE FROM lattice_agent_runs WHERE tenant_id = $1",
46
+ [TENANT],
47
+ );
48
+ });
49
+
50
+ async function terminal(
51
+ status: "success" | "error" | "interrupted" | "cancelled" | "timeout",
52
+ finishedAt: number,
53
+ ): Promise<string> {
54
+ const runId = randomUUID();
55
+ const input = {
56
+ runId,
57
+ tenantId: TENANT,
58
+ assistantId: randomUUID(),
59
+ threadId: randomUUID(),
60
+ owner: "replica-a",
61
+ leaseMs: 60_000,
62
+ inputMessageIds: [randomUUID()],
63
+ };
64
+ await store.beginRun(input);
65
+ await store.settleRun(runId, input.owner, status);
66
+ await cleanupPool.query(
67
+ "UPDATE lattice_agent_runs SET finished_at = to_timestamp($2::double precision / 1000) WHERE run_id = $1",
68
+ [runId, finishedAt],
69
+ );
70
+ return runId;
71
+ }
72
+
73
+ it("deletes only terminal rows before the cutoff and reports the count", async () => {
74
+ const oldSuccess = await terminal("success", CUTOFF - 2_000);
75
+ const oldTimeout = await terminal("timeout", CUTOFF - 1_000);
76
+ const atCutoff = await terminal("error", CUTOFF);
77
+ const newerCancelled = await terminal("cancelled", CUTOFF + 1_000);
78
+
79
+ const pendingId = randomUUID();
80
+ await store.createPendingRun({
81
+ runId: pendingId,
82
+ tenantId: TENANT,
83
+ assistantId: randomUUID(),
84
+ threadId: randomUUID(),
85
+ input: { id: randomUUID() },
86
+ });
87
+ const runningId = randomUUID();
88
+ await store.beginRun({
89
+ runId: runningId,
90
+ tenantId: TENANT,
91
+ assistantId: randomUUID(),
92
+ threadId: randomUUID(),
93
+ owner: "replica-a",
94
+ leaseMs: 60_000,
95
+ inputMessageIds: [randomUUID()],
96
+ });
97
+ await cleanupPool.query(
98
+ "UPDATE lattice_agent_runs SET created_at = now() - interval '60 days', updated_at = now() - interval '60 days' WHERE run_id = ANY($1::uuid[])",
99
+ [[pendingId, runningId]],
100
+ );
101
+
102
+ await expect(store.deleteTerminalBefore(CUTOFF, 100)).resolves.toBe(2);
103
+
104
+ await expect(store.getRun(TENANT, oldSuccess)).resolves.toBeUndefined();
105
+ await expect(store.getRun(TENANT, oldTimeout)).resolves.toBeUndefined();
106
+ await expect(store.getRun(TENANT, atCutoff)).resolves.toMatchObject({ status: "error" });
107
+ await expect(store.getRun(TENANT, newerCancelled)).resolves.toMatchObject({ status: "cancelled" });
108
+ await expect(store.getRun(TENANT, pendingId)).resolves.toMatchObject({ status: "pending" });
109
+ await expect(store.getRun(TENANT, runningId)).resolves.toMatchObject({ status: "running" });
110
+ });
111
+
112
+ it("deletes oldest terminal rows first and respects the limit", async () => {
113
+ const oldest = await terminal("error", CUTOFF - 3_000);
114
+ const middle = await terminal("interrupted", CUTOFF - 2_000);
115
+ const newest = await terminal("success", CUTOFF - 1_000);
116
+
117
+ await expect(store.deleteTerminalBefore(CUTOFF, 2)).resolves.toBe(2);
118
+
119
+ await expect(store.getRun(TENANT, oldest)).resolves.toBeUndefined();
120
+ await expect(store.getRun(TENANT, middle)).resolves.toBeUndefined();
121
+ await expect(store.getRun(TENANT, newest)).resolves.toMatchObject({ status: "success" });
122
+
123
+ await expect(store.deleteTerminalBefore(CUTOFF, 2)).resolves.toBe(1);
124
+ await expect(store.getRun(TENANT, newest)).resolves.toBeUndefined();
125
+ });
126
+ });