use std::path::Path;
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
#[derive(Clone)]
pub struct DbPool {
conn: Arc<Mutex<Connection>>,
}
impl DbPool {
pub fn open(db_path: &Path) -> Result<Self, rusqlite::Error> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let conn = Connection::open(db_path)?;
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
conn.execute_batch("PRAGMA busy_timeout=5000;")?;
let pool = Self {
conn: Arc::new(Mutex::new(conn)),
};
pool.run_migrations()?;
Ok(pool)
}
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
let conn = Connection::open_in_memory()?;
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
let pool = Self {
conn: Arc::new(Mutex::new(conn)),
};
pool.run_migrations()?;
Ok(pool)
}
pub fn with_conn<F, T>(&self, f: F) -> Result<T, rusqlite::Error>
where
F: FnOnce(&Connection) -> Result<T, rusqlite::Error>,
{
let conn = self
.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = f(&conn);
drop(conn);
result
}
fn run_migrations(&self) -> Result<(), rusqlite::Error> {
let conn = self
.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
conn.execute_batch(INITIAL_MIGRATION)?;
drop(conn);
Ok(())
}
}
const INITIAL_MIGRATION: &str = r"
-- Users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
login TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
pwdhash TEXT NOT NULL,
apikey TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_seen TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_uuid ON users (uuid);
CREATE INDEX IF NOT EXISTS idx_users_apikey ON users (apikey);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
-- Audit log (ISO 8601 timestamps)
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')),
action TEXT NOT NULL,
tracking_id TEXT NOT NULL,
user_id INTEGER REFERENCES users(id),
details TEXT,
CONSTRAINT valid_action CHECK (action IN ('create', 'update', 'delete', 'import', 'export'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_tracking ON audit_log(tracking_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_open_in_memory() {
let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
pool.with_conn(|conn| {
conn.execute(
"INSERT INTO users (uuid, login, name, email, pwdhash, apikey) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
"test-uuid",
"admin",
"Admin",
"admin@test.com",
"hash",
"key"
],
)?;
let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
assert_eq!(count, 1);
Ok(())
})
.expect("DB operation failed");
}
#[test]
fn test_audit_log_constraint() {
let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
let result = pool.with_conn(|conn| {
conn.execute(
"INSERT INTO audit_log (action, tracking_id) VALUES (?1, ?2)",
rusqlite::params!["invalid_action", "test-id"],
)?;
Ok(())
});
assert!(result.is_err(), "Invalid action should fail constraint");
}
#[test]
fn test_audit_log_valid_actions() {
let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
for action in &["create", "update", "delete", "import", "export"] {
pool.with_conn(|conn| {
conn.execute(
"INSERT INTO audit_log (action, tracking_id) VALUES (?1, ?2)",
rusqlite::params![action, format!("test-{action}")],
)?;
Ok(())
})
.unwrap_or_else(|e| panic!("Action '{action}' should be valid: {e}"));
}
}
#[test]
fn test_foreign_key_enforcement() {
let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
let result = pool.with_conn(|conn| {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at) VALUES (?1, ?2, ?3)",
rusqlite::params!["session-1", 999, "2026-12-31T00:00:00Z"],
)?;
Ok(())
});
assert!(result.is_err(), "FK to nonexistent user should fail");
}
}