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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-09 08:27:32 -06:00
parent 07b4df5544
commit 6cb423b342
4 changed files with 168 additions and 14 deletions

8
.gitignore vendored
View File

@ -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/

View File

@ -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,
},
},

43
dev.sh
View File

@ -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) &

View File

@ -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<String> {
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)