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:
parent
07b4df5544
commit
6cb423b342
8
.gitignore
vendored
8
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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
43
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) &
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user