@axiom-lattice/pg-stores 3.1.1 → 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 +9 -0
- package/dist/index.d.mts +195 -15
- package/dist/index.d.ts +195 -15
- package/dist/index.js +1309 -199
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1295 -191
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ChannelBindingStore.test.ts +122 -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 +74 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +96 -0
- package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +113 -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 +14 -0
- package/src/index.ts +7 -0
- package/src/migrations/add_trusted_run_context_column.ts +18 -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 +29 -1
- package/src/stores/ChannelBindingStore.ts +99 -59
- 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/PostgreSQLTaskStore.ts +89 -3
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +159 -8
- package/src/stores/ThreadMessageQueueStore.ts +38 -9
|
@@ -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
|
+
}
|
|
@@ -4,8 +4,7 @@ import {
|
|
|
4
4
|
ChannelInstallation,
|
|
5
5
|
ChannelInstallationStore,
|
|
6
6
|
ChannelInstallationType,
|
|
7
|
-
|
|
8
|
-
LarkChannelInstallationConfig,
|
|
7
|
+
CreateChannelInstallationInput,
|
|
9
8
|
UpdateChannelInstallationRequest,
|
|
10
9
|
} from "@axiom-lattice/protocols";
|
|
11
10
|
import { MigrationManager } from "../migrations/migration";
|
|
@@ -179,12 +178,13 @@ export class PostgreSQLChannelInstallationStore
|
|
|
179
178
|
async createInstallation(
|
|
180
179
|
tenantId: string,
|
|
181
180
|
installationId: string,
|
|
182
|
-
data:
|
|
181
|
+
data: CreateChannelInstallationInput,
|
|
183
182
|
): Promise<ChannelInstallation> {
|
|
184
183
|
await this.ensureInitialized();
|
|
185
184
|
|
|
186
185
|
const now = new Date();
|
|
187
|
-
const
|
|
186
|
+
const config = { ...data.config };
|
|
187
|
+
const encryptedConfig = this.encryptSecrets(config);
|
|
188
188
|
|
|
189
189
|
await this.pool.query(
|
|
190
190
|
`
|
|
@@ -211,7 +211,7 @@ export class PostgreSQLChannelInstallationStore
|
|
|
211
211
|
tenantId,
|
|
212
212
|
channel: data.channel,
|
|
213
213
|
name: data.name,
|
|
214
|
-
config
|
|
214
|
+
config,
|
|
215
215
|
enabled: data.enabled ?? true,
|
|
216
216
|
fallbackAgentId: data.fallbackAgentId,
|
|
217
217
|
rejectWhenNoBinding: data.rejectWhenNoBinding ?? true,
|
|
@@ -308,7 +308,7 @@ export class PostgreSQLChannelInstallationStore
|
|
|
308
308
|
typeof row.config === "string"
|
|
309
309
|
? JSON.parse(row.config)
|
|
310
310
|
: row.config,
|
|
311
|
-
)
|
|
311
|
+
),
|
|
312
312
|
enabled: row.enabled,
|
|
313
313
|
fallbackAgentId: row.fallback_agent_id || undefined,
|
|
314
314
|
rejectWhenNoBinding: row.reject_when_no_binding,
|
|
@@ -322,18 +322,9 @@ export class PostgreSQLChannelInstallationStore
|
|
|
322
322
|
): Record<string, unknown> {
|
|
323
323
|
return {
|
|
324
324
|
...config,
|
|
325
|
-
appSecret:
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
: config.appSecret,
|
|
329
|
-
verificationToken:
|
|
330
|
-
typeof config.verificationToken === "string"
|
|
331
|
-
? encrypt(config.verificationToken)
|
|
332
|
-
: config.verificationToken,
|
|
333
|
-
encryptKey:
|
|
334
|
-
typeof config.encryptKey === "string"
|
|
335
|
-
? encrypt(config.encryptKey)
|
|
336
|
-
: config.encryptKey,
|
|
325
|
+
...(typeof config.appSecret === "string" ? { appSecret: encrypt(config.appSecret) } : {}),
|
|
326
|
+
...(typeof config.verificationToken === "string" ? { verificationToken: encrypt(config.verificationToken) } : {}),
|
|
327
|
+
...(typeof config.encryptKey === "string" ? { encryptKey: encrypt(config.encryptKey) } : {}),
|
|
337
328
|
};
|
|
338
329
|
}
|
|
339
330
|
|
|
@@ -342,18 +333,9 @@ export class PostgreSQLChannelInstallationStore
|
|
|
342
333
|
): Record<string, unknown> {
|
|
343
334
|
return {
|
|
344
335
|
...config,
|
|
345
|
-
appSecret:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
: config.appSecret,
|
|
349
|
-
verificationToken:
|
|
350
|
-
typeof config.verificationToken === "string"
|
|
351
|
-
? decrypt(config.verificationToken)
|
|
352
|
-
: config.verificationToken,
|
|
353
|
-
encryptKey:
|
|
354
|
-
typeof config.encryptKey === "string"
|
|
355
|
-
? decrypt(config.encryptKey)
|
|
356
|
-
: config.encryptKey,
|
|
336
|
+
...(typeof config.appSecret === "string" ? { appSecret: decrypt(config.appSecret) } : {}),
|
|
337
|
+
...(typeof config.verificationToken === "string" ? { verificationToken: decrypt(config.verificationToken) } : {}),
|
|
338
|
+
...(typeof config.encryptKey === "string" ? { encryptKey: decrypt(config.encryptKey) } : {}),
|
|
357
339
|
};
|
|
358
340
|
}
|
|
359
341
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProjectBotMembership,
|
|
3
|
+
ProjectBotMembershipStatus,
|
|
4
|
+
ProjectBotMembershipStore,
|
|
5
|
+
ProjectBotRole,
|
|
6
|
+
} from "@axiom-lattice/protocols";
|
|
7
|
+
import type { Pool, PoolClient } from "pg";
|
|
8
|
+
|
|
9
|
+
type BotMembershipRow = {
|
|
10
|
+
id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; room_id: unknown;
|
|
11
|
+
assistant_id: unknown; role: unknown; title: unknown; responsibility: unknown; mention_name: unknown;
|
|
12
|
+
status: unknown; room_thread_id: unknown; joined_at: unknown; updated_at: unknown;
|
|
13
|
+
};
|
|
14
|
+
type PostgreSQLError = { code?: unknown; constraint?: unknown };
|
|
15
|
+
type ConstraintConflict = "coordinator_conflict" | "mention_conflict";
|
|
16
|
+
|
|
17
|
+
const columns = "id, tenant_id, workspace_id, project_id, room_id, assistant_id, role, title, responsibility, mention_name, status, room_thread_id, joined_at, updated_at";
|
|
18
|
+
|
|
19
|
+
/** Indicates that a new bot membership ID already exists in the tenant. */
|
|
20
|
+
export class ProjectBotMembershipIdConflictError extends Error {
|
|
21
|
+
/** Creates an accurate tenant-scoped membership ID collision error. */
|
|
22
|
+
constructor(tenantId: string, id: string) {
|
|
23
|
+
super(`Project bot membership ID '${id}' already exists in tenant '${tenantId}'`);
|
|
24
|
+
this.name = "ProjectBotMembershipIdConflictError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isDate(value: unknown): value is Date {
|
|
29
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
30
|
+
}
|
|
31
|
+
function isRole(value: unknown): value is ProjectBotRole {
|
|
32
|
+
return value === "coordinator" || value === "specialist";
|
|
33
|
+
}
|
|
34
|
+
function isStatus(value: unknown): value is ProjectBotMembershipStatus {
|
|
35
|
+
return value === "active" || value === "paused" || value === "removed";
|
|
36
|
+
}
|
|
37
|
+
function mapRow(row: BotMembershipRow): ProjectBotMembership {
|
|
38
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string"
|
|
39
|
+
|| typeof row.project_id !== "string" || typeof row.room_id !== "string" || typeof row.assistant_id !== "string"
|
|
40
|
+
|| !isRole(row.role) || typeof row.title !== "string"
|
|
41
|
+
|| (row.responsibility !== null && typeof row.responsibility !== "string")
|
|
42
|
+
|| typeof row.mention_name !== "string" || !isStatus(row.status) || typeof row.room_thread_id !== "string"
|
|
43
|
+
|| !isDate(row.joined_at) || !isDate(row.updated_at)) {
|
|
44
|
+
throw new Error("Invalid project bot membership row");
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id,
|
|
48
|
+
roomId: row.room_id, assistantId: row.assistant_id, role: row.role, title: row.title,
|
|
49
|
+
...(row.responsibility === null ? {} : { responsibility: row.responsibility }),
|
|
50
|
+
mentionName: row.mention_name, status: row.status, roomThreadId: row.room_thread_id,
|
|
51
|
+
joinedAt: row.joined_at, updatedAt: row.updated_at,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function isUniqueViolation(error: unknown): error is PostgreSQLError {
|
|
55
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
56
|
+
&& (error as PostgreSQLError).code === "23505";
|
|
57
|
+
}
|
|
58
|
+
function constraintConflict(error: unknown): ConstraintConflict | undefined {
|
|
59
|
+
if (!isUniqueViolation(error) || typeof error.constraint !== "string") return undefined;
|
|
60
|
+
if (error.constraint === "uq_lattice_project_bot_memberships_coordinator") return "coordinator_conflict";
|
|
61
|
+
if (error.constraint === "uq_lattice_project_bot_memberships_mention") return "mention_conflict";
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
async function rollback(client: PoolClient): Promise<void> {
|
|
65
|
+
try { await client.query("ROLLBACK"); } catch { /* preserve the original outcome */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Persists tenant-isolated project bot roster memberships in PostgreSQL. */
|
|
69
|
+
export class PostgreSQLProjectBotMembershipStore implements ProjectBotMembershipStore {
|
|
70
|
+
/** Creates a store using an externally managed shared pool. */
|
|
71
|
+
constructor(options: { pool: Pool }) { this.pool = options.pool; }
|
|
72
|
+
private readonly pool: Pool;
|
|
73
|
+
|
|
74
|
+
/** Lists retained memberships in stable join order. */
|
|
75
|
+
async list(tenantId: string, projectId: string): Promise<ProjectBotMembership[]> {
|
|
76
|
+
const result = await this.pool.query<BotMembershipRow>(
|
|
77
|
+
`SELECT ${columns} FROM lattice_project_bot_memberships WHERE tenant_id = $1 AND project_id = $2 ORDER BY joined_at ASC, id ASC`,
|
|
78
|
+
[tenantId, projectId],
|
|
79
|
+
);
|
|
80
|
+
return result.rows.map(mapRow);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Finds a membership by tenant-scoped ID. */
|
|
84
|
+
async findById(tenantId: string, id: string): Promise<ProjectBotMembership | null> {
|
|
85
|
+
const result = await this.pool.query<BotMembershipRow>(
|
|
86
|
+
`SELECT ${columns} FROM lattice_project_bot_memberships WHERE tenant_id = $1 AND id = $2`, [tenantId, id],
|
|
87
|
+
);
|
|
88
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Finds an assistant's durable membership in a tenant-scoped project. */
|
|
92
|
+
async findByAssistant(tenantId: string, projectId: string, assistantId: string): Promise<ProjectBotMembership | null> {
|
|
93
|
+
const result = await this.pool.query<BotMembershipRow>(
|
|
94
|
+
`SELECT ${columns} FROM lattice_project_bot_memberships WHERE tenant_id = $1 AND project_id = $2 AND assistant_id = $3`,
|
|
95
|
+
[tenantId, projectId, assistantId],
|
|
96
|
+
);
|
|
97
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Inserts a new membership or reactivates the assistant's durable membership atomically. */
|
|
101
|
+
async save(input: Omit<ProjectBotMembership, "joinedAt" | "updatedAt">): Promise<
|
|
102
|
+
{ kind: "created" | "updated" | "reactivated"; membership: ProjectBotMembership } | { kind: ConstraintConflict }
|
|
103
|
+
> {
|
|
104
|
+
const client = await this.pool.connect();
|
|
105
|
+
try {
|
|
106
|
+
await client.query("BEGIN");
|
|
107
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${input.tenantId}:${input.projectId}`]);
|
|
108
|
+
const existingResult = await client.query<BotMembershipRow>(
|
|
109
|
+
`SELECT ${columns} FROM lattice_project_bot_memberships WHERE tenant_id = $1 AND project_id = $2 AND assistant_id = $3 FOR UPDATE`,
|
|
110
|
+
[input.tenantId, input.projectId, input.assistantId],
|
|
111
|
+
);
|
|
112
|
+
const existing = existingResult.rows[0] ? mapRow(existingResult.rows[0]) : undefined;
|
|
113
|
+
let result;
|
|
114
|
+
if (existing) {
|
|
115
|
+
result = await client.query<BotMembershipRow>(
|
|
116
|
+
`UPDATE lattice_project_bot_memberships
|
|
117
|
+
SET role = $3, title = $4, responsibility = $5, mention_name = $6, status = 'active',
|
|
118
|
+
updated_at = GREATEST(date_trunc('milliseconds', updated_at) + interval '1 millisecond', date_trunc('milliseconds', clock_timestamp()))
|
|
119
|
+
WHERE tenant_id = $1 AND id = $2 RETURNING ${columns}`,
|
|
120
|
+
[input.tenantId, existing.id, input.role, input.title, input.responsibility ?? null, input.mentionName],
|
|
121
|
+
);
|
|
122
|
+
} else {
|
|
123
|
+
const idResult = await client.query<{ id: unknown }>(
|
|
124
|
+
"SELECT id FROM lattice_project_bot_memberships WHERE tenant_id = $1 AND id = $2", [input.tenantId, input.id],
|
|
125
|
+
);
|
|
126
|
+
if (idResult.rows.length > 0) throw new ProjectBotMembershipIdConflictError(input.tenantId, input.id);
|
|
127
|
+
result = await client.query<BotMembershipRow>(
|
|
128
|
+
`INSERT INTO lattice_project_bot_memberships
|
|
129
|
+
(id, tenant_id, workspace_id, project_id, room_id, assistant_id, role, title, responsibility, mention_name, status, room_thread_id, joined_at, updated_at)
|
|
130
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'active', $11, date_trunc('milliseconds', clock_timestamp()), date_trunc('milliseconds', clock_timestamp()))
|
|
131
|
+
RETURNING ${columns}`,
|
|
132
|
+
[input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, input.assistantId,
|
|
133
|
+
input.role, input.title, input.responsibility ?? null, input.mentionName, input.roomThreadId],
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const membership = mapRow(result.rows[0]);
|
|
137
|
+
await client.query("COMMIT");
|
|
138
|
+
return { kind: existing === undefined ? "created" : existing.status === "removed" ? "reactivated" : "updated", membership };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await rollback(client);
|
|
141
|
+
const conflict = constraintConflict(error);
|
|
142
|
+
if (conflict) return { kind: conflict };
|
|
143
|
+
if (isUniqueViolation(error) && error.constraint === "lattice_project_bot_memberships_pkey") {
|
|
144
|
+
throw new ProjectBotMembershipIdConflictError(input.tenantId, input.id);
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
} finally { client.release(); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Applies a mutable-field patch with project serialization and millisecond-safe optimistic concurrency. */
|
|
151
|
+
async update(input: {
|
|
152
|
+
tenantId: string;
|
|
153
|
+
id: string;
|
|
154
|
+
patch: Partial<Pick<ProjectBotMembership, "role" | "title" | "responsibility" | "mentionName" | "status">>;
|
|
155
|
+
expectedUpdatedAt: Date;
|
|
156
|
+
}): Promise<
|
|
157
|
+
{ kind: "updated"; membership: ProjectBotMembership }
|
|
158
|
+
| { kind: "not_found" | "conflict" | ConstraintConflict }
|
|
159
|
+
> {
|
|
160
|
+
const client = await this.pool.connect();
|
|
161
|
+
try {
|
|
162
|
+
await client.query("BEGIN");
|
|
163
|
+
const identity = await client.query<{ project_id: unknown }>(
|
|
164
|
+
"SELECT project_id FROM lattice_project_bot_memberships WHERE tenant_id = $1 AND id = $2", [input.tenantId, input.id],
|
|
165
|
+
);
|
|
166
|
+
if (!identity.rows[0]) { await client.query("COMMIT"); return { kind: "not_found" }; }
|
|
167
|
+
if (typeof identity.rows[0].project_id !== "string") throw new Error("Invalid project bot membership row");
|
|
168
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${input.tenantId}:${identity.rows[0].project_id}`]);
|
|
169
|
+
const locked = await client.query<BotMembershipRow>(
|
|
170
|
+
`SELECT ${columns} FROM lattice_project_bot_memberships WHERE tenant_id = $1 AND id = $2 FOR UPDATE`,
|
|
171
|
+
[input.tenantId, input.id],
|
|
172
|
+
);
|
|
173
|
+
if (!locked.rows[0]) { await client.query("COMMIT"); return { kind: "not_found" }; }
|
|
174
|
+
const existing = mapRow(locked.rows[0]);
|
|
175
|
+
if (existing.updatedAt.getTime() !== input.expectedUpdatedAt.getTime()) {
|
|
176
|
+
await client.query("COMMIT"); return { kind: "conflict" };
|
|
177
|
+
}
|
|
178
|
+
const candidate = { ...existing, ...input.patch };
|
|
179
|
+
const updated = await client.query<BotMembershipRow>(
|
|
180
|
+
`UPDATE lattice_project_bot_memberships
|
|
181
|
+
SET role = $4, title = $5, responsibility = $6, mention_name = $7, status = $8,
|
|
182
|
+
updated_at = GREATEST(date_trunc('milliseconds', updated_at) + interval '1 millisecond', date_trunc('milliseconds', clock_timestamp()))
|
|
183
|
+
WHERE tenant_id = $1 AND id = $2 AND date_trunc('milliseconds', updated_at) = $3
|
|
184
|
+
RETURNING ${columns}`,
|
|
185
|
+
[input.tenantId, input.id, input.expectedUpdatedAt, candidate.role, candidate.title,
|
|
186
|
+
candidate.responsibility ?? null, candidate.mentionName, candidate.status],
|
|
187
|
+
);
|
|
188
|
+
if (!updated.rows[0]) { await client.query("COMMIT"); return { kind: "conflict" }; }
|
|
189
|
+
const membership = mapRow(updated.rows[0]);
|
|
190
|
+
await client.query("COMMIT");
|
|
191
|
+
return { kind: "updated", membership };
|
|
192
|
+
} catch (error) {
|
|
193
|
+
await rollback(client);
|
|
194
|
+
const conflict = constraintConflict(error);
|
|
195
|
+
if (conflict) return { kind: conflict };
|
|
196
|
+
throw error;
|
|
197
|
+
} finally { client.release(); }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProjectHumanRole, ProjectMembership, ProjectMembershipMutationResult, ProjectMembershipStore,
|
|
3
|
+
} from "@axiom-lattice/protocols";
|
|
4
|
+
import type { Pool, PoolClient } from "pg";
|
|
5
|
+
|
|
6
|
+
type MembershipRow = {
|
|
7
|
+
id: unknown; tenant_id: unknown; project_id: unknown; user_id: unknown;
|
|
8
|
+
role: unknown; status: unknown; joined_at: unknown; updated_at: unknown;
|
|
9
|
+
};
|
|
10
|
+
type DuplicateError = { code?: unknown; constraint?: unknown };
|
|
11
|
+
|
|
12
|
+
/** Indicates that a tenant/project/user membership already exists. */
|
|
13
|
+
export class DuplicateProjectMembershipError extends Error {
|
|
14
|
+
constructor() { super("Project membership already exists for tenant, project, and user"); this.name = "DuplicateProjectMembershipError"; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Indicates that a membership ID already exists in the tenant. */
|
|
18
|
+
export class ProjectMembershipIdConflictError extends Error {
|
|
19
|
+
constructor() { super("Project membership ID already exists for tenant"); this.name = "ProjectMembershipIdConflictError"; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isDate(value: unknown): value is Date { return value instanceof Date && !Number.isNaN(value.getTime()); }
|
|
23
|
+
function isRole(value: unknown): value is ProjectHumanRole { return value === "owner" || value === "admin" || value === "member" || value === "viewer"; }
|
|
24
|
+
function isStatus(value: unknown): value is "active" | "removed" { return value === "active" || value === "removed"; }
|
|
25
|
+
function mapRow(row: MembershipRow): ProjectMembership {
|
|
26
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.project_id !== "string"
|
|
27
|
+
|| typeof row.user_id !== "string" || !isRole(row.role) || !isStatus(row.status)
|
|
28
|
+
|| !isDate(row.joined_at) || !isDate(row.updated_at)) throw new Error("Invalid project membership row");
|
|
29
|
+
return { id: row.id, tenantId: row.tenant_id, projectId: row.project_id, userId: row.user_id,
|
|
30
|
+
role: row.role, status: row.status, joinedAt: row.joined_at, updatedAt: row.updated_at };
|
|
31
|
+
}
|
|
32
|
+
function isDuplicate(error: unknown): error is DuplicateError {
|
|
33
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
34
|
+
&& (error as DuplicateError).code === "23505";
|
|
35
|
+
}
|
|
36
|
+
function mapDuplicate(error: unknown): never {
|
|
37
|
+
if (isDuplicate(error) && typeof error.constraint === "string"
|
|
38
|
+
&& error.constraint.includes("project_id_user_id")) throw new DuplicateProjectMembershipError();
|
|
39
|
+
if (isDuplicate(error)) throw new ProjectMembershipIdConflictError();
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
const columns = "id, tenant_id, project_id, user_id, role, status, joined_at, updated_at";
|
|
43
|
+
|
|
44
|
+
/** Persists tenant-isolated human project memberships in PostgreSQL. */
|
|
45
|
+
export class PostgreSQLProjectMembershipStore implements ProjectMembershipStore {
|
|
46
|
+
/** Creates a store using an externally managed pool; the pool is not migrated or closed. */
|
|
47
|
+
constructor(options: { pool: Pool }) { this.pool = options.pool; }
|
|
48
|
+
private readonly pool: Pool;
|
|
49
|
+
|
|
50
|
+
/** Lists memberships in stable joined-time and ID order. */
|
|
51
|
+
async list(tenantId: string, projectId: string): Promise<ProjectMembership[]> {
|
|
52
|
+
const result = await this.pool.query<MembershipRow>(`SELECT ${columns} FROM lattice_project_memberships WHERE tenant_id = $1 AND project_id = $2 ORDER BY joined_at ASC, id ASC`, [tenantId, projectId]);
|
|
53
|
+
return result.rows.map(mapRow);
|
|
54
|
+
}
|
|
55
|
+
/** Finds a membership by exact tenant, project, and user identity. */
|
|
56
|
+
async findByUser(tenantId: string, projectId: string, userId: string): Promise<ProjectMembership | null> {
|
|
57
|
+
const result = await this.pool.query<MembershipRow>(`SELECT ${columns} FROM lattice_project_memberships WHERE tenant_id = $1 AND project_id = $2 AND user_id = $3`, [tenantId, projectId, userId]);
|
|
58
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
59
|
+
}
|
|
60
|
+
/** Inserts a membership and maps database uniqueness errors to stable typed errors. */
|
|
61
|
+
async create(input: Omit<ProjectMembership, "joinedAt" | "updatedAt">): Promise<ProjectMembership> {
|
|
62
|
+
const client = await this.pool.connect();
|
|
63
|
+
try {
|
|
64
|
+
await client.query("BEGIN");
|
|
65
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${input.tenantId}:${input.projectId}`]);
|
|
66
|
+
const result = await client.query<MembershipRow>(`INSERT INTO lattice_project_memberships (id, tenant_id, project_id, user_id, role, status, joined_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, date_trunc('milliseconds', clock_timestamp()), date_trunc('milliseconds', clock_timestamp())) RETURNING ${columns}`, [input.id, input.tenantId, input.projectId, input.userId, input.role, input.status]);
|
|
67
|
+
const membership = mapRow(result.rows[0]);
|
|
68
|
+
await client.query("COMMIT");
|
|
69
|
+
return membership;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
try { await client.query("ROLLBACK"); } catch { /* preserve original failure */ }
|
|
72
|
+
return mapDuplicate(error);
|
|
73
|
+
} finally { client.release(); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Atomically creates the first active owner while serializing the project scope. */
|
|
77
|
+
async createInitialOwner(input: Omit<ProjectMembership, "role" | "status" | "joinedAt" | "updatedAt">): Promise<{ kind: "created" | "existing"; membership: ProjectMembership } | { kind: "already_initialized" }> {
|
|
78
|
+
const client = await this.pool.connect();
|
|
79
|
+
try {
|
|
80
|
+
await client.query("BEGIN");
|
|
81
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${input.tenantId}:${input.projectId}`]);
|
|
82
|
+
const existingResult = await client.query<MembershipRow>(`SELECT ${columns} FROM lattice_project_memberships WHERE tenant_id = $1 AND project_id = $2 FOR UPDATE`, [input.tenantId, input.projectId]);
|
|
83
|
+
if (existingResult.rows.length > 0) {
|
|
84
|
+
const existing = existingResult.rows.map(mapRow).find((item) => item.userId === input.userId && item.role === "owner" && item.status === "active");
|
|
85
|
+
await client.query("COMMIT");
|
|
86
|
+
return existing ? { kind: "existing", membership: existing } : { kind: "already_initialized" };
|
|
87
|
+
}
|
|
88
|
+
const idResult = await client.query("SELECT id FROM lattice_project_memberships WHERE tenant_id = $1 AND id = $2", [input.tenantId, input.id]);
|
|
89
|
+
if (idResult.rows.length > 0) throw new ProjectMembershipIdConflictError();
|
|
90
|
+
const inserted = await client.query<MembershipRow>(`INSERT INTO lattice_project_memberships (id, tenant_id, project_id, user_id, role, status, joined_at, updated_at) VALUES ($1, $2, $3, $4, 'owner', 'active', date_trunc('milliseconds', clock_timestamp()), date_trunc('milliseconds', clock_timestamp())) RETURNING ${columns}`, [input.id, input.tenantId, input.projectId, input.userId]);
|
|
91
|
+
const membership = mapRow(inserted.rows[0]);
|
|
92
|
+
await client.query("COMMIT");
|
|
93
|
+
return { kind: "created", membership };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
try { await client.query("ROLLBACK"); } catch { /* preserve original failure */ }
|
|
96
|
+
if (isDuplicate(error)) return mapDuplicate(error);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
finally { client.release(); }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Updates a role under a project lock with exact timestamp and owner safeguards. */
|
|
103
|
+
async updateRole(input: { tenantId: string; id: string; role: ProjectHumanRole; expectedUpdatedAt: Date }): Promise<ProjectMembershipMutationResult> {
|
|
104
|
+
return this.mutate(input.tenantId, input.id, input.expectedUpdatedAt, input.role, false);
|
|
105
|
+
}
|
|
106
|
+
/** Marks a membership removed under a project lock with exact timestamp and owner safeguards. */
|
|
107
|
+
async remove(input: { tenantId: string; id: string; expectedUpdatedAt: Date }): Promise<ProjectMembershipMutationResult> {
|
|
108
|
+
return this.mutate(input.tenantId, input.id, input.expectedUpdatedAt, undefined, true);
|
|
109
|
+
}
|
|
110
|
+
private async mutate(tenantId: string, id: string, expected: Date, role: ProjectHumanRole, remove: false): Promise<ProjectMembershipMutationResult>;
|
|
111
|
+
private async mutate(tenantId: string, id: string, expected: Date, role: undefined, remove: true): Promise<ProjectMembershipMutationResult>;
|
|
112
|
+
private async mutate(tenantId: string, id: string, expected: Date, role: ProjectHumanRole | undefined, remove: boolean): Promise<ProjectMembershipMutationResult> {
|
|
113
|
+
const client: PoolClient = await this.pool.connect();
|
|
114
|
+
try {
|
|
115
|
+
await client.query("BEGIN");
|
|
116
|
+
const identity = await client.query<{ project_id: unknown }>("SELECT project_id FROM lattice_project_memberships WHERE tenant_id = $1 AND id = $2", [tenantId, id]);
|
|
117
|
+
if (!identity.rows[0] || typeof identity.rows[0].project_id !== "string") { await client.query("COMMIT"); return { kind: "not_found" }; }
|
|
118
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${tenantId}:${identity.rows[0].project_id}`]);
|
|
119
|
+
const activeRows = await client.query<MembershipRow>(`SELECT ${columns} FROM lattice_project_memberships WHERE tenant_id = $1 AND project_id = $2 AND status = 'active' FOR UPDATE`, [tenantId, identity.rows[0].project_id]);
|
|
120
|
+
const lockedMemberships = activeRows.rows.map(mapRow);
|
|
121
|
+
let current = lockedMemberships.find((membership) => membership.id === id);
|
|
122
|
+
if (!current) {
|
|
123
|
+
const target = await client.query<MembershipRow>(`SELECT ${columns} FROM lattice_project_memberships WHERE tenant_id = $1 AND id = $2 FOR UPDATE`, [tenantId, id]);
|
|
124
|
+
if (!target.rows[0]) { await client.query("COMMIT"); return { kind: "not_found" }; }
|
|
125
|
+
current = mapRow(target.rows[0]);
|
|
126
|
+
}
|
|
127
|
+
if (current.updatedAt.getTime() !== expected.getTime()) { await client.query("COMMIT"); return { kind: "conflict" }; }
|
|
128
|
+
if (current.role === "owner" && current.status === "active" && (remove || role !== "owner")) {
|
|
129
|
+
const owners = lockedMemberships.filter((membership) => membership.id !== id && membership.role === "owner");
|
|
130
|
+
if (owners.length === 0) { await client.query("COMMIT"); return { kind: "last_owner" }; }
|
|
131
|
+
}
|
|
132
|
+
const result = await client.query<MembershipRow>(remove
|
|
133
|
+
? `UPDATE lattice_project_memberships SET status = 'removed', updated_at = GREATEST(date_trunc('milliseconds', updated_at) + interval '1 millisecond', date_trunc('milliseconds', clock_timestamp())) WHERE tenant_id = $1 AND id = $2 AND date_trunc('milliseconds', updated_at) = $3 RETURNING ${columns}`
|
|
134
|
+
: `UPDATE lattice_project_memberships SET role = $4, updated_at = GREATEST(date_trunc('milliseconds', updated_at) + interval '1 millisecond', date_trunc('milliseconds', clock_timestamp())) WHERE tenant_id = $1 AND id = $2 AND date_trunc('milliseconds', updated_at) = $3 RETURNING ${columns}`, remove ? [tenantId, id, expected] : [tenantId, id, expected, role]);
|
|
135
|
+
if (!result.rows[0]) { await client.query("COMMIT"); return { kind: "conflict" }; }
|
|
136
|
+
await client.query("COMMIT"); return { kind: remove ? "removed" : "updated", membership: mapRow(result.rows[0]) };
|
|
137
|
+
} catch (error) { try { await client.query("ROLLBACK"); } catch { /* preserve original failure */ } throw error; }
|
|
138
|
+
finally { client.release(); }
|
|
139
|
+
}
|
|
140
|
+
}
|