182 lines
6.2 KiB
Rust
182 lines
6.2 KiB
Rust
//! Explicit database connections for database-backed objects.
|
|
//! Connecting never creates application tables or stores VM/host metadata.
|
|
use std::fmt;
|
|
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
use std::time::Duration;
|
|
|
|
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
|
use sqlx::{PgPool, SqlitePool};
|
|
|
|
/// An explicit PostgreSQL configuration never falls back to SQLite.
|
|
#[derive(Clone)]
|
|
pub enum DatabaseConfig {
|
|
SqliteFile(PathBuf),
|
|
SqliteMemory,
|
|
Postgres(String),
|
|
}
|
|
|
|
impl fmt::Debug for DatabaseConfig {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::SqliteFile(path) => f.debug_tuple("SqliteFile").field(path).finish(),
|
|
Self::SqliteMemory => f.write_str("SqliteMemory"),
|
|
Self::Postgres(_) => f.write_str("Postgres([REDACTED])"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum DatabaseError {
|
|
Config(&'static str),
|
|
Directory(std::io::Error),
|
|
Database(sqlx::Error),
|
|
}
|
|
|
|
impl fmt::Display for DatabaseError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Config(message) => f.write_str(message),
|
|
Self::Directory(_) => f.write_str("cannot create SQLite parent directory"),
|
|
// CLI/HTTP logs must never print a connection URL or credentials.
|
|
Self::Database(_) => {
|
|
f.write_str("database operation failed; no fallback was attempted")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DatabaseError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Config(_) => None,
|
|
Self::Directory(error) => Some(error),
|
|
Self::Database(error) => Some(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<sqlx::Error> for DatabaseError {
|
|
fn from(error: sqlx::Error) -> Self {
|
|
Self::Database(error)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum Backend {
|
|
Sqlite(SqlitePool),
|
|
Postgres(PgPool),
|
|
}
|
|
|
|
/// Created only on explicit request by an object or host adapter. Clones share
|
|
/// a pool; close() closes it for all users. There is no automatic schema or data.
|
|
/// In-memory SQLite uses one connection to preserve its database lifetime.
|
|
#[derive(Clone)]
|
|
pub struct DatabaseConnection {
|
|
backend: Backend,
|
|
}
|
|
|
|
impl DatabaseConnection {
|
|
/// Connects eagerly to the explicit backend without executing application DDL.
|
|
pub async fn connect(config: &DatabaseConfig) -> Result<Self, DatabaseError> {
|
|
let backend = match config {
|
|
DatabaseConfig::Postgres(url) => {
|
|
if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) {
|
|
return Err(DatabaseError::Config(
|
|
"expected a PostgreSQL connection URL",
|
|
));
|
|
}
|
|
let options = PgConnectOptions::from_str(url)
|
|
.map_err(|_| DatabaseError::Config("invalid PostgreSQL connection URL"))?;
|
|
Backend::Postgres(
|
|
PgPoolOptions::new()
|
|
.max_connections(5)
|
|
.acquire_timeout(Duration::from_secs(5))
|
|
.connect_with(options)
|
|
.await?,
|
|
)
|
|
}
|
|
DatabaseConfig::SqliteFile(path) => {
|
|
if path.as_os_str().is_empty() || path == std::path::Path::new(":memory:") {
|
|
return Err(DatabaseError::Config(
|
|
"expected a nonempty file path; use SqliteMemory for :memory:",
|
|
));
|
|
}
|
|
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
|
tokio::fs::create_dir_all(parent)
|
|
.await
|
|
.map_err(DatabaseError::Directory)?;
|
|
}
|
|
let options = SqliteConnectOptions::new()
|
|
.filename(path)
|
|
.create_if_missing(true)
|
|
.journal_mode(SqliteJournalMode::Wal)
|
|
.busy_timeout(Duration::from_secs(5));
|
|
Backend::Sqlite(
|
|
SqlitePoolOptions::new()
|
|
.max_connections(5)
|
|
.acquire_timeout(Duration::from_secs(5))
|
|
.connect_with(options)
|
|
.await?,
|
|
)
|
|
}
|
|
DatabaseConfig::SqliteMemory => {
|
|
let options = SqliteConnectOptions::new().in_memory(true);
|
|
Backend::Sqlite(
|
|
SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.min_connections(1)
|
|
.idle_timeout(None)
|
|
.max_lifetime(None)
|
|
.connect_with(options)
|
|
.await?,
|
|
)
|
|
}
|
|
};
|
|
Ok(Self { backend })
|
|
}
|
|
|
|
/// Backend-specific access for the database-backed object implementation.
|
|
pub fn sqlite_pool(&self) -> Option<&SqlitePool> {
|
|
match &self.backend {
|
|
Backend::Sqlite(pool) => Some(pool),
|
|
Backend::Postgres(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn postgres_pool(&self) -> Option<&PgPool> {
|
|
match &self.backend {
|
|
Backend::Postgres(pool) => Some(pool),
|
|
Backend::Sqlite(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn backend_name(&self) -> &'static str {
|
|
match &self.backend {
|
|
Backend::Sqlite(_) => "sqlite",
|
|
Backend::Postgres(_) => "postgresql",
|
|
}
|
|
}
|
|
|
|
pub async fn ping(&self) -> Result<(), DatabaseError> {
|
|
match &self.backend {
|
|
Backend::Sqlite(pool) => {
|
|
sqlx::query("SELECT 1").execute(pool).await?;
|
|
}
|
|
Backend::Postgres(pool) => {
|
|
sqlx::query("SELECT 1").execute(pool).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Explicitly closes the shared pool, including all DatabaseConnection clones.
|
|
pub async fn close(&self) {
|
|
match &self.backend {
|
|
Backend::Sqlite(pool) => pool.close().await,
|
|
Backend::Postgres(pool) => pool.close().await,
|
|
}
|
|
}
|
|
}
|