From bdb3e6a6b31b5fe5fcb63104604f4b92e3e65100 Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 24 Jan 2026 23:35:42 +0530 Subject: [PATCH 1/3] feat: Introduce database connection management, discovery, schema browsing, and query execution capabilities. --- bridge/src/jsonRpcHandler.ts | 13 + bridge/src/services/discoveryService.ts | 337 ++++++++++++++++++ src/components/home/AddConnectionDialog.tsx | 13 +- .../home/DiscoveredDatabasesCard.tsx | 165 +++++++++ src/components/home/WelcomeView.tsx | 10 +- src/components/home/index.ts | 1 + src/components/home/types.ts | 4 +- src/hooks/useDiscoveredDatabases.ts | 46 +++ src/pages/Index.tsx | 26 +- src/services/bridgeApi.ts | 20 +- src/types/database.ts | 12 + 11 files changed, 641 insertions(+), 6 deletions(-) create mode 100644 bridge/src/services/discoveryService.ts create mode 100644 src/components/home/DiscoveredDatabasesCard.tsx create mode 100644 src/hooks/useDiscoveredDatabases.ts diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index 607879b..ba92907 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -7,6 +7,7 @@ import { DatabaseHandlers } from "./handlers/databaseHandlers"; import { SessionHandlers } from "./handlers/sessionHandlers"; import { StatsHandlers } from "./handlers/statsHandlers"; import { MigrationHandlers } from "./handlers/migrationHandlers"; +import { discoveryService } from "./services/discoveryService"; import { Logger } from "pino"; /** @@ -164,6 +165,18 @@ export function registerDbHandlers( statsHandlers.handleGetTotalStats(p, id) ); + // ========================================== + // DATABASE DISCOVERY HANDLERS + // ========================================== + rpcRegister("db.discover", async (_p, id) => { + try { + const discovered = await discoveryService.discoverLocalDatabases(); + rpc.sendResponse(id, { ok: true, data: discovered }); + } catch (error: any) { + rpc.sendError(id, { code: "DISCOVERY_ERROR", message: error.message }); + } + }); + logger?.info("All RPC handlers registered successfully"); } diff --git a/bridge/src/services/discoveryService.ts b/bridge/src/services/discoveryService.ts new file mode 100644 index 0000000..7045c97 --- /dev/null +++ b/bridge/src/services/discoveryService.ts @@ -0,0 +1,337 @@ +/** + * Database Discovery Service + * + * Automatically discovers locally running databases by scanning common ports + * and detecting Docker containers with database images. + */ + +import { exec } from "child_process"; +import { promisify } from "util"; +import * as net from "net"; + +const execAsync = promisify(exec); + +// Port configurations for each database type +const DATABASE_PORTS: Record = { + postgresql: [5432, 5433, 5434], + mysql: [3306, 3307, 3308], + mariadb: [3306, 3307, 3308], +}; + +// Fun adjectives and nouns for generating database names +const ADJECTIVES = [ + "swift", "cosmic", "stellar", "nimble", "turbo", "quantum", "neon", "cyber", + "atomic", "hyper", "mega", "ultra", "blazing", "electric", "dynamic", "rapid", + "mighty", "brave", "clever", "noble", "fierce", "silent", "golden", "crystal", + "shadow", "frost", "thunder", "ember", "azure", "crimson", "violet", "lunar", +]; + +const NOUNS = [ + "phoenix", "dragon", "falcon", "panther", "tiger", "wolf", "hawk", "eagle", + "lion", "bear", "shark", "cobra", "viper", "raven", "storm", "blaze", + "nova", "comet", "nebula", "galaxy", "cosmos", "orbit", "pulse", "flux", + "spark", "bolt", "wave", "surge", "core", "nexus", "vertex", "matrix", +]; + +export interface DiscoveredDatabase { + type: "postgresql" | "mysql" | "mariadb"; + host: string; + port: number; + source: "local" | "docker"; + containerName?: string; + suggestedName: string; + defaultUser: string; + defaultDatabase: string; + defaultPassword?: string; // Only available for Docker containers +} + +export class DiscoveryService { + private connectionTimeout = 500; // ms to wait for port response + + /** + * Generate a fun name for a database connection + */ + generateFunName(type: string, port: number): string { + const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + const suffix = port !== this.getDefaultPort(type) ? `-${port}` : ""; + return `${adjective}-${noun}${suffix}`; + } + + /** + * Get the default port for a database type + */ + private getDefaultPort(type: string): number { + switch (type) { + case "postgresql": + return 5432; + case "mysql": + case "mariadb": + return 3306; + default: + return 0; + } + } + + /** + * Get default username for a database type + */ + private getDefaultUser(type: string): string { + switch (type) { + case "postgresql": + return "postgres"; + case "mysql": + case "mariadb": + return "root"; + default: + return ""; + } + } + + /** + * Get default database name for a database type + */ + private getDefaultDatabase(type: string): string { + switch (type) { + case "postgresql": + return "postgres"; + case "mysql": + case "mariadb": + return "mysql"; + default: + return ""; + } + } + + /** + * Check if a specific port is open on a host + */ + private async isPortOpen(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + + socket.setTimeout(this.connectionTimeout); + + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + + socket.connect(port, host); + }); + } + + /** + * Scan localhost for open database ports + */ + private async scanLocalPorts(): Promise { + const discovered: DiscoveredDatabase[] = []; + const hosts = ["127.0.0.1", "localhost"]; + const scannedPorts = new Set(); + + for (const host of hosts) { + for (const [dbType, ports] of Object.entries(DATABASE_PORTS)) { + for (const port of ports) { + const key = `${port}`; + if (scannedPorts.has(key)) continue; + + const isOpen = await this.isPortOpen(host, port); + if (isOpen) { + scannedPorts.add(key); + + // Determine the actual type based on the port + let actualType: "postgresql" | "mysql" | "mariadb" = dbType as any; + if (port >= 5432 && port <= 5434) { + actualType = "postgresql"; + } else if (port >= 3306 && port <= 3308) { + // Can't distinguish MySQL from MariaDB by port alone + actualType = "mysql"; + } + + discovered.push({ + type: actualType, + host: "localhost", + port, + source: "local", + suggestedName: this.generateFunName(actualType, port), + defaultUser: this.getDefaultUser(actualType), + defaultDatabase: this.getDefaultDatabase(actualType), + }); + } + } + } + } + + return discovered; + } + + /** + * Get environment variables from a Docker container + */ + private async getContainerEnvVars(containerName: string): Promise> { + const envMap = new Map(); + + try { + const { stdout } = await execAsync( + `docker inspect --format "{{range .Config.Env}}{{println .}}{{end}}" "${containerName}"`, + { timeout: 3000 } + ); + + const lines = stdout.trim().split("\n").filter(Boolean); + for (const line of lines) { + const eqIndex = line.indexOf("="); + if (eqIndex > 0) { + const key = line.substring(0, eqIndex); + const value = line.substring(eqIndex + 1); + envMap.set(key, value); + } + } + } catch { + // Ignore errors - we'll just use defaults + } + + return envMap; + } + + /** + * Extract database credentials from container environment variables + */ + private extractCredentialsFromEnv( + dbType: "postgresql" | "mysql" | "mariadb", + envVars: Map + ): { user: string; password: string; database: string } { + if (dbType === "postgresql") { + return { + user: envVars.get("POSTGRES_USER") || "postgres", + password: envVars.get("POSTGRES_PASSWORD") || "", + database: envVars.get("POSTGRES_DB") || envVars.get("POSTGRES_USER") || "postgres", + }; + } else if (dbType === "mysql") { + // MySQL can use MYSQL_USER or root with MYSQL_ROOT_PASSWORD + const user = envVars.get("MYSQL_USER") || "root"; + const password = user === "root" + ? (envVars.get("MYSQL_ROOT_PASSWORD") || "") + : (envVars.get("MYSQL_PASSWORD") || ""); + return { + user, + password, + database: envVars.get("MYSQL_DATABASE") || "mysql", + }; + } else { + // MariaDB - similar to MySQL + const user = envVars.get("MARIADB_USER") || envVars.get("MYSQL_USER") || "root"; + const password = user === "root" + ? (envVars.get("MARIADB_ROOT_PASSWORD") || envVars.get("MYSQL_ROOT_PASSWORD") || "") + : (envVars.get("MARIADB_PASSWORD") || envVars.get("MYSQL_PASSWORD") || ""); + return { + user, + password, + database: envVars.get("MARIADB_DATABASE") || envVars.get("MYSQL_DATABASE") || "mysql", + }; + } + } + + /** + * Discover databases running in Docker containers + */ + private async discoverDockerDatabases(): Promise { + const discovered: DiscoveredDatabase[] = []; + + try { + // Check if Docker is available + const { stdout } = await execAsync( + 'docker ps --format "{{.Names}}|{{.Image}}|{{.Ports}}"', + { timeout: 5000 } + ); + + const lines = stdout.trim().split("\n").filter(Boolean); + + for (const line of lines) { + const [containerName, image, ports] = line.split("|"); + + // Determine database type from image name + let dbType: "postgresql" | "mysql" | "mariadb" | null = null; + if ( + image.includes("postgres") || + image.includes("pg") || + image.includes("postgresql") + ) { + dbType = "postgresql"; + } else if (image.includes("mariadb")) { + dbType = "mariadb"; + } else if (image.includes("mysql")) { + dbType = "mysql"; + } + + if (dbType && ports) { + // Parse port mappings like "0.0.0.0:5432->5432/tcp" + const portMatch = ports.match(/0\.0\.0\.0:(\d+)->(\d+)/); + if (portMatch) { + const hostPort = parseInt(portMatch[1], 10); + + // Get environment variables from the container + const envVars = await this.getContainerEnvVars(containerName); + const credentials = this.extractCredentialsFromEnv(dbType, envVars); + + discovered.push({ + type: dbType, + host: "localhost", + port: hostPort, + source: "docker", + containerName, + suggestedName: this.generateFunName(dbType, hostPort), + defaultUser: credentials.user, + defaultDatabase: credentials.database, + defaultPassword: credentials.password, + }); + } + } + } + } catch { + // Docker not available or error running command - silently ignore + } + + return discovered; + } + + /** + * Discover all locally running databases + * Combines local port scanning and Docker container detection + */ + async discoverLocalDatabases(): Promise { + // Run both discovery methods in parallel + const [localDbs, dockerDbs] = await Promise.all([ + this.scanLocalPorts(), + this.discoverDockerDatabases(), + ]); + + // Merge results, preferring Docker source info when available + const merged = new Map(); + + for (const db of localDbs) { + const key = `${db.host}:${db.port}`; + merged.set(key, db); + } + + // Docker info takes precedence (has container name) + for (const db of dockerDbs) { + const key = `${db.host}:${db.port}`; + merged.set(key, db); + } + + return Array.from(merged.values()); + } +} + +// Export singleton instance +export const discoveryService = new DiscoveryService(); diff --git a/src/components/home/AddConnectionDialog.tsx b/src/components/home/AddConnectionDialog.tsx index 44d6ba9..f6585ce 100644 --- a/src/components/home/AddConnectionDialog.tsx +++ b/src/components/home/AddConnectionDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Database, Link as LinkIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -27,11 +27,22 @@ export function AddConnectionDialog({ onOpenChange, onSubmit, isLoading, + initialData, }: AddConnectionDialogProps) { const [useUrl, setUseUrl] = useState(false); const [connectionUrl, setConnectionUrl] = useState(""); const [formData, setFormData] = useState(INITIAL_FORM_DATA); + // Apply initial data when dialog opens with prefilled values + useEffect(() => { + if (open && initialData) { + setFormData(prev => ({ + ...prev, + ...initialData, + })); + } + }, [open, initialData]); + const handleInputChange = (field: string, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); }; diff --git a/src/components/home/DiscoveredDatabasesCard.tsx b/src/components/home/DiscoveredDatabasesCard.tsx new file mode 100644 index 0000000..f57154e --- /dev/null +++ b/src/components/home/DiscoveredDatabasesCard.tsx @@ -0,0 +1,165 @@ +import { useEffect } from "react"; +import { Radar, Plus, Container, Monitor, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useDiscoveredDatabases } from "@/hooks/useDiscoveredDatabases"; +import { DiscoveredDatabase } from "@/types/database"; + +interface DiscoveredDatabasesCardProps { + onAddDatabase: (db: DiscoveredDatabase) => void; +} + +const DB_TYPE_COLORS = { + postgresql: { + bg: "bg-blue-500/10", + text: "text-blue-500", + border: "border-blue-500/20", + }, + mysql: { + bg: "bg-orange-500/10", + text: "text-orange-500", + border: "border-orange-500/20", + }, + mariadb: { + bg: "bg-teal-500/10", + text: "text-teal-500", + border: "border-teal-500/20", + }, +}; + +export function DiscoveredDatabasesCard({ + onAddDatabase, +}: DiscoveredDatabasesCardProps) { + const { databases, isScanning, scan, lastScanned } = useDiscoveredDatabases(); + + // Auto-scan on mount + useEffect(() => { + scan(); + }, [scan]); + + // Don't render if no databases found and not scanning + if (!isScanning && databases.length === 0) { + return null; + } + + return ( +
+
+
+ +

