Skip to main content

rto_graph/
store.rs

1//! SQLite-backed graph store.
2
3use std::path::Path;
4
5use rusqlite::{Connection, OptionalExtension, params};
6
7use crate::migrations;
8use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
9use crate::provenance::Provenance;
10
11/// Errors raised by the store.
12#[derive(Debug, thiserror::Error)]
13pub enum StoreError {
14    /// Underlying `SQLite` failure.
15    #[error("sqlite error: {0}")]
16    Sqlite(#[from] rusqlite::Error),
17    /// A node's `meta` could not be (de)serialized as JSON.
18    #[error("json error: {0}")]
19    Json(#[from] serde_json::Error),
20    /// An edge referenced a node key that does not exist in the store.
21    #[error("unknown node key: {0}")]
22    UnknownNode(String),
23    /// An edge violated the provenance/confidence invariant.
24    #[error("invalid edge: {0}")]
25    InvalidEdge(String),
26    /// A stored value could not be interpreted (database corruption).
27    #[error("corrupt store: {0}")]
28    Corrupt(String),
29}
30
31/// Qualified node columns for `SELECT`s that alias the `nodes` table as `n`.
32const NODE_COLS: &str =
33    "n.key, n.kind, n.name, n.path, n.lang, n.blob_hash, n.span_start, n.span_end, n.meta";
34
35/// `SELECT` prefix that yields an [`Edge`] row (endpoints resolved back to keys).
36const EDGE_SELECT: &str = "SELECT ns.key AS src, nd.key AS dst, e.kind, e.provenance, \
37     e.confidence, e.src_ref \
38     FROM edges e JOIN nodes ns ON ns.id = e.src JOIN nodes nd ON nd.id = e.dst";
39
40/// A Roteiro graph store backed by a single `SQLite` database.
41pub struct Store {
42    conn: Connection,
43}
44
45impl Store {
46    /// Open (creating if absent) a store at `path` and apply pending migrations.
47    ///
48    /// # Errors
49    /// Returns [`StoreError::Sqlite`] if the database cannot be opened or a
50    /// migration fails.
51    pub fn open(path: &Path) -> Result<Self, StoreError> {
52        let conn = Connection::open(path)?;
53        Self::from_conn(conn)
54    }
55
56    /// Open an in-memory store (tests, previews).
57    ///
58    /// # Errors
59    /// Returns [`StoreError::Sqlite`] if a migration fails.
60    pub fn open_in_memory() -> Result<Self, StoreError> {
61        let conn = Connection::open_in_memory()?;
62        Self::from_conn(conn)
63    }
64
65    fn from_conn(mut conn: Connection) -> Result<Self, StoreError> {
66        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
67        migrations::apply(&mut conn)?;
68        Ok(Self { conn })
69    }
70
71    /// The schema version this store has been migrated to.
72    ///
73    /// # Errors
74    /// Returns [`StoreError::Sqlite`] on query failure.
75    pub fn schema_version(&self) -> Result<u32, StoreError> {
76        let v: i64 = self.conn.query_row(
77            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
78            [],
79            |r| r.get(0),
80        )?;
81        Ok(u32::try_from(v).unwrap_or(0))
82    }
83
84    /// Number of nodes currently in the store.
85    ///
86    /// # Errors
87    /// Returns [`StoreError::Sqlite`] on query failure.
88    pub fn node_count(&self) -> Result<u64, StoreError> {
89        let n: i64 = self
90            .conn
91            .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
92        Ok(u64::try_from(n).unwrap_or(0))
93    }
94
95    /// Number of edges currently in the store.
96    ///
97    /// # Errors
98    /// Returns [`StoreError::Sqlite`] on query failure.
99    pub fn edge_count(&self) -> Result<u64, StoreError> {
100        let n: i64 = self
101            .conn
102            .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
103        Ok(u64::try_from(n).unwrap_or(0))
104    }
105
106    /// Insert or update a node, keyed by its natural [`Node::key`].
107    ///
108    /// # Errors
109    /// Returns [`StoreError::Json`] if `meta` cannot be serialized, or
110    /// [`StoreError::Sqlite`] on write failure.
111    pub fn upsert_node(&self, node: &Node) -> Result<(), StoreError> {
112        upsert_node(&self.conn, node)
113    }
114
115    /// Insert an edge. Both endpoints must already resolve to nodes.
116    ///
117    /// # Errors
118    /// Returns [`StoreError::InvalidEdge`] if the provenance/confidence
119    /// invariant is violated, [`StoreError::UnknownNode`] if an endpoint key is
120    /// absent, or [`StoreError::Sqlite`] on write failure.
121    pub fn insert_edge(&self, edge: &Edge) -> Result<(), StoreError> {
122        insert_edge(&self.conn, edge)
123    }
124
125    /// Apply a fact set atomically: all nodes are upserted, then all edges are
126    /// inserted, in a single transaction. On any error nothing is committed.
127    ///
128    /// # Errors
129    /// Returns the first error encountered (see [`Store::upsert_node`] and
130    /// [`Store::insert_edge`]); the transaction is rolled back.
131    pub fn apply_factset(&mut self, facts: &FactSet) -> Result<(), StoreError> {
132        let tx = self.conn.transaction()?;
133        for node in &facts.nodes {
134            upsert_node(&tx, node)?;
135        }
136        for edge in &facts.edges {
137            insert_edge(&tx, edge)?;
138        }
139        tx.commit()?;
140        Ok(())
141    }
142
143    /// The `HEAD` tree id recorded at the last successful [`Store::rebuild`], if
144    /// any. Used by the sync engine to detect an unchanged tree.
145    ///
146    /// # Errors
147    /// Returns [`StoreError::Sqlite`] on query failure.
148    pub fn sync_state(&self) -> Result<Option<String>, StoreError> {
149        Ok(self
150            .conn
151            .query_row("SELECT tree FROM sync_state WHERE id = 0", [], |r| r.get(0))
152            .optional()?)
153    }
154
155    /// Atomically replace the entire graph with `facts` and record `tree` as the
156    /// synced state. All existing nodes and edges are deleted first, so the
157    /// store reflects exactly the given fact set.
158    ///
159    /// # Errors
160    /// Returns the first error encountered (see [`Store::apply_factset`]); on any
161    /// error nothing is committed.
162    pub fn rebuild(&mut self, facts: &FactSet, tree: &str) -> Result<(), StoreError> {
163        let tx = self.conn.transaction()?;
164        tx.execute("DELETE FROM edges", [])?;
165        tx.execute("DELETE FROM nodes", [])?;
166        for node in &facts.nodes {
167            upsert_node(&tx, node)?;
168        }
169        for edge in &facts.edges {
170            insert_edge(&tx, edge)?;
171        }
172        tx.execute(
173            "INSERT INTO sync_state (id, tree) VALUES (0, ?1)
174             ON CONFLICT(id) DO UPDATE SET tree = excluded.tree",
175            [tree],
176        )?;
177        tx.commit()?;
178        Ok(())
179    }
180
181    /// Fetch a node by its natural key.
182    ///
183    /// # Errors
184    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
185    /// [`StoreError::Corrupt`] if a stored value cannot be decoded.
186    pub fn get_node(&self, key: &str) -> Result<Option<Node>, StoreError> {
187        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.key = ?1");
188        let mut stmt = self.conn.prepare(&sql)?;
189        let mut rows = stmt.query([key])?;
190        match rows.next()? {
191            Some(row) => Ok(Some(row_to_node(row)?)),
192            None => Ok(None),
193        }
194    }
195
196    /// All nodes of a given kind.
197    ///
198    /// # Errors
199    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
200    /// [`StoreError::Corrupt`] on decode failure.
201    pub fn nodes_by_kind(&self, kind: &NodeKind) -> Result<Vec<Node>, StoreError> {
202        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.kind = ?1 ORDER BY n.key");
203        let mut stmt = self.conn.prepare(&sql)?;
204        let mut rows = stmt.query([kind.as_str()])?;
205        collect_nodes(&mut rows)
206    }
207
208    /// Edges whose source is the node with the given key.
209    ///
210    /// # Errors
211    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
212    pub fn edges_from(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
213        let sql = format!("{EDGE_SELECT} WHERE ns.key = ?1 ORDER BY e.id");
214        let mut stmt = self.conn.prepare(&sql)?;
215        let mut rows = stmt.query([key])?;
216        collect_edges(&mut rows)
217    }
218
219    /// Edges whose destination is the node with the given key.
220    ///
221    /// # Errors
222    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
223    pub fn edges_to(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
224        let sql = format!("{EDGE_SELECT} WHERE nd.key = ?1 ORDER BY e.id");
225        let mut stmt = self.conn.prepare(&sql)?;
226        let mut rows = stmt.query([key])?;
227        collect_edges(&mut rows)
228    }
229
230    /// All edges with the given provenance.
231    ///
232    /// # Errors
233    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
234    pub fn edges_by_provenance(&self, provenance: Provenance) -> Result<Vec<Edge>, StoreError> {
235        let sql = format!("{EDGE_SELECT} WHERE e.provenance = ?1 ORDER BY e.id");
236        let mut stmt = self.conn.prepare(&sql)?;
237        let mut rows = stmt.query([provenance.as_str()])?;
238        collect_edges(&mut rows)
239    }
240
241    /// Neighbouring nodes reachable from `key` in the given direction. Returns
242    /// an empty vector if the node does not exist.
243    ///
244    /// # Errors
245    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
246    /// [`StoreError::Corrupt`] on failure.
247    pub fn neighbors(&self, key: &str, dir: Direction) -> Result<Vec<Node>, StoreError> {
248        let out = format!(
249            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.dst \
250             JOIN nodes s ON s.id = e.src WHERE s.key = ?1"
251        );
252        let inc = format!(
253            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.src \
254             JOIN nodes d ON d.id = e.dst WHERE d.key = ?1"
255        );
256        // Order by output column 1 (the node key) so results are deterministic
257        // across SQLite versions/plans. Positional ordering avoids both the
258        // ambiguity of a bare `key` (present in every joined table) and the fact
259        // that a table-qualified name cannot be used after the `Both` UNION.
260        let sql = match dir {
261            Direction::Outgoing => format!("{out} ORDER BY 1"),
262            Direction::Incoming => format!("{inc} ORDER BY 1"),
263            Direction::Both => format!("{out} UNION {inc} ORDER BY 1"),
264        };
265        let mut stmt = self.conn.prepare(&sql)?;
266        let mut rows = stmt.query([key])?;
267        collect_nodes(&mut rows)
268    }
269}
270
271// --- Free helpers operating on a `Connection` (a `Transaction` derefs to one) ---
272
273fn node_row_id(conn: &Connection, key: &str) -> rusqlite::Result<Option<i64>> {
274    conn.query_row("SELECT id FROM nodes WHERE key = ?1", [key], |r| r.get(0))
275        .optional()
276}
277
278fn upsert_node(conn: &Connection, node: &Node) -> Result<(), StoreError> {
279    let meta = serde_json::to_string(&node.meta)?;
280    let (span_start, span_end) = match node.span {
281        Some(s) => (Some(i64::from(s.start)), Some(i64::from(s.end))),
282        None => (None, None),
283    };
284    conn.execute(
285        "INSERT INTO nodes (key, kind, name, path, lang, blob_hash, span_start, span_end, meta)
286         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
287         ON CONFLICT(key) DO UPDATE SET
288             kind = excluded.kind, name = excluded.name, path = excluded.path,
289             lang = excluded.lang, blob_hash = excluded.blob_hash,
290             span_start = excluded.span_start, span_end = excluded.span_end,
291             meta = excluded.meta",
292        params![
293            node.key,
294            node.kind.as_str(),
295            node.name,
296            node.path,
297            node.lang,
298            node.blob_hash,
299            span_start,
300            span_end,
301            meta,
302        ],
303    )?;
304    Ok(())
305}
306
307fn insert_edge(conn: &Connection, edge: &Edge) -> Result<(), StoreError> {
308    if !edge.is_valid() {
309        return Err(StoreError::InvalidEdge(format!(
310            "confidence must be present iff provenance is inferred (src={}, dst={})",
311            edge.src, edge.dst
312        )));
313    }
314    let src_id =
315        node_row_id(conn, &edge.src)?.ok_or_else(|| StoreError::UnknownNode(edge.src.clone()))?;
316    let dst_id =
317        node_row_id(conn, &edge.dst)?.ok_or_else(|| StoreError::UnknownNode(edge.dst.clone()))?;
318    // Edges are a set: a duplicate `(src, dst, kind, provenance)` is a no-op, so
319    // re-applying a fact set does not accumulate duplicate edges. `ON CONFLICT …
320    // DO NOTHING` targets only that unique index — other constraint violations
321    // (already guarded in Rust above) still surface.
322    conn.execute(
323        "INSERT INTO edges (src, dst, kind, provenance, confidence, src_ref)
324         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
325         ON CONFLICT(src, dst, kind, provenance) DO NOTHING",
326        params![
327            src_id,
328            dst_id,
329            edge.kind.as_str(),
330            edge.provenance.as_str(),
331            edge.confidence,
332            edge.src_ref,
333        ],
334    )?;
335    Ok(())
336}
337
338fn collect_nodes(rows: &mut rusqlite::Rows) -> Result<Vec<Node>, StoreError> {
339    let mut out = Vec::new();
340    while let Some(row) = rows.next()? {
341        out.push(row_to_node(row)?);
342    }
343    Ok(out)
344}
345
346fn collect_edges(rows: &mut rusqlite::Rows) -> Result<Vec<Edge>, StoreError> {
347    let mut out = Vec::new();
348    while let Some(row) = rows.next()? {
349        out.push(row_to_edge(row)?);
350    }
351    Ok(out)
352}
353
354fn row_to_node(row: &rusqlite::Row) -> Result<Node, StoreError> {
355    let kind: String = row.get("kind")?;
356    let span_start: Option<i64> = row.get("span_start")?;
357    let span_end: Option<i64> = row.get("span_end")?;
358    let span = match (span_start, span_end) {
359        (Some(s), Some(e)) => Some(Span::new(to_u32(s)?, to_u32(e)?)),
360        _ => None,
361    };
362    let meta: String = row.get("meta")?;
363    Ok(Node {
364        key: row.get("key")?,
365        kind: NodeKind::from_token(&kind),
366        name: row.get("name")?,
367        path: row.get("path")?,
368        lang: row.get("lang")?,
369        blob_hash: row.get("blob_hash")?,
370        span,
371        meta: serde_json::from_str(&meta)?,
372    })
373}
374
375fn row_to_edge(row: &rusqlite::Row) -> Result<Edge, StoreError> {
376    let kind: String = row.get("kind")?;
377    let provenance: String = row.get("provenance")?;
378    let provenance = Provenance::from_token(&provenance)
379        .ok_or_else(|| StoreError::Corrupt(format!("unknown provenance: {provenance}")))?;
380    Ok(Edge {
381        src: row.get("src")?,
382        dst: row.get("dst")?,
383        kind: EdgeKind::from_token(&kind),
384        provenance,
385        confidence: row.get("confidence")?,
386        src_ref: row.get("src_ref")?,
387    })
388}
389
390fn to_u32(v: i64) -> Result<u32, StoreError> {
391    u32::try_from(v).map_err(|_| StoreError::Corrupt(format!("span offset out of range: {v}")))
392}
393
394#[cfg(test)]
395mod tests {
396    use super::Store;
397    use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
398    use crate::provenance::Provenance;
399
400    fn sample_node(key: &str) -> Node {
401        Node {
402            key: key.to_owned(),
403            kind: NodeKind::Fn,
404            name: "sample".to_owned(),
405            path: Some("src/lib.rs".to_owned()),
406            lang: Some("rust".to_owned()),
407            blob_hash: Some("deadbeef".to_owned()),
408            span: Some(Span::new(10, 42)),
409            meta: serde_json::json!({"vis": "pub"}),
410        }
411    }
412
413    #[test]
414    fn open_in_memory_applies_schema() {
415        let store = Store::open_in_memory().expect("open");
416        assert_eq!(store.node_count().expect("count"), 0);
417        assert_eq!(store.schema_version().expect("version"), 3);
418    }
419
420    #[test]
421    fn upsert_and_get_round_trips_all_fields() {
422        let store = Store::open_in_memory().expect("open");
423        let node = sample_node("sym:rust:src/lib.rs#sample");
424        store.upsert_node(&node).expect("upsert");
425        let got = store.get_node(&node.key).expect("get").expect("present");
426        assert_eq!(got, node);
427    }
428
429    #[test]
430    fn upsert_updates_in_place() {
431        let store = Store::open_in_memory().expect("open");
432        let mut node = sample_node("k");
433        store.upsert_node(&node).expect("insert");
434        node.name = "renamed".to_owned();
435        node.kind = NodeKind::Struct;
436        store.upsert_node(&node).expect("update");
437        assert_eq!(store.node_count().expect("count"), 1);
438        let got = store.get_node("k").expect("get").expect("present");
439        assert_eq!(got.name, "renamed");
440        assert_eq!(got.kind, NodeKind::Struct);
441    }
442
443    #[test]
444    fn edge_with_unknown_endpoint_is_rejected() {
445        let store = Store::open_in_memory().expect("open");
446        store
447            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
448            .expect("a");
449        let edge = Edge::derived("a", "missing", EdgeKind::Calls);
450        let err = store.insert_edge(&edge).expect_err("should reject");
451        assert!(matches!(err, super::StoreError::UnknownNode(k) if k == "missing"));
452    }
453
454    #[test]
455    fn inferred_edge_requires_confidence() {
456        let store = Store::open_in_memory().expect("open");
457        store
458            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
459            .expect("a");
460        store
461            .upsert_node(&Node::new("b", NodeKind::Fn, "b"))
462            .expect("b");
463        // Hand-build an inferred edge with no confidence to violate the invariant.
464        let bad = Edge {
465            src: "a".to_owned(),
466            dst: "b".to_owned(),
467            kind: EdgeKind::References,
468            provenance: Provenance::Inferred,
469            confidence: None,
470            src_ref: None,
471        };
472        assert!(matches!(
473            store.insert_edge(&bad).expect_err("reject"),
474            super::StoreError::InvalidEdge(_)
475        ));
476    }
477
478    #[test]
479    fn apply_factset_is_atomic() {
480        let mut store = Store::open_in_memory().expect("open");
481        // Second edge references a missing node, so the whole set must roll back.
482        let facts = FactSet::new()
483            .with_node(Node::new("a", NodeKind::Fn, "a"))
484            .with_node(Node::new("b", NodeKind::Fn, "b"))
485            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
486            .with_edge(Edge::derived("a", "ghost", EdgeKind::Calls));
487        assert!(store.apply_factset(&facts).is_err());
488        assert_eq!(store.node_count().expect("count"), 0, "rolled back");
489        assert_eq!(store.edge_count().expect("count"), 0, "rolled back");
490    }
491
492    #[test]
493    fn neighbors_and_provenance_queries() {
494        let mut store = Store::open_in_memory().expect("open");
495        let facts = FactSet::new()
496            .with_node(Node::new("a", NodeKind::Fn, "a"))
497            .with_node(Node::new("b", NodeKind::Fn, "b"))
498            .with_node(Node::new("c", NodeKind::Fn, "c"))
499            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
500            .with_edge(Edge::inferred("a", "c", EdgeKind::References, 0.5));
501        store.apply_factset(&facts).expect("apply");
502
503        let out = store.neighbors("a", Direction::Outgoing).expect("out");
504        let mut keys: Vec<_> = out.iter().map(|n| n.key.clone()).collect();
505        keys.sort();
506        assert_eq!(keys, ["b", "c"]);
507
508        assert!(
509            store
510                .neighbors("b", Direction::Outgoing)
511                .expect("b out")
512                .is_empty()
513        );
514        assert_eq!(
515            store
516                .neighbors("b", Direction::Incoming)
517                .expect("b in")
518                .len(),
519            1
520        );
521
522        let inferred = store
523            .edges_by_provenance(Provenance::Inferred)
524            .expect("inf");
525        assert_eq!(inferred.len(), 1);
526        assert_eq!(inferred[0].confidence, Some(0.5));
527    }
528
529    #[test]
530    fn neighbors_of_absent_node_is_empty() {
531        let store = Store::open_in_memory().expect("open");
532        assert!(
533            store
534                .neighbors("nope", Direction::Both)
535                .expect("q")
536                .is_empty()
537        );
538    }
539
540    #[test]
541    fn get_missing_node_is_none() {
542        let store = Store::open_in_memory().expect("open");
543        assert!(store.get_node("absent").expect("get").is_none());
544    }
545
546    #[test]
547    fn nodes_by_kind_and_edges_to() {
548        let mut store = Store::open_in_memory().expect("open");
549        let facts = FactSet::new()
550            .with_node(Node::new("f1", NodeKind::Fn, "f1"))
551            .with_node(Node::new("f2", NodeKind::Fn, "f2"))
552            .with_node(Node::new("s1", NodeKind::Struct, "s1"))
553            .with_edge(Edge::derived("f1", "s1", EdgeKind::References))
554            .with_edge(Edge::derived("f2", "s1", EdgeKind::References));
555        store.apply_factset(&facts).expect("apply");
556
557        let fns = store.nodes_by_kind(&NodeKind::Fn).expect("fns");
558        assert_eq!(
559            fns.iter().map(|n| n.key.as_str()).collect::<Vec<_>>(),
560            ["f1", "f2"]
561        );
562        assert!(
563            store
564                .nodes_by_kind(&NodeKind::Enum)
565                .expect("enums")
566                .is_empty()
567        );
568
569        let into_s1 = store.edges_to("s1").expect("edges_to");
570        assert_eq!(into_s1.len(), 2);
571        assert!(into_s1.iter().all(|e| e.dst == "s1"));
572    }
573
574    #[test]
575    fn open_persists_across_reopen() {
576        let path =
577            std::env::temp_dir().join(format!("roteiro-open-test-{}.db", std::process::id()));
578        std::fs::remove_file(&path).ok();
579        {
580            let store = Store::open(&path).expect("open");
581            store
582                .upsert_node(&sample_node("persisted"))
583                .expect("upsert");
584        }
585        {
586            let store = Store::open(&path).expect("reopen");
587            assert_eq!(store.node_count().expect("count"), 1);
588            assert_eq!(store.schema_version().expect("version"), 3);
589            assert!(store.get_node("persisted").expect("get").is_some());
590        }
591        std::fs::remove_file(&path).expect("cleanup");
592    }
593}