pglens 1.0.0 → 2.0.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pglens",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "A simple PostgreSQL database viewer tool",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Database Connection Pool Manager
3
3
  *
4
- * Manages PostgreSQL connection pool using postgres library.
4
+ * Manages PostgreSQL connection pools using postgres library.
5
5
  * Provides connection pooling for efficient database access.
6
+ * Supports multiple simultaneous connections.
6
7
  */
7
8
 
8
9
  const postgres = require('postgres');
10
+ const crypto = require('crypto');
9
11
 
10
- let sql = null;
12
+ // Map to store multiple connections: id -> { pool, name, connectionString }
13
+ const connections = new Map();
11
14
 
12
15
  /**
13
16
  * Create a wrapper that provides pg-compatible query interface.
@@ -25,49 +28,98 @@ function createPoolWrapper(sqlClient) {
25
28
  };
26
29
  }
27
30
 
31
+ /**
32
+ * Extract database name from connection string
33
+ * @param {string} connectionString
34
+ * @returns {string} Database name or 'Unknown'
35
+ */
36
+ function getDatabaseName(connectionString) {
37
+ try {
38
+ const url = new URL(connectionString);
39
+ return url.pathname.replace(/^\//, '') || 'postgres';
40
+ } catch (e) {
41
+ return 'postgres';
42
+ }
43
+ }
44
+
28
45
  /**
29
46
  * Create a new connection pool.
30
- * Closes existing pool if one exists.
31
47
  * @param {string} connectionString - PostgreSQL connection string
32
48
  * @param {string} sslMode - SSL mode: disable, require, prefer, verify-ca, verify-full
33
- * @returns {Promise} The created connection wrapper
49
+ * @param {string} [customName] - Optional custom name for the connection
50
+ * @returns {Promise<{id: string, name: string}>} The created connection info
34
51
  */
35
- function createPool(connectionString, sslMode = 'prefer') {
36
- if (sql) {
37
- sql.end();
38
- }
39
-
52
+ function createPool(connectionString, sslMode = 'prefer', customName = null) {
40
53
  const sslConfig = getSslConfig(sslMode);
41
54
  const poolConfig = {
42
55
  max: 10,
43
56
  idle_timeout: 30,
44
57
  connect_timeout: 10,
58
+ timeout: 30, // Query timeout in seconds
45
59
  };
46
60
 
47
61
  if (sslConfig !== null) {
48
62
  poolConfig.ssl = sslConfig;
49
63
  }
50
64
 
51
- sql = postgres(connectionString, poolConfig);
65
+ const sql = postgres(connectionString, poolConfig);
52
66
 
53
67
  // Test the connection
54
68
  return sql`SELECT NOW()`
55
69
  .then(() => {
56
70
  console.log('✓ Connected to PostgreSQL database');
57
- return createPoolWrapper(sql);
71
+
72
+ // Check if this connection string already exists
73
+ for (const [existingId, existingConn] of connections.entries()) {
74
+ if (existingConn.connectionString === connectionString) {
75
+ // If a custom name is provided and different from existing, update it
76
+ if (customName && customName !== existingConn.name) {
77
+ existingConn.name = customName;
78
+ }
79
+ // Return the existing connection details
80
+ sql.end();
81
+ return { id: existingId, name: existingConn.name, reused: true };
82
+ }
83
+ }
84
+
85
+ const id = crypto.randomUUID();
86
+ const name = customName || getDatabaseName(connectionString);
87
+
88
+ connections.set(id, {
89
+ pool: sql,
90
+ name,
91
+ connectionString,
92
+ sslMode
93
+ });
94
+
95
+ return { id, name };
58
96
  })
59
97
  .catch((err) => {
60
98
  console.error('✗ Failed to connect to PostgreSQL database:', err.message);
61
99
  const recommendation = getSslModeRecommendation(err, sslMode);
62
100
  if (recommendation) {
63
- console.error(`\n💡 SSL Mode Recommendation: Try using --sslmode ${recommendation}`);
64
- console.error(` Current SSL mode: ${sslMode}`);
65
- console.error(` Suggested command: Add --sslmode ${recommendation} to your command\n`);
101
+ console.error(`\n💡 SSL Mode Recommendation: Try using sslmode '${recommendation}'`);
66
102
  }
67
103
  throw err;
68
104
  });
69
105
  }
70
106
 
107
+ /**
108
+ * Check if a specific connection is active.
109
+ * @param {string} connectionId - Connection ID
110
+ * @returns {Promise<boolean>} True if connected
111
+ */
112
+ function checkConnection(connectionId) {
113
+ const conn = connections.get(connectionId);
114
+ if (!conn) {
115
+ return Promise.resolve(false);
116
+ }
117
+
118
+ return conn.pool`SELECT 1`
119
+ .then(() => true)
120
+ .catch(() => false);
121
+ }
122
+
71
123
  /**
72
124
  * Analyze connection error and recommend appropriate SSL mode.
73
125
  * @param {Error} error - Connection error object
@@ -170,31 +222,115 @@ function getSslConfig(sslMode) {
170
222
  }
171
223
 
172
224
  /**
173
- * Get the current connection pool.
174
- * @returns {object} The connection pool wrapper
175
- * @throws {Error} If pool is not initialized
225
+ * Get a connection pool by ID.
226
+ * @param {string} connectionId - The connection ID
227
+ * @returns {object|null} The connection pool wrapper or null if not found
176
228
  */
177
- function getPool() {
178
- if (!sql) {
179
- throw new Error('Database pool not initialized. Call createPool first.');
229
+ function getPool(connectionId) {
230
+ const conn = connections.get(connectionId);
231
+ if (!conn) {
232
+ return null;
180
233
  }
181
- return createPoolWrapper(sql);
234
+ return createPoolWrapper(conn.pool);
182
235
  }
183
236
 
184
237
  /**
185
- * Close the connection pool gracefully.
238
+ * Get list of all active connections.
239
+ * @returns {Array<{id: string, name: string}>} List of connections
240
+ */
241
+ function getConnections() {
242
+ const result = [];
243
+ for (const [id, conn] of connections.entries()) {
244
+ result.push({
245
+ id,
246
+ name: conn.name,
247
+ connectionString: conn.connectionString,
248
+ sslMode: conn.sslMode
249
+ });
250
+ }
251
+ return result;
252
+ }
253
+
254
+ /**
255
+ * Update an existing connection.
256
+ * @param {string} id - Connection ID to update
257
+ * @param {string} connectionString - New connection string
258
+ * @param {string} sslMode - New SSL mode
259
+ * @param {string} name - New name
260
+ * @returns {Promise<{id: string, name: string}>} Updated connection info
261
+ */
262
+ async function updateConnection(id, connectionString, sslMode, name) {
263
+ const existingConn = connections.get(id);
264
+ if (!existingConn) {
265
+ throw new Error('Connection not found');
266
+ }
267
+
268
+ const sslConfig = getSslConfig(sslMode);
269
+ const poolConfig = {
270
+ max: 10,
271
+ idle_timeout: 30,
272
+ connect_timeout: 10,
273
+ timeout: 30, // Query timeout in seconds
274
+ };
275
+
276
+ if (sslConfig !== null) {
277
+ poolConfig.ssl = sslConfig;
278
+ }
279
+
280
+ const sql = postgres(connectionString, poolConfig);
281
+
282
+ // Test the new connection
283
+ return sql`SELECT NOW()`
284
+ .then(async () => {
285
+ console.log('✓ Updated connection to PostgreSQL database');
286
+
287
+ // Close old pool
288
+ await existingConn.pool.end();
289
+
290
+ // Update map with new pool and details
291
+ connections.set(id, {
292
+ pool: sql,
293
+ name: name || getDatabaseName(connectionString),
294
+ connectionString,
295
+ sslMode
296
+ });
297
+
298
+ return { id, name: connections.get(id).name };
299
+ })
300
+ .catch((err) => {
301
+ console.error('✗ Failed to update connection:', err.message);
302
+ throw err;
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Close a specific connection pool.
308
+ * @param {string} connectionId - The connection ID to close
186
309
  * @returns {Promise} Promise that resolves when pool is closed
187
310
  */
188
- function closePool() {
189
- if (sql) {
190
- return sql.end();
311
+ async function closePool(connectionId) {
312
+ if (connectionId) {
313
+ const conn = connections.get(connectionId);
314
+ if (conn) {
315
+ await conn.pool.end();
316
+ connections.delete(connectionId);
317
+ }
318
+ } else {
319
+ // Close all connections (e.g., on server shutdown)
320
+ const promises = [];
321
+ for (const conn of connections.values()) {
322
+ promises.push(conn.pool.end());
323
+ }
324
+ await Promise.all(promises);
325
+ connections.clear();
191
326
  }
192
- return Promise.resolve();
193
327
  }
194
328
 
195
329
  module.exports = {
196
330
  createPool,
197
331
  getPool,
198
332
  closePool,
333
+ checkConnection,
334
+ getConnections,
335
+ updateConnection
199
336
  };
200
-
package/src/routes/api.js CHANGED
@@ -14,10 +14,119 @@
14
14
  */
15
15
 
16
16
  const express = require('express');
17
- const { getPool } = require('../db/connection');
17
+ const { getPool, createPool, closePool, checkConnection, getConnections, updateConnection } = require('../db/connection');
18
18
 
19
19
  const router = express.Router();
20
20
 
21
+ /**
22
+ * Middleware to check if connected to database
23
+ */
24
+ const requireConnection = async (req, res, next) => {
25
+ const connectionId = req.headers['x-connection-id'];
26
+ if (!connectionId) {
27
+ return res.status(400).json({ error: 'Connection ID header required' });
28
+ }
29
+
30
+ const pool = getPool(connectionId);
31
+ if (!pool) {
32
+ return res.status(503).json({ error: 'Not connected to database or invalid connection ID' });
33
+ }
34
+
35
+ req.pool = pool;
36
+ next();
37
+ };
38
+
39
+ /**
40
+ * POST /api/connect
41
+ * Connect to a PostgreSQL database
42
+ */
43
+ router.post('/connect', async (req, res) => {
44
+ const { url, sslMode, name } = req.body;
45
+
46
+ if (!url) {
47
+ return res.status(400).json({ error: 'Connection string is required' });
48
+ }
49
+
50
+ try {
51
+ const { id, name: connectionName } = await createPool(url, sslMode || 'prefer', name);
52
+ res.json({ connected: true, connectionId: id, name: connectionName });
53
+ } catch (error) {
54
+ res.status(400).json({
55
+ connected: false,
56
+ error: error.message
57
+ });
58
+ }
59
+ });
60
+
61
+ /**
62
+ * PUT /api/connections/:id
63
+ * Update an existing connection
64
+ */
65
+ router.put('/connections/:id', async (req, res) => {
66
+ const { id } = req.params;
67
+ const { url, sslMode, name } = req.body;
68
+
69
+ if (!url) {
70
+ return res.status(400).json({ error: 'Connection string is required' });
71
+ }
72
+
73
+ try {
74
+ const { name: connectionName } = await updateConnection(id, url, sslMode || 'prefer', name);
75
+ res.json({ updated: true, connectionId: id, name: connectionName });
76
+ } catch (error) {
77
+ res.status(400).json({
78
+ updated: false,
79
+ error: error.message
80
+ });
81
+ }
82
+ });
83
+
84
+ /**
85
+ * GET /api/connections
86
+ * List active connections
87
+ */
88
+ router.get('/connections', (req, res) => {
89
+ const connections = getConnections();
90
+ res.json({ connections });
91
+ });
92
+
93
+ /**
94
+ * POST /api/disconnect
95
+ * Disconnect from a database
96
+ */
97
+ router.post('/disconnect', async (req, res) => {
98
+ const connectionId = req.body.connectionId || req.headers['x-connection-id'];
99
+
100
+ if (!connectionId) {
101
+ return res.status(400).json({ error: 'Connection ID required' });
102
+ }
103
+
104
+ try {
105
+ await closePool(connectionId);
106
+ res.json({ connected: false });
107
+ } catch (error) {
108
+ res.status(500).json({ error: error.message });
109
+ }
110
+ });
111
+
112
+ /**
113
+ * GET /api/status
114
+ * Check connection status
115
+ */
116
+ router.get('/status', async (req, res) => {
117
+ const connectionId = req.headers['x-connection-id'];
118
+ if (!connectionId) {
119
+ return res.json({ connected: false });
120
+ }
121
+
122
+ try {
123
+ const connected = await checkConnection(connectionId);
124
+ res.json({ connected });
125
+ } catch (error) {
126
+ res.json({ connected: false, error: error.message });
127
+ }
128
+ });
129
+
21
130
  /**
22
131
  * Sanitize table name to prevent SQL injection.
23
132
  * Only allows alphanumeric characters, underscores, and dots.
@@ -40,9 +149,9 @@ function sanitizeTableName(tableName) {
40
149
  *
41
150
  * Response: { tables: string[] }
42
151
  */
43
- router.get('/tables', async (req, res) => {
152
+ router.get('/tables', requireConnection, async (req, res) => {
44
153
  try {
45
- const pool = getPool();
154
+ const pool = req.pool;
46
155
  const result = await pool.query(`
47
156
  SELECT table_name
48
157
  FROM information_schema.tables
@@ -88,6 +197,147 @@ async function getPrimaryKeyColumn(pool, tableName) {
88
197
  }
89
198
  }
90
199
 
200
+ /**
201
+ * Get foreign key relationships for a table.
202
+ * Queries information_schema to get foreign key constraints and their references.
203
+ * @param {Pool} pool - Database connection pool
204
+ * @param {string} tableName - Name of the table
205
+ * @returns {Promise<Object>} Object mapping column names to their foreign key references { table, column }
206
+ */
207
+ async function getForeignKeyRelations(pool, tableName) {
208
+ try {
209
+ const fkQuery = `
210
+ SELECT
211
+ kcu.column_name,
212
+ ccu.table_name AS foreign_table_name,
213
+ ccu.column_name AS foreign_column_name
214
+ FROM information_schema.table_constraints AS tc
215
+ JOIN information_schema.key_column_usage AS kcu
216
+ ON tc.constraint_name = kcu.constraint_name
217
+ AND tc.table_schema = kcu.table_schema
218
+ JOIN information_schema.constraint_column_usage AS ccu
219
+ ON ccu.constraint_name = tc.constraint_name
220
+ AND ccu.table_schema = tc.table_schema
221
+ WHERE tc.constraint_type = 'FOREIGN KEY'
222
+ AND tc.table_name = $1
223
+ AND tc.table_schema = 'public';
224
+ `;
225
+ const result = await pool.query(fkQuery, [tableName]);
226
+ const foreignKeys = {};
227
+ result.rows.forEach(row => {
228
+ foreignKeys[row.column_name] = {
229
+ table: row.foreign_table_name,
230
+ column: row.foreign_column_name
231
+ };
232
+ });
233
+ return foreignKeys;
234
+ } catch (error) {
235
+ console.error('Error getting foreign key relations:', error);
236
+ return {};
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Get all primary key columns for a table.
242
+ * @param {Pool} pool - Database connection pool
243
+ * @param {string} tableName - Name of the table
244
+ * @returns {Promise<Set>} Set of primary key column names
245
+ */
246
+ async function getPrimaryKeyColumns(pool, tableName) {
247
+ try {
248
+ const pkQuery = `
249
+ SELECT kcu.column_name
250
+ FROM information_schema.table_constraints tc
251
+ JOIN information_schema.key_column_usage kcu
252
+ ON tc.constraint_name = kcu.constraint_name
253
+ AND tc.table_schema = kcu.table_schema
254
+ WHERE tc.constraint_type = 'PRIMARY KEY'
255
+ AND tc.table_name = $1
256
+ AND tc.table_schema = 'public';
257
+ `;
258
+ const result = await pool.query(pkQuery, [tableName]);
259
+ const pkColumns = new Set();
260
+ result.rows.forEach(row => {
261
+ pkColumns.add(row.column_name);
262
+ });
263
+ return pkColumns;
264
+ } catch (error) {
265
+ console.error('Error getting primary key columns:', error);
266
+ return new Set();
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Get all unique constraint columns for a table.
272
+ * @param {Pool} pool - Database connection pool
273
+ * @param {string} tableName - Name of the table
274
+ * @returns {Promise<Set>} Set of unique constraint column names
275
+ */
276
+ async function getUniqueColumns(pool, tableName) {
277
+ try {
278
+ const uniqueQuery = `
279
+ SELECT kcu.column_name
280
+ FROM information_schema.table_constraints tc
281
+ JOIN information_schema.key_column_usage kcu
282
+ ON tc.constraint_name = kcu.constraint_name
283
+ AND tc.table_schema = kcu.table_schema
284
+ WHERE tc.constraint_type = 'UNIQUE'
285
+ AND tc.table_name = $1
286
+ AND tc.table_schema = 'public';
287
+ `;
288
+ const result = await pool.query(uniqueQuery, [tableName]);
289
+ const uniqueColumns = new Set();
290
+ result.rows.forEach(row => {
291
+ uniqueColumns.add(row.column_name);
292
+ });
293
+ return uniqueColumns;
294
+ } catch (error) {
295
+ console.error('Error getting unique columns:', error);
296
+ return new Set();
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Get column metadata (datatypes and key relationships) for a table.
302
+ * Queries information_schema.columns to get column names and their data types,
303
+ * and includes key relationship information (primary keys, foreign keys, unique constraints).
304
+ * @param {Pool} pool - Database connection pool
305
+ * @param {string} tableName - Name of the table
306
+ * @returns {Promise<Object>} Object mapping column names to metadata objects with dataType, isPrimaryKey, isForeignKey, foreignKeyRef, isUnique
307
+ */
308
+ async function getColumnMetadata(pool, tableName) {
309
+ try {
310
+ const metadataQuery = `
311
+ SELECT column_name, data_type
312
+ FROM information_schema.columns
313
+ WHERE table_schema = 'public'
314
+ AND table_name = $1
315
+ ORDER BY ordinal_position;
316
+ `;
317
+ const result = await pool.query(metadataQuery, [tableName]);
318
+
319
+ const primaryKeyColumns = await getPrimaryKeyColumns(pool, tableName);
320
+ const foreignKeyRelations = await getForeignKeyRelations(pool, tableName);
321
+ const uniqueColumns = await getUniqueColumns(pool, tableName);
322
+
323
+ const columns = {};
324
+ result.rows.forEach(row => {
325
+ const columnName = row.column_name;
326
+ columns[columnName] = {
327
+ dataType: row.data_type,
328
+ isPrimaryKey: primaryKeyColumns.has(columnName),
329
+ isForeignKey: !!foreignKeyRelations[columnName],
330
+ foreignKeyRef: foreignKeyRelations[columnName] || null,
331
+ isUnique: uniqueColumns.has(columnName)
332
+ };
333
+ });
334
+ return columns;
335
+ } catch (error) {
336
+ console.error('Error getting column metadata:', error);
337
+ return {};
338
+ }
339
+ }
340
+
91
341
  /**
92
342
  * GET /api/tables/:tableName
93
343
  *
@@ -110,22 +360,36 @@ async function getPrimaryKeyColumn(pool, tableName) {
110
360
  * limit: number,
111
361
  * isApproximate: boolean,
112
362
  * nextCursor: string|null,
113
- * hasPrimaryKey: boolean
363
+ * hasPrimaryKey: boolean,
364
+ * columns: Object - Map of column names to metadata objects with:
365
+ * - dataType: string
366
+ * - isPrimaryKey: boolean
367
+ * - isForeignKey: boolean
368
+ * - foreignKeyRef: { table: string, column: string } | null
369
+ * - isUnique: boolean
114
370
  * }
115
371
  */
116
- router.get('/tables/:tableName', async (req, res) => {
372
+ router.get('/tables/:tableName', requireConnection, async (req, res) => {
117
373
  try {
118
374
  const tableName = sanitizeTableName(req.params.tableName);
119
375
  const page = parseInt(req.query.page || '1', 10);
120
376
  const limit = parseInt(req.query.limit || '100', 10);
121
377
  const cursor = req.query.cursor;
378
+ const sortColumn = req.query.sortColumn ? req.query.sortColumn : null;
379
+ const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
122
380
 
123
381
  if (page < 1 || limit < 1) {
124
382
  return res.status(400).json({ error: 'Page and limit must be positive integers' });
125
383
  }
126
384
 
127
- const pool = getPool();
385
+ const pool = req.pool;
128
386
  const primaryKeyColumn = await getPrimaryKeyColumn(pool, tableName);
387
+ const columnMetadata = await getColumnMetadata(pool, tableName);
388
+
389
+ // Validate sortColumn if provided
390
+ if (sortColumn && !columnMetadata[sortColumn]) {
391
+ return res.status(400).json({ error: 'Invalid sort column' });
392
+ }
129
393
 
130
394
  const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
131
395
  const countResult = await pool.query(countQuery);
@@ -135,7 +399,22 @@ router.get('/tables/:tableName', async (req, res) => {
135
399
  let dataResult;
136
400
  let nextCursor = null;
137
401
 
138
- if (primaryKeyColumn && cursor) {
402
+ if (sortColumn) {
403
+ // Custom Sorting: Use OFFSET-based pagination
404
+ // Always add primary key (if exists) as secondary sort for stability
405
+ const offset = (page - 1) * limit;
406
+ let orderByClause = `ORDER BY "${sortColumn}" ${sortDirection}`;
407
+
408
+ if (primaryKeyColumn && sortColumn !== primaryKeyColumn) {
409
+ orderByClause += `, "${primaryKeyColumn}" ASC`;
410
+ }
411
+
412
+ const query = `SELECT * FROM "${tableName}" ${orderByClause} LIMIT $1 OFFSET $2`;
413
+ dataResult = await pool.query(query, [limit, offset]);
414
+
415
+ // Cursor not valid for custom sorts in this simple implementation
416
+ nextCursor = null;
417
+ } else if (primaryKeyColumn && cursor) {
139
418
  // Cursor-based pagination: WHERE id > cursor (most efficient for forward navigation)
140
419
  const cursorQuery = `SELECT * FROM "${tableName}" WHERE "${primaryKeyColumn}" > $1 ORDER BY "${primaryKeyColumn}" ASC LIMIT $2`;
141
420
  const cursorParams = [cursor, limit];
@@ -173,7 +452,7 @@ router.get('/tables/:tableName', async (req, res) => {
173
452
  }
174
453
  dataResult = await pool.query(query, queryParams);
175
454
 
176
- // Calculate cursor for next page if primary key exists
455
+ // Calculate cursor for next page if primary key exists (only if default sort)
177
456
  if (primaryKeyColumn && dataResult.rows.length > 0) {
178
457
  const lastRow = dataResult.rows[dataResult.rows.length - 1];
179
458
  nextCursor = lastRow[primaryKeyColumn];
@@ -188,6 +467,7 @@ router.get('/tables/:tableName', async (req, res) => {
188
467
  isApproximate,
189
468
  nextCursor,
190
469
  hasPrimaryKey: !!primaryKeyColumn,
470
+ columns: columnMetadata,
191
471
  };
192
472
  res.json(responseData);
193
473
  } catch (error) {
@@ -197,4 +477,3 @@ router.get('/tables/:tableName', async (req, res) => {
197
477
  });
198
478
 
199
479
  module.exports = router;
200
-