@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
@@ -98,10 +98,25 @@ export const addFilesToTasks: Migration = {
98
98
  },
99
99
  };
100
100
 
101
+ /** Add JSONB containment support for dependency recovery queries. */
102
+ export const addTaskDependenciesGinIndex: Migration = {
103
+ version: 174,
104
+ name: "add_task_dependencies_gin_index",
105
+ up: async (client) => {
106
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_lattice_tasks_dependencies_gin
107
+ ON lattice_tasks USING GIN (dependencies jsonb_path_ops)
108
+ WHERE dependencies IS NOT NULL`);
109
+ },
110
+ down: async (client) => {
111
+ await client.query("DROP INDEX IF EXISTS idx_lattice_tasks_dependencies_gin");
112
+ },
113
+ };
114
+
101
115
  /** All task migrations in version order */
102
116
  export const taskMigrations: Migration[] = [
103
117
  createTasksTable,
104
118
  addTaskFieldsMigration,
105
119
  addTaskProjectFieldsMigration,
106
120
  addFilesToTasks,
121
+ addTaskDependenciesGinIndex,
107
122
  ];
@@ -16,7 +16,7 @@ export const createTaskWorkItemsMigration: Migration = {
16
16
  summary TEXT,
17
17
  detail JSONB,
18
18
  attempt INTEGER,
19
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19
+ created_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', clock_timestamp()),
20
20
  PRIMARY KEY (tenant_id, id)
21
21
  )
22
22
  `);
@@ -64,3 +64,47 @@ 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
+ };
83
+
84
+ /** Add deterministic project lifecycle event scan support. */
85
+ export const addProjectLifecycleEventIndex: Migration = {
86
+ version: 175,
87
+ name: "add_project_lifecycle_event_index",
88
+ up: async (client) => {
89
+ await client.query(`UPDATE lattice_task_work_items
90
+ SET created_at = date_trunc('milliseconds', created_at)
91
+ WHERE created_at <> date_trunc('milliseconds', created_at)`);
92
+ await client.query(`ALTER TABLE lattice_task_work_items
93
+ ALTER COLUMN created_at SET DEFAULT date_trunc('milliseconds', clock_timestamp())`);
94
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_task_work_items_project_lifecycle
95
+ ON lattice_task_work_items (tenant_id, workspace_id, project_id, created_at DESC, id DESC)
96
+ WHERE event_key IS NOT NULL`);
97
+ },
98
+ down: async (client) => {
99
+ await client.query("DROP INDEX IF EXISTS idx_task_work_items_project_lifecycle");
100
+ },
101
+ };
102
+
103
+ /** Complete standalone TaskWorkItem migration chain in dependency order. */
104
+ export const taskWorkItemMigrations: Migration[] = [
105
+ createTaskWorkItemsMigration,
106
+ addWorkItemProjectFieldsMigration,
107
+ addTaskWorkItemEventKeyMigration,
108
+ addTaskWorkItemPendingIndexesMigration,
109
+ addProjectLifecycleEventIndex,
110
+ ];
@@ -1,13 +1,16 @@
1
1
  import { Pool } from "pg";
2
2
  import type { PoolConfig } from "pg";
3
- import type {
4
- Binding,
5
- BindingRegistry,
6
- CreateBindingInput,
3
+ import {
4
+ DuplicateChannelBindingSubjectError,
5
+ type Binding, type BindingListParams, type BindingMutablePatch,
6
+ type BindingRegistry, type CreateBindingInput,
7
7
  } from "@axiom-lattice/protocols";
8
8
  import { MigrationManager } from "../migrations/migration";
9
9
  import { createChannelBindingsTable } from "../migrations/channel_bindings_migration";
10
10
 
11
+ const BINDING_SUBJECT_CONSTRAINT =
12
+ "lattice_channel_bindings_channel_channel_installation_id_te_key";
13
+
11
14
  export interface ChannelBindingStoreOptions {
12
15
  pool?: Pool;
13
16
  poolConfig?: string | PoolConfig;
@@ -107,13 +110,33 @@ export class ChannelBindingStore implements BindingRegistry {
107
110
  return this.mapRowToBinding(result.rows[0]);
108
111
  }
109
112
 
110
- async create(input: CreateBindingInput): Promise<Binding> {
113
+ async findById(tenantId: string, id: string): Promise<Binding | null> {
111
114
  await this.ensureInitialized();
112
115
  const result = await this.pool.query<BindingRow>(
116
+ "SELECT * FROM lattice_channel_bindings WHERE id = $1 AND tenant_id = $2",
117
+ [id, tenantId],
118
+ );
119
+ return result.rows[0] ? this.mapRowToBinding(result.rows[0]) : null;
120
+ }
121
+
122
+ async findBySubject(params: {
123
+ tenantId: string; channel: string; channelInstallationId: string; senderId: string;
124
+ }): Promise<Binding | null> {
125
+ await this.ensureInitialized();
126
+ const result = await this.pool.query<BindingRow>(`SELECT * FROM lattice_channel_bindings
127
+ WHERE tenant_id = $1 AND channel = $2 AND channel_installation_id = $3 AND sender_id = $4 LIMIT 1`,
128
+ [params.tenantId, params.channel, params.channelInstallationId, params.senderId]);
129
+ return result.rows[0] ? this.mapRowToBinding(result.rows[0]) : null;
130
+ }
131
+
132
+ async create(input: CreateBindingInput): Promise<Binding> {
133
+ await this.ensureInitialized();
134
+ try {
135
+ const result = await this.pool.query<BindingRow>(
113
136
  `INSERT INTO lattice_channel_bindings
114
137
  (channel, channel_installation_id, tenant_id, sender_id, agent_id,
115
- thread_mode, sender_display_name, sender_metadata, workspace_id, project_id)
116
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
138
+ thread_id, thread_mode, sender_display_name, sender_metadata, workspace_id, project_id, enabled)
139
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
117
140
  RETURNING *`,
118
141
  [
119
142
  input.channel,
@@ -121,75 +144,58 @@ export class ChannelBindingStore implements BindingRegistry {
121
144
  input.tenantId,
122
145
  input.senderId,
123
146
  input.agentId,
147
+ input.threadId || null,
124
148
  input.threadMode || "fixed",
125
149
  input.senderDisplayName || null,
126
150
  input.senderMetadata ? JSON.stringify(input.senderMetadata) : null,
127
- input.workspaceId || null,
128
- input.projectId || null,
151
+ input.workspaceId || null,
152
+ input.projectId || null,
153
+ input.enabled ?? true,
129
154
  ],
130
- );
131
- return this.mapRowToBinding(result.rows[0]);
155
+ );
156
+ return this.mapRowToBinding(result.rows[0]);
157
+ } catch (error) {
158
+ if (isBindingSubjectConflict(error)) {
159
+ throw new DuplicateChannelBindingSubjectError();
160
+ }
161
+ throw error;
162
+ }
132
163
  }
133
164
 
134
- async update(id: string, patch: Partial<Binding>): Promise<Binding> {
165
+ async update(tenantId: string, id: string, patch: BindingMutablePatch): Promise<Binding> {
135
166
  await this.ensureInitialized();
136
- const existing = await this.pool.query<BindingRow>(
137
- `SELECT * FROM lattice_channel_bindings WHERE id = $1`,
138
- [id],
139
- );
140
- if (existing.rows.length === 0) {
141
- throw new Error(`Binding ${id} not found`);
142
- }
143
-
144
- const row = existing.rows[0];
145
- const updated = {
146
- channel: patch.channel ?? row.channel,
147
- channel_installation_id: patch.channelInstallationId ?? row.channel_installation_id,
148
- sender_id: patch.senderId ?? row.sender_id,
149
- agent_id: patch.agentId ?? row.agent_id,
150
- thread_id: patch.threadId !== undefined ? patch.threadId : row.thread_id,
151
- workspace_id: patch.workspaceId !== undefined ? patch.workspaceId : row.workspace_id,
152
- project_id: patch.projectId !== undefined ? patch.projectId : row.project_id,
153
- thread_mode: patch.threadMode ?? row.thread_mode,
154
- sender_display_name: patch.senderDisplayName !== undefined
155
- ? patch.senderDisplayName : row.sender_display_name,
156
- sender_metadata: patch.senderMetadata !== undefined
157
- ? patch.senderMetadata : row.sender_metadata,
158
- enabled: patch.enabled ?? row.enabled,
159
- };
160
-
161
167
  const result = await this.pool.query<BindingRow>(
162
168
  `UPDATE lattice_channel_bindings SET
163
- channel = $1, channel_installation_id = $2, sender_id = $3,
164
- agent_id = $4, thread_id = $5, workspace_id = $6, project_id = $7,
165
- thread_mode = $8, sender_display_name = $9, sender_metadata = $10,
166
- enabled = $11, updated_at = NOW()
167
- WHERE id = $12
169
+ agent_id = COALESCE($1, agent_id),
170
+ thread_id = COALESCE($2, thread_id),
171
+ workspace_id = COALESCE($3, workspace_id),
172
+ project_id = COALESCE($4, project_id),
173
+ thread_mode = COALESCE($5, thread_mode),
174
+ sender_display_name = COALESCE($6, sender_display_name),
175
+ sender_metadata = COALESCE($7, sender_metadata),
176
+ enabled = COALESCE($8, enabled), updated_at = NOW()
177
+ WHERE id = $9 AND tenant_id = $10
168
178
  RETURNING *`,
169
179
  [
170
- updated.channel, updated.channel_installation_id, updated.sender_id,
171
- updated.agent_id, updated.thread_id, updated.workspace_id,
172
- updated.project_id, updated.thread_mode, updated.sender_display_name,
173
- updated.sender_metadata ? JSON.stringify(updated.sender_metadata) : null,
174
- updated.enabled, id,
180
+ patch.agentId ?? null, patch.threadId ?? null, patch.workspaceId ?? null,
181
+ patch.projectId ?? null, patch.threadMode ?? null, patch.senderDisplayName ?? null,
182
+ patch.senderMetadata ? JSON.stringify(patch.senderMetadata) : null,
183
+ patch.enabled ?? null, id, tenantId,
175
184
  ],
176
185
  );
186
+ if (!result.rows[0]) throw new Error(`Binding ${id} not found`);
177
187
  return this.mapRowToBinding(result.rows[0]);
178
188
  }
179
189
 
180
- async delete(id: string): Promise<void> {
190
+ async delete(tenantId: string, id: string): Promise<void> {
181
191
  await this.ensureInitialized();
182
- await this.pool.query(`DELETE FROM lattice_channel_bindings WHERE id = $1`, [id]);
192
+ await this.pool.query(
193
+ `DELETE FROM lattice_channel_bindings WHERE id = $1 AND tenant_id = $2`,
194
+ [id, tenantId],
195
+ );
183
196
  }
184
197
 
185
- async list(params: {
186
- channel?: string;
187
- agentId?: string;
188
- tenantId: string;
189
- channelInstallationId?: string;
190
- limit?: number;
191
- offset?: number;
192
- }): Promise<Binding[]> {
198
+ async list(params: BindingListParams): Promise<Binding[]> {
193
199
  await this.ensureInitialized();
194
200
  const conditions: string[] = ["tenant_id = $1"];
195
201
  const values: unknown[] = [params.tenantId];
@@ -207,6 +213,14 @@ export class ChannelBindingStore implements BindingRegistry {
207
213
  conditions.push(`channel_installation_id = $${idx++}`);
208
214
  values.push(params.channelInstallationId);
209
215
  }
216
+ if (params.excludeChannels?.length) {
217
+ conditions.push(`channel <> ALL($${idx++}::text[])`);
218
+ values.push(params.excludeChannels);
219
+ }
220
+ for (const prefix of params.excludeInstallationIdPrefixes ?? []) {
221
+ conditions.push(`channel_installation_id NOT LIKE $${idx++} ESCAPE '\\'`);
222
+ values.push(`${escapeLikePattern(prefix)}%`);
223
+ }
210
224
 
211
225
  const limit = params.limit ?? 50;
212
226
  const offset = params.offset ?? 0;
@@ -222,7 +236,16 @@ export class ChannelBindingStore implements BindingRegistry {
222
236
  return result.rows.map((r) => this.mapRowToBinding(r));
223
237
  }
224
238
 
225
- async import(bindings: CreateBindingInput[]): Promise<Binding[]> {
239
+ async import(tenantId: string, bindings: CreateBindingInput[]): Promise<Binding[]> {
240
+ if (bindings.some((binding) => binding.channel === "room")) {
241
+ throw new Error("Room bindings cannot be imported through the public store API");
242
+ }
243
+ if (bindings.some((binding) => binding.channelInstallationId.startsWith("room-internal:"))) {
244
+ throw new Error("Internal bindings cannot be imported through the public store API");
245
+ }
246
+ if (bindings.some((binding) => binding.tenantId !== tenantId)) {
247
+ throw new Error("Binding import tenant mismatch");
248
+ }
226
249
  const result: Binding[] = [];
227
250
  for (const input of bindings) {
228
251
  result.push(await this.create(input));
@@ -231,7 +254,13 @@ export class ChannelBindingStore implements BindingRegistry {
231
254
  }
232
255
 
233
256
  async export(params: { tenantId: string }): Promise<Binding[]> {
234
- return this.list({ tenantId: params.tenantId, limit: 10000, offset: 0 });
257
+ return this.list({
258
+ tenantId: params.tenantId,
259
+ excludeChannels: ["room"],
260
+ excludeInstallationIdPrefixes: ["room-internal:"],
261
+ limit: 10000,
262
+ offset: 0,
263
+ });
235
264
  }
236
265
 
237
266
  private async ensureInitialized(): Promise<void> {
@@ -260,3 +289,14 @@ export class ChannelBindingStore implements BindingRegistry {
260
289
  };
261
290
  }
262
291
  }
292
+
293
+ function isBindingSubjectConflict(error: unknown): boolean {
294
+ if (typeof error !== "object" || error === null) return false;
295
+ const pgError = error as { code?: unknown; constraint?: unknown };
296
+ return pgError.code === "23505"
297
+ && pgError.constraint === BINDING_SUBJECT_CONSTRAINT;
298
+ }
299
+
300
+ function escapeLikePattern(value: string): string {
301
+ return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
302
+ }
@@ -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
+ }