1use 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
30pub trait GraphBackend: Send + Sync {
36 fn upsert_node(&self, node: &GraphNode) -> Result<()>;
38 fn read_node(&self, id: &str) -> Result<Option<GraphNode>>;
40 fn delete_node(&self, id: &str) -> Result<bool>;
42 fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>>;
44 #[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 fn smart_recall(
56 &self,
57 project: Option<&str>,
58 hint: Option<&str>,
59 limit: usize,
60 ) -> Result<Vec<ScoredNode>>;
61 fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>>;
64 fn append_edge(&self, edge: &GraphEdge) -> Result<()>;
66 fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>>;
68 fn delete_edge(&self, id: &str) -> Result<bool>;
70 fn remove_edges_for_node(&self, node_id: &str) -> Result<()>;
72
73 fn current_version(&self) -> Result<u32>;
75 fn migrate(&self) -> Result<u32>;
78}
79
80pub struct SqliteGraph {
85 conn: Mutex<Connection>,
86}
87
88impl SqliteGraph {
89 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 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 fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
110 self.conn.lock().unwrap_or_else(|e| e.into_inner())
111 }
112}
113
114fn 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#[cfg(feature = "graph-cjk")]
208impl SqliteGraph {
209 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 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 #[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 #[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 #[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 #[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 #[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}