pglens 1.1.0 → 2.0.1

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.1.0",
3
+ "version": "2.0.1",
4
4
  "description": "A simple PostgreSQL database viewer tool",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -31,4 +31,4 @@
31
31
  "express": "^4.18.2",
32
32
  "postgres": "^3.4.3"
33
33
  }
34
- }
34
+ }
@@ -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
@@ -202,7 +311,7 @@ async function getColumnMetadata(pool, tableName) {
202
311
  SELECT column_name, data_type
203
312
  FROM information_schema.columns
204
313
  WHERE table_schema = 'public'
205
- AND table_name = $1
314
+ AND table_name = $1
206
315
  ORDER BY ordinal_position;
207
316
  `;
208
317
  const result = await pool.query(metadataQuery, [tableName]);
@@ -260,21 +369,28 @@ async function getColumnMetadata(pool, tableName) {
260
369
  * - isUnique: boolean
261
370
  * }
262
371
  */
263
- router.get('/tables/:tableName', async (req, res) => {
372
+ router.get('/tables/:tableName', requireConnection, async (req, res) => {
264
373
  try {
265
374
  const tableName = sanitizeTableName(req.params.tableName);
266
375
  const page = parseInt(req.query.page || '1', 10);
267
376
  const limit = parseInt(req.query.limit || '100', 10);
268
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';
269
380
 
270
381
  if (page < 1 || limit < 1) {
271
382
  return res.status(400).json({ error: 'Page and limit must be positive integers' });
272
383
  }
273
384
 
274
- const pool = getPool();
385
+ const pool = req.pool;
275
386
  const primaryKeyColumn = await getPrimaryKeyColumn(pool, tableName);
276
387
  const columnMetadata = await getColumnMetadata(pool, tableName);
277
388
 
389
+ // Validate sortColumn if provided
390
+ if (sortColumn && !columnMetadata[sortColumn]) {
391
+ return res.status(400).json({ error: 'Invalid sort column' });
392
+ }
393
+
278
394
  const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
279
395
  const countResult = await pool.query(countQuery);
280
396
  const totalCount = parseInt(countResult.rows[0].total, 10);
@@ -283,7 +399,22 @@ router.get('/tables/:tableName', async (req, res) => {
283
399
  let dataResult;
284
400
  let nextCursor = null;
285
401
 
286
- 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) {
287
418
  // Cursor-based pagination: WHERE id > cursor (most efficient for forward navigation)
288
419
  const cursorQuery = `SELECT * FROM "${tableName}" WHERE "${primaryKeyColumn}" > $1 ORDER BY "${primaryKeyColumn}" ASC LIMIT $2`;
289
420
  const cursorParams = [cursor, limit];
@@ -321,7 +452,7 @@ router.get('/tables/:tableName', async (req, res) => {
321
452
  }
322
453
  dataResult = await pool.query(query, queryParams);
323
454
 
324
- // Calculate cursor for next page if primary key exists
455
+ // Calculate cursor for next page if primary key exists (only if default sort)
325
456
  if (primaryKeyColumn && dataResult.rows.length > 0) {
326
457
  const lastRow = dataResult.rows[dataResult.rows.length - 1];
327
458
  nextCursor = lastRow[primaryKeyColumn];
@@ -346,4 +477,3 @@ router.get('/tables/:tableName', async (req, res) => {
346
477
  });
347
478
 
348
479
  module.exports = router;
349
-
package/src/server.js CHANGED
@@ -19,43 +19,85 @@ const apiRoutes = require('./routes/api');
19
19
 
20
20
  /**
21
21
  * Start the Express server.
22
- * @param {string} connectionString - PostgreSQL connection string
23
- * @param {number} port - Port number to listen on
24
- * @param {string} sslMode - SSL mode for database connection
25
22
  */
26
- function startServer(connectionString, port, sslMode) {
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const net = require('net');
26
+
27
+ const PORT_FILE = path.join(os.homedir(), '.pglens.port');
28
+
29
+ /**
30
+ * Check if a port is in use.
31
+ * @param {number} port
32
+ * @returns {Promise<boolean>}
33
+ */
34
+ function isPortInUse(port) {
35
+ return new Promise((resolve) => {
36
+ const server = net.createServer();
37
+ server.once('error', (err) => {
38
+ if (err.code === 'EADDRINUSE') {
39
+ resolve(true);
40
+ } else {
41
+ resolve(false);
42
+ }
43
+ });
44
+ server.once('listening', () => {
45
+ server.close();
46
+ resolve(false);
47
+ });
48
+ server.listen(port);
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Start the Express server.
54
+ */
55
+ async function startServer() {
27
56
  const app = express();
57
+ let port = 54321;
58
+
59
+ // Try to find an available port starting from 54321
60
+ if (await isPortInUse(port)) {
61
+ port = 0; // Let OS choose a random available port
62
+ }
28
63
 
29
64
  app.use(cors());
30
65
  app.use(express.json());
31
66
 
32
- createPool(connectionString, sslMode)
33
- .then(() => {
34
- app.use('/api', apiRoutes);
35
-
36
- const clientPath = path.join(__dirname, '../client');
37
- app.use(express.static(clientPath));
38
-
39
- app.get('*', (_, res) => {
40
- res.sendFile(path.join(clientPath, 'index.html'));
41
- });
42
-
43
- app.listen(port, () => {
44
- console.log(`✓ Server running on http://localhost:${port}`);
45
- console.log(` Open your browser to view your database`);
46
- });
47
-
48
- process.on('SIGINT', () => {
49
- console.log('\nShutting down...');
50
- closePool().then(() => {
51
- process.exit(0);
52
- });
53
- });
54
- })
55
- .catch((error) => {
56
- console.error('Failed to start server:', error.message);
57
- process.exit(1);
67
+ app.use('/api', apiRoutes);
68
+
69
+ const clientPath = path.join(__dirname, '../client');
70
+ app.use(express.static(clientPath));
71
+
72
+ app.get('*', (_, res) => {
73
+ res.sendFile(path.join(clientPath, 'index.html'));
74
+ });
75
+
76
+ const server = app.listen(port, () => {
77
+ const actualPort = server.address().port;
78
+ console.log(`✓ Server running on http://localhost:${actualPort}`);
79
+ console.log(` Open your browser to view your database`);
80
+
81
+ // Write port to file for CLI to read
82
+ fs.writeFileSync(PORT_FILE, actualPort.toString());
83
+ });
84
+
85
+ const shutdown = () => {
86
+ console.log('\nShutting down...');
87
+ if (fs.existsSync(PORT_FILE)) {
88
+ try {
89
+ fs.unlinkSync(PORT_FILE);
90
+ } catch (e) {
91
+ // Ignore removal errors
92
+ }
93
+ }
94
+ closePool().then(() => {
95
+ process.exit(0);
58
96
  });
97
+ };
98
+
99
+ process.on('SIGINT', shutdown);
100
+ process.on('SIGTERM', shutdown);
59
101
  }
60
102
 
61
103
  module.exports = { startServer };
package/pglens-1.1.0.tgz DELETED
Binary file