@axiom-lattice/pg-stores 3.0.2 → 3.1.1
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 +21 -0
- package/dist/index.d.mts +91 -11
- package/dist/index.d.ts +91 -11
- package/dist/index.js +619 -86
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +614 -81
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLCapabilityBundleStore.migrations.test.ts +67 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +383 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +33 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +66 -1
- package/src/__tests__/ThreadMessageQueueStore.test.ts +96 -4
- package/src/__tests__/add_workspace_project_to_queue.test.ts +15 -0
- package/src/createPgStoreConfig.ts +11 -1
- package/src/index.ts +6 -0
- package/src/migrations/capability_bundle_migration.ts +20 -0
- package/src/migrations/task_work_items_migration.ts +16 -0
- package/src/stores/PostgreSQLCapabilityBundleStore.ts +306 -0
- package/src/stores/PostgreSQLProjectStore.ts +230 -50
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +39 -0
- package/src/stores/ThreadMessageQueueStore.ts +97 -28
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
import { Pool } from "pg";
|
|
6
6
|
import type { PoolConfig } from "pg";
|
|
7
7
|
import {
|
|
8
|
+
assertGenericProjectConfig,
|
|
8
9
|
ProjectStore,
|
|
9
10
|
Project,
|
|
10
11
|
ProjectFilter,
|
|
11
12
|
CreateProjectRequest,
|
|
12
13
|
UpdateProjectRequest,
|
|
14
|
+
UpdateProjectCapabilityBundlesResult,
|
|
13
15
|
} from "@axiom-lattice/protocols";
|
|
14
16
|
import { MigrationManager } from "../migrations/migration";
|
|
15
17
|
import { createProjectsTable } from "../migrations/project_migrations";
|
|
@@ -218,37 +220,70 @@ export class PostgreSQLProjectStore implements ProjectStore {
|
|
|
218
220
|
id: string,
|
|
219
221
|
data: CreateProjectRequest
|
|
220
222
|
): Promise<Project> {
|
|
223
|
+
assertGenericProjectConfig(data.config);
|
|
221
224
|
await this.ensureInitialized();
|
|
222
225
|
|
|
223
226
|
const now = new Date();
|
|
224
227
|
const kind = data.kind || "business";
|
|
225
228
|
|
|
226
|
-
await this.pool.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
+
const client = await this.pool.connect();
|
|
230
|
+
try {
|
|
231
|
+
await client.query("BEGIN");
|
|
232
|
+
await client.query(
|
|
233
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
|
|
234
|
+
[tenantId],
|
|
235
|
+
);
|
|
236
|
+
await client.query(
|
|
237
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
|
|
238
|
+
[id, tenantId],
|
|
239
|
+
);
|
|
240
|
+
const existing = await client.query<{ config: Record<string, unknown> | null }>(
|
|
241
|
+
"SELECT config FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
|
|
242
|
+
[id, tenantId],
|
|
243
|
+
);
|
|
244
|
+
const ids = existing.rows[0]?.config?.capabilityBundleIds;
|
|
245
|
+
const bundleIds = Array.isArray(ids) && ids.every((bundleId) => typeof bundleId === "string")
|
|
246
|
+
? [...ids].sort()
|
|
247
|
+
: [];
|
|
248
|
+
if (bundleIds.length > 0) {
|
|
249
|
+
await client.query(
|
|
250
|
+
`SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
|
|
251
|
+
FROM unnest($2::text[]) AS bundle_id
|
|
252
|
+
ORDER BY bundle_id`,
|
|
253
|
+
[tenantId, bundleIds],
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const result = await client.query<{
|
|
257
|
+
id: string; tenant_id: string; workspace_id: string; name: string;
|
|
258
|
+
description: string | null; config: unknown | null; kind: string | null;
|
|
259
|
+
created_at: Date; updated_at: Date;
|
|
260
|
+
}>(
|
|
261
|
+
`INSERT INTO lattice_projects (id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at)
|
|
229
262
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
230
263
|
ON CONFLICT (id, tenant_id) DO UPDATE SET
|
|
231
264
|
workspace_id = EXCLUDED.workspace_id,
|
|
232
265
|
name = EXCLUDED.name,
|
|
233
266
|
description = EXCLUDED.description,
|
|
234
|
-
config =
|
|
267
|
+
config = CASE
|
|
268
|
+
WHEN lattice_projects.config ? 'capabilityBundleIds'
|
|
269
|
+
THEN COALESCE(EXCLUDED.config, '{}'::jsonb)
|
|
270
|
+
|| jsonb_build_object('capabilityBundleIds', lattice_projects.config->'capabilityBundleIds')
|
|
271
|
+
ELSE EXCLUDED.config
|
|
272
|
+
END,
|
|
235
273
|
kind = EXCLUDED.kind,
|
|
236
274
|
updated_at = EXCLUDED.updated_at
|
|
275
|
+
RETURNING id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at
|
|
237
276
|
`,
|
|
238
277
|
[id, tenantId, workspaceId, data.name, data.description || null, data.config || null, kind, now, now]
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
kind,
|
|
249
|
-
createdAt: now,
|
|
250
|
-
updatedAt: now,
|
|
251
|
-
};
|
|
278
|
+
);
|
|
279
|
+
await client.query("COMMIT");
|
|
280
|
+
return this.mapRowToProject(result.rows[0]);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await client.query("ROLLBACK");
|
|
283
|
+
throw error;
|
|
284
|
+
} finally {
|
|
285
|
+
client.release();
|
|
286
|
+
}
|
|
252
287
|
}
|
|
253
288
|
|
|
254
289
|
/**
|
|
@@ -263,17 +298,12 @@ export class PostgreSQLProjectStore implements ProjectStore {
|
|
|
263
298
|
id: string,
|
|
264
299
|
updates: UpdateProjectRequest
|
|
265
300
|
): Promise<Project | null> {
|
|
301
|
+
assertGenericProjectConfig(updates.config);
|
|
266
302
|
await this.ensureInitialized();
|
|
267
303
|
|
|
268
|
-
// Get existing project
|
|
269
|
-
const existing = await this.getProjectById(tenantId, id);
|
|
270
|
-
if (!existing) {
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
304
|
// Build update query dynamically based on provided fields
|
|
275
305
|
const updateFields: string[] = [];
|
|
276
|
-
const updateValues:
|
|
306
|
+
const updateValues: unknown[] = [];
|
|
277
307
|
let paramIndex = 1;
|
|
278
308
|
|
|
279
309
|
if (updates.name !== undefined) {
|
|
@@ -287,8 +317,14 @@ export class PostgreSQLProjectStore implements ProjectStore {
|
|
|
287
317
|
}
|
|
288
318
|
|
|
289
319
|
if (updates.config !== undefined) {
|
|
290
|
-
|
|
291
|
-
|
|
320
|
+
const configParam = `$${paramIndex++}`;
|
|
321
|
+
updateFields.push(`config = CASE
|
|
322
|
+
WHEN config ? 'capabilityBundleIds'
|
|
323
|
+
THEN COALESCE(${configParam}::jsonb, '{}'::jsonb)
|
|
324
|
+
|| jsonb_build_object('capabilityBundleIds', config->'capabilityBundleIds')
|
|
325
|
+
ELSE ${configParam}::jsonb
|
|
326
|
+
END`);
|
|
327
|
+
updateValues.push(updates.config);
|
|
292
328
|
}
|
|
293
329
|
|
|
294
330
|
if (updates.kind !== undefined) {
|
|
@@ -298,28 +334,63 @@ export class PostgreSQLProjectStore implements ProjectStore {
|
|
|
298
334
|
|
|
299
335
|
if (updateFields.length === 0) {
|
|
300
336
|
// No fields to update
|
|
301
|
-
return
|
|
337
|
+
return await this.getProjectById(tenantId, id);
|
|
302
338
|
}
|
|
303
339
|
|
|
304
340
|
// Always update updated_at
|
|
305
341
|
updateFields.push(`updated_at = $${paramIndex++}`);
|
|
306
342
|
updateValues.push(new Date());
|
|
307
343
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
344
|
+
updateValues.push(id, tenantId);
|
|
345
|
+
const client = await this.pool.connect();
|
|
346
|
+
try {
|
|
347
|
+
await client.query("BEGIN");
|
|
348
|
+
await client.query(
|
|
349
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
|
|
350
|
+
[tenantId],
|
|
351
|
+
);
|
|
352
|
+
await client.query(
|
|
353
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
|
|
354
|
+
[id, tenantId],
|
|
355
|
+
);
|
|
356
|
+
const existing = await client.query<{ config: Record<string, unknown> | null }>(
|
|
357
|
+
"SELECT config FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
|
|
358
|
+
[id, tenantId],
|
|
359
|
+
);
|
|
360
|
+
if (!existing.rows[0]) {
|
|
361
|
+
await client.query("COMMIT");
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
const ids = existing.rows[0].config?.capabilityBundleIds;
|
|
365
|
+
const bundleIds = Array.isArray(ids) && ids.every((bundleId) => typeof bundleId === "string")
|
|
366
|
+
? [...ids].sort()
|
|
367
|
+
: [];
|
|
368
|
+
if (bundleIds.length > 0) {
|
|
369
|
+
await client.query(
|
|
370
|
+
`SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
|
|
371
|
+
FROM unnest($2::text[]) AS bundle_id
|
|
372
|
+
ORDER BY bundle_id`,
|
|
373
|
+
[tenantId, bundleIds],
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const result = await client.query<{
|
|
377
|
+
id: string; tenant_id: string; workspace_id: string; name: string;
|
|
378
|
+
description: string | null; config: unknown | null; kind: string | null;
|
|
379
|
+
created_at: Date; updated_at: Date;
|
|
380
|
+
}>(
|
|
381
|
+
`UPDATE lattice_projects SET ${updateFields.join(", ")}
|
|
382
|
+
WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
|
|
383
|
+
RETURNING id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at`,
|
|
384
|
+
updateValues,
|
|
385
|
+
);
|
|
386
|
+
await client.query("COMMIT");
|
|
387
|
+
return this.mapRowToProject(result.rows[0]);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
await client.query("ROLLBACK");
|
|
390
|
+
throw error;
|
|
391
|
+
} finally {
|
|
392
|
+
client.release();
|
|
393
|
+
}
|
|
323
394
|
}
|
|
324
395
|
|
|
325
396
|
/**
|
|
@@ -327,15 +398,124 @@ export class PostgreSQLProjectStore implements ProjectStore {
|
|
|
327
398
|
*/
|
|
328
399
|
async deleteProject(tenantId: string, id: string): Promise<boolean> {
|
|
329
400
|
await this.ensureInitialized();
|
|
401
|
+
const client = await this.pool.connect();
|
|
402
|
+
try {
|
|
403
|
+
await client.query("BEGIN");
|
|
404
|
+
await client.query(
|
|
405
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
|
|
406
|
+
[tenantId],
|
|
407
|
+
);
|
|
408
|
+
await client.query(
|
|
409
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
|
|
410
|
+
[id, tenantId],
|
|
411
|
+
);
|
|
412
|
+
const selected = await client.query<{ config: Record<string, unknown> | null }>(
|
|
413
|
+
"SELECT config FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
|
|
414
|
+
[id, tenantId],
|
|
415
|
+
);
|
|
416
|
+
if (!selected.rows[0]) {
|
|
417
|
+
await client.query("COMMIT");
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
const ids = selected.rows[0].config?.capabilityBundleIds;
|
|
421
|
+
const bundleIds = Array.isArray(ids) && ids.every((bundleId) => typeof bundleId === "string")
|
|
422
|
+
? [...ids].sort()
|
|
423
|
+
: [];
|
|
424
|
+
if (bundleIds.length > 0) {
|
|
425
|
+
await client.query(
|
|
426
|
+
`SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
|
|
427
|
+
FROM unnest($2::text[]) AS bundle_id
|
|
428
|
+
ORDER BY bundle_id`,
|
|
429
|
+
[tenantId, bundleIds],
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const result = await client.query(
|
|
433
|
+
"DELETE FROM lattice_projects WHERE id = $1 AND tenant_id = $2",
|
|
434
|
+
[id, tenantId],
|
|
435
|
+
);
|
|
436
|
+
await client.query("COMMIT");
|
|
437
|
+
return result.rowCount !== null && result.rowCount > 0;
|
|
438
|
+
} catch (error) {
|
|
439
|
+
await client.query("ROLLBACK");
|
|
440
|
+
throw error;
|
|
441
|
+
} finally {
|
|
442
|
+
client.release();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
330
445
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
446
|
+
async updateCapabilityBundleIds(tenantId: string, projectId: string, bundleIds: string[], expectedRevisions: Record<string, string> = {}): Promise<UpdateProjectCapabilityBundlesResult> {
|
|
447
|
+
await this.ensureInitialized();
|
|
448
|
+
const client = await this.pool.connect();
|
|
449
|
+
try {
|
|
450
|
+
await client.query("BEGIN");
|
|
451
|
+
await client.query(
|
|
452
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
|
|
453
|
+
[tenantId],
|
|
454
|
+
);
|
|
455
|
+
await client.query(
|
|
456
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
|
|
457
|
+
[projectId, tenantId],
|
|
458
|
+
);
|
|
459
|
+
await client.query(
|
|
460
|
+
"SELECT id FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
|
|
461
|
+
[projectId, tenantId],
|
|
462
|
+
);
|
|
463
|
+
if (bundleIds.length > 0) {
|
|
464
|
+
await client.query(
|
|
465
|
+
`SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
|
|
466
|
+
FROM unnest($2::text[]) AS bundle_id
|
|
467
|
+
ORDER BY bundle_id`,
|
|
468
|
+
[tenantId, [...bundleIds].sort()],
|
|
469
|
+
);
|
|
470
|
+
if (Object.keys(expectedRevisions).length > 0) {
|
|
471
|
+
const revisions = await client.query<{ id: string; updated_at: string }>("SELECT id, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = ANY($2::uuid[]) FOR UPDATE", [tenantId, bundleIds]);
|
|
472
|
+
if (revisions.rows.some((bundle) => expectedRevisions[bundle.id] !== undefined && expectedRevisions[bundle.id] !== bundle.updated_at)) {
|
|
473
|
+
await client.query("ROLLBACK");
|
|
474
|
+
return { status: "bundle_conflict" };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const result = await client.query(
|
|
479
|
+
`UPDATE lattice_projects
|
|
480
|
+
SET config = COALESCE(config, '{}'::jsonb) || jsonb_build_object('capabilityBundleIds', $3::jsonb), updated_at = NOW()
|
|
481
|
+
WHERE id = $1 AND tenant_id = $2
|
|
482
|
+
AND NOT EXISTS (
|
|
483
|
+
SELECT 1 FROM unnest($4::uuid[]) AS bundle_id
|
|
484
|
+
WHERE NOT EXISTS (
|
|
485
|
+
SELECT 1 FROM lattice_capability_bundles
|
|
486
|
+
WHERE tenant_id = $2 AND id = bundle_id
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
RETURNING id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at`,
|
|
490
|
+
[projectId, tenantId, JSON.stringify(bundleIds), bundleIds],
|
|
491
|
+
);
|
|
492
|
+
if (result.rows[0]) {
|
|
493
|
+
await client.query("COMMIT");
|
|
494
|
+
return { status: "updated", project: this.mapRowToProject(result.rows[0]) };
|
|
495
|
+
}
|
|
496
|
+
const project = await client.query(
|
|
497
|
+
"SELECT id FROM lattice_projects WHERE id = $1 AND tenant_id = $2",
|
|
498
|
+
[projectId, tenantId],
|
|
499
|
+
);
|
|
500
|
+
await client.query("COMMIT");
|
|
501
|
+
return project.rows.length > 0 ? { status: "bundle_not_found" } : { status: "project_not_found" };
|
|
502
|
+
} catch (error) {
|
|
503
|
+
await client.query("ROLLBACK");
|
|
504
|
+
throw error;
|
|
505
|
+
} finally {
|
|
506
|
+
client.release();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
338
509
|
|
|
339
|
-
|
|
510
|
+
async isCapabilityBundleReferenced(tenantId: string, bundleId: string): Promise<boolean> {
|
|
511
|
+
await this.ensureInitialized();
|
|
512
|
+
const result = await this.pool.query<{ exists: number }>(
|
|
513
|
+
`SELECT 1 AS exists FROM lattice_projects
|
|
514
|
+
WHERE tenant_id = $1
|
|
515
|
+
AND jsonb_typeof(config->'capabilityBundleIds') = 'array'
|
|
516
|
+
AND COALESCE(config->'capabilityBundleIds', '[]'::jsonb) ? $2
|
|
517
|
+
LIMIT 1`, [tenantId, bundleId],
|
|
518
|
+
);
|
|
519
|
+
return result.rows.length > 0;
|
|
340
520
|
}
|
|
341
521
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Pool } from "pg";
|
|
2
2
|
import type { TaskWorkItemStore, TaskWorkItem, CreateWorkItemRequest, CreateWorkItemIfAbsentRequest, TaskWorkItemListFilter } from "@axiom-lattice/protocols";
|
|
3
|
+
import { MAX_PENDING_EXECUTION_RESULTS_LIMIT } from "@axiom-lattice/protocols";
|
|
3
4
|
import { v4 } from "uuid";
|
|
4
5
|
|
|
5
6
|
export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
|
|
@@ -87,6 +88,44 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
87
88
|
return result.rows.map((row: Record<string, unknown>) => this.rowToItem(row));
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/** List pending execution results using one bounded PostgreSQL anti-join query. */
|
|
92
|
+
async listPendingExecutionResults(params: {
|
|
93
|
+
tenantId: string;
|
|
94
|
+
taskId: string;
|
|
95
|
+
limit: number;
|
|
96
|
+
}): Promise<TaskWorkItem[]> {
|
|
97
|
+
if (!Number.isSafeInteger(params.limit)
|
|
98
|
+
|| params.limit < 0
|
|
99
|
+
|| params.limit > MAX_PENDING_EXECUTION_RESULTS_LIMIT) {
|
|
100
|
+
const error = new RangeError(`limit must be a safe integer between 0 and ${MAX_PENDING_EXECUTION_RESULTS_LIMIT}`) as RangeError & {
|
|
101
|
+
code: "INVALID_LIMIT";
|
|
102
|
+
};
|
|
103
|
+
error.code = "INVALID_LIMIT";
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (params.limit === 0) return [];
|
|
107
|
+
const result = await this.pool.query(
|
|
108
|
+
`SELECT result.*
|
|
109
|
+
FROM lattice_task_work_items AS result
|
|
110
|
+
WHERE result.tenant_id = $1
|
|
111
|
+
AND result.task_id = $2
|
|
112
|
+
AND result.action = 'execution_result'
|
|
113
|
+
AND result.event_key COLLATE "C" ~ '^execution-result:[A-Za-z0-9._:-]+$'
|
|
114
|
+
AND NOT EXISTS (
|
|
115
|
+
SELECT 1
|
|
116
|
+
FROM lattice_task_work_items AS reconciled
|
|
117
|
+
WHERE reconciled.tenant_id = result.tenant_id
|
|
118
|
+
AND reconciled.task_id = result.task_id
|
|
119
|
+
AND reconciled.action = 'execution_reconciled'
|
|
120
|
+
AND reconciled.detail ->> 'executionResultId' = result.event_key
|
|
121
|
+
)
|
|
122
|
+
ORDER BY result.created_at DESC, result.id DESC
|
|
123
|
+
LIMIT $3`,
|
|
124
|
+
[params.tenantId, params.taskId, params.limit],
|
|
125
|
+
);
|
|
126
|
+
return result.rows.map((row: Record<string, unknown>) => this.rowToItem(row));
|
|
127
|
+
}
|
|
128
|
+
|
|
90
129
|
private rowToItem(row: Record<string, unknown>): TaskWorkItem {
|
|
91
130
|
return {
|
|
92
131
|
id: row.id as string,
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
PendingMessage,
|
|
12
12
|
AddMessageParams,
|
|
13
13
|
ThreadInfo,
|
|
14
|
+
QueueScope,
|
|
14
15
|
} from "@axiom-lattice/core";
|
|
15
16
|
import { MigrationManager } from "../migrations/migration";
|
|
16
17
|
import { createThreadMessageQueueTable } from "../migrations/thread_message_queue_migrations";
|
|
@@ -108,12 +109,51 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
108
109
|
(id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
|
|
109
110
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
110
111
|
RETURNING *`,
|
|
111
|
-
[id || crypto.randomUUID(), threadId, tenantId, assistantId, workspaceId
|
|
112
|
+
[id || crypto.randomUUID(), threadId, tenantId, assistantId, workspaceId ?? null, projectId ?? null, JSON.stringify(content), type, nextSeq, priority, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
|
|
112
113
|
);
|
|
113
114
|
|
|
114
115
|
return this.rowToMessage(result.rows[0]);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
async addMessageIfCapacity(params: AddMessageParams, maxSize: number): Promise<boolean> {
|
|
119
|
+
if (maxSize === Infinity) {
|
|
120
|
+
await this.addMessage(params);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
const client = await this.pool.connect();
|
|
124
|
+
try {
|
|
125
|
+
await client.query("BEGIN");
|
|
126
|
+
const scope = { tenantId: params.tenantId, assistantId: params.assistantId, workspaceId: params.workspaceId, projectId: params.projectId };
|
|
127
|
+
const lockKey = `${params.tenantId}:${params.assistantId}:${params.threadId}:${params.workspaceId ?? ""}:${params.projectId ?? ""}`;
|
|
128
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [lockKey]);
|
|
129
|
+
const filter = scopeClause(scope, 2);
|
|
130
|
+
const count = await client.query(
|
|
131
|
+
`SELECT COUNT(*) as count FROM lattice_thread_message_queue WHERE thread_id = $1 AND status = 'pending'${filter.sql}`,
|
|
132
|
+
[params.threadId, ...filter.params],
|
|
133
|
+
);
|
|
134
|
+
if (parseInt(count.rows[0].count, 10) >= maxSize) {
|
|
135
|
+
await client.query("ROLLBACK");
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const seq = await client.query(
|
|
139
|
+
`SELECT COALESCE(MAX(sequence_order), 0) + 1 as next_seq FROM lattice_thread_message_queue WHERE thread_id = $1`,
|
|
140
|
+
[params.threadId],
|
|
141
|
+
);
|
|
142
|
+
const result = await client.query(
|
|
143
|
+
`INSERT INTO lattice_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
|
|
144
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
|
145
|
+
[params.id || crypto.randomUUID(), params.threadId, params.tenantId, params.assistantId, params.workspaceId ?? null, params.projectId ?? null, JSON.stringify(params.content), params.type || "human", seq.rows[0].next_seq, params.priority ?? 0, params.command ? JSON.stringify(params.command) : null, params.custom_run_config ? JSON.stringify(params.custom_run_config) : null],
|
|
146
|
+
);
|
|
147
|
+
await client.query("COMMIT");
|
|
148
|
+
return Boolean(result.rows[0]);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
await client.query("ROLLBACK");
|
|
151
|
+
throw error;
|
|
152
|
+
} finally {
|
|
153
|
+
client.release();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
117
157
|
/**
|
|
118
158
|
* Add message at head of queue (high priority, e.g., STEER/Command messages)
|
|
119
159
|
* Uses priority=100 to ensure message is processed first
|
|
@@ -139,7 +179,7 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
139
179
|
(id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
|
|
140
180
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 100, $10, $11)
|
|
141
181
|
RETURNING *`,
|
|
142
|
-
[id || crypto.randomUUID(), threadId, resolvedTenantId, resolvedAssistantId, workspaceId
|
|
182
|
+
[id || crypto.randomUUID(), threadId, resolvedTenantId, resolvedAssistantId, workspaceId ?? null, projectId ?? null, JSON.stringify(content), type, nextSeq, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
|
|
143
183
|
);
|
|
144
184
|
|
|
145
185
|
return this.rowToMessage(result.rows[0]);
|
|
@@ -148,12 +188,13 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
148
188
|
/**
|
|
149
189
|
* Get pending messages for thread
|
|
150
190
|
*/
|
|
151
|
-
async getPendingMessages(threadId: string): Promise<PendingMessage[]> {
|
|
191
|
+
async getPendingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
|
|
192
|
+
const filter = scopeClause(scope, 2);
|
|
152
193
|
const result = await this.pool.query(
|
|
153
|
-
`SELECT * FROM lattice_thread_message_queue
|
|
154
|
-
|
|
194
|
+
`SELECT * FROM lattice_thread_message_queue
|
|
195
|
+
WHERE thread_id = $1 AND status = 'pending'${filter.sql}
|
|
155
196
|
ORDER BY priority DESC, sequence_order ASC`,
|
|
156
|
-
[threadId]
|
|
197
|
+
[threadId, ...filter.params]
|
|
157
198
|
);
|
|
158
199
|
|
|
159
200
|
return result.rows.map(row => this.rowToMessage(row));
|
|
@@ -162,12 +203,13 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
162
203
|
/**
|
|
163
204
|
* Get processing messages for a thread
|
|
164
205
|
*/
|
|
165
|
-
async getProcessingMessages(threadId: string): Promise<PendingMessage[]> {
|
|
206
|
+
async getProcessingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
|
|
207
|
+
const filter = scopeClause(scope, 2);
|
|
166
208
|
const result = await this.pool.query(
|
|
167
209
|
`SELECT * FROM lattice_thread_message_queue
|
|
168
|
-
|
|
210
|
+
WHERE thread_id = $1 AND status = 'processing'${filter.sql}
|
|
169
211
|
ORDER BY priority DESC, sequence_order ASC`,
|
|
170
|
-
[threadId]
|
|
212
|
+
[threadId, ...filter.params]
|
|
171
213
|
);
|
|
172
214
|
|
|
173
215
|
return result.rows.map(row => this.rowToMessage(row));
|
|
@@ -176,11 +218,12 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
176
218
|
/**
|
|
177
219
|
* Get queue size
|
|
178
220
|
*/
|
|
179
|
-
async getQueueSize(threadId: string): Promise<number> {
|
|
221
|
+
async getQueueSize(threadId: string, scope?: QueueScope): Promise<number> {
|
|
222
|
+
const filter = scopeClause(scope, 2);
|
|
180
223
|
const result = await this.pool.query(
|
|
181
224
|
`SELECT COUNT(*) as count FROM lattice_thread_message_queue
|
|
182
|
-
|
|
183
|
-
[threadId]
|
|
225
|
+
WHERE thread_id = $1 AND status = 'pending'${filter.sql}`,
|
|
226
|
+
[threadId, ...filter.params]
|
|
184
227
|
);
|
|
185
228
|
|
|
186
229
|
return parseInt(result.rows[0].count, 10);
|
|
@@ -191,28 +234,29 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
191
234
|
*/
|
|
192
235
|
async getThreadsWithPendingMessages(): Promise<ThreadInfo[]> {
|
|
193
236
|
const result = await this.pool.query(
|
|
194
|
-
|
|
237
|
+
`SELECT DISTINCT ON (tenant_id, assistant_id, workspace_id, project_id, thread_id) tenant_id, assistant_id, thread_id, workspace_id, project_id
|
|
195
238
|
FROM lattice_thread_message_queue
|
|
196
239
|
WHERE status IN ('pending', 'processing')
|
|
197
|
-
|
|
240
|
+
ORDER BY tenant_id, assistant_id, workspace_id, project_id, thread_id`
|
|
198
241
|
);
|
|
199
242
|
|
|
200
243
|
return result.rows.map(row => ({
|
|
201
244
|
tenantId: row.tenant_id,
|
|
202
245
|
assistantId: row.assistant_id,
|
|
203
246
|
threadId: row.thread_id,
|
|
204
|
-
workspaceId: row.workspace_id
|
|
205
|
-
projectId: row.project_id
|
|
247
|
+
workspaceId: row.workspace_id,
|
|
248
|
+
projectId: row.project_id,
|
|
206
249
|
}));
|
|
207
250
|
}
|
|
208
251
|
|
|
209
252
|
/**
|
|
210
253
|
* Remove message
|
|
211
254
|
*/
|
|
212
|
-
async removeMessage(messageId: string): Promise<boolean> {
|
|
255
|
+
async removeMessage(messageId: string, scope?: QueueScope): Promise<boolean> {
|
|
256
|
+
const filter = scopeClause(scope, 2);
|
|
213
257
|
const result = await this.pool.query(
|
|
214
|
-
`DELETE FROM lattice_thread_message_queue WHERE id = $1 RETURNING id`,
|
|
215
|
-
[messageId]
|
|
258
|
+
`DELETE FROM lattice_thread_message_queue WHERE id = $1${filter.sql} RETURNING id`,
|
|
259
|
+
[messageId, ...filter.params]
|
|
216
260
|
);
|
|
217
261
|
|
|
218
262
|
return (result.rowCount ?? 0) > 0;
|
|
@@ -221,20 +265,30 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
221
265
|
/**
|
|
222
266
|
* Clear all messages for thread
|
|
223
267
|
*/
|
|
224
|
-
async clearMessages(threadId: string): Promise<void> {
|
|
268
|
+
async clearMessages(threadId: string, scope?: QueueScope): Promise<void> {
|
|
269
|
+
const filter = scopeClause(scope, 2);
|
|
225
270
|
await this.pool.query(
|
|
226
|
-
`DELETE FROM lattice_thread_message_queue WHERE thread_id = $1`,
|
|
227
|
-
[threadId]
|
|
271
|
+
`DELETE FROM lattice_thread_message_queue WHERE thread_id = $1${filter.sql}`,
|
|
272
|
+
[threadId, ...filter.params]
|
|
228
273
|
);
|
|
229
274
|
}
|
|
230
275
|
|
|
231
276
|
/**
|
|
232
277
|
* Mark message as processing
|
|
233
278
|
*/
|
|
234
|
-
async markProcessing(messageId: string): Promise<void> {
|
|
279
|
+
async markProcessing(messageId: string, customRunConfig?: unknown, scope?: QueueScope): Promise<void> {
|
|
280
|
+
if (customRunConfig !== undefined) {
|
|
281
|
+
const filter = scopeClause(scope, 3);
|
|
282
|
+
await this.pool.query(
|
|
283
|
+
`UPDATE lattice_thread_message_queue SET status = 'processing', custom_run_config = $2 WHERE id = $1${filter.sql}`,
|
|
284
|
+
[messageId, JSON.stringify(customRunConfig), ...filter.params]
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const filter = scopeClause(scope, 2);
|
|
235
289
|
await this.pool.query(
|
|
236
|
-
`UPDATE lattice_thread_message_queue SET status = 'processing' WHERE id = $1`,
|
|
237
|
-
[messageId]
|
|
290
|
+
`UPDATE lattice_thread_message_queue SET status = 'processing' WHERE id = $1${filter.sql}`,
|
|
291
|
+
[messageId, ...filter.params]
|
|
238
292
|
);
|
|
239
293
|
}
|
|
240
294
|
|
|
@@ -242,13 +296,14 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
242
296
|
* Reset all processing messages to pending state for a thread
|
|
243
297
|
* Returns the number of messages reset
|
|
244
298
|
*/
|
|
245
|
-
async resetProcessingToPending(threadId: string): Promise<number> {
|
|
299
|
+
async resetProcessingToPending(threadId: string, scope?: QueueScope): Promise<number> {
|
|
300
|
+
const filter = scopeClause(scope, 2);
|
|
246
301
|
const result = await this.pool.query(
|
|
247
302
|
`UPDATE lattice_thread_message_queue
|
|
248
303
|
SET status = 'pending'
|
|
249
|
-
|
|
304
|
+
WHERE thread_id = $1 AND status = 'processing'${filter.sql}
|
|
250
305
|
RETURNING id`,
|
|
251
|
-
[threadId]
|
|
306
|
+
[threadId, ...filter.params]
|
|
252
307
|
);
|
|
253
308
|
return result.rowCount ?? 0;
|
|
254
309
|
}
|
|
@@ -270,3 +325,17 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
270
325
|
};
|
|
271
326
|
}
|
|
272
327
|
}
|
|
328
|
+
|
|
329
|
+
function scopeClause(scope: QueueScope | undefined, start: number): { sql: string; params: unknown[] } {
|
|
330
|
+
if (!scope) return { sql: "", params: [] };
|
|
331
|
+
const params: unknown[] = [];
|
|
332
|
+
const entries = (["tenantId", "assistantId", "workspaceId", "projectId"] as const)
|
|
333
|
+
.map((key) => [key, scope[key]] as const);
|
|
334
|
+
const sql = entries.map(([key, value]) => {
|
|
335
|
+
const column = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
336
|
+
if (value == null) return ` AND ${column} IS NULL`;
|
|
337
|
+
params.push(value);
|
|
338
|
+
return ` AND ${column} = $${start + params.length - 1}`;
|
|
339
|
+
}).join("");
|
|
340
|
+
return { sql, params };
|
|
341
|
+
}
|