@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProjectRoomMention,
|
|
3
|
+
ProjectRoomMessage,
|
|
4
|
+
ProjectRoomMessageAuthor,
|
|
5
|
+
ProjectRoomMessageSource,
|
|
6
|
+
ProjectRoomMessageStore,
|
|
7
|
+
} from "@axiom-lattice/protocols";
|
|
8
|
+
import type { Pool } from "pg";
|
|
9
|
+
|
|
10
|
+
type MessageRow = {
|
|
11
|
+
id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; room_id: unknown;
|
|
12
|
+
author: unknown; content: unknown; mentions: unknown; reply_to_message_id: unknown; source: unknown;
|
|
13
|
+
source_id: unknown; idempotency_key: unknown; created_at: unknown;
|
|
14
|
+
};
|
|
15
|
+
type PostgreSQLError = { code?: unknown; constraint?: unknown };
|
|
16
|
+
|
|
17
|
+
const columns = "id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at";
|
|
18
|
+
|
|
19
|
+
/** Indicates that a tenant-scoped room message ID already exists. */
|
|
20
|
+
export class ProjectRoomMessageIdConflictError extends Error {
|
|
21
|
+
/** Creates an accurate tenant-scoped message ID collision error. */
|
|
22
|
+
constructor(tenantId: string, id: string) {
|
|
23
|
+
super(`Project room message ID '${id}' already exists in tenant '${tenantId}'`);
|
|
24
|
+
this.name = "ProjectRoomMessageIdConflictError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Indicates that ordinary creation reused a room-scoped idempotency key. */
|
|
29
|
+
export class DuplicateProjectRoomMessageIdempotencyKeyError extends Error {
|
|
30
|
+
/** Creates an accurate tenant and room-scoped idempotency collision error. */
|
|
31
|
+
constructor(tenantId: string, roomId: string, idempotencyKey: string) {
|
|
32
|
+
super(`Project room message idempotency key '${idempotencyKey}' already exists in tenant '${tenantId}' room '${roomId}'`);
|
|
33
|
+
this.name = "DuplicateProjectRoomMessageIdempotencyKeyError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function hasOnlyKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
41
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
42
|
+
}
|
|
43
|
+
function mapAuthor(value: unknown): ProjectRoomMessageAuthor | undefined {
|
|
44
|
+
if (!isRecord(value) || typeof value.type !== "string") return undefined;
|
|
45
|
+
if (value.type === "human" && hasOnlyKeys(value, ["type", "userId"]) && typeof value.userId === "string") return { type: "human", userId: value.userId };
|
|
46
|
+
if (value.type === "bot" && hasOnlyKeys(value, ["type", "membershipId", "assistantId"])
|
|
47
|
+
&& typeof value.membershipId === "string" && typeof value.assistantId === "string") {
|
|
48
|
+
return { type: "bot", membershipId: value.membershipId, assistantId: value.assistantId };
|
|
49
|
+
}
|
|
50
|
+
if (value.type === "system" && hasOnlyKeys(value, ["type"])) return { type: "system" };
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
function mapMention(value: unknown): ProjectRoomMention | undefined {
|
|
54
|
+
if (!isRecord(value) || typeof value.type !== "string") return undefined;
|
|
55
|
+
if (value.type === "bot" && hasOnlyKeys(value, ["type", "membershipId"]) && typeof value.membershipId === "string") {
|
|
56
|
+
return { type: "bot", membershipId: value.membershipId };
|
|
57
|
+
}
|
|
58
|
+
if (value.type === "team" && hasOnlyKeys(value, ["type"])) return { type: "team" };
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function isSource(value: unknown): value is ProjectRoomMessageSource {
|
|
62
|
+
return value === "user" || value === "agent" || value === "task" || value === "routine" || value === "system";
|
|
63
|
+
}
|
|
64
|
+
function mapRow(row: MessageRow): ProjectRoomMessage {
|
|
65
|
+
const author = mapAuthor(row.author);
|
|
66
|
+
const content = isRecord(row.content) && row.content.type === "text" && typeof row.content.text === "string"
|
|
67
|
+
&& hasOnlyKeys(row.content, ["type", "text"]) ? { type: "text" as const, text: row.content.text } : undefined;
|
|
68
|
+
const mentions = Array.isArray(row.mentions) ? row.mentions.map(mapMention) : undefined;
|
|
69
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string"
|
|
70
|
+
|| typeof row.project_id !== "string" || typeof row.room_id !== "string" || !author || !content || !mentions
|
|
71
|
+
|| mentions.some((mention) => mention === undefined)
|
|
72
|
+
|| (row.reply_to_message_id !== null && typeof row.reply_to_message_id !== "string") || !isSource(row.source)
|
|
73
|
+
|| (row.source_id !== null && typeof row.source_id !== "string")
|
|
74
|
+
|| (row.idempotency_key !== null && typeof row.idempotency_key !== "string")
|
|
75
|
+
|| !(row.created_at instanceof Date) || Number.isNaN(row.created_at.getTime())) {
|
|
76
|
+
throw new Error("Invalid project room message row");
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id,
|
|
80
|
+
roomId: row.room_id, author, content, mentions: mentions as ProjectRoomMention[],
|
|
81
|
+
...(row.reply_to_message_id === null ? {} : { replyToMessageId: row.reply_to_message_id }),
|
|
82
|
+
source: row.source, ...(row.source_id === null ? {} : { sourceId: row.source_id }),
|
|
83
|
+
...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt: row.created_at,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function isUniqueViolation(error: unknown): error is PostgreSQLError {
|
|
87
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
88
|
+
&& (error as PostgreSQLError).code === "23505";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Persists tenant-isolated project room messages in PostgreSQL. */
|
|
92
|
+
export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStore {
|
|
93
|
+
/** Creates a store using an externally managed shared pool. */
|
|
94
|
+
constructor(options: { pool: Pool }) { this.pool = options.pool; }
|
|
95
|
+
private readonly pool: Pool;
|
|
96
|
+
|
|
97
|
+
/** Creates a room message and maps known uniqueness failures to typed errors. */
|
|
98
|
+
async create(input: Omit<ProjectRoomMessage, "createdAt">): Promise<ProjectRoomMessage> {
|
|
99
|
+
try {
|
|
100
|
+
const result = await this.pool.query<MessageRow>(
|
|
101
|
+
`INSERT INTO lattice_project_room_messages
|
|
102
|
+
(id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at)
|
|
103
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, date_trunc('milliseconds', clock_timestamp()))
|
|
104
|
+
RETURNING ${columns}`,
|
|
105
|
+
this.parameters(input),
|
|
106
|
+
);
|
|
107
|
+
return mapRow(result.rows[0]);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (isUniqueViolation(error) && error.constraint === "lattice_project_room_messages_pkey") {
|
|
110
|
+
throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
|
|
111
|
+
}
|
|
112
|
+
if (isUniqueViolation(error) && error.constraint === "uq_lattice_project_room_messages_idempotency") {
|
|
113
|
+
throw new DuplicateProjectRoomMessageIdempotencyKeyError(input.tenantId, input.roomId, input.idempotencyKey ?? "");
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Atomically creates or returns the canonical message for a room-scoped idempotency key. */
|
|
120
|
+
async createIdempotent(
|
|
121
|
+
input: Omit<ProjectRoomMessage, "createdAt"> & { idempotencyKey: string },
|
|
122
|
+
): Promise<ProjectRoomMessage> {
|
|
123
|
+
try {
|
|
124
|
+
const result = await this.pool.query<MessageRow>(
|
|
125
|
+
`INSERT INTO lattice_project_room_messages
|
|
126
|
+
(id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at)
|
|
127
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, date_trunc('milliseconds', clock_timestamp()))
|
|
128
|
+
ON CONFLICT (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL
|
|
129
|
+
DO UPDATE SET idempotency_key = lattice_project_room_messages.idempotency_key
|
|
130
|
+
RETURNING ${columns}`,
|
|
131
|
+
this.parameters(input),
|
|
132
|
+
);
|
|
133
|
+
return mapRow(result.rows[0]);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (isUniqueViolation(error) && error.constraint === "lattice_project_room_messages_pkey") {
|
|
136
|
+
throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Lists messages newest first using an exclusive stable cursor and a clamped limit. */
|
|
143
|
+
async list(input: {
|
|
144
|
+
tenantId: string; roomId: string; before?: { createdAt: Date; id: string }; limit: number;
|
|
145
|
+
}): Promise<ProjectRoomMessage[]> {
|
|
146
|
+
if (input.before && (!(input.before.createdAt instanceof Date) || Number.isNaN(input.before.createdAt.getTime()))) {
|
|
147
|
+
throw new RangeError("Project room message cursor date is invalid");
|
|
148
|
+
}
|
|
149
|
+
const limit = Math.min(100, Math.max(1, Math.trunc(Number.isFinite(input.limit) ? input.limit : 1)));
|
|
150
|
+
const result = input.before
|
|
151
|
+
? await this.pool.query<MessageRow>(
|
|
152
|
+
`SELECT ${columns} FROM lattice_project_room_messages
|
|
153
|
+
WHERE tenant_id = $1 AND room_id = $2 AND (created_at < $3 OR (created_at = $3 AND id < $4))
|
|
154
|
+
ORDER BY created_at DESC, id DESC LIMIT $5`,
|
|
155
|
+
[input.tenantId, input.roomId, input.before.createdAt, input.before.id, limit],
|
|
156
|
+
)
|
|
157
|
+
: await this.pool.query<MessageRow>(
|
|
158
|
+
`SELECT ${columns} FROM lattice_project_room_messages
|
|
159
|
+
WHERE tenant_id = $1 AND room_id = $2 ORDER BY created_at DESC, id DESC LIMIT $3`,
|
|
160
|
+
[input.tenantId, input.roomId, limit],
|
|
161
|
+
);
|
|
162
|
+
return result.rows.map(mapRow);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Finds a message by tenant-scoped ID. */
|
|
166
|
+
async findById(tenantId: string, id: string): Promise<ProjectRoomMessage | null> {
|
|
167
|
+
const result = await this.pool.query<MessageRow>(
|
|
168
|
+
`SELECT ${columns} FROM lattice_project_room_messages WHERE tenant_id = $1 AND id = $2`, [tenantId, id],
|
|
169
|
+
);
|
|
170
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private parameters(input: Omit<ProjectRoomMessage, "createdAt">): unknown[] {
|
|
174
|
+
return [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content),
|
|
175
|
+
JSON.stringify(input.mentions), input.replyToMessageId ?? null, input.source, input.sourceId ?? null, input.idempotencyKey ?? null];
|
|
176
|
+
}
|
|
177
|
+
}
|