pglens 1.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 ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "pglens",
3
+ "version": "1.0.0",
4
+ "description": "A simple PostgreSQL database viewer tool",
5
+ "main": "src/server.js",
6
+ "bin": {
7
+ "pglens": "./bin/pglens"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/pglens"
11
+ },
12
+ "keywords": [
13
+ "postgresql",
14
+ "database",
15
+ "viewer",
16
+ "cli"
17
+ ],
18
+ "author": "Tekeshwar Singh (https://github.com/tsvillain)",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/tsvillain/pglens.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/tsvillain/pglens/issues"
25
+ },
26
+ "homepage": "https://github.com/tsvillain/pglens#readme",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "commander": "^11.1.0",
30
+ "cors": "^2.8.5",
31
+ "express": "^4.18.2",
32
+ "postgres": "^3.4.3"
33
+ }
34
+ }
Binary file
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Database Connection Pool Manager
3
+ *
4
+ * Manages PostgreSQL connection pool using postgres library.
5
+ * Provides connection pooling for efficient database access.
6
+ */
7
+
8
+ const postgres = require('postgres');
9
+
10
+ let sql = null;
11
+
12
+ /**
13
+ * Create a wrapper that provides pg-compatible query interface.
14
+ * The postgres package uses a different API, so we wrap it to maintain compatibility.
15
+ * @param {object} sqlClient - The postgres client instance
16
+ * @returns {object} Wrapped client with .query() method
17
+ */
18
+ function createPoolWrapper(sqlClient) {
19
+ return {
20
+ query: async (queryText, params) => {
21
+ const result = await sqlClient.unsafe(queryText, params || []);
22
+ return { rows: result };
23
+ },
24
+ end: () => sqlClient.end(),
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Create a new connection pool.
30
+ * Closes existing pool if one exists.
31
+ * @param {string} connectionString - PostgreSQL connection string
32
+ * @param {string} sslMode - SSL mode: disable, require, prefer, verify-ca, verify-full
33
+ * @returns {Promise} The created connection wrapper
34
+ */
35
+ function createPool(connectionString, sslMode = 'prefer') {
36
+ if (sql) {
37
+ sql.end();
38
+ }
39
+
40
+ const sslConfig = getSslConfig(sslMode);
41
+ const poolConfig = {
42
+ max: 10,
43
+ idle_timeout: 30,
44
+ connect_timeout: 10,
45
+ };
46
+
47
+ if (sslConfig !== null) {
48
+ poolConfig.ssl = sslConfig;
49
+ }
50
+
51
+ sql = postgres(connectionString, poolConfig);
52
+
53
+ // Test the connection
54
+ return sql`SELECT NOW()`
55
+ .then(() => {
56
+ console.log('āœ“ Connected to PostgreSQL database');
57
+ return createPoolWrapper(sql);
58
+ })
59
+ .catch((err) => {
60
+ console.error('āœ— Failed to connect to PostgreSQL database:', err.message);
61
+ const recommendation = getSslModeRecommendation(err, sslMode);
62
+ 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`);
66
+ }
67
+ throw err;
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Analyze connection error and recommend appropriate SSL mode.
73
+ * @param {Error} error - Connection error object
74
+ * @param {string} currentSslMode - Current SSL mode that failed
75
+ * @returns {string|null} Recommended SSL mode or null if not SSL-related
76
+ */
77
+ function getSslModeRecommendation(error, currentSslMode) {
78
+ const errorMessage = error.message?.toLowerCase() || '';
79
+ const errorCode = error.code;
80
+ const errorStack = error.stack?.toLowerCase() || '';
81
+
82
+ // Certificate verification errors
83
+ if (
84
+ errorMessage.includes('certificate') ||
85
+ errorMessage.includes('self signed') ||
86
+ errorMessage.includes('unable to verify') ||
87
+ errorCode === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
88
+ errorCode === 'SELF_SIGNED_CERT_IN_CHAIN'
89
+ ) {
90
+ if (currentSslMode === 'verify-full' || currentSslMode === 'verify-ca') {
91
+ return 'require';
92
+ }
93
+ if (currentSslMode === 'prefer') {
94
+ return 'require';
95
+ }
96
+ }
97
+
98
+ // Hostname mismatch errors
99
+ if (
100
+ errorMessage.includes('hostname') ||
101
+ errorMessage.includes('host name') ||
102
+ errorCode === 'ERR_TLS_CERT_ALTNAME_INVALID'
103
+ ) {
104
+ if (currentSslMode === 'verify-full') {
105
+ return 'verify-ca';
106
+ }
107
+ return 'require';
108
+ }
109
+
110
+ // SSL/TLS protocol errors (but not connection refused, which is handled separately)
111
+ if (
112
+ (errorMessage.includes('ssl') ||
113
+ errorMessage.includes('tls') ||
114
+ errorMessage.includes('protocol') ||
115
+ errorStack.includes('ssl')) &&
116
+ errorCode !== 'ECONNREFUSED'
117
+ ) {
118
+ // If SSL is enabled and failing, try disabling
119
+ if (currentSslMode !== 'disable' && currentSslMode !== 'prefer') {
120
+ // First try prefer (allows fallback to non-SSL)
121
+ if (currentSslMode === 'require' || currentSslMode === 'verify-ca' || currentSslMode === 'verify-full') {
122
+ return 'prefer';
123
+ }
124
+ }
125
+ // If prefer failed, try disable
126
+ if (currentSslMode === 'prefer') {
127
+ return 'disable';
128
+ }
129
+ }
130
+
131
+ // Connection refused might indicate SSL requirement
132
+ if (errorCode === 'ECONNREFUSED' || errorMessage.includes('connection refused')) {
133
+ // If SSL is disabled, server might require SSL
134
+ if (currentSslMode === 'disable') {
135
+ return 'prefer';
136
+ }
137
+ }
138
+
139
+ // Connection timeout with SSL might need different mode
140
+ if (
141
+ (errorCode === 'ETIMEDOUT' || errorMessage.includes('timeout')) &&
142
+ currentSslMode !== 'disable'
143
+ ) {
144
+ return 'prefer';
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Get SSL configuration based on SSL mode.
152
+ * @param {string} sslMode - SSL mode string
153
+ * @returns {object|null} SSL configuration object or null to disable SSL
154
+ */
155
+ function getSslConfig(sslMode) {
156
+ switch (sslMode?.toLowerCase()) {
157
+ case 'disable':
158
+ return null;
159
+ case 'require':
160
+ return { rejectUnauthorized: false };
161
+ case 'prefer':
162
+ return { rejectUnauthorized: false };
163
+ case 'verify-ca':
164
+ return { rejectUnauthorized: true };
165
+ case 'verify-full':
166
+ return { rejectUnauthorized: true };
167
+ default:
168
+ return { rejectUnauthorized: false };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Get the current connection pool.
174
+ * @returns {object} The connection pool wrapper
175
+ * @throws {Error} If pool is not initialized
176
+ */
177
+ function getPool() {
178
+ if (!sql) {
179
+ throw new Error('Database pool not initialized. Call createPool first.');
180
+ }
181
+ return createPoolWrapper(sql);
182
+ }
183
+
184
+ /**
185
+ * Close the connection pool gracefully.
186
+ * @returns {Promise} Promise that resolves when pool is closed
187
+ */
188
+ function closePool() {
189
+ if (sql) {
190
+ return sql.end();
191
+ }
192
+ return Promise.resolve();
193
+ }
194
+
195
+ module.exports = {
196
+ createPool,
197
+ getPool,
198
+ closePool,
199
+ };
200
+
@@ -0,0 +1,200 @@
1
+ /**
2
+ * API Routes
3
+ *
4
+ * RESTful API endpoints for database operations.
5
+ *
6
+ * Endpoints:
7
+ * - GET /api/tables - List all tables in the database
8
+ * - GET /api/tables/:tableName - Get paginated table data
9
+ *
10
+ * Features:
11
+ * - SQL injection prevention via table name sanitization
12
+ * - Cursor-based pagination for efficient large table navigation
13
+ * - Automatic primary key detection for optimized pagination
14
+ */
15
+
16
+ const express = require('express');
17
+ const { getPool } = require('../db/connection');
18
+
19
+ const router = express.Router();
20
+
21
+ /**
22
+ * Sanitize table name to prevent SQL injection.
23
+ * Only allows alphanumeric characters, underscores, and dots.
24
+ * @param {string} tableName - Table name to sanitize
25
+ * @returns {string} Sanitized table name
26
+ * @throws {Error} If table name contains invalid characters
27
+ */
28
+ function sanitizeTableName(tableName) {
29
+ if (!/^[a-zA-Z0-9_.]+$/.test(tableName)) {
30
+ throw new Error('Invalid table name');
31
+ }
32
+ return tableName;
33
+ }
34
+
35
+ /**
36
+ * GET /api/tables
37
+ *
38
+ * Returns a list of all tables in the public schema.
39
+ * Only returns BASE TABLE types (excludes views, sequences, etc.).
40
+ *
41
+ * Response: { tables: string[] }
42
+ */
43
+ router.get('/tables', async (req, res) => {
44
+ try {
45
+ const pool = getPool();
46
+ const result = await pool.query(`
47
+ SELECT table_name
48
+ FROM information_schema.tables
49
+ WHERE table_schema = 'public'
50
+ AND table_type = 'BASE TABLE'
51
+ ORDER BY table_name;
52
+ `);
53
+
54
+ const tables = result.rows.map(row => row.table_name);
55
+ res.json({ tables });
56
+ } catch (error) {
57
+ console.error('Error fetching tables:', error);
58
+ res.status(500).json({ error: error.message });
59
+ }
60
+ });
61
+
62
+ /**
63
+ * Get the primary key column name for a table.
64
+ * Used to enable cursor-based pagination for better performance.
65
+ * @param {Pool} pool - Database connection pool
66
+ * @param {string} tableName - Name of the table
67
+ * @returns {Promise<string|null>} Primary key column name or null if no primary key exists
68
+ */
69
+ async function getPrimaryKeyColumn(pool, tableName) {
70
+ try {
71
+ const pkQuery = `
72
+ SELECT kcu.column_name
73
+ FROM information_schema.table_constraints tc
74
+ JOIN information_schema.key_column_usage kcu
75
+ ON tc.constraint_name = kcu.constraint_name
76
+ AND tc.table_schema = kcu.table_schema
77
+ WHERE tc.constraint_type = 'PRIMARY KEY'
78
+ AND tc.table_name = $1
79
+ AND tc.table_schema = 'public'
80
+ LIMIT 1;
81
+ `;
82
+ const result = await pool.query(pkQuery, [tableName]);
83
+ const pkColumn = result.rows.length > 0 ? result.rows[0].column_name : null;
84
+ return pkColumn;
85
+ } catch (error) {
86
+ console.error('Error getting primary key:', error);
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * GET /api/tables/:tableName
93
+ *
94
+ * Returns paginated table data with support for cursor-based pagination.
95
+ *
96
+ * Query parameters:
97
+ * - page: Page number (default: 1)
98
+ * - limit: Rows per page (default: 100)
99
+ * - cursor: Cursor value for cursor-based pagination (optional)
100
+ *
101
+ * Pagination strategy:
102
+ * - If table has primary key and cursor is provided: Use cursor-based pagination (WHERE id > cursor)
103
+ * - If table has primary key and page=1: Start from beginning, return cursor for next page
104
+ * - Otherwise: Use OFFSET-based pagination (for backward nav, page jumps, or tables without PK)
105
+ *
106
+ * Response: {
107
+ * rows: Object[],
108
+ * totalCount: number,
109
+ * page: number,
110
+ * limit: number,
111
+ * isApproximate: boolean,
112
+ * nextCursor: string|null,
113
+ * hasPrimaryKey: boolean
114
+ * }
115
+ */
116
+ router.get('/tables/:tableName', async (req, res) => {
117
+ try {
118
+ const tableName = sanitizeTableName(req.params.tableName);
119
+ const page = parseInt(req.query.page || '1', 10);
120
+ const limit = parseInt(req.query.limit || '100', 10);
121
+ const cursor = req.query.cursor;
122
+
123
+ if (page < 1 || limit < 1) {
124
+ return res.status(400).json({ error: 'Page and limit must be positive integers' });
125
+ }
126
+
127
+ const pool = getPool();
128
+ const primaryKeyColumn = await getPrimaryKeyColumn(pool, tableName);
129
+
130
+ const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
131
+ const countResult = await pool.query(countQuery);
132
+ const totalCount = parseInt(countResult.rows[0].total, 10);
133
+ const isApproximate = false;
134
+
135
+ let dataResult;
136
+ let nextCursor = null;
137
+
138
+ if (primaryKeyColumn && cursor) {
139
+ // Cursor-based pagination: WHERE id > cursor (most efficient for forward navigation)
140
+ const cursorQuery = `SELECT * FROM "${tableName}" WHERE "${primaryKeyColumn}" > $1 ORDER BY "${primaryKeyColumn}" ASC LIMIT $2`;
141
+ const cursorParams = [cursor, limit];
142
+ dataResult = await pool.query(cursorQuery, cursorParams);
143
+
144
+ if (dataResult.rows.length > 0) {
145
+ const lastRow = dataResult.rows[dataResult.rows.length - 1];
146
+ nextCursor = lastRow[primaryKeyColumn];
147
+ }
148
+ } else if (primaryKeyColumn && page === 1) {
149
+ // First page with primary key: start from beginning, return cursor
150
+ const firstPageQuery = `SELECT * FROM "${tableName}" ORDER BY "${primaryKeyColumn}" ASC LIMIT $1`;
151
+ const firstPageParams = [limit];
152
+ dataResult = await pool.query(firstPageQuery, firstPageParams);
153
+
154
+ if (dataResult.rows.length > 0) {
155
+ const lastRow = dataResult.rows[dataResult.rows.length - 1];
156
+ nextCursor = lastRow[primaryKeyColumn];
157
+ }
158
+ } else {
159
+ // Fallback to OFFSET-based pagination
160
+ // Used for: backward navigation, page jumps, or tables without primary key
161
+ const offset = (page - 1) * limit;
162
+ let query;
163
+ const queryParams = [];
164
+
165
+ if (primaryKeyColumn) {
166
+ // Order by primary key for consistent results
167
+ query = `SELECT * FROM "${tableName}" ORDER BY "${primaryKeyColumn}" ASC LIMIT $1 OFFSET $2`;
168
+ queryParams.push(limit, offset);
169
+ } else {
170
+ // No primary key: no ordering guarantee
171
+ query = `SELECT * FROM "${tableName}" LIMIT $1 OFFSET $2`;
172
+ queryParams.push(limit, offset);
173
+ }
174
+ dataResult = await pool.query(query, queryParams);
175
+
176
+ // Calculate cursor for next page if primary key exists
177
+ if (primaryKeyColumn && dataResult.rows.length > 0) {
178
+ const lastRow = dataResult.rows[dataResult.rows.length - 1];
179
+ nextCursor = lastRow[primaryKeyColumn];
180
+ }
181
+ }
182
+
183
+ const responseData = {
184
+ rows: dataResult.rows,
185
+ totalCount,
186
+ page,
187
+ limit,
188
+ isApproximate,
189
+ nextCursor,
190
+ hasPrimaryKey: !!primaryKeyColumn,
191
+ };
192
+ res.json(responseData);
193
+ } catch (error) {
194
+ console.error('Error fetching table data:', error);
195
+ res.status(500).json({ error: error.message });
196
+ }
197
+ });
198
+
199
+ module.exports = router;
200
+
package/src/server.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * pglens Server
3
+ *
4
+ * Express server that serves the web UI and provides API endpoints
5
+ * for querying PostgreSQL database tables.
6
+ *
7
+ * Features:
8
+ * - RESTful API for table listing and data retrieval
9
+ * - Static file serving for the client application
10
+ * - CORS enabled for development
11
+ * - Graceful shutdown handling
12
+ */
13
+
14
+ const express = require('express');
15
+ const cors = require('cors');
16
+ const path = require('path');
17
+ const { createPool, closePool } = require('./db/connection');
18
+ const apiRoutes = require('./routes/api');
19
+
20
+ /**
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
+ */
26
+ function startServer(connectionString, port, sslMode) {
27
+ const app = express();
28
+
29
+ app.use(cors());
30
+ app.use(express.json());
31
+
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);
58
+ });
59
+ }
60
+
61
+ module.exports = { startServer };
62
+