gioser

tawasuyu/brahman

Rust 467TOML 184Markdown 49JSON 176411 Definitions

feat(nouser): persistencia sled write-through del MonadDb

MonadDb ahora soporta backend dual:
- MonadDb::new() → memoria pura (default, back-compat).
- MonadDb::open(path) → sled-backed con cache en memoria. Carga
  contenido existente al abrir; cada insert_* hace write-through
  (cache + sled).

Diseño:
- 2 trees sled: files y monads.
- Wire format: serde_json (ergonomía + inspectability con sled-cli;
  los manifests son chicos, JSON gana sobre postcard aquí).
- Reads SIEMPRE desde la cache — sled se consulta sólo al abrir.
- replace_monads() purga el tree de sled antes de escribir.

Bin nouser: nueva env var NOUSER_DB_PATH. Si está set, persiste; si
no, in-memory:

  $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core
  scan: 102 archivos, 5 mónadas
  $ ls /tmp/monads.sled
  blobs  conf

Tests nuevos en db.rs:
- persistence_roundtrip — escribe, cierra, reabre, datos están.
- replace_monads_purges_persistent_tree — replace limpia tree.

24 tests en nouser-core (era 22, +2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

7831c0c827134e4753a9631765ab76a3fb3b63ba · by Sergio · (5 months ago) · parent d7b488616 · browse the tree at this commit

Semantic delta · 21

crates/modules/nouser/core/src/bin/nouser.rs

crates/modules/nouser/core/src/db.rs

5 files · +220 −12

diff --git a/CHANGELOG.md b/CHANGELOG.md
index edc6c5c..2d42f6d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,37 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-08
 
+### feat(nouser): persistencia sled write-through del MonadDb
+`MonadDb` ahora soporta backend dual:
+
+- `MonadDb::new()` → memoria pura (default, back-compat).
+- `MonadDb::open(path)` → sled-backed con cache en memoria. Carga
+  contenido existente al abrir; cada `insert_*` hace write-through
+  (cache + sled).
+
+Diseño:
+- 2 trees sled: `files` y `monads`.
+- Wire format: serde_json (ergonomía + inspectability con sled-cli;
+  los manifests son chicos, JSON gana sobre postcard aquí).
+- Reads SIEMPRE desde la cache — sled se consulta sólo al abrir.
+- `replace_monads()` purga el tree de sled antes de escribir.
+
+Bin nouser: nueva env var `NOUSER_DB_PATH`. Si está set, persiste
+en esa ruta; si no, in-memory:
+
+  $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core
+  scan: 102 archivos en crates/core, 5 mónadas
+  $ ls /tmp/monads.sled
+  blobs  conf
+  $ NOUSER_DB_PATH=/tmp/monads.sled nouser scan crates/core
+  # segunda corrida re-escribe la DB con el nuevo scan
+
+Tests nuevos en db.rs:
+- `persistence_roundtrip` — escribe, cierra, reabre, datos están.
+- `replace_monads_purges_persistent_tree` — replace limpia el tree.
+
+24 tests en nouser-core (era 22, +2).
+
 ### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime
 Antes: cada `spawn(card)` creaba un thread + tokio runtime propio.
 Para módulos que publican muchas sesiones (nouser daemon con 50+
diff --git a/Cargo.lock b/Cargo.lock
index de58a10..180c34c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6367,6 +6367,7 @@ dependencies = [
  "nouser-nous",
  "serde",
  "serde_json",
+ "sled",
  "tempfile",
  "thiserror 2.0.18",
  "ulid",
diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml
index 6245607..eb89103 100644
--- a/crates/modules/nouser/core/Cargo.toml
+++ b/crates/modules/nouser/core/Cargo.toml
@@ -16,6 +16,7 @@ brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
 blake3 = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
+sled = { workspace = true }
 thiserror = { workspace = true }
 ulid = { workspace = true }
 walkdir = "2"
diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs
index 7e648a2..b27b9d8 100644
--- a/crates/modules/nouser/core/src/bin/nouser.rs
+++ b/crates/modules/nouser/core/src/bin/nouser.rs
@@ -68,6 +68,7 @@ fn print_usage(prog: &str) {
     eprintln!();
     eprintln!("env:");
     eprintln!("  NOUSER_MIN_FILES         mínimo de archivos por Mónada (default: 3)");
+    eprintln!("  NOUSER_DB_PATH           si está set, abre sled en esa ruta (persistencia)");
     eprintln!("  BRAHMAN_INIT_SOCKET      socket del Init (heredado de brahman-handshake)");
 }
 
@@ -89,12 +90,22 @@ fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::E
     let files = scanner::scan_directory(dir, &ScanConfig::default())?;
     let n_files = files.len();
     let monads = cluster::by_directory(&files, min_files());
-    let mut db = db::MonadDb::new();
+    let mut db = open_db()?;
     db.ingest_files(files);
     db.replace_monads(monads);
     Ok((db, n_files))
 }
 
+/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
+/// si no, store en memoria.
+fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
+    if let Ok(path) = std::env::var("NOUSER_DB_PATH") {
+        Ok(db::MonadDb::open(&path)?)
+    } else {
+        Ok(db::MonadDb::new())
+    }
+}
+
 fn cmd_scan(args: &[String]) -> Cmd {
     let dir = require_dir(args)?;
     let (db, n_files) = run_scan(&dir)?;
diff --git a/crates/modules/nouser/core/src/db.rs b/crates/modules/nouser/core/src/db.rs
index 1d76023..f1997aa 100644
--- a/crates/modules/nouser/core/src/db.rs
+++ b/crates/modules/nouser/core/src/db.rs
@@ -1,35 +1,108 @@
-//! DB en memoria de Mónadas y archivos.
+//! DB de Mónadas y archivos. Backend dual:
 //!
-//! Phase A: store volátil con índices `BTreeMap<Id, Manifest>` para
-//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un
-//! índice `file_id → Vec<monad_id>` (membership).
+//! - **Memoria** (default, cache): `BTreeMap<Id, T>` 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 nouser_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),
+}
 
-/// Store de Mónadas + archivos. Operaciones lock-free (mut & por
-/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`.
-#[derive(Debug, Default)]
+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<FileId, FileEntry>,
     monads: BTreeMap<MonadId, MonadManifest>,
+    persistence: Option<sled::Db>,
+}
+
+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::default()
+        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<Path>) -> Result<Self, MonadDbError> {
+        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<FileEntry> {
+        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<FileEntry>) {
         for f in files {
-            self.files.insert(f.id, f);
+            self.insert_file(f);
         }
     }
 
@@ -48,13 +121,24 @@ impl MonadDb {
     // ---- Monads ----
 
     pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> {
+        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<MonadManifest>) {
+        // 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.monads.insert(m.id, m);
+            self.insert_monad(m);
         }
     }
 
@@ -80,6 +164,27 @@ impl MonadDb {
     }
 }
 
+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<ulid::Ulid, MonadDbError> {
+    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::*;
@@ -106,6 +211,7 @@ mod tests {
         db.ingest_files(vec![f1, f2]);
         assert_eq!(db.file_count(), 2);
         assert!(db.file(id1).is_some());
+        assert!(!db.is_persistent());
     }
 
     #[test]
@@ -125,7 +231,6 @@ mod tests {
         db.insert_monad(m);
 
         let resolved = db.resolve_members(mid);
-        // Sólo el archivo realmente presente en files.
         assert_eq!(resolved.len(), 1);
         assert_eq!(resolved[0].id, id1);
     }
@@ -146,4 +251,63 @@ mod tests {
         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");
+    }
 }