import { exec, spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import readline from "node:readline"; import { promisify } from "node:util"; const execAsync = promisify(exec); const MAX_CUSTOMER_COUNT_LIMIT = 1000; /** * Execute command and show live output */ function execWithOutput(command: string): Promise { return new Promise((resolve, reject) => { const child = spawn(command, { shell: true, stdio: "inherit", }); child.on("close", (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Command failed with exit code ${code}`)); } }); child.on("error", reject); }); } /** * Prompt user for confirmation */ function promptUser(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); }); }); } /** * Validate PostgreSQL URL format */ function validatePostgresUrl(url: string): boolean { return url.startsWith("postgresql://") || url.startsWith("postgres://"); } /** * Extract database name from PostgreSQL URL */ function extractDbName(url: string): string { try { const urlObj = new URL(url); return urlObj.pathname.slice(1) || "database"; } catch { return "database"; } } /** * Build connection URL to default postgres database * Used for creating databases and checking if they exist */ function buildDefaultDbUrl(url: string): string { try { const urlObj = new URL(url); // Replace the database name with 'postgres' (default database) urlObj.pathname = "/postgres"; return urlObj.toString(); } catch { return url; } } /** * Escape single quotes in database name for SQL queries */ function escapeDbName(dbName: string): string { return dbName.replace(/'/g, "''"); } /** * Check if a database exists */ async function databaseExists(dbUrl: string, dbName: string): Promise { try { const defaultDbUrl = buildDefaultDbUrl(dbUrl); const cleanedUrl = cleanUrl(defaultDbUrl); const escapedDbName = escapeDbName(dbName); const { stdout } = await execAsync( `psql "${cleanedUrl}" -t -c "SELECT 1 FROM pg_database WHERE datname = '${escapedDbName}';"`, ); return stdout.trim() === "1"; } catch { return false; } } /** * Create a database if it doesn't exist */ async function ensureDatabaseExists( dbUrl: string, dbName: string, ): Promise { const exists = await databaseExists(dbUrl, dbName); if (exists) { console.log(`โœ… Database "${dbName}" already exists`); return; } console.log(`๐Ÿ“ Creating database "${dbName}"...`); try { const defaultDbUrl = buildDefaultDbUrl(dbUrl); const cleanedUrl = cleanUrl(defaultDbUrl); await execWithOutput( `psql "${cleanedUrl}" -c "CREATE DATABASE \\"${dbName}\\";"`, ); console.log(`โœ… Database "${dbName}" created successfully`); } catch (error) { console.error(`โŒ Failed to create database "${dbName}"`); if (error instanceof Error) { console.error(` ${error.message}`); } throw error; } } /** * Clean up PostgreSQL URL for pg_dump/psql * - Removes query parameters * - Changes port 6432 to 5432 */ function cleanUrl(url: string): string { try { const urlObj = new URL(url); // Remove query parameters urlObj.search = ""; // Replace port 6432 with 5432 if (urlObj.port === "6432") { urlObj.port = "5432"; } return urlObj.toString(); } catch { // Fallback to regex-based cleaning if URL parsing fails let cleaned = url.replace(/\?.*$/, ""); cleaned = cleaned.replace(/:6432\//, ":5432/"); return cleaned; } } /** * Get customer table row count */ async function getCustomerCount( url: string, ): Promise<{ count: number | null; error?: string }> { try { const cleanedUrl = cleanUrl(url); const { stdout } = await execAsync( `psql "${cleanedUrl}" -t -c "SELECT COUNT(*) FROM customers;"`, ); const count = parseInt(stdout.trim()); if (!Number.isNaN(count)) { return { count }; } return { count: null, error: "Could not parse customer count" }; } catch (error) { // Table might not exist or connection failed return { count: null, error: error instanceof Error ? error.message : "Unknown error", }; } } /** * Read DATABASE_URL from server/.env file */ function readLocalDatabaseUrl(): string | null { try { const envPath = join(process.cwd(), "server", ".env"); const envContent = readFileSync(envPath, "utf-8"); const lines = envContent.split("\n"); for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith("DATABASE_URL=")) { const url = trimmed.replace(/^DATABASE_URL=/, "").trim(); // Remove quotes if present return url.replace(/^["']|["']$/g, ""); } } return null; } catch { return null; } } /** * Pull database from remote to local */ async function pullDatabase({ remoteUrl, localUrl: providedLocalUrl, }: { remoteUrl: string; localUrl?: string; }): Promise { console.log("\n๐Ÿ“ฅ Pulling Remote Database to Local\n"); // Validate remote URL if (!validatePostgresUrl(remoteUrl)) { console.error("โŒ Invalid remote database URL"); console.error( " Expected format: postgresql://user:pass@host:port/dbname", ); process.exit(1); } // Get local database URL (from argument, env var, or .env file) const localUrl = providedLocalUrl || process.env.LOCAL_DATABASE_URL || readLocalDatabaseUrl(); if (!localUrl) { console.error("โŒ Could not find local database URL"); console.error(" Options:"); console.error(" 1. Ensure server/.env exists and contains DATABASE_URL"); console.error(" 2. Pass local URL as second argument"); console.error(" 3. Set LOCAL_DATABASE_URL environment variable"); process.exit(1); } if (!validatePostgresUrl(localUrl)) { console.error("โŒ Invalid local database URL in server/.env"); console.error( " Expected format: postgresql://user:pass@host:port/dbname", ); process.exit(1); } const remoteDbName = extractDbName(remoteUrl); const localDbName = extractDbName(localUrl); console.log(`๐Ÿ“ค Remote: ${remoteDbName}`); console.log(`๐Ÿ“ฅ Local: ${localDbName}`); // Ensure local database exists console.log("\n๐Ÿ” Checking if local database exists..."); await ensureDatabaseExists(localUrl, localDbName); // Check local database for production data console.log("\n๐Ÿ” Checking local database contents..."); const customerResult = await getCustomerCount(localUrl); if (customerResult.count === null) { // Table doesn't exist or can't query - likely a new/empty database console.log("โœ… Local database appears to be empty (no customers table)"); } else { console.log(`๐Ÿ“Š Local database has ${customerResult.count} customers`); // Protection: Don't allow overwriting databases with > MAX_CUSTOMER_COUNT_LIMIT customers if (customerResult.count > MAX_CUSTOMER_COUNT_LIMIT) { console.error("\nโŒ PROTECTION: Local database has too many customers!"); console.error( ` Customer count (${customerResult.count}) exceeds ${MAX_CUSTOMER_COUNT_LIMIT} limit.`, ); console.error( " This safety check prevents accidental overwrites of production databases.\n", ); process.exit(1); } } // Ask for confirmation const confirmed = await promptUser( "\nโš ๏ธ This will OVERWRITE your local database. Continue? (y/n): ", ); if (!confirmed) { console.log("\nโŒ Pull cancelled\n"); process.exit(0); } const tempFile = `/tmp/db_dump_${Date.now()}.sql`; // Clean URLs const cleanedRemoteUrl = cleanUrl(remoteUrl); const cleanedLocalUrl = cleanUrl(localUrl); console.log(`\n๐Ÿ“ค Using remote URL: ${cleanedRemoteUrl}`); console.log(`๐Ÿ“ฅ Using local URL: ${cleanedLocalUrl}\n`); try { // Step 1: Dump the remote database console.log("๐Ÿ“ฆ Dumping remote database..."); await execWithOutput( `pg_dump "${cleanedRemoteUrl}" --no-owner --no-privileges -f "${tempFile}" 2>&1`, ); console.log("โœ… Remote database dumped successfully"); // Step 2: Restore to local database console.log("\n๐Ÿ“ฅ Restoring to local database...\n"); await execWithOutput( `psql "${cleanedLocalUrl}" -f "${tempFile}" --set ON_ERROR_STOP=off 2>&1`, ); console.log("\nโœ… Database restored successfully"); // Step 3: Clean up temp file console.log("\n๐Ÿงน Cleaning up temporary files..."); await execAsync(`rm "${tempFile}"`); console.log("โœ… Cleanup complete"); console.log("\nโœจ Database pull completed successfully!\n"); } catch (error) { console.error("\nโŒ Pull failed:"); if (error instanceof Error) { console.error(` ${error.message}\n`); } // Try to clean up temp file try { await execAsync(`rm "${tempFile}"`); } catch { // Ignore cleanup errors } process.exit(1); } } // Parse command line arguments or environment variable const args = process.argv.slice(2); const remoteUrl = args[0] || process.env.REMOTE_DATABASE_URL || process.env.DATABASE_URL_REMOTE; const localUrl = args[1] || process.env.LOCAL_DATABASE_URL; if (!remoteUrl) { console.log("\n๐Ÿ“ฅ Pull Remote Database to Local\n"); console.log("Usage:"); console.log(' bun db:pull "" [local-database-url]\n'); console.log("Arguments:"); console.log( " remote-database-url (required) Remote database URL to pull from", ); console.log( " local-database-url (optional) Local database URL to pull into", ); console.log( " If not provided, reads from server/.env DATABASE_URL\n", ); console.log("Environment variables:"); console.log(" REMOTE_DATABASE_URL Remote database URL"); console.log(" LOCAL_DATABASE_URL Local database URL (overrides .env)\n"); console.log("Examples:"); console.log(' bun db:pull "postgresql://user:pass@remote-host:5432/db"'); console.log(' bun db:pull "postgresql://remote..." "postgresql://local..."'); console.log(' REMOTE_DATABASE_URL="postgresql://..." bun db:pull\n'); console.log("โš ๏ธ Important: URLs must be quoted to prevent shell expansion\n"); console.log( "By default, the script reads DATABASE_URL from server/.env for the local database.\n", ); process.exit(1); } pullDatabase({ remoteUrl, localUrl }).catch((error) => { console.error("Unexpected error:", error); process.exit(1); });