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<()> {
174 if edges.is_empty() {
175 return Ok(());
176 }
177 let tx = conn
178 .unchecked_transaction()
179 .map_err(|e| KernelError::Store(e.to_string()))?;
180 {
181 let mut stmt = tx
182 .prepare(
183 "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
184 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
185 )
186 .map_err(|e| KernelError::Store(e.to_string()))?;
187 for edge in edges {
188 stmt.execute(params![
189 edge.id,
190 edge.source,
191 edge.target,
192 edge.relation,
193 edge.weight,
194 edge.ts
195 ])
196 .map_err(|e| KernelError::Store(e.to_string()))?;
197 }
198 }
199 tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
200 Ok(())
201}
202
203pub(crate) fn edges_for_node_dir(
213 conn: &Connection,
214 node_id: &str,
215 dir: EdgeDirection,
216 relation: Option<&str>,
217) -> Result<Vec<GraphEdge>> {
218 let mut sql = String::from("SELECT id, source, target, relation, weight, ts FROM edges WHERE ");
219 match dir {
220 EdgeDirection::Out => sql.push_str("source = ?1"),
221 EdgeDirection::In => sql.push_str("target = ?1"),
222 EdgeDirection::Both => sql.push_str("(source = ?1 OR target = ?1)"),
223 }
224 if relation.is_some() {
225 sql.push_str(" AND relation = ?2");
226 }
227 sql.push_str(" ORDER BY weight DESC");
228 let mut stmt = conn
229 .prepare(&sql)
230 .map_err(|e| KernelError::Store(e.to_string()))?;
231 let mapper = |row: &rusqlite::Row<'_>| {
232 Ok(GraphEdge {
233 id: row.get(0)?,
234 source: row.get(1)?,
235 target: row.get(2)?,
236 relation: row.get(3)?,
237 weight: row.get(4)?,
238 ts: row.get(5)?,
239 })
240 };
241 let edges: Vec<GraphEdge> = if let Some(r) = relation {
242 stmt.query_map(params![node_id, r], mapper)
243 } else {
244 stmt.query_map(params![node_id], mapper)
245 }
246 .map_err(|e| KernelError::Store(e.to_string()))?
247 .filter_map(|r| r.ok())
248 .collect();
249 Ok(edges)
250}
251
252pub fn read_edges(conn: &Connection, limit: usize) -> Result<Vec<GraphEdge>> {
254 let mut stmt = conn
255 .prepare("SELECT id, source, target, relation, weight, ts FROM edges LIMIT ?1")
256 .map_err(|e| KernelError::Store(e.to_string()))?;
257 let edges: Vec<GraphEdge> = stmt
258 .query_map(params![limit as i64], |row| {
259 Ok(GraphEdge {
260 id: row.get(0)?,
261 source: row.get(1)?,
262 target: row.get(2)?,
263 relation: row.get(3)?,
264 weight: row.get(4)?,
265 ts: row.get(5)?,
266 })
267 })
268 .map_err(|e| KernelError::Store(e.to_string()))?
269 .filter_map(|r| r.ok())
270 .collect();
271 Ok(edges)
272}
273
274pub(crate) fn edges_among(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphEdge>> {
281 if ids.is_empty() {
282 return Ok(Vec::new());
283 }
284 let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
285 let sql = format!(
286 "SELECT id, source, target, relation, weight, ts FROM edges \
287 WHERE source IN ({ph}) AND target IN ({ph})"
288 );
289 let mut stmt = conn
290 .prepare(&sql)
291 .map_err(|e| KernelError::Store(e.to_string()))?;
292 let edges: Vec<GraphEdge> = stmt
293 .query_map(
294 rusqlite::params_from_iter(ids.iter().chain(ids.iter()).copied()),
295 |row| {
296 Ok(GraphEdge {
297 id: row.get(0)?,
298 source: row.get(1)?,
299 target: row.get(2)?,
300 relation: row.get(3)?,
301 weight: row.get(4)?,
302 ts: row.get(5)?,
303 })
304 },
305 )
306 .map_err(|e| KernelError::Store(e.to_string()))?
307 .filter_map(|r| r.ok())
308 .collect();
309 Ok(edges)
310}
311
312pub fn delete_edge(conn: &Connection, id: &str) -> Result<bool> {
314 let changed = conn
315 .execute("DELETE FROM edges WHERE id = ?1", params![id])
316 .map_err(|e| KernelError::Store(e.to_string()))?;
317 Ok(changed > 0)
318}
319
320pub(crate) fn remove_edges_for_node(conn: &Connection, node_id: &str) -> Result<()> {
322 conn.execute(
323 "DELETE FROM edges WHERE source = ?1 OR target = ?1",
324 params![node_id],
325 )
326 .map_err(|e| KernelError::Store(e.to_string()))?;
327 Ok(())
328}
329
330pub(crate) fn edges_for_node(conn: &Connection, node_id: &str) -> Result<Vec<GraphEdge>> {
332 let mut stmt = conn
333 .prepare(
334 "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = ?1 OR target = ?1",
335 )
336 .map_err(|e| KernelError::Store(e.to_string()))?;
337 let edges: Vec<GraphEdge> = stmt
338 .query_map(params![node_id], |row| {
339 Ok(GraphEdge {
340 id: row.get(0)?,
341 source: row.get(1)?,
342 target: row.get(2)?,
343 relation: row.get(3)?,
344 weight: row.get(4)?,
345 ts: row.get(5)?,
346 })
347 })
348 .map_err(|e| KernelError::Store(e.to_string()))?
349 .filter_map(|r| r.ok())
350 .collect();
351 Ok(edges)
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::graph::schema::init_graph_schema;
358 use rusqlite::Connection;
359
360 fn mem_db() -> Connection {
361 let conn = Connection::open_in_memory().unwrap();
362 init_graph_schema(&conn).unwrap();
363 conn
364 }
365
366 fn test_node(id: &str) -> GraphNode {
367 GraphNode {
368 id: id.to_string(),
369 node_type: "concept".to_string(),
370 title: format!("Node {id}"),
371 body: "test body".to_string(),
372 tags: vec!["test".to_string()],
373 projects: vec![],
374 agents: vec![],
375 created: "2026-01-01T00:00:00Z".to_string(),
376 updated: "2026-01-01T00:00:00Z".to_string(),
377 importance: 0.7,
378 access_count: 0,
379 accessed_at: String::new(),
380 ..Default::default()
381 }
382 }
383
384 #[test]
385 fn upsert_and_read_node() {
386 let conn = mem_db();
387 let node = test_node("n1");
388 upsert_node(&conn, &node).unwrap();
389 let loaded = read_node(&conn, "n1").unwrap().unwrap();
390 assert_eq!(loaded.id, "n1");
391 assert_eq!(loaded.title, "Node n1");
392 assert_eq!(loaded.tags, vec!["test"]);
393 }
394
395 #[test]
396 fn upsert_roundtrips_temporal_validity() {
397 let conn = mem_db();
398 let mut n = test_node("tv");
399 n.valid_until = "2027-01-01T00:00:00Z".to_string();
400 n.last_verified = "2026-08-01T00:00:00Z".to_string();
401 upsert_node(&conn, &n).unwrap();
402 let got = read_node(&conn, "tv").unwrap().unwrap();
403 assert_eq!(got.valid_until, "2027-01-01T00:00:00Z");
404 assert_eq!(got.last_verified, "2026-08-01T00:00:00Z");
405 }
406
407 #[test]
408 fn read_missing_node_returns_none() {
409 let conn = mem_db();
410 assert!(read_node(&conn, "nope").unwrap().is_none());
411 }
412
413 #[test]
414 fn delete_node_returns_true_when_exists() {
415 let conn = mem_db();
416 upsert_node(&conn, &test_node("n1")).unwrap();
417 assert!(delete_node(&conn, "n1").unwrap());
418 assert!(!delete_node(&conn, "n1").unwrap());
419 }
420
421 #[test]
424 fn upsert_preserves_created_and_access_on_conflict() {
425 let conn = mem_db();
426 let mut n = test_node("fixed");
427 n.created = "2026-01-01T00:00:00Z".to_string();
428 n.access_count = 42;
429 upsert_node(&conn, &n).unwrap();
430
431 let mut again = test_node("fixed");
433 again.created = "2026-12-31T00:00:00Z".to_string();
434 again.updated = "2026-12-31T00:00:00Z".to_string();
435 again.access_count = 0;
436 upsert_node(&conn, &again).unwrap();
437
438 let got = read_node(&conn, "fixed").unwrap().unwrap();
439 assert_eq!(got.created, "2026-01-01T00:00:00Z", "created must persist");
440 assert_eq!(
441 got.access_count, 42,
442 "access_count must not be zeroed on re-upsert"
443 );
444 assert_eq!(got.updated, "2026-12-31T00:00:00Z", "updated must refresh");
445 }
446
447 #[test]
449 fn delete_node_removes_edges() {
450 let conn = mem_db();
451 upsert_node(&conn, &test_node("a")).unwrap();
452 upsert_node(&conn, &test_node("b")).unwrap();
453 append_edge(
454 &conn,
455 &GraphEdge {
456 id: "e1".into(),
457 source: "a".into(),
458 target: "b".into(),
459 relation: "rel".into(),
460 weight: 1.0,
461 ts: "2026-01-01T00:00:00Z".into(),
462 },
463 )
464 .unwrap();
465
466 assert!(delete_node(&conn, "a").unwrap());
467 let edges = edges_for_node(&conn, "a").unwrap();
468 assert!(edges.is_empty(), "orphan edges remain: {edges:?}");
469 }
470
471 #[test]
472 fn list_node_ids_returns_all() {
473 let conn = mem_db();
474 upsert_node(&conn, &test_node("a")).unwrap();
475 upsert_node(&conn, &test_node("b")).unwrap();
476 let ids = list_node_ids(&conn).unwrap();
477 assert_eq!(ids.len(), 2);
478 }
479
480 #[test]
481 fn append_and_read_edges() {
482 let conn = mem_db();
483 let edge = GraphEdge {
484 id: "e1".to_string(),
485 source: "a".to_string(),
486 target: "b".to_string(),
487 relation: "related".to_string(),
488 weight: 1.0,
489 ts: "2026-01-01T00:00:00Z".to_string(),
490 };
491 append_edge(&conn, &edge).unwrap();
492 let edges = read_edges(&conn, 10).unwrap();
493 assert_eq!(edges.len(), 1);
494 assert_eq!(edges[0].source, "a");
495 }
496
497 #[test]
498 fn edges_for_node_returns_both_directions() {
499 let conn = mem_db();
500 append_edge(
501 &conn,
502 &GraphEdge {
503 id: "e1".into(),
504 source: "a".into(),
505 target: "b".into(),
506 relation: "related".into(),
507 weight: 1.0,
508 ts: "2026-01-01T00:00:00Z".into(),
509 },
510 )
511 .unwrap();
512 append_edge(
513 &conn,
514 &GraphEdge {
515 id: "e2".into(),
516 source: "c".into(),
517 target: "a".into(),
518 relation: "related".into(),
519 weight: 1.0,
520 ts: "2026-01-01T00:00:00Z".into(),
521 },
522 )
523 .unwrap();
524 let edges = edges_for_node(&conn, "a").unwrap();
525 assert_eq!(edges.len(), 2);
526 }
527
528 #[test]
529 fn test_remove_edges_for_node() {
530 let conn = mem_db();
531 append_edge(
532 &conn,
533 &GraphEdge {
534 id: "e1".into(),
535 source: "a".into(),
536 target: "b".into(),
537 relation: "related".into(),
538 weight: 1.0,
539 ts: "2026-01-01T00:00:00Z".into(),
540 },
541 )
542 .unwrap();
543 remove_edges_for_node(&conn, "a").unwrap();
544 assert!(read_edges(&conn, 10).unwrap().is_empty());
545 }
546
547 #[test]
548 fn append_edges_inserts_batch() {
549 let conn = mem_db();
550 let edges = vec![
551 GraphEdge {
552 id: "e1".into(),
553 source: "a".into(),
554 target: "b".into(),
555 relation: "cites".into(),
556 weight: 1.0,
557 ts: "2026-01-01T00:00:00Z".into(),
558 },
559 GraphEdge {
560 id: "e2".into(),
561 source: "a".into(),
562 target: "c".into(),
563 relation: "cites".into(),
564 weight: 0.5,
565 ts: "2026-01-01T00:00:00Z".into(),
566 },
567 ];
568 append_edges(&conn, &edges).unwrap();
569 assert_eq!(read_edges(&conn, 10).unwrap().len(), 2);
570 }
571
572 #[test]
573 fn append_edges_empty_is_noop() {
574 let conn = mem_db();
575 append_edges(&conn, &[]).unwrap();
576 assert!(read_edges(&conn, 10).unwrap().is_empty());
577 }
578
579 #[test]
580 fn append_edges_ignores_duplicate_id_and_unique() {
581 let conn = mem_db();
582 let e = GraphEdge {
583 id: "e1".into(),
584 source: "a".into(),
585 target: "b".into(),
586 relation: "cites".into(),
587 weight: 1.0,
588 ts: "2026-01-01T00:00:00Z".into(),
589 };
590 append_edges(&conn, std::slice::from_ref(&e)).unwrap();
591 append_edges(
594 &conn,
595 &[
596 e,
597 GraphEdge {
598 id: "e2".into(),
599 source: "a".into(),
600 target: "b".into(),
601 relation: "cites".into(),
602 weight: 1.0,
603 ts: "2026-01-01T00:00:00Z".into(),
604 },
605 ],
606 )
607 .unwrap();
608 assert_eq!(read_edges(&conn, 10).unwrap().len(), 1);
609 }
610
611 #[test]
612 fn edges_for_node_dir_filters_direction_and_relation() {
613 let conn = mem_db();
614 append_edges(
615 &conn,
616 &[
617 GraphEdge {
618 id: "e1".into(),
619 source: "a".into(),
620 target: "b".into(),
621 relation: "cites".into(),
622 weight: 1.0,
623 ts: "t".into(),
624 },
625 GraphEdge {
626 id: "e2".into(),
627 source: "c".into(),
628 target: "a".into(),
629 relation: "cites".into(),
630 weight: 1.0,
631 ts: "t".into(),
632 },
633 GraphEdge {
634 id: "e3".into(),
635 source: "a".into(),
636 target: "d".into(),
637 relation: "see_also".into(),
638 weight: 1.0,
639 ts: "t".into(),
640 },
641 ],
642 )
643 .unwrap();
644 assert_eq!(
646 edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
647 .unwrap()
648 .len(),
649 2
650 );
651 let out_cites = edges_for_node_dir(&conn, "a", EdgeDirection::Out, Some("cites")).unwrap();
653 assert_eq!(out_cites.len(), 1);
654 assert_eq!(out_cites[0].target, "b");
655 let inc = edges_for_node_dir(&conn, "a", EdgeDirection::In, None).unwrap();
657 assert_eq!(inc.len(), 1);
658 assert_eq!(inc[0].source, "c");
659 assert_eq!(
661 edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
662 .unwrap()
663 .len(),
664 3
665 );
666 }
667}