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::{EdgeDirection, 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 append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
73 for edge in edges {
74 self.append_edge(edge)?;
75 }
76 Ok(())
77 }
78 fn edges_for_node_dir(
83 &self,
84 node_id: &str,
85 dir: EdgeDirection,
86 relation: Option<&str>,
87 ) -> Result<Vec<GraphEdge>> {
88 Ok(self
89 .edges_for_node(node_id)?
90 .into_iter()
91 .filter(|e| match dir {
92 EdgeDirection::Out => e.source == node_id,
93 EdgeDirection::In => e.target == node_id,
94 EdgeDirection::Both => true,
95 })
96 .filter(|e| relation.is_none_or(|r| e.relation == r))
97 .collect())
98 }
99 fn neighbors_weighted(
103 &self,
104 seed_ids: &[String],
105 dir: EdgeDirection,
106 relation: Option<&str>,
107 ) -> Result<Vec<(String, f64)>> {
108 let mut weights: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
109 let seed_set: std::collections::HashSet<&str> =
110 seed_ids.iter().map(String::as_str).collect();
111 for seed in seed_ids {
112 for e in self.edges_for_node_dir(seed, dir, relation)? {
113 let other = if e.source == *seed {
114 &e.target
115 } else {
116 &e.source
117 };
118 if !seed_set.contains(other.as_str()) {
119 *weights.entry(other.clone()).or_default() += e.weight;
120 }
121 }
122 }
123 let mut result: Vec<(String, f64)> = weights.into_iter().collect();
124 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
125 Ok(result)
126 }
127 fn related_nodes_filtered(
131 &self,
132 start_id: &str,
133 depth: usize,
134 dir: EdgeDirection,
135 relation: Option<&str>,
136 ) -> Result<Vec<String>> {
137 if depth == 0 {
138 return Ok(vec![]);
139 }
140 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
141 visited.insert(start_id.to_string());
142 let mut frontier: Vec<String> = vec![start_id.to_string()];
143 for _ in 0..depth {
144 let mut next: Vec<String> = Vec::new();
145 for node in &frontier {
146 for (nb, _) in self.neighbors_weighted(std::slice::from_ref(node), dir, relation)? {
147 if visited.insert(nb.clone()) {
148 next.push(nb);
149 }
150 }
151 }
152 if next.is_empty() {
153 break;
154 }
155 frontier = next;
156 }
157 visited.remove(start_id);
158 Ok(visited.into_iter().collect())
159 }
160 fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>>;
162 fn delete_edge(&self, id: &str) -> Result<bool>;
164 fn remove_edges_for_node(&self, node_id: &str) -> Result<()>;
166
167 fn current_version(&self) -> Result<u32>;
169 fn migrate(&self) -> Result<u32>;
172}
173
174pub struct SqliteGraph {
179 conn: Mutex<Connection>,
180}
181
182impl SqliteGraph {
183 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
185 let conn = open_with_schema(path.as_ref())?;
186 Ok(Self {
187 conn: Mutex::new(conn),
188 })
189 }
190
191 pub fn open_in_memory() -> Result<Self> {
193 let conn = Connection::open_in_memory().map_err(store_err)?;
194 init_graph_schema(&conn)?;
195 let current = schema_version(&conn)?;
196 migrate_graph(&conn, current)?;
197 Ok(Self {
198 conn: Mutex::new(conn),
199 })
200 }
201
202 fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
204 self.conn.lock().unwrap_or_else(|e| e.into_inner())
205 }
206
207 pub fn mark_verified(&self, id: &str, now: &str) -> Result<bool> {
213 let c = self.lock();
214 crate::graph::lifecycle::mark_verified(&c, id, now)
215 }
216
217 pub fn count_expired_nodes(&self, now: &str) -> Result<u64> {
221 let c = self.lock();
222 crate::graph::lifecycle::count_expired_nodes(&c, now)
223 }
224
225 pub fn with_tx(&self, f: impl FnOnce(&Connection) -> Result<()>) -> Result<()> {
236 let conn = self.lock();
237 let tx = conn.unchecked_transaction().map_err(store_err)?;
238 f(&tx)?;
239 tx.commit().map_err(store_err)
240 }
241}
242
243fn open_with_schema(path: &Path) -> Result<Connection> {
245 let conn = Connection::open(path).map_err(store_err)?;
246 init_graph_schema(&conn)?;
247 let current = schema_version(&conn)?;
248 migrate_graph(&conn, current)?;
249 Ok(conn)
250}
251
252fn store_err(e: rusqlite::Error) -> crate::error::KernelError {
253 crate::error::KernelError::Store(e.to_string())
254}
255
256impl GraphBackend for SqliteGraph {
257 fn upsert_node(&self, node: &GraphNode) -> Result<()> {
258 let c = self.lock();
259 upsert_node(&c, node)
260 }
261
262 fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
263 let c = self.lock();
264 crate::graph::store::read_node(&c, id)
265 }
266
267 fn delete_node(&self, id: &str) -> Result<bool> {
268 let c = self.lock();
269 delete_node(&c, id)
270 }
271
272 fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
273 let c = self.lock();
274 search_nodes(&c, query, limit)
275 }
276
277 fn query_nodes(
278 &self,
279 tag: Option<&str>,
280 node_type: Option<&str>,
281 project: Option<&str>,
282 limit: usize,
283 ) -> Result<Vec<GraphNode>> {
284 let c = self.lock();
285 query_nodes(&c, tag, node_type, project, limit)
286 }
287
288 fn smart_recall(
289 &self,
290 project: Option<&str>,
291 hint: Option<&str>,
292 limit: usize,
293 ) -> Result<Vec<ScoredNode>> {
294 let c = self.lock();
295 smart_recall(&c, project, hint, limit)
296 }
297
298 fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
299 let c = self.lock();
300 Ok(related_nodes(&c, start_id, depth))
301 }
302
303 fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
304 let c = self.lock();
305 append_edge(&c, edge)
306 }
307
308 fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
309 let c = self.lock();
310 crate::graph::store::append_edges(&c, edges)
311 }
312
313 fn edges_for_node_dir(
314 &self,
315 node_id: &str,
316 dir: EdgeDirection,
317 relation: Option<&str>,
318 ) -> Result<Vec<GraphEdge>> {
319 let c = self.lock();
320 crate::graph::store::edges_for_node_dir(&c, node_id, dir, relation)
321 }
322
323 fn neighbors_weighted(
324 &self,
325 seed_ids: &[String],
326 dir: EdgeDirection,
327 relation: Option<&str>,
328 ) -> Result<Vec<(String, f64)>> {
329 let c = self.lock();
330 Ok(crate::graph::traversal::neighbors_weighted(
331 &c, seed_ids, dir, relation,
332 ))
333 }
334
335 fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
336 let c = self.lock();
337 edges_for_node(&c, node_id)
338 }
339
340 fn delete_edge(&self, id: &str) -> Result<bool> {
341 let c = self.lock();
342 delete_edge(&c, id)
343 }
344
345 fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
346 let c = self.lock();
347 remove_edges_for_node(&c, node_id)
348 }
349
350 fn current_version(&self) -> Result<u32> {
351 let c = self.lock();
352 schema_version(&c)
353 }
354
355 fn migrate(&self) -> Result<u32> {
356 let c = self.lock();
357 let current = schema_version(&c)?;
358 migrate_graph(&c, current)
359 }
360}
361
362#[cfg(feature = "graph-cjk")]
364impl SqliteGraph {
365 pub fn search_nodes_cjk(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
370 let c = self.lock();
371 crate::graph::cjk::search_nodes_cjk(&c, query, limit)
372 }
373
374 pub fn segment_cjk(text: &str) -> String {
377 crate::graph::cjk::segment_cjk(text)
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn sample_node(id: &str) -> GraphNode {
386 GraphNode {
387 id: id.to_string(),
388 node_type: "concept".to_string(),
389 title: format!("Node {id}"),
390 body: "graph backend test body".to_string(),
391 tags: vec!["backend".to_string()],
392 projects: vec![],
393 agents: vec![],
394 created: "2026-01-01T00:00:00Z".to_string(),
395 updated: "2026-01-01T00:00:00Z".to_string(),
396 importance: 0.5,
397 access_count: 0,
398 accessed_at: String::new(),
399 ..Default::default()
400 }
401 }
402
403 #[test]
406 fn dyn_backend_round_trips_node() {
407 let backend: Box<dyn GraphBackend> = Box::new(SqliteGraph::open_in_memory().unwrap());
408 assert!(backend.read_node("n1").unwrap().is_none());
409 backend.upsert_node(&sample_node("n1")).unwrap();
410 let loaded = backend.read_node("n1").unwrap().unwrap();
411 assert_eq!(loaded.title, "Node n1");
412 assert_eq!(loaded.tags, vec!["backend".to_string()]);
413 assert!(backend.delete_node("n1").unwrap());
414 assert!(backend.read_node("n1").unwrap().is_none());
415 }
416
417 #[test]
419 fn mark_verified_and_count_expired_wrappers() {
420 let backend = SqliteGraph::open_in_memory().unwrap();
421 backend.upsert_node(&sample_node("n1")).unwrap();
422 assert!(backend.mark_verified("n1", "2026-08-18T00:00:00Z").unwrap());
423 let node = backend.read_node("n1").unwrap().unwrap();
424 assert_eq!(node.last_verified, "2026-08-18T00:00:00Z");
425 assert_eq!(
426 backend.count_expired_nodes("2026-08-18T00:00:00Z").unwrap(),
427 0
428 );
429 }
430
431 #[test]
433 fn with_tx_commits_on_ok() {
434 let backend = SqliteGraph::open_in_memory().unwrap();
435 backend.upsert_node(&sample_node("a")).unwrap();
436 backend.upsert_node(&sample_node("b")).unwrap();
437 backend
438 .with_tx(|conn| {
439 crate::graph::store::remove_edges_for_node(conn, "a")?;
440 crate::graph::store::append_edge(
441 conn,
442 &GraphEdge {
443 id: "e1".into(),
444 source: "a".into(),
445 target: "b".into(),
446 relation: "related".into(),
447 weight: 1.0,
448 ts: "2026-01-01T00:00:00Z".into(),
449 },
450 )
451 })
452 .unwrap();
453 let edges = backend.edges_for_node("a").unwrap();
454 assert_eq!(edges.len(), 1);
455 }
456
457 #[test]
459 fn with_tx_rolls_back_on_err() {
460 let backend = SqliteGraph::open_in_memory().unwrap();
461 backend.upsert_node(&sample_node("a")).unwrap();
462 backend.upsert_node(&sample_node("b")).unwrap();
463 backend
464 .append_edge(&GraphEdge {
465 id: "e0".into(),
466 source: "a".into(),
467 target: "b".into(),
468 relation: "related".into(),
469 weight: 1.0,
470 ts: "2026-01-01T00:00:00Z".into(),
471 })
472 .unwrap();
473
474 let result = backend.with_tx(|conn| {
475 crate::graph::store::remove_edges_for_node(conn, "a")?;
476 Err(crate::error::KernelError::Store("boom".into()))
478 });
479 assert!(result.is_err());
480 let edges = backend.edges_for_node("a").unwrap();
482 assert_eq!(edges.len(), 1, "edge lost mid-transaction: {edges:?}");
483 }
484
485 #[test]
487 fn fresh_backend_reports_current_version() {
488 let backend = SqliteGraph::open_in_memory().unwrap();
489 assert_eq!(
490 backend.current_version().unwrap(),
491 crate::graph::schema::GRAPH_SCHEMA_VERSION
492 );
493 }
494
495 #[test]
497 fn backend_search_finds_node() {
498 let backend = SqliteGraph::open_in_memory().unwrap();
499 backend.upsert_node(&sample_node("rust")).unwrap();
500 let hits = backend.search_nodes("graph backend", 10).unwrap();
501 assert_eq!(hits.len(), 1);
502 assert_eq!(hits[0].id, "rust");
503 }
504
505 #[test]
507 fn backend_smart_recall_finds_relevant() {
508 let backend = SqliteGraph::open_in_memory().unwrap();
509 let mut n = sample_node("rust");
510 n.body = "rust ownership borrow checker".to_string();
511 backend.upsert_node(&n).unwrap();
512 let recalled = backend.smart_recall(None, Some("ownership"), 5).unwrap();
513 assert!(recalled.iter().any(|s| s.node.id == "rust"));
514 }
515
516 #[test]
518 fn backend_related_nodes_traverses_edges() {
519 let backend = SqliteGraph::open_in_memory().unwrap();
520 backend.upsert_node(&sample_node("a")).unwrap();
521 backend.upsert_node(&sample_node("b")).unwrap();
522 backend
523 .append_edge(&GraphEdge {
524 id: "e1".into(),
525 source: "a".into(),
526 target: "b".into(),
527 relation: "related".into(),
528 weight: 1.0,
529 ts: "2026-01-01T00:00:00Z".into(),
530 })
531 .unwrap();
532 let related = backend.related_nodes("a", 2).unwrap();
533 assert!(related.contains(&"b".to_string()));
534 }
535
536 #[test]
539 fn dyn_backend_batch_and_filtered_edges() {
540 let backend: Box<dyn GraphBackend> = Box::new(SqliteGraph::open_in_memory().unwrap());
541 backend
542 .append_edges(&[
543 GraphEdge {
544 id: "e1".into(),
545 source: "a".into(),
546 target: "b".into(),
547 relation: "cites".into(),
548 weight: 1.0,
549 ts: "t".into(),
550 },
551 GraphEdge {
552 id: "e2".into(),
553 source: "c".into(),
554 target: "a".into(),
555 relation: "cites".into(),
556 weight: 1.0,
557 ts: "t".into(),
558 },
559 GraphEdge {
560 id: "e3".into(),
561 source: "a".into(),
562 target: "d".into(),
563 relation: "see_also".into(),
564 weight: 1.0,
565 ts: "t".into(),
566 },
567 ])
568 .unwrap();
569 assert_eq!(
571 backend
572 .edges_for_node_dir("a", EdgeDirection::Out, None)
573 .unwrap()
574 .len(),
575 2
576 );
577 let nbs = backend
579 .neighbors_weighted(&["a".to_string()], EdgeDirection::Out, Some("cites"))
580 .unwrap();
581 let ids: Vec<&str> = nbs.iter().map(|(id, _)| id.as_str()).collect();
582 assert_eq!(ids, vec!["b"]);
583 let rel = backend
585 .related_nodes_filtered("a", 2, EdgeDirection::Out, None)
586 .unwrap();
587 assert!(rel.contains(&"b".to_string()));
588 assert!(rel.contains(&"d".to_string()));
589 }
590}