Files
endorphin-aster-vm/crates/storage/src/lib.rs
T
codex-bot e52d2e1e4b
Verify databases and deploy Aster VM / verify-and-deploy (push) Successful in 7m25s
Separate database connections from file-backed host information and document handoff
2026-10-19 02:07:36 +08:00

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,
}
}
}