Skip to main content

llm_kernel/graph/
store.rs

1//! Node and edge CRUD operations.
2
3use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{EdgeDirection, GraphEdge, GraphNode, NODE_COLUMNS, join_csv, row_to_node};
8
9// ── Node CRUD ─────────────────────────────────────────
10
11/// Insert or replace a node.
12pub fn upsert_node(conn: &Connection, node: &GraphNode) -> Result<()> {
13    conn.execute(
14        "INSERT OR REPLACE INTO nodes
15         (id, type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at)
16         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
17        params![
18            node.id,
19            node.node_type,
20            node.title,
21            join_csv(&node.tags),
22            join_csv(&node.projects),
23            join_csv(&node.agents),
24            node.created,
25            node.updated,
26            node.body,
27            node.importance,
28            node.access_count,
29            node.accessed_at,
30        ],
31    )
32    .map_err(|e| KernelError::Store(e.to_string()))?;
33    Ok(())
34}
35
36/// Read a single node by ID. Returns `None` if not found.
37pub fn read_node(conn: &Connection, id: &str) -> Result<Option<GraphNode>> {
38    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1");
39    match conn.query_row(&sql, params![id], row_to_node) {
40        Ok(node) => Ok(Some(node)),
41        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
42        Err(e) => Err(KernelError::Store(e.to_string())),
43    }
44}
45
46/// Batch-read multiple nodes by ID.
47pub fn read_nodes(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphNode>> {
48    if ids.is_empty() {
49        return Ok(vec![]);
50    }
51    let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
52    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id IN ({ph})");
53    let mut stmt = conn
54        .prepare(&sql)
55        .map_err(|e| KernelError::Store(e.to_string()))?;
56    let nodes: Vec<GraphNode> = stmt
57        .query_map(rusqlite::params_from_iter(ids.iter()), row_to_node)
58        .map_err(|e| KernelError::Store(e.to_string()))?
59        .filter_map(|r| r.ok())
60        .collect();
61    Ok(nodes)
62}
63
64/// Delete a node by ID. Returns whether a row was deleted.
65pub fn delete_node(conn: &Connection, id: &str) -> Result<bool> {
66    let changed = conn
67        .execute("DELETE FROM nodes WHERE id = ?1", params![id])
68        .map_err(|e| KernelError::Store(e.to_string()))?;
69    Ok(changed > 0)
70}
71
72/// List all node IDs.
73pub fn list_node_ids(conn: &Connection) -> Result<Vec<String>> {
74    let mut stmt = conn
75        .prepare("SELECT id FROM nodes")
76        .map_err(|e| KernelError::Store(e.to_string()))?;
77    let ids: Vec<String> = stmt
78        .query_map([], |row| row.get(0))
79        .map_err(|e| KernelError::Store(e.to_string()))?
80        .filter_map(|r| r.ok())
81        .collect();
82    Ok(ids)
83}
84
85/// Read nodes with optional limit, ordered by updated DESC.
86pub fn read_nodes_limited(conn: &Connection, limit: usize) -> Result<Vec<GraphNode>> {
87    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes ORDER BY updated DESC LIMIT ?");
88    let mut stmt = conn
89        .prepare(&sql)
90        .map_err(|e| KernelError::Store(e.to_string()))?;
91    let nodes: Vec<GraphNode> = stmt
92        .query_map(params![limit as i64], row_to_node)
93        .map_err(|e| KernelError::Store(e.to_string()))?
94        .filter_map(|r| r.ok())
95        .collect();
96    Ok(nodes)
97}
98
99// ── Edge CRUD ─────────────────────────────────────────
100
101/// Append an edge (INSERT OR IGNORE — duplicates by ID are skipped).
102pub fn append_edge(conn: &Connection, edge: &GraphEdge) -> Result<()> {
103    conn.execute(
104        "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
105         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
106        params![
107            edge.id,
108            edge.source,
109            edge.target,
110            edge.relation,
111            edge.weight,
112            edge.ts
113        ],
114    )
115    .map_err(|e| KernelError::Store(e.to_string()))?;
116    Ok(())
117}
118
119/// Append many edges in a single transaction (INSERT OR IGNORE — duplicates
120/// by ID *or* by the `(source, target, relation)` unique index are skipped).
121///
122/// Equivalent to calling [`append_edge`] per edge, but commits once and reuses
123/// one prepared statement, so it scales to hundreds of thousands of edges
124/// (e.g. building a citation graph during indexing).
125pub fn append_edges(conn: &Connection, edges: &[GraphEdge]) -> Result<()> {
126    if edges.is_empty() {
127        return Ok(());
128    }
129    let tx = conn
130        .unchecked_transaction()
131        .map_err(|e| KernelError::Store(e.to_string()))?;
132    {
133        let mut stmt = tx
134            .prepare(
135                "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
136                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
137            )
138            .map_err(|e| KernelError::Store(e.to_string()))?;
139        for edge in edges {
140            stmt.execute(params![
141                edge.id,
142                edge.source,
143                edge.target,
144                edge.relation,
145                edge.weight,
146                edge.ts
147            ])
148            .map_err(|e| KernelError::Store(e.to_string()))?;
149        }
150    }
151    tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
152    Ok(())
153}
154
155/// Read edges touching `node_id`, filtered by direction and optional relation.
156///
157/// - [`EdgeDirection::Both`] matches `source = node_id OR target = node_id`
158///   (the historical behavior).
159/// - [`EdgeDirection::Out`] matches `source = node_id` only (out-edges).
160/// - [`EdgeDirection::In`] matches `target = node_id` only (in-edges).
161///
162/// When `relation` is `Some(r)`, additionally filters `relation = r`, using the
163/// `idx_edges_src_rel` / `idx_edges_tgt_rel` v3 indexes for the directional cases.
164pub(crate) fn edges_for_node_dir(
165    conn: &Connection,
166    node_id: &str,
167    dir: EdgeDirection,
168    relation: Option<&str>,
169) -> Result<Vec<GraphEdge>> {
170    let mut sql = String::from("SELECT id, source, target, relation, weight, ts FROM edges WHERE ");
171    match dir {
172        EdgeDirection::Out => sql.push_str("source = ?1"),
173        EdgeDirection::In => sql.push_str("target = ?1"),
174        EdgeDirection::Both => sql.push_str("(source = ?1 OR target = ?1)"),
175    }
176    if relation.is_some() {
177        sql.push_str(" AND relation = ?2");
178    }
179    sql.push_str(" ORDER BY weight DESC");
180    let mut stmt = conn
181        .prepare(&sql)
182        .map_err(|e| KernelError::Store(e.to_string()))?;
183    let mapper = |row: &rusqlite::Row<'_>| {
184        Ok(GraphEdge {
185            id: row.get(0)?,
186            source: row.get(1)?,
187            target: row.get(2)?,
188            relation: row.get(3)?,
189            weight: row.get(4)?,
190            ts: row.get(5)?,
191        })
192    };
193    let edges: Vec<GraphEdge> = if let Some(r) = relation {
194        stmt.query_map(params![node_id, r], mapper)
195    } else {
196        stmt.query_map(params![node_id], mapper)
197    }
198    .map_err(|e| KernelError::Store(e.to_string()))?
199    .filter_map(|r| r.ok())
200    .collect();
201    Ok(edges)
202}
203
204/// Read edges, capped at `limit`.
205pub fn read_edges(conn: &Connection, limit: usize) -> Result<Vec<GraphEdge>> {
206    let mut stmt = conn
207        .prepare("SELECT id, source, target, relation, weight, ts FROM edges LIMIT ?1")
208        .map_err(|e| KernelError::Store(e.to_string()))?;
209    let edges: Vec<GraphEdge> = stmt
210        .query_map(params![limit as i64], |row| {
211            Ok(GraphEdge {
212                id: row.get(0)?,
213                source: row.get(1)?,
214                target: row.get(2)?,
215                relation: row.get(3)?,
216                weight: row.get(4)?,
217                ts: row.get(5)?,
218            })
219        })
220        .map_err(|e| KernelError::Store(e.to_string()))?
221        .filter_map(|r| r.ok())
222        .collect();
223    Ok(edges)
224}
225
226/// Read edges whose source AND target are both in `ids` — the induced subgraph
227/// over a candidate node set.
228///
229/// Used to build the candidate subgraph for PageRank boosting in
230/// [`smart_recall`](super::recall::smart_recall). `ids.len()` must stay under
231/// SQLite's bind-variable limit (999 by default); `smart_recall` caps at 100.
232pub(crate) fn edges_among(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphEdge>> {
233    if ids.is_empty() {
234        return Ok(Vec::new());
235    }
236    let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
237    let sql = format!(
238        "SELECT id, source, target, relation, weight, ts FROM edges \
239         WHERE source IN ({ph}) AND target IN ({ph})"
240    );
241    let mut stmt = conn
242        .prepare(&sql)
243        .map_err(|e| KernelError::Store(e.to_string()))?;
244    let edges: Vec<GraphEdge> = stmt
245        .query_map(
246            rusqlite::params_from_iter(ids.iter().chain(ids.iter()).copied()),
247            |row| {
248                Ok(GraphEdge {
249                    id: row.get(0)?,
250                    source: row.get(1)?,
251                    target: row.get(2)?,
252                    relation: row.get(3)?,
253                    weight: row.get(4)?,
254                    ts: row.get(5)?,
255                })
256            },
257        )
258        .map_err(|e| KernelError::Store(e.to_string()))?
259        .filter_map(|r| r.ok())
260        .collect();
261    Ok(edges)
262}
263
264/// Delete an edge by ID.
265pub fn delete_edge(conn: &Connection, id: &str) -> Result<bool> {
266    let changed = conn
267        .execute("DELETE FROM edges WHERE id = ?1", params![id])
268        .map_err(|e| KernelError::Store(e.to_string()))?;
269    Ok(changed > 0)
270}
271
272/// Delete all edges connected to a node (source or target).
273pub(crate) fn remove_edges_for_node(conn: &Connection, node_id: &str) -> Result<()> {
274    conn.execute(
275        "DELETE FROM edges WHERE source = ?1 OR target = ?1",
276        params![node_id],
277    )
278    .map_err(|e| KernelError::Store(e.to_string()))?;
279    Ok(())
280}
281
282/// Read edges where the given node is source or target.
283pub(crate) fn edges_for_node(conn: &Connection, node_id: &str) -> Result<Vec<GraphEdge>> {
284    let mut stmt = conn
285        .prepare(
286            "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = ?1 OR target = ?1",
287        )
288        .map_err(|e| KernelError::Store(e.to_string()))?;
289    let edges: Vec<GraphEdge> = stmt
290        .query_map(params![node_id], |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        .map_err(|e| KernelError::Store(e.to_string()))?
301        .filter_map(|r| r.ok())
302        .collect();
303    Ok(edges)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::graph::schema::init_graph_schema;
310    use rusqlite::Connection;
311
312    fn mem_db() -> Connection {
313        let conn = Connection::open_in_memory().unwrap();
314        init_graph_schema(&conn).unwrap();
315        conn
316    }
317
318    fn test_node(id: &str) -> GraphNode {
319        GraphNode {
320            id: id.to_string(),
321            node_type: "concept".to_string(),
322            title: format!("Node {id}"),
323            body: "test body".to_string(),
324            tags: vec!["test".to_string()],
325            projects: vec![],
326            agents: vec![],
327            created: "2026-01-01T00:00:00Z".to_string(),
328            updated: "2026-01-01T00:00:00Z".to_string(),
329            importance: 0.7,
330            access_count: 0,
331            accessed_at: String::new(),
332        }
333    }
334
335    #[test]
336    fn upsert_and_read_node() {
337        let conn = mem_db();
338        let node = test_node("n1");
339        upsert_node(&conn, &node).unwrap();
340        let loaded = read_node(&conn, "n1").unwrap().unwrap();
341        assert_eq!(loaded.id, "n1");
342        assert_eq!(loaded.title, "Node n1");
343        assert_eq!(loaded.tags, vec!["test"]);
344    }
345
346    #[test]
347    fn read_missing_node_returns_none() {
348        let conn = mem_db();
349        assert!(read_node(&conn, "nope").unwrap().is_none());
350    }
351
352    #[test]
353    fn delete_node_returns_true_when_exists() {
354        let conn = mem_db();
355        upsert_node(&conn, &test_node("n1")).unwrap();
356        assert!(delete_node(&conn, "n1").unwrap());
357        assert!(!delete_node(&conn, "n1").unwrap());
358    }
359
360    #[test]
361    fn list_node_ids_returns_all() {
362        let conn = mem_db();
363        upsert_node(&conn, &test_node("a")).unwrap();
364        upsert_node(&conn, &test_node("b")).unwrap();
365        let ids = list_node_ids(&conn).unwrap();
366        assert_eq!(ids.len(), 2);
367    }
368
369    #[test]
370    fn append_and_read_edges() {
371        let conn = mem_db();
372        let edge = GraphEdge {
373            id: "e1".to_string(),
374            source: "a".to_string(),
375            target: "b".to_string(),
376            relation: "related".to_string(),
377            weight: 1.0,
378            ts: "2026-01-01T00:00:00Z".to_string(),
379        };
380        append_edge(&conn, &edge).unwrap();
381        let edges = read_edges(&conn, 10).unwrap();
382        assert_eq!(edges.len(), 1);
383        assert_eq!(edges[0].source, "a");
384    }
385
386    #[test]
387    fn edges_for_node_returns_both_directions() {
388        let conn = mem_db();
389        append_edge(
390            &conn,
391            &GraphEdge {
392                id: "e1".into(),
393                source: "a".into(),
394                target: "b".into(),
395                relation: "related".into(),
396                weight: 1.0,
397                ts: "2026-01-01T00:00:00Z".into(),
398            },
399        )
400        .unwrap();
401        append_edge(
402            &conn,
403            &GraphEdge {
404                id: "e2".into(),
405                source: "c".into(),
406                target: "a".into(),
407                relation: "related".into(),
408                weight: 1.0,
409                ts: "2026-01-01T00:00:00Z".into(),
410            },
411        )
412        .unwrap();
413        let edges = edges_for_node(&conn, "a").unwrap();
414        assert_eq!(edges.len(), 2);
415    }
416
417    #[test]
418    fn test_remove_edges_for_node() {
419        let conn = mem_db();
420        append_edge(
421            &conn,
422            &GraphEdge {
423                id: "e1".into(),
424                source: "a".into(),
425                target: "b".into(),
426                relation: "related".into(),
427                weight: 1.0,
428                ts: "2026-01-01T00:00:00Z".into(),
429            },
430        )
431        .unwrap();
432        remove_edges_for_node(&conn, "a").unwrap();
433        assert!(read_edges(&conn, 10).unwrap().is_empty());
434    }
435
436    #[test]
437    fn append_edges_inserts_batch() {
438        let conn = mem_db();
439        let edges = vec![
440            GraphEdge {
441                id: "e1".into(),
442                source: "a".into(),
443                target: "b".into(),
444                relation: "cites".into(),
445                weight: 1.0,
446                ts: "2026-01-01T00:00:00Z".into(),
447            },
448            GraphEdge {
449                id: "e2".into(),
450                source: "a".into(),
451                target: "c".into(),
452                relation: "cites".into(),
453                weight: 0.5,
454                ts: "2026-01-01T00:00:00Z".into(),
455            },
456        ];
457        append_edges(&conn, &edges).unwrap();
458        assert_eq!(read_edges(&conn, 10).unwrap().len(), 2);
459    }
460
461    #[test]
462    fn append_edges_empty_is_noop() {
463        let conn = mem_db();
464        append_edges(&conn, &[]).unwrap();
465        assert!(read_edges(&conn, 10).unwrap().is_empty());
466    }
467
468    #[test]
469    fn append_edges_ignores_duplicate_id_and_unique() {
470        let conn = mem_db();
471        let e = GraphEdge {
472            id: "e1".into(),
473            source: "a".into(),
474            target: "b".into(),
475            relation: "cites".into(),
476            weight: 1.0,
477            ts: "2026-01-01T00:00:00Z".into(),
478        };
479        append_edges(&conn, &[e.clone()]).unwrap();
480        // Same id (INSERT OR IGNORE) and same (source, target, relation) with a
481        // different id (unique index) are both skipped.
482        append_edges(
483            &conn,
484            &[
485                e,
486                GraphEdge {
487                    id: "e2".into(),
488                    source: "a".into(),
489                    target: "b".into(),
490                    relation: "cites".into(),
491                    weight: 1.0,
492                    ts: "2026-01-01T00:00:00Z".into(),
493                },
494            ],
495        )
496        .unwrap();
497        assert_eq!(read_edges(&conn, 10).unwrap().len(), 1);
498    }
499
500    #[test]
501    fn edges_for_node_dir_filters_direction_and_relation() {
502        let conn = mem_db();
503        append_edges(
504            &conn,
505            &[
506                GraphEdge {
507                    id: "e1".into(),
508                    source: "a".into(),
509                    target: "b".into(),
510                    relation: "cites".into(),
511                    weight: 1.0,
512                    ts: "t".into(),
513                },
514                GraphEdge {
515                    id: "e2".into(),
516                    source: "c".into(),
517                    target: "a".into(),
518                    relation: "cites".into(),
519                    weight: 1.0,
520                    ts: "t".into(),
521                },
522                GraphEdge {
523                    id: "e3".into(),
524                    source: "a".into(),
525                    target: "d".into(),
526                    relation: "see_also".into(),
527                    weight: 1.0,
528                    ts: "t".into(),
529                },
530            ],
531        )
532        .unwrap();
533        // Out-edges of `a`: b (cites) and d (see_also).
534        assert_eq!(
535            edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
536                .unwrap()
537                .len(),
538            2
539        );
540        // Out-edges of `a` restricted to `cites`: only b.
541        let out_cites = edges_for_node_dir(&conn, "a", EdgeDirection::Out, Some("cites")).unwrap();
542        assert_eq!(out_cites.len(), 1);
543        assert_eq!(out_cites[0].target, "b");
544        // In-edges of `a`: c→a.
545        let inc = edges_for_node_dir(&conn, "a", EdgeDirection::In, None).unwrap();
546        assert_eq!(inc.len(), 1);
547        assert_eq!(inc[0].source, "c");
548        // Both directions: b, d, c → 3.
549        assert_eq!(
550            edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
551                .unwrap()
552                .len(),
553            3
554        );
555    }
556}