feat(fana): fana-store — persistencia del grafo narrativo (sled)
- fana-core: NarrativeAtom + CoherenceState ahora Serialize/Deserialize
(serde con feature rc para el Arc<String>; uuid con feature serde).
- fana-graph: + atoms() iterator + from_atoms() constructor.
- fana-store: GraphStore sobre sled. put/get/remove_atom por Uuid,
serialización bincode. save_graph persiste átomo por átomo;
load_graph reconstruye el grafo (la adjacency se re-cablea desde las
dependencies de cada átomo).
7 tests verdes (roundtrip put/get/remove + save/load_graph preserva
estructura). cargo check --workspace verde.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6884b3f8cb5e27ab0a48870229a19c8bf50a18b8 · por sergio · 2026-05-20T15:43:01Z (hace 5 meses) · padre 353e0bbb4 · ver el árbol en este commit
Delta semántico · 16
crates/modules/fana/fana-graph/src/lib.rs
crates/modules/fana/fana-store/src/lib.rs
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<Item = &NarrativeAtom> {
+ self.nodes.values()
+ }
+
+ /// Construye un grafo desde una colección de átomos.
+ pub fn from_atoms(atoms: impl IntoIterator<Item = NarrativeAtom>) -> 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<std::path::Path>) -> Result<Self, StoreError> {
+ 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<Option<NarrativeAtom>, 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<NarrativeGraph, StoreError> {
+ let mut atoms = Vec::with_capacity(self.db.len());
+ for entry in self.db.iter() {
+ let (_, bytes) = entry?;
+ atoms.push(bincode::deserialize::<NarrativeAtom>(&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]);
+ }
+}