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>
This commit is contained in:
sergio
2026-05-20 15:43:01 +00:00
parent 353e0bbb43
commit 6884b3f8cb
7 changed files with 178 additions and 3 deletions
Generated
+14
View File
@@ -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"
+1
View File
@@ -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",
+2 -1
View File
@@ -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"] }
+3 -2
View File
@@ -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.
+14
View File
@@ -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;
+19
View File
@@ -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 }
+125
View File
@@ -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]);
}
}