Skip to main content

larust_db/
lib.rs

1//! Larust's own database admin dashboard (`/xr-db`, `dashboard/` module) -
2//! a phpMyAdmin/Adminer-style tool built into the framework itself: browse
3//! and edit the app's *actual* SQL database (whatever `DB_CONNECTION` is
4//! configured - SQLite/MySQL/Postgres via `larust_orm::AnyPool`; see
5//! `sql/` for the schema-introspection/generic-row engine behind it), run
6//! raw SQL, all from a browser during development. This is the primary
7//! reason this crate exists.
8//!
9//! **Also an embedded, pure-Rust key-value store** (this module - wraps
10//! [`redb`], single-file, MVCC, zero C dependencies), reachable from the
11//! same dashboard's secondary "Key-Value" section. Named `db` (not `kv`)
12//! for the `xr new` wizard/CLI/feature surface deliberately - a developer
13//! scanning feature names recognizes "db" instantly. **Additive, not a
14//! second SQL backend**: `#[derive(Model)]` generates literal SQL and
15//! requires `sqlx::FromRow` - a KV store has no columns to decode, so it
16//! structurally cannot plug into that macro, `larust_repository`'s
17//! relations, or `QueryBuilder`. Every real model (`users`, `posts`, ...)
18//! lives in the SQL database, which the dashboard's primary section
19//! browses directly - the KV store is only ever for app-local data that
20//! never needed relations in the first place (feature flags, small local
21//! caches, offline queues, embedded config); the same posture
22//! `larust_cache` already has for its own SQLite-backed store.
23//!
24//! **The KV store has no network port, no server process, ever.** `redb`
25//! is an in-process embedded library, the same way `rusqlite`/
26//! `sqlx-sqlite` are for SQLite - `connect()` just opens a plain file on
27//! disk (`database/db.redb` by default) directly inside the app's own
28//! process. There is nothing to start, stop, or point a connection
29//! string's host/port at. The dashboard itself is likewise just a route
30//! on the app's *own* existing HTTP server, not a second listener - it
31//! shares whatever port `APP_PORT` already binds, for both its SQL and KV
32//! sections alike; only the SQL side additionally talks to whatever real
33//! database server the app itself is already configured to use.
34//!
35//! Gated behind `larust-support`'s `db` feature - re-exported through
36//! `larust_support::db`, never depended on directly by a generated app -
37//! selectable via `xr new`'s wizard. See `docs/ARCHITECTURE.md`'s
38//! "Embedded key-value store" section for the full design, and
39//! `dashboard/mod.rs`'s own doc comment for the dashboard itself.
40
41mod dashboard;
42pub mod sql;
43
44pub use dashboard::DbPlugin;
45
46use larust_core::AppError;
47use redb::{Database, ReadableTable, TableDefinition};
48use serde::de::DeserializeOwned;
49use serde::Serialize;
50use std::path::Path;
51use std::sync::{Arc, OnceLock};
52
53const TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("kv");
54
55static DATABASE: OnceLock<Arc<Database>> = OnceLock::new();
56
57/// Opens (creating if missing) the embedded store at `path` and stores it
58/// process-wide - same `OnceLock` singleton discipline as
59/// `larust_orm::connect()` ([`crates/larust-orm/src/pool.rs`]), for the
60/// same reason: an embedded engine's own file lock means only one
61/// `Database` handle per process per file. Call once at startup, before
62/// any other function in this module.
63pub async fn connect(path: impl AsRef<Path>) -> Result<(), AppError> {
64    let path = path.as_ref().to_path_buf();
65    let db = tokio::task::spawn_blocking(move || -> Result<Database, AppError> {
66        if let Some(parent) = path.parent() {
67            if !parent.as_os_str().is_empty() {
68                std::fs::create_dir_all(parent).map_err(internal)?;
69            }
70        }
71        let db = Database::create(&path).map_err(internal)?;
72        // Opening a table on a write transaction creates it if it doesn't
73        // exist yet - done once here so every later `get`/`keys` (which
74        // only ever open a *read* transaction, where opening a
75        // still-missing table is an error, not an empty result) can rely
76        // on the table always already existing.
77        let txn = db.begin_write().map_err(internal)?;
78        {
79            txn.open_table(TABLE).map_err(internal)?;
80        }
81        txn.commit().map_err(internal)?;
82        Ok(db)
83    })
84    .await
85    .map_err(internal)??;
86
87    DATABASE.set(Arc::new(db)).map_err(|_| {
88        AppError::Internal(Box::new(std::io::Error::other(
89            "connect() called more than once",
90        )))
91    })
92}
93
94fn store() -> Result<Arc<Database>, AppError> {
95    DATABASE.get().cloned().ok_or_else(|| {
96        AppError::Internal(Box::new(std::io::Error::other(
97            "embedded db not connected; call larust_db::connect() (via \
98             larust_support::db::connect) at startup before using it",
99        )))
100    })
101}
102
103/// Stores `value` (JSON-serialized) under `key`, overwriting any existing
104/// value.
105pub async fn put<T: Serialize>(key: &str, value: &T) -> Result<(), AppError> {
106    let value = serde_json::to_value(value).map_err(internal)?;
107    put_raw(key, value).await
108}
109
110/// Reads the value stored under `key`, if any, deserialized as `T`.
111pub async fn get<T: DeserializeOwned>(key: &str) -> Result<Option<T>, AppError> {
112    match get_raw(key).await? {
113        Some(value) => Ok(Some(serde_json::from_value(value).map_err(internal)?)),
114        None => Ok(None),
115    }
116}
117
118/// Removes `key`, if present. A no-op (not an error) if it isn't.
119pub async fn forget(key: &str) -> Result<(), AppError> {
120    let db = store()?;
121    let key = key.to_string();
122    tokio::task::spawn_blocking(move || {
123        let txn = db.begin_write().map_err(internal)?;
124        {
125            let mut table = txn.open_table(TABLE).map_err(internal)?;
126            table.remove(key.as_str()).map_err(internal)?;
127        }
128        txn.commit().map_err(internal)?;
129        Ok(())
130    })
131    .await
132    .map_err(internal)?
133}
134
135/// Every key currently stored, in no particular order. A dev/inspection
136/// tool (the CLI browser, the dashboard) - not meant for a hot request
137/// path in a store with a large key count.
138pub async fn keys() -> Result<Vec<String>, AppError> {
139    let db = store()?;
140    tokio::task::spawn_blocking(move || {
141        let txn = db.begin_read().map_err(internal)?;
142        let table = txn.open_table(TABLE).map_err(internal)?;
143        let mut out = Vec::new();
144        for entry in table.iter().map_err(internal)? {
145            let (k, _v) = entry.map_err(internal)?;
146            out.push(k.value().to_string());
147        }
148        Ok(out)
149    })
150    .await
151    .map_err(internal)?
152}
153
154/// Untyped read - what the CLI browser and dashboard use, since they don't
155/// know any app's Rust types at compile time. [`get`] is a thin
156/// `serde_json::from_value` wrapper around this.
157pub async fn get_raw(key: &str) -> Result<Option<serde_json::Value>, AppError> {
158    let db = store()?;
159    let key = key.to_string();
160    tokio::task::spawn_blocking(move || {
161        let txn = db.begin_read().map_err(internal)?;
162        let table = txn.open_table(TABLE).map_err(internal)?;
163        match table.get(key.as_str()).map_err(internal)? {
164            Some(value) => Ok(Some(
165                serde_json::from_slice(value.value()).map_err(internal)?,
166            )),
167            None => Ok(None),
168        }
169    })
170    .await
171    .map_err(internal)?
172}
173
174/// Untyped write - what the CLI browser and dashboard use. [`put`] is a
175/// thin `serde_json::to_value` wrapper around this.
176pub async fn put_raw(key: &str, value: serde_json::Value) -> Result<(), AppError> {
177    let db = store()?;
178    let key = key.to_string();
179    tokio::task::spawn_blocking(move || {
180        let bytes = serde_json::to_vec(&value).map_err(internal)?;
181        let txn = db.begin_write().map_err(internal)?;
182        {
183            let mut table = txn.open_table(TABLE).map_err(internal)?;
184            table
185                .insert(key.as_str(), bytes.as_slice())
186                .map_err(internal)?;
187        }
188        txn.commit().map_err(internal)?;
189        Ok(())
190    })
191    .await
192    .map_err(internal)?
193}
194
195/// Parses a CLI argument into a JSON value for [`put_raw`] - tries real
196/// JSON first (`42` -> a number, `"Alice"` -> a string, `true` -> a bool),
197/// falling back to storing the raw text as a JSON string so `xr db:put
198/// name Alice` works without shell-quoting a JSON string.
199pub fn parse_cli_value(raw: &str) -> serde_json::Value {
200    serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
201}
202
203fn internal<E: std::error::Error + Send + Sync + 'static>(error: E) -> AppError {
204    AppError::Internal(Box::new(error))
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use serde::Deserialize;
211
212    #[derive(Debug, Serialize, Deserialize, PartialEq)]
213    struct Item {
214        name: String,
215        count: u32,
216    }
217
218    // `DATABASE` is a process-wide `OnceLock` (mirroring
219    // `larust_orm::connect()`'s own singleton discipline - see this
220    // module's doc comment) - a second `connect()` call in the same test
221    // binary errors "connect() called more than once", and `cargo test`
222    // runs every `#[tokio::test]` in one process. So every scenario below
223    // runs from this one test function, the same convention every
224    // DB-touching test file in this codebase already follows
225    // (`examples/repository_bench`, `larust-orm`'s own tests, ...).
226    #[tokio::test]
227    async fn store_behaves_correctly_across_every_scenario() {
228        let dir = tempfile::tempdir().unwrap();
229        connect(dir.path().join("test.redb")).await.unwrap();
230
231        // put/get/forget/keys round trip.
232        assert_eq!(get::<Item>("missing").await.unwrap(), None);
233        assert_eq!(keys().await.unwrap(), Vec::<String>::new());
234
235        let item = Item {
236            name: "widget".to_string(),
237            count: 3,
238        };
239        put("item", &item).await.unwrap();
240        assert_eq!(get::<Item>("item").await.unwrap(), Some(item));
241        assert_eq!(keys().await.unwrap(), vec!["item".to_string()]);
242
243        forget("item").await.unwrap();
244        assert_eq!(get::<Item>("item").await.unwrap(), None);
245        assert_eq!(keys().await.unwrap(), Vec::<String>::new());
246
247        // Forgetting a missing key is not an error.
248        forget("nope").await.unwrap();
249
250        // put overwrites an existing value.
251        put("count", &1).await.unwrap();
252        put("count", &2).await.unwrap();
253        assert_eq!(get::<i64>("count").await.unwrap(), Some(2));
254
255        // Raw variants round-trip arbitrary JSON.
256        put_raw("raw", serde_json::json!({"a": 1, "b": [true, null]}))
257            .await
258            .unwrap();
259        assert_eq!(
260            get_raw("raw").await.unwrap(),
261            Some(serde_json::json!({"a": 1, "b": [true, null]}))
262        );
263
264        // A second connect() call is rejected, not silently accepted.
265        let second = tempfile::tempdir().unwrap();
266        let err = connect(second.path().join("other.redb")).await.unwrap_err();
267        assert!(err.to_string().contains("more than once"));
268    }
269
270    #[test]
271    fn parse_cli_value_parses_real_json() {
272        assert_eq!(parse_cli_value("42"), serde_json::json!(42));
273        assert_eq!(parse_cli_value("true"), serde_json::json!(true));
274        assert_eq!(parse_cli_value("\"Alice\""), serde_json::json!("Alice"));
275    }
276
277    #[test]
278    fn parse_cli_value_falls_back_to_a_plain_string() {
279        assert_eq!(parse_cli_value("Alice"), serde_json::json!("Alice"));
280    }
281}