//! DB de Mónadas y archivos. Backend dual: //! //! - **Memoria** (default, cache): `BTreeMap` para reads O(log n). //! - **Persistencia** (opcional): sled-backed write-through. Si se abre //! con `MonadDb::open(path)`, cada `insert_*` escribe a sled además //! de la cache. Reads siempre vienen de la cache. //! //! Wire format: JSON via serde_json. Los manifestos son chicos y //! ocasionalmente inspeccionables a mano (`sled-cli`); JSON gana sobre //! postcard en debuggability. use std::collections::BTreeMap; use std::path::Path; use chasqui_card::{FileEntry, FileId, MonadId, MonadManifest}; use thiserror::Error; #[derive(Debug, Error)] pub enum MonadDbError { #[error("sled: {0}")] Sled(#[from] sled::Error), #[error("JSON: {0}")] Json(#[from] serde_json::Error), #[error("ULID inválido en clave: {0}")] BadKey(String), } const TREE_FILES: &str = "files"; const TREE_MONADS: &str = "monads"; /// Store de Mónadas + archivos. Cache en memoria + persistencia /// opcional sled. pub struct MonadDb { files: BTreeMap, monads: BTreeMap, persistence: Option, } impl Default for MonadDb { fn default() -> Self { Self::new() } } impl MonadDb { /// Store en memoria pura (sin persistencia). El estado se pierde al salir. pub fn new() -> Self { Self { files: BTreeMap::new(), monads: BTreeMap::new(), persistence: None, } } /// Abre (o crea) un store sled-backed en `path`. Carga el contenido /// existente a la cache antes de devolver. pub fn open(path: impl AsRef) -> Result { let db = sled::open(path)?; let mut files = BTreeMap::new(); let mut monads = BTreeMap::new(); let files_tree = db.open_tree(TREE_FILES)?; for kv in files_tree.iter() { let (k, v) = kv?; let id = decode_key(&k)?; let entry: FileEntry = serde_json::from_slice(&v)?; files.insert(id, entry); } let monads_tree = db.open_tree(TREE_MONADS)?; for kv in monads_tree.iter() { let (k, v) = kv?; let id = decode_key(&k)?; let monad: MonadManifest = serde_json::from_slice(&v)?; monads.insert(id, monad); } Ok(Self { files, monads, persistence: Some(db), }) } /// `true` si tiene backend persistente. pub fn is_persistent(&self) -> bool { self.persistence.is_some() } // ---- Files ---- pub fn insert_file(&mut self, file: FileEntry) -> Option { if let Some(db) = &self.persistence { // Write-through: si falla el persist, lo logeamos pero la // memoria queda actualizada. Filosofía: cache nunca miente // sobre el último estado conocido en este proceso. if let Err(e) = persist_file(db, &file) { eprintln!("[MonadDb] persist file falló: {e}"); } } self.files.insert(file.id, file) } pub fn ingest_files(&mut self, files: Vec) { for f in files { self.insert_file(f); } } pub fn file(&self, id: FileId) -> Option<&FileEntry> { self.files.get(&id) } pub fn files(&self) -> impl Iterator + '_ { self.files.values() } pub fn file_count(&self) -> usize { self.files.len() } // ---- Monads ---- pub fn insert_monad(&mut self, monad: MonadManifest) -> Option { if let Some(db) = &self.persistence { if let Err(e) = persist_monad(db, &monad) { eprintln!("[MonadDb] persist monad falló: {e}"); } } self.monads.insert(monad.id, monad) } pub fn replace_monads(&mut self, monads: Vec) { // Si hay persistencia, limpiar tree antes de insertar. if let Some(db) = &self.persistence { if let Ok(tree) = db.open_tree(TREE_MONADS) { let _ = tree.clear(); } } self.monads.clear(); for m in monads { self.insert_monad(m); } } pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> { self.monads.get(&id) } pub fn monads(&self) -> impl Iterator + '_ { self.monads.values() } pub fn monad_count(&self) -> usize { self.monads.len() } /// Resuelve los archivos miembros de una Mónada como referencias. /// Skipea silenciosamente IDs que ya no estén en la tabla `files`. pub fn resolve_members(&self, monad_id: MonadId) -> Vec<&FileEntry> { match self.monads.get(&monad_id) { Some(m) => m.members.iter().filter_map(|id| self.files.get(id)).collect(), None => Vec::new(), } } } fn persist_file(db: &sled::Db, f: &FileEntry) -> Result<(), MonadDbError> { let tree = db.open_tree(TREE_FILES)?; let key = f.id.to_string(); let val = serde_json::to_vec(f)?; tree.insert(key.as_bytes(), val)?; Ok(()) } fn persist_monad(db: &sled::Db, m: &MonadManifest) -> Result<(), MonadDbError> { let tree = db.open_tree(TREE_MONADS)?; let key = m.id.to_string(); let val = serde_json::to_vec(m)?; tree.insert(key.as_bytes(), val)?; Ok(()) } fn decode_key(k: &[u8]) -> Result { let s = std::str::from_utf8(k).map_err(|_| MonadDbError::BadKey(format!("{:?}", k)))?; ulid::Ulid::from_string(s).map_err(|_| MonadDbError::BadKey(s.to_string())) } #[cfg(test)] mod tests { use super::*; use chasqui_card::Lens; use ulid::Ulid; fn mk_file(path: &str) -> FileEntry { FileEntry { id: FileId::from(Ulid::new()), path: std::path::PathBuf::from(path), content_hash: None, size: 100, mtime_ms: 0, extension: Some("rs".into()), } } #[test] fn ingest_and_lookup() { let mut db = MonadDb::new(); let f1 = mk_file("/a/x.rs"); let f2 = mk_file("/a/y.rs"); let id1 = f1.id; db.ingest_files(vec![f1, f2]); assert_eq!(db.file_count(), 2); assert!(db.file(id1).is_some()); assert!(!db.is_persistent()); } #[test] fn resolve_members_filters_missing() { let mut db = MonadDb::new(); let f1 = mk_file("/x/a.rs"); let id1 = f1.id; db.insert_file(f1); let mut m = MonadManifest::new("test"); m.members.insert(id1); m.members.insert(FileId::from(Ulid::new())); // miembro fantasma m.dominant_lens = Lens::Code; m.touch(); let mid = m.id; db.insert_monad(m); let resolved = db.resolve_members(mid); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].id, id1); } #[test] fn replace_monads_clears_old() { let mut db = MonadDb::new(); let mut m1 = MonadManifest::new("a"); m1.members.insert(FileId::from(Ulid::new())); m1.touch(); db.insert_monad(m1); assert_eq!(db.monad_count(), 1); let mut m2 = MonadManifest::new("b"); m2.members.insert(FileId::from(Ulid::new())); m2.touch(); db.replace_monads(vec![m2]); assert_eq!(db.monad_count(), 1); assert!(db.monads().next().unwrap().label == "b"); } #[test] fn persistence_roundtrip() { let tmp = tempfile::tempdir().unwrap(); let dbpath = tmp.path().join("monads.sled"); // Escribimos algunos datos { let mut db = MonadDb::open(&dbpath).expect("open"); assert!(db.is_persistent()); let f = mk_file("/persist/a.rs"); let fid = f.id; db.insert_file(f); let mut m = MonadManifest::new("persist-test"); m.members.insert(fid); m.dominant_lens = Lens::Code; m.touch(); db.insert_monad(m); } // Reabrimos y verificamos que están let db = MonadDb::open(&dbpath).expect("reopen"); assert_eq!(db.file_count(), 1); assert_eq!(db.monad_count(), 1); let m = db.monads().next().unwrap(); assert_eq!(m.label, "persist-test"); assert_eq!(m.cardinality, 1); } #[test] fn replace_monads_purges_persistent_tree() { let tmp = tempfile::tempdir().unwrap(); let dbpath = tmp.path().join("replace.sled"); { let mut db = MonadDb::open(&dbpath).unwrap(); let mut m1 = MonadManifest::new("old"); m1.members.insert(FileId::from(Ulid::new())); m1.touch(); db.insert_monad(m1); } // Reabrir, replace, verificar { let mut db = MonadDb::open(&dbpath).unwrap(); assert_eq!(db.monad_count(), 1); let mut m2 = MonadManifest::new("new"); m2.members.insert(FileId::from(Ulid::new())); m2.touch(); db.replace_monads(vec![m2]); assert_eq!(db.monad_count(), 1); } // Tercera apertura: sólo "new" sobrevive let db = MonadDb::open(&dbpath).unwrap(); assert_eq!(db.monad_count(), 1); assert_eq!(db.monads().next().unwrap().label, "new"); } }