Skip to main content

atrg_db/
lib.rs

1//! Database layer for at-rust-go: SQLite connection pooling and migrations.
2//!
3//! This crate provides a thin wrapper around `sqlx` with SQLite, handling
4//! connection pool creation with sensible defaults (WAL journal mode, foreign
5//! keys enabled) and a two-stage migration system: internal migrations for
6//! atrg's own tables (sessions, etc.) and user-supplied migrations for
7//! application-specific schema.
8
9#![deny(unsafe_code)]
10#![warn(missing_docs)]
11
12use std::str::FromStr;
13
14/// A SQLite connection pool. This is the primary database handle passed
15/// throughout the atrg application via `AppState`.
16pub type DbConn = sqlx::SqlitePool;
17
18/// Connect to a SQLite database and return a connection pool.
19///
20/// The pool is configured with:
21/// - `create_if_missing(true)` — the database file is created automatically
22/// - WAL journal mode — better concurrent read performance
23/// - Foreign keys enabled — referential integrity is enforced
24/// - Up to 8 concurrent connections
25///
26/// # Examples
27///
28/// ```no_run
29/// # async fn example() -> anyhow::Result<()> {
30/// let pool = atrg_db::connect("sqlite://atrg.db").await?;
31/// # Ok(())
32/// # }
33/// ```
34pub async fn connect(url: &str) -> anyhow::Result<sqlx::SqlitePool> {
35    let opts = sqlx::sqlite::SqliteConnectOptions::from_str(url)?
36        .create_if_missing(true)
37        .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
38        .foreign_keys(true);
39
40    let pool = sqlx::sqlite::SqlitePoolOptions::new()
41        .max_connections(8)
42        .connect_with(opts)
43        .await?;
44
45    tracing::info!("connected to SQLite database: {}", url);
46
47    Ok(pool)
48}
49
50/// Run atrg's internal migrations (sessions table, etc.).
51///
52/// These migrations are embedded in the `atrg-db` crate at compile time
53/// from the `migrations/` directory next to this crate's `Cargo.toml`.
54/// They are idempotent and safe to run on every startup.
55pub async fn run_internal_migrations(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
56    let migrator = sqlx::migrate!("./migrations");
57    let num_migrations = migrator.migrations.len();
58
59    migrator.run(pool).await?;
60
61    tracing::info!(
62        count = num_migrations,
63        "applied atrg internal migrations (if pending)"
64    );
65
66    Ok(())
67}
68
69/// Run user-supplied migrations from the given directory.
70///
71/// If the directory does not exist or contains no `.sql` files, this
72/// function returns `Ok(())` silently — it is not an error for a project
73/// to have no custom migrations yet.
74///
75/// Migrations are discovered and applied in filename order using the
76/// standard `sqlx` migration conventions.
77pub async fn run_user_migrations(
78    pool: &sqlx::SqlitePool,
79    dir: &std::path::Path,
80) -> anyhow::Result<()> {
81    if !dir.exists() {
82        tracing::debug!(
83            path = %dir.display(),
84            "user migrations directory does not exist, skipping"
85        );
86        return Ok(());
87    }
88
89    let has_sql_files = std::fs::read_dir(dir)?
90        .filter_map(|entry| entry.ok())
91        .any(|entry| entry.path().extension().is_some_and(|ext| ext == "sql"));
92
93    if !has_sql_files {
94        tracing::debug!(
95            path = %dir.display(),
96            "user migrations directory contains no .sql files, skipping"
97        );
98        return Ok(());
99    }
100
101    let migrator = sqlx::migrate::Migrator::new(dir).await?;
102    let num_migrations = migrator.migrations.len();
103
104    migrator.run(pool).await?;
105
106    tracing::info!(
107        count = num_migrations,
108        path = %dir.display(),
109        "applied user migrations (if pending)"
110    );
111
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[tokio::test]
120    async fn test_connect_memory() {
121        let pool = connect("sqlite::memory:")
122            .await
123            .expect("should connect to in-memory SQLite");
124
125        let row: (i32,) = sqlx::query_as("SELECT 1")
126            .fetch_one(&pool)
127            .await
128            .expect("should execute SELECT 1");
129
130        assert_eq!(row.0, 1);
131    }
132
133    #[tokio::test]
134    async fn test_internal_migrations() {
135        let pool = connect("sqlite::memory:").await.expect("should connect");
136
137        run_internal_migrations(&pool)
138            .await
139            .expect("should run internal migrations");
140
141        let row: (String,) = sqlx::query_as(
142            "SELECT name FROM sqlite_master WHERE type='table' AND name='atrg_sessions'",
143        )
144        .fetch_one(&pool)
145        .await
146        .expect("atrg_sessions table should exist");
147
148        assert_eq!(row.0, "atrg_sessions");
149    }
150
151    #[tokio::test]
152    async fn test_migrations_idempotent() {
153        let pool = connect("sqlite::memory:").await.expect("should connect");
154
155        run_internal_migrations(&pool)
156            .await
157            .expect("first run should succeed");
158
159        run_internal_migrations(&pool)
160            .await
161            .expect("second run should also succeed (idempotent)");
162    }
163
164    #[tokio::test]
165    async fn test_user_migrations_empty_dir() {
166        let pool = connect("sqlite::memory:").await.expect("should connect");
167
168        let tmp_dir = std::env::temp_dir().join(format!("atrg_test_empty_{}", std::process::id()));
169        std::fs::create_dir_all(&tmp_dir).expect("should create temp dir");
170
171        let result = run_user_migrations(&pool, &tmp_dir).await;
172
173        // Clean up before asserting
174        let _ = std::fs::remove_dir_all(&tmp_dir);
175
176        result.expect("empty dir should succeed silently");
177    }
178
179    #[tokio::test]
180    async fn test_user_migrations_nonexistent_dir() {
181        let pool = connect("sqlite::memory:").await.expect("should connect");
182
183        let nonexistent =
184            std::path::Path::new("/tmp/atrg_test_nonexistent_dir_that_does_not_exist");
185
186        run_user_migrations(&pool, nonexistent)
187            .await
188            .expect("nonexistent dir should succeed silently");
189    }
190}