// server/db.rs — Database connection pool and migrations // // This module provides two functions called once at server startup: // - `create_pool` : opens a connection pool to the database // - `run_migrations` : applies all pending SQL migrations // // `AnyPool` lets the same Rust code target both SQLite (dev) and // PostgreSQL (production) — only DATABASE_URL changes. use sqlx::AnyPool; use thiserror::Error; use crate::server::config::{AppConfig, DatabaseBackend}; // ─── Errors ─────────────────────────────────────────────────────────────────── #[derive(Debug, Error)] pub enum DbError { #[error("Database connection failed: {0}")] Connection(#[from] sqlx::Error), #[error("Migration failed: {0}")] Migration(#[from] sqlx::migrate::MigrateError), #[error("Record not found: {0}")] NotFound(String), } // ─── Pool creation ──────────────────────────────────────────────────────────── /// Opens a connection pool to the database specified in `config.database_url`. /// /// A pool maintains multiple open connections so concurrent requests /// do not block each other waiting for a single connection. /// /// `install_default_drivers()` must be called before `AnyPool::connect` /// to register both the SQLite and PostgreSQL drivers in the `Any` registry. /// /// For SQLite, this function also creates the database file and its parent /// directory if they do not exist yet. The `AnyPool` driver cannot create /// a new SQLite file by itself — unlike the `SqlitePool` which has an /// explicit `create_if_missing` option. pub async fn create_pool(config: &AppConfig) -> Result { // Register SQLite and PostgreSQL drivers so `AnyPool` can dispatch // to the correct one based on the URL scheme (sqlite:// vs postgres://). sqlx::any::install_default_drivers(); // SQLite-specific setup: ensure the file exists before connecting. // // `AnyPool` does not expose `create_if_missing` like `SqlitePool` does, // so we must touch the file ourselves. // // The URL is parsed the same way SQLx does it internally: // sqlite://data/ipam.db → strip sqlite:// → path = data/ipam.db if let DatabaseBackend::Sqlite = &config.backend { // Strip both possible prefixes (SQLx accepts both forms) let path_str = config .database_url .trim_start_matches("sqlite://") .trim_start_matches("sqlite:"); // Skip special filenames (in-memory, shared cache) if path_str != ":memory:" && !path_str.is_empty() { let path = std::path::Path::new(path_str); // Create the parent directory if it does not exist if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent) .map_err(|e| sqlx::Error::Io(e))?; } } // Create an empty file so SQLite can open it if !path.exists() { std::fs::File::create(path) .map_err(|e| sqlx::Error::Io(e))?; } } } let pool = AnyPool::connect(&config.database_url).await?; Ok(pool) } // ─── Migrations ─────────────────────────────────────────────────────────────── /// Applies all pending migrations from the directory matching the active backend. /// /// Two separate directories handle SQL syntax differences: /// - `migrations/sqlite/` : uses `INTEGER PRIMARY KEY AUTOINCREMENT` /// - `migrations/postgres/` : uses `BIGSERIAL PRIMARY KEY` /// /// SQLx tracks applied migrations in a `_sqlx_migrations` table, so running /// this function on an already-migrated database is always safe (idempotent). /// /// `sqlx::migrate!("path")` is a compile-time macro: it embeds all `.sql` /// files from the given path directly into the binary. The path is relative /// to the project root (where Cargo.toml lives). pub async fn run_migrations(pool: &AnyPool, backend: &DatabaseBackend) -> Result<(), DbError> { match backend { DatabaseBackend::Sqlite => { sqlx::migrate!("migrations/sqlite").run(pool).await?; } DatabaseBackend::Postgres => { sqlx::migrate!("migrations/postgres").run(pool).await?; } } Ok(()) }