// Append-only Cap'n Proto storage + redb indices // // capnp logs are the source of truth: // nodes.capnp - ContentNode messages // relations.capnp - Relation messages // // redb provides indexed access; Store struct holds in-memory state. // // Module layout: // types.rs — Node, Relation, enums, path/time helpers // capnp.rs — serialization macros, log IO (load, replay, append, fsck) // index.rs — redb index operations // ops.rs — mutations (upsert, delete, rename, etc.) // view.rs — StoreView trait for read-only access mod types; mod index; mod capnp; mod ops; mod view; // Re-export everything callers need pub use types::{ memory_dir, nodes_path, now_epoch, epoch_to_local, format_date, format_datetime, format_datetime_space, compact_timestamp, today, Node, Relation, NodeType, RelationType, new_node, new_relation, }; pub use view::StoreView; pub use capnp::fsck; pub use ops::current_provenance; use crate::graph::{self, Graph}; use anyhow::{bail, Result}; /// Strip .md suffix from a key, handling both bare keys and section keys. /// "identity.md" → "identity", "foo.md#section" → "foo#section", "identity" → "identity" pub fn strip_md_suffix(key: &str) -> String { if let Some((file, section)) = key.split_once('#') { let bare = file.strip_suffix(".md").unwrap_or(file); format!("{}#{}", bare, section) } else { key.strip_suffix(".md").unwrap_or(key).to_string() } } // The full in-memory store pub struct Store { /// Log sizes at load time — used for staleness detection. pub(crate) loaded_nodes_size: u64, pub(crate) loaded_rels_size: u64, /// redb index database pub(crate) db: Option, } impl Default for Store { fn default() -> Self { Store { loaded_nodes_size: 0, loaded_rels_size: 0, db: None, } } } impl Store { pub fn build_graph(&self) -> Graph { graph::build_graph(self) } /// Get a node by key, reading from capnp via the index. pub fn get_node(&self, key: &str) -> Result> { let db = self.db.as_ref() .ok_or_else(|| anyhow::anyhow!("store not loaded"))?; match index::get_offset(db, key)? { Some(offset) => Ok(Some(capnp::read_node_at_offset(offset)?)), None => Ok(None), } } /// Check if a node exists by key. pub fn contains_key(&self, key: &str) -> Result { let db = self.db.as_ref() .ok_or_else(|| anyhow::anyhow!("store not loaded"))?; index::contains_key(db, key) } /// Get all node keys. pub fn all_keys(&self) -> Result> { let db = self.db.as_ref() .ok_or_else(|| anyhow::anyhow!("store not loaded"))?; index::all_keys(db) } /// Get neighbors of a node: (key, strength) pairs. pub fn neighbors(&self, key: &str) -> Result> { let db = self.db.as_ref() .ok_or_else(|| anyhow::anyhow!("store not loaded"))?; let uuid = match index::get_uuid_for_key(db, key)? { Some(u) => u, None => return Ok(Vec::new()), }; let edges = index::edges_for_node(db, &uuid)?; let mut neighbors = Vec::new(); for (other_uuid, strength, _, _) in edges { // Look up key for other_uuid let offsets = index::get_offsets_for_uuid(db, &other_uuid)?; if offsets.is_empty() { continue; } match capnp::read_node_at_offset(offsets[0]) { Ok(n) if !n.deleted => neighbors.push((n.key, strength)), _ => continue, } } Ok(neighbors) } /// Remove a node from the index (used after appending a tombstone). pub fn remove_from_index(&self, key: &str, uuid: &[u8; 16]) -> Result<()> { if let Some(db) = self.db.as_ref() { index::remove_node(db, key, uuid)?; } Ok(()) } /// Get all edges for a node by UUID. Returns (other_uuid, strength, rel_type, is_outgoing). pub fn edges_for_uuid(&self, uuid: &[u8; 16]) -> Result> { let db = self.db.as_ref() .ok_or_else(|| anyhow::anyhow!("store not loaded"))?; index::edges_for_node(db, uuid) } /// Add a relation to the index. pub fn index_relation(&self, source: &[u8; 16], target: &[u8; 16], strength: f32, rel_type: u8) -> Result<()> { if let Some(db) = self.db.as_ref() { index::index_relation(db, source, target, strength, rel_type)?; } Ok(()) } /// Remove a relation from the index. pub fn remove_relation_from_index(&self, source: &[u8; 16], target: &[u8; 16], strength: f32, rel_type: u8) -> Result<()> { if let Some(db) = self.db.as_ref() { index::remove_relation(db, source, target, strength, rel_type)?; } Ok(()) } pub fn resolve_key(&self, target: &str) -> Result { // Strip .md suffix if present — keys no longer use it let bare = strip_md_suffix(target); if self.contains_key(&bare)? { return Ok(bare); } let db = self.db.as_ref() .ok_or_else(|| anyhow::anyhow!("store not loaded"))?; let all_keys = index::all_keys(db)?; let matches: Vec<_> = all_keys.iter() .filter(|k| k.to_lowercase().contains(&target.to_lowercase())) .cloned().collect(); match matches.len() { 0 => bail!("No entry for '{}'. Run 'init'?", target), 1 => Ok(matches[0].clone()), n if n <= 10 => { let list = matches.join("\n "); bail!("Ambiguous '{}'. Matches:\n {}", target, list) } n => bail!("Too many matches for '{}' ({}). Be more specific.", target, n), } } }