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}