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