@axiom-lattice/pg-stores 1.0.37 → 1.0.39

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.
@@ -0,0 +1,334 @@
1
+ import { Pool, PoolConfig } from "pg";
2
+ import { MigrationManager } from "../migrations/migration";
3
+ import { createChannelIdentityMappingTables } from "../migrations/channel_identity_mapping_migration";
4
+
5
+ export interface ChannelIdentityMappingStoreOptions {
6
+ poolConfig: string | PoolConfig;
7
+ autoMigrate?: boolean;
8
+ }
9
+
10
+ export interface CreateChannelIdentityMappingInput {
11
+ channel: string;
12
+ channelAppId: string;
13
+ tenantId: string;
14
+ assistantId: string;
15
+ mappingMode: "user" | "group" | "hybrid";
16
+ externalSubjectType: "user" | "chat";
17
+ externalSubjectKey: string;
18
+ larkOpenId?: string;
19
+ larkChatId: string;
20
+ larkMessageId?: string;
21
+ threadId: string;
22
+ }
23
+
24
+ interface ChannelIdentityMappingRow {
25
+ id: string;
26
+ channel: string;
27
+ channel_app_id: string;
28
+ tenant_id: string;
29
+ assistant_id: string;
30
+ mapping_mode: "user" | "group" | "hybrid";
31
+ external_subject_type: "user" | "chat";
32
+ external_subject_key: string;
33
+ lark_open_id: string | null;
34
+ lark_chat_id: string;
35
+ lark_message_id: string | null;
36
+ thread_id: string;
37
+ created_at: Date;
38
+ updated_at: Date;
39
+ last_activity_at: Date;
40
+ }
41
+
42
+ export interface ChannelIdentityMapping {
43
+ id: string;
44
+ channel: string;
45
+ channelAppId: string;
46
+ tenantId: string;
47
+ assistantId: string;
48
+ mappingMode: "user" | "group" | "hybrid";
49
+ externalSubjectType: "user" | "chat";
50
+ externalSubjectKey: string;
51
+ larkOpenId?: string;
52
+ larkChatId: string;
53
+ larkMessageId?: string;
54
+ threadId: string;
55
+ createdAt: Date;
56
+ updatedAt: Date;
57
+ lastActivityAt: Date;
58
+ }
59
+
60
+ export class ChannelIdentityMappingStore {
61
+ private pool: Pool;
62
+ private migrationManager: MigrationManager;
63
+ private initialized = false;
64
+ private initPromise: Promise<void> | null = null;
65
+
66
+ constructor(options: ChannelIdentityMappingStoreOptions) {
67
+ this.pool =
68
+ typeof options.poolConfig === "string"
69
+ ? new Pool({ connectionString: options.poolConfig })
70
+ : new Pool(options.poolConfig);
71
+
72
+ this.migrationManager = new MigrationManager(this.pool);
73
+ this.migrationManager.register(createChannelIdentityMappingTables);
74
+
75
+ if (options.autoMigrate !== false) {
76
+ this.initialize().catch((error) => {
77
+ console.error("Failed to initialize ChannelIdentityMappingStore:", error);
78
+ throw error;
79
+ });
80
+ }
81
+ }
82
+
83
+ async initialize(): Promise<void> {
84
+ if (this.initialized) {
85
+ return;
86
+ }
87
+
88
+ if (this.initPromise) {
89
+ return this.initPromise;
90
+ }
91
+
92
+ this.initPromise = (async () => {
93
+ try {
94
+ await this.migrationManager.migrate();
95
+ this.initialized = true;
96
+ } finally {
97
+ this.initPromise = null;
98
+ }
99
+ })();
100
+
101
+ return this.initPromise;
102
+ }
103
+
104
+ async createMapping(
105
+ input: CreateChannelIdentityMappingInput,
106
+ ): Promise<ChannelIdentityMapping> {
107
+ await this.ensureInitialized();
108
+
109
+ const result = await this.pool.query<ChannelIdentityMappingRow>(
110
+ `
111
+ INSERT INTO channel_identity_mappings (
112
+ channel,
113
+ channel_app_id,
114
+ tenant_id,
115
+ assistant_id,
116
+ mapping_mode,
117
+ external_subject_type,
118
+ external_subject_key,
119
+ lark_open_id,
120
+ lark_chat_id,
121
+ lark_message_id,
122
+ thread_id
123
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
124
+ RETURNING *
125
+ `,
126
+ [
127
+ input.channel,
128
+ input.channelAppId,
129
+ input.tenantId,
130
+ input.assistantId,
131
+ input.mappingMode,
132
+ input.externalSubjectType,
133
+ input.externalSubjectKey,
134
+ input.larkOpenId || null,
135
+ input.larkChatId,
136
+ input.larkMessageId || null,
137
+ input.threadId,
138
+ ],
139
+ );
140
+
141
+ return mapRowToChannelIdentityMapping(result.rows[0]);
142
+ }
143
+
144
+ async getMappingBySubject(input: {
145
+ channel: string;
146
+ channelAppId: string;
147
+ tenantId: string;
148
+ assistantId: string;
149
+ externalSubjectKey: string;
150
+ }): Promise<ChannelIdentityMapping | null> {
151
+ await this.ensureInitialized();
152
+
153
+ const result = await this.pool.query<ChannelIdentityMappingRow>(
154
+ `
155
+ SELECT *
156
+ FROM channel_identity_mappings
157
+ WHERE channel = $1
158
+ AND channel_app_id = $2
159
+ AND tenant_id = $3
160
+ AND assistant_id = $4
161
+ AND external_subject_key = $5
162
+ LIMIT 1
163
+ `,
164
+ [
165
+ input.channel,
166
+ input.channelAppId,
167
+ input.tenantId,
168
+ input.assistantId,
169
+ input.externalSubjectKey,
170
+ ],
171
+ );
172
+
173
+ if (result.rows.length === 0) {
174
+ return null;
175
+ }
176
+
177
+ return mapRowToChannelIdentityMapping(result.rows[0]);
178
+ }
179
+
180
+ async claimInboundReceipt(input: {
181
+ channel: string;
182
+ channelAppId: string;
183
+ externalMessageId: string;
184
+ tenantId: string;
185
+ }): Promise<{ accepted: boolean; status: "processing" | "completed" }> {
186
+ await this.ensureInitialized();
187
+
188
+ const result = await this.pool.query<{
189
+ source: "inserted" | "existing" | "retried";
190
+ status: "processing" | "completed" | "failed";
191
+ }>(
192
+ `
193
+ WITH existing AS (
194
+ SELECT status
195
+ FROM channel_inbound_message_receipts
196
+ WHERE channel = $1
197
+ AND channel_app_id = $2
198
+ AND external_message_id = $3
199
+ AND tenant_id = $4
200
+ ),
201
+ inserted AS (
202
+ INSERT INTO channel_inbound_message_receipts (
203
+ channel,
204
+ channel_app_id,
205
+ external_message_id,
206
+ tenant_id,
207
+ status
208
+ )
209
+ SELECT $1, $2, $3, $4, 'processing'
210
+ WHERE NOT EXISTS (SELECT 1 FROM existing)
211
+ RETURNING 'inserted'::text AS source, status
212
+ ),
213
+ retried AS (
214
+ UPDATE channel_inbound_message_receipts
215
+ SET status = 'processing', updated_at = CURRENT_TIMESTAMP
216
+ WHERE channel = $1
217
+ AND channel_app_id = $2
218
+ AND external_message_id = $3
219
+ AND tenant_id = $4
220
+ AND EXISTS (SELECT 1 FROM existing WHERE status = 'failed')
221
+ RETURNING 'retried'::text AS source, status
222
+ )
223
+ SELECT source, status FROM inserted
224
+ UNION ALL
225
+ SELECT source, status FROM retried
226
+ UNION ALL
227
+ SELECT 'existing'::text AS source, status FROM existing
228
+ WHERE EXISTS (SELECT 1 FROM existing WHERE status IN ('processing', 'completed'))
229
+ LIMIT 1
230
+ `,
231
+ [
232
+ input.channel,
233
+ input.channelAppId,
234
+ input.externalMessageId,
235
+ input.tenantId,
236
+ ],
237
+ );
238
+
239
+ const source = result.rows[0]?.source;
240
+ const status = result.rows[0]?.status || "processing";
241
+
242
+ if (source === "inserted" || source === "retried") {
243
+ return { accepted: true, status: "processing" };
244
+ }
245
+
246
+ if (status === "completed") {
247
+ return { accepted: false, status: "completed" };
248
+ }
249
+
250
+ return { accepted: false, status: "processing" };
251
+ }
252
+
253
+ async markInboundReceiptCompleted(input: {
254
+ channel: string;
255
+ channelAppId: string;
256
+ externalMessageId: string;
257
+ tenantId: string;
258
+ threadId: string;
259
+ }): Promise<void> {
260
+ await this.ensureInitialized();
261
+
262
+ await this.pool.query(
263
+ `
264
+ UPDATE channel_inbound_message_receipts
265
+ SET status = 'completed', thread_id = $1, updated_at = CURRENT_TIMESTAMP
266
+ WHERE channel = $2
267
+ AND channel_app_id = $3
268
+ AND external_message_id = $4
269
+ AND tenant_id = $5
270
+ `,
271
+ [
272
+ input.threadId,
273
+ input.channel,
274
+ input.channelAppId,
275
+ input.externalMessageId,
276
+ input.tenantId,
277
+ ],
278
+ );
279
+ }
280
+
281
+ async markInboundReceiptFailed(input: {
282
+ channel: string;
283
+ channelAppId: string;
284
+ externalMessageId: string;
285
+ tenantId: string;
286
+ }): Promise<void> {
287
+ await this.ensureInitialized();
288
+
289
+ await this.pool.query(
290
+ `
291
+ UPDATE channel_inbound_message_receipts
292
+ SET status = 'failed', updated_at = CURRENT_TIMESTAMP
293
+ WHERE channel = $1
294
+ AND channel_app_id = $2
295
+ AND external_message_id = $3
296
+ AND tenant_id = $4
297
+ `,
298
+ [
299
+ input.channel,
300
+ input.channelAppId,
301
+ input.externalMessageId,
302
+ input.tenantId,
303
+ ],
304
+ );
305
+ }
306
+
307
+ private async ensureInitialized(): Promise<void> {
308
+ if (!this.initialized) {
309
+ await this.initialize();
310
+ }
311
+ }
312
+ }
313
+
314
+ function mapRowToChannelIdentityMapping(
315
+ row: ChannelIdentityMappingRow,
316
+ ): ChannelIdentityMapping {
317
+ return {
318
+ id: row.id,
319
+ channel: row.channel,
320
+ channelAppId: row.channel_app_id,
321
+ tenantId: row.tenant_id,
322
+ assistantId: row.assistant_id,
323
+ mappingMode: row.mapping_mode,
324
+ externalSubjectType: row.external_subject_type,
325
+ externalSubjectKey: row.external_subject_key,
326
+ larkOpenId: row.lark_open_id || undefined,
327
+ larkChatId: row.lark_chat_id,
328
+ larkMessageId: row.lark_message_id || undefined,
329
+ threadId: row.thread_id,
330
+ createdAt: row.created_at,
331
+ updatedAt: row.updated_at,
332
+ lastActivityAt: row.last_activity_at,
333
+ };
334
+ }
@@ -0,0 +1,304 @@
1
+ import { Pool, PoolConfig } from "pg";
2
+ import {
3
+ ChannelInstallation,
4
+ ChannelInstallationStore,
5
+ CreateChannelInstallationRequest,
6
+ LarkChannelInstallationConfig,
7
+ UpdateChannelInstallationRequest,
8
+ } from "@axiom-lattice/protocols";
9
+ import { MigrationManager } from "../migrations/migration";
10
+ import { createChannelInstallationsTable } from "../migrations/channel_installation_migrations";
11
+ import { decrypt, encrypt } from "@axiom-lattice/core";
12
+
13
+ export interface PostgreSQLChannelInstallationStoreOptions {
14
+ poolConfig: string | PoolConfig;
15
+ autoMigrate?: boolean;
16
+ }
17
+
18
+ type ChannelInstallationRow = {
19
+ id: string;
20
+ tenant_id: string;
21
+ channel: "lark";
22
+ name: string | null;
23
+ config: Record<string, unknown>;
24
+ created_at: Date;
25
+ updated_at: Date;
26
+ };
27
+
28
+ export class PostgreSQLChannelInstallationStore
29
+ implements ChannelInstallationStore
30
+ {
31
+ private pool: Pool;
32
+ private migrationManager: MigrationManager;
33
+ private initialized = false;
34
+ private initPromise: Promise<void> | null = null;
35
+
36
+ constructor(options: PostgreSQLChannelInstallationStoreOptions) {
37
+ this.pool =
38
+ typeof options.poolConfig === "string"
39
+ ? new Pool({ connectionString: options.poolConfig })
40
+ : new Pool(options.poolConfig);
41
+
42
+ this.migrationManager = new MigrationManager(this.pool);
43
+ this.migrationManager.register(createChannelInstallationsTable);
44
+
45
+ if (options.autoMigrate !== false) {
46
+ this.initialize().catch((error) => {
47
+ console.error(
48
+ "Failed to initialize PostgreSQLChannelInstallationStore:",
49
+ error,
50
+ );
51
+ throw error;
52
+ });
53
+ }
54
+ }
55
+
56
+ async initialize(): Promise<void> {
57
+ if (this.initialized) {
58
+ return;
59
+ }
60
+
61
+ if (this.initPromise) {
62
+ return this.initPromise;
63
+ }
64
+
65
+ this.initPromise = (async () => {
66
+ try {
67
+ await this.migrationManager.migrate();
68
+ this.initialized = true;
69
+ } finally {
70
+ this.initPromise = null;
71
+ }
72
+ })();
73
+
74
+ return this.initPromise;
75
+ }
76
+
77
+ async getInstallationById(
78
+ installationId: string,
79
+ ): Promise<ChannelInstallation | null> {
80
+ await this.ensureInitialized();
81
+
82
+ const result = await this.pool.query<ChannelInstallationRow>(
83
+ `
84
+ SELECT id, tenant_id, channel, name, config, created_at, updated_at
85
+ FROM lattice_channel_installations
86
+ WHERE id = $1
87
+ LIMIT 1
88
+ `,
89
+ [installationId],
90
+ );
91
+
92
+ if (result.rows.length === 0) {
93
+ return null;
94
+ }
95
+
96
+ return this.mapRowToInstallation(result.rows[0]);
97
+ }
98
+
99
+ async getInstallationsByTenant(
100
+ tenantId: string,
101
+ channel?: "lark",
102
+ ): Promise<ChannelInstallation[]> {
103
+ await this.ensureInitialized();
104
+
105
+ const result = channel
106
+ ? await this.pool.query<ChannelInstallationRow>(
107
+ `
108
+ SELECT id, tenant_id, channel, name, config, created_at, updated_at
109
+ FROM lattice_channel_installations
110
+ WHERE tenant_id = $1 AND channel = $2
111
+ ORDER BY created_at DESC
112
+ `,
113
+ [tenantId, channel],
114
+ )
115
+ : await this.pool.query<ChannelInstallationRow>(
116
+ `
117
+ SELECT id, tenant_id, channel, name, config, created_at, updated_at
118
+ FROM lattice_channel_installations
119
+ WHERE tenant_id = $1
120
+ ORDER BY created_at DESC
121
+ `,
122
+ [tenantId],
123
+ );
124
+
125
+ return result.rows.map((row) => this.mapRowToInstallation(row));
126
+ }
127
+
128
+ async createInstallation(
129
+ tenantId: string,
130
+ installationId: string,
131
+ data: CreateChannelInstallationRequest,
132
+ ): Promise<ChannelInstallation> {
133
+ await this.ensureInitialized();
134
+
135
+ const now = new Date();
136
+ const encryptedConfig = this.encryptSecrets(data.config);
137
+
138
+ await this.pool.query(
139
+ `
140
+ INSERT INTO lattice_channel_installations (
141
+ id, tenant_id, channel, name, config, created_at, updated_at
142
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
143
+ `,
144
+ [
145
+ installationId,
146
+ tenantId,
147
+ data.channel,
148
+ data.name || null,
149
+ JSON.stringify(encryptedConfig),
150
+ now,
151
+ now,
152
+ ],
153
+ );
154
+
155
+ return {
156
+ id: installationId,
157
+ tenantId,
158
+ channel: data.channel,
159
+ name: data.name,
160
+ config: data.config,
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ };
164
+ }
165
+
166
+ async updateInstallation(
167
+ tenantId: string,
168
+ installationId: string,
169
+ updates: UpdateChannelInstallationRequest,
170
+ ): Promise<ChannelInstallation | null> {
171
+ await this.ensureInitialized();
172
+
173
+ const existing = await this.getInstallationById(installationId);
174
+ if (!existing || existing.tenantId !== tenantId) {
175
+ return null;
176
+ }
177
+
178
+ const mergedConfig = {
179
+ ...existing.config,
180
+ ...(updates.config || {}),
181
+ };
182
+ const now = new Date();
183
+
184
+ await this.pool.query(
185
+ `
186
+ UPDATE lattice_channel_installations
187
+ SET name = $1,
188
+ config = $2,
189
+ updated_at = $3
190
+ WHERE tenant_id = $4 AND id = $5
191
+ `,
192
+ [
193
+ updates.name ?? existing.name ?? null,
194
+ JSON.stringify(this.encryptSecrets(mergedConfig)),
195
+ now,
196
+ tenantId,
197
+ installationId,
198
+ ],
199
+ );
200
+
201
+ return {
202
+ ...existing,
203
+ name: updates.name ?? existing.name,
204
+ config: mergedConfig,
205
+ updatedAt: now,
206
+ };
207
+ }
208
+
209
+ async deleteInstallation(
210
+ tenantId: string,
211
+ installationId: string,
212
+ ): Promise<boolean> {
213
+ await this.ensureInitialized();
214
+
215
+ const result = await this.pool.query(
216
+ `
217
+ DELETE FROM lattice_channel_installations
218
+ WHERE tenant_id = $1 AND id = $2
219
+ `,
220
+ [tenantId, installationId],
221
+ );
222
+
223
+ return (result.rowCount || 0) > 0;
224
+ }
225
+
226
+ private async ensureInitialized(): Promise<void> {
227
+ if (!this.initialized) {
228
+ await this.initialize();
229
+ }
230
+ }
231
+
232
+ private mapRowToInstallation(
233
+ row: ChannelInstallationRow,
234
+ ): ChannelInstallation {
235
+ return {
236
+ id: row.id,
237
+ tenantId: row.tenant_id,
238
+ channel: row.channel,
239
+ name: row.name || undefined,
240
+ config: this.decryptSecrets(
241
+ typeof row.config === "string"
242
+ ? JSON.parse(row.config)
243
+ : row.config,
244
+ ),
245
+ createdAt: row.created_at,
246
+ updatedAt: row.updated_at,
247
+ };
248
+ }
249
+
250
+ private encryptSecrets(
251
+ config: LarkChannelInstallationConfig,
252
+ ): Record<string, unknown> {
253
+ return {
254
+ ...config,
255
+ appSecret:
256
+ typeof config.appSecret === "string"
257
+ ? encrypt(config.appSecret)
258
+ : config.appSecret,
259
+ verificationToken:
260
+ typeof config.verificationToken === "string"
261
+ ? encrypt(config.verificationToken)
262
+ : config.verificationToken,
263
+ encryptKey:
264
+ typeof config.encryptKey === "string"
265
+ ? encrypt(config.encryptKey)
266
+ : config.encryptKey,
267
+ };
268
+ }
269
+
270
+ private decryptSecrets(
271
+ config: Record<string, unknown>,
272
+ ): LarkChannelInstallationConfig {
273
+ return {
274
+ appId: String(config.appId || ""),
275
+ appSecret:
276
+ typeof config.appSecret === "string"
277
+ ? decrypt(config.appSecret)
278
+ : "",
279
+ verificationToken:
280
+ typeof config.verificationToken === "string"
281
+ ? decrypt(config.verificationToken)
282
+ : undefined,
283
+ encryptKey:
284
+ typeof config.encryptKey === "string"
285
+ ? decrypt(config.encryptKey)
286
+ : undefined,
287
+ mappingMode:
288
+ config.mappingMode === "user" ||
289
+ config.mappingMode === "group" ||
290
+ config.mappingMode === "hybrid"
291
+ ? config.mappingMode
292
+ : "hybrid",
293
+ assistantId: String(config.assistantId || ""),
294
+ workspaceId:
295
+ typeof config.workspaceId === "string"
296
+ ? config.workspaceId
297
+ : undefined,
298
+ projectId:
299
+ typeof config.projectId === "string"
300
+ ? config.projectId
301
+ : undefined,
302
+ };
303
+ }
304
+ }