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, 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
66/// Read a single node by ID. Returns `None` if not found.
67pub 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
76/// Batch-read multiple nodes by ID.
77pub 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
94/// Delete a node by ID, removing its edges first in the same transaction.
95///
96/// Previously this deleted only the node row and left dangling edges behind —
97/// `remove_edges_for_node` existed but was never called here. The transaction
98/// keeps the node deletion and edge cleanup atomic.
99pub fn delete_node(conn: &Connection, id: &str) -> Result<bool> {
100    // `unchecked_transaction` is used instead of `transaction` because the
101    // function signature takes `&Connection` (not `&mut`). This is safe: in the
102    // async pool (`AsyncPoolGraph::with_conn`) the semaphore guarantees exclusive
103    // access, and in sync call sites no other statements are open on `conn`.
104    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
115/// List all node IDs.
116pub 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
128/// Read nodes with optional limit, ordered by updated DESC.
129pub 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
142// ── Edge CRUD ─────────────────────────────────────────
143
144/// Append an edge (INSERT OR IGNORE — duplicates by ID are skipped).
145pub 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
162/// Append many edges in a single transaction (INSERT OR IGNORE — duplicates
163/// by ID *or* by the `(source, target, relation)` unique index are skipped).
164///
165/// Equivalent to calling [`append_edge`] per edge, but commits once and reuses
166/// one prepared statement, so it scales to hundreds of thousands of edges
167/// (e.g. building a citation graph during indexing).
168pub 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
198/// Read edges touching `node_id`, filtered by direction and optional relation.
199///
200/// - [`EdgeDirection::Both`] matches `source = node_id OR target = node_id`
201///   (the historical behavior).
202/// - [`EdgeDirection::Out`] matches `source = node_id` only (out-edges).
203/// - [`EdgeDirection::In`] matches `target = node_id` only (in-edges).
204///
205/// When `relation` is `Some(r)`, additionally filters `relation = r`, using the
206/// `idx_edges_src_rel` / `idx_edges_tgt_rel` v3 indexes for the directional cases.
207pub(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
247/// Read edges, capped at `limit`.
248pub 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
269/// Read edges whose source AND target are both in `ids` — the induced subgraph
270/// over a candidate node set.
271///
272/// Used to build the candidate subgraph for PageRank boosting in
273/// [`smart_recall`](super::recall::smart_recall). `ids.len()` must stay under
274/// SQLite's bind-variable limit (999 by default); `smart_recall` caps at 100.
275pub(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
307/// Delete an edge by ID.
308pub 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
315/// Delete all edges connected to a node (source or target).
316pub(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
325/// Read edges where the given node is source or target.
326pub(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    /// Regression: upsert must not destroy `created`/`access_count`. The old
417    /// `INSERT OR REPLACE` zeroed these on every re-insert of a fixed-id node.
418    #[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        // Re-insert the same id with a fresh timestamp and zeroed access count.
427        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    /// Regression: deleting a node must also drop its edges (no orphans).
443    #[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        // Same id (INSERT OR IGNORE) and same (source, target, relation) with a
587        // different id (unique index) are both skipped.
588        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        // Out-edges of `a`: b (cites) and d (see_also).
640        assert_eq!(
641            edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
642                .unwrap()
643                .len(),
644            2
645        );
646        // Out-edges of `a` restricted to `cites`: only b.
647        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        // In-edges of `a`: c→a.
651        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        // Both directions: b, d, c → 3.
655        assert_eq!(
656            edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
657                .unwrap()
658                .len(),
659            3
660        );
661    }
662}