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}