@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.d.mts +195 -15
  4. package/dist/index.d.ts +195 -15
  5. package/dist/index.js +1309 -199
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1295 -191
  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__/PostgreSQLChannelInstallationStore.test.ts +16 -0
  12. package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
  13. package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
  14. package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
  15. package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
  16. package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
  17. package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
  18. package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
  19. package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +74 -0
  20. package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
  21. package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +96 -0
  22. package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
  23. package/src/__tests__/ThreadMessageQueueStore.test.ts +113 -0
  24. package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
  25. package/src/__tests__/task-files.test.ts +4 -3
  26. package/src/createPgStoreConfig.ts +14 -0
  27. package/src/index.ts +7 -0
  28. package/src/migrations/add_trusted_run_context_column.ts +18 -0
  29. package/src/migrations/migration.ts +2 -1
  30. package/src/migrations/project_room_migration.ts +128 -0
  31. package/src/migrations/task_migration.ts +15 -0
  32. package/src/migrations/task_work_items_migration.ts +29 -1
  33. package/src/stores/ChannelBindingStore.ts +99 -59
  34. package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
  35. package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
  36. package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
  37. package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
  38. package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
  39. package/src/stores/PostgreSQLTaskStore.ts +89 -3
  40. package/src/stores/PostgreSQLTaskWorkItemStore.ts +159 -8
  41. 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 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
+ }
@@ -4,8 +4,7 @@ import {
4
4
  ChannelInstallation,
5
5
  ChannelInstallationStore,
6
6
  ChannelInstallationType,
7
- CreateChannelInstallationRequest,
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: CreateChannelInstallationRequest,
181
+ data: CreateChannelInstallationInput,
183
182
  ): Promise<ChannelInstallation> {
184
183
  await this.ensureInitialized();
185
184
 
186
185
  const now = new Date();
187
- const encryptedConfig = this.encryptSecrets(data.config as unknown as Record<string, unknown>);
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: data.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
- ) as unknown as LarkChannelInstallationConfig,
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
- typeof config.appSecret === "string"
327
- ? encrypt(config.appSecret)
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
- typeof config.appSecret === "string"
347
- ? decrypt(config.appSecret)
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
+ }