1use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{EdgeDirection, GraphEdge, GraphNode, NODE_COLUMNS, join_csv, row_to_node};
8
9pub fn upsert_node(conn: &Connection, node: &GraphNode) -> Result<()> {
26 conn.execute(
27 "INSERT INTO nodes
28 (id, type, title, tags, projects, agents, created, updated, body,
29 importance, access_count, accessed_at, valid_until, last_verified)
30 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
31 ON CONFLICT(id) DO UPDATE SET
32 type = excluded.type,
33 title = excluded.title,
34 tags = excluded.tags,
35 projects = excluded.projects,
36 agents = excluded.agents,
37 updated = excluded.updated,
38 body = excluded.body,
39 importance = excluded.importance,
40 created = nodes.created,
41 access_count = MAX(nodes.access_count, excluded.access_count),
42 accessed_at = MAX(nodes.accessed_at, excluded.accessed_at),
43 valid_until = excluded.valid_until,
44 last_verified = excluded.last_verified",
45 params![
46 node.id,
47 node.node_type,
48 node.title,
49 join_csv(&node.tags),
50 join_csv(&node.projects),
51 join_csv(&node.agents),
52 node.created,
53 node.updated,
54 node.body,
55 node.importance,
56 node.access_count,
57 node.accessed_at,
58 node.valid_until,
59 node.last_verified,
60 ],
61 )
62 .map_err(|e| KernelError::Store(e.to_string()))?;
63 Ok(())
64}
65
66pub fn read_node(conn: &Connection, id: &str) -> Result<Option<GraphNode>> {
68 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1");
69 match conn.query_row(&sql, params![id], row_to_node) {
70 Ok(node) => Ok(Some(node)),
71 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
72 Err(e) => Err(KernelError::Store(e.to_string())),
73 }
74}
75
76pub fn read_nodes(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphNode>> {
78 if ids.is_empty() {
79 return Ok(vec![]);
80 }
81 let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
82 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id IN ({ph})");
83 let mut stmt = conn
84 .prepare(&sql)
85 .map_err(|e| KernelError::Store(e.to_string()))?;
86 let nodes: Vec<GraphNode> = stmt
87 .query_map(rusqlite::params_from_iter(ids.iter()), row_to_node)
88 .map_err(|e| KernelError::Store(e.to_string()))?
89 .filter_map(|r| r.ok())
90 .collect();
91 Ok(nodes)
92}
93
94pub fn delete_node(conn: &Connection, id: &str) -> Result<bool> {
100 let tx = conn
105 .unchecked_transaction()
106 .map_err(|e| KernelError::Store(e.to_string()))?;
107 remove_edges_for_node(&tx, id)?;
108 let changed = tx
109 .execute("DELETE FROM nodes WHERE id = ?1", params![id])
110 .map_err(|e| KernelError::Store(e.to_string()))?;
111 tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
112 Ok(changed > 0)
113}
114
115pub fn list_node_ids(conn: &Connection) -> Result<Vec<String>> {
117 let mut stmt = conn
118 .prepare("SELECT id FROM nodes")
119 .map_err(|e| KernelError::Store(e.to_string()))?;
120 let ids: Vec<String> = stmt
121 .query_map([], |row| row.get(0))
122 .map_err(|e| KernelError::Store(e.to_string()))?
123 .filter_map(|r| r.ok())
124 .collect();
125 Ok(ids)
126}
127
128pub fn read_nodes_limited(conn: &Connection, limit: usize) -> Result<Vec<GraphNode>> {
130 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes ORDER BY updated DESC LIMIT ?");
131 let mut stmt = conn
132 .prepare(&sql)
133 .map_err(|e| KernelError::Store(e.to_string()))?;
134 let nodes: Vec<GraphNode> = stmt
135 .query_map(params![limit as i64], row_to_node)
136 .map_err(|e| KernelError::Store(e.to_string()))?
137 .filter_map(|r| r.ok())
138 .collect();
139 Ok(nodes)
140}
141
142pub fn append_edge(conn: &Connection, edge: &GraphEdge) -> Result<()> {
146 conn.execute(
147 "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
148 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
149 params![
150 edge.id,
151 edge.source,
152 edge.target,
153 edge.relation,
154 edge.weight,
155 edge.ts
156 ],
157 )
158 .map_err(|e| KernelError::Store(e.to_string()))?;
159 Ok(())
160}
161
162pub fn append_edges(conn: &Connection, edges: &[GraphEdge]) -> Result<()> {
169 if edges.is_empty() {
170 return Ok(());
171 }
172 let tx = conn
173 .unchecked_transaction()
174 .map_err(|e| KernelError::Store(e.to_string()))?;
175 {
176 let mut stmt = tx
177 .prepare(
178 "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
179 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
180 )
181 .map_err(|e| KernelError::Store(e.to_string()))?;
182 for edge in edges {
183 stmt.execute(params![
184 edge.id,
185 edge.source,
186 edge.target,
187 edge.relation,
188 edge.weight,
189 edge.ts
190 ])
191 .map_err(|e| KernelError::Store(e.to_string()))?;
192 }
193 }
194 tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
195 Ok(())
196}
197
198pub(crate) fn edges_for_node_dir(
208 conn: &Connection,
209 node_id: &str,
210 dir: EdgeDirection,
211 relation: Option<&str>,
212) -> Result<Vec<GraphEdge>> {
213 let mut sql = String::from("SELECT id, source, target, relation, weight, ts FROM edges WHERE ");
214 match dir {
215 EdgeDirection::Out => sql.push_str("source = ?1"),
216 EdgeDirection::In => sql.push_str("target = ?1"),
217 EdgeDirection::Both => sql.push_str("(source = ?1 OR target = ?1)"),
218 }
219 if relation.is_some() {
220 sql.push_str(" AND relation = ?2");
221 }
222 sql.push_str(" ORDER BY weight DESC");
223 let mut stmt = conn
224 .prepare(&sql)
225 .map_err(|e| KernelError::Store(e.to_string()))?;
226 let mapper = |row: &rusqlite::Row<'_>| {
227 Ok(GraphEdge {
228 id: row.get(0)?,
229 source: row.get(1)?,
230 target: row.get(2)?,
231 relation: row.get(3)?,
232 weight: row.get(4)?,
233 ts: row.get(5)?,
234 })
235 };
236 let edges: Vec<GraphEdge> = if let Some(r) = relation {
237 stmt.query_map(params![node_id, r], mapper)
238 } else {
239 stmt.query_map(params![node_id], mapper)
240 }
241 .map_err(|e| KernelError::Store(e.to_string()))?
242 .filter_map(|r| r.ok())
243 .collect();
244 Ok(edges)
245}
246
247pub fn read_edges(conn: &Connection, limit: usize) -> Result<Vec<GraphEdge>> {
249 let mut stmt = conn
250 .prepare("SELECT id, source, target, relation, weight, ts FROM edges LIMIT ?1")
251 .map_err(|e| KernelError::Store(e.to_string()))?;
252 let edges: Vec<GraphEdge> = stmt
253 .query_map(params![limit as i64], |row| {
254 Ok(GraphEdge {
255 id: row.get(0)?,
256 source: row.get(1)?,
257 target: row.get(2)?,
258 relation: row.get(3)?,
259 weight: row.get(4)?,
260 ts: row.get(5)?,
261 })
262 })
263 .map_err(|e| KernelError::Store(e.to_string()))?
264 .filter_map(|r| r.ok())
265 .collect();
266 Ok(edges)
267}
268
269pub(crate) fn edges_among(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphEdge>> {
276 if ids.is_empty() {
277 return Ok(Vec::new());
278 }
279 let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
280 let sql = format!(
281 "SELECT id, source, target, relation, weight, ts FROM edges \
282 WHERE source IN ({ph}) AND target IN ({ph})"
283 );
284 let mut stmt = conn
285 .prepare(&sql)
286 .map_err(|e| KernelError::Store(e.to_string()))?;
287 let edges: Vec<GraphEdge> = stmt
288 .query_map(
289 rusqlite::params_from_iter(ids.iter().chain(ids.iter()).copied()),
290 |row| {
291 Ok(GraphEdge {
292 id: row.get(0)?,
293 source: row.get(1)?,
294 target: row.get(2)?,
295 relation: row.get(3)?,
296 weight: row.get(4)?,
297 ts: row.get(5)?,
298 })
299 },
300 )
301 .map_err(|e| KernelError::Store(e.to_string()))?
302 .filter_map(|r| r.ok())
303 .collect();
304 Ok(edges)
305}
306
307pub fn delete_edge(conn: &Connection, id: &str) -> Result<bool> {
309 let changed = conn
310 .execute("DELETE FROM edges WHERE id = ?1", params![id])
311 .map_err(|e| KernelError::Store(e.to_string()))?;
312 Ok(changed > 0)
313}
314
315pub(crate) fn remove_edges_for_node(conn: &Connection, node_id: &str) -> Result<()> {
317 conn.execute(
318 "DELETE FROM edges WHERE source = ?1 OR target = ?1",
319 params![node_id],
320 )
321 .map_err(|e| KernelError::Store(e.to_string()))?;
322 Ok(())
323}
324
325pub(crate) fn edges_for_node(conn: &Connection, node_id: &str) -> Result<Vec<GraphEdge>> {
327 let mut stmt = conn
328 .prepare(
329 "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = ?1 OR target = ?1",
330 )
331 .map_err(|e| KernelError::Store(e.to_string()))?;
332 let edges: Vec<GraphEdge> = stmt
333 .query_map(params![node_id], |row| {
334 Ok(GraphEdge {
335 id: row.get(0)?,
336 source: row.get(1)?,
337 target: row.get(2)?,
338 relation: row.get(3)?,
339 weight: row.get(4)?,
340 ts: row.get(5)?,
341 })
342 })
343 .map_err(|e| KernelError::Store(e.to_string()))?
344 .filter_map(|r| r.ok())
345 .collect();
346 Ok(edges)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::graph::schema::init_graph_schema;
353 use rusqlite::Connection;
354
355 fn mem_db() -> Connection {
356 let conn = Connection::open_in_memory().unwrap();
357 init_graph_schema(&conn).unwrap();
358 conn
359 }
360
361 fn test_node(id: &str) -> GraphNode {
362 GraphNode {
363 id: id.to_string(),
364 node_type: "concept".to_string(),
365 title: format!("Node {id}"),
366 body: "test body".to_string(),
367 tags: vec!["test".to_string()],
368 projects: vec![],
369 agents: vec![],
370 created: "2026-01-01T00:00:00Z".to_string(),
371 updated: "2026-01-01T00:00:00Z".to_string(),
372 importance: 0.7,
373 access_count: 0,
374 accessed_at: String::new(),
375 ..Default::default()
376 }
377 }
378
379 #[test]
380 fn upsert_and_read_node() {
381 let conn = mem_db();
382 let node = test_node("n1");
383 upsert_node(&conn, &node).unwrap();
384 let loaded = read_node(&conn, "n1").unwrap().unwrap();
385 assert_eq!(loaded.id, "n1");
386 assert_eq!(loaded.title, "Node n1");
387 assert_eq!(loaded.tags, vec!["test"]);
388 }
389
390 #[test]
391 fn upsert_roundtrips_temporal_validity() {
392 let conn = mem_db();
393 let mut n = test_node("tv");
394 n.valid_until = "2027-01-01T00:00:00Z".to_string();
395 n.last_verified = "2026-08-01T00:00:00Z".to_string();
396 upsert_node(&conn, &n).unwrap();
397 let got = read_node(&conn, "tv").unwrap().unwrap();
398 assert_eq!(got.valid_until, "2027-01-01T00:00:00Z");
399 assert_eq!(got.last_verified, "2026-08-01T00:00:00Z");
400 }
401
402 #[test]
403 fn read_missing_node_returns_none() {
404 let conn = mem_db();
405 assert!(read_node(&conn, "nope").unwrap().is_none());
406 }
407
408 #[test]
409 fn delete_node_returns_true_when_exists() {
410 let conn = mem_db();
411 upsert_node(&conn, &test_node("n1")).unwrap();
412 assert!(delete_node(&conn, "n1").unwrap());
413 assert!(!delete_node(&conn, "n1").unwrap());
414 }
415
416 #[test]
419 fn upsert_preserves_created_and_access_on_conflict() {
420 let conn = mem_db();
421 let mut n = test_node("fixed");
422 n.created = "2026-01-01T00:00:00Z".to_string();
423 n.access_count = 42;
424 upsert_node(&conn, &n).unwrap();
425
426 let mut again = test_node("fixed");
428 again.created = "2026-12-31T00:00:00Z".to_string();
429 again.updated = "2026-12-31T00:00:00Z".to_string();
430 again.access_count = 0;
431 upsert_node(&conn, &again).unwrap();
432
433 let got = read_node(&conn, "fixed").unwrap().unwrap();
434 assert_eq!(got.created, "2026-01-01T00:00:00Z", "created must persist");
435 assert_eq!(
436 got.access_count, 42,
437 "access_count must not be zeroed on re-upsert"
438 );
439 assert_eq!(got.updated, "2026-12-31T00:00:00Z", "updated must refresh");
440 }
441
442 #[test]
444 fn delete_node_removes_edges() {
445 let conn = mem_db();
446 upsert_node(&conn, &test_node("a")).unwrap();
447 upsert_node(&conn, &test_node("b")).unwrap();
448 append_edge(
449 &conn,
450 &GraphEdge {
451 id: "e1".into(),
452 source: "a".into(),
453 target: "b".into(),
454 relation: "rel".into(),
455 weight: 1.0,
456 ts: "2026-01-01T00:00:00Z".into(),
457 },
458 )
459 .unwrap();
460
461 assert!(delete_node(&conn, "a").unwrap());
462 let edges = edges_for_node(&conn, "a").unwrap();
463 assert!(edges.is_empty(), "orphan edges remain: {edges:?}");
464 }
465
466 #[test]
467 fn list_node_ids_returns_all() {
468 let conn = mem_db();
469 upsert_node(&conn, &test_node("a")).unwrap();
470 upsert_node(&conn, &test_node("b")).unwrap();
471 let ids = list_node_ids(&conn).unwrap();
472 assert_eq!(ids.len(), 2);
473 }
474
475 #[test]
476 fn append_and_read_edges() {
477 let conn = mem_db();
478 let edge = GraphEdge {
479 id: "e1".to_string(),
480 source: "a".to_string(),
481 target: "b".to_string(),
482 relation: "related".to_string(),
483 weight: 1.0,
484 ts: "2026-01-01T00:00:00Z".to_string(),
485 };
486 append_edge(&conn, &edge).unwrap();
487 let edges = read_edges(&conn, 10).unwrap();
488 assert_eq!(edges.len(), 1);
489 assert_eq!(edges[0].source, "a");
490 }
491
492 #[test]
493 fn edges_for_node_returns_both_directions() {
494 let conn = mem_db();
495 append_edge(
496 &conn,
497 &GraphEdge {
498 id: "e1".into(),
499 source: "a".into(),
500 target: "b".into(),
501 relation: "related".into(),
502 weight: 1.0,
503 ts: "2026-01-01T00:00:00Z".into(),
504 },
505 )
506 .unwrap();
507 append_edge(
508 &conn,
509 &GraphEdge {
510 id: "e2".into(),
511 source: "c".into(),
512 target: "a".into(),
513 relation: "related".into(),
514 weight: 1.0,
515 ts: "2026-01-01T00:00:00Z".into(),
516 },
517 )
518 .unwrap();
519 let edges = edges_for_node(&conn, "a").unwrap();
520 assert_eq!(edges.len(), 2);
521 }
522
523 #[test]
524 fn test_remove_edges_for_node() {
525 let conn = mem_db();
526 append_edge(
527 &conn,
528 &GraphEdge {
529 id: "e1".into(),
530 source: "a".into(),
531 target: "b".into(),
532 relation: "related".into(),
533 weight: 1.0,
534 ts: "2026-01-01T00:00:00Z".into(),
535 },
536 )
537 .unwrap();
538 remove_edges_for_node(&conn, "a").unwrap();
539 assert!(read_edges(&conn, 10).unwrap().is_empty());
540 }
541
542 #[test]
543 fn append_edges_inserts_batch() {
544 let conn = mem_db();
545 let edges = vec![
546 GraphEdge {
547 id: "e1".into(),
548 source: "a".into(),
549 target: "b".into(),
550 relation: "cites".into(),
551 weight: 1.0,
552 ts: "2026-01-01T00:00:00Z".into(),
553 },
554 GraphEdge {
555 id: "e2".into(),
556 source: "a".into(),
557 target: "c".into(),
558 relation: "cites".into(),
559 weight: 0.5,
560 ts: "2026-01-01T00:00:00Z".into(),
561 },
562 ];
563 append_edges(&conn, &edges).unwrap();
564 assert_eq!(read_edges(&conn, 10).unwrap().len(), 2);
565 }
566
567 #[test]
568 fn append_edges_empty_is_noop() {
569 let conn = mem_db();
570 append_edges(&conn, &[]).unwrap();
571 assert!(read_edges(&conn, 10).unwrap().is_empty());
572 }
573
574 #[test]
575 fn append_edges_ignores_duplicate_id_and_unique() {
576 let conn = mem_db();
577 let e = GraphEdge {
578 id: "e1".into(),
579 source: "a".into(),
580 target: "b".into(),
581 relation: "cites".into(),
582 weight: 1.0,
583 ts: "2026-01-01T00:00:00Z".into(),
584 };
585 append_edges(&conn, std::slice::from_ref(&e)).unwrap();
586 append_edges(
589 &conn,
590 &[
591 e,
592 GraphEdge {
593 id: "e2".into(),
594 source: "a".into(),
595 target: "b".into(),
596 relation: "cites".into(),
597 weight: 1.0,
598 ts: "2026-01-01T00:00:00Z".into(),
599 },
600 ],
601 )
602 .unwrap();
603 assert_eq!(read_edges(&conn, 10).unwrap().len(), 1);
604 }
605
606 #[test]
607 fn edges_for_node_dir_filters_direction_and_relation() {
608 let conn = mem_db();
609 append_edges(
610 &conn,
611 &[
612 GraphEdge {
613 id: "e1".into(),
614 source: "a".into(),
615 target: "b".into(),
616 relation: "cites".into(),
617 weight: 1.0,
618 ts: "t".into(),
619 },
620 GraphEdge {
621 id: "e2".into(),
622 source: "c".into(),
623 target: "a".into(),
624 relation: "cites".into(),
625 weight: 1.0,
626 ts: "t".into(),
627 },
628 GraphEdge {
629 id: "e3".into(),
630 source: "a".into(),
631 target: "d".into(),
632 relation: "see_also".into(),
633 weight: 1.0,
634 ts: "t".into(),
635 },
636 ],
637 )
638 .unwrap();
639 assert_eq!(
641 edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
642 .unwrap()
643 .len(),
644 2
645 );
646 let out_cites = edges_for_node_dir(&conn, "a", EdgeDirection::Out, Some("cites")).unwrap();
648 assert_eq!(out_cites.len(), 1);
649 assert_eq!(out_cites[0].target, "b");
650 let inc = edges_for_node_dir(&conn, "a", EdgeDirection::In, None).unwrap();
652 assert_eq!(inc.len(), 1);
653 assert_eq!(inc[0].source, "c");
654 assert_eq!(
656 edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
657 .unwrap()
658 .len(),
659 3
660 );
661 }
662}