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    /// Every node key in the store, ordered. Useful for whole-graph exports.
197    ///
198    /// # Errors
199    /// Returns [`StoreError::Sqlite`] on query failure.
200    pub fn all_keys(&self) -> Result<Vec<String>, StoreError> {
201        let mut stmt = self.conn.prepare("SELECT key FROM nodes ORDER BY key")?;
202        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
203        let mut out = Vec::new();
204        for row in rows {
205            out.push(row?);
206        }
207        Ok(out)
208    }
209
210    /// All nodes of a given kind.
211    ///
212    /// # Errors
213    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
214    /// [`StoreError::Corrupt`] on decode failure.
215    pub fn nodes_by_kind(&self, kind: &NodeKind) -> Result<Vec<Node>, StoreError> {
216        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.kind = ?1 ORDER BY n.key");
217        let mut stmt = self.conn.prepare(&sql)?;
218        let mut rows = stmt.query([kind.as_str()])?;
219        collect_nodes(&mut rows)
220    }
221
222    /// Edges whose source is the node with the given key.
223    ///
224    /// # Errors
225    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
226    pub fn edges_from(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
227        let sql = format!("{EDGE_SELECT} WHERE ns.key = ?1 ORDER BY e.id");
228        let mut stmt = self.conn.prepare(&sql)?;
229        let mut rows = stmt.query([key])?;
230        collect_edges(&mut rows)
231    }
232
233    /// Edges whose destination is the node with the given key.
234    ///
235    /// # Errors
236    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
237    pub fn edges_to(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
238        let sql = format!("{EDGE_SELECT} WHERE nd.key = ?1 ORDER BY e.id");
239        let mut stmt = self.conn.prepare(&sql)?;
240        let mut rows = stmt.query([key])?;
241        collect_edges(&mut rows)
242    }
243
244    /// All edges with the given provenance.
245    ///
246    /// # Errors
247    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
248    pub fn edges_by_provenance(&self, provenance: Provenance) -> Result<Vec<Edge>, StoreError> {
249        let sql = format!("{EDGE_SELECT} WHERE e.provenance = ?1 ORDER BY e.id");
250        let mut stmt = self.conn.prepare(&sql)?;
251        let mut rows = stmt.query([provenance.as_str()])?;
252        collect_edges(&mut rows)
253    }
254
255    /// Neighbouring nodes reachable from `key` in the given direction. Returns
256    /// an empty vector if the node does not exist.
257    ///
258    /// # Errors
259    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
260    /// [`StoreError::Corrupt`] on failure.
261    pub fn neighbors(&self, key: &str, dir: Direction) -> Result<Vec<Node>, StoreError> {
262        let out = format!(
263            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.dst \
264             JOIN nodes s ON s.id = e.src WHERE s.key = ?1"
265        );
266        let inc = format!(
267            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.src \
268             JOIN nodes d ON d.id = e.dst WHERE d.key = ?1"
269        );
270        // Order by output column 1 (the node key) so results are deterministic
271        // across SQLite versions/plans. Positional ordering avoids both the
272        // ambiguity of a bare `key` (present in every joined table) and the fact
273        // that a table-qualified name cannot be used after the `Both` UNION.
274        let sql = match dir {
275            Direction::Outgoing => format!("{out} ORDER BY 1"),
276            Direction::Incoming => format!("{inc} ORDER BY 1"),
277            Direction::Both => format!("{out} UNION {inc} ORDER BY 1"),
278        };
279        let mut stmt = self.conn.prepare(&sql)?;
280        let mut rows = stmt.query([key])?;
281        collect_nodes(&mut rows)
282    }
283}
284
285// --- Free helpers operating on a `Connection` (a `Transaction` derefs to one) ---
286
287fn node_row_id(conn: &Connection, key: &str) -> rusqlite::Result<Option<i64>> {
288    conn.query_row("SELECT id FROM nodes WHERE key = ?1", [key], |r| r.get(0))
289        .optional()
290}
291
292fn upsert_node(conn: &Connection, node: &Node) -> Result<(), StoreError> {
293    let meta = serde_json::to_string(&node.meta)?;
294    let (span_start, span_end) = match node.span {
295        Some(s) => (Some(i64::from(s.start)), Some(i64::from(s.end))),
296        None => (None, None),
297    };
298    conn.execute(
299        "INSERT INTO nodes (key, kind, name, path, lang, blob_hash, span_start, span_end, meta)
300         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
301         ON CONFLICT(key) DO UPDATE SET
302             kind = excluded.kind, name = excluded.name, path = excluded.path,
303             lang = excluded.lang, blob_hash = excluded.blob_hash,
304             span_start = excluded.span_start, span_end = excluded.span_end,
305             meta = excluded.meta",
306        params![
307            node.key,
308            node.kind.as_str(),
309            node.name,
310            node.path,
311            node.lang,
312            node.blob_hash,
313            span_start,
314            span_end,
315            meta,
316        ],
317    )?;
318    Ok(())
319}
320
321fn insert_edge(conn: &Connection, edge: &Edge) -> Result<(), StoreError> {
322    if !edge.is_valid() {
323        return Err(StoreError::InvalidEdge(format!(
324            "confidence must be present iff provenance is inferred (src={}, dst={})",
325            edge.src, edge.dst
326        )));
327    }
328    let src_id =
329        node_row_id(conn, &edge.src)?.ok_or_else(|| StoreError::UnknownNode(edge.src.clone()))?;
330    let dst_id =
331        node_row_id(conn, &edge.dst)?.ok_or_else(|| StoreError::UnknownNode(edge.dst.clone()))?;
332    // Edges are a set: a duplicate `(src, dst, kind, provenance)` is a no-op, so
333    // re-applying a fact set does not accumulate duplicate edges. `ON CONFLICT …
334    // DO NOTHING` targets only that unique index — other constraint violations
335    // (already guarded in Rust above) still surface.
336    conn.execute(
337        "INSERT INTO edges (src, dst, kind, provenance, confidence, src_ref)
338         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
339         ON CONFLICT(src, dst, kind, provenance) DO NOTHING",
340        params![
341            src_id,
342            dst_id,
343            edge.kind.as_str(),
344            edge.provenance.as_str(),
345            edge.confidence,
346            edge.src_ref,
347        ],
348    )?;
349    Ok(())
350}
351
352fn collect_nodes(rows: &mut rusqlite::Rows) -> Result<Vec<Node>, StoreError> {
353    let mut out = Vec::new();
354    while let Some(row) = rows.next()? {
355        out.push(row_to_node(row)?);
356    }
357    Ok(out)
358}
359
360fn collect_edges(rows: &mut rusqlite::Rows) -> Result<Vec<Edge>, StoreError> {
361    let mut out = Vec::new();
362    while let Some(row) = rows.next()? {
363        out.push(row_to_edge(row)?);
364    }
365    Ok(out)
366}
367
368fn row_to_node(row: &rusqlite::Row) -> Result<Node, StoreError> {
369    let kind: String = row.get("kind")?;
370    let span_start: Option<i64> = row.get("span_start")?;
371    let span_end: Option<i64> = row.get("span_end")?;
372    let span = match (span_start, span_end) {
373        (Some(s), Some(e)) => Some(Span::new(to_u32(s)?, to_u32(e)?)),
374        _ => None,
375    };
376    let meta: String = row.get("meta")?;
377    Ok(Node {
378        key: row.get("key")?,
379        kind: NodeKind::from_token(&kind),
380        name: row.get("name")?,
381        path: row.get("path")?,
382        lang: row.get("lang")?,
383        blob_hash: row.get("blob_hash")?,
384        span,
385        meta: serde_json::from_str(&meta)?,
386    })
387}
388
389fn row_to_edge(row: &rusqlite::Row) -> Result<Edge, StoreError> {
390    let kind: String = row.get("kind")?;
391    let provenance: String = row.get("provenance")?;
392    let provenance = Provenance::from_token(&provenance)
393        .ok_or_else(|| StoreError::Corrupt(format!("unknown provenance: {provenance}")))?;
394    Ok(Edge {
395        src: row.get("src")?,
396        dst: row.get("dst")?,
397        kind: EdgeKind::from_token(&kind),
398        provenance,
399        confidence: row.get("confidence")?,
400        src_ref: row.get("src_ref")?,
401    })
402}
403
404fn to_u32(v: i64) -> Result<u32, StoreError> {
405    u32::try_from(v).map_err(|_| StoreError::Corrupt(format!("span offset out of range: {v}")))
406}
407
408#[cfg(test)]
409mod tests {
410    use super::Store;
411    use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
412    use crate::provenance::Provenance;
413
414    fn sample_node(key: &str) -> Node {
415        Node {
416            key: key.to_owned(),
417            kind: NodeKind::Fn,
418            name: "sample".to_owned(),
419            path: Some("src/lib.rs".to_owned()),
420            lang: Some("rust".to_owned()),
421            blob_hash: Some("deadbeef".to_owned()),
422            span: Some(Span::new(10, 42)),
423            meta: serde_json::json!({"vis": "pub"}),
424        }
425    }
426
427    #[test]
428    fn open_in_memory_applies_schema() {
429        let store = Store::open_in_memory().expect("open");
430        assert_eq!(store.node_count().expect("count"), 0);
431        assert_eq!(store.schema_version().expect("version"), 3);
432    }
433
434    #[test]
435    fn upsert_and_get_round_trips_all_fields() {
436        let store = Store::open_in_memory().expect("open");
437        let node = sample_node("sym:rust:src/lib.rs#sample");
438        store.upsert_node(&node).expect("upsert");
439        let got = store.get_node(&node.key).expect("get").expect("present");
440        assert_eq!(got, node);
441    }
442
443    #[test]
444    fn upsert_updates_in_place() {
445        let store = Store::open_in_memory().expect("open");
446        let mut node = sample_node("k");
447        store.upsert_node(&node).expect("insert");
448        node.name = "renamed".to_owned();
449        node.kind = NodeKind::Struct;
450        store.upsert_node(&node).expect("update");
451        assert_eq!(store.node_count().expect("count"), 1);
452        let got = store.get_node("k").expect("get").expect("present");
453        assert_eq!(got.name, "renamed");
454        assert_eq!(got.kind, NodeKind::Struct);
455    }
456
457    #[test]
458    fn edge_with_unknown_endpoint_is_rejected() {
459        let store = Store::open_in_memory().expect("open");
460        store
461            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
462            .expect("a");
463        let edge = Edge::derived("a", "missing", EdgeKind::Calls);
464        let err = store.insert_edge(&edge).expect_err("should reject");
465        assert!(matches!(err, super::StoreError::UnknownNode(k) if k == "missing"));
466    }
467
468    #[test]
469    fn inferred_edge_requires_confidence() {
470        let store = Store::open_in_memory().expect("open");
471        store
472            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
473            .expect("a");
474        store
475            .upsert_node(&Node::new("b", NodeKind::Fn, "b"))
476            .expect("b");
477        // Hand-build an inferred edge with no confidence to violate the invariant.
478        let bad = Edge {
479            src: "a".to_owned(),
480            dst: "b".to_owned(),
481            kind: EdgeKind::References,
482            provenance: Provenance::Inferred,
483            confidence: None,
484            src_ref: None,
485        };
486        assert!(matches!(
487            store.insert_edge(&bad).expect_err("reject"),
488            super::StoreError::InvalidEdge(_)
489        ));
490    }
491
492    #[test]
493    fn apply_factset_is_atomic() {
494        let mut store = Store::open_in_memory().expect("open");
495        // Second edge references a missing node, so the whole set must roll back.
496        let facts = FactSet::new()
497            .with_node(Node::new("a", NodeKind::Fn, "a"))
498            .with_node(Node::new("b", NodeKind::Fn, "b"))
499            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
500            .with_edge(Edge::derived("a", "ghost", EdgeKind::Calls));
501        assert!(store.apply_factset(&facts).is_err());
502        assert_eq!(store.node_count().expect("count"), 0, "rolled back");
503        assert_eq!(store.edge_count().expect("count"), 0, "rolled back");
504    }
505
506    #[test]
507    fn neighbors_and_provenance_queries() {
508        let mut store = Store::open_in_memory().expect("open");
509        let facts = FactSet::new()
510            .with_node(Node::new("a", NodeKind::Fn, "a"))
511            .with_node(Node::new("b", NodeKind::Fn, "b"))
512            .with_node(Node::new("c", NodeKind::Fn, "c"))
513            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
514            .with_edge(Edge::inferred("a", "c", EdgeKind::References, 0.5));
515        store.apply_factset(&facts).expect("apply");
516
517        let out = store.neighbors("a", Direction::Outgoing).expect("out");
518        let mut keys: Vec<_> = out.iter().map(|n| n.key.clone()).collect();
519        keys.sort();
520        assert_eq!(keys, ["b", "c"]);
521
522        assert!(
523            store
524                .neighbors("b", Direction::Outgoing)
525                .expect("b out")
526                .is_empty()
527        );
528        assert_eq!(
529            store
530                .neighbors("b", Direction::Incoming)
531                .expect("b in")
532                .len(),
533            1
534        );
535
536        let inferred = store
537            .edges_by_provenance(Provenance::Inferred)
538            .expect("inf");
539        assert_eq!(inferred.len(), 1);
540        assert_eq!(inferred[0].confidence, Some(0.5));
541    }
542
543    #[test]
544    fn neighbors_of_absent_node_is_empty() {
545        let store = Store::open_in_memory().expect("open");
546        assert!(
547            store
548                .neighbors("nope", Direction::Both)
549                .expect("q")
550                .is_empty()
551        );
552    }
553
554    #[test]
555    fn get_missing_node_is_none() {
556        let store = Store::open_in_memory().expect("open");
557        assert!(store.get_node("absent").expect("get").is_none());
558    }
559
560    #[test]
561    fn nodes_by_kind_and_edges_to() {
562        let mut store = Store::open_in_memory().expect("open");
563        let facts = FactSet::new()
564            .with_node(Node::new("f1", NodeKind::Fn, "f1"))
565            .with_node(Node::new("f2", NodeKind::Fn, "f2"))
566            .with_node(Node::new("s1", NodeKind::Struct, "s1"))
567            .with_edge(Edge::derived("f1", "s1", EdgeKind::References))
568            .with_edge(Edge::derived("f2", "s1", EdgeKind::References));
569        store.apply_factset(&facts).expect("apply");
570
571        let fns = store.nodes_by_kind(&NodeKind::Fn).expect("fns");
572        assert_eq!(
573            fns.iter().map(|n| n.key.as_str()).collect::<Vec<_>>(),
574            ["f1", "f2"]
575        );
576        assert!(
577            store
578                .nodes_by_kind(&NodeKind::Enum)
579                .expect("enums")
580                .is_empty()
581        );
582
583        let into_s1 = store.edges_to("s1").expect("edges_to");
584        assert_eq!(into_s1.len(), 2);
585        assert!(into_s1.iter().all(|e| e.dst == "s1"));
586    }
587
588    #[test]
589    fn open_persists_across_reopen() {
590        let path =
591            std::env::temp_dir().join(format!("roteiro-open-test-{}.db", std::process::id()));
592        std::fs::remove_file(&path).ok();
593        {
594            let store = Store::open(&path).expect("open");
595            store
596                .upsert_node(&sample_node("persisted"))
597                .expect("upsert");
598        }
599        {
600            let store = Store::open(&path).expect("reopen");
601            assert_eq!(store.node_count().expect("count"), 1);
602            assert_eq!(store.schema_version().expect("version"), 3);
603            assert!(store.get_node("persisted").expect("get").is_some());
604        }
605        std::fs::remove_file(&path).expect("cleanup");
606    }
607}