use std::collections::{BTreeMap, HashSet};
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use super::jsonio;
use super::model::Session;
const SESSIONS: TableDefinition<&str, &[u8]> = TableDefinition::new("sessions");
pub struct StoreDb {
db: Database,
}
impl StoreDb {
pub fn open(port: u16) -> Result<StoreDb, String> {
Self::open_path(&super::paths::store_db_file(port))
}
pub fn open_path(path: &std::path::Path) -> Result<StoreDb, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let db = Database::create(path).map_err(|e| format!("open redb {}: {e}", path.display()))?;
let txn = db.begin_write().map_err(|e| e.to_string())?;
{
let _ = txn.open_table(SESSIONS).map_err(|e| e.to_string())?;
}
txn.commit().map_err(|e| e.to_string())?;
Ok(StoreDb { db })
}
pub fn load_sessions(&self) -> BTreeMap<String, Session> {
let mut out = BTreeMap::new();
let Ok(txn) = self.db.begin_read() else { return out };
let Ok(table) = txn.open_table(SESSIONS) else { return out };
let Ok(iter) = table.iter() else { return out };
for (k, v) in iter.flatten() {
if let Ok(session) = jsonio::parse_session_json(&String::from_utf8_lossy(v.value())) {
out.insert(k.value().to_string(), session);
}
}
out
}
pub fn sync(&self, dirty: &[(String, String)], keep: &HashSet<String>) -> Result<(), String> {
let txn = self.db.begin_write().map_err(|e| e.to_string())?;
{
let mut table = txn.open_table(SESSIONS).map_err(|e| e.to_string())?;
for (id, json) in dirty {
table.insert(id.as_str(), json.as_bytes()).map_err(|e| e.to_string())?;
}
let orphans: Vec<String> = {
let iter = table.iter().map_err(|e| e.to_string())?;
iter.flatten().map(|(k, _)| k.value().to_string()).filter(|k| !keep.contains(k)).collect()
};
for id in orphans {
table.remove(id.as_str()).map_err(|e| e.to_string())?;
}
}
txn.commit().map_err(|e| e.to_string())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::model::{DaemonMode, ProcKind, ProcRecord, ProcStatus, Session, Store};
fn session(id: &str) -> Session {
Session {
id: id.into(),
started_at: 1,
ended_at: None,
profile: Some("default".into()),
repo: "/r".into(),
branch: "main".into(),
skills: Vec::new(),
procs: vec![ProcRecord {
index: 0,
label: "s".into(),
kind: ProcKind::Skill,
status: ProcStatus::Ok,
skill_name: None,
harness: None,
model: None,
started_at: Some(1),
note: None,
detail: None,
fail_reason: None,
elapsed: Some(1.0),
lines: Vec::new(),
container_name: None,
cast_path: None,
}],
last_seen_at: 1,
client_connected: false,
}
}
fn temp_db_path() -> std::path::PathBuf {
std::env::temp_dir().join(format!("scsh-db-test-{}.redb", crate::runtime::random_nonce_6()))
}
fn ids(list: &[&str]) -> HashSet<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn sync_persists_deletes_and_reloads() {
let path = temp_db_path();
let db = StoreDb::open_path(&path).unwrap();
let dirty = vec![
("aaaaaa".to_string(), jsonio::session_json_api(&session("aaaaaa"))),
("bbbbbb".to_string(), jsonio::session_json_api(&session("bbbbbb"))),
];
db.sync(&dirty, &ids(&["aaaaaa", "bbbbbb"])).unwrap();
let loaded = db.load_sessions();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded["aaaaaa"].procs[0].elapsed, Some(1.0));
db.sync(&[], &ids(&["aaaaaa"])).unwrap();
let loaded = db.load_sessions();
assert_eq!(loaded.len(), 1);
assert!(loaded.contains_key("aaaaaa") && !loaded.contains_key("bbbbbb"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn reopen_sees_prior_writes() {
let path = temp_db_path();
{
let db = StoreDb::open_path(&path).unwrap();
db.sync(&[("persb".to_string(), jsonio::session_json_api(&session("persb")))], &ids(&["persb"])).unwrap();
} let reopened = StoreDb::open_path(&path).unwrap();
let store = Store { sessions: reopened.load_sessions(), ..Store::new(DaemonMode::Persistent, 7274, 0) };
assert_eq!(store.sessions.len(), 1);
assert_eq!(store.sessions["persb"].branch, "main");
drop(reopened);
let _ = std::fs::remove_file(&path);
}
}