diff --git a/bridge/__tests__/connectors/sqlite.test.ts b/bridge/__tests__/connectors/sqlite.test.ts index 5e08256..f6f6136 100644 --- a/bridge/__tests__/connectors/sqlite.test.ts +++ b/bridge/__tests__/connectors/sqlite.test.ts @@ -103,7 +103,7 @@ describe("SQLite Connector", () => { const connection = await sqliteConnector.testConnection({ path: tmpDir }); expect(connection.ok).toBe(false); expect(connection.status).toBe("disconnected"); - expect(connection.message).toContain("directory"); + expect(connection.message).toContain("must have a valid SQLite extension"); }); }); @@ -261,7 +261,7 @@ describe("SQLite Connector", () => { expect(stats).toHaveProperty("total_db_size_mb"); expect(stats).toHaveProperty("total_rows"); expect(stats.total_tables).toBeGreaterThanOrEqual(2); - expect(stats.total_rows).toBeGreaterThan(0); + expect(stats.total_rows).toBe(-1); }); }); diff --git a/bridge/__tests__/dbStore.test.ts b/bridge/__tests__/dbStore.test.ts index f9f851d..b626b3d 100644 --- a/bridge/__tests__/dbStore.test.ts +++ b/bridge/__tests__/dbStore.test.ts @@ -11,7 +11,7 @@ const TEST_CONFIG_FILE = path.join(TEST_CONFIG_FOLDER, "databases.json"); const TEST_CREDENTIALS_FILE = path.join(TEST_CONFIG_FOLDER, ".credentials"); // Short TTL for testing cache expiration -const SHORT_CACHE_TTL = 200; // 200ms for testing +const SHORT_CACHE_TTL = 1000; // 200ms for testing const NORMAL_CACHE_TTL = 30000; // 30 seconds const mockDBPayload = { @@ -223,7 +223,7 @@ describe("DbStore Cache Tests", () => { expect(shortTtlStore.getCacheStats().configCached).toBe(true); // Wait for TTL to expire (add extra buffer for system lag) - await new Promise((resolve) => setTimeout(resolve, SHORT_CACHE_TTL + 150)); + await new Promise((resolve) => setTimeout(resolve, SHORT_CACHE_TTL + 500)); // Cache should be expired now expect(shortTtlStore.getCacheStats().configCached).toBe(false); diff --git a/bridge/__tests__/discoveryService.test.ts b/bridge/__tests__/discoveryService.test.ts index e122c6c..03e992e 100644 --- a/bridge/__tests__/discoveryService.test.ts +++ b/bridge/__tests__/discoveryService.test.ts @@ -339,7 +339,7 @@ describe("DiscoveryService", () => { // Actual discovery depends on system state const result = await service.discoverLocalDatabases(); expect(Array.isArray(result)).toBe(true); - }); + }, 30000); // Increased timeout for docker commands test("each discovered database should have required fields", async () => { const result = await service.discoverLocalDatabases(); @@ -355,6 +355,6 @@ describe("DiscoveryService", () => { expect(db.defaultUser).toBeDefined(); expect(db.defaultDatabase).toBeDefined(); } - }); + }, 30000); // Increased timeout for docker commands }); }); diff --git a/bridge/src/connectors/mariadb.ts b/bridge/src/connectors/mariadb.ts index 6c77d95..bfbc2fc 100644 --- a/bridge/src/connectors/mariadb.ts +++ b/bridge/src/connectors/mariadb.ts @@ -1331,7 +1331,12 @@ export async function ensureMigrationTable(conn: MariaDBConfig) { const pool = mysql.createPool(createPoolConfig(conn)); const connection = await pool.getConnection(); - await connection.query(CREATE_MIGRATION_TABLE); + try { + await connection.query(CREATE_MIGRATION_TABLE); + } finally { + connection.release(); + await pool.end(); + } } @@ -1339,8 +1344,13 @@ export async function hasAnyMigrations(conn: MariaDBConfig): Promise { const pool = mysql.createPool(createPoolConfig(conn)); const connection = await pool.getConnection(); - const [rows] = await connection.query(CHECK_MIGRATIONS_EXIST); - return rows.length > 0; + try { + const [rows] = await connection.query(CHECK_MIGRATIONS_EXIST); + return rows.length > 0; + } finally { + connection.release(); + await pool.end(); + } } @@ -1353,7 +1363,12 @@ export async function insertBaseline( const pool = mysql.createPool(createPoolConfig(conn)); const connection = await pool.getConnection(); - await connection.query(INSERT_MIGRATION, [version, name, checksum]); + try { + await connection.query(INSERT_MIGRATION, [version, name, checksum]); + } finally { + connection.release(); + await pool.end(); + } } export async function baselineIfNeeded( diff --git a/bridge/src/connectors/mysql.ts b/bridge/src/connectors/mysql.ts index dd49a42..eefc44d 100644 --- a/bridge/src/connectors/mysql.ts +++ b/bridge/src/connectors/mysql.ts @@ -1306,7 +1306,12 @@ export async function ensureMigrationTable(conn: MySQLConfig) { const pool = mysql.createPool(conn); const connection = await pool.getConnection(); - await connection.query(CREATE_MIGRATION_TABLE); + try { + await connection.query(CREATE_MIGRATION_TABLE); + } finally { + connection.release(); + await pool.end(); + } } @@ -1314,8 +1319,13 @@ export async function hasAnyMigrations(conn: MySQLConfig): Promise { const pool = mysql.createPool(conn); const connection = await pool.getConnection(); - const [rows] = await connection.query(CHECK_MIGRATIONS_EXIST); - return rows.length > 0; + try { + const [rows] = await connection.query(CHECK_MIGRATIONS_EXIST); + return rows.length > 0; + } finally { + connection.release(); + await pool.end(); + } } @@ -1328,7 +1338,12 @@ export async function insertBaseline( const pool = mysql.createPool(conn); const connection = await pool.getConnection(); - await connection.query(INSERT_MIGRATION, [version, name, checksum]); + try { + await connection.query(INSERT_MIGRATION, [version, name, checksum]); + } finally { + connection.release(); + await pool.end(); + } } export async function baselineIfNeeded( diff --git a/bridge/src/connectors/postgres.ts b/bridge/src/connectors/postgres.ts index 4f56d94..4bc5bbd 100644 --- a/bridge/src/connectors/postgres.ts +++ b/bridge/src/connectors/postgres.ts @@ -1,5 +1,5 @@ // bridge/src/connectors/postgres.ts -import { Client } from "pg"; +import { Pool, PoolClient } from "pg"; import QueryStream from "pg-query-stream"; import { Readable } from "stream"; import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration"; @@ -13,6 +13,7 @@ import { STATS_CACHE_TTL, SCHEMA_CACHE_TTL } from "../types/cache"; +import { LRUCache } from "../utils/lruCache"; import { TableInfo, DBStats, @@ -82,17 +83,17 @@ import { pgQuoteIdentifier } from "../queries/postgres/crud"; export class PostgresCacheManager { // Cache stores for different data types - private tableListCache = new Map>(); - private primaryKeysCache = new Map>(); - private dbStatsCache = new Map>(); - private schemasCache = new Map>(); - private tableDetailsCache = new Map>(); - private foreignKeysCache = new Map>(); - private indexesCache = new Map>(); - private uniqueCache = new Map>(); - private checksCache = new Map>(); - private enumsCache = new Map>(); - private sequencesCache = new Map>(); + private tableListCache = new LRUCache(100); + private primaryKeysCache = new LRUCache(1000); + private dbStatsCache = new LRUCache(50); + private schemasCache = new LRUCache(50); + private tableDetailsCache = new LRUCache(1000); + private foreignKeysCache = new LRUCache(1000); + private indexesCache = new LRUCache(1000); + private uniqueCache = new LRUCache(1000); + private checksCache = new LRUCache(1000); + private enumsCache = new LRUCache(1000); + private sequencesCache = new LRUCache(1000); /** * Generate cache key from config @@ -398,34 +399,39 @@ export const postgresCache = new PostgresCacheManager(); * Creates a new Client instance from the config. * Encapsulates the configuration mapping logic. */ -function createClient(cfg: PGConfig): Client { - // Build SSL configuration - let sslConfig: boolean | { rejectUnauthorized: boolean } | undefined; - - if (cfg.ssl) { - // For cloud databases (Supabase, Railway, etc.), we need to allow self-signed certs - // sslmode=require or sslmode=prefer should use rejectUnauthorized: false - sslConfig = { - rejectUnauthorized: cfg.sslmode === 'verify-full' || cfg.sslmode === 'verify-ca' - }; - } - - return new Client({ - host: cfg.host, - port: cfg.port, - user: cfg.user, - ssl: sslConfig, - password: cfg.password || undefined, - database: cfg.database || undefined, - }); +const connectionPools = new Map(); + +export function getPool(cfg: PGConfig): Pool { + const key = `${cfg.host}:${cfg.port || 5432}:${cfg.database || ""}:${cfg.user}`; + let pool = connectionPools.get(key); + if (!pool) { + let sslConfig; + if (cfg.ssl) { + sslConfig = { rejectUnauthorized: cfg.sslmode === 'verify-full' || cfg.sslmode === 'verify-ca' }; + } + pool = new Pool({ + host: cfg.host, + port: cfg.port, + user: cfg.user, + ssl: sslConfig, + password: cfg.password || undefined, + database: cfg.database || undefined, + max: 10, + idleTimeoutMillis: 30000, + }); + connectionPools.set(key, pool); + } + return pool; } + + /** test connection quickly */ export async function testConnection(cfg: PGConfig): Promise<{ ok: boolean; message?: string; status: 'connected' | 'disconnected' }> { - const client = createClient(cfg); + const pool = getPool(cfg); try { - await client.connect(); - await client.end(); + const client = await pool.connect(); + await client.release(); return { ok: true, status: 'connected', message: "Connection successful" }; } catch (err: any) { return { ok: false, message: err.message || String(err), status: 'disconnected' }; @@ -437,15 +443,15 @@ export async function testConnection(cfg: PGConfig): Promise<{ ok: boolean; mess * Returns true if successful (pg_cancel_backend returns boolean). */ export async function pgCancel(cfg: PGConfig, targetPid: number) { - const c = createClient(cfg); + const pool = getPool(cfg); + const c = await pool.connect(); try { - await c.connect(); const res = await c.query(PG_CANCEL_QUERY, [targetPid]); - await c.end(); + await c.release(); return res.rows?.[0]?.cancelled === true; } catch (err) { try { - await c.end(); + await c.release(); } catch (e) { } throw err; } @@ -466,10 +472,10 @@ export async function fetchTableData( page: number ): Promise<{ rows: any[]; total: number }> { - const client = createClient(config); + const pool = getPool(config); + const client = await pool.connect(); try { - await client.connect(); const safeSchema = `"${schemaName.replace(/"/g, '""')}"`; const safeTable = `"${tableName.replace(/"/g, '""')}"`; @@ -522,7 +528,7 @@ export async function fetchTableData( ); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -539,7 +545,8 @@ export async function listTables(connection: PGConfig, schemaName?: string) { return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); let query = PG_LIST_TABLES; let queryParams: string[] = []; @@ -551,12 +558,11 @@ export async function listTables(connection: PGConfig, schemaName?: string) { } try { - await client.connect(); // Execute the dynamically constructed query const res = await client.query(query, queryParams); - await client.end(); + await client.release(); const result = res.rows; @@ -566,7 +572,7 @@ export async function listTables(connection: PGConfig, schemaName?: string) { return result; // [{schema, name, type}, ...] } catch (err) { try { - await client.end(); + await client.release(); } catch (e) { } throw err; } @@ -579,10 +585,10 @@ export async function listPrimaryKeys(connection: PGConfig, schemaName: string = return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_PRIMARY_KEYS, [tableName]); const result = res.rows; @@ -593,7 +599,7 @@ export async function listPrimaryKeys(connection: PGConfig, schemaName: string = return result; } catch (err) { try { - await client.end(); + await client.release(); } catch (e) { } throw err; } @@ -608,10 +614,10 @@ export async function listForeignKeys( const cached = postgresCache.getForeignKeys(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_FOREIGN_KEYS, [tableName, schemaName]); const result = res.rows; @@ -622,7 +628,7 @@ export async function listForeignKeys( throw err; } finally { try { - await client.end(); + await client.release(); } catch { } } } @@ -633,15 +639,15 @@ export async function listIndexes(connection: PGConfig, schemaName = "public", t const cached = postgresCache.getIndexes(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_INDEXES, [tableName, schemaName]); postgresCache.setIndexes(connection, schemaName, tableName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -649,15 +655,15 @@ export async function listUniqueConstraints(connection: PGConfig, schemaName = " const cached = postgresCache.getUnique(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_UNIQUE_CONSTRAINTS, [tableName, schemaName]); postgresCache.setUnique(connection, schemaName, tableName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -665,15 +671,15 @@ export async function listCheckConstraints(connection: PGConfig, schemaName = "p const cached = postgresCache.getChecks(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_CHECK_CONSTRAINTS, [tableName, schemaName]); postgresCache.setChecks(connection, schemaName, tableName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -682,15 +688,15 @@ export async function listEnumTypes(connection: PGConfig, schemaName = "public") const cached = postgresCache.getEnums(connection, schemaName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_ENUMS, [schemaName]); postgresCache.setEnums(connection, schemaName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -698,15 +704,15 @@ export async function listSequences(connection: PGConfig, schemaName = "public") const cached = postgresCache.getSequences(connection, schemaName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_SEQUENCES, [schemaName]); postgresCache.setSequences(connection, schemaName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -734,10 +740,10 @@ export async function getSchemaMetadataBatch( enumTypes: EnumInfo[]; sequences: SequenceInfo[]; }> { - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); // Execute all queries in parallel using imported batch queries const [ @@ -855,7 +861,7 @@ export async function getSchemaMetadataBatch( sequences: sequencesResult.rows }; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -873,14 +879,15 @@ export function streamQueryCancelable( onBatch: (rows: any[], columns: { name: string }[]) => Promise | void, onDone?: () => void ): { promise: Promise; cancel: () => Promise } { - const client = createClient(cfg); + let client: PoolClient | null = null; let stream: Readable | null = null; let finished = false; let cancelled = false; let backendPid: number | null = null; const promise = (async () => { - await client.connect(); + const pool = getPool(cfg); + client = await pool.connect(); // capture backend pid (node-postgres exposes processID) // @ts-ignore @@ -941,7 +948,7 @@ export function streamQueryCancelable( } } finally { try { - await client.end(); + await client.release(); } catch (e) { } } } @@ -973,7 +980,7 @@ export function streamQueryCancelable( // 3) Close client connection try { - await client.end(); + await client.release(); } catch (e) { /* ignore */ } @@ -993,13 +1000,13 @@ export async function getDBStats(connection: PGConfig): Promise<{ return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_DB_STATS); // CRITICAL: Ensure the pg client is closed after a successful query - await client.end(); + await client.release(); // CRITICAL: Update the return type structure const result = res.rows?.[0] as { @@ -1017,7 +1024,7 @@ export async function getDBStats(connection: PGConfig): Promise<{ console.error("Error fetching database stats:", error); // Attempt to close the client even if an error occurred during connection/query try { - await client.end(); + await client.release(); } catch (endError) { console.error("Error closing client after failure:", endError); } @@ -1035,11 +1042,11 @@ export async function listSchemas(connection: PGConfig) { return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_SCHEMAS); - await client.end(); + await client.release(); const result = res.rows; @@ -1049,7 +1056,7 @@ export async function listSchemas(connection: PGConfig) { return result; // [{ name: 'public' }, { name: 'analytics' }, ...] } catch (err) { try { - await client.end(); + await client.release(); } catch (e) { } throw err; } @@ -1067,11 +1074,11 @@ export async function getTableDetails( return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_TABLE_DETAILS, [`${schemaName}.${tableName}`]); - await client.end(); + await client.release(); const result = res.rows; @@ -1104,7 +1111,8 @@ export async function createTable( columns: ColumnDetail[], foreignKeys: ForeignKeyInfo[] = [] ) { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); const primaryKeys = columns .filter(c => c.is_primary_key) @@ -1136,7 +1144,6 @@ export async function createTable( `; try { - await client.connect(); await client.query("BEGIN"); await client.query(createTableQuery); @@ -1161,7 +1168,7 @@ export async function createTable( await client.query("ROLLBACK"); throw err; } finally { - await client.end(); + await client.release(); } } @@ -1185,10 +1192,10 @@ export async function createIndexes( schemaName: string, indexes: IndexInfo[] ): Promise { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); const grouped = groupIndexes(indexes); try { - await client.connect(); for (const group of grouped) { const first = group[0]; @@ -1214,7 +1221,7 @@ export async function createIndexes( } catch (error) { throw error; } finally { - await client.end(); + await client.release(); } } @@ -1225,10 +1232,10 @@ export async function alterTable( tableName: string, operations: PGAlterTableOperation[] ): Promise { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); await client.query("BEGIN"); for (const op of operations) { @@ -1306,7 +1313,7 @@ export async function alterTable( await client.query("ROLLBACK"); throw err; } finally { - await client.end(); + await client.release(); } } @@ -1317,10 +1324,10 @@ export async function dropTable( tableName: string, mode: PGDropMode = "RESTRICT" ): Promise { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); await client.query("BEGIN"); if (mode !== "CASCADE") { @@ -1368,33 +1375,33 @@ export async function dropTable( await client.query("ROLLBACK"); throw err; } finally { - await client.end(); + await client.release(); } } export async function ensureMigrationTable(client: PGConfig) { - const connection = createClient(client) + const pool = getPool(client); + const connection = await pool.connect(); try { - await connection.connect() await connection.query(PG_CREATE_MIGRATION_TABLE); } catch (error) { throw error; } finally { - await connection.end(); + await connection.release(); } } export async function hasAnyMigrations(connection: PGConfig): Promise { - const client = createClient(connection) + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const { rows } = await client.query(PG_CHECK_MIGRATIONS_EXIST); return rows.length > 0; } catch (error) { throw error; } finally { - await client.end(); + await client.release(); } } @@ -1404,15 +1411,15 @@ export async function insertBaseline( name: string, checksum: string ): Promise { - const client = createClient(conn) + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); await client.query(PG_INSERT_MIGRATION, [version, name, checksum]); return true; } catch (error) { throw error; } finally { - await client.end(); + await client.release(); } } @@ -1421,13 +1428,13 @@ export async function baselineIfNeeded( migrationsDir: string, snapshot?: SchemaFile ) { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); - await ensureMigrationTable(client); + await ensureMigrationTable(conn); - const hasMigrations = await hasAnyMigrations(client); + const hasMigrations = await hasAnyMigrations(conn); if (hasMigrations) return { baselined: false }; const version = Date.now().toString(); @@ -1456,11 +1463,11 @@ export async function baselineIfNeeded( .update(fs.readFileSync(filePath)) .digest("hex"); - await insertBaseline(client, version, name, checksum); + await insertBaseline(conn, version, name, checksum); return { baselined: true, version }; } finally { - await client.end(); + await client.release(); } } @@ -1469,10 +1476,10 @@ export async function baselineIfNeeded( export async function listAppliedMigrations( cfg: PGConfig, ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); // Important: table may not exist yet const tableExists = await client.query(` @@ -1491,7 +1498,7 @@ export async function listAppliedMigrations( return res.rows as AppliedMigration[]; } finally { - await client.end(); + await client.release(); } } @@ -1545,10 +1552,10 @@ export async function applyMigration( cfg: PGConfig, migrationFilePath: string ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); // Read and parse migration file const { readMigrationFile } = await import('../utils/migrationFileReader'); @@ -1578,7 +1585,7 @@ export async function applyMigration( await client.query('ROLLBACK'); throw error; } finally { - await client.end(); + await client.release(); } } @@ -1590,10 +1597,10 @@ export async function rollbackMigration( version: string, migrationFilePath: string ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); // Read and parse migration file const { readMigrationFile } = await import('../utils/migrationFileReader'); @@ -1619,7 +1626,7 @@ export async function rollbackMigration( await client.query('ROLLBACK'); throw error; } finally { - await client.end(); + await client.release(); } } @@ -1637,10 +1644,10 @@ export async function insertRow( tableName: string, rowData: Record ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const columns = Object.keys(rowData); const values = Object.values(rowData); @@ -1671,7 +1678,7 @@ export async function insertRow( throw new Error(`Failed to insert row into ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1694,10 +1701,10 @@ export async function updateRow( primaryKeyValue: any, rowData: Record ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const columns = Object.keys(rowData); const values = Object.values(rowData); @@ -1744,7 +1751,7 @@ export async function updateRow( throw new Error(`Failed to update row in ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1765,10 +1772,10 @@ export async function deleteRow( primaryKeyColumn: string, primaryKeyValue: any ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const safeSchema = `"${schemaName.replace(/"/g, '""')}"`; const safeTable = `"${tableName.replace(/"/g, '""')}"`; @@ -1807,7 +1814,7 @@ export async function deleteRow( throw new Error(`Failed to delete row from ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1831,10 +1838,10 @@ export async function searchTable( page: number = 1, pageSize: number = 50 ): Promise<{ rows: any[]; total: number }> { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const safeSchema = `"${schemaName.replace(/"/g, '""')}"`; const safeTable = `"${tableName.replace(/"/g, '""')}"`; @@ -1892,7 +1899,7 @@ export async function searchTable( throw new Error(`Failed to search table ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1904,15 +1911,15 @@ export async function searchTable( export async function listSchemaNames(connection: PGConfig): Promise { // Check cache first (re-use schemas cache if available, or a new cache if needed) // For now, simpler to just query as it's very fast - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_SCHEMAS); return res.rows.map((r: any) => r.name); } finally { try { - await client.end(); + await client.release(); } catch (e) { } } } diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts index 38b56e4..f5b9f75 100644 --- a/bridge/src/connectors/sqlite.ts +++ b/bridge/src/connectors/sqlite.ts @@ -62,16 +62,18 @@ import { SchemaFile } from "../services/projectStore"; // CACHING SYSTEM FOR SQLITE CONNECTOR // ============================================ +import { LRUCache } from "../utils/lruCache"; + export class SQLiteCacheManager { - private tableListCache = new Map>(); - private primaryKeysCache = new Map>(); - private dbStatsCache = new Map>(); - private schemasCache = new Map>(); - private tableDetailsCache = new Map>(); - private foreignKeysCache = new Map>(); - private indexesCache = new Map>(); - private uniqueCache = new Map>(); - private checksCache = new Map>(); + private tableListCache = new LRUCache(100); + private primaryKeysCache = new LRUCache(1000); + private dbStatsCache = new LRUCache(50); + private schemasCache = new LRUCache(50); + private tableDetailsCache = new LRUCache(1000); + private foreignKeysCache = new LRUCache(1000); + private indexesCache = new LRUCache(1000); + private uniqueCache = new LRUCache(1000); + private checksCache = new LRUCache(1000); private getConfigKey(cfg: SQLiteConfig): string { return cfg.path; @@ -276,6 +278,15 @@ function validateSQLitePath( ); } + const validExtensions = ['.db', '.sqlite', '.sqlite3', '.db3', '.s3db', '.sl3']; + const ext = dbPath.substring(dbPath.lastIndexOf('.')).toLowerCase(); + + if (dbPath !== ':memory:' && !validExtensions.includes(ext)) { + throw new Error( + `Invalid SQLite path "${dbPath}" - must have a valid SQLite extension (.db, .sqlite, etc.) to prevent arbitrary file access.` + ); + } + if (fs.existsSync(dbPath)) { const stat = fs.statSync(dbPath); if (stat.isDirectory()) { @@ -623,27 +634,9 @@ export async function getDBStats(cfg: SQLiteConfig): Promise { // Get DB file size const pageCount = db.pragma("page_count", { simple: true }) as number; const pageSize = db.pragma("page_size", { simple: true }) as number; - const totalSizeMB = (pageCount * pageSize) / (1024 * 1024); - - // Count total rows across all tables. - // On large databases this can be very expensive and will block the event loop - // because better-sqlite3 is synchronous. To keep stats fetching responsive, - // only compute total_rows for databases up to a certain size. - const MAX_DB_SIZE_MB_FOR_ROWCOUNT = 50; - let totalRows = 0; - if (totalSizeMB <= MAX_DB_SIZE_MB_FOR_ROWCOUNT) { - const tables = db.prepare(SQLITE_LIST_TABLES).all() as any[]; - for (const t of tables) { - try { - const countRow = db - .prepare(`SELECT COUNT(*) AS cnt FROM ${quoteIdent(t.name)}`) - .get() as any; - totalRows += Number(countRow?.cnt) || 0; - } catch { - // Skip tables that can't be counted - } - } - } + const totalSizeMB = (pageCount * pageSize) / (1024 * 1024); // Count total rows across all tables. + // Removed because better-sqlite3 is synchronous and SELECT COUNT(*) blocks the Node event loop. + let totalRows = -1; const result: DBStats = { total_tables: totalTables, @@ -991,16 +984,19 @@ export async function createIndexes( grouped.get(idx.index_name)!.push(idx); } - for (const [, group] of grouped) { - const sorted = group.sort((a, b) => (a.ordinal_position || 0) - (b.ordinal_position || 0)); - const first = sorted[0]; - if (first.is_primary) continue; + const transaction = db.transaction(() => { + for (const [, group] of grouped) { + const sorted = group.sort((a, b) => (a.ordinal_position || 0) - (b.ordinal_position || 0)); + const first = sorted[0]; + if (first.is_primary) continue; - const cols = sorted.map(i => quoteIdent(i.column_name)).join(", "); - const sql = `CREATE ${first.is_unique ? "UNIQUE" : ""} INDEX IF NOT EXISTS ${quoteIdent(first.index_name)} ON ${quoteIdent(first.table_name)} (${cols});`; - db.exec(sql); - } + const cols = sorted.map(i => quoteIdent(i.column_name)).join(", "); + const sql = `CREATE ${first.is_unique ? "UNIQUE" : ""} INDEX IF NOT EXISTS ${quoteIdent(first.index_name)} ON ${quoteIdent(first.table_name)} (${cols});`; + db.exec(sql); + } + }); + transaction(); return true; } finally { db.close(); diff --git a/bridge/src/index.ts b/bridge/src/index.ts index a37e203..c12af89 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -77,3 +77,13 @@ function shutdown(signal: string) { } process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); + +rpc.on("end", () => { + logger.info("JSON-RPC stream ended — shutting down"); + shutdown("RPC_END"); +}); + +rpc.on("error", (err: any) => { + logger.error({ err }, "JSON-RPC stream error — shutting down"); + shutdown("RPC_ERROR"); +}); diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts index fbf5b5f..bfe286d 100644 --- a/bridge/src/services/gitService.ts +++ b/bridge/src/services/gitService.ts @@ -104,6 +104,13 @@ export class GitError extends Error { } } + +function assertSafeGitRef(name: string) { + if (name.startsWith("-")) { + throw new Error("Invalid git reference name: cannot start with a hyphen"); + } +} + export class GitService { /** * Run a git command in a specific directory. @@ -446,6 +453,7 @@ export class GitService { * Create and checkout a new branch */ async createBranch(dir: string, name: string): Promise { + assertSafeGitRef(name); await this.git(dir, "checkout", "-b", name); } @@ -453,6 +461,7 @@ export class GitService { * Checkout an existing branch */ async checkoutBranch(dir: string, name: string): Promise { + assertSafeGitRef(name); await this.git(dir, "checkout", name); } @@ -487,7 +496,31 @@ export class GitService { const args = ["diff"]; if (staged) args.push("--staged"); if (file) args.push("--", file); - return this.git(dir, ...args); + const output = await this.git(dir, ...args); + + // If git diff returns nothing for a specific file, it might be an untracked file. + if (!output && file && !staged) { + try { + // git ls-files --error-unmatch throws if the file is untracked + await this.git(dir, "ls-files", "--error-unmatch", file); + } catch { + try { + const filePath = path.join(dir, file); + const stat = fsSync.statSync(filePath); + if (stat.isFile() && stat.size <= 2097152) { // Max 2MB diff + const content = fsSync.readFileSync(filePath, "utf-8"); + const lines = content.split(/\r?\n/); + let diffStr = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n`; + for (const line of lines) diffStr += `+${line}\n`; + return diffStr.trimEnd(); + } + } catch { + // Ignore fs errors + } + } + } + + return output; } /** @@ -589,6 +622,7 @@ export class GitService { * Create an annotated tag at the current HEAD (or a given ref) */ async createTag(dir: string, tagName: string, message?: string, ref?: string): Promise { + assertSafeGitRef(tagName); const args = ["tag"]; if (message) { args.push("-a", tagName, "-m", message); @@ -603,6 +637,7 @@ export class GitService { * Delete a tag */ async deleteTag(dir: string, tagName: string): Promise { + assertSafeGitRef(tagName); await this.git(dir, "tag", "-d", tagName); } @@ -626,6 +661,7 @@ export class GitService { * Get the message of an annotated tag */ async getTagMessage(dir: string, tagName: string): Promise { + assertSafeGitRef(tagName); try { return await this.git(dir, "tag", "-l", "-n99", tagName); } catch { @@ -642,6 +678,8 @@ export class GitService { * Returns full hash, or null if no common ancestor. */ async mergeBase(dir: string, refA: string, refB: string): Promise { + assertSafeGitRef(refA); + assertSafeGitRef(refB); try { const output = await this.git(dir, "merge-base", refA, refB); return output || null; @@ -717,6 +755,7 @@ export class GitService { * Add a named remote */ async remoteAdd(dir: string, name: string, url: string): Promise { + assertSafeGitRef(name); await this.git(dir, "remote", "add", name, url); } @@ -724,6 +763,7 @@ export class GitService { * Remove a named remote */ async remoteRemove(dir: string, name: string): Promise { + assertSafeGitRef(name); await this.git(dir, "remote", "remove", name); } @@ -742,6 +782,7 @@ export class GitService { * Change the URL of an existing remote */ async remoteSetUrl(dir: string, name: string, url: string): Promise { + assertSafeGitRef(name); await this.git(dir, "remote", "set-url", name, url); } @@ -759,6 +800,8 @@ export class GitService { branch?: string, options?: { force?: boolean; setUpstream?: boolean } ): Promise { + if (remote) assertSafeGitRef(remote); + if (branch) assertSafeGitRef(branch); const args = ["push"]; if (options?.force) args.push("--force-with-lease"); if (options?.setUpstream) args.push("--set-upstream"); @@ -777,6 +820,8 @@ export class GitService { branch?: string, options?: { rebase?: boolean } ): Promise { + if (remote) assertSafeGitRef(remote); + if (branch) assertSafeGitRef(branch); const args = ["pull"]; if (options?.rebase) args.push("--rebase"); args.push(remote); @@ -792,6 +837,7 @@ export class GitService { remote?: string, options?: { prune?: boolean; all?: boolean } ): Promise { + if (remote) assertSafeGitRef(remote); const args = ["fetch"]; if (options?.prune) args.push("--prune"); if (options?.all || !remote) { diff --git a/bridge/src/utils/lruCache.ts b/bridge/src/utils/lruCache.ts new file mode 100644 index 0000000..31bad12 --- /dev/null +++ b/bridge/src/utils/lruCache.ts @@ -0,0 +1,61 @@ +import { CacheEntry } from "../types/cache"; + +export class LRUCache { + private map = new Map>(); + private maxSize: number; + + constructor(maxSize = 1000) { + this.maxSize = maxSize; + } + + get(key: K): CacheEntry | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + + // Check TTL on read to lazily evict + if (Date.now() - entry.timestamp > entry.ttl) { + this.map.delete(key); + return undefined; + } + + // Refresh position to maintain LRU order (most recently used at the end) + this.map.delete(key); + this.map.set(key, entry); + return entry; + } + + set(key: K, value: CacheEntry): void { + if (this.map.has(key)) { + this.map.delete(key); + } + this.map.set(key, value); + + if (this.map.size > this.maxSize) { + // The first item in a Map iterator is the oldest inserted item + const oldestKey = this.map.keys().next().value; + if (oldestKey !== undefined) { + this.map.delete(oldestKey); + } + } + } + + delete(key: K): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } + + get size(): number { + return this.map.size; + } + + keys(): IterableIterator { + return this.map.keys(); + } + + [Symbol.iterator](): IterableIterator<[K, CacheEntry]> { + return this.map[Symbol.iterator](); + } +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 098a41a..27a1b52 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -2,10 +2,12 @@ import * as React from "react" import { cn } from "@/lib/utils" -function Input({ className, type, ...props }: React.ComponentProps<"input">) { +function Input({ className, type, autoComplete = "off", spellCheck = false, ...props }: React.ComponentProps<"input">) { return ( { "--border-radius": "var(--radius)", } as React.CSSProperties } + toastOptions={{ + classNames: { + title: 'text-black dark:text-white', + description: '!text-black dark:!text-white', + }, + }} {...props} /> ) diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 7f21b5e..4077ed4 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -2,9 +2,11 @@ import * as React from "react" import { cn } from "@/lib/utils" -function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { +function Textarea({ className, autoComplete = "off", spellCheck = false, ...props }: React.ComponentProps<"textarea">) { return (