pub mod config_encryption;
pub mod content;
pub mod models;
pub mod repositories;
pub mod schema;
use std::sync::OnceLock;
use std::time::Duration;
use sea_query_sqlx::SqlxBinder;
use crate::config::StorageConfig;
use crate::errors::OrionError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DbBackend {
Sqlite,
Postgres,
Mysql,
}
impl std::fmt::Display for DbBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Sqlite => write!(f, "sqlite"),
Self::Postgres => write!(f, "postgres"),
Self::Mysql => write!(f, "mysql"),
}
}
}
static DB_BACKEND: OnceLock<DbBackend> = OnceLock::new();
pub fn detect_backend(url: &str) -> Result<DbBackend, OrionError> {
if url.starts_with("sqlite:") || url.starts_with("file:") {
Ok(DbBackend::Sqlite)
} else if url.starts_with("postgres://") || url.starts_with("postgresql://") {
Ok(DbBackend::Postgres)
} else if url.starts_with("mysql://") || url.starts_with("mariadb://") {
Ok(DbBackend::Mysql)
} else {
Err(OrionError::Config {
message: format!(
"Unsupported database URL scheme: {url}. Expected sqlite:, postgres://, or mysql://"
),
})
}
}
pub fn get_backend() -> DbBackend {
*DB_BACKEND
.get()
.expect("Database backend not initialized. Call init_pool() first.")
}
#[cfg(test)]
pub fn set_backend_for_test(backend: DbBackend) {
DB_BACKEND.set(backend).ok();
}
pub trait DbRow:
for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
+ for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
+ for<'r> sqlx::FromRow<'r, sqlx::mysql::MySqlRow>
+ Send
+ Unpin
{
}
impl<T> DbRow for T where
T: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
+ for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
+ for<'r> sqlx::FromRow<'r, sqlx::mysql::MySqlRow>
+ Send
+ Unpin
{
}
#[derive(Clone)]
pub enum DbPool {
Sqlite(sqlx::SqlitePool),
Postgres(sqlx::PgPool),
Mysql(sqlx::MySqlPool),
}
macro_rules! dispatch_pool {
($self:expr, $p:ident => $body:expr) => {
match $self {
DbPool::Sqlite($p) => $body,
DbPool::Postgres($p) => $body,
DbPool::Mysql($p) => $body,
}
};
}
impl DbPool {
pub fn size(&self) -> u32 {
dispatch_pool!(self, p => p.size())
}
pub async fn ping(&self) -> Result<(), sqlx::Error> {
crate::metrics::timed_db_op("db.ping", async {
let (sql, values) =
build_sqlx(sea_query::Query::select().expr(sea_query::Expr::val(1i32)));
self.fetch_scalar::<i32>(&sql, values).await?;
Ok(())
})
.await
}
pub fn num_idle(&self) -> usize {
dispatch_pool!(self, p => p.num_idle())
}
pub async fn fetch_all_as<T: DbRow>(
&self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<Vec<T>, sqlx::Error> {
dispatch_pool!(self, p => sqlx::query_as_with::<_, T, _>(sql, values).fetch_all(p).await)
}
pub async fn fetch_one_as<T: DbRow>(
&self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<T, sqlx::Error> {
dispatch_pool!(self, p => sqlx::query_as_with::<_, T, _>(sql, values).fetch_one(p).await)
}
pub async fn fetch_optional_as<T: DbRow>(
&self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<Option<T>, sqlx::Error> {
dispatch_pool!(self, p => sqlx::query_as_with::<_, T, _>(sql, values).fetch_optional(p).await)
}
pub async fn execute_query(
&self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<u64, sqlx::Error> {
dispatch_pool!(self, p => {
let r = sqlx::query_with(sql, values).execute(p).await?;
Ok(r.rows_affected())
})
}
pub async fn fetch_scalar<T>(
&self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<T, sqlx::Error>
where
T: Send + Unpin + 'static,
T: sqlx::Type<sqlx::Sqlite> + for<'r> sqlx::Decode<'r, sqlx::Sqlite>,
T: sqlx::Type<sqlx::Postgres> + for<'r> sqlx::Decode<'r, sqlx::Postgres>,
T: sqlx::Type<sqlx::MySql> + for<'r> sqlx::Decode<'r, sqlx::MySql>,
(T,): for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>,
(T,): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>,
(T,): for<'r> sqlx::FromRow<'r, sqlx::mysql::MySqlRow>,
{
dispatch_pool!(self, p => sqlx::query_scalar_with::<_, T, _>(sql, values).fetch_one(p).await)
}
pub async fn begin_tx(&self) -> Result<DbTransaction, sqlx::Error> {
match self {
DbPool::Sqlite(p) => Ok(DbTransaction::Sqlite(p.begin().await?)),
DbPool::Postgres(p) => Ok(DbTransaction::Postgres(p.begin().await?)),
DbPool::Mysql(p) => Ok(DbTransaction::Mysql(p.begin().await?)),
}
}
}
pub enum DbTransaction {
Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
Mysql(sqlx::Transaction<'static, sqlx::MySql>),
}
macro_rules! dispatch_tx {
($self:expr, $tx:ident => $body:expr) => {
match $self {
DbTransaction::Sqlite($tx) => $body,
DbTransaction::Postgres($tx) => $body,
DbTransaction::Mysql($tx) => $body,
}
};
}
impl DbTransaction {
pub async fn commit(self) -> Result<(), sqlx::Error> {
match self {
DbTransaction::Sqlite(tx) => tx.commit().await,
DbTransaction::Postgres(tx) => tx.commit().await,
DbTransaction::Mysql(tx) => tx.commit().await,
}
}
pub async fn fetch_all_as<T: DbRow>(
&mut self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<Vec<T>, sqlx::Error> {
dispatch_tx!(self, tx => sqlx::query_as_with::<_, T, _>(sql, values).fetch_all(&mut **tx).await)
}
pub async fn fetch_optional_as<T: DbRow>(
&mut self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<Option<T>, sqlx::Error> {
dispatch_tx!(self, tx => sqlx::query_as_with::<_, T, _>(sql, values).fetch_optional(&mut **tx).await)
}
pub async fn execute_query(
&mut self,
sql: &str,
values: sea_query_sqlx::SqlxValues,
) -> Result<u64, sqlx::Error> {
dispatch_tx!(self, tx => {
let r = sqlx::query_with(sql, values).execute(&mut **tx).await?;
Ok(r.rows_affected())
})
}
}
pub fn build_sqlx<S: SqlxBinder>(stmt: &mut S) -> (String, sea_query_sqlx::SqlxValues) {
match get_backend() {
DbBackend::Sqlite => stmt.build_sqlx(sea_query::SqliteQueryBuilder),
DbBackend::Postgres => stmt.build_sqlx(sea_query::PostgresQueryBuilder),
DbBackend::Mysql => stmt.build_sqlx(sea_query::MysqlQueryBuilder),
}
}
static MIGRATOR_SQLITE: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/sqlite");
static MIGRATOR_POSTGRES: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/postgres");
static MIGRATOR_MYSQL: sqlx::migrate::Migrator = sqlx::migrate!("./migrations/mysql");
pub fn migrator_for(backend: DbBackend) -> &'static sqlx::migrate::Migrator {
match backend {
DbBackend::Sqlite => &MIGRATOR_SQLITE,
DbBackend::Postgres => &MIGRATOR_POSTGRES,
DbBackend::Mysql => &MIGRATOR_MYSQL,
}
}
fn migrator() -> &'static sqlx::migrate::Migrator {
migrator_for(get_backend())
}
#[cfg(test)]
pub(crate) async fn test_sqlite_pool() -> DbPool {
init_pool(&crate::config::StorageConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
..Default::default()
})
.await
.expect("test pool")
}
pub async fn init_pool(config: &StorageConfig) -> Result<DbPool, OrionError> {
let pool = init_pool_no_migrate(config).await?;
run_migrations(&pool).await?;
Ok(pool)
}
pub async fn init_pool_for_startup(config: &StorageConfig) -> Result<DbPool, OrionError> {
if config.auto_migrate {
return init_pool(config).await;
}
let pool = init_pool_no_migrate(config).await?;
let pending = pending_migrations(&pool).await?;
if !pending.is_empty() {
return Err(OrionError::Config {
message: format!(
"{} pending migration(s) and storage.auto_migrate = false — \
run `orion-server migrate` first",
pending.len()
),
});
}
Ok(pool)
}
pub async fn init_pool_no_migrate(config: &StorageConfig) -> Result<DbPool, OrionError> {
let backend = detect_backend(&config.url)?;
DB_BACKEND.set(backend).ok();
connect_with_retry(config, backend).await
}
const CONNECT_RETRY_BACKOFF_MAX: Duration = Duration::from_secs(5);
fn connect_backoff(failures: u32) -> Duration {
let doublings = failures.saturating_sub(1).min(20);
Duration::from_millis(250u64 << doublings).min(CONNECT_RETRY_BACKOFF_MAX)
}
async fn connect_with_retry(
config: &StorageConfig,
backend: DbBackend,
) -> Result<DbPool, OrionError> {
let window = match backend {
DbBackend::Sqlite => Duration::ZERO,
_ => Duration::from_secs(config.connect_retry_secs),
};
retry_within(window, |attempt| async move {
if attempt > 1 {
tracing::info!(attempt, backend = %backend, "Retrying database connection");
}
match backend {
DbBackend::Sqlite => init_sqlite_pool(config).await,
DbBackend::Postgres => init_postgres_pool(config).await,
DbBackend::Mysql => init_mysql_pool(config).await,
}
})
.await
}
async fn retry_within<T, F, Fut>(window: Duration, mut attempt: F) -> Result<T, OrionError>
where
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<T, OrionError>>,
{
let started = tokio::time::Instant::now();
let deadline = started + window;
let mut failures = 0u32;
loop {
match attempt(failures + 1).await {
Ok(value) => {
if failures > 0 {
tracing::info!(
attempts = failures + 1,
elapsed_ms = started.elapsed().as_millis() as u64,
"Database connection established after retrying"
);
}
return Ok(value);
}
Err(err) => {
failures += 1;
let backoff = connect_backoff(failures);
if tokio::time::Instant::now() + backoff > deadline {
if failures > 1 {
tracing::error!(
attempts = failures,
elapsed_ms = started.elapsed().as_millis() as u64,
"Giving up on the database connection — retry window exhausted"
);
}
return Err(err);
}
tracing::warn!(
attempt = failures,
retry_in_ms = backoff.as_millis() as u64,
error = %err,
"Database unavailable at startup; retrying \
(bounded by storage.connect_retry_secs)"
);
tokio::time::sleep(backoff).await;
}
}
}
}
pub async fn run_migrations(pool: &DbPool) -> Result<(), OrionError> {
let m = migrator();
match pool {
DbPool::Sqlite(p) => m.run(p).await,
DbPool::Postgres(p) => m.run(p).await,
DbPool::Mysql(p) => m.run(p).await,
}
.map_err(|e| OrionError::Internal {
context: "Failed to run migrations".to_string(),
source: Some(Box::new(e)),
})
}
pub async fn pending_migrations(pool: &DbPool) -> Result<Vec<(i64, String)>, OrionError> {
let applied: std::collections::HashSet<i64> = {
let sql = "SELECT version FROM _sqlx_migrations ORDER BY version";
let result: Result<Vec<i64>, _> = match pool {
DbPool::Sqlite(p) => sqlx::query_scalar::<_, i64>(sql).fetch_all(p).await,
DbPool::Postgres(p) => sqlx::query_scalar::<_, i64>(sql).fetch_all(p).await,
DbPool::Mysql(p) => sqlx::query_scalar::<_, i64>(sql).fetch_all(p).await,
};
match result {
Ok(versions) => versions.into_iter().collect(),
Err(_) => std::collections::HashSet::new(),
}
};
let pending: Vec<(i64, String)> = migrator()
.iter()
.filter(|m| !applied.contains(&m.version))
.map(|m| (m.version, m.description.to_string()))
.collect();
Ok(pending)
}
async fn init_sqlite_pool(config: &StorageConfig) -> Result<DbPool, OrionError> {
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
let busy_timeout = config.busy_timeout_ms.to_string();
let options = SqliteConnectOptions::from_str(&config.url)
.map_err(|e| OrionError::Internal {
context: "Invalid DB path".to_string(),
source: Some(Box::new(e)),
})?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.pragma("foreign_keys", "ON")
.pragma("busy_timeout", busy_timeout)
.pragma("synchronous", "NORMAL")
.pragma("cache_size", "-20000");
let mut pool_opts = SqlitePoolOptions::new()
.max_connections(config.max_connections)
.min_connections(config.min_connections)
.acquire_timeout(Duration::from_secs(config.acquire_timeout_secs));
if config.idle_timeout_secs > 0 {
pool_opts = pool_opts.idle_timeout(Duration::from_secs(config.idle_timeout_secs));
}
let pool = pool_opts
.connect_with(options)
.await
.map_err(|e| OrionError::Internal {
context: "Failed to connect to database".to_string(),
source: Some(Box::new(e)),
})?;
Ok(DbPool::Sqlite(pool))
}
async fn init_postgres_pool(config: &StorageConfig) -> Result<DbPool, OrionError> {
use sqlx::postgres::PgPoolOptions;
let mut pool_opts = PgPoolOptions::new()
.max_connections(config.max_connections)
.min_connections(config.min_connections)
.acquire_timeout(Duration::from_secs(config.acquire_timeout_secs));
if config.idle_timeout_secs > 0 {
pool_opts = pool_opts.idle_timeout(Duration::from_secs(config.idle_timeout_secs));
}
let pool = pool_opts
.connect(&config.url)
.await
.map_err(|e| OrionError::Internal {
context: "Failed to connect to database".to_string(),
source: Some(Box::new(e)),
})?;
Ok(DbPool::Postgres(pool))
}
async fn init_mysql_pool(config: &StorageConfig) -> Result<DbPool, OrionError> {
use sqlx::mysql::MySqlPoolOptions;
let mut pool_opts = MySqlPoolOptions::new()
.max_connections(config.max_connections)
.min_connections(config.min_connections)
.acquire_timeout(Duration::from_secs(config.acquire_timeout_secs));
if config.idle_timeout_secs > 0 {
pool_opts = pool_opts.idle_timeout(Duration::from_secs(config.idle_timeout_secs));
}
let pool = pool_opts
.connect(&config.url)
.await
.map_err(|e| OrionError::Internal {
context: "Failed to connect to database".to_string(),
source: Some(Box::new(e)),
})?;
Ok(DbPool::Mysql(pool))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_backend_sqlite() {
assert_eq!(
detect_backend("sqlite:orion.db").expect("test"),
DbBackend::Sqlite
);
assert_eq!(
detect_backend("sqlite::memory:").expect("test"),
DbBackend::Sqlite
);
}
#[test]
fn test_detect_backend_postgres() {
assert_eq!(
detect_backend("postgres://user:pass@localhost/db").expect("test"),
DbBackend::Postgres
);
assert_eq!(
detect_backend("postgresql://user:pass@localhost/db").expect("test"),
DbBackend::Postgres
);
}
#[test]
fn test_detect_backend_mysql() {
assert_eq!(
detect_backend("mysql://user:pass@localhost/db").expect("test"),
DbBackend::Mysql
);
}
#[test]
fn test_detect_backend_unsupported() {
assert!(detect_backend("mssql://localhost").is_err());
}
fn unavailable() -> OrionError {
OrionError::Config {
message: "connection refused".to_string(),
}
}
#[test]
fn connect_backoff_doubles_then_caps() {
assert_eq!(connect_backoff(1), Duration::from_millis(250));
assert_eq!(connect_backoff(2), Duration::from_millis(500));
assert_eq!(connect_backoff(3), Duration::from_millis(1000));
assert_eq!(connect_backoff(4), Duration::from_millis(2000));
assert_eq!(connect_backoff(5), Duration::from_millis(4000));
assert_eq!(connect_backoff(6), CONNECT_RETRY_BACKOFF_MAX);
assert_eq!(connect_backoff(60), CONNECT_RETRY_BACKOFF_MAX);
}
#[tokio::test(start_paused = true)]
async fn retry_within_recovers_when_the_database_comes_back() {
let mut seen = Vec::new();
let started = tokio::time::Instant::now();
let pool: Result<&str, OrionError> = retry_within(Duration::from_secs(60), |attempt| {
seen.push(attempt);
async move {
if attempt < 4 {
Err(unavailable())
} else {
Ok("pool")
}
}
})
.await;
assert_eq!(pool.expect("test"), "pool");
assert_eq!(seen, vec![1, 2, 3, 4], "every attempt must be numbered");
assert_eq!(started.elapsed(), Duration::from_millis(1750));
}
#[tokio::test(start_paused = true)]
async fn retry_within_gives_up_at_the_window_and_returns_the_last_error() {
let mut attempts = 0u32;
let started = tokio::time::Instant::now();
let pool: Result<&str, OrionError> = retry_within(Duration::from_secs(2), |_| {
attempts += 1;
async { Err(unavailable()) }
})
.await;
assert!(pool.is_err(), "an unreachable database must still fail");
assert_eq!(attempts, 4);
assert!(
started.elapsed() <= Duration::from_secs(2),
"the retry window is a hard bound, elapsed {:?}",
started.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn retry_within_zero_window_is_fail_fast() {
let mut attempts = 0u32;
let pool: Result<&str, OrionError> = retry_within(Duration::ZERO, |_| {
attempts += 1;
async { Err(unavailable()) }
})
.await;
assert!(pool.is_err());
assert_eq!(attempts, 1, "connect_retry_secs = 0 must not retry");
}
#[tokio::test]
async fn postgres_connect_retries_but_sqlite_fails_fast() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("test");
let port = listener.local_addr().expect("test").port();
drop(listener);
let config = StorageConfig {
url: format!("postgres://orion:orion@127.0.0.1:{port}/orion"),
acquire_timeout_secs: 1,
connect_retry_secs: 2,
min_connections: 0,
max_connections: 1,
..Default::default()
};
let started = std::time::Instant::now();
let err = connect_with_retry(&config, DbBackend::Postgres).await;
assert!(err.is_err(), "a closed port cannot yield a pool");
assert!(
started.elapsed() >= Duration::from_secs(2),
"the first failure must be retried, not propagated immediately \
(elapsed {:?})",
started.elapsed()
);
let missing = std::env::temp_dir().join("orion-d14-missing/nested/orion.db");
let sqlite = StorageConfig {
url: format!("sqlite:{}", missing.display()),
connect_retry_secs: 30,
min_connections: 0,
max_connections: 1,
..Default::default()
};
let started = std::time::Instant::now();
assert!(
connect_with_retry(&sqlite, DbBackend::Sqlite)
.await
.is_err(),
"an unwritable SQLite path must fail"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"SQLite must fail fast regardless of connect_retry_secs (elapsed {:?})",
started.elapsed()
);
}
}