@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
|
@@ -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
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
128
|
-
|
|
151
|
+
input.workspaceId || null,
|
|
152
|
+
input.projectId || null,
|
|
153
|
+
input.enabled ?? true,
|
|
129
154
|
],
|
|
130
|
-
|
|
131
|
-
|
|
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:
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
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({
|
|
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
|
+
}
|