@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.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.d.mts +283 -23
  4. package/dist/index.d.ts +283 -23
  5. package/dist/index.js +1867 -224
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1854 -217
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/ChannelBindingStore.test.ts +122 -0
  11. package/src/__tests__/PostgreSQLCapabilityBundleStore.migrations.test.ts +67 -0
  12. package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +383 -0
  13. package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
  14. package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
  15. package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
  16. package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
  17. package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
  18. package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
  19. package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
  20. package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
  21. package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +107 -0
  22. package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
  23. package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +162 -1
  24. package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
  25. package/src/__tests__/ThreadMessageQueueStore.test.ts +209 -4
  26. package/src/__tests__/add_workspace_project_to_queue.test.ts +15 -0
  27. package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
  28. package/src/__tests__/task-files.test.ts +4 -3
  29. package/src/createPgStoreConfig.ts +25 -1
  30. package/src/index.ts +13 -0
  31. package/src/migrations/add_trusted_run_context_column.ts +18 -0
  32. package/src/migrations/capability_bundle_migration.ts +20 -0
  33. package/src/migrations/migration.ts +2 -1
  34. package/src/migrations/project_room_migration.ts +128 -0
  35. package/src/migrations/task_migration.ts +15 -0
  36. package/src/migrations/task_work_items_migration.ts +45 -1
  37. package/src/stores/ChannelBindingStore.ts +99 -59
  38. package/src/stores/PostgreSQLCapabilityBundleStore.ts +306 -0
  39. package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
  40. package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
  41. package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
  42. package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
  43. package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
  44. package/src/stores/PostgreSQLProjectStore.ts +230 -50
  45. package/src/stores/PostgreSQLTaskStore.ts +89 -3
  46. package/src/stores/PostgreSQLTaskWorkItemStore.ts +198 -8
  47. 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.query(
227
- `
228
- INSERT INTO lattice_projects (id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at)
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 = EXCLUDED.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
- return {
242
- id,
243
- tenantId,
244
- workspaceId,
245
- name: data.name,
246
- description: data.description,
247
- config: data.config,
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: any[] = [];
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
- updateFields.push(`config = $${paramIndex++}`);
291
- updateValues.push(updates.config || null);
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 existing;
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
- // Add id and tenant_id for WHERE clause
309
- updateValues.push(id);
310
- updateValues.push(tenantId);
311
-
312
- await this.pool.query(
313
- `
314
- UPDATE lattice_projects
315
- SET ${updateFields.join(", ")}
316
- WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
317
- `,
318
- updateValues
319
- );
320
-
321
- // Return updated project
322
- return await this.getProjectById(tenantId, id);
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
- const result = await this.pool.query(
332
- `
333
- DELETE FROM lattice_projects
334
- WHERE id = $1 AND tenant_id = $2
335
- `,
336
- [id, tenantId]
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
- return result.rowCount !== null && result.rowCount > 0;
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();