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:
Generated
+14
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user