@axiom-lattice/pg-stores 3.1.0 → 3.1.2
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 +18 -0
- package/dist/index.d.mts +283 -23
- package/dist/index.d.ts +283 -23
- package/dist/index.js +1867 -224
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1854 -217
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ChannelBindingStore.test.ts +122 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.migrations.test.ts +67 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +383 -0
- package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
- package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
- package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
- package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
- package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
- package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +107 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +162 -1
- package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +209 -4
- package/src/__tests__/add_workspace_project_to_queue.test.ts +15 -0
- package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
- package/src/__tests__/task-files.test.ts +4 -3
- package/src/createPgStoreConfig.ts +25 -1
- package/src/index.ts +13 -0
- package/src/migrations/add_trusted_run_context_column.ts +18 -0
- package/src/migrations/capability_bundle_migration.ts +20 -0
- package/src/migrations/migration.ts +2 -1
- package/src/migrations/project_room_migration.ts +128 -0
- package/src/migrations/task_migration.ts +15 -0
- package/src/migrations/task_work_items_migration.ts +45 -1
- package/src/stores/ChannelBindingStore.ts +99 -59
- package/src/stores/PostgreSQLCapabilityBundleStore.ts +306 -0
- package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
- package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
- package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
- package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
- package/src/stores/PostgreSQLProjectStore.ts +230 -50
- package/src/stores/PostgreSQLTaskStore.ts +89 -3
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +198 -8
- package/src/stores/ThreadMessageQueueStore.ts +130 -32
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ProjectRoom, ProjectRoomStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
type ProjectRoomRow = {
|
|
5
|
+
id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown;
|
|
6
|
+
type: unknown; name: unknown; created_at: unknown; updated_at: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function isValidDate(value: unknown): value is Date {
|
|
10
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mapRow(row: ProjectRoomRow): ProjectRoom {
|
|
14
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string"
|
|
15
|
+
|| typeof row.workspace_id !== "string" || typeof row.project_id !== "string"
|
|
16
|
+
|| row.type !== "main" || typeof row.name !== "string"
|
|
17
|
+
|| !isValidDate(row.created_at) || !isValidDate(row.updated_at)) {
|
|
18
|
+
throw new Error("Invalid project room row");
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id,
|
|
22
|
+
projectId: row.project_id, type: "main", name: row.name,
|
|
23
|
+
createdAt: row.created_at, updatedAt: row.updated_at,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Persists tenant-isolated canonical project main rooms in PostgreSQL. */
|
|
28
|
+
export class PostgreSQLProjectRoomStore implements ProjectRoomStore {
|
|
29
|
+
/** Creates a store using an externally managed pool; the pool is not migrated or closed. */
|
|
30
|
+
constructor(options: { pool: Pool }) { this.pool = options.pool; }
|
|
31
|
+
private readonly pool: Pool;
|
|
32
|
+
|
|
33
|
+
/** Creates or returns the single persisted main room for a project. */
|
|
34
|
+
async ensureMainRoom(input: {
|
|
35
|
+
id: string; tenantId: string; workspaceId: string; projectId: string; name: string;
|
|
36
|
+
}): Promise<ProjectRoom> {
|
|
37
|
+
const result = await this.pool.query<ProjectRoomRow>(
|
|
38
|
+
`INSERT INTO lattice_project_rooms
|
|
39
|
+
(id, tenant_id, workspace_id, project_id, type, name)
|
|
40
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
41
|
+
ON CONFLICT (tenant_id, project_id, type)
|
|
42
|
+
DO UPDATE SET updated_at = lattice_project_rooms.updated_at
|
|
43
|
+
RETURNING id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at`,
|
|
44
|
+
[input.id, input.tenantId, input.workspaceId, input.projectId, "main", input.name],
|
|
45
|
+
);
|
|
46
|
+
return mapRow(result.rows[0]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Finds a tenant-scoped project's main room, or returns null. */
|
|
50
|
+
async getMainRoom(tenantId: string, projectId: string): Promise<ProjectRoom | null> {
|
|
51
|
+
const result = await this.pool.query<ProjectRoomRow>(
|
|
52
|
+
"SELECT id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at FROM lattice_project_rooms WHERE tenant_id = $1 AND project_id = $2 AND type = 'main'",
|
|
53
|
+
[tenantId, projectId],
|
|
54
|
+
);
|
|
55
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -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
|
}
|
|
@@ -11,11 +11,18 @@ import type {
|
|
|
11
11
|
UpdateTaskRequest,
|
|
12
12
|
TaskListFilter,
|
|
13
13
|
TaskFileRef,
|
|
14
|
+
TaskDependentListQuery,
|
|
15
|
+
TaskMutationSnapshot,
|
|
14
16
|
} from "@axiom-lattice/protocols";
|
|
15
17
|
import { MigrationManager } from "../migrations/migration";
|
|
16
|
-
import { createTasksTable, addTaskFieldsMigration, addTaskProjectFieldsMigration, addFilesToTasks } from "../migrations/task_migration";
|
|
18
|
+
import { createTasksTable, addTaskFieldsMigration, addTaskProjectFieldsMigration, addFilesToTasks, addTaskDependenciesGinIndex } from "../migrations/task_migration";
|
|
17
19
|
import { v4 as uuidv4 } from "uuid";
|
|
18
20
|
|
|
21
|
+
const TASK_STATUSES: ReadonlySet<string> = new Set([
|
|
22
|
+
"pending", "in_progress", "review", "failed", "interrupted", "completed", "cancelled",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
|
|
19
26
|
interface TaskRow {
|
|
20
27
|
id: string;
|
|
21
28
|
tenant_id: string;
|
|
@@ -131,6 +138,11 @@ function mapRowToTask(row: TaskRow): TaskItem {
|
|
|
131
138
|
};
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
function assertPage(limit: number, offset = 0): void {
|
|
142
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100
|
|
143
|
+
|| !Number.isSafeInteger(offset) || offset < 0) throw new RangeError("Invalid task page");
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
export interface PostgreSQLTaskStoreOptions {
|
|
135
147
|
pool?: Pool;
|
|
136
148
|
poolConfig?: string | PoolConfig;
|
|
@@ -166,6 +178,7 @@ export class PostgreSQLTaskStore implements TaskStore {
|
|
|
166
178
|
this.migrationManager.register(addTaskFieldsMigration);
|
|
167
179
|
this.migrationManager.register(addTaskProjectFieldsMigration);
|
|
168
180
|
this.migrationManager.register(addFilesToTasks);
|
|
181
|
+
this.migrationManager.register(addTaskDependenciesGinIndex);
|
|
169
182
|
|
|
170
183
|
if (options.autoMigrate !== false) {
|
|
171
184
|
this.initialize().catch((error) => {
|
|
@@ -274,7 +287,9 @@ export class PostgreSQLTaskStore implements TaskStore {
|
|
|
274
287
|
conditions.push(`workspace_id = $${paramIndex++}`);
|
|
275
288
|
params.push(filter.workspaceId);
|
|
276
289
|
}
|
|
277
|
-
if (filter.projectId) {
|
|
290
|
+
if (filter.projectId === null) {
|
|
291
|
+
conditions.push("(project_id IS NULL OR project_id = '' OR project_id = 'default')");
|
|
292
|
+
} else if (filter.projectId !== undefined) {
|
|
278
293
|
conditions.push(`project_id = $${paramIndex++}`);
|
|
279
294
|
params.push(filter.projectId);
|
|
280
295
|
}
|
|
@@ -301,12 +316,30 @@ export class PostgreSQLTaskStore implements TaskStore {
|
|
|
301
316
|
const offset = filter.offset || 0;
|
|
302
317
|
|
|
303
318
|
const result = await this.pool.query<TaskRow>(
|
|
304
|
-
`SELECT * FROM lattice_tasks WHERE ${where} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
319
|
+
`SELECT * FROM lattice_tasks WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
305
320
|
[...params, limit, offset],
|
|
306
321
|
);
|
|
307
322
|
return result.rows.map((r) => mapRowToTask(r));
|
|
308
323
|
}
|
|
309
324
|
|
|
325
|
+
/** Lists exact project tasks containing a JSON string dependency. */
|
|
326
|
+
async listDependents(query: TaskDependentListQuery): Promise<TaskItem[]> {
|
|
327
|
+
assertPage(query.limit, query.offset);
|
|
328
|
+
if (query.statuses.length === 0 || query.statuses.some((status) => !TASK_STATUSES.has(status))) {
|
|
329
|
+
throw new RangeError("Invalid task statuses");
|
|
330
|
+
}
|
|
331
|
+
await this.ensureInitialized();
|
|
332
|
+
const result = await this.pool.query<TaskRow>(
|
|
333
|
+
`SELECT * FROM lattice_tasks
|
|
334
|
+
WHERE tenant_id=$1 AND workspace_id=$2 AND project_id=$3
|
|
335
|
+
AND dependencies @> $4::jsonb AND status = ANY($5::text[])
|
|
336
|
+
ORDER BY created_at DESC, id DESC LIMIT $6 OFFSET $7`,
|
|
337
|
+
[query.tenantId, query.workspaceId, query.projectId, JSON.stringify([query.dependencyTaskId]),
|
|
338
|
+
query.statuses, query.limit, query.offset],
|
|
339
|
+
);
|
|
340
|
+
return result.rows.map(mapRowToTask);
|
|
341
|
+
}
|
|
342
|
+
|
|
310
343
|
async update(
|
|
311
344
|
tenantId: string,
|
|
312
345
|
id: string,
|
|
@@ -558,6 +591,45 @@ export class PostgreSQLTaskStore implements TaskStore {
|
|
|
558
591
|
return result.rows[0] ? mapRowToTask(result.rows[0]) : null;
|
|
559
592
|
}
|
|
560
593
|
|
|
594
|
+
/** Atomically update only while status, timestamp, owner, and Project scope match. */
|
|
595
|
+
async updateIfSnapshot(
|
|
596
|
+
tenantId: string, id: string, updates: UpdateTaskRequest, snapshot: TaskMutationSnapshot,
|
|
597
|
+
): Promise<TaskItem | null> {
|
|
598
|
+
await this.ensureInitialized();
|
|
599
|
+
const setClauses: string[] = [];
|
|
600
|
+
const params: unknown[] = [];
|
|
601
|
+
let index = 1;
|
|
602
|
+
const fields: Array<[keyof UpdateTaskRequest, string, boolean?]> = [
|
|
603
|
+
["title", "title"], ["description", "description"], ["status", "status"], ["priority", "priority"],
|
|
604
|
+
["dueDate", "due_date"], ["metadata", "metadata", true], ["files", "files", true],
|
|
605
|
+
["parentId", "parent_id"], ["sourceId", "source_id"], ["context", "context", true],
|
|
606
|
+
["ownerType", "owner_type"], ["ownerId", "owner_id"], ["requireReview", "require_review"],
|
|
607
|
+
["dependencies", "dependencies", true], ["result", "result"], ["failureReason", "failure_reason"],
|
|
608
|
+
["workspaceId", "workspace_id"], ["projectId", "project_id"],
|
|
609
|
+
];
|
|
610
|
+
for (const [field, column, json] of fields) {
|
|
611
|
+
const value = updates[field];
|
|
612
|
+
if (value === undefined) continue;
|
|
613
|
+
setClauses.push(`${column} = $${index++}`);
|
|
614
|
+
params.push(json && value !== null ? JSON.stringify(value) : value);
|
|
615
|
+
}
|
|
616
|
+
if (setClauses.length === 0) return this.getById(tenantId, id);
|
|
617
|
+
setClauses.push(`updated_at = ${nextUpdatedAtSql()}`);
|
|
618
|
+
const predicates = [tenantId, id, snapshot.status, new Date(snapshot.updatedAt).toISOString(), snapshot.ownerType,
|
|
619
|
+
snapshot.ownerId, snapshot.workspaceId, snapshot.projectId];
|
|
620
|
+
const placeholders = predicates.map(() => `$${index++}`);
|
|
621
|
+
params.push(...predicates);
|
|
622
|
+
const result = await this.pool.query<TaskRow>(
|
|
623
|
+
`UPDATE lattice_tasks SET ${setClauses.join(", ")}
|
|
624
|
+
WHERE tenant_id = ${placeholders[0]} AND id = ${placeholders[1]} AND status = ${placeholders[2]}
|
|
625
|
+
AND ${canonicalTimestampSnapshotSql("updated_at", placeholders[3])}
|
|
626
|
+
AND owner_type = ${placeholders[4]} AND owner_id = ${placeholders[5]}
|
|
627
|
+
AND workspace_id IS NOT DISTINCT FROM ${placeholders[6]}
|
|
628
|
+
AND project_id IS NOT DISTINCT FROM ${placeholders[7]} RETURNING *`, params,
|
|
629
|
+
);
|
|
630
|
+
return result.rows[0] ? mapRowToTask(result.rows[0]) : null;
|
|
631
|
+
}
|
|
632
|
+
|
|
561
633
|
/** Atomically update a child only when both child and parent snapshots match. */
|
|
562
634
|
async updateIfStatusUpdatedAtAndParentUpdatedAt(
|
|
563
635
|
tenantId: string,
|
|
@@ -631,6 +703,20 @@ export class PostgreSQLTaskStore implements TaskStore {
|
|
|
631
703
|
return (result.rowCount ?? 0) > 0;
|
|
632
704
|
}
|
|
633
705
|
|
|
706
|
+
/** Atomically delete only while status, timestamp, owner, and Project scope match. */
|
|
707
|
+
async deleteIfSnapshot(tenantId: string, id: string, snapshot: TaskMutationSnapshot): Promise<boolean> {
|
|
708
|
+
await this.ensureInitialized();
|
|
709
|
+
const result = await this.pool.query(
|
|
710
|
+
`DELETE FROM lattice_tasks WHERE tenant_id = $1 AND id = $2 AND status = $3
|
|
711
|
+
AND ${canonicalTimestampSnapshotSql("updated_at", "$4")}
|
|
712
|
+
AND owner_type = $5 AND owner_id = $6
|
|
713
|
+
AND workspace_id IS NOT DISTINCT FROM $7 AND project_id IS NOT DISTINCT FROM $8`,
|
|
714
|
+
[tenantId, id, snapshot.status, new Date(snapshot.updatedAt).toISOString(), snapshot.ownerType,
|
|
715
|
+
snapshot.ownerId, snapshot.workspaceId, snapshot.projectId],
|
|
716
|
+
);
|
|
717
|
+
return (result.rowCount ?? 0) > 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
634
720
|
async dispose(): Promise<void> {
|
|
635
721
|
if (this.ownsPool && this.pool) {
|
|
636
722
|
await this.pool.end();
|