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 update a node, preserving first-creation and access telemetry.
12///
13/// Uses `ON CONFLICT ... DO UPDATE` rather than `INSERT OR REPLACE` so that
14/// re-inserting a fixed-id node (e.g. an identity/stock node touched on every
15/// analysis) does not destroy `created`, `access_count`, or `accessed_at`.
16/// `INSERT OR REPLACE` is DELETE+INSERT in SQLite, which zeroes the access
17/// counters and rewrites the creation timestamp on every write — diverging from
18/// the Postgres backend (`sqlx_pg.rs`), which already does `ON CONFLICT`. This
19/// brings SQLite to parity.
20///
21/// - `created`: first-write wins (`nodes.created` is kept).
22/// - `access_count`/`accessed_at`: the caller's value never *lowers* an existing
23///   one (`MAX(...)`), so an import restoring a higher count is honored while a
24///   routine `access_count: 0` write is a no-op on update.
25pub 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)
30         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
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        params![
44            node.id,
45            node.node_type,
46            node.title,
47            join_csv(&node.tags),
48            join_csv(&node.projects),
49            join_csv(&node.agents),
50            node.created,
51            node.updated,
52            node.body,
53            node.importance,
54            node.access_count,
55            node.accessed_at,
56        ],
57    )
58    .map_err(|e| KernelError::Store(e.to_string()))?;
59    Ok(())
60}
61
62/// Read a single node by ID. Returns `None` if not found.
63pub fn read_node(conn: &Connection, id: &str) -> Result<Option<GraphNode>> {
64    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1");
65    match conn.query_row(&sql, params![id], row_to_node) {
66        Ok(node) => Ok(Some(node)),
67        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
68        Err(e) => Err(KernelError::Store(e.to_string())),
69    }
70}
71
72/// Batch-read multiple nodes by ID.
73pub fn read_nodes(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphNode>> {
74    if ids.is_empty() {
75        return Ok(vec![]);
76    }
77    let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
78    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id IN ({ph})");
79    let mut stmt = conn
80        .prepare(&sql)
81        .map_err(|e| KernelError::Store(e.to_string()))?;
82    let nodes: Vec<GraphNode> = stmt
83        .query_map(rusqlite::params_from_iter(ids.iter()), row_to_node)
84        .map_err(|e| KernelError::Store(e.to_string()))?
85        .filter_map(|r| r.ok())
86        .collect();
87    Ok(nodes)
88}
89
90/// Delete a node by ID, removing its edges first in the same transaction.
91///
92/// Previously this deleted only the node row and left dangling edges behind —
93/// `remove_edges_for_node` existed but was never called here. The transaction
94/// keeps the node deletion and edge cleanup atomic.
95pub fn delete_node(conn: &Connection, id: &str) -> Result<bool> {
96    // `unchecked_transaction` is used instead of `transaction` because the
97    // function signature takes `&Connection` (not `&mut`). This is safe: in the
98    // async pool (`AsyncPoolGraph::with_conn`) the semaphore guarantees exclusive
99    // access, and in sync call sites no other statements are open on `conn`.
100    let tx = conn
101        .unchecked_transaction()
102        .map_err(|e| KernelError::Store(e.to_string()))?;
103    remove_edges_for_node(&tx, id)?;
104    let changed = tx
105        .execute("DELETE FROM nodes WHERE id = ?1", params![id])
106        .map_err(|e| KernelError::Store(e.to_string()))?;
107    tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
108    Ok(changed > 0)
109}
110
111/// List all node IDs.
112pub fn list_node_ids(conn: &Connection) -> Result<Vec<String>> {
113    let mut stmt = conn
114        .prepare("SELECT id FROM nodes")
115        .map_err(|e| KernelError::Store(e.to_string()))?;
116    let ids: Vec<String> = stmt
117        .query_map([], |row| row.get(0))
118        .map_err(|e| KernelError::Store(e.to_string()))?
119        .filter_map(|r| r.ok())
120        .collect();
121    Ok(ids)
122}
123
124/// Read nodes with optional limit, ordered by updated DESC.
125pub fn read_nodes_limited(conn: &Connection, limit: usize) -> Result<Vec<GraphNode>> {
126    let sql = format!("SELECT {NODE_COLUMNS} FROM nodes ORDER BY updated DESC LIMIT ?");
127    let mut stmt = conn
128        .prepare(&sql)
129        .map_err(|e| KernelError::Store(e.to_string()))?;
130    let nodes: Vec<GraphNode> = stmt
131        .query_map(params![limit as i64], row_to_node)
132        .map_err(|e| KernelError::Store(e.to_string()))?
133        .filter_map(|r| r.ok())
134        .collect();
135    Ok(nodes)
136}
137
138// ── Edge CRUD ─────────────────────────────────────────
139
140/// Append an edge (INSERT OR IGNORE — duplicates by ID are skipped).
141pub fn append_edge(conn: &Connection, edge: &GraphEdge) -> Result<()> {
142    conn.execute(
143        "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
144         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
145        params![
146            edge.id,
147            edge.source,
148            edge.target,
149            edge.relation,
150            edge.weight,
151            edge.ts
152        ],
153    )
154    .map_err(|e| KernelError::Store(e.to_string()))?;
155    Ok(())
156}
157
158/// Append many edges in a single transaction (INSERT OR IGNORE — duplicates
159/// by ID *or* by the `(source, target, relation)` unique index are skipped).
160///
161/// Equivalent to calling [`append_edge`] per edge, but commits once and reuses
162/// one prepared statement, so it scales to hundreds of thousands of edges
163/// (e.g. building a citation graph during indexing).
164pub fn append_edges(conn: &Connection, edges: &[GraphEdge]) -> Result<()> {
165    if edges.is_empty() {
166        return Ok(());
167    }
168    let tx = conn
169        .unchecked_transaction()
170        .map_err(|e| KernelError::Store(e.to_string()))?;
171    {
172        let mut stmt = tx
173            .prepare(
174                "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
175                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
176            )
177            .map_err(|e| KernelError::Store(e.to_string()))?;
178        for edge in edges {
179            stmt.execute(params![
180                edge.id,
181                edge.source,
182                edge.target,
183                edge.relation,
184                edge.weight,
185                edge.ts
186            ])
187            .map_err(|e| KernelError::Store(e.to_string()))?;
188        }
189    }
190    tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
191    Ok(())
192}
193
194/// Read edges touching `node_id`, filtered by direction and optional relation.
195///
196/// - [`EdgeDirection::Both`] matches `source = node_id OR target = node_id`
197///   (the historical behavior).
198/// - [`EdgeDirection::Out`] matches `source = node_id` only (out-edges).
199/// - [`EdgeDirection::In`] matches `target = node_id` only (in-edges).
200///
201/// When `relation` is `Some(r)`, additionally filters `relation = r`, using the
202/// `idx_edges_src_rel` / `idx_edges_tgt_rel` v3 indexes for the directional cases.
203pub(crate) fn edges_for_node_dir(
204    conn: &Connection,
205    node_id: &str,
206    dir: EdgeDirection,
207    relation: Option<&str>,
208) -> Result<Vec<GraphEdge>> {
209    let mut sql = String::from("SELECT id, source, target, relation, weight, ts FROM edges WHERE ");
210    match dir {
211        EdgeDirection::Out => sql.push_str("source = ?1"),
212        EdgeDirection::In => sql.push_str("target = ?1"),
213        EdgeDirection::Both => sql.push_str("(source = ?1 OR target = ?1)"),
214    }
215    if relation.is_some() {
216        sql.push_str(" AND relation = ?2");
217    }
218    sql.push_str(" ORDER BY weight DESC");
219    let mut stmt = conn
220        .prepare(&sql)
221        .map_err(|e| KernelError::Store(e.to_string()))?;
222    let mapper = |row: &rusqlite::Row<'_>| {
223        Ok(GraphEdge {
224            id: row.get(0)?,
225            source: row.get(1)?,
226            target: row.get(2)?,
227            relation: row.get(3)?,
228            weight: row.get(4)?,
229            ts: row.get(5)?,
230        })
231    };
232    let edges: Vec<GraphEdge> = if let Some(r) = relation {
233        stmt.query_map(params![node_id, r], mapper)
234    } else {
235        stmt.query_map(params![node_id], mapper)
236    }
237    .map_err(|e| KernelError::Store(e.to_string()))?
238    .filter_map(|r| r.ok())
239    .collect();
240    Ok(edges)
241}
242
243/// Read edges, capped at `limit`.
244pub fn read_edges(conn: &Connection, limit: usize) -> Result<Vec<GraphEdge>> {
245    let mut stmt = conn
246        .prepare("SELECT id, source, target, relation, weight, ts FROM edges LIMIT ?1")
247        .map_err(|e| KernelError::Store(e.to_string()))?;
248    let edges: Vec<GraphEdge> = stmt
249        .query_map(params![limit as i64], |row| {
250            Ok(GraphEdge {
251                id: row.get(0)?,
252                source: row.get(1)?,
253                target: row.get(2)?,
254                relation: row.get(3)?,
255                weight: row.get(4)?,
256                ts: row.get(5)?,
257            })
258        })
259        .map_err(|e| KernelError::Store(e.to_string()))?
260        .filter_map(|r| r.ok())
261        .collect();
262    Ok(edges)
263}
264
265/// Read edges whose source AND target are both in `ids` — the induced subgraph
266/// over a candidate node set.
267///
268/// Used to build the candidate subgraph for PageRank boosting in
269/// [`smart_recall`](super::recall::smart_recall). `ids.len()` must stay under
270/// SQLite's bind-variable limit (999 by default); `smart_recall` caps at 100.
271pub(crate) fn edges_among(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphEdge>> {
272    if ids.is_empty() {
273        return Ok(Vec::new());
274    }
275    let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
276    let sql = format!(
277        "SELECT id, source, target, relation, weight, ts FROM edges \
278         WHERE source IN ({ph}) AND target IN ({ph})"
279    );
280    let mut stmt = conn
281        .prepare(&sql)
282        .map_err(|e| KernelError::Store(e.to_string()))?;
283    let edges: Vec<GraphEdge> = stmt
284        .query_map(
285            rusqlite::params_from_iter(ids.iter().chain(ids.iter()).copied()),
286            |row| {
287                Ok(GraphEdge {
288                    id: row.get(0)?,
289                    source: row.get(1)?,
290                    target: row.get(2)?,
291                    relation: row.get(3)?,
292                    weight: row.get(4)?,
293                    ts: row.get(5)?,
294                })
295            },
296        )
297        .map_err(|e| KernelError::Store(e.to_string()))?
298        .filter_map(|r| r.ok())
299        .collect();
300    Ok(edges)
301}
302
303/// Delete an edge by ID.
304pub fn delete_edge(conn: &Connection, id: &str) -> Result<bool> {
305    let changed = conn
306        .execute("DELETE FROM edges WHERE id = ?1", params![id])
307        .map_err(|e| KernelError::Store(e.to_string()))?;
308    Ok(changed > 0)
309}
310
311/// Delete all edges connected to a node (source or target).
312pub(crate) fn remove_edges_for_node(conn: &Connection, node_id: &str) -> Result<()> {
313    conn.execute(
314        "DELETE FROM edges WHERE source = ?1 OR target = ?1",
315        params![node_id],
316    )
317    .map_err(|e| KernelError::Store(e.to_string()))?;
318    Ok(())
319}
320
321/// Read edges where the given node is source or target.
322pub(crate) fn edges_for_node(conn: &Connection, node_id: &str) -> Result<Vec<GraphEdge>> {
323    let mut stmt = conn
324        .prepare(
325            "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = ?1 OR target = ?1",
326        )
327        .map_err(|e| KernelError::Store(e.to_string()))?;
328    let edges: Vec<GraphEdge> = stmt
329        .query_map(params![node_id], |row| {
330            Ok(GraphEdge {
331                id: row.get(0)?,
332                source: row.get(1)?,
333                target: row.get(2)?,
334                relation: row.get(3)?,
335                weight: row.get(4)?,
336                ts: row.get(5)?,
337            })
338        })
339        .map_err(|e| KernelError::Store(e.to_string()))?
340        .filter_map(|r| r.ok())
341        .collect();
342    Ok(edges)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::graph::schema::init_graph_schema;
349    use rusqlite::Connection;
350
351    fn mem_db() -> Connection {
352        let conn = Connection::open_in_memory().unwrap();
353        init_graph_schema(&conn).unwrap();
354        conn
355    }
356
357    fn test_node(id: &str) -> GraphNode {
358        GraphNode {
359            id: id.to_string(),
360            node_type: "concept".to_string(),
361            title: format!("Node {id}"),
362            body: "test body".to_string(),
363            tags: vec!["test".to_string()],
364            projects: vec![],
365            agents: vec![],
366            created: "2026-01-01T00:00:00Z".to_string(),
367            updated: "2026-01-01T00:00:00Z".to_string(),
368            importance: 0.7,
369            access_count: 0,
370            accessed_at: String::new(),
371        }
372    }
373
374    #[test]
375    fn upsert_and_read_node() {
376        let conn = mem_db();
377        let node = test_node("n1");
378        upsert_node(&conn, &node).unwrap();
379        let loaded = read_node(&conn, "n1").unwrap().unwrap();
380        assert_eq!(loaded.id, "n1");
381        assert_eq!(loaded.title, "Node n1");
382        assert_eq!(loaded.tags, vec!["test"]);
383    }
384
385    #[test]
386    fn read_missing_node_returns_none() {
387        let conn = mem_db();
388        assert!(read_node(&conn, "nope").unwrap().is_none());
389    }
390
391    #[test]
392    fn delete_node_returns_true_when_exists() {
393        let conn = mem_db();
394        upsert_node(&conn, &test_node("n1")).unwrap();
395        assert!(delete_node(&conn, "n1").unwrap());
396        assert!(!delete_node(&conn, "n1").unwrap());
397    }
398
399    /// Regression: upsert must not destroy `created`/`access_count`. The old
400    /// `INSERT OR REPLACE` zeroed these on every re-insert of a fixed-id node.
401    #[test]
402    fn upsert_preserves_created_and_access_on_conflict() {
403        let conn = mem_db();
404        let mut n = test_node("fixed");
405        n.created = "2026-01-01T00:00:00Z".to_string();
406        n.access_count = 42;
407        upsert_node(&conn, &n).unwrap();
408
409        // Re-insert the same id with a fresh timestamp and zeroed access count.
410        let mut again = test_node("fixed");
411        again.created = "2026-12-31T00:00:00Z".to_string();
412        again.updated = "2026-12-31T00:00:00Z".to_string();
413        again.access_count = 0;
414        upsert_node(&conn, &again).unwrap();
415
416        let got = read_node(&conn, "fixed").unwrap().unwrap();
417        assert_eq!(got.created, "2026-01-01T00:00:00Z", "created must persist");
418        assert_eq!(
419            got.access_count, 42,
420            "access_count must not be zeroed on re-upsert"
421        );
422        assert_eq!(got.updated, "2026-12-31T00:00:00Z", "updated must refresh");
423    }
424
425    /// Regression: deleting a node must also drop its edges (no orphans).
426    #[test]
427    fn delete_node_removes_edges() {
428        let conn = mem_db();
429        upsert_node(&conn, &test_node("a")).unwrap();
430        upsert_node(&conn, &test_node("b")).unwrap();
431        append_edge(
432            &conn,
433            &GraphEdge {
434                id: "e1".into(),
435                source: "a".into(),
436                target: "b".into(),
437                relation: "rel".into(),
438                weight: 1.0,
439                ts: "2026-01-01T00:00:00Z".into(),
440            },
441        )
442        .unwrap();
443
444        assert!(delete_node(&conn, "a").unwrap());
445        let edges = edges_for_node(&conn, "a").unwrap();
446        assert!(edges.is_empty(), "orphan edges remain: {edges:?}");
447    }
448
449    #[test]
450    fn list_node_ids_returns_all() {
451        let conn = mem_db();
452        upsert_node(&conn, &test_node("a")).unwrap();
453        upsert_node(&conn, &test_node("b")).unwrap();
454        let ids = list_node_ids(&conn).unwrap();
455        assert_eq!(ids.len(), 2);
456    }
457
458    #[test]
459    fn append_and_read_edges() {
460        let conn = mem_db();
461        let edge = GraphEdge {
462            id: "e1".to_string(),
463            source: "a".to_string(),
464            target: "b".to_string(),
465            relation: "related".to_string(),
466            weight: 1.0,
467            ts: "2026-01-01T00:00:00Z".to_string(),
468        };
469        append_edge(&conn, &edge).unwrap();
470        let edges = read_edges(&conn, 10).unwrap();
471        assert_eq!(edges.len(), 1);
472        assert_eq!(edges[0].source, "a");
473    }
474
475    #[test]
476    fn edges_for_node_returns_both_directions() {
477        let conn = mem_db();
478        append_edge(
479            &conn,
480            &GraphEdge {
481                id: "e1".into(),
482                source: "a".into(),
483                target: "b".into(),
484                relation: "related".into(),
485                weight: 1.0,
486                ts: "2026-01-01T00:00:00Z".into(),
487            },
488        )
489        .unwrap();
490        append_edge(
491            &conn,
492            &GraphEdge {
493                id: "e2".into(),
494                source: "c".into(),
495                target: "a".into(),
496                relation: "related".into(),
497                weight: 1.0,
498                ts: "2026-01-01T00:00:00Z".into(),
499            },
500        )
501        .unwrap();
502        let edges = edges_for_node(&conn, "a").unwrap();
503        assert_eq!(edges.len(), 2);
504    }
505
506    #[test]
507    fn test_remove_edges_for_node() {
508        let conn = mem_db();
509        append_edge(
510            &conn,
511            &GraphEdge {
512                id: "e1".into(),
513                source: "a".into(),
514                target: "b".into(),
515                relation: "related".into(),
516                weight: 1.0,
517                ts: "2026-01-01T00:00:00Z".into(),
518            },
519        )
520        .unwrap();
521        remove_edges_for_node(&conn, "a").unwrap();
522        assert!(read_edges(&conn, 10).unwrap().is_empty());
523    }
524
525    #[test]
526    fn append_edges_inserts_batch() {
527        let conn = mem_db();
528        let edges = vec![
529            GraphEdge {
530                id: "e1".into(),
531                source: "a".into(),
532                target: "b".into(),
533                relation: "cites".into(),
534                weight: 1.0,
535                ts: "2026-01-01T00:00:00Z".into(),
536            },
537            GraphEdge {
538                id: "e2".into(),
539                source: "a".into(),
540                target: "c".into(),
541                relation: "cites".into(),
542                weight: 0.5,
543                ts: "2026-01-01T00:00:00Z".into(),
544            },
545        ];
546        append_edges(&conn, &edges).unwrap();
547        assert_eq!(read_edges(&conn, 10).unwrap().len(), 2);
548    }
549
550    #[test]
551    fn append_edges_empty_is_noop() {
552        let conn = mem_db();
553        append_edges(&conn, &[]).unwrap();
554        assert!(read_edges(&conn, 10).unwrap().is_empty());
555    }
556
557    #[test]
558    fn append_edges_ignores_duplicate_id_and_unique() {
559        let conn = mem_db();
560        let e = GraphEdge {
561            id: "e1".into(),
562            source: "a".into(),
563            target: "b".into(),
564            relation: "cites".into(),
565            weight: 1.0,
566            ts: "2026-01-01T00:00:00Z".into(),
567        };
568        append_edges(&conn, std::slice::from_ref(&e)).unwrap();
569        // Same id (INSERT OR IGNORE) and same (source, target, relation) with a
570        // different id (unique index) are both skipped.
571        append_edges(
572            &conn,
573            &[
574                e,
575                GraphEdge {
576                    id: "e2".into(),
577                    source: "a".into(),
578                    target: "b".into(),
579                    relation: "cites".into(),
580                    weight: 1.0,
581                    ts: "2026-01-01T00:00:00Z".into(),
582                },
583            ],
584        )
585        .unwrap();
586        assert_eq!(read_edges(&conn, 10).unwrap().len(), 1);
587    }
588
589    #[test]
590    fn edges_for_node_dir_filters_direction_and_relation() {
591        let conn = mem_db();
592        append_edges(
593            &conn,
594            &[
595                GraphEdge {
596                    id: "e1".into(),
597                    source: "a".into(),
598                    target: "b".into(),
599                    relation: "cites".into(),
600                    weight: 1.0,
601                    ts: "t".into(),
602                },
603                GraphEdge {
604                    id: "e2".into(),
605                    source: "c".into(),
606                    target: "a".into(),
607                    relation: "cites".into(),
608                    weight: 1.0,
609                    ts: "t".into(),
610                },
611                GraphEdge {
612                    id: "e3".into(),
613                    source: "a".into(),
614                    target: "d".into(),
615                    relation: "see_also".into(),
616                    weight: 1.0,
617                    ts: "t".into(),
618                },
619            ],
620        )
621        .unwrap();
622        // Out-edges of `a`: b (cites) and d (see_also).
623        assert_eq!(
624            edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
625                .unwrap()
626                .len(),
627            2
628        );
629        // Out-edges of `a` restricted to `cites`: only b.
630        let out_cites = edges_for_node_dir(&conn, "a", EdgeDirection::Out, Some("cites")).unwrap();
631        assert_eq!(out_cites.len(), 1);
632        assert_eq!(out_cites[0].target, "b");
633        // In-edges of `a`: c→a.
634        let inc = edges_for_node_dir(&conn, "a", EdgeDirection::In, None).unwrap();
635        assert_eq!(inc.len(), 1);
636        assert_eq!(inc[0].source, "c");
637        // Both directions: b, d, c → 3.
638        assert_eq!(
639            edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
640                .unwrap()
641                .len(),
642            3
643        );
644    }
645}