@tejasanik/postgres-mcp-server 2.1.0 → 2.2.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/README.md +186 -10
- package/dist/db-manager/index.d.ts +7 -0
- package/dist/db-manager/index.d.ts.map +1 -0
- package/dist/db-manager/index.js +7 -0
- package/dist/db-manager/index.js.map +1 -0
- package/dist/db-manager/validation.d.ts +35 -0
- package/dist/db-manager/validation.d.ts.map +1 -0
- package/dist/db-manager/validation.js +54 -0
- package/dist/db-manager/validation.js.map +1 -0
- package/dist/db-manager.d.ts +175 -5
- package/dist/db-manager.d.ts.map +1 -1
- package/dist/db-manager.js +589 -26
- package/dist/db-manager.js.map +1 -1
- package/dist/index.js +141 -11
- package/dist/index.js.map +1 -1
- package/dist/tools/analysis-tools.d.ts.map +1 -1
- package/dist/tools/analysis-tools.js +53 -49
- package/dist/tools/analysis-tools.js.map +1 -1
- package/dist/tools/schema-tools.d.ts +40 -1
- package/dist/tools/schema-tools.d.ts.map +1 -1
- package/dist/tools/schema-tools.js +174 -92
- package/dist/tools/schema-tools.js.map +1 -1
- package/dist/tools/server-tools.d.ts +1 -0
- package/dist/tools/server-tools.d.ts.map +1 -1
- package/dist/tools/server-tools.js +10 -6
- package/dist/tools/server-tools.js.map +1 -1
- package/dist/tools/sql/utils/connection-utils.d.ts +79 -0
- package/dist/tools/sql/utils/connection-utils.d.ts.map +1 -0
- package/dist/tools/sql/utils/connection-utils.js +129 -0
- package/dist/tools/sql/utils/connection-utils.js.map +1 -0
- package/dist/tools/sql/utils/constants.d.ts +55 -0
- package/dist/tools/sql/utils/constants.d.ts.map +1 -0
- package/dist/tools/sql/utils/constants.js +55 -0
- package/dist/tools/sql/utils/constants.js.map +1 -0
- package/dist/tools/sql/utils/dry-run-utils.d.ts +31 -0
- package/dist/tools/sql/utils/dry-run-utils.d.ts.map +1 -0
- package/dist/tools/sql/utils/dry-run-utils.js +173 -0
- package/dist/tools/sql/utils/dry-run-utils.js.map +1 -0
- package/dist/tools/sql/utils/file-handler.d.ts +57 -0
- package/dist/tools/sql/utils/file-handler.d.ts.map +1 -0
- package/dist/tools/sql/utils/file-handler.js +150 -0
- package/dist/tools/sql/utils/file-handler.js.map +1 -0
- package/dist/tools/sql/utils/index.d.ts +12 -0
- package/dist/tools/sql/utils/index.d.ts.map +1 -0
- package/dist/tools/sql/utils/index.js +12 -0
- package/dist/tools/sql/utils/index.js.map +1 -0
- package/dist/tools/sql/utils/result-formatter.d.ts +94 -0
- package/dist/tools/sql/utils/result-formatter.d.ts.map +1 -0
- package/dist/tools/sql/utils/result-formatter.js +154 -0
- package/dist/tools/sql/utils/result-formatter.js.map +1 -0
- package/dist/tools/sql/utils/sql-parser.d.ts +125 -0
- package/dist/tools/sql/utils/sql-parser.d.ts.map +1 -0
- package/dist/tools/sql/utils/sql-parser.js +468 -0
- package/dist/tools/sql/utils/sql-parser.js.map +1 -0
- package/dist/tools/sql-tools.d.ts +21 -0
- package/dist/tools/sql-tools.d.ts.map +1 -1
- package/dist/tools/sql-tools.js +383 -532
- package/dist/tools/sql-tools.js.map +1 -1
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/retry.d.ts +1 -1
- package/dist/utils/retry.d.ts.map +1 -1
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/validation.d.ts +45 -9
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +335 -72
- package/dist/utils/validation.js.map +1 -1
- package/package.json +9 -2
- package/dist/__tests__/analysis-tools.test.d.ts +0 -2
- package/dist/__tests__/analysis-tools.test.d.ts.map +0 -1
- package/dist/__tests__/analysis-tools.test.js +0 -294
- package/dist/__tests__/analysis-tools.test.js.map +0 -1
- package/dist/__tests__/db-manager.test.d.ts +0 -2
- package/dist/__tests__/db-manager.test.d.ts.map +0 -1
- package/dist/__tests__/db-manager.test.js +0 -410
- package/dist/__tests__/db-manager.test.js.map +0 -1
- package/dist/__tests__/mcp-server.test.d.ts +0 -13
- package/dist/__tests__/mcp-server.test.d.ts.map +0 -1
- package/dist/__tests__/mcp-server.test.js +0 -146
- package/dist/__tests__/mcp-server.test.js.map +0 -1
- package/dist/__tests__/schema-tools.test.d.ts +0 -2
- package/dist/__tests__/schema-tools.test.d.ts.map +0 -1
- package/dist/__tests__/schema-tools.test.js +0 -171
- package/dist/__tests__/schema-tools.test.js.map +0 -1
- package/dist/__tests__/server-tools.test.d.ts +0 -2
- package/dist/__tests__/server-tools.test.d.ts.map +0 -1
- package/dist/__tests__/server-tools.test.js +0 -113
- package/dist/__tests__/server-tools.test.js.map +0 -1
- package/dist/__tests__/sql-tools.test.d.ts +0 -2
- package/dist/__tests__/sql-tools.test.d.ts.map +0 -1
- package/dist/__tests__/sql-tools.test.js +0 -1912
- package/dist/__tests__/sql-tools.test.js.map +0 -1
- package/dist/__tests__/validation.test.d.ts +0 -2
- package/dist/__tests__/validation.test.d.ts.map +0 -1
- package/dist/__tests__/validation.test.js +0 -203
- package/dist/__tests__/validation.test.js.map +0 -1
package/dist/db-manager.js
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
import { Pool } from "pg";
|
|
2
2
|
import { v4 as uuidv4 } from "uuid";
|
|
3
3
|
import { isReadOnlySql } from "./utils/validation.js";
|
|
4
|
+
import { validateDatabaseName, validateSchemaName, } from "./db-manager/validation.js";
|
|
5
|
+
import { TRANSACTION_CLEANUP_TIMEOUT_MS, TRANSACTION_CLEANUP_INTERVAL_MS, } from "./tools/sql/utils/constants.js";
|
|
4
6
|
const DEFAULT_PORT = "5432";
|
|
5
7
|
const DEFAULT_DATABASE = "postgres";
|
|
6
8
|
const DEFAULT_SCHEMA = "public";
|
|
7
|
-
const DEFAULT_QUERY_TIMEOUT_MS =
|
|
9
|
+
const DEFAULT_QUERY_TIMEOUT_MS = 200000; // 30 seconds
|
|
8
10
|
const MAX_QUERY_TIMEOUT_MS = 300000; // 5 minutes
|
|
11
|
+
// Pool cache configuration
|
|
12
|
+
const MAX_CACHED_POOLS = 10; // Maximum number of cached pools for override connections
|
|
13
|
+
const POOL_IDLE_TIMEOUT_MS = 300000; // 5 minutes - idle pools are closed after this time
|
|
14
|
+
const POOL_CLEANUP_INTERVAL_MS = 60000; // 1 minute - interval for checking and cleaning up idle pools
|
|
15
|
+
const MAX_POOL_SIZE_MAIN = 10; // Max connections for main pool
|
|
16
|
+
const MAX_POOL_SIZE_CACHED = 5; // Max connections per cached pool
|
|
17
|
+
const MAX_TOTAL_CONNECTIONS = 50; // Global limit across all pools
|
|
9
18
|
/**
|
|
10
19
|
* Converts the ServerConfig ssl option to pg Pool ssl config format.
|
|
20
|
+
* Returns undefined for disabled SSL, or an SSL config object for enabled SSL.
|
|
11
21
|
*/
|
|
12
22
|
function getSslConfig(ssl) {
|
|
13
23
|
if (ssl === undefined || ssl === false || ssl === "disable") {
|
|
14
24
|
return undefined;
|
|
15
25
|
}
|
|
16
|
-
if (ssl === true ||
|
|
26
|
+
if (ssl === true ||
|
|
27
|
+
ssl === "require" ||
|
|
28
|
+
ssl === "prefer" ||
|
|
29
|
+
ssl === "allow") {
|
|
17
30
|
// Most cloud providers need rejectUnauthorized: false for self-signed certs
|
|
18
31
|
return { rejectUnauthorized: false };
|
|
19
32
|
}
|
|
20
|
-
if (ssl === "prefer" || ssl === "allow") {
|
|
21
|
-
return { rejectUnauthorized: false };
|
|
22
|
-
}
|
|
23
33
|
if (typeof ssl === "object") {
|
|
24
34
|
return ssl;
|
|
25
35
|
}
|
|
@@ -31,16 +41,14 @@ function getSslConfig(ssl) {
|
|
|
31
41
|
*/
|
|
32
42
|
function getAccessModeFromEnv() {
|
|
33
43
|
const mode = process.env.POSTGRES_ACCESS_MODE?.toLowerCase().trim();
|
|
34
|
-
|
|
35
|
-
return true; // read-only mode
|
|
36
|
-
}
|
|
37
|
-
// Default is 'full' access (read-only = false)
|
|
38
|
-
return false;
|
|
44
|
+
return mode === "readonly" || mode === "read-only" || mode === "ro";
|
|
39
45
|
}
|
|
40
46
|
/**
|
|
41
47
|
* Parses SSL configuration from environment variable string.
|
|
42
48
|
* Accepts: "true", "false", "require", "prefer", "allow", "disable", or JSON object
|
|
49
|
+
* Note: Returns union type intentionally - this is a parser function
|
|
43
50
|
*/
|
|
51
|
+
// eslint-disable-next-line sonarjs/function-return-type
|
|
44
52
|
function parseSslFromEnv(sslValue) {
|
|
45
53
|
if (!sslValue)
|
|
46
54
|
return undefined;
|
|
@@ -150,6 +158,16 @@ export class DatabaseManager {
|
|
|
150
158
|
readOnlyMode;
|
|
151
159
|
queryTimeoutMs;
|
|
152
160
|
activeTransactions = new Map();
|
|
161
|
+
// Pool cache for connection overrides (keyed by "serverName:database")
|
|
162
|
+
poolCache = new Map();
|
|
163
|
+
poolCleanupInterval = null;
|
|
164
|
+
transactionCleanupInterval = null;
|
|
165
|
+
// Track pool creation promises to prevent race conditions
|
|
166
|
+
// When multiple concurrent requests need the same pool, they all wait for the same Promise
|
|
167
|
+
poolCreationPromises = new Map();
|
|
168
|
+
// Track total active connections across all pools for global limit enforcement
|
|
169
|
+
totalActiveConnections = 0;
|
|
170
|
+
mainPoolActiveConnections = 0;
|
|
153
171
|
constructor(readOnlyMode = true, queryTimeoutMs = DEFAULT_QUERY_TIMEOUT_MS) {
|
|
154
172
|
this.serversConfig = this.loadServersConfig();
|
|
155
173
|
this.connectionState = {
|
|
@@ -159,6 +177,154 @@ export class DatabaseManager {
|
|
|
159
177
|
};
|
|
160
178
|
this.readOnlyMode = readOnlyMode;
|
|
161
179
|
this.queryTimeoutMs = Math.min(queryTimeoutMs, MAX_QUERY_TIMEOUT_MS);
|
|
180
|
+
// Start pool cleanup interval
|
|
181
|
+
this.startPoolCleanup();
|
|
182
|
+
// Start transaction cleanup interval
|
|
183
|
+
this.startTransactionCleanup();
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Starts the periodic cleanup of idle cached pools
|
|
187
|
+
*/
|
|
188
|
+
startPoolCleanup() {
|
|
189
|
+
if (this.poolCleanupInterval) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.poolCleanupInterval = setInterval(() => {
|
|
193
|
+
this.cleanupIdlePools();
|
|
194
|
+
}, POOL_CLEANUP_INTERVAL_MS);
|
|
195
|
+
// Allow the process to exit even if the interval is running
|
|
196
|
+
if (this.poolCleanupInterval.unref) {
|
|
197
|
+
this.poolCleanupInterval.unref();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Cleans up pools that have been idle for too long
|
|
202
|
+
*/
|
|
203
|
+
cleanupIdlePools() {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const poolsToRemove = [];
|
|
206
|
+
for (const [key, cached] of this.poolCache.entries()) {
|
|
207
|
+
if (now - cached.lastUsed > POOL_IDLE_TIMEOUT_MS) {
|
|
208
|
+
poolsToRemove.push(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const key of poolsToRemove) {
|
|
212
|
+
const cached = this.poolCache.get(key);
|
|
213
|
+
if (cached) {
|
|
214
|
+
cached.pool.end().catch((err) => {
|
|
215
|
+
console.error(`Error closing idle pool ${key}:`, err);
|
|
216
|
+
});
|
|
217
|
+
this.poolCache.delete(key);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Starts the periodic cleanup of abandoned transactions.
|
|
223
|
+
* Transactions older than TRANSACTION_CLEANUP_TIMEOUT_MS (45 minutes) are
|
|
224
|
+
* automatically rolled back to prevent connection leaks.
|
|
225
|
+
*/
|
|
226
|
+
startTransactionCleanup() {
|
|
227
|
+
if (this.transactionCleanupInterval) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.transactionCleanupInterval = setInterval(() => {
|
|
231
|
+
this.cleanupAbandonedTransactions();
|
|
232
|
+
}, TRANSACTION_CLEANUP_INTERVAL_MS);
|
|
233
|
+
// Allow the process to exit even if the interval is running
|
|
234
|
+
if (this.transactionCleanupInterval.unref) {
|
|
235
|
+
this.transactionCleanupInterval.unref();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Cleans up transactions that have been open for too long.
|
|
240
|
+
* Rolls back and releases the client for any transaction older than
|
|
241
|
+
* TRANSACTION_CLEANUP_TIMEOUT_MS (45 minutes).
|
|
242
|
+
*
|
|
243
|
+
* @returns Array of cleaned up transaction IDs with their info
|
|
244
|
+
*/
|
|
245
|
+
cleanupAbandonedTransactions() {
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
const cleanedUp = [];
|
|
248
|
+
for (const [transactionId, transaction,] of this.activeTransactions.entries()) {
|
|
249
|
+
const age = now - transaction.info.startedAt.getTime();
|
|
250
|
+
if (age > TRANSACTION_CLEANUP_TIMEOUT_MS) {
|
|
251
|
+
const info = {
|
|
252
|
+
transactionId,
|
|
253
|
+
name: transaction.info.name,
|
|
254
|
+
age,
|
|
255
|
+
};
|
|
256
|
+
// Roll back the transaction and release the client
|
|
257
|
+
transaction.client
|
|
258
|
+
.query("ROLLBACK")
|
|
259
|
+
.catch((err) => {
|
|
260
|
+
console.error(`Error rolling back abandoned transaction ${transactionId}:`, err);
|
|
261
|
+
})
|
|
262
|
+
.finally(() => {
|
|
263
|
+
transaction.client.release();
|
|
264
|
+
});
|
|
265
|
+
this.activeTransactions.delete(transactionId);
|
|
266
|
+
cleanedUp.push(info);
|
|
267
|
+
console.error(`Cleaned up abandoned transaction: ${transactionId}` +
|
|
268
|
+
(transaction.info.name ? ` (${transaction.info.name})` : "") +
|
|
269
|
+
` - age: ${Math.round(age / 60000)} minutes`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return cleanedUp;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Gets the age of a transaction in milliseconds.
|
|
276
|
+
*
|
|
277
|
+
* @param transactionId - The transaction ID
|
|
278
|
+
* @returns Age in milliseconds or null if transaction not found
|
|
279
|
+
*/
|
|
280
|
+
getTransactionAge(transactionId) {
|
|
281
|
+
const transaction = this.activeTransactions.get(transactionId);
|
|
282
|
+
if (!transaction)
|
|
283
|
+
return null;
|
|
284
|
+
return Date.now() - transaction.info.startedAt.getTime();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Gets the remaining time before a transaction is auto-cleaned in milliseconds.
|
|
288
|
+
*
|
|
289
|
+
* @param transactionId - The transaction ID
|
|
290
|
+
* @returns Remaining time in milliseconds or null if transaction not found
|
|
291
|
+
*/
|
|
292
|
+
getTransactionTimeRemaining(transactionId) {
|
|
293
|
+
const age = this.getTransactionAge(transactionId);
|
|
294
|
+
if (age === null)
|
|
295
|
+
return null;
|
|
296
|
+
return Math.max(0, TRANSACTION_CLEANUP_TIMEOUT_MS - age);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Generates a cache key for a server/database combination
|
|
300
|
+
*/
|
|
301
|
+
getPoolCacheKey(serverName, database) {
|
|
302
|
+
return `${serverName}:${database}`;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Evicts the least recently used pool when cache is full
|
|
306
|
+
*/
|
|
307
|
+
evictLruPool() {
|
|
308
|
+
if (this.poolCache.size < MAX_CACHED_POOLS) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let oldestKey = null;
|
|
312
|
+
let oldestTime = Infinity;
|
|
313
|
+
for (const [key, cached] of this.poolCache.entries()) {
|
|
314
|
+
if (cached.lastUsed < oldestTime) {
|
|
315
|
+
oldestTime = cached.lastUsed;
|
|
316
|
+
oldestKey = key;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (oldestKey) {
|
|
320
|
+
const cached = this.poolCache.get(oldestKey);
|
|
321
|
+
if (cached) {
|
|
322
|
+
cached.pool.end().catch((err) => {
|
|
323
|
+
console.error(`Error closing evicted pool ${oldestKey}:`, err);
|
|
324
|
+
});
|
|
325
|
+
this.poolCache.delete(oldestKey);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
162
328
|
}
|
|
163
329
|
loadServersConfig() {
|
|
164
330
|
// Load from both sources - PG_* env vars take precedence over POSTGRES_SERVERS JSON
|
|
@@ -213,12 +379,8 @@ export class DatabaseManager {
|
|
|
213
379
|
}
|
|
214
380
|
// Use provided database, server's default, or system default
|
|
215
381
|
const dbName = database || serverConfig.defaultDatabase || DEFAULT_DATABASE;
|
|
216
|
-
// Validate database name
|
|
217
|
-
|
|
218
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(dbName) ||
|
|
219
|
-
/--|;|'|"|`/.test(dbName)) {
|
|
220
|
-
throw new Error("Invalid database name. Allowed: letters, digits, underscores, hyphens. Cannot contain SQL characters (;, --, quotes).");
|
|
221
|
-
}
|
|
382
|
+
// Validate database name for SQL injection prevention
|
|
383
|
+
validateDatabaseName(dbName);
|
|
222
384
|
const sslConfig = getSslConfig(serverConfig.ssl);
|
|
223
385
|
this.currentPool = new Pool({
|
|
224
386
|
host: serverConfig.host,
|
|
@@ -253,14 +415,14 @@ export class DatabaseManager {
|
|
|
253
415
|
}
|
|
254
416
|
}
|
|
255
417
|
setCurrentSchema(schema) {
|
|
256
|
-
|
|
257
|
-
throw new Error("Invalid schema name. Only alphanumeric characters and underscores are allowed.");
|
|
258
|
-
}
|
|
418
|
+
validateSchemaName(schema);
|
|
259
419
|
this.connectionState.currentSchema = schema;
|
|
260
420
|
}
|
|
261
421
|
getConnectionInfo() {
|
|
262
422
|
const currentServer = this.connectionState.currentServer;
|
|
263
|
-
const serverConfig = currentServer
|
|
423
|
+
const serverConfig = currentServer
|
|
424
|
+
? this.serversConfig[currentServer]
|
|
425
|
+
: null;
|
|
264
426
|
return {
|
|
265
427
|
isConnected: this.isConnected(),
|
|
266
428
|
server: currentServer,
|
|
@@ -335,7 +497,386 @@ export class DatabaseManager {
|
|
|
335
497
|
}
|
|
336
498
|
return this.currentPool.connect();
|
|
337
499
|
}
|
|
500
|
+
/**
|
|
501
|
+
* Checks if we can acquire a new connection without exceeding global limits
|
|
502
|
+
*/
|
|
503
|
+
canAcquireConnection() {
|
|
504
|
+
return this.totalActiveConnections < MAX_TOTAL_CONNECTIONS;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Acquires a client from the main pool with proper tracking
|
|
508
|
+
*/
|
|
509
|
+
async acquireMainPoolClient() {
|
|
510
|
+
if (!this.currentPool) {
|
|
511
|
+
throw new Error("No database connection. Please switch to a server and database first.");
|
|
512
|
+
}
|
|
513
|
+
if (!this.canAcquireConnection()) {
|
|
514
|
+
throw new Error(`Connection limit reached (${MAX_TOTAL_CONNECTIONS} max). Please wait for existing operations to complete.`);
|
|
515
|
+
}
|
|
516
|
+
const client = await this.currentPool.connect();
|
|
517
|
+
this.totalActiveConnections++;
|
|
518
|
+
this.mainPoolActiveConnections++;
|
|
519
|
+
const release = () => {
|
|
520
|
+
this.totalActiveConnections = Math.max(0, this.totalActiveConnections - 1);
|
|
521
|
+
this.mainPoolActiveConnections = Math.max(0, this.mainPoolActiveConnections - 1);
|
|
522
|
+
client.release();
|
|
523
|
+
};
|
|
524
|
+
return { client, release };
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Acquires a client from a cached pool with proper tracking
|
|
528
|
+
*/
|
|
529
|
+
async acquireCachedPoolClient(cached) {
|
|
530
|
+
if (!this.canAcquireConnection()) {
|
|
531
|
+
throw new Error(`Connection limit reached (${MAX_TOTAL_CONNECTIONS} max). Please wait for existing operations to complete.`);
|
|
532
|
+
}
|
|
533
|
+
const client = await cached.pool.connect();
|
|
534
|
+
this.totalActiveConnections++;
|
|
535
|
+
cached.activeConnections++;
|
|
536
|
+
cached.lastUsed = Date.now();
|
|
537
|
+
const release = () => {
|
|
538
|
+
this.totalActiveConnections = Math.max(0, this.totalActiveConnections - 1);
|
|
539
|
+
cached.activeConnections = Math.max(0, cached.activeConnections - 1);
|
|
540
|
+
client.release();
|
|
541
|
+
};
|
|
542
|
+
return { client, release };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Creates a new cached pool. This method handles concurrent creation requests
|
|
546
|
+
* by storing the creation promise so multiple callers wait for the same pool.
|
|
547
|
+
*/
|
|
548
|
+
async getOrCreateCachedPool(serverName, database, serverConfig) {
|
|
549
|
+
const cacheKey = this.getPoolCacheKey(serverName, database);
|
|
550
|
+
// Check if pool already exists
|
|
551
|
+
const existing = this.poolCache.get(cacheKey);
|
|
552
|
+
if (existing) {
|
|
553
|
+
return existing;
|
|
554
|
+
}
|
|
555
|
+
// Check if pool creation is already in progress (handles concurrent requests)
|
|
556
|
+
const inProgress = this.poolCreationPromises.get(cacheKey);
|
|
557
|
+
if (inProgress) {
|
|
558
|
+
return inProgress;
|
|
559
|
+
}
|
|
560
|
+
// Create pool - wrap in promise and store it so concurrent requests wait
|
|
561
|
+
const creationPromise = (async () => {
|
|
562
|
+
try {
|
|
563
|
+
// Double-check after acquiring "lock" (in case another request completed)
|
|
564
|
+
const existingAfterLock = this.poolCache.get(cacheKey);
|
|
565
|
+
if (existingAfterLock) {
|
|
566
|
+
return existingAfterLock;
|
|
567
|
+
}
|
|
568
|
+
// Evict LRU pool if cache is full
|
|
569
|
+
this.evictLruPool();
|
|
570
|
+
// Create new pool with smaller size for cached connections
|
|
571
|
+
const sslConfig = getSslConfig(serverConfig.ssl);
|
|
572
|
+
const pool = new Pool({
|
|
573
|
+
host: serverConfig.host,
|
|
574
|
+
port: parseInt(serverConfig.port || DEFAULT_PORT, 10),
|
|
575
|
+
user: serverConfig.username,
|
|
576
|
+
password: serverConfig.password,
|
|
577
|
+
database,
|
|
578
|
+
max: MAX_POOL_SIZE_CACHED,
|
|
579
|
+
idleTimeoutMillis: 30000,
|
|
580
|
+
connectionTimeoutMillis: 10000,
|
|
581
|
+
statement_timeout: this.queryTimeoutMs,
|
|
582
|
+
...(sslConfig && { ssl: sslConfig }),
|
|
583
|
+
});
|
|
584
|
+
// Handle pool errors - remove from cache on critical errors
|
|
585
|
+
pool.on("error", (err) => {
|
|
586
|
+
console.error(`Pool error for ${cacheKey}:`, err);
|
|
587
|
+
this.poolCache.delete(cacheKey);
|
|
588
|
+
});
|
|
589
|
+
// Test connection before caching
|
|
590
|
+
const testClient = await pool.connect();
|
|
591
|
+
testClient.release();
|
|
592
|
+
const cached = {
|
|
593
|
+
pool,
|
|
594
|
+
serverName,
|
|
595
|
+
database,
|
|
596
|
+
lastUsed: Date.now(),
|
|
597
|
+
createdAt: Date.now(),
|
|
598
|
+
activeConnections: 0,
|
|
599
|
+
};
|
|
600
|
+
this.poolCache.set(cacheKey, cached);
|
|
601
|
+
return cached;
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
// Always remove the creation promise when done (success or failure)
|
|
605
|
+
this.poolCreationPromises.delete(cacheKey);
|
|
606
|
+
}
|
|
607
|
+
})();
|
|
608
|
+
// Store the promise so concurrent requests can wait for it
|
|
609
|
+
this.poolCreationPromises.set(cacheKey, creationPromise);
|
|
610
|
+
return creationPromise;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Gets a client with optional connection override for one-time execution.
|
|
614
|
+
* Handles concurrent calls efficiently by:
|
|
615
|
+
* - Reusing existing pools for the same server/database
|
|
616
|
+
* - Preventing duplicate pool creation through promise caching
|
|
617
|
+
* - Tracking active connections for resource management
|
|
618
|
+
* - Enforcing global connection limits
|
|
619
|
+
*
|
|
620
|
+
* @param override - Optional connection override parameters
|
|
621
|
+
* @returns Object containing the client, release function, and resolved connection info
|
|
622
|
+
* @throws Error if no connection and no override, or if override server not found
|
|
623
|
+
*/
|
|
624
|
+
async getClientWithOverride(override) {
|
|
625
|
+
// If no override, use current connection
|
|
626
|
+
if (!override ||
|
|
627
|
+
(!override.server && !override.database && !override.schema)) {
|
|
628
|
+
if (!this.currentPool) {
|
|
629
|
+
throw new Error("No database connection. Please switch to a server and database first, or provide server/database/schema override parameters.");
|
|
630
|
+
}
|
|
631
|
+
const { client, release } = await this.acquireMainPoolClient();
|
|
632
|
+
const serverConfig = this.connectionState.currentServer
|
|
633
|
+
? this.serversConfig[this.connectionState.currentServer]
|
|
634
|
+
: null;
|
|
635
|
+
// Set search_path to current schema
|
|
636
|
+
const schema = this.connectionState.currentSchema || DEFAULT_SCHEMA;
|
|
637
|
+
try {
|
|
638
|
+
await client.query(`SET search_path TO ${this.escapeIdentifier(schema)}`);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
release();
|
|
642
|
+
throw new Error(`Failed to set schema '${schema}': ${error instanceof Error ? error.message : String(error)}`);
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
client,
|
|
646
|
+
release,
|
|
647
|
+
server: this.connectionState.currentServer || "",
|
|
648
|
+
database: this.connectionState.currentDatabase || "",
|
|
649
|
+
schema,
|
|
650
|
+
context: serverConfig?.context,
|
|
651
|
+
isOverride: false,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
// Resolve server - use override or current
|
|
655
|
+
const serverName = override.server || this.connectionState.currentServer;
|
|
656
|
+
if (!serverName) {
|
|
657
|
+
// Build a helpful error message describing what was provided vs what's needed
|
|
658
|
+
const providedParams = [];
|
|
659
|
+
if (override.database)
|
|
660
|
+
providedParams.push(`database='${override.database}'`);
|
|
661
|
+
if (override.schema)
|
|
662
|
+
providedParams.push(`schema='${override.schema}'`);
|
|
663
|
+
const providedStr = providedParams.length > 0
|
|
664
|
+
? ` You provided ${providedParams.join(", ")} but no server.`
|
|
665
|
+
: "";
|
|
666
|
+
const availableServers = this.getServerNames();
|
|
667
|
+
const serversHint = availableServers.length > 0
|
|
668
|
+
? ` Available servers: ${availableServers.join(", ")}.`
|
|
669
|
+
: " No servers are configured. Please set PG_* environment variables or POSTGRES_SERVERS.";
|
|
670
|
+
throw new Error(`No server specified and no current connection.${providedStr} ` +
|
|
671
|
+
`Provide 'server' parameter to specify target server, or use switch_server to connect first.${serversHint}`);
|
|
672
|
+
}
|
|
673
|
+
const serverConfig = this.getServerConfig(serverName);
|
|
674
|
+
if (!serverConfig) {
|
|
675
|
+
const availableServers = this.getServerNames();
|
|
676
|
+
throw new Error(`Server '${serverName}' not found. Available servers: ${availableServers.join(", ") || "none configured"}`);
|
|
677
|
+
}
|
|
678
|
+
// Resolve database - use override, or if same server use current, or use server default
|
|
679
|
+
let database;
|
|
680
|
+
if (override.database) {
|
|
681
|
+
database = override.database;
|
|
682
|
+
}
|
|
683
|
+
else if (serverName === this.connectionState.currentServer &&
|
|
684
|
+
this.connectionState.currentDatabase) {
|
|
685
|
+
database = this.connectionState.currentDatabase;
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
database = serverConfig.defaultDatabase || DEFAULT_DATABASE;
|
|
689
|
+
}
|
|
690
|
+
// Validate database name for SQL injection prevention
|
|
691
|
+
validateDatabaseName(database);
|
|
692
|
+
// Resolve schema
|
|
693
|
+
let schema;
|
|
694
|
+
if (override.schema) {
|
|
695
|
+
schema = override.schema;
|
|
696
|
+
}
|
|
697
|
+
else if (serverName === this.connectionState.currentServer &&
|
|
698
|
+
this.connectionState.currentSchema) {
|
|
699
|
+
schema = this.connectionState.currentSchema;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
schema = serverConfig.defaultSchema || DEFAULT_SCHEMA;
|
|
703
|
+
}
|
|
704
|
+
// Validate schema name
|
|
705
|
+
validateSchemaName(schema);
|
|
706
|
+
// Check if this is the same as current connection (can use main pool)
|
|
707
|
+
const isSameAsMain = serverName === this.connectionState.currentServer &&
|
|
708
|
+
database === this.connectionState.currentDatabase;
|
|
709
|
+
if (isSameAsMain && this.currentPool) {
|
|
710
|
+
// Use main pool but with potentially different schema
|
|
711
|
+
const { client, release } = await this.acquireMainPoolClient();
|
|
712
|
+
try {
|
|
713
|
+
await client.query(`SET search_path TO ${this.escapeIdentifier(schema)}`);
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
release();
|
|
717
|
+
throw new Error(`Failed to set schema '${schema}': ${error instanceof Error ? error.message : String(error)}`);
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
client,
|
|
721
|
+
release,
|
|
722
|
+
server: serverName,
|
|
723
|
+
database,
|
|
724
|
+
schema,
|
|
725
|
+
context: serverConfig.context,
|
|
726
|
+
isOverride: override.schema !== undefined &&
|
|
727
|
+
override.schema !== this.connectionState.currentSchema,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
// Get or create cached pool for the override connection
|
|
731
|
+
// This handles concurrent requests efficiently
|
|
732
|
+
const cached = await this.getOrCreateCachedPool(serverName, database, serverConfig);
|
|
733
|
+
const { client, release } = await this.acquireCachedPoolClient(cached);
|
|
734
|
+
try {
|
|
735
|
+
await client.query(`SET search_path TO ${this.escapeIdentifier(schema)}`);
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
release();
|
|
739
|
+
throw new Error(`Failed to set schema '${schema}': ${error instanceof Error ? error.message : String(error)}`);
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
client,
|
|
743
|
+
release,
|
|
744
|
+
server: serverName,
|
|
745
|
+
database,
|
|
746
|
+
schema,
|
|
747
|
+
context: serverConfig.context,
|
|
748
|
+
isOverride: true,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Executes a query with optional connection override.
|
|
753
|
+
* Convenience method that handles client lifecycle automatically.
|
|
754
|
+
*
|
|
755
|
+
* @param sql - SQL query to execute
|
|
756
|
+
* @param params - Query parameters
|
|
757
|
+
* @param override - Optional connection override parameters
|
|
758
|
+
* @returns Query result with connection info
|
|
759
|
+
*/
|
|
760
|
+
async queryWithOverride(sql, params, override) {
|
|
761
|
+
if (!sql || typeof sql !== "string") {
|
|
762
|
+
throw new Error("SQL query is required and must be a string");
|
|
763
|
+
}
|
|
764
|
+
// Check for read-only mode violations
|
|
765
|
+
if (this.readOnlyMode) {
|
|
766
|
+
const { isReadOnly, reason } = isReadOnlySql(sql);
|
|
767
|
+
if (!isReadOnly) {
|
|
768
|
+
throw new Error(`Read-only mode violation: ${reason}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const { client, release, server, database, schema } = await this.getClientWithOverride(override);
|
|
772
|
+
try {
|
|
773
|
+
const result = await client.query(sql, params);
|
|
774
|
+
return {
|
|
775
|
+
...result,
|
|
776
|
+
connectionInfo: { server, database, schema },
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
release();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Escapes a PostgreSQL identifier to prevent SQL injection
|
|
785
|
+
*/
|
|
786
|
+
escapeIdentifier(identifier) {
|
|
787
|
+
// Double any double quotes and wrap in double quotes
|
|
788
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Closes all cached pools and cleans up resources
|
|
792
|
+
*/
|
|
793
|
+
async closeAllCachedPools() {
|
|
794
|
+
const closePromises = [];
|
|
795
|
+
for (const [key, cached] of this.poolCache.entries()) {
|
|
796
|
+
closePromises.push(cached.pool.end().catch((err) => {
|
|
797
|
+
console.error(`Error closing pool ${key}:`, err);
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
await Promise.all(closePromises);
|
|
801
|
+
this.poolCache.clear();
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Gets comprehensive statistics about all connection pools (for monitoring/debugging)
|
|
805
|
+
*/
|
|
806
|
+
getConnectionStats() {
|
|
807
|
+
const now = Date.now();
|
|
808
|
+
const pools = Array.from(this.poolCache.entries()).map(([key, cached]) => ({
|
|
809
|
+
key,
|
|
810
|
+
serverName: cached.serverName,
|
|
811
|
+
database: cached.database,
|
|
812
|
+
activeConnections: cached.activeConnections,
|
|
813
|
+
maxSize: MAX_POOL_SIZE_CACHED,
|
|
814
|
+
lastUsed: new Date(cached.lastUsed),
|
|
815
|
+
createdAt: new Date(cached.createdAt),
|
|
816
|
+
idleTimeRemaining: Math.max(0, POOL_IDLE_TIMEOUT_MS - (now - cached.lastUsed)),
|
|
817
|
+
}));
|
|
818
|
+
const cachedPoolsTotalActive = Array.from(this.poolCache.values()).reduce((sum, cached) => sum + cached.activeConnections, 0);
|
|
819
|
+
return {
|
|
820
|
+
totalActiveConnections: this.totalActiveConnections,
|
|
821
|
+
maxTotalConnections: MAX_TOTAL_CONNECTIONS,
|
|
822
|
+
mainPool: {
|
|
823
|
+
activeConnections: this.mainPoolActiveConnections,
|
|
824
|
+
maxSize: MAX_POOL_SIZE_MAIN,
|
|
825
|
+
isConnected: this.currentPool !== null,
|
|
826
|
+
},
|
|
827
|
+
cachedPools: {
|
|
828
|
+
count: this.poolCache.size,
|
|
829
|
+
maxCount: MAX_CACHED_POOLS,
|
|
830
|
+
totalActiveConnections: cachedPoolsTotalActive,
|
|
831
|
+
pools,
|
|
832
|
+
},
|
|
833
|
+
pendingPoolCreations: this.poolCreationPromises.size,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* @deprecated Use getConnectionStats() instead
|
|
838
|
+
* Gets statistics about cached pools (for monitoring/debugging)
|
|
839
|
+
*/
|
|
840
|
+
getCachedPoolStats() {
|
|
841
|
+
const pools = Array.from(this.poolCache.entries()).map(([key, cached]) => ({
|
|
842
|
+
key,
|
|
843
|
+
serverName: cached.serverName,
|
|
844
|
+
database: cached.database,
|
|
845
|
+
lastUsed: new Date(cached.lastUsed),
|
|
846
|
+
createdAt: new Date(cached.createdAt),
|
|
847
|
+
}));
|
|
848
|
+
return {
|
|
849
|
+
count: this.poolCache.size,
|
|
850
|
+
maxSize: MAX_CACHED_POOLS,
|
|
851
|
+
pools,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
338
854
|
async close() {
|
|
855
|
+
// Stop cleanup intervals
|
|
856
|
+
if (this.poolCleanupInterval) {
|
|
857
|
+
clearInterval(this.poolCleanupInterval);
|
|
858
|
+
this.poolCleanupInterval = null;
|
|
859
|
+
}
|
|
860
|
+
if (this.transactionCleanupInterval) {
|
|
861
|
+
clearInterval(this.transactionCleanupInterval);
|
|
862
|
+
this.transactionCleanupInterval = null;
|
|
863
|
+
}
|
|
864
|
+
// Roll back and release all active transactions
|
|
865
|
+
for (const [transactionId, transaction,] of this.activeTransactions.entries()) {
|
|
866
|
+
try {
|
|
867
|
+
await transaction.client.query("ROLLBACK");
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
console.error(`Error rolling back transaction ${transactionId} during close:`, err);
|
|
871
|
+
}
|
|
872
|
+
finally {
|
|
873
|
+
transaction.client.release();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
this.activeTransactions.clear();
|
|
877
|
+
// Close all cached pools
|
|
878
|
+
await this.closeAllCachedPools();
|
|
879
|
+
// Close main pool
|
|
339
880
|
if (this.currentPool) {
|
|
340
881
|
await this.currentPool.end();
|
|
341
882
|
this.currentPool = null;
|
|
@@ -413,6 +954,8 @@ export class DatabaseManager {
|
|
|
413
954
|
"no connection to the server",
|
|
414
955
|
"server conn crashed",
|
|
415
956
|
"database removed",
|
|
957
|
+
"timed out",
|
|
958
|
+
"idle",
|
|
416
959
|
];
|
|
417
960
|
// Check error codes
|
|
418
961
|
const connectionErrorCodes = [
|
|
@@ -534,19 +1077,39 @@ export class DatabaseManager {
|
|
|
534
1077
|
return transaction.client.query(sql, params);
|
|
535
1078
|
}
|
|
536
1079
|
/**
|
|
537
|
-
* Gets information about an active transaction
|
|
1080
|
+
* Gets information about an active transaction, including time remaining.
|
|
1081
|
+
*
|
|
1082
|
+
* @param transactionId - The transaction ID
|
|
1083
|
+
* @returns Transaction info with age and time remaining, or null if not found
|
|
538
1084
|
*/
|
|
539
1085
|
getTransactionInfo(transactionId) {
|
|
540
1086
|
const transaction = this.activeTransactions.get(transactionId);
|
|
541
|
-
|
|
1087
|
+
if (!transaction)
|
|
1088
|
+
return null;
|
|
1089
|
+
const ageMs = Date.now() - transaction.info.startedAt.getTime();
|
|
1090
|
+
const timeRemainingMs = Math.max(0, TRANSACTION_CLEANUP_TIMEOUT_MS - ageMs);
|
|
1091
|
+
return {
|
|
1092
|
+
...transaction.info,
|
|
1093
|
+
ageMs,
|
|
1094
|
+
timeRemainingMs,
|
|
1095
|
+
};
|
|
542
1096
|
}
|
|
543
1097
|
/**
|
|
544
|
-
* Lists all active transactions
|
|
1098
|
+
* Lists all active transactions with age and time remaining.
|
|
1099
|
+
*
|
|
1100
|
+
* @returns Array of transaction info objects with age and time remaining
|
|
545
1101
|
*/
|
|
546
1102
|
listActiveTransactions() {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1103
|
+
const now = Date.now();
|
|
1104
|
+
return Array.from(this.activeTransactions.values()).map((t) => {
|
|
1105
|
+
const ageMs = now - t.info.startedAt.getTime();
|
|
1106
|
+
const timeRemainingMs = Math.max(0, TRANSACTION_CLEANUP_TIMEOUT_MS - ageMs);
|
|
1107
|
+
return {
|
|
1108
|
+
...t.info,
|
|
1109
|
+
ageMs,
|
|
1110
|
+
timeRemainingMs,
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
550
1113
|
}
|
|
551
1114
|
}
|
|
552
1115
|
// Singleton instance
|