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/postgres.ts b/bridge/src/connectors/postgres.ts index 4f56d94..df7c3c9 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,34 @@ 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 +1412,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 +1429,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 +1464,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 +1477,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 +1499,7 @@ export async function listAppliedMigrations( return res.rows as AppliedMigration[]; } finally { - await client.end(); + await client.release(); } } @@ -1545,10 +1553,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 +1586,7 @@ export async function applyMigration( await client.query('ROLLBACK'); throw error; } finally { - await client.end(); + await client.release(); } } @@ -1590,10 +1598,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 +1627,7 @@ export async function rollbackMigration( await client.query('ROLLBACK'); throw error; } finally { - await client.end(); + await client.release(); } } @@ -1637,10 +1645,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 +1679,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 +1702,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 +1752,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 +1773,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 +1815,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 +1839,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 +1900,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 +1912,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..3ae955b 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); } @@ -589,6 +598,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 +613,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 +637,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 +654,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 +731,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 +739,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 +758,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 +776,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 +796,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 +813,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/features/home/components/AddConnectionDialog.tsx b/src/features/home/components/AddConnectionDialog.tsx index d7e5c7f..1b1a60f 100644 --- a/src/features/home/components/AddConnectionDialog.tsx +++ b/src/features/home/components/AddConnectionDialog.tsx @@ -30,6 +30,7 @@ export function AddConnectionDialog({ onSubmit, isLoading, initialData, + isDiscoveredMode, }: AddConnectionDialogProps) { const [useUrl, setUseUrl] = useState(true); const [connectionUrl, setConnectionUrl] = useState(""); @@ -40,6 +41,9 @@ export function AddConnectionDialog({ if (open) { if (initialData) { setFormData(prev => ({ ...prev, ...INITIAL_FORM_DATA, ...initialData })); + if (isDiscoveredMode) { + setUseUrl(false); + } } else { // Reset to empty form when opening without initial data setFormData(INITIAL_FORM_DATA); @@ -112,7 +116,7 @@ export function AddConnectionDialog({
- {!isSQLite && ( + {!isSQLite && !isDiscoveredMode && ( setUseUrl(v === "url")}> @@ -152,7 +156,7 @@ export function AddConnectionDialog({ {!useUrl && (
- { handleInputChange("type", val); if (val === "sqlite") { // Clear network-related fields when switching to SQLite @@ -200,13 +204,15 @@ export function AddConnectionDialog({ placeholder="/path/to/database.db" value={formData.database} onChange={(e) => handleInputChange("database", e.target.value)} - className="h-9 text-sm font-mono flex-1" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono flex-1 ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -237,7 +244,8 @@ export function AddConnectionDialog({ placeholder={formData.type === "mysql" || formData.type === "mariadb" ? "3306" : "5432"} value={formData.port} onChange={(e) => handleInputChange("port", e.target.value)} - className="h-9 text-sm font-mono" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -249,7 +257,8 @@ export function AddConnectionDialog({ placeholder={formData.type === "postgresql" ? "postgres" : "root"} value={formData.user} onChange={(e) => handleInputChange("user", e.target.value)} - className="h-9 text-sm font-mono" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -259,7 +268,8 @@ export function AddConnectionDialog({ placeholder="••••••••" value={formData.password} onChange={(e) => handleInputChange("password", e.target.value)} - className="h-9 text-sm" + readOnly={isDiscoveredMode} + className={`h-9 text-sm ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -270,11 +280,12 @@ export function AddConnectionDialog({ placeholder="myapp" value={formData.database} onChange={(e) => handleInputChange("database", e.target.value)} - className="h-9 text-sm font-mono" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} /> - {showSslOption && ( + {showSslOption && !isDiscoveredMode && (
-
+ {!isDiscoveredMode && ( +
+
@@ -415,6 +427,7 @@ export function AddConnectionDialog({
)}
+ )} ) )} diff --git a/src/features/home/components/DatabaseDetail.tsx b/src/features/home/components/DatabasePreview.tsx similarity index 98% rename from src/features/home/components/DatabaseDetail.tsx rename to src/features/home/components/DatabasePreview.tsx index 5b3d699..7d680a5 100644 --- a/src/features/home/components/DatabaseDetail.tsx +++ b/src/features/home/components/DatabasePreview.tsx @@ -20,7 +20,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { DatabaseDetailProps } from "../types"; +import { DatabasePreviewProps } from "../types"; import { DatabaseOverviewPanel } from "./DatabaseOverviewPanel"; import { useProjectByDatabaseId, useProjectSchema, useProjectERDiagram, useProjectQueries } from "@/features/project/hooks/useProjectQueries"; import { projectService } from "@/services/bridge/project"; @@ -37,7 +37,7 @@ function getDbColors(type: string) { return DB_COLORS[type] || { bg: "bg-primary/10", text: "text-primary" }; } -export function DatabaseDetail({ +export function DatabasePreview({ database, isConnected, onTest, @@ -46,7 +46,7 @@ export function DatabaseDetail({ onBack, size, tables -}: DatabaseDetailProps) { +}: DatabasePreviewProps) { // Fetch linked project and its sub-resources const { data: project } = useProjectByDatabaseId(database.id); const { data: schemaData } = useProjectSchema(project?.id); diff --git a/src/features/home/components/WelcomeView.tsx b/src/features/home/components/WelcomeView.tsx index e18aa57..1b7c813 100644 --- a/src/features/home/components/WelcomeView.tsx +++ b/src/features/home/components/WelcomeView.tsx @@ -21,7 +21,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { useDbStats, useTables } from "@/features/project/hooks/useDbQueries"; import { bytesToMBString } from "@/lib/bytesToMB"; import { DatabaseConnection } from "@/features/database/types"; -import { useCountUp } from "@/hooks/useCountUp"; +import { useCountUp } from "../hooks/useCountUp"; const DB_COLORS: Record = { diff --git a/src/features/home/components/index.ts b/src/features/home/components/index.ts index 765f30b..62a3a47 100644 --- a/src/features/home/components/index.ts +++ b/src/features/home/components/index.ts @@ -1,5 +1,5 @@ export { ConnectionList } from "./ConnectionList"; -export { DatabaseDetail } from "./DatabaseDetail"; +export { DatabasePreview } from "./DatabasePreview"; export { WelcomeView } from "../components/WelcomeView"; export { AddConnectionDialog } from "./AddConnectionDialog"; export { DeleteDialog } from "./DeleteDialog"; diff --git a/src/hooks/useCountUp.ts b/src/features/home/hooks/useCountUp.ts similarity index 100% rename from src/hooks/useCountUp.ts rename to src/features/home/hooks/useCountUp.ts diff --git a/src/features/home/hooks/useIndexPage.ts b/src/features/home/hooks/useIndexPage.ts index ac514a7..960cbaa 100644 --- a/src/features/home/hooks/useIndexPage.ts +++ b/src/features/home/hooks/useIndexPage.ts @@ -55,6 +55,7 @@ export const useIndexPage = (bridgeReady: boolean) => { const [isDialogOpen, setIsDialogOpen] = useState(false); const [prefilledConnectionData, setPrefilledConnectionData] = useState | undefined>(undefined); const [isImportOpen, setIsImportOpen] = useState(false); + const [isDiscoveredMode, setIsDiscoveredMode] = useState(false); // Selected db derived state const selectedDatabase = useMemo( @@ -231,6 +232,7 @@ export const useIndexPage = (bridgeReady: boolean) => { ssl: false, sslmode: "", }); + setIsDiscoveredMode(true); setIsDialogOpen(true); }, [] @@ -238,7 +240,10 @@ export const useIndexPage = (bridgeReady: boolean) => { const handleDialogClose = (open: boolean) => { setIsDialogOpen(open); - if (!open) setPrefilledConnectionData(undefined); + if (!open) { + setPrefilledConnectionData(undefined); + setIsDiscoveredMode(false); + } }; // ---- Delete Hook ---- @@ -298,6 +303,7 @@ export const useIndexPage = (bridgeReady: boolean) => { deleteConnectionDialogProps, isDeleting, prefilledConnectionData, + isDiscoveredMode, // Handlers handleAddDatabase, diff --git a/src/features/home/types.ts b/src/features/home/types.ts index f197b97..e8bca29 100644 --- a/src/features/home/types.ts +++ b/src/features/home/types.ts @@ -24,7 +24,7 @@ export interface ConnectionListProps { onDeleteProject?: (projectId: string) => void; } -export interface DatabaseDetailProps { +export interface DatabasePreviewProps { database: DatabaseConnection; isConnected: boolean; tables: number | string | undefined; @@ -56,6 +56,7 @@ export interface AddConnectionDialogProps { onSubmit: (data: ConnectionFormData, useUrl: boolean, connectionUrl: string) => void; isLoading?: boolean; initialData?: Partial; + isDiscoveredMode?: boolean; } export interface DeleteDialogProps { diff --git a/src/main.tsx b/src/main.tsx index 15bab9d..784aa1b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -69,7 +69,8 @@ function AnimatedRoutes() { function AppRoot() { useEffect(() => { - const handleSelectAll = (e: KeyboardEvent) => { + const handleKeydown = (e: KeyboardEvent) => { + // Block Ctrl+A outside editable fields (prevents full-page select) if ((e.ctrlKey || e.metaKey) && e.key === 'a') { const tag = (e.target as HTMLElement)?.tagName; const isEditable = (e.target as HTMLElement)?.isContentEditable; @@ -77,9 +78,14 @@ function AppRoot() { if (tag === 'INPUT' || tag === 'TEXTAREA' || isEditable) return; e.preventDefault(); } + + // Block Ctrl+F to suppress WebView's built-in Find-in-Page bar + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + } }; - document.addEventListener('keydown', handleSelectAll); - return () => document.removeEventListener('keydown', handleSelectAll); + document.addEventListener('keydown', handleKeydown); + return () => document.removeEventListener('keydown', handleKeydown); }, []); return ( diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 4402950..3695e7d 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -3,7 +3,7 @@ import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; import { ConnectionList, - DatabaseDetail, + DatabasePreview, WelcomeView, AddConnectionDialog, DeleteConnectionDialog, @@ -66,6 +66,7 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, setDeleteDialogOpen, deleteConnectionDialogProps, prefilledConnectionData, + isDiscoveredMode, // Handlers handleAddDatabase, @@ -112,7 +113,7 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, {/* Right Panel */}
{selectedDatabase ? ( - {deleteConnectionDialogProps && (