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"
|
name = "fana-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -3979,6 +3980,19 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fana-store"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
|
"fana-core",
|
||||||
|
"fana-graph",
|
||||||
|
"sled",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastembed"
|
name = "fastembed"
|
||||||
version = "4.9.1"
|
version = "4.9.1"
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ members = [
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
"crates/modules/fana/fana-core",
|
"crates/modules/fana/fana-core",
|
||||||
"crates/modules/fana/fana-graph",
|
"crates/modules/fana/fana-graph",
|
||||||
|
"crates/modules/fana/fana-store",
|
||||||
"crates/modules/fana/fana-md",
|
"crates/modules/fana/fana-md",
|
||||||
"crates/modules/fana/fana-md-reader-web",
|
"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."
|
description = "fana — átomo narrativo (NarrativeAtom) + estado de coherencia. Tipos puros del editor DAG, agnósticos de UI."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true, features = ["serde"] }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["rc"] }
|
||||||
|
|||||||
@@ -10,12 +10,13 @@
|
|||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Estado de coherencia lógica de un átomo dentro del grafo narrativo.
|
/// 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 {
|
pub enum CoherenceState {
|
||||||
/// Consistente con sus dependencias.
|
/// Consistente con sus dependencias.
|
||||||
Valid,
|
Valid,
|
||||||
@@ -26,7 +27,7 @@ pub enum CoherenceState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Un átomo narrativo: la unidad atómica del documento.
|
/// Un átomo narrativo: la unidad atómica del documento.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NarrativeAtom {
|
pub struct NarrativeAtom {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
/// SHA-256 del contenido — verifica integridad de toda mutación.
|
/// SHA-256 del contenido — verifica integridad de toda mutación.
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ impl NarrativeGraph {
|
|||||||
self.nodes.get_mut(&id)
|
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.
|
/// Inserta un átomo y conecta las aristas desde sus dependencias.
|
||||||
pub fn insert(&mut self, atom: NarrativeAtom) {
|
pub fn insert(&mut self, atom: NarrativeAtom) {
|
||||||
let id = atom.id;
|
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