From 6cb423b342ed1d5a0b8dc04c8dc69439c28d255d Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 9 Mar 2026 08:27:32 -0600 Subject: [PATCH] feat: add database backup on startup and isolate dev environment Add automatic SQLite database backup before migrations on every server start. Backups are timestamped and stored in backups/, with WAL/SHM files included. Old backups are pruned to keep only the 10 most recent. Update dev.sh to use separate ports and database from production so both can run simultaneously on the same machine: - Production: server :3001, DB chat.db - Development: server :3002, Vite :3003, DB chat-dev.db Make Vite config dynamic via VITE_PORT and VITE_API_PORT env vars. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 8 +++ client/vite.config.js | 11 ++-- dev.sh | 43 +++++++++++---- server/src/main.rs | 120 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index e779903..f4670a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,14 @@ server/target/ server/chat.db server/chat.db-journal server/chat.db-wal +server/chat.db-shm +server/chat-dev.db +server/chat-dev.db-journal +server/chat-dev.db-wal +server/chat-dev.db-shm + +# Database backups +backups/ # Node client/node_modules/ diff --git a/client/vite.config.js b/client/vite.config.js index 52943c0..d76528b 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -18,21 +18,24 @@ function riotPlugin() { } } +const apiPort = process.env.VITE_API_PORT || '3001' +const clientPort = parseInt(process.env.VITE_PORT || '3000') + export default defineConfig({ plugins: [riotPlugin()], server: { - port: 3000, + port: clientPort, proxy: { '/api': { - target: 'http://localhost:3001', + target: `http://localhost:${apiPort}`, changeOrigin: true, }, '/ws': { - target: 'ws://localhost:3001', + target: `ws://localhost:${apiPort}`, ws: true, }, '/uploads': { - target: 'http://localhost:3001', + target: `http://localhost:${apiPort}`, changeOrigin: true, }, }, diff --git a/dev.sh b/dev.sh index 16a2bdb..f768996 100755 --- a/dev.sh +++ b/dev.sh @@ -1,12 +1,28 @@ #!/usr/bin/env bash # GroupChat2 Dev Script (macOS/Linux) # Runs both server (Rust/Axum) and client (Vite) with unified console output. +# Uses separate ports and database from production so both can run simultaneously. +# +# Production: server :3001, DB chat.db +# Development: server :3002, Vite client :3003, DB chat-dev.db +# # Ctrl+C stops both processes. set -e ROOT="$(cd "$(dirname "$0")" && pwd)" +# ─── Dev environment (separate from production) ───────────────────── +DEV_SERVER_PORT=3002 +DEV_CLIENT_PORT=3003 +DEV_DATABASE="chat-dev.db" + +export DATABASE_URL="sqlite:${DEV_DATABASE}?mode=rwc" +export BIND_ADDR="0.0.0.0:${DEV_SERVER_PORT}" +export VITE_PORT="${DEV_CLIENT_PORT}" +export VITE_API_PORT="${DEV_SERVER_PORT}" +export RUST_LOG="${RUST_LOG:-info}" + # Colors MAGENTA='\033[0;35m' YELLOW='\033[0;33m' @@ -15,6 +31,16 @@ RED='\033[0;31m' GRAY='\033[0;90m' NC='\033[0m' +# Load .env for API keys (shared with production) +if [ -f "$ROOT/server/.env" ]; then + set -a + source "$ROOT/server/.env" + set +a + # Override DB and port with dev values (in case .env sets them) + export DATABASE_URL="sqlite:${DEV_DATABASE}?mode=rwc" + export BIND_ADDR="0.0.0.0:${DEV_SERVER_PORT}" +fi + # Install client deps if needed if [ ! -d "$ROOT/client/node_modules" ]; then echo -e "${CYAN}[dev] Installing client dependencies...${NC}" @@ -23,8 +49,8 @@ fi echo "" echo -e " ${MAGENTA}GroupChat2 Dev Server${NC}" -echo -e " ${YELLOW}Server: http://localhost:3001${NC}" -echo -e " ${CYAN}Client: http://localhost:3000${NC}" +echo -e " ${YELLOW}Server: http://localhost:${DEV_SERVER_PORT}${NC} ${GRAY}(DB: ${DEV_DATABASE})${NC}" +echo -e " ${CYAN}Client: http://localhost:${DEV_CLIENT_PORT}${NC}" echo -e " ${GRAY}Press Ctrl+C to stop both${NC}" echo "" @@ -40,12 +66,9 @@ cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null [ -n "$CLIENT_PID" ] && kill "$CLIENT_PID" 2>/dev/null - # Kill any lingering processes on ports 3000/3001 - lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true - lsof -ti:3001 2>/dev/null | xargs kill -9 2>/dev/null || true - - # Kill groupchat-server if still running - pkill -f "groupchat-server" 2>/dev/null || true + # Kill any lingering processes on dev ports + lsof -ti:${DEV_CLIENT_PORT} 2>/dev/null | xargs kill -9 2>/dev/null || true + lsof -ti:${DEV_SERVER_PORT} 2>/dev/null | xargs kill -9 2>/dev/null || true echo -e "${RED}[dev] Stopped.${NC}" exit 0 @@ -53,13 +76,13 @@ cleanup() { trap cleanup INT TERM -# Start server (Rust/Axum) +# Start server (Rust/Axum) on dev port with dev database (cd "$ROOT/server" && cargo run 2>&1 | while IFS= read -r line; do [ -n "$line" ] && echo -e "${YELLOW}[server]${NC} $line" done) & SERVER_PID=$! -# Start client (Vite) +# Start client (Vite) on dev port, proxying to dev server (cd "$ROOT/client" && npm run dev 2>&1 | while IFS= read -r line; do [ -n "$line" ] && echo -e "${CYAN}[client]${NC} $line" done) & diff --git a/server/src/main.rs b/server/src/main.rs index 663c976..8d0c5bf 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,6 +14,123 @@ use tower_http::cors::{Any, CorsLayer}; use tower_http::services::{ServeDir, ServeFile}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +/// Extract the file path from a SQLite DATABASE_URL like "sqlite:chat.db?mode=rwc" +fn db_file_path(database_url: &str) -> Option { + let path = database_url.strip_prefix("sqlite:")?; + // Strip query params like ?mode=rwc + let path = path.split('?').next().unwrap_or(path); + Some(path.to_string()) +} + +/// Create a timestamped backup of the SQLite database file. +/// Backups are stored in a `backups/` directory next to the db file. +/// Only keeps the 10 most recent backups to avoid unbounded disk usage. +fn backup_database(database_url: &str) { + let Some(db_path) = db_file_path(database_url) else { + tracing::warn!("Could not parse database path from URL, skipping backup"); + return; + }; + + let db_file = std::path::Path::new(&db_path); + if !db_file.exists() { + tracing::info!("Database file does not exist yet, skipping backup"); + return; + } + + // Create backups directory next to the database + let backup_dir = db_file + .parent() + .unwrap_or(std::path::Path::new(".")) + .join("backups"); + + if let Err(e) = std::fs::create_dir_all(&backup_dir) { + tracing::error!("Failed to create backup directory: {}", e); + return; + } + + // Build timestamped backup filename: chat.db -> chat_2026-03-09_143022.db + let stem = db_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("db"); + let ext = db_file + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("db"); + + let now = chrono::Local::now(); + let backup_name = format!("{}_{}.{}", stem, now.format("%Y-%m-%d_%H%M%S"), ext); + let backup_path = backup_dir.join(&backup_name); + + match std::fs::copy(db_file, &backup_path) { + Ok(bytes) => { + tracing::info!( + "Database backup created: {} ({:.1} KB)", + backup_path.display(), + bytes as f64 / 1024.0 + ); + } + Err(e) => { + tracing::error!("Failed to backup database: {}", e); + return; + } + } + + // Also copy WAL and SHM files if they exist (for consistency) + let wal_path = format!("{}-wal", db_path); + let shm_path = format!("{}-shm", db_path); + if std::path::Path::new(&wal_path).exists() { + let wal_backup = backup_dir.join(format!("{}_{}.{}-wal", stem, now.format("%Y-%m-%d_%H%M%S"), ext)); + let _ = std::fs::copy(&wal_path, &wal_backup); + } + if std::path::Path::new(&shm_path).exists() { + let shm_backup = backup_dir.join(format!("{}_{}.{}-shm", stem, now.format("%Y-%m-%d_%H%M%S"), ext)); + let _ = std::fs::copy(&shm_path, &shm_backup); + } + + // Prune old backups: keep only the 10 most recent + prune_old_backups(&backup_dir, stem, 10); +} + +/// Remove old backups, keeping only the `keep` most recent ones. +fn prune_old_backups(backup_dir: &std::path::Path, stem: &str, keep: usize) { + let prefix = format!("{}_", stem); + let mut backups: Vec<_> = std::fs::read_dir(backup_dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + // Match main db backups (not -wal/-shm) + name.starts_with(&prefix) && !name.ends_with("-wal") && !name.ends_with("-shm") + }) + .collect(); + + if backups.len() <= keep { + return; + } + + // Sort by filename (timestamps sort lexicographically) + backups.sort_by_key(|e| e.file_name()); + + let to_remove = backups.len() - keep; + for entry in backups.into_iter().take(to_remove) { + let path = entry.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!("Failed to remove old backup {}: {}", name, e); + } else { + tracing::debug!("Pruned old backup: {}", name); + // Also remove associated WAL/SHM backups + let wal = path.with_extension(format!("{}-wal", path.extension().unwrap_or_default().to_string_lossy())); + let shm = path.with_extension(format!("{}-shm", path.extension().unwrap_or_default().to_string_lossy())); + let _ = std::fs::remove_file(&wal); + let _ = std::fs::remove_file(&shm); + } + } +} + pub struct AppState { pub db: sqlx::SqlitePool, pub jwt_secret: String, @@ -38,6 +155,9 @@ async fn main() { let openrouter_key = std::env::var("OPENROUTER_API_KEY").expect("OPENROUTER_API_KEY must be set"); let brave_api_key = std::env::var("BRAVE_API_KEY").expect("BRAVE_API_KEY must be set"); + // Backup the database before connecting and running migrations + backup_database(&database_url); + let db = SqlitePoolOptions::new() .max_connections(5) .connect(&database_url)