diff --git a/Cargo.lock b/Cargo.lock index fd43879..33895b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3949,6 +3949,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" name = "fana-core" version = "0.1.0" dependencies = [ + "serde", "sha2", "uuid", ] @@ -3979,6 +3980,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "fana-store" +version = "0.1.0" +dependencies = [ + "bincode", + "fana-core", + "fana-graph", + "sled", + "tempfile", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "fastembed" version = "4.9.1" diff --git a/Cargo.toml b/Cargo.toml index 404e504..f8277ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,7 @@ members = [ # ============================================================ "crates/modules/fana/fana-core", "crates/modules/fana/fana-graph", + "crates/modules/fana/fana-store", "crates/modules/fana/fana-md", "crates/modules/fana/fana-md-reader-web", diff --git a/crates/modules/fana/fana-core/Cargo.toml b/crates/modules/fana/fana-core/Cargo.toml index d77a82f..9cbd3d1 100644 --- a/crates/modules/fana/fana-core/Cargo.toml +++ b/crates/modules/fana/fana-core/Cargo.toml @@ -8,5 +8,6 @@ publish.workspace = true description = "fana — átomo narrativo (NarrativeAtom) + estado de coherencia. Tipos puros del editor DAG, agnósticos de UI." [dependencies] -uuid = { workspace = true } +uuid = { workspace = true, features = ["serde"] } sha2 = { workspace = true } +serde = { workspace = true, features = ["rc"] } diff --git a/crates/modules/fana/fana-core/src/lib.rs b/crates/modules/fana/fana-core/src/lib.rs index fb8e3bb..70c881a 100644 --- a/crates/modules/fana/fana-core/src/lib.rs +++ b/crates/modules/fana/fana-core/src/lib.rs @@ -10,12 +10,13 @@ #![forbid(unsafe_code)] +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; /// Estado de coherencia lógica de un átomo dentro del grafo narrativo. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum CoherenceState { /// Consistente con sus dependencias. Valid, @@ -26,7 +27,7 @@ pub enum CoherenceState { } /// Un átomo narrativo: la unidad atómica del documento. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct NarrativeAtom { pub id: Uuid, /// SHA-256 del contenido — verifica integridad de toda mutación. diff --git a/crates/modules/fana/fana-graph/src/lib.rs b/crates/modules/fana/fana-graph/src/lib.rs index d3f8bd7..8488f4f 100644 --- a/crates/modules/fana/fana-graph/src/lib.rs +++ b/crates/modules/fana/fana-graph/src/lib.rs @@ -45,6 +45,20 @@ impl NarrativeGraph { self.nodes.get_mut(&id) } + /// Itera todos los átomos del grafo (orden no determinista). + pub fn atoms(&self) -> impl Iterator { + self.nodes.values() + } + + /// Construye un grafo desde una colección de átomos. + pub fn from_atoms(atoms: impl IntoIterator) -> Self { + let mut g = Self::new(); + for a in atoms { + g.insert(a); + } + g + } + /// Inserta un átomo y conecta las aristas desde sus dependencias. pub fn insert(&mut self, atom: NarrativeAtom) { let id = atom.id; diff --git a/crates/modules/fana/fana-store/Cargo.toml b/crates/modules/fana/fana-store/Cargo.toml new file mode 100644 index 0000000..9c8bbf6 --- /dev/null +++ b/crates/modules/fana/fana-store/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fana-store" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "fana — persistencia del grafo narrativo en un store key-value embebido (sled), serializado con bincode." + +[dependencies] +fana-core = { path = "../fana-core" } +fana-graph = { path = "../fana-graph" } +sled = { workspace = true } +bincode = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/modules/fana/fana-store/src/lib.rs b/crates/modules/fana/fana-store/src/lib.rs new file mode 100644 index 0000000..e74d882 --- /dev/null +++ b/crates/modules/fana/fana-store/src/lib.rs @@ -0,0 +1,125 @@ +//! `fana-store` — persistencia del grafo narrativo. +//! +//! Store key-value embebido (`sled`): cada `NarrativeAtom` se guarda con +//! su `Uuid` como clave y su serialización `bincode` como valor. El +//! grafo completo se reconstruye leyendo todos los átomos y re-cableando +//! la adjacency desde sus `dependencies`. + +#![forbid(unsafe_code)] + +use fana_core::NarrativeAtom; +use fana_graph::NarrativeGraph; +use uuid::Uuid; + +/// Falla de una operación de store. +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("sled: {0}")] + Db(#[from] sled::Error), + #[error("serialización: {0}")] + Serde(#[from] bincode::Error), +} + +/// Store del grafo narrativo sobre sled. +pub struct GraphStore { + db: sled::Db, +} + +impl GraphStore { + /// Abre (o crea) el store en `path`. + pub fn open(path: impl AsRef) -> Result { + Ok(Self { db: sled::open(path)? }) + } + + /// Guarda (o reemplaza) un átomo. + pub fn put_atom(&self, atom: &NarrativeAtom) -> Result<(), StoreError> { + let bytes = bincode::serialize(atom)?; + self.db.insert(atom.id.as_bytes(), bytes)?; + Ok(()) + } + + /// Lee un átomo por id. + pub fn get_atom(&self, id: Uuid) -> Result, StoreError> { + match self.db.get(id.as_bytes())? { + Some(bytes) => Ok(Some(bincode::deserialize(&bytes)?)), + None => Ok(None), + } + } + + /// Elimina un átomo. + pub fn remove_atom(&self, id: Uuid) -> Result<(), StoreError> { + self.db.remove(id.as_bytes())?; + Ok(()) + } + + /// Cantidad de átomos persistidos. + pub fn len(&self) -> usize { + self.db.len() + } + + pub fn is_empty(&self) -> bool { + self.db.is_empty() + } + + /// Persiste el grafo completo (un `put` por átomo). + pub fn save_graph(&self, graph: &NarrativeGraph) -> Result<(), StoreError> { + for atom in graph.atoms() { + self.put_atom(atom)?; + } + self.db.flush()?; + Ok(()) + } + + /// Reconstruye el grafo leyendo todos los átomos persistidos. + pub fn load_graph(&self) -> Result { + let mut atoms = Vec::with_capacity(self.db.len()); + for entry in self.db.iter() { + let (_, bytes) = entry?; + atoms.push(bincode::deserialize::(&bytes)?); + } + Ok(NarrativeGraph::from_atoms(atoms)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_store() -> (GraphStore, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let store = GraphStore::open(dir.path().join("fana.sled")).unwrap(); + (store, dir) + } + + #[test] + fn put_get_remove_roundtrip() { + let (store, _d) = temp_store(); + let atom = NarrativeAtom::new("capítulo uno", "main"); + let id = atom.id; + store.put_atom(&atom).unwrap(); + let loaded = store.get_atom(id).unwrap().expect("debe existir"); + assert_eq!(loaded.id, id); + assert_eq!(*loaded.content, *atom.content); + assert!(loaded.hash_matches()); + store.remove_atom(id).unwrap(); + assert!(store.get_atom(id).unwrap().is_none()); + } + + #[test] + fn save_and_load_graph_preserves_structure() { + let (store, _d) = temp_store(); + let a = NarrativeAtom::new("a", "main"); + let b = NarrativeAtom::new("b", "main").depends_on(a.id); + let (a_id, b_id) = (a.id, b.id); + let mut g = NarrativeGraph::new(); + g.insert(a); + g.insert(b); + store.save_graph(&g).unwrap(); + + let reloaded = store.load_graph().unwrap(); + assert_eq!(reloaded.len(), 2); + assert!(reloaded.contains(a_id) && reloaded.contains(b_id)); + // La adjacency se reconstruye desde las dependencies. + assert_eq!(reloaded.dependents(a_id), &[b_id]); + } +}