feat(store): implement DatabaseEngine with SQLite WAL pragmas and schema migrations (GH-7) #36

Merged
KofTwentyTwo merged 1 commits from feature/GH-7-sqlite-engine-wal-migrations into main 2026-07-23 00:42:40 +00:00
4 changed files with 184 additions and 19 deletions

No files matched your search

+2
View File
@@ -30,3 +30,5 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
ratatui = "0.28"
crossterm = "0.28"
clap = { version = "4.5", features = ["derive", "env"] }
tempfile = "3.10"
+4
View File
@@ -11,5 +11,9 @@ nuncio-core = { path = "../nuncio-core" }
tokio.workspace = true
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
+175
View File
@@ -0,0 +1,175 @@
//! SQLite database engine initialization, connection pooling, and migrations.
use std::path::Path;
use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use tempfile::TempDir;
use thiserror::Error;
/// Database errors emitted by `nuncio-store`.
#[derive(Error, Debug)]
pub enum DatabaseError {
/// Failed to open or configure connection pool.
#[error("failed to create sqlite connection pool: {0}")]
PoolCreation(String),
/// Failed to execute schema migration.
#[error("failed to run database migration: {0}")]
Migration(String),
/// Database query execution error.
#[error("database query execution error: {0}")]
Query(#[from] sqlx::Error),
}
/// SQLite database storage engine managing WAL connection pools and migrations.
#[derive(Debug, Clone)]
pub struct DatabaseEngine {
pool: SqlitePool,
}
impl DatabaseEngine {
/// Maximum concurrent read/write pool size.
pub const MAX_CONNECTIONS: u32 = 16;
/// Connect to a local SQLite database file with production WAL pragmas.
pub async fn connect_file(path: &Path) -> Result<Self, DatabaseError> {
let path_str = path.to_str().ok_to_null();
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path_str))
.map_err(|e| DatabaseError::PoolCreation(e.to_string()))?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(std::time::Duration::from_millis(5000))
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(Self::MAX_CONNECTIONS)
.connect_with(options)
.await
.map_err(|e| DatabaseError::PoolCreation(e.to_string()))?;
let engine = Self { pool };
engine.migrate().await?;
Ok(engine)
}
/// Connect to an isolated ephemeral database for unit and integration testing.
pub async fn connect_ephemeral() -> Result<(Self, TempDir), DatabaseError> {
let dir = tempfile::tempdir().map_err(|e| DatabaseError::PoolCreation(e.to_string()))?;
let db_path = dir.path().join("nuncio_test.sqlite");
let engine = Self::connect_file(&db_path).await?;
Ok((engine, dir))
}
/// Access the underlying `sqlx::SqlitePool`.
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
/// Execute initial database migrations creating core envelope tables.
pub async fn migrate(&self) -> Result<(), DatabaseError> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
account_id TEXT NOT NULL,
folder_id TEXT NOT NULL,
subject TEXT NOT NULL,
sender TEXT NOT NULL,
recipient TEXT NOT NULL,
received_at INTEGER NOT NULL,
read_flag INTEGER NOT NULL DEFAULT 0,
body_plain TEXT,
body_html TEXT
);
CREATE TABLE IF NOT EXISTS calendar_events (
id TEXT PRIMARY KEY NOT NULL,
account_id TEXT NOT NULL,
calendar_id TEXT NOT NULL,
summary TEXT NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
rrule TEXT,
location TEXT
);
"#,
)
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
Ok(())
}
}
trait ToNullStr {
fn ok_to_null(&self) -> &str;
}
impl ToNullStr for Option<&str> {
fn ok_to_null(&self) -> &str {
self.unwrap_or("")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ephemeral_database_initializes_and_migrates() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral DB created");
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM messages")
.fetch_one(engine.pool())
.await
.expect("query messages table");
assert_eq!(row.0, 0);
let event_row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM calendar_events")
.fetch_one(engine.pool())
.await
.expect("query calendar_events table");
assert_eq!(event_row.0, 0);
}
#[tokio::test]
async fn insert_and_query_message() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
sqlx::query(
"INSERT INTO messages (id, account_id, folder_id, subject, sender, recipient, received_at, read_flag, body_plain)
VALUES ('msg-1', 'acct-1', 'inbox', 'Hello', 'alice@nuncio.mx', 'bob@nuncio.mx', 1700000000, 0, 'Hi Bob')",
)
.execute(engine.pool())
.await
.unwrap();
let subject: (String,) = sqlx::query_as("SELECT subject FROM messages WHERE id = 'msg-1'")
.fetch_one(engine.pool())
.await
.unwrap();
assert_eq!(subject.0, "Hello");
}
#[test]
fn database_error_display() {
let err = DatabaseError::PoolCreation("failed".to_string());
assert_eq!(
err.to_string(),
"failed to create sqlite connection pool: failed"
);
let mig_err = DatabaseError::Migration("mig failed".to_string());
assert_eq!(
mig_err.to_string(),
"failed to run database migration: mig failed"
);
}
}
+3 -19
View File
@@ -1,21 +1,5 @@
use thiserror::Error;
//! Local storage, full-text search indexing, and credential security for Nuncio.
#[derive(Error, Debug)]
pub enum StoreError {
#[error("Database error: {0}")]
Database(String),
}
pub mod db;
pub struct StorageEngine;
impl StorageEngine {
pub fn new() -> Self {
Self
}
}
impl Default for StorageEngine {
fn default() -> Self {
Self::new()
}
}
pub use db::{DatabaseEngine, DatabaseError};