@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.
@@ -34,6 +34,33 @@ describe('ThreadMessageQueueStore', () => {
34
34
  });
35
35
 
36
36
  describe('addMessage', () => {
37
+ it('atomically rejects enqueue after the scoped pending capacity is reached', async () => {
38
+ const client = {
39
+ query: jest.fn()
40
+ .mockResolvedValueOnce({})
41
+ .mockResolvedValueOnce({})
42
+ .mockResolvedValueOnce({ rows: [{ count: '2' }] })
43
+ .mockResolvedValueOnce({}),
44
+ release: jest.fn(),
45
+ };
46
+ mockConnect.mockResolvedValueOnce(client);
47
+
48
+ await expect(store.addMessageIfCapacity({
49
+ threadId: mockThreadId,
50
+ tenantId: mockTenantId,
51
+ assistantId: mockAssistantId,
52
+ workspaceId: null,
53
+ projectId: null,
54
+ content: 'full',
55
+ }, 2)).resolves.toBe(false);
56
+
57
+ expect(client.query).toHaveBeenCalledWith(
58
+ 'SELECT pg_advisory_xact_lock(hashtext($1))',
59
+ [`${mockTenantId}:${mockAssistantId}:${mockThreadId}::`],
60
+ );
61
+ expect(client.release).toHaveBeenCalled();
62
+ });
63
+
37
64
  it('should add message to queue', async () => {
38
65
  const mockMessage = {
39
66
  id: 'msg-1',
@@ -131,7 +158,6 @@ describe('ThreadMessageQueueStore', () => {
131
158
  it('should insert message with high priority', async () => {
132
159
  // Mock thread info query
133
160
  mockQuery
134
- .mockResolvedValueOnce({ rows: [{ tenant_id: mockTenantId, assistant_id: mockAssistantId }] }) // Get thread info
135
161
  .mockResolvedValueOnce({ rows: [{ next_seq: 1 }] }) // Get next sequence
136
162
  .mockResolvedValueOnce({
137
163
  rows: [{
@@ -158,7 +184,6 @@ describe('ThreadMessageQueueStore', () => {
158
184
 
159
185
  it('should use default tenant/assistant when no existing messages', async () => {
160
186
  mockQuery
161
- .mockResolvedValueOnce({ rows: [] }) // No existing messages
162
187
  .mockResolvedValueOnce({ rows: [{ next_seq: 0 }] }) // Get next sequence
163
188
  .mockResolvedValueOnce({
164
189
  rows: [{
@@ -259,6 +284,53 @@ describe('ThreadMessageQueueStore', () => {
259
284
  expect(result[0].custom_run_config).toEqual(customRunConfig);
260
285
  expect((result[0].content as { command?: unknown }).command).toBeUndefined();
261
286
  });
287
+
288
+ it('matches nullish dimensions as NULL when a scope is supplied', async () => {
289
+ mockQuery.mockResolvedValueOnce({ rows: [] });
290
+
291
+ await store.getPendingMessages(mockThreadId, {
292
+ tenantId: mockTenantId,
293
+ assistantId: mockAssistantId,
294
+ workspaceId: undefined,
295
+ projectId: undefined,
296
+ });
297
+
298
+ expect(mockQuery).toHaveBeenCalledWith(
299
+ expect.stringMatching(/tenant_id = \$2.*assistant_id = \$3.*workspace_id IS NULL.*project_id IS NULL/s),
300
+ [mockThreadId, mockTenantId, mockAssistantId],
301
+ );
302
+ });
303
+
304
+ it('applies null-sensitive full scope predicates to every scoped operation', async () => {
305
+ const scope = {
306
+ tenantId: mockTenantId,
307
+ assistantId: mockAssistantId,
308
+ workspaceId: undefined,
309
+ projectId: undefined,
310
+ };
311
+ mockQuery
312
+ .mockResolvedValueOnce({ rows: [] })
313
+ .mockResolvedValueOnce({ rows: [] })
314
+ .mockResolvedValueOnce({ rows: [{ count: '0' }] })
315
+ .mockResolvedValueOnce({ rowCount: 0 })
316
+ .mockResolvedValueOnce({ rowCount: 0 })
317
+ .mockResolvedValueOnce({ rowCount: 0 })
318
+ .mockResolvedValueOnce({ rowCount: 0 });
319
+
320
+ await store.getProcessingMessages(mockThreadId, scope);
321
+ await store.getPendingMessages(mockThreadId, scope);
322
+ await store.getQueueSize(mockThreadId, scope);
323
+ await store.markProcessing('msg-1', undefined, scope);
324
+ await store.resetProcessingToPending(mockThreadId, scope);
325
+ await store.removeMessage('msg-1', scope);
326
+ await store.clearMessages(mockThreadId, scope);
327
+
328
+ expect(mockQuery).toHaveBeenCalledTimes(7);
329
+ for (const [sql, params] of mockQuery.mock.calls as Array<[string, unknown[]]>) {
330
+ expect(sql).toMatch(/tenant_id = \$2.*assistant_id = \$3.*workspace_id IS NULL.*project_id IS NULL/s);
331
+ expect(params).toEqual(expect.arrayContaining([mockTenantId, mockAssistantId]));
332
+ }
333
+ });
262
334
  });
263
335
 
264
336
  describe('getQueueSize', () => {
@@ -296,6 +368,26 @@ describe('ThreadMessageQueueStore', () => {
296
368
  expect(result[1].threadId).toBe('thread-2');
297
369
  expect(result[2].threadId).toBe('thread-3');
298
370
  });
371
+
372
+ it('preserves NULL workspace and project identity for legacy rows', async () => {
373
+ mockQuery.mockResolvedValueOnce({
374
+ rows: [{
375
+ tenant_id: mockTenantId,
376
+ assistant_id: mockAssistantId,
377
+ thread_id: mockThreadId,
378
+ workspace_id: null,
379
+ project_id: null,
380
+ }],
381
+ });
382
+
383
+ await expect(store.getThreadsWithPendingMessages()).resolves.toEqual([{
384
+ tenantId: mockTenantId,
385
+ assistantId: mockAssistantId,
386
+ threadId: mockThreadId,
387
+ workspaceId: null,
388
+ projectId: null,
389
+ }]);
390
+ });
299
391
  });
300
392
 
301
393
  describe('removeMessage', () => {
@@ -323,7 +415,7 @@ describe('ThreadMessageQueueStore', () => {
323
415
  await store.markProcessing('msg-1');
324
416
 
325
417
  expect(mockQuery).toHaveBeenCalledWith(
326
- expect.stringContaining("UPDATE thread_message_queue"),
418
+ expect.stringContaining("UPDATE lattice_thread_message_queue"),
327
419
  ['msg-1']
328
420
  );
329
421
  });
@@ -336,7 +428,7 @@ describe('ThreadMessageQueueStore', () => {
336
428
  await store.clearMessages(mockThreadId);
337
429
 
338
430
  expect(mockQuery).toHaveBeenCalledWith(
339
- expect.stringContaining("DELETE FROM thread_message_queue"),
431
+ expect.stringContaining("DELETE FROM lattice_thread_message_queue"),
340
432
  [mockThreadId]
341
433
  );
342
434
  });
@@ -0,0 +1,15 @@
1
+ import type { PoolClient } from "pg";
2
+ import { addWorkspaceProjectToQueue } from "../migrations/add_workspace_project_to_queue";
3
+
4
+ describe("addWorkspaceProjectToQueue", () => {
5
+ it("adds nullable scope columns so legacy rows retain NULL identity", async () => {
6
+ const query = jest.fn().mockResolvedValue({ rows: [] });
7
+
8
+ await addWorkspaceProjectToQueue.up({ query } as unknown as PoolClient);
9
+
10
+ const sql = query.mock.calls.map(([statement]) => statement as string).join("\n");
11
+ expect(sql).toContain("ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(255)");
12
+ expect(sql).toContain("ADD COLUMN IF NOT EXISTS project_id VARCHAR(255)");
13
+ expect(sql).not.toMatch(/SET\s+DEFAULT|NOT\s+NULL|UPDATE\s+lattice_thread_message_queue/i);
14
+ });
15
+ });
@@ -71,8 +71,15 @@ import { createMenuItemsTable } from "./migrations/menu_items_migration";
71
71
  import { addFileContentType } from "./migrations/menu_items_add_file_type";
72
72
  import { createSharedResourcesTable } from "./migrations/shared_resources_migration";
73
73
  import { createCollectionsTable } from "./migrations/collection_migrations";
74
- import { createTaskWorkItemsMigration, addWorkItemProjectFieldsMigration, addTaskWorkItemEventKeyMigration } from "./migrations/task_work_items_migration";
74
+ import {
75
+ createTaskWorkItemsMigration,
76
+ addWorkItemProjectFieldsMigration,
77
+ addTaskWorkItemEventKeyMigration,
78
+ addTaskWorkItemPendingIndexesMigration,
79
+ } from "./migrations/task_work_items_migration";
75
80
  import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
81
+ import { createCapabilityBundlesTable } from "./migrations/capability_bundle_migration";
82
+ import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
76
83
 
77
84
  export async function createPgStoreConfig(connectionString: string) {
78
85
  const pool = new Pool({ connectionString });
@@ -134,6 +141,8 @@ export async function createPgStoreConfig(connectionString: string) {
134
141
  mm.register(addA2AKeyAssistantIds); // v167
135
142
  mm.register(addTaskWorkItemEventKeyMigration); // v168
136
143
  mm.register(createAgentWebAppsTable); // v169
144
+ mm.register(createCapabilityBundlesTable); // v170
145
+ mm.register(addTaskWorkItemPendingIndexesMigration); // v171
137
146
 
138
147
  await mm.migrate();
139
148
 
@@ -167,6 +176,7 @@ export async function createPgStoreConfig(connectionString: string) {
167
176
  taskWorkItem: taskWorkItemStore,
168
177
  a2aApiKey: new PostgreSQLA2AApiKeyStore(opts),
169
178
  agentWebApp: new PostgreSQLAgentWebAppStore(opts),
179
+ capabilityBundle: new PostgreSQLCapabilityBundleStore(opts),
170
180
  schedule: new PostgreSQLScheduleStorage(opts),
171
181
  menu: new MenuStore(opts),
172
182
  sharedResource: new PostgresSharedResourceStore(opts),
package/src/index.ts CHANGED
@@ -58,6 +58,8 @@ export * from "./stores/ChannelIdentityMappingStore";
58
58
  export * from "./stores/PostgreSQLChannelInstallationStore";
59
59
  export * from "./stores/PostgreSQLA2AApiKeyStore";
60
60
  export * from "./stores/PostgreSQLAgentWebAppStore";
61
+ export * from "./stores/PostgreSQLCapabilityBundleStore";
62
+ export * from "./migrations/capability_bundle_migration";
61
63
  export * from "./stores/PostgreSQLWorkflowTrackingStore";
62
64
  export * from "./stores/PostgreSQLEvalStore";
63
65
  export * from "./migrations/eval_migrations";
@@ -191,4 +193,8 @@ export type {
191
193
  AgentWebAppAppearance,
192
194
  CreateAgentWebAppInput,
193
195
  UpdateAgentWebAppInput,
196
+ CapabilityBundleStore,
197
+ CapabilityBundle,
198
+ CreateCapabilityBundleInput,
199
+ UpdateCapabilityBundleInput,
194
200
  } from "@axiom-lattice/protocols";
@@ -0,0 +1,20 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Creates the tenant-scoped capability bundle table. */
4
+ export const createCapabilityBundlesTable: Migration = {
5
+ version: 170,
6
+ name: "create_capability_bundles_table",
7
+ up: async (client) => {
8
+ await client.query(`CREATE TABLE IF NOT EXISTS lattice_capability_bundles (
9
+ id UUID PRIMARY KEY, tenant_id VARCHAR(255) NOT NULL, bundle_key VARCHAR(255) NOT NULL,
10
+ name VARCHAR(255) NOT NULL, description TEXT, capabilities JSONB NOT NULL DEFAULT '[]'::jsonb,
11
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12
+ UNIQUE (tenant_id, bundle_key)
13
+ )`);
14
+ await client.query("CREATE INDEX IF NOT EXISTS idx_lattice_capability_bundles_tenant ON lattice_capability_bundles(tenant_id)");
15
+ },
16
+ down: async (client) => {
17
+ await client.query("DROP INDEX IF EXISTS idx_lattice_capability_bundles_tenant");
18
+ await client.query("DROP TABLE IF EXISTS lattice_capability_bundles");
19
+ },
20
+ };
@@ -64,3 +64,19 @@ export const addTaskWorkItemEventKeyMigration: Migration = {
64
64
  `);
65
65
  },
66
66
  };
67
+
68
+ export const addTaskWorkItemPendingIndexesMigration: Migration = {
69
+ version: 171,
70
+ name: "add_task_work_item_pending_indexes",
71
+ up: async (client: PoolClient) => {
72
+ await client.query(`
73
+ CREATE INDEX IF NOT EXISTS idx_task_work_items_pending_order
74
+ ON lattice_task_work_items (tenant_id, task_id, action, created_at DESC, id DESC)
75
+ `);
76
+ await client.query(`
77
+ CREATE INDEX IF NOT EXISTS idx_task_work_items_reconciled_result
78
+ ON lattice_task_work_items (tenant_id, task_id, (detail ->> 'executionResultId'))
79
+ WHERE action = 'execution_reconciled'
80
+ `);
81
+ },
82
+ };
@@ -0,0 +1,306 @@
1
+ import { randomUUID } from "crypto";
2
+ import { Pool } from "pg";
3
+ import type { PoolConfig } from "pg";
4
+ import type {
5
+ CapabilityBundle,
6
+ CapabilityBundleDeleteResult,
7
+ CapabilityBundleStore,
8
+ InternalCreateCapabilityBundleInput,
9
+ InternalUpdateCapabilityBundleInput,
10
+ } from "@axiom-lattice/protocols";
11
+ import { MigrationManager } from "../migrations/migration";
12
+ import { createCapabilityBundlesTable } from "../migrations/capability_bundle_migration";
13
+
14
+ /**
15
+ * Configures PostgreSQL connectivity and migration behavior for capability bundles.
16
+ *
17
+ * @remarks
18
+ * An injected `pool` is externally managed and assumed to have migrations applied.
19
+ * When `poolConfig` is used, the store owns the pool and runs migrations eagerly
20
+ * unless `autoMigrate` is false. Call `initialize()` before use in that case.
21
+ */
22
+ export interface PostgreSQLCapabilityBundleStoreOptions {
23
+ /** Externally managed PostgreSQL pool. */
24
+ pool?: Pool;
25
+ /** Connection string or pool configuration used to create an owned pool. */
26
+ poolConfig?: string | PoolConfig;
27
+ /** Whether an owned pool should run migrations during construction. @default true */
28
+ autoMigrate?: boolean;
29
+ }
30
+
31
+ type Row = {
32
+ id: string;
33
+ tenant_id: string;
34
+ bundle_key: string;
35
+ name: string;
36
+ description: string | null;
37
+ capabilities: unknown;
38
+ created_at: Date;
39
+ updated_at: Date | string;
40
+ };
41
+ const duplicateMessage = "Capability bundle key already exists for tenant";
42
+ function map(row: Row): CapabilityBundle {
43
+ return {
44
+ id: row.id,
45
+ tenantId: row.tenant_id,
46
+ key: row.bundle_key,
47
+ name: row.name,
48
+ description: row.description ?? undefined,
49
+ capabilities: row.capabilities as CapabilityBundle["capabilities"],
50
+ createdAt: row.created_at.toISOString(),
51
+ updatedAt: typeof row.updated_at === "string" ? row.updated_at : row.updated_at.toISOString(),
52
+ };
53
+ }
54
+
55
+ function isDuplicate(error: unknown): boolean {
56
+ return typeof error === "object"
57
+ && error !== null
58
+ && "code" in error
59
+ && error.code === "23505";
60
+ }
61
+
62
+ /**
63
+ * Persists tenant-scoped capability bundles in PostgreSQL.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * const store = new PostgreSQLCapabilityBundleStore({ poolConfig: connectionString });
68
+ * await store.initialize();
69
+ * const bundles = await store.listByTenant("tenant-1");
70
+ * await store.dispose();
71
+ * ```
72
+ *
73
+ * @remarks
74
+ * Stores created from `poolConfig` own their pool and must be disposed. Stores
75
+ * created with an injected pool neither migrate nor close that pool.
76
+ */
77
+ export class PostgreSQLCapabilityBundleStore implements CapabilityBundleStore {
78
+ private readonly pool: Pool;
79
+ private migrationManager!: MigrationManager;
80
+ private initialized = false;
81
+ private ownsPool = true;
82
+ private initPromise: Promise<void> | null = null;
83
+
84
+ /**
85
+ * Creates a PostgreSQL capability bundle store.
86
+ *
87
+ * @param options - Pool ownership, connection, and migration options.
88
+ */
89
+ constructor(options: PostgreSQLCapabilityBundleStoreOptions) {
90
+ if (options.pool) {
91
+ this.pool = options.pool;
92
+ this.ownsPool = false;
93
+ this.initialized = true;
94
+ return;
95
+ }
96
+
97
+ this.pool = typeof options.poolConfig === "string"
98
+ ? new Pool({ connectionString: options.poolConfig })
99
+ : options.poolConfig
100
+ ? new Pool(options.poolConfig)
101
+ : (() => { throw new Error("Either pool or poolConfig must be provided"); })();
102
+
103
+ this.migrationManager = new MigrationManager(this.pool);
104
+ this.migrationManager.register(createCapabilityBundlesTable);
105
+
106
+ if (options.autoMigrate !== false) {
107
+ this.startInitialization();
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Applies pending migrations for an internally managed pool.
113
+ *
114
+ * @returns A shared promise that resolves when initialization completes.
115
+ */
116
+ async initialize(): Promise<void> {
117
+ if (this.initialized) return;
118
+ if (this.initPromise) return this.initPromise;
119
+ return this.startInitialization();
120
+ }
121
+
122
+ private startInitialization(): Promise<void> {
123
+ this.initPromise = this.migrationManager.migrate().then(() => {
124
+ this.initialized = true;
125
+ });
126
+ void this.initPromise.catch(() => undefined);
127
+ return this.initPromise;
128
+ }
129
+
130
+ /**
131
+ * Closes the pool when it was created by this store.
132
+ *
133
+ * @returns A promise that resolves after owned resources are released.
134
+ */
135
+ async dispose(): Promise<void> {
136
+ if (this.ownsPool) await this.pool.end();
137
+ }
138
+
139
+ private async ready(): Promise<void> {
140
+ if (!this.initialized) await this.initialize();
141
+ }
142
+
143
+ async listByTenant(tenantId: string): Promise<CapabilityBundle[]> {
144
+ await this.ready();
145
+ const result = await this.pool.query<Row>(
146
+ "SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 ORDER BY created_at",
147
+ [tenantId],
148
+ );
149
+ return result.rows.map(map);
150
+ }
151
+
152
+ async getById(tenantId: string, id: string): Promise<CapabilityBundle | null> {
153
+ await this.ready();
154
+ const result = await this.pool.query<Row>(
155
+ "SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = $2",
156
+ [tenantId, id],
157
+ );
158
+ return result.rows[0] ? map(result.rows[0]) : null;
159
+ }
160
+
161
+ async getManyByIds(tenantId: string, ids: string[]): Promise<CapabilityBundle[]> {
162
+ await this.ready();
163
+ if (ids.length === 0) return [];
164
+ const result = await this.pool.query<Row>(
165
+ "SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = ANY($2::uuid[])",
166
+ [tenantId, ids],
167
+ );
168
+ return result.rows.map(map);
169
+ }
170
+
171
+ async create(
172
+ tenantId: string,
173
+ input: InternalCreateCapabilityBundleInput,
174
+ ): Promise<CapabilityBundle> {
175
+ await this.ready();
176
+ try {
177
+ const result = await this.pool.query<Row>(
178
+ `INSERT INTO lattice_capability_bundles
179
+ (id, tenant_id, bundle_key, name, description, capabilities)
180
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb)
181
+ RETURNING id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at`,
182
+ [
183
+ randomUUID(),
184
+ tenantId,
185
+ input.key,
186
+ input.name,
187
+ input.description ?? null,
188
+ JSON.stringify(input.capabilities),
189
+ ],
190
+ );
191
+ return map(result.rows[0]);
192
+ } catch (error) {
193
+ if (isDuplicate(error)) throw new Error(duplicateMessage);
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ async update(
199
+ tenantId: string,
200
+ id: string,
201
+ input: InternalUpdateCapabilityBundleInput,
202
+ ): Promise<CapabilityBundle | { status: "conflict" } | null> {
203
+ await this.ready();
204
+ const fields: string[] = [];
205
+ const values: unknown[] = [];
206
+ const add = (field: string, value: unknown): void => {
207
+ values.push(value);
208
+ fields.push(`${field} = $${values.length}`);
209
+ };
210
+ if (input.name !== undefined) add("name", input.name);
211
+ if (Object.prototype.hasOwnProperty.call(input, "description")) {
212
+ add("description", input.description ?? null);
213
+ }
214
+ if (input.capabilities !== undefined) {
215
+ add("capabilities", JSON.stringify(input.capabilities));
216
+ }
217
+ if (fields.length === 0) {
218
+ const expectedUpdatedAt = input.expectedUpdatedAt;
219
+ const result = await this.pool.query<Row>(
220
+ `SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at
221
+ FROM lattice_capability_bundles
222
+ WHERE tenant_id = $1 AND id = $2${expectedUpdatedAt === undefined ? "" : " AND updated_at::text = $3"}`,
223
+ expectedUpdatedAt === undefined ? [tenantId, id] : [tenantId, id, expectedUpdatedAt],
224
+ );
225
+ if (result.rows[0]) return map(result.rows[0]);
226
+ return expectedUpdatedAt === undefined ? null : { status: "conflict" };
227
+ }
228
+
229
+ const expectedUpdatedAt = input.expectedUpdatedAt;
230
+ values.push(tenantId, id);
231
+ if (expectedUpdatedAt !== undefined) values.push(expectedUpdatedAt);
232
+ const tenantParam = values.length - (expectedUpdatedAt === undefined ? 1 : 2);
233
+ const idParam = tenantParam + 1;
234
+ const revisionPredicate = expectedUpdatedAt === undefined ? "" : ` AND updated_at::text = $${values.length}`;
235
+ try {
236
+ const result = await this.pool.query<Row>(
237
+ `UPDATE lattice_capability_bundles
238
+ SET ${fields.join(", ")}, updated_at = GREATEST(updated_at + interval '1 microsecond', clock_timestamp())
239
+ WHERE tenant_id = $${tenantParam} AND id = $${idParam}${revisionPredicate}
240
+ RETURNING id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at`,
241
+ values,
242
+ );
243
+ if (result.rows[0]) return map(result.rows[0]);
244
+ return expectedUpdatedAt === undefined ? null : { status: "conflict" };
245
+ } catch (error) {
246
+ if (isDuplicate(error)) throw new Error(duplicateMessage);
247
+ throw error;
248
+ }
249
+ }
250
+
251
+ async deleteIfUnreferenced(tenantId: string, id: string): Promise<CapabilityBundleDeleteResult> {
252
+ await this.ready();
253
+ const client = await this.pool.connect();
254
+ try {
255
+ await client.query("BEGIN");
256
+ await client.query(
257
+ "SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
258
+ [tenantId],
259
+ );
260
+ const projectIds = await client.query<{ id: string }>(
261
+ `SELECT id FROM lattice_projects
262
+ WHERE tenant_id = $1
263
+ ORDER BY id`,
264
+ [tenantId],
265
+ );
266
+ if (projectIds.rows.length > 0) {
267
+ await client.query(
268
+ `SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project:' || project_id, 0))
269
+ FROM unnest($2::text[]) AS project_id
270
+ ORDER BY project_id`,
271
+ [tenantId, projectIds.rows.map((project) => project.id)],
272
+ );
273
+ }
274
+ await client.query(
275
+ `SELECT id FROM lattice_projects
276
+ WHERE tenant_id = $1
277
+ FOR UPDATE`,
278
+ [tenantId],
279
+ );
280
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || $2, 0))", [tenantId, id]);
281
+ const referenced = await client.query(
282
+ `SELECT 1 FROM lattice_projects
283
+ WHERE tenant_id = $1
284
+ AND jsonb_typeof(config->'capabilityBundleIds') = 'array'
285
+ AND COALESCE(config->'capabilityBundleIds', '[]'::jsonb) ? $2
286
+ LIMIT 1`,
287
+ [tenantId, id],
288
+ );
289
+ if (referenced.rows.length > 0) {
290
+ await client.query("COMMIT");
291
+ return "in_use";
292
+ }
293
+ const deleted = await client.query(
294
+ "DELETE FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = $2 RETURNING id",
295
+ [tenantId, id],
296
+ );
297
+ await client.query("COMMIT");
298
+ return deleted.rows.length > 0 ? "deleted" : "not_found";
299
+ } catch (error) {
300
+ await client.query("ROLLBACK");
301
+ throw error;
302
+ } finally {
303
+ client.release();
304
+ }
305
+ }
306
+ }