Files
cfw-autumn/scripts/db/pull.ts
2026-04-26 22:32:13 +01:00

382 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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_V2_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_V2_URL=")) {
const url = trimmed.replace(/^DATABASE_V2_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<void> {
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_V2_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 "<remote-database-url>" [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_V2_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_V2_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);
});