Skip to main content

logdive_api/
state.rs

1//! Shared application state for the HTTP server.
2//!
3//! `AppState` carries the configured database path and offers a uniform
4//! helper, [`AppState::with_connection`], for running blocking SQLite work
5//! on Tokio's blocking-task pool. Every handler that touches the database
6//! routes through this helper so that:
7//!   1. The rusqlite dependency stays contained to one module,
8//!   2. No handler accidentally blocks the async runtime,
9//!   3. Each request gets a fresh read-only connection — matching the
10//!      milestone 8 design decision on connection strategy.
11//!
12//! The read-only connection is opened via [`logdive_core::Indexer::
13//! open_read_only`], which enforces SQLite-level `SQLITE_OPEN_READ_ONLY`
14//! semantics and fails fast if the DB file is missing (as opposed to
15//! creating it, the way `Indexer::open` does).
16
17use std::path::PathBuf;
18
19use logdive_core::{Indexer, LogdiveError, Result};
20
21/// State shared across every HTTP handler.
22///
23/// Cheap to clone: a single `PathBuf` per instance. Axum requires the
24/// state type to be `Clone` so each request handler can get its own
25/// owned copy via the `State` extractor.
26#[derive(Debug, Clone)]
27pub struct AppState {
28    /// Absolute or resolved path to the logdive index database.
29    ///
30    /// Opened read-only per request; never modified by the server.
31    pub db_path: PathBuf,
32}
33
34impl AppState {
35    /// Construct a new `AppState` for the given database path.
36    ///
37    /// Does not perform any I/O — existence/readability of the file is
38    /// checked at startup in `main`, and each request re-opens the file
39    /// read-only via [`AppState::with_connection`].
40    pub fn new(db_path: PathBuf) -> Self {
41        Self { db_path }
42    }
43
44    /// Run `f` on Tokio's blocking-task pool with a fresh read-only
45    /// [`Indexer`] over the configured database.
46    ///
47    /// Propagates the closure's result through as-is. Any error from
48    /// opening the database, or a blocking-task join failure (which
49    /// happens only if the closure itself panics), is folded into
50    /// [`LogdiveError`] — handlers map this to an HTTP `AppError` at the
51    /// response boundary.
52    ///
53    /// # Why `spawn_blocking`
54    ///
55    /// `rusqlite` calls are synchronous and can do real work (microseconds
56    /// for a point lookup, milliseconds for a large `LIKE`). Running them
57    /// directly inside an async handler would block one of Tokio's worker
58    /// threads for the duration of the query, starving other connections.
59    /// `spawn_blocking` hands the work to the dedicated blocking pool,
60    /// leaving the worker threads free for other async tasks.
61    pub async fn with_connection<F, T>(&self, f: F) -> Result<T>
62    where
63        F: FnOnce(&Indexer) -> Result<T> + Send + 'static,
64        T: Send + 'static,
65    {
66        let path = self.db_path.clone();
67        let join_result = tokio::task::spawn_blocking(move || -> Result<T> {
68            let indexer = Indexer::open_read_only(&path)?;
69            f(&indexer)
70        })
71        .await;
72
73        match join_result {
74            Ok(inner) => inner,
75            Err(join_err) => {
76                // `JoinError` from `spawn_blocking` means the closure panicked
77                // or the runtime is shutting down. We surface both as an I/O
78                // error at the DB path so they have a path context attached,
79                // consistent with how other DB-adjacent failures are reported.
80                //
81                // `Error::other` is the idiomatic constructor for "wrap an
82                // arbitrary error message as io::Error without caring about
83                // the specific ErrorKind" — equivalent to the older
84                // `Error::new(ErrorKind::Other, _)` pattern but clearer.
85                let io_err = std::io::Error::other(format!("blocking task failed: {join_err}"));
86                Err(LogdiveError::io_at(&self.db_path, io_err))
87            }
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Tests
94// ---------------------------------------------------------------------------
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[tokio::test]
101    async fn with_connection_runs_closure_and_propagates_result() {
102        let dir = tempfile::tempdir().unwrap();
103        let db = dir.path().join("ws.db");
104
105        // Initialize the DB via the core opener (creates schema).
106        let _ = Indexer::open(&db).expect("create db");
107
108        let state = AppState::new(db.clone());
109        let stats = state
110            .with_connection(|idx| idx.stats())
111            .await
112            .expect("with_connection");
113        assert_eq!(stats.entries, 0);
114        assert!(stats.tags.is_empty());
115    }
116
117    #[tokio::test]
118    async fn with_connection_errors_when_db_is_missing() {
119        let dir = tempfile::tempdir().unwrap();
120        let missing = dir.path().join("missing.db");
121
122        let state = AppState::new(missing);
123        let err = state
124            .with_connection(|idx| idx.stats())
125            .await
126            .expect_err("should fail when db missing");
127        // Open_read_only surfaces SQLite's "unable to open" as LogdiveError::Sqlite.
128        assert!(matches!(err, LogdiveError::Sqlite(_)));
129    }
130
131    #[tokio::test]
132    async fn with_connection_uses_read_only_connection() {
133        let dir = tempfile::tempdir().unwrap();
134        let db = dir.path().join("ro.db");
135        let _ = Indexer::open(&db).unwrap();
136
137        let state = AppState::new(db);
138        let result = state
139            .with_connection(|idx| {
140                // Try to write via the RO connection — must fail.
141                idx.connection()
142                    .execute(
143                        "INSERT INTO log_entries (timestamp, raw, raw_hash) \
144                         VALUES ('x', 'y', 'z')",
145                        [],
146                    )
147                    .map_err(LogdiveError::from)
148            })
149            .await;
150        assert!(result.is_err(), "expected RO write rejection");
151    }
152
153    #[tokio::test]
154    async fn with_connection_surfaces_panic_as_io_error() {
155        let dir = tempfile::tempdir().unwrap();
156        let db = dir.path().join("panic.db");
157        let _ = Indexer::open(&db).unwrap();
158
159        let state = AppState::new(db);
160        let err = state
161            .with_connection(|_idx| -> Result<()> { panic!("intentional test panic") })
162            .await
163            .expect_err("panic should propagate as error, not silent success");
164        assert!(matches!(err, LogdiveError::Io { .. }));
165    }
166}