Skip to main content

llm_kernel/graph/
backend.rs

1//! Backend-agnostic graph trait and SQLite implementation.
2//!
3//! [`GraphBackend`] is a sync, object-safe trait covering the primitive node/edge
4//! operations every graph backend must support. It deliberately exposes **no
5//! `rusqlite` types**, so a PostgreSQL or in-memory backend can implement it
6//! (see the v0.8.0 roadmap). [`SqliteGraph`] is the bundled implementation: it
7//! wraps a single mutex-guarded connection and delegates to the existing
8//! free-function graph API in [`crate::graph`].
9//!
10//! The existing free functions (`upsert_node(&conn, …)`, `search_nodes(&conn, …)`,
11//! …) are unchanged — callers that already own a `Connection` keep using them.
12//! [`SqliteGraph`] simply packages a connection behind the trait for users who
13//! want backend-agnostic graph access.
14
15use std::path::Path;
16use std::sync::Mutex;
17
18use rusqlite::Connection;
19
20use crate::error::Result;
21use crate::graph::recall::smart_recall;
22use crate::graph::schema::{init_graph_schema, migrate_graph, schema_version};
23use crate::graph::search::{query_nodes, search_nodes};
24use crate::graph::store::{
25    append_edge, delete_edge, delete_node, edges_for_node, remove_edges_for_node, upsert_node,
26};
27use crate::graph::traversal::related_nodes;
28use crate::graph::types::{GraphEdge, GraphNode, ScoredNode};
29
30/// Sync, object-safe trait for graph backends.
31///
32/// Methods cover node/edge CRUD, FTS search, filtered query, and schema
33/// migration. No method exposes `rusqlite` types, so the trait is implementable
34/// by any backend. `dyn GraphBackend` is usable.
35pub trait GraphBackend: Send + Sync {
36    /// Insert or replace a node.
37    fn upsert_node(&self, node: &GraphNode) -> Result<()>;
38    /// Read a single node by ID (`None` if absent).
39    fn read_node(&self, id: &str) -> Result<Option<GraphNode>>;
40    /// Delete a node by ID. Returns `true` if a row was removed.
41    fn delete_node(&self, id: &str) -> Result<bool>;
42    /// FTS5 full-text search, ranked by importance DESC.
43    fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>>;
44    /// Dynamic filter by tag / node_type / project.
45    #[allow(clippy::too_many_arguments)]
46    fn query_nodes(
47        &self,
48        tag: Option<&str>,
49        node_type: Option<&str>,
50        project: Option<&str>,
51        limit: usize,
52    ) -> Result<Vec<GraphNode>>;
53    /// Composite recall — rank nodes by recency, importance, access, FTS, and
54    /// graph boost. The canonical high-level read path for "what's relevant".
55    fn smart_recall(
56        &self,
57        project: Option<&str>,
58        hint: Option<&str>,
59        limit: usize,
60    ) -> Result<Vec<ScoredNode>>;
61    /// BFS-traverse up to `depth` hops from `start_id`, returning related node
62    /// IDs (excluding the start).
63    fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>>;
64    /// Append an edge (duplicates by edge ID are ignored).
65    fn append_edge(&self, edge: &GraphEdge) -> Result<()>;
66    /// Read edges where the given node is source or target.
67    fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>>;
68    /// Delete an edge by ID. Returns `true` if a row was removed.
69    fn delete_edge(&self, id: &str) -> Result<bool>;
70    /// Remove every edge connected to a node.
71    fn remove_edges_for_node(&self, node_id: &str) -> Result<()>;
72
73    /// Recorded schema version for this backend.
74    fn current_version(&self) -> Result<u32>;
75    /// Apply pending migrations up to the backend's latest schema version.
76    /// Returns the resulting version.
77    fn migrate(&self) -> Result<u32>;
78}
79
80/// SQLite-backed [`GraphBackend`] over one mutex-guarded connection.
81///
82/// Opening applies the schema and runs any pending migrations, so a database
83/// created by an older `llm-kernel` is upgraded transparently on open.
84pub struct SqliteGraph {
85    conn: Mutex<Connection>,
86}
87
88impl SqliteGraph {
89    /// Open (or create) a graph database at `path`, applying schema + migrations.
90    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
91        let conn = open_with_schema(path.as_ref())?;
92        Ok(Self {
93            conn: Mutex::new(conn),
94        })
95    }
96
97    /// Create an in-memory graph (useful for tests and ephemeral stores).
98    pub fn open_in_memory() -> Result<Self> {
99        let conn = Connection::open_in_memory().map_err(store_err)?;
100        init_graph_schema(&conn)?;
101        let current = schema_version(&conn)?;
102        migrate_graph(&conn, current)?;
103        Ok(Self {
104            conn: Mutex::new(conn),
105        })
106    }
107
108    /// Lock helper: recover the guard even if a previous holder panicked.
109    fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
110        self.conn.lock().unwrap_or_else(|e| e.into_inner())
111    }
112}
113
114/// Open a file-backed connection, apply the schema, then run pending migrations.
115fn open_with_schema(path: &Path) -> Result<Connection> {
116    let conn = Connection::open(path).map_err(store_err)?;
117    init_graph_schema(&conn)?;
118    let current = schema_version(&conn)?;
119    migrate_graph(&conn, current)?;
120    Ok(conn)
121}
122
123fn store_err(e: rusqlite::Error) -> crate::error::KernelError {
124    crate::error::KernelError::Store(e.to_string())
125}
126
127impl GraphBackend for SqliteGraph {
128    fn upsert_node(&self, node: &GraphNode) -> Result<()> {
129        let c = self.lock();
130        upsert_node(&c, node)
131    }
132
133    fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
134        let c = self.lock();
135        crate::graph::store::read_node(&c, id)
136    }
137
138    fn delete_node(&self, id: &str) -> Result<bool> {
139        let c = self.lock();
140        delete_node(&c, id)
141    }
142
143    fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
144        let c = self.lock();
145        search_nodes(&c, query, limit)
146    }
147
148    fn query_nodes(
149        &self,
150        tag: Option<&str>,
151        node_type: Option<&str>,
152        project: Option<&str>,
153        limit: usize,
154    ) -> Result<Vec<GraphNode>> {
155        let c = self.lock();
156        query_nodes(&c, tag, node_type, project, limit)
157    }
158
159    fn smart_recall(
160        &self,
161        project: Option<&str>,
162        hint: Option<&str>,
163        limit: usize,
164    ) -> Result<Vec<ScoredNode>> {
165        let c = self.lock();
166        smart_recall(&c, project, hint, limit)
167    }
168
169    fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
170        let c = self.lock();
171        Ok(related_nodes(&c, start_id, depth))
172    }
173
174    fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
175        let c = self.lock();
176        append_edge(&c, edge)
177    }
178
179    fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
180        let c = self.lock();
181        edges_for_node(&c, node_id)
182    }
183
184    fn delete_edge(&self, id: &str) -> Result<bool> {
185        let c = self.lock();
186        delete_edge(&c, id)
187    }
188
189    fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
190        let c = self.lock();
191        remove_edges_for_node(&c, node_id)
192    }
193
194    fn current_version(&self) -> Result<u32> {
195        let c = self.lock();
196        schema_version(&c)
197    }
198
199    fn migrate(&self) -> Result<u32> {
200        let c = self.lock();
201        let current = schema_version(&c)?;
202        migrate_graph(&c, current)
203    }
204}
205
206/// CJK-aware convenience methods (available with the `graph-cjk` feature).
207#[cfg(feature = "graph-cjk")]
208impl SqliteGraph {
209    /// CJK (or mixed) search via contiguous substring matching.
210    ///
211    /// Delegates to [`crate::graph::cjk::search_nodes_cjk`]; see its docs for
212    /// the matching semantics.
213    pub fn search_nodes_cjk(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
214        let c = self.lock();
215        crate::graph::cjk::search_nodes_cjk(&c, query, limit)
216    }
217
218    /// Segment a string for CJK tokenization (exposed for callers that want to
219    /// pre-process queries or inspect tokenization).
220    pub fn segment_cjk(text: &str) -> String {
221        crate::graph::cjk::segment_cjk(text)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn sample_node(id: &str) -> GraphNode {
230        GraphNode {
231            id: id.to_string(),
232            node_type: "concept".to_string(),
233            title: format!("Node {id}"),
234            body: "graph backend test body".to_string(),
235            tags: vec!["backend".to_string()],
236            projects: vec![],
237            agents: vec![],
238            created: "2026-01-01T00:00:00Z".to_string(),
239            updated: "2026-01-01T00:00:00Z".to_string(),
240            importance: 0.5,
241            access_count: 0,
242            accessed_at: String::new(),
243        }
244    }
245
246    /// AC4: a node round-trips through the trait, and the trait is usable as
247    /// `dyn GraphBackend` (object-safety) with no `rusqlite` in the surface.
248    #[test]
249    fn dyn_backend_round_trips_node() {
250        let backend: Box<dyn GraphBackend> = Box::new(SqliteGraph::open_in_memory().unwrap());
251        assert!(backend.read_node("n1").unwrap().is_none());
252        backend.upsert_node(&sample_node("n1")).unwrap();
253        let loaded = backend.read_node("n1").unwrap().unwrap();
254        assert_eq!(loaded.title, "Node n1");
255        assert_eq!(loaded.tags, vec!["backend".to_string()]);
256        assert!(backend.delete_node("n1").unwrap());
257        assert!(backend.read_node("n1").unwrap().is_none());
258    }
259
260    /// AC5: a fresh backend reports the current schema version.
261    #[test]
262    fn fresh_backend_reports_current_version() {
263        let backend = SqliteGraph::open_in_memory().unwrap();
264        assert_eq!(
265            backend.current_version().unwrap(),
266            crate::graph::schema::GRAPH_SCHEMA_VERSION
267        );
268    }
269
270    /// AC5: search through the trait finds an inserted node by title.
271    #[test]
272    fn backend_search_finds_node() {
273        let backend = SqliteGraph::open_in_memory().unwrap();
274        backend.upsert_node(&sample_node("rust")).unwrap();
275        let hits = backend.search_nodes("graph backend", 10).unwrap();
276        assert_eq!(hits.len(), 1);
277        assert_eq!(hits[0].id, "rust");
278    }
279
280    /// The composite recall path is reachable through the trait.
281    #[test]
282    fn backend_smart_recall_finds_relevant() {
283        let backend = SqliteGraph::open_in_memory().unwrap();
284        let mut n = sample_node("rust");
285        n.body = "rust ownership borrow checker".to_string();
286        backend.upsert_node(&n).unwrap();
287        let recalled = backend.smart_recall(None, Some("ownership"), 5).unwrap();
288        assert!(recalled.iter().any(|s| s.node.id == "rust"));
289    }
290
291    /// The composite traversal path is reachable through the trait.
292    #[test]
293    fn backend_related_nodes_traverses_edges() {
294        let backend = SqliteGraph::open_in_memory().unwrap();
295        backend.upsert_node(&sample_node("a")).unwrap();
296        backend.upsert_node(&sample_node("b")).unwrap();
297        backend
298            .append_edge(&GraphEdge {
299                id: "e1".into(),
300                source: "a".into(),
301                target: "b".into(),
302                relation: "related".into(),
303                weight: 1.0,
304                ts: "2026-01-01T00:00:00Z".into(),
305            })
306            .unwrap();
307        let related = backend.related_nodes("a", 2).unwrap();
308        assert!(related.contains(&"b".to_string()));
309    }
310}