@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.
Files changed (96) hide show
  1. package/README.md +186 -10
  2. package/dist/db-manager/index.d.ts +7 -0
  3. package/dist/db-manager/index.d.ts.map +1 -0
  4. package/dist/db-manager/index.js +7 -0
  5. package/dist/db-manager/index.js.map +1 -0
  6. package/dist/db-manager/validation.d.ts +35 -0
  7. package/dist/db-manager/validation.d.ts.map +1 -0
  8. package/dist/db-manager/validation.js +54 -0
  9. package/dist/db-manager/validation.js.map +1 -0
  10. package/dist/db-manager.d.ts +175 -5
  11. package/dist/db-manager.d.ts.map +1 -1
  12. package/dist/db-manager.js +589 -26
  13. package/dist/db-manager.js.map +1 -1
  14. package/dist/index.js +141 -11
  15. package/dist/index.js.map +1 -1
  16. package/dist/tools/analysis-tools.d.ts.map +1 -1
  17. package/dist/tools/analysis-tools.js +53 -49
  18. package/dist/tools/analysis-tools.js.map +1 -1
  19. package/dist/tools/schema-tools.d.ts +40 -1
  20. package/dist/tools/schema-tools.d.ts.map +1 -1
  21. package/dist/tools/schema-tools.js +174 -92
  22. package/dist/tools/schema-tools.js.map +1 -1
  23. package/dist/tools/server-tools.d.ts +1 -0
  24. package/dist/tools/server-tools.d.ts.map +1 -1
  25. package/dist/tools/server-tools.js +10 -6
  26. package/dist/tools/server-tools.js.map +1 -1
  27. package/dist/tools/sql/utils/connection-utils.d.ts +79 -0
  28. package/dist/tools/sql/utils/connection-utils.d.ts.map +1 -0
  29. package/dist/tools/sql/utils/connection-utils.js +129 -0
  30. package/dist/tools/sql/utils/connection-utils.js.map +1 -0
  31. package/dist/tools/sql/utils/constants.d.ts +55 -0
  32. package/dist/tools/sql/utils/constants.d.ts.map +1 -0
  33. package/dist/tools/sql/utils/constants.js +55 -0
  34. package/dist/tools/sql/utils/constants.js.map +1 -0
  35. package/dist/tools/sql/utils/dry-run-utils.d.ts +31 -0
  36. package/dist/tools/sql/utils/dry-run-utils.d.ts.map +1 -0
  37. package/dist/tools/sql/utils/dry-run-utils.js +173 -0
  38. package/dist/tools/sql/utils/dry-run-utils.js.map +1 -0
  39. package/dist/tools/sql/utils/file-handler.d.ts +57 -0
  40. package/dist/tools/sql/utils/file-handler.d.ts.map +1 -0
  41. package/dist/tools/sql/utils/file-handler.js +150 -0
  42. package/dist/tools/sql/utils/file-handler.js.map +1 -0
  43. package/dist/tools/sql/utils/index.d.ts +12 -0
  44. package/dist/tools/sql/utils/index.d.ts.map +1 -0
  45. package/dist/tools/sql/utils/index.js +12 -0
  46. package/dist/tools/sql/utils/index.js.map +1 -0
  47. package/dist/tools/sql/utils/result-formatter.d.ts +94 -0
  48. package/dist/tools/sql/utils/result-formatter.d.ts.map +1 -0
  49. package/dist/tools/sql/utils/result-formatter.js +154 -0
  50. package/dist/tools/sql/utils/result-formatter.js.map +1 -0
  51. package/dist/tools/sql/utils/sql-parser.d.ts +125 -0
  52. package/dist/tools/sql/utils/sql-parser.d.ts.map +1 -0
  53. package/dist/tools/sql/utils/sql-parser.js +468 -0
  54. package/dist/tools/sql/utils/sql-parser.js.map +1 -0
  55. package/dist/tools/sql-tools.d.ts +21 -0
  56. package/dist/tools/sql-tools.d.ts.map +1 -1
  57. package/dist/tools/sql-tools.js +383 -532
  58. package/dist/tools/sql-tools.js.map +1 -1
  59. package/dist/types.d.ts +38 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/utils/retry.d.ts +1 -1
  62. package/dist/utils/retry.d.ts.map +1 -1
  63. package/dist/utils/retry.js.map +1 -1
  64. package/dist/utils/validation.d.ts +45 -9
  65. package/dist/utils/validation.d.ts.map +1 -1
  66. package/dist/utils/validation.js +335 -72
  67. package/dist/utils/validation.js.map +1 -1
  68. package/package.json +9 -2
  69. package/dist/__tests__/analysis-tools.test.d.ts +0 -2
  70. package/dist/__tests__/analysis-tools.test.d.ts.map +0 -1
  71. package/dist/__tests__/analysis-tools.test.js +0 -294
  72. package/dist/__tests__/analysis-tools.test.js.map +0 -1
  73. package/dist/__tests__/db-manager.test.d.ts +0 -2
  74. package/dist/__tests__/db-manager.test.d.ts.map +0 -1
  75. package/dist/__tests__/db-manager.test.js +0 -410
  76. package/dist/__tests__/db-manager.test.js.map +0 -1
  77. package/dist/__tests__/mcp-server.test.d.ts +0 -13
  78. package/dist/__tests__/mcp-server.test.d.ts.map +0 -1
  79. package/dist/__tests__/mcp-server.test.js +0 -146
  80. package/dist/__tests__/mcp-server.test.js.map +0 -1
  81. package/dist/__tests__/schema-tools.test.d.ts +0 -2
  82. package/dist/__tests__/schema-tools.test.d.ts.map +0 -1
  83. package/dist/__tests__/schema-tools.test.js +0 -171
  84. package/dist/__tests__/schema-tools.test.js.map +0 -1
  85. package/dist/__tests__/server-tools.test.d.ts +0 -2
  86. package/dist/__tests__/server-tools.test.d.ts.map +0 -1
  87. package/dist/__tests__/server-tools.test.js +0 -113
  88. package/dist/__tests__/server-tools.test.js.map +0 -1
  89. package/dist/__tests__/sql-tools.test.d.ts +0 -2
  90. package/dist/__tests__/sql-tools.test.d.ts.map +0 -1
  91. package/dist/__tests__/sql-tools.test.js +0 -1912
  92. package/dist/__tests__/sql-tools.test.js.map +0 -1
  93. package/dist/__tests__/validation.test.d.ts +0 -2
  94. package/dist/__tests__/validation.test.d.ts.map +0 -1
  95. package/dist/__tests__/validation.test.js +0 -203
  96. package/dist/__tests__/validation.test.js.map +0 -1
@@ -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 = 30000; // 30 seconds
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 || ssl === "require") {
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
- if (mode === "readonly" || mode === "read-only" || mode === "ro") {
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 - allow alphanumeric, underscores, hyphens, but block SQL injection
217
- // PostgreSQL allows hyphens in database names when quoted (pg library handles this)
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
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(schema)) {
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 ? this.serversConfig[currentServer] : null;
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
- return transaction ? { ...transaction.info } : null;
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
- return Array.from(this.activeTransactions.values()).map((t) => ({
548
- ...t.info,
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