import { exec, spawn } from "node:child_process"; import readline from "node:readline"; import { promisify } from "node:util"; const execAsync = promisify(exec); /** * 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"; } } /** * Clean up PostgreSQL URL for pg_dump/psql * - Removes query parameters after /postgres * - Changes port 6432 to 5432 */ function cleanUrl(url: string): string { // Remove everything after /postgres (including query params) let cleaned = url.replace(/\/postgres\?.*$/, "/postgres"); // Replace port 6432 with 5432 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", }; } } /** * Replicate database from source to destination */ async function replicateDatabase({ fromUrl, toUrl, }: { fromUrl: string; toUrl: string; }) { console.log("\n๐Ÿ”„ PostgreSQL Database Replication\n"); // Validate URLs if (!validatePostgresUrl(fromUrl)) { console.error("โŒ Invalid source database URL"); console.error( " Expected format: postgresql://user:pass@host:port/dbname", ); process.exit(1); } if (!validatePostgresUrl(toUrl)) { console.error("โŒ Invalid destination database URL"); console.error( " Expected format: postgresql://user:pass@host:port/dbname", ); process.exit(1); } const fromDbName = extractDbName(fromUrl); const toDbName = extractDbName(toUrl); console.log(`๐Ÿ“ค Source: ${fromDbName}`); console.log(`๐Ÿ“ฅ Destination: ${toDbName}`); // Check destination database for production data console.log("\n๐Ÿ” Checking destination database..."); const customerResult = await getCustomerCount(toUrl); if (customerResult.count === null) { // Table doesn't exist or can't query - likely a new/empty database console.log("โœ… Destination appears to be empty (no customers table)"); } else { console.log(`๐Ÿ“Š Destination has ${customerResult.count} customers`); // Protection: Don't allow overwriting databases with > 1000 customers if (customerResult.count > 1000) { console.error( "\nโŒ PROTECTION: Destination database has too many customers!", ); console.error( ` Customer count (${customerResult.count}) exceeds 1000 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 the destination database. Continue? (y/n): ", ); if (!confirmed) { console.log("\nโŒ Replication cancelled\n"); process.exit(0); } const tempFile = `/tmp/db_dump_${Date.now()}.sql`; // Clean URLs const cleanedFromUrl = cleanUrl(fromUrl); const cleanedToUrl = cleanUrl(toUrl); console.log(`\n๐Ÿ“ค Using source URL: ${cleanedFromUrl}`); console.log(`๐Ÿ“ฅ Using destination URL: ${cleanedToUrl}\n`); try { // Step 1: Dump the source database console.log("๐Ÿ“ฆ Dumping source database..."); await execWithOutput( `pg_dump "${cleanedFromUrl}" --no-owner --no-privileges --exclude-schema='ddb$*' -f "${tempFile}" 2>&1`, ); console.log("โœ… Source database dumped successfully"); // Step 2: Restore to destination database console.log("\n๐Ÿ“ฅ Restoring to destination database...\n"); await execWithOutput( `psql "${cleanedToUrl}" -f "${tempFile}" --set ON_ERROR_STOP=off 2>&1 | grep -v "invalid command"`, ); 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 replication completed successfully!\n"); } catch (error) { console.error("\nโŒ Replication 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 and reconstruct URLs * Handles cases where URLs aren't quoted and get split by shell */ function parseUrls(): { fromUrl: string; toUrl: string } | null { const args = process.argv.slice(2); if (args.length === 0) { return null; } // Join all arguments and try to extract two PostgreSQL URLs const fullString = args.join(" "); // Match two postgresql:// URLs (greedy match for first, non-greedy for separation) const urlPattern = /(postgres(?:ql)?:\/\/[^\s]+?)[\s]+(postgres(?:ql)?:\/\/[^\s]+)/; const match = fullString.match(urlPattern); if (match?.[1] && match[2]) { return { fromUrl: match[1], toUrl: match[2], }; } // Fallback: if we have exactly 2 args that look like URLs if (args.length === 2) { const [fromUrl, toUrl] = args; if ( validatePostgresUrl(fromUrl || "") && validatePostgresUrl(toUrl || "") ) { return { fromUrl, toUrl }; } } return null; } // Parse command line arguments const urls = parseUrls(); if (!urls) { console.log("\n๐Ÿ”„ PostgreSQL Database Replication\n"); console.log("Usage:"); console.log(' bun replicate "" ""\n'); console.log("Example:"); console.log( ' bun replicate "postgresql://user:pass@eu-host:5432/db" "postgresql://user:pass@us-host:5432/db"\n', ); console.log("โš ๏ธ Important: URLs must be quoted to prevent shell expansion\n"); console.log("Tip: Store URLs in .env and use environment variables:\n"); console.log(" bun replicate $EU_DATABASE_URL $US_DATABASE_URL\n"); process.exit(1); } const { fromUrl, toUrl } = urls; replicateDatabase({ fromUrl, toUrl }).catch((error) => { console.error("Unexpected error:", error); process.exit(1); });