llm_kernel/graph/
async_graph.rs1use std::sync::{Arc, Mutex};
37
38use rusqlite::Connection;
39use tokio::task;
40
41use crate::error::{KernelError, Result};
42use crate::graph::types::{EdgeDirection, GraphEdge, GraphNode, GraphStats, ScoredNode};
43
44#[derive(Clone)]
50pub struct AsyncGraph {
51 conn: Arc<Mutex<Connection>>,
52}
53
54impl AsyncGraph {
55 pub fn new(conn: Connection) -> Self {
57 Self {
58 conn: Arc::new(Mutex::new(conn)),
59 }
60 }
61
62 pub async fn open(path: impl Into<String>) -> Result<Self> {
64 let path = path.into();
65 task::spawn_blocking(move || {
66 let conn = Connection::open(&path).map_err(|e| KernelError::Store(e.to_string()))?;
67 crate::graph::schema::init_graph_schema(&conn)?;
68 Ok(Self::new(conn))
69 })
70 .await
71 .map_err(|e| KernelError::Store(e.to_string()))?
72 }
73
74 fn with_conn<F, T>(&self, f: F) -> task::JoinHandle<Result<T>>
75 where
76 F: FnOnce(&Connection) -> Result<T> + Send + 'static,
77 T: Send + 'static,
78 {
79 let conn = Arc::clone(&self.conn);
80 task::spawn_blocking(move || {
81 let guard = conn
82 .lock()
83 .map_err(|_| KernelError::Store("mutex poisoned".into()))?;
84 f(&guard)
85 })
86 }
87
88 pub async fn upsert_node(&self, node: GraphNode) -> Result<()> {
90 self.with_conn(move |c| crate::graph::store::upsert_node(c, &node))
91 .await
92 .map_err(|e| KernelError::Store(e.to_string()))?
93 }
94
95 pub async fn read_node(&self, id: impl Into<String>) -> Result<Option<GraphNode>> {
97 let id = id.into();
98 self.with_conn(move |c| crate::graph::store::read_node(c, &id))
99 .await
100 .map_err(|e| KernelError::Store(e.to_string()))?
101 }
102
103 pub async fn delete_node(&self, id: impl Into<String>) -> Result<bool> {
105 let id = id.into();
106 self.with_conn(move |c| crate::graph::store::delete_node(c, &id))
107 .await
108 .map_err(|e| KernelError::Store(e.to_string()))?
109 }
110
111 pub async fn append_edge(&self, edge: GraphEdge) -> Result<()> {
113 self.with_conn(move |c| crate::graph::store::append_edge(c, &edge))
114 .await
115 .map_err(|e| KernelError::Store(e.to_string()))?
116 }
117
118 pub async fn append_edges(&self, edges: Vec<GraphEdge>) -> Result<()> {
121 self.with_conn(move |c| crate::graph::store::append_edges(c, &edges))
122 .await
123 .map_err(|e| KernelError::Store(e.to_string()))?
124 }
125
126 pub async fn edges_for_node_dir(
128 &self,
129 node_id: impl Into<String>,
130 dir: EdgeDirection,
131 relation: Option<String>,
132 ) -> Result<Vec<GraphEdge>> {
133 let node_id = node_id.into();
134 self.with_conn(move |c| {
135 crate::graph::store::edges_for_node_dir(c, &node_id, dir, relation.as_deref())
136 })
137 .await
138 .map_err(|e| KernelError::Store(e.to_string()))?
139 }
140
141 pub async fn neighbors_weighted(
144 &self,
145 seed_ids: Vec<String>,
146 dir: EdgeDirection,
147 relation: Option<String>,
148 ) -> Result<Vec<(String, f64)>> {
149 self.with_conn(move |c| {
150 Ok(crate::graph::traversal::neighbors_weighted(
151 c,
152 &seed_ids,
153 dir,
154 relation.as_deref(),
155 ))
156 })
157 .await
158 .map_err(|e| KernelError::Store(e.to_string()))?
159 }
160
161 pub async fn delete_edge(&self, id: impl Into<String>) -> Result<bool> {
163 let id = id.into();
164 self.with_conn(move |c| crate::graph::store::delete_edge(c, &id))
165 .await
166 .map_err(|e| KernelError::Store(e.to_string()))?
167 }
168
169 pub async fn smart_recall(
171 &self,
172 project: Option<String>,
173 hint: Option<String>,
174 limit: usize,
175 ) -> Result<Vec<ScoredNode>> {
176 self.with_conn(move |c| {
177 crate::graph::recall::smart_recall(c, project.as_deref(), hint.as_deref(), limit)
178 })
179 .await
180 .map_err(|e| KernelError::Store(e.to_string()))?
181 }
182
183 pub async fn search_nodes(
185 &self,
186 query: impl Into<String>,
187 limit: usize,
188 ) -> Result<Vec<GraphNode>> {
189 let query = query.into();
190 self.with_conn(move |c| crate::graph::search::search_nodes(c, &query, limit))
191 .await
192 .map_err(|e| KernelError::Store(e.to_string()))?
193 }
194
195 pub async fn stats(&self) -> Result<GraphStats> {
197 self.with_conn(crate::graph::lifecycle::compute_stats)
198 .await
199 .map_err(|e| KernelError::Store(e.to_string()))?
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::graph::schema::init_graph_schema;
207
208 fn mem_graph() -> AsyncGraph {
209 let conn = Connection::open_in_memory().unwrap();
210 init_graph_schema(&conn).unwrap();
211 AsyncGraph::new(conn)
212 }
213
214 fn node(id: &str) -> GraphNode {
215 GraphNode {
216 id: id.into(),
217 node_type: "concept".into(),
218 title: format!("Node {id}"),
219 body: "body".into(),
220 tags: vec![],
221 projects: vec![],
222 agents: vec![],
223 created: "2026-01-01T00:00:00Z".into(),
224 updated: "2026-01-01T00:00:00Z".into(),
225 importance: 0.5,
226 access_count: 0,
227 accessed_at: String::new(),
228 }
229 }
230
231 #[tokio::test]
232 async fn upsert_and_read() {
233 let g = mem_graph();
234 g.upsert_node(node("n1")).await.unwrap();
235 let loaded = g.read_node("n1").await.unwrap().unwrap();
236 assert_eq!(loaded.id, "n1");
237 }
238
239 #[tokio::test]
240 async fn read_missing_returns_none() {
241 let g = mem_graph();
242 assert!(g.read_node("ghost").await.unwrap().is_none());
243 }
244
245 #[tokio::test]
246 async fn delete_node() {
247 let g = mem_graph();
248 g.upsert_node(node("n1")).await.unwrap();
249 assert!(g.delete_node("n1").await.unwrap());
250 assert!(g.read_node("n1").await.unwrap().is_none());
251 }
252
253 #[tokio::test]
254 async fn append_and_delete_edge() {
255 let g = mem_graph();
256 g.upsert_node(node("a")).await.unwrap();
257 g.upsert_node(node("b")).await.unwrap();
258 g.append_edge(GraphEdge {
259 id: "e1".into(),
260 source: "a".into(),
261 target: "b".into(),
262 relation: "related".into(),
263 weight: 1.0,
264 ts: "2026-01-01T00:00:00Z".into(),
265 })
266 .await
267 .unwrap();
268 assert!(g.delete_edge("e1").await.unwrap());
269 }
270
271 #[tokio::test]
272 async fn smart_recall_async() {
273 let g = mem_graph();
274 g.upsert_node(node("x")).await.unwrap();
275 let results = g.smart_recall(None, None, 10).await.unwrap();
276 assert_eq!(results.len(), 1);
277 }
278
279 #[tokio::test]
280 async fn stats_returns_counts() {
281 let g = mem_graph();
282 g.upsert_node(node("a")).await.unwrap();
283 g.upsert_node(node("b")).await.unwrap();
284 let s = g.stats().await.unwrap();
285 assert_eq!(s.total_nodes, 2);
286 assert_eq!(s.total_edges, 0);
287 }
288
289 #[tokio::test]
290 async fn clone_shares_connection() {
291 let g = mem_graph();
292 let g2 = g.clone();
293 g.upsert_node(node("n1")).await.unwrap();
294 assert!(g2.read_node("n1").await.unwrap().is_some());
296 }
297}