Detected Databases

+ {databases.length > 0 && ( + + {databases.length} found + + )} +
+ +
+ + {isScanning && databases.length === 0 ? ( +
+
+

+ Scanning local ports... +

+
+ ) : ( +
+ {databases.map((db, index) => { + const colors = DB_TYPE_COLORS[db.type] || DB_TYPE_COLORS.postgresql; + return ( +
+
+ {/* Database Icon */} +
+ {db.source === "docker" ? ( + + ) : ( + + )} +
+ + {/* Details */} +
+
+ + {db.type} + + + {db.source === "docker" ? "Docker" : "Local"} + +
+ +

+ {db.host}:{db.port} +

+ + {db.containerName && ( +

+ Container: {db.containerName} +

+ )} + +

+ Suggested: {db.suggestedName} +

+
+ + {/* Add Button */} + +
+
+ ); + })} +
+ )} + + {lastScanned && !isScanning && ( +

+ Last scanned: {lastScanned.toLocaleTimeString()} +

+ )} +
+ ); +} diff --git a/src/components/home/WelcomeView.tsx b/src/components/home/WelcomeView.tsx index ca3916b..220fd49 100644 --- a/src/components/home/WelcomeView.tsx +++ b/src/components/home/WelcomeView.tsx @@ -12,6 +12,8 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { WelcomeViewProps } from "./types"; import { formatRelativeTime } from "./utils"; +import { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard"; +import { DiscoveredDatabase } from "@/types/database"; export function WelcomeView({ databases, @@ -25,6 +27,7 @@ export function WelcomeView({ onSelectDb, onDatabaseClick, onDatabaseHover, + onDiscoveredDatabaseAdd, }: WelcomeViewProps) { return (
@@ -87,6 +90,11 @@ export function WelcomeView({
+ {/* Discovered Databases */} + {onDiscoveredDatabaseAdd && ( + + )} + {/* Recent Activity */} {recentDatabases.length > 0 && (
@@ -109,7 +117,7 @@ export function WelcomeView({ className={cn( "w-full flex items-center gap-4 px-4 py-3 text-left transition-colors", index !== recentDatabases.length - 1 && - "border-b border-border/30", + "border-b border-border/30", isConnected ? "hover:bg-muted/50" : "opacity-50 cursor-not-allowed" diff --git a/src/components/home/index.ts b/src/components/home/index.ts index 1ce158a..f4eb381 100644 --- a/src/components/home/index.ts +++ b/src/components/home/index.ts @@ -3,5 +3,6 @@ export { DatabaseDetail } from "./DatabaseDetail"; export { WelcomeView } from "./WelcomeView"; export { AddConnectionDialog } from "./AddConnectionDialog"; export { DeleteDialog } from "./DeleteDialog"; +export { DiscoveredDatabasesCard } from "./DiscoveredDatabasesCard"; export * from "./types"; export * from "./utils"; diff --git a/src/components/home/types.ts b/src/components/home/types.ts index 2af5975..ccee068 100644 --- a/src/components/home/types.ts +++ b/src/components/home/types.ts @@ -1,4 +1,4 @@ -import { DatabaseConnection } from "@/types/database"; +import { DatabaseConnection, DiscoveredDatabase } from "@/types/database"; export interface ConnectionListProps { databases: DatabaseConnection[]; @@ -40,6 +40,7 @@ export interface WelcomeViewProps { onSelectDb: (id: string) => void; onDatabaseClick: (id: string) => void; onDatabaseHover: (dbId: string) => void; + onDiscoveredDatabaseAdd?: (db: DiscoveredDatabase) => void; } export interface AddConnectionDialogProps { @@ -47,6 +48,7 @@ export interface AddConnectionDialogProps { onOpenChange: (open: boolean) => void; onSubmit: (data: ConnectionFormData, useUrl: boolean, connectionUrl: string) => void; isLoading?: boolean; + initialData?: Partial; } export interface DeleteDialogProps { diff --git a/src/hooks/useDiscoveredDatabases.ts b/src/hooks/useDiscoveredDatabases.ts new file mode 100644 index 0000000..98c8d36 --- /dev/null +++ b/src/hooks/useDiscoveredDatabases.ts @@ -0,0 +1,46 @@ +import { useState, useCallback } from "react"; +import { bridgeApi } from "@/services/bridgeApi"; +import { DiscoveredDatabase } from "@/types/database"; + +interface UseDiscoveredDatabasesReturn { + databases: DiscoveredDatabase[]; + isScanning: boolean; + error: string | null; + scan: () => Promise; + lastScanned: Date | null; +} + +/** + * Hook for discovering locally running databases + * Scans localhost ports and Docker containers for PostgreSQL, MySQL, and MariaDB + */ +export function useDiscoveredDatabases(): UseDiscoveredDatabasesReturn { + const [databases, setDatabases] = useState([]); + const [isScanning, setIsScanning] = useState(false); + const [error, setError] = useState(null); + const [lastScanned, setLastScanned] = useState(null); + + const scan = useCallback(async () => { + setIsScanning(true); + setError(null); + + try { + const discovered = await bridgeApi.discoverDatabases(); + setDatabases(discovered); + setLastScanned(new Date()); + } catch (err: any) { + setError(err.message || "Failed to scan for databases"); + setDatabases([]); + } finally { + setIsScanning(false); + } + }, []); + + return { + databases, + isScanning, + error, + scan, + lastScanned, + }; +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index f97a04a..120564b 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { toast } from "sonner"; import { useNavigate } from "react-router-dom"; import { bridgeApi } from "@/services/bridgeApi"; @@ -75,6 +75,7 @@ const Index = () => { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [dbToDelete, setDbToDelete] = useState<{ id: string; name: string } | null>(null); const [selectedDb, setSelectedDb] = useState(null); + const [prefilledConnectionData, setPrefilledConnectionData] = useState | undefined>(undefined); // Use fresh data if available, fall back to cached data const status = statusData || cachedStatus; @@ -170,6 +171,22 @@ const Index = () => { prefetchStats(dbId); }; + // Handler for when a discovered database is selected + const handleDiscoveredDatabaseAdd = useCallback((db: { type: string; host: string; port: number; suggestedName: string; defaultUser: string; defaultDatabase: string; defaultPassword?: string }) => { + setPrefilledConnectionData({ + name: db.suggestedName, + type: db.type, + host: db.host, + port: String(db.port), + user: db.defaultUser, + database: db.defaultDatabase, + password: db.defaultPassword || "", + ssl: false, + sslmode: "", + }); + setIsDialogOpen(true); + }, []); + // Loading states if (bridgeLoading || bridgeReady === undefined) return ; if (!bridgeReady) return ; @@ -231,6 +248,7 @@ const Index = () => { onSelectDb={setSelectedDb} onDatabaseClick={handleDatabaseClick} onDatabaseHover={handleDatabaseHover} + onDiscoveredDatabaseAdd={handleDiscoveredDatabaseAdd} /> )}
@@ -239,9 +257,13 @@ const Index = () => { {/* Add Database Dialog */} { + setIsDialogOpen(open); + if (!open) setPrefilledConnectionData(undefined); + }} onSubmit={(formData) => handleAddDatabase(formData)} isLoading={addDatabaseMutation.isPending} + initialData={prefilledConnectionData} /> {/* Delete Dialog */} diff --git a/src/services/bridgeApi.ts b/src/services/bridgeApi.ts index 601da1e..d01cb9a 100644 --- a/src/services/bridgeApi.ts +++ b/src/services/bridgeApi.ts @@ -1,4 +1,4 @@ -import { AddDatabaseParams, ColumnDetails, ConnectionTestResult, CreateTableColumn, DatabaseConnection, DatabaseSchemaDetails, DatabaseStats, RunQueryParams, TableRow, UpdateDatabaseParams } from "@/types/database"; +import { AddDatabaseParams, ConnectionTestResult, CreateTableColumn, DatabaseConnection, DatabaseSchemaDetails, DatabaseStats, DiscoveredDatabase, RunQueryParams, TableRow, UpdateDatabaseParams } from "@/types/database"; import { bridgeRequest } from "./bridgeClient"; @@ -761,6 +761,24 @@ class BridgeApiService { throw new Error(`Health check failed: ${error.message}`); } } + + // ------------------------------------ + // 5. DATABASE DISCOVERY METHODS + // ------------------------------------ + + /** + * Discover locally running databases (on localhost or Docker) + * Scans common database ports and detects Docker containers + */ + async discoverDatabases(): Promise { + try { + const result = await bridgeRequest("db.discover", {}); + return result?.data || []; + } catch (error: any) { + console.error("Failed to discover databases:", error); + return []; // Return empty array on error, don't throw + } + } } // Export singleton instance diff --git a/src/types/database.ts b/src/types/database.ts index 7d67d71..3aa07b6 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,6 +1,18 @@ export type DatabaseType = "postgresql" | "mysql"; +export interface DiscoveredDatabase { + type: "postgresql" | "mysql" | "mariadb"; + host: string; + port: number; + source: "local" | "docker"; + containerName?: string; + suggestedName: string; + defaultUser: string; + defaultDatabase: string; + defaultPassword?: string; +} + export interface DatabaseConnection { id: string; name: string; From a26f42032a8859e57caa9040cc5a444099394ee2 Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 24 Jan 2026 23:48:13 +0530 Subject: [PATCH 2/3] test: add comprehensive unit tests for DiscoveryService's name generation and credential extraction methods. --- bridge/__tests__/discoveryService.test.ts | 360 ++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 bridge/__tests__/discoveryService.test.ts diff --git a/bridge/__tests__/discoveryService.test.ts b/bridge/__tests__/discoveryService.test.ts new file mode 100644 index 0000000..e122c6c --- /dev/null +++ b/bridge/__tests__/discoveryService.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test, beforeEach } from "@jest/globals"; +import { DiscoveryService, DiscoveredDatabase } from "../src/services/discoveryService"; + +describe("DiscoveryService", () => { + let service: DiscoveryService; + + beforeEach(() => { + service = new DiscoveryService(); + }); + + describe("generateFunName", () => { + test("should generate a name in format adjective-noun", () => { + const name = service.generateFunName("postgresql", 5432); + expect(name).toMatch(/^[a-z]+-[a-z]+$/); + }); + + test("should add port suffix for non-default PostgreSQL port", () => { + const name = service.generateFunName("postgresql", 5433); + expect(name).toMatch(/^[a-z]+-[a-z]+-5433$/); + }); + + test("should not add port suffix for default PostgreSQL port", () => { + const name = service.generateFunName("postgresql", 5432); + expect(name).not.toContain("-5432"); + }); + + test("should not add port suffix for default MySQL port", () => { + const name = service.generateFunName("mysql", 3306); + expect(name).not.toContain("-3306"); + }); + + test("should not add port suffix for default MariaDB port", () => { + const name = service.generateFunName("mariadb", 3306); + expect(name).not.toContain("-3306"); + }); + + test("should add port suffix for non-default MySQL port", () => { + const name = service.generateFunName("mysql", 3307); + expect(name).toMatch(/^[a-z]+-[a-z]+-3307$/); + }); + + test("should generate different names on multiple calls", () => { + // Given the randomness, most calls should produce different names + const names = new Set(); + for (let i = 0; i < 20; i++) { + names.add(service.generateFunName("postgresql", 5432)); + } + // At least some variety expected (not all same) + expect(names.size).toBeGreaterThan(1); + }); + }); + + describe("extractCredentialsFromEnv (via reflection)", () => { + // Access private method via prototype for testing + const extractCredentials = ( + svc: DiscoveryService, + dbType: "postgresql" | "mysql" | "mariadb", + envVars: Map + ) => { + return (svc as any).extractCredentialsFromEnv(dbType, envVars); + }; + + describe("PostgreSQL", () => { + test("should extract PostgreSQL credentials from env vars", () => { + const envVars = new Map([ + ["POSTGRES_USER", "testuser"], + ["POSTGRES_PASSWORD", "testpass"], + ["POSTGRES_DB", "testdb"], + ]); + + const result = extractCredentials(service, "postgresql", envVars); + + expect(result.user).toBe("testuser"); + expect(result.password).toBe("testpass"); + expect(result.database).toBe("testdb"); + }); + + test("should use defaults when PostgreSQL env vars are missing", () => { + const envVars = new Map(); + + const result = extractCredentials(service, "postgresql", envVars); + + expect(result.user).toBe("postgres"); + expect(result.password).toBe(""); + expect(result.database).toBe("postgres"); + }); + + test("should use POSTGRES_USER as database when POSTGRES_DB is missing", () => { + const envVars = new Map([ + ["POSTGRES_USER", "customuser"], + ]); + + const result = extractCredentials(service, "postgresql", envVars); + + expect(result.user).toBe("customuser"); + expect(result.database).toBe("customuser"); + }); + }); + + describe("MySQL", () => { + test("should extract MySQL credentials with custom user", () => { + const envVars = new Map([ + ["MYSQL_USER", "myuser"], + ["MYSQL_PASSWORD", "mypass"], + ["MYSQL_DATABASE", "mydb"], + ]); + + const result = extractCredentials(service, "mysql", envVars); + + expect(result.user).toBe("myuser"); + expect(result.password).toBe("mypass"); + expect(result.database).toBe("mydb"); + }); + + test("should use root password when user is root", () => { + const envVars = new Map([ + ["MYSQL_ROOT_PASSWORD", "rootpass"], + ["MYSQL_DATABASE", "mydb"], + ]); + + const result = extractCredentials(service, "mysql", envVars); + + expect(result.user).toBe("root"); + expect(result.password).toBe("rootpass"); + expect(result.database).toBe("mydb"); + }); + + test("should use defaults when MySQL env vars are missing", () => { + const envVars = new Map(); + + const result = extractCredentials(service, "mysql", envVars); + + expect(result.user).toBe("root"); + expect(result.password).toBe(""); + expect(result.database).toBe("mysql"); + }); + }); + + describe("MariaDB", () => { + test("should extract MariaDB credentials with MARIADB_ prefix", () => { + const envVars = new Map([ + ["MARIADB_USER", "mariauser"], + ["MARIADB_PASSWORD", "mariapass"], + ["MARIADB_DATABASE", "mariadb"], + ]); + + const result = extractCredentials(service, "mariadb", envVars); + + expect(result.user).toBe("mariauser"); + expect(result.password).toBe("mariapass"); + expect(result.database).toBe("mariadb"); + }); + + test("should fall back to MYSQL_ prefix for MariaDB", () => { + const envVars = new Map([ + ["MYSQL_USER", "fallbackuser"], + ["MYSQL_PASSWORD", "fallbackpass"], + ["MYSQL_DATABASE", "fallbackdb"], + ]); + + const result = extractCredentials(service, "mariadb", envVars); + + expect(result.user).toBe("fallbackuser"); + expect(result.password).toBe("fallbackpass"); + expect(result.database).toBe("fallbackdb"); + }); + + test("should use MARIADB_ROOT_PASSWORD for root user", () => { + const envVars = new Map([ + ["MARIADB_ROOT_PASSWORD", "mariarootpass"], + ]); + + const result = extractCredentials(service, "mariadb", envVars); + + expect(result.user).toBe("root"); + expect(result.password).toBe("mariarootpass"); + }); + + test("should fall back to MYSQL_ROOT_PASSWORD for root user", () => { + const envVars = new Map([ + ["MYSQL_ROOT_PASSWORD", "mysqlrootpass"], + ]); + + const result = extractCredentials(service, "mariadb", envVars); + + expect(result.user).toBe("root"); + expect(result.password).toBe("mysqlrootpass"); + }); + }); + }); + + describe("getDefaultPort (private)", () => { + const getDefaultPort = (svc: DiscoveryService, type: string) => { + return (svc as any).getDefaultPort(type); + }; + + test("should return 5432 for postgresql", () => { + expect(getDefaultPort(service, "postgresql")).toBe(5432); + }); + + test("should return 3306 for mysql", () => { + expect(getDefaultPort(service, "mysql")).toBe(3306); + }); + + test("should return 3306 for mariadb", () => { + expect(getDefaultPort(service, "mariadb")).toBe(3306); + }); + + test("should return 0 for unknown type", () => { + expect(getDefaultPort(service, "unknown")).toBe(0); + }); + }); + + describe("getDefaultUser (private)", () => { + const getDefaultUser = (svc: DiscoveryService, type: string) => { + return (svc as any).getDefaultUser(type); + }; + + test("should return postgres for postgresql", () => { + expect(getDefaultUser(service, "postgresql")).toBe("postgres"); + }); + + test("should return root for mysql", () => { + expect(getDefaultUser(service, "mysql")).toBe("root"); + }); + + test("should return root for mariadb", () => { + expect(getDefaultUser(service, "mariadb")).toBe("root"); + }); + + test("should return empty string for unknown type", () => { + expect(getDefaultUser(service, "unknown")).toBe(""); + }); + }); + + describe("getDefaultDatabase (private)", () => { + const getDefaultDatabase = (svc: DiscoveryService, type: string) => { + return (svc as any).getDefaultDatabase(type); + }; + + test("should return postgres for postgresql", () => { + expect(getDefaultDatabase(service, "postgresql")).toBe("postgres"); + }); + + test("should return mysql for mysql", () => { + expect(getDefaultDatabase(service, "mysql")).toBe("mysql"); + }); + + test("should return mysql for mariadb", () => { + expect(getDefaultDatabase(service, "mariadb")).toBe("mysql"); + }); + + test("should return empty string for unknown type", () => { + expect(getDefaultDatabase(service, "unknown")).toBe(""); + }); + }); + + describe("DiscoveredDatabase interface", () => { + test("should have all required properties for local source", () => { + const db: DiscoveredDatabase = { + type: "postgresql", + host: "localhost", + port: 5432, + source: "local", + suggestedName: "test-db", + defaultUser: "postgres", + defaultDatabase: "postgres", + }; + + expect(db.type).toBe("postgresql"); + expect(db.host).toBe("localhost"); + expect(db.port).toBe(5432); + expect(db.source).toBe("local"); + expect(db.suggestedName).toBe("test-db"); + expect(db.defaultUser).toBe("postgres"); + expect(db.defaultDatabase).toBe("postgres"); + expect(db.containerName).toBeUndefined(); + expect(db.defaultPassword).toBeUndefined(); + }); + + test("should accept optional properties for Docker containers", () => { + const db: DiscoveredDatabase = { + type: "mysql", + host: "localhost", + port: 3306, + source: "docker", + containerName: "mysql-container", + suggestedName: "test-mysql", + defaultUser: "root", + defaultDatabase: "mydb", + defaultPassword: "secret123", + }; + + expect(db.source).toBe("docker"); + expect(db.containerName).toBe("mysql-container"); + expect(db.defaultPassword).toBe("secret123"); + }); + + test("should accept all database types", () => { + const pgDb: DiscoveredDatabase = { + type: "postgresql", + host: "localhost", + port: 5432, + source: "local", + suggestedName: "pg-db", + defaultUser: "postgres", + defaultDatabase: "postgres", + }; + + const mysqlDb: DiscoveredDatabase = { + type: "mysql", + host: "localhost", + port: 3306, + source: "local", + suggestedName: "mysql-db", + defaultUser: "root", + defaultDatabase: "mysql", + }; + + const mariaDb: DiscoveredDatabase = { + type: "mariadb", + host: "localhost", + port: 3306, + source: "docker", + suggestedName: "maria-db", + defaultUser: "root", + defaultDatabase: "mysql", + }; + + expect(pgDb.type).toBe("postgresql"); + expect(mysqlDb.type).toBe("mysql"); + expect(mariaDb.type).toBe("mariadb"); + }); + }); + + describe("discoverLocalDatabases", () => { + test("should return an array", async () => { + // This test just verifies the method exists and returns an array + // Actual discovery depends on system state + const result = await service.discoverLocalDatabases(); + expect(Array.isArray(result)).toBe(true); + }); + + test("each discovered database should have required fields", async () => { + const result = await service.discoverLocalDatabases(); + + for (const db of result) { + expect(db.type).toBeDefined(); + expect(["postgresql", "mysql", "mariadb"]).toContain(db.type); + expect(db.host).toBeDefined(); + expect(typeof db.port).toBe("number"); + expect(db.source).toBeDefined(); + expect(["local", "docker"]).toContain(db.source); + expect(db.suggestedName).toBeDefined(); + expect(db.defaultUser).toBeDefined(); + expect(db.defaultDatabase).toBeDefined(); + } + }); + }); +}); From fb398df69d048001c08591bee192bc0b1fe522e2 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 25 Jan 2026 23:47:05 +0530 Subject: [PATCH 3/3] chore: update the project version and repo URL --- README.md | 6 +++--- bridge/package.json | 2 +- package.json | 2 +- src-tauri/tauri.conf.json | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 15486c6..f994ed4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

- Download | + Download | Features | Installation | Contributing @@ -103,7 +103,7 @@ Before building from source, ensure you have: ### From Releases (Recommended) -Download the latest release for your platform from the [**Releases**](https://github.com/Yashh56/RelWave/releases) page: +Download the latest release for your platform from the [**Releases**](https://github.com/Relwave/relwave-app/releases) page: | Platform | File Type | Description | |----------|----------|-------------| @@ -116,7 +116,7 @@ Download the latest release for your platform from the [**Releases**](https://gi 1. Clone the repository: ```bash -git clone https://github.com/Yashh56/RelWave.git +git clone https://github.com/Relwave/relwave-app.git cd RelWave ``` diff --git a/bridge/package.json b/bridge/package.json index 047480c..61fe8fd 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "db-visualizer-bridge", - "version": "0.1.0", + "version": "0.1.0-beta.5", "type": "commonjs", "main": "dist/index.cjs", "scripts": { diff --git a/package.json b/package.json index 3671bb5..666dd72 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "relwave", "private": true, - "version": "0.1.0-beta.4", + "version": "0.1.0-beta.5", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 799b9c1..8e85649 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "RelWave", - "version": "0.1.0-beta.4", + "version": "0.1.0-beta.5", "identifier": "com.yashs.RelWave", "build": { "beforeDevCommand": "npm run dev", @@ -48,7 +48,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZCODA3MzJBRUNGNDE3NUIKUldSYkYvVHNLbk9BKzlKTVVlU2F2NGJET0VFcGlvZXhWVEhpUmhXR0J0cVFtY25zTlNIRDFNa0MK", "endpoints": [ - "https://github.com/Yashh56/RelWave/releases/latest/download/latest.json" + "https://github.com/Relwave/relwave-app/releases/latest/download/latest.json" ], "windows": { "installMode": "passive"