@axiom-lattice/pg-stores 1.0.13 → 1.0.14

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,50 @@
1
+ /**
2
+ * Project table migrations
3
+ */
4
+
5
+ import { PoolClient } from "pg";
6
+ import { Migration } from "./migration";
7
+
8
+ /**
9
+ * Initial migration: Create projects table
10
+ */
11
+ export const createProjectsTable: Migration = {
12
+ version: 6,
13
+ name: "create_projects_table",
14
+ up: async (client: PoolClient) => {
15
+ await client.query(`
16
+ CREATE TABLE IF NOT EXISTS lattice_projects (
17
+ id VARCHAR(255) NOT NULL,
18
+ tenant_id VARCHAR(255) NOT NULL,
19
+ workspace_id VARCHAR(255) NOT NULL,
20
+ name VARCHAR(255) NOT NULL,
21
+ description TEXT,
22
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
23
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
24
+ PRIMARY KEY (id, tenant_id)
25
+ )
26
+ `);
27
+
28
+ // Create indexes for better query performance
29
+ await client.query(`
30
+ CREATE INDEX IF NOT EXISTS idx_lattice_projects_tenant_id
31
+ ON lattice_projects(tenant_id)
32
+ `);
33
+
34
+ await client.query(`
35
+ CREATE INDEX IF NOT EXISTS idx_lattice_projects_workspace_id
36
+ ON lattice_projects(workspace_id)
37
+ `);
38
+
39
+ await client.query(`
40
+ CREATE INDEX IF NOT EXISTS idx_lattice_projects_created_at
41
+ ON lattice_projects(created_at DESC)
42
+ `);
43
+ },
44
+ down: async (client: PoolClient) => {
45
+ await client.query("DROP INDEX IF EXISTS idx_lattice_projects_created_at");
46
+ await client.query("DROP INDEX IF EXISTS idx_lattice_projects_workspace_id");
47
+ await client.query("DROP INDEX IF EXISTS idx_lattice_projects_tenant_id");
48
+ await client.query("DROP TABLE IF EXISTS lattice_projects");
49
+ },
50
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Workspace table migrations
3
+ */
4
+
5
+ import { PoolClient } from "pg";
6
+ import { Migration } from "./migration";
7
+
8
+ /**
9
+ * Initial migration: Create workspaces table
10
+ */
11
+ export const createWorkspacesTable: Migration = {
12
+ version: 5,
13
+ name: "create_workspaces_table",
14
+ up: async (client: PoolClient) => {
15
+ await client.query(`
16
+ CREATE TABLE IF NOT EXISTS lattice_workspaces (
17
+ id VARCHAR(255) NOT NULL,
18
+ tenant_id VARCHAR(255) NOT NULL,
19
+ name VARCHAR(255) NOT NULL,
20
+ description TEXT,
21
+ storage_type VARCHAR(50) NOT NULL DEFAULT 'sandbox',
22
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
23
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
24
+ PRIMARY KEY (id, tenant_id)
25
+ )
26
+ `);
27
+
28
+ // Create indexes for better query performance
29
+ await client.query(`
30
+ CREATE INDEX IF NOT EXISTS idx_lattice_workspaces_tenant_id
31
+ ON lattice_workspaces(tenant_id)
32
+ `);
33
+
34
+ await client.query(`
35
+ CREATE INDEX IF NOT EXISTS idx_lattice_workspaces_created_at
36
+ ON lattice_workspaces(created_at DESC)
37
+ `);
38
+ },
39
+ down: async (client: PoolClient) => {
40
+ await client.query("DROP INDEX IF EXISTS idx_lattice_workspaces_created_at");
41
+ await client.query("DROP INDEX IF EXISTS idx_lattice_workspaces_tenant_id");
42
+ await client.query("DROP TABLE IF EXISTS lattice_workspaces");
43
+ },
44
+ };
@@ -0,0 +1,434 @@
1
+ /**
2
+ * PostgreSQL implementation of DatabaseConfigStore
3
+ */
4
+
5
+ import { Pool, PoolClient, PoolConfig } from 'pg';
6
+ import {
7
+ DatabaseConfigStore,
8
+ DatabaseConfigEntry,
9
+ CreateDatabaseConfigRequest,
10
+ UpdateDatabaseConfigRequest,
11
+ DatabaseConfig,
12
+ } from '@axiom-lattice/protocols';
13
+ import { MigrationManager } from '../migrations/migration';
14
+ import { createDatabaseConfigsTable } from '../migrations/database_config_migrations';
15
+ import { encrypt, decrypt } from '@axiom-lattice/core';
16
+
17
+ /**
18
+ * PostgreSQL DatabaseConfigStore options
19
+ */
20
+ export interface PostgreSQLDatabaseConfigStoreOptions {
21
+ /**
22
+ * PostgreSQL connection pool configuration
23
+ * Can be a connection string or PoolConfig object
24
+ */
25
+ poolConfig: string | PoolConfig;
26
+
27
+ /**
28
+ * Whether to run migrations automatically on initialization
29
+ * @default true
30
+ */
31
+ autoMigrate?: boolean;
32
+ }
33
+
34
+ /**
35
+ * PostgreSQL implementation of DatabaseConfigStore
36
+ *
37
+ * Features:
38
+ * - Multi-tenant isolation via tenant_id
39
+ * - Automatic password encryption/decryption
40
+ * - Unique constraint on (tenant_id, key)
41
+ */
42
+ export class PostgreSQLDatabaseConfigStore implements DatabaseConfigStore {
43
+ private pool: Pool;
44
+ private migrationManager: MigrationManager;
45
+ private initialized: boolean = false;
46
+ private initPromise: Promise<void> | null = null;
47
+
48
+ constructor(options: PostgreSQLDatabaseConfigStoreOptions) {
49
+ // Create Pool from config
50
+ if (typeof options.poolConfig === 'string') {
51
+ this.pool = new Pool({ connectionString: options.poolConfig });
52
+ } else {
53
+ this.pool = new Pool(options.poolConfig);
54
+ }
55
+
56
+ this.migrationManager = new MigrationManager(this.pool);
57
+ this.migrationManager.register(createDatabaseConfigsTable);
58
+
59
+ // Auto-migrate by default
60
+ if (options.autoMigrate !== false) {
61
+ this.initialize().catch((error) => {
62
+ console.error('Failed to initialize PostgreSQLDatabaseConfigStore:', error);
63
+ throw error;
64
+ });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Initialize the store and run migrations
70
+ * Uses a promise-based lock to prevent concurrent initialization
71
+ */
72
+ async initialize(): Promise<void> {
73
+ if (this.initialized) {
74
+ return;
75
+ }
76
+
77
+ if (this.initPromise) {
78
+ return this.initPromise;
79
+ }
80
+
81
+ this.initPromise = (async () => {
82
+ try {
83
+ await this.migrationManager.migrate();
84
+ this.initialized = true;
85
+ } finally {
86
+ this.initPromise = null;
87
+ }
88
+ })();
89
+
90
+ return this.initPromise;
91
+ }
92
+
93
+ /**
94
+ * Get all database configurations for a tenant
95
+ */
96
+ async getAllConfigs(tenantId: string): Promise<DatabaseConfigEntry[]> {
97
+ await this.ensureInitialized();
98
+
99
+ const result = await this.pool.query<{
100
+ id: string;
101
+ tenant_id: string;
102
+ key: string;
103
+ name: string | null;
104
+ description: string | null;
105
+ config: any;
106
+ created_at: Date;
107
+ updated_at: Date;
108
+ }>(
109
+ `
110
+ SELECT id, tenant_id, key, name, description, config, created_at, updated_at
111
+ FROM lattice_database_configs
112
+ WHERE tenant_id = $1
113
+ ORDER BY created_at DESC
114
+ `,
115
+ [tenantId]
116
+ );
117
+
118
+ return result.rows.map((row) => this.mapRowToEntry(row));
119
+ }
120
+
121
+ /**
122
+ * Get all database configurations across all tenants
123
+ */
124
+ async getAllConfigsWithoutTenant(): Promise<DatabaseConfigEntry[]> {
125
+ await this.ensureInitialized();
126
+
127
+ const result = await this.pool.query<{
128
+ id: string;
129
+ tenant_id: string;
130
+ key: string;
131
+ name: string | null;
132
+ description: string | null;
133
+ config: any;
134
+ created_at: Date;
135
+ updated_at: Date;
136
+ }>(
137
+ `
138
+ SELECT id, tenant_id, key, name, description, config, created_at, updated_at
139
+ FROM lattice_database_configs
140
+ ORDER BY created_at DESC
141
+ `
142
+ );
143
+
144
+ return result.rows.map((row) => this.mapRowToEntry(row));
145
+ }
146
+
147
+ /**
148
+ * Get database configuration by ID
149
+ */
150
+ async getConfigById(tenantId: string, id: string): Promise<DatabaseConfigEntry | null> {
151
+ await this.ensureInitialized();
152
+
153
+ const result = await this.pool.query<{
154
+ id: string;
155
+ tenant_id: string;
156
+ key: string;
157
+ name: string | null;
158
+ description: string | null;
159
+ config: any;
160
+ created_at: Date;
161
+ updated_at: Date;
162
+ }>(
163
+ `
164
+ SELECT id, tenant_id, key, name, description, config, created_at, updated_at
165
+ FROM lattice_database_configs
166
+ WHERE tenant_id = $1 AND id = $2
167
+ `,
168
+ [tenantId, id]
169
+ );
170
+
171
+ if (result.rows.length === 0) {
172
+ return null;
173
+ }
174
+
175
+ return this.mapRowToEntry(result.rows[0]);
176
+ }
177
+
178
+ /**
179
+ * Get database configuration by business key
180
+ */
181
+ async getConfigByKey(tenantId: string, key: string): Promise<DatabaseConfigEntry | null> {
182
+ await this.ensureInitialized();
183
+
184
+ const result = await this.pool.query<{
185
+ id: string;
186
+ tenant_id: string;
187
+ key: string;
188
+ name: string | null;
189
+ description: string | null;
190
+ config: any;
191
+ created_at: Date;
192
+ updated_at: Date;
193
+ }>(
194
+ `
195
+ SELECT id, tenant_id, key, name, description, config, created_at, updated_at
196
+ FROM lattice_database_configs
197
+ WHERE tenant_id = $1 AND key = $2
198
+ `,
199
+ [tenantId, key]
200
+ );
201
+
202
+ if (result.rows.length === 0) {
203
+ return null;
204
+ }
205
+
206
+ return this.mapRowToEntry(result.rows[0]);
207
+ }
208
+
209
+ /**
210
+ * Create a new database configuration
211
+ */
212
+ async createConfig(
213
+ tenantId: string,
214
+ id: string,
215
+ data: CreateDatabaseConfigRequest
216
+ ): Promise<DatabaseConfigEntry> {
217
+ await this.ensureInitialized();
218
+
219
+ const now = new Date();
220
+ const nowString = now.toISOString();
221
+ const configWithEncryptedPassword = this.encryptPasswordInConfig(data.config);
222
+
223
+ await this.pool.query(
224
+ `
225
+ INSERT INTO lattice_database_configs (id, tenant_id, key, name, description, config, created_at, updated_at)
226
+ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamp, $8::timestamp)
227
+ ON CONFLICT (tenant_id, id) DO UPDATE SET
228
+ key = EXCLUDED.key,
229
+ name = EXCLUDED.name,
230
+ description = EXCLUDED.description,
231
+ config = EXCLUDED.config,
232
+ updated_at = EXCLUDED.updated_at
233
+ `,
234
+ [
235
+ id,
236
+ tenantId,
237
+ data.key,
238
+ data.name || null,
239
+ data.description || null,
240
+ JSON.stringify(configWithEncryptedPassword),
241
+ nowString,
242
+ nowString,
243
+ ]
244
+ );
245
+
246
+ return {
247
+ id,
248
+ tenantId,
249
+ key: data.key,
250
+ config: data.config,
251
+ name: data.name,
252
+ description: data.description,
253
+ createdAt: now,
254
+ updatedAt: now,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Update an existing database configuration
260
+ */
261
+ async updateConfig(
262
+ tenantId: string,
263
+ id: string,
264
+ updates: Partial<UpdateDatabaseConfigRequest>
265
+ ): Promise<DatabaseConfigEntry | null> {
266
+ await this.ensureInitialized();
267
+
268
+ const existing = await this.getConfigById(tenantId, id);
269
+ if (!existing) {
270
+ return null;
271
+ }
272
+
273
+ // Build update fields and values as key-value pairs for clarity
274
+ const updateData: Record<string, any> = {};
275
+
276
+ if (updates.key !== undefined) {
277
+ updateData.key = updates.key;
278
+ }
279
+
280
+ if (updates.name !== undefined) {
281
+ updateData.name = updates.name || null;
282
+ }
283
+
284
+ if (updates.description !== undefined) {
285
+ updateData.description = updates.description || null;
286
+ }
287
+
288
+ if (updates.config !== undefined) {
289
+ const configWithEncryptedPassword = this.encryptPasswordInConfig(updates.config);
290
+ updateData.config = JSON.stringify(configWithEncryptedPassword);
291
+ }
292
+
293
+ if (Object.keys(updateData).length === 0) {
294
+ return existing;
295
+ }
296
+
297
+ // Always update the updated_at timestamp
298
+ updateData.updated_at = new Date().toISOString();
299
+
300
+ // Build SQL with tenantId and id at fixed positions at the end
301
+ const fields = Object.keys(updateData);
302
+ const values = Object.values(updateData);
303
+
304
+ // Add WHERE clause values at the end
305
+ values.push(tenantId);
306
+ values.push(id);
307
+
308
+ // Build SET clause with numbered parameters
309
+ const setClauses = fields.map((field, index) => {
310
+ if (field === 'updated_at') {
311
+ return `${field} = $${index + 1}::timestamp`;
312
+ }
313
+ return `${field} = $${index + 1}`;
314
+ });
315
+
316
+ // WHERE clause parameters are at the end
317
+ const whereTenantIndex = fields.length + 1;
318
+ const whereIdIndex = fields.length + 2;
319
+
320
+ const sql = `
321
+ UPDATE lattice_database_configs
322
+ SET ${setClauses.join(', ')}
323
+ WHERE tenant_id = $${whereTenantIndex} AND id = $${whereIdIndex}
324
+ `;
325
+
326
+ await this.pool.query(sql, values);
327
+
328
+ return await this.getConfigById(tenantId, id);
329
+ }
330
+
331
+ /**
332
+ * Delete a database configuration by ID
333
+ */
334
+ async deleteConfig(tenantId: string, id: string): Promise<boolean> {
335
+ await this.ensureInitialized();
336
+
337
+ const result = await this.pool.query(
338
+ `
339
+ DELETE FROM lattice_database_configs
340
+ WHERE tenant_id = $1 AND id = $2
341
+ `,
342
+ [tenantId, id]
343
+ );
344
+
345
+ return result.rowCount !== null && result.rowCount > 0;
346
+ }
347
+
348
+ /**
349
+ * Check if configuration exists
350
+ */
351
+ async hasConfig(tenantId: string, id: string): Promise<boolean> {
352
+ await this.ensureInitialized();
353
+
354
+ const result = await this.pool.query(
355
+ `
356
+ SELECT 1 FROM lattice_database_configs
357
+ WHERE tenant_id = $1 AND id = $2
358
+ LIMIT 1
359
+ `,
360
+ [tenantId, id]
361
+ );
362
+
363
+ return result.rows.length > 0;
364
+ }
365
+
366
+ /**
367
+ * Dispose resources and close the connection pool
368
+ */
369
+ async dispose(): Promise<void> {
370
+ await this.pool.end();
371
+ }
372
+
373
+ /**
374
+ * Ensure store is initialized
375
+ */
376
+ private async ensureInitialized(): Promise<void> {
377
+ if (!this.initialized) {
378
+ await this.initialize();
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Map database row to DatabaseConfigEntry object
384
+ * Automatically decrypts password if present
385
+ */
386
+ private mapRowToEntry(row: {
387
+ id: string;
388
+ tenant_id: string;
389
+ key: string;
390
+ name: string | null;
391
+ description: string | null;
392
+ config: any;
393
+ created_at: Date;
394
+ updated_at: Date;
395
+ }): DatabaseConfigEntry {
396
+ const config: DatabaseConfig = typeof row.config === 'string'
397
+ ? JSON.parse(row.config)
398
+ : row.config;
399
+
400
+ // Decrypt password if present
401
+ if (config.password) {
402
+ try {
403
+ config.password = decrypt(config.password);
404
+ } catch (error) {
405
+ console.error('Failed to decrypt database password:', error);
406
+ throw new Error('Failed to decrypt database configuration');
407
+ }
408
+ }
409
+
410
+ return {
411
+ id: row.id,
412
+ tenantId: row.tenant_id,
413
+ key: row.key,
414
+ config,
415
+ name: row.name || undefined,
416
+ description: row.description || undefined,
417
+ createdAt: row.created_at,
418
+ updatedAt: row.updated_at,
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Encrypt password in config before storing
424
+ */
425
+ private encryptPasswordInConfig(config: DatabaseConfig): DatabaseConfig {
426
+ const configCopy = { ...config };
427
+
428
+ if (configCopy.password) {
429
+ configCopy.password = encrypt(configCopy.password);
430
+ }
431
+
432
+ return configCopy;
433
+ }
434
+ }