pub mod migration;
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use sea_orm_migration::MigratorTrait;
use bitrouter_sdk::{BitrouterError, Result};
pub async fn connect(url: &str) -> Result<DatabaseConnection> {
let mut opts = ConnectOptions::new(normalize_url(url));
opts.sqlx_logging(false);
if is_sqlite_memory(url) {
opts.min_connections(1).max_connections(1);
}
let db = Database::connect(opts)
.await
.map_err(|e| BitrouterError::internal(format!("connecting to database {url}: {e}")))?;
if wants_wal(url) {
enable_wal(&db).await;
}
Ok(db)
}
fn wants_wal(url: &str) -> bool {
url.starts_with("sqlite:") && !url.contains(":memory:") && !url.contains("mode=ro")
}
async fn enable_wal(db: &DatabaseConnection) {
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
if db.get_database_backend() != DatabaseBackend::Sqlite {
return;
}
let stmt = Statement::from_string(DatabaseBackend::Sqlite, "PRAGMA journal_mode=WAL;");
if let Err(e) = db.execute(stmt).await {
tracing::debug!(
error = %e,
"could not enable SQLite WAL journaling; readers may contend with writes"
);
}
}
pub async fn run_migrations(db: &DatabaseConnection) -> Result<()> {
migration::Migrator::up(db, None)
.await
.map_err(|e| BitrouterError::internal(format!("running database migrations: {e}")))?;
Ok(())
}
pub fn anchor_url(url: &str, home: &std::path::Path) -> String {
let Some(after_scheme) = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
else {
return url.to_owned();
};
let (path_part, query) = after_scheme
.split_once('?')
.map_or((after_scheme, None), |(path, query)| (path, Some(query)));
if path_part.is_empty()
|| path_part == ":memory:"
|| query.is_some_and(|query| query.split('&').any(|parameter| parameter == "mode=memory"))
{
return url.to_owned();
}
let path = std::path::Path::new(path_part);
if path.is_absolute() {
return url.to_owned();
}
let relative = path_part.strip_prefix("./").unwrap_or(path_part);
let anchored = home.join(relative);
let anchored = anchored.to_string_lossy();
#[cfg(windows)]
let anchored = anchored.replace('\\', "/");
match query {
Some(query) => format!("sqlite://{anchored}?{query}"),
None => format!("sqlite://{anchored}"),
}
}
fn is_sqlite_memory(url: &str) -> bool {
url.starts_with("sqlite:") && url.contains(":memory:")
}
fn normalize_url(url: &str) -> String {
let is_sqlite_file =
url.starts_with("sqlite:") && !url.contains(":memory:") && !url.contains("mode=");
if is_sqlite_file {
let sep = if url.contains('?') { '&' } else { '?' };
format!("{url}{sep}mode=rwc")
} else {
url.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlite_file_urls_get_mode_rwc() {
assert_eq!(
normalize_url("sqlite://./bitrouter.db"),
"sqlite://./bitrouter.db?mode=rwc"
);
assert_eq!(
normalize_url("sqlite://./bitrouter.db?cache=shared"),
"sqlite://./bitrouter.db?cache=shared&mode=rwc"
);
}
#[test]
fn explicit_mode_and_non_sqlite_urls_are_left_alone() {
assert_eq!(
normalize_url("sqlite://./x.db?mode=ro"),
"sqlite://./x.db?mode=ro"
);
assert_eq!(normalize_url("sqlite::memory:"), "sqlite::memory:");
assert_eq!(
normalize_url("postgres://u:p@host/db"),
"postgres://u:p@host/db"
);
assert_eq!(normalize_url("mysql://u:p@host/db"), "mysql://u:p@host/db");
}
#[test]
fn only_writable_sqlite_files_take_the_wal_pragma() {
assert!(wants_wal("sqlite://./bitrouter.db"));
assert!(wants_wal("sqlite://./bitrouter.db?mode=rwc"));
assert!(!wants_wal("sqlite://./bitrouter.db?mode=ro"));
assert!(!wants_wal("sqlite::memory:"));
assert!(!wants_wal("postgres://u:p@host/db"));
assert!(!wants_wal("mysql://u:p@host/db"));
}
#[tokio::test]
async fn connecting_to_a_sqlite_file_leaves_it_in_wal_mode() {
use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("wal.db");
let url = format!("sqlite://{}", path.display());
let db = connect(&url).await.expect("connect");
let mode = db
.query_one(Statement::from_string(
DatabaseBackend::Sqlite,
"PRAGMA journal_mode;",
))
.await
.expect("pragma query")
.expect("one row");
let mode: String = mode.try_get_by_index(0).expect("journal_mode column");
assert_eq!(
mode.to_lowercase(),
"wal",
"a second process polling this file must not block the daemon's writes"
);
}
#[test]
fn detects_in_memory_sqlite() {
assert!(is_sqlite_memory("sqlite::memory:"));
assert!(is_sqlite_memory("sqlite://:memory:"));
assert!(!is_sqlite_memory("sqlite://./bitrouter.db"));
assert!(!is_sqlite_memory("postgres://host/db"));
}
#[test]
fn relative_sqlite_urls_anchor_to_config_home_and_preserve_queries() {
let home = std::path::Path::new("/srv/bitrouter");
assert_eq!(
anchor_url("sqlite://./bitrouter.db", home),
"sqlite:///srv/bitrouter/bitrouter.db"
);
assert_eq!(
anchor_url("sqlite:history.db?cache=shared&mode=rwc", home),
"sqlite:///srv/bitrouter/history.db?cache=shared&mode=rwc"
);
}
#[test]
fn non_file_or_already_anchored_database_urls_are_unchanged() {
let home = std::path::Path::new("/srv/bitrouter");
for url in [
"postgres://db.internal/bitrouter?sslmode=require",
"mysql://db.internal/bitrouter",
"sqlite:///var/lib/bitrouter.db?mode=ro",
"sqlite::memory:",
"sqlite://:memory:",
"sqlite://named-memory?mode=memory&cache=shared",
] {
assert_eq!(anchor_url(url, home), url);
}
}
}