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/// A summary of applying/re-applying import layers (see
32/// [`Store::apply_import_layer`] and [`Store::reapply_imports`]).
33#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
34pub struct ImportApplied {
35    /// Number of import layers processed.
36    pub layers: usize,
37    /// Import nodes upserted (across all layers).
38    pub nodes: usize,
39    /// Import edges applied — both endpoints resolved. A duplicate of an
40    /// already-present edge is a harmless no-op but still counted as applied.
41    pub edges_applied: usize,
42    /// Import edges **pruned**: an endpoint was absent (a cross-reference to code
43    /// that no longer exists), so the edge was dropped from the persisted layer
44    /// rather than kept as stale data.
45    pub edges_pruned: usize,
46}
47
48/// Qualified node columns for `SELECT`s that alias the `nodes` table as `n`.
49const NODE_COLS: &str =
50    "n.key, n.kind, n.name, n.path, n.lang, n.blob_hash, n.span_start, n.span_end, n.meta";
51
52/// `SELECT` prefix that yields an [`Edge`] row (endpoints resolved back to keys).
53const EDGE_SELECT: &str = "SELECT ns.key AS src, nd.key AS dst, e.kind, e.provenance, \
54     e.confidence, e.src_ref \
55     FROM edges e JOIN nodes ns ON ns.id = e.src JOIN nodes nd ON nd.id = e.dst";
56
57/// A Roteiro graph store backed by a single `SQLite` database.
58pub struct Store {
59    conn: Connection,
60}
61
62impl Store {
63    /// Open (creating if absent) a store at `path` and apply pending migrations.
64    ///
65    /// # Errors
66    /// Returns [`StoreError::Sqlite`] if the database cannot be opened or a
67    /// migration fails.
68    pub fn open(path: &Path) -> Result<Self, StoreError> {
69        let conn = Connection::open(path)?;
70        Self::from_conn(conn)
71    }
72
73    /// Open an in-memory store (tests, previews).
74    ///
75    /// # Errors
76    /// Returns [`StoreError::Sqlite`] if a migration fails.
77    pub fn open_in_memory() -> Result<Self, StoreError> {
78        let conn = Connection::open_in_memory()?;
79        Self::from_conn(conn)
80    }
81
82    fn from_conn(mut conn: Connection) -> Result<Self, StoreError> {
83        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
84        migrations::apply(&mut conn)?;
85        Ok(Self { conn })
86    }
87
88    /// The schema version this store has been migrated to.
89    ///
90    /// # Errors
91    /// Returns [`StoreError::Sqlite`] on query failure.
92    pub fn schema_version(&self) -> Result<u32, StoreError> {
93        let v: i64 = self.conn.query_row(
94            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
95            [],
96            |r| r.get(0),
97        )?;
98        Ok(u32::try_from(v).unwrap_or(0))
99    }
100
101    /// Number of nodes currently in the store.
102    ///
103    /// # Errors
104    /// Returns [`StoreError::Sqlite`] on query failure.
105    pub fn node_count(&self) -> Result<u64, StoreError> {
106        let n: i64 = self
107            .conn
108            .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
109        Ok(u64::try_from(n).unwrap_or(0))
110    }
111
112    /// Number of edges currently in the store.
113    ///
114    /// # Errors
115    /// Returns [`StoreError::Sqlite`] on query failure.
116    pub fn edge_count(&self) -> Result<u64, StoreError> {
117        let n: i64 = self
118            .conn
119            .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
120        Ok(u64::try_from(n).unwrap_or(0))
121    }
122
123    /// Insert or update a node, keyed by its natural [`Node::key`].
124    ///
125    /// # Errors
126    /// Returns [`StoreError::Json`] if `meta` cannot be serialized, or
127    /// [`StoreError::Sqlite`] on write failure.
128    pub fn upsert_node(&self, node: &Node) -> Result<(), StoreError> {
129        upsert_node(&self.conn, node)
130    }
131
132    /// Insert an edge. Both endpoints must already resolve to nodes.
133    ///
134    /// # Errors
135    /// Returns [`StoreError::InvalidEdge`] if the provenance/confidence
136    /// invariant is violated, [`StoreError::UnknownNode`] if an endpoint key is
137    /// absent, or [`StoreError::Sqlite`] on write failure.
138    pub fn insert_edge(&self, edge: &Edge) -> Result<(), StoreError> {
139        insert_edge(&self.conn, edge)
140    }
141
142    /// Apply a fact set atomically: all nodes are upserted, then all edges are
143    /// inserted, in a single transaction. On any error nothing is committed.
144    ///
145    /// # Errors
146    /// Returns the first error encountered (see [`Store::upsert_node`] and
147    /// [`Store::insert_edge`]); the transaction is rolled back.
148    pub fn apply_factset(&mut self, facts: &FactSet) -> Result<(), StoreError> {
149        let tx = self.conn.transaction()?;
150        for node in &facts.nodes {
151            upsert_node(&tx, node)?;
152        }
153        for edge in &facts.edges {
154            insert_edge(&tx, edge)?;
155        }
156        tx.commit()?;
157        Ok(())
158    }
159
160    /// The `HEAD` tree id recorded at the last successful [`Store::rebuild`], if
161    /// any. Used by the sync engine to detect an unchanged tree.
162    ///
163    /// # Errors
164    /// Returns [`StoreError::Sqlite`] on query failure.
165    pub fn sync_state(&self) -> Result<Option<String>, StoreError> {
166        Ok(self
167            .conn
168            .query_row("SELECT tree FROM sync_state WHERE id = 0", [], |r| r.get(0))
169            .optional()?)
170    }
171
172    /// Atomically replace the entire graph with `facts`, recording `tree` as the
173    /// synced state (or clearing it when `tree` is `None`). All existing nodes
174    /// and edges are deleted first, so the store reflects exactly the given fact
175    /// set.
176    ///
177    /// Passing `None` records *no* synced tree — distinct from an empty string —
178    /// so [`Store::sync_state`] returns `None` and a later `sync` will not
179    /// spuriously short-circuit.
180    ///
181    /// # Errors
182    /// Returns the first error encountered (see [`Store::apply_factset`]); on any
183    /// error nothing is committed.
184    pub fn rebuild(&mut self, facts: &FactSet, tree: Option<&str>) -> Result<(), StoreError> {
185        let tx = self.conn.transaction()?;
186        tx.execute("DELETE FROM edges", [])?;
187        tx.execute("DELETE FROM nodes", [])?;
188        for node in &facts.nodes {
189            upsert_node(&tx, node)?;
190        }
191        for edge in &facts.edges {
192            insert_edge(&tx, edge)?;
193        }
194        match tree {
195            Some(tree) => tx.execute(
196                "INSERT INTO sync_state (id, tree) VALUES (0, ?1)
197                 ON CONFLICT(id) DO UPDATE SET tree = excluded.tree",
198                [tree],
199            )?,
200            None => tx.execute("DELETE FROM sync_state WHERE id = 0", [])?,
201        };
202        tx.commit()?;
203        Ok(())
204    }
205
206    /// Fetch a node by its natural key.
207    ///
208    /// # Errors
209    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
210    /// [`StoreError::Corrupt`] if a stored value cannot be decoded.
211    pub fn get_node(&self, key: &str) -> Result<Option<Node>, StoreError> {
212        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.key = ?1");
213        let mut stmt = self.conn.prepare(&sql)?;
214        let mut rows = stmt.query([key])?;
215        match rows.next()? {
216            Some(row) => Ok(Some(row_to_node(row)?)),
217            None => Ok(None),
218        }
219    }
220
221    /// Every node key in the store, ordered. Useful for whole-graph exports.
222    ///
223    /// # Errors
224    /// Returns [`StoreError::Sqlite`] on query failure.
225    pub fn all_keys(&self) -> Result<Vec<String>, StoreError> {
226        let mut stmt = self.conn.prepare("SELECT key FROM nodes ORDER BY key")?;
227        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
228        let mut out = Vec::new();
229        for row in rows {
230            out.push(row?);
231        }
232        Ok(out)
233    }
234
235    /// Dump the entire graph as a single [`FactSet`], with nodes and edges in a
236    /// deterministic order — suitable for a portable, content-stable artifact.
237    ///
238    /// # Errors
239    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
240    /// [`StoreError::Corrupt`] on decode failure.
241    pub fn export_factset(&self) -> Result<FactSet, StoreError> {
242        let node_sql = format!("SELECT {NODE_COLS} FROM nodes n ORDER BY n.key");
243        let mut node_stmt = self.conn.prepare(&node_sql)?;
244        let mut node_rows = node_stmt.query([])?;
245        let nodes = collect_nodes(&mut node_rows)?;
246
247        // Order edges by their resolved endpoint keys (not row id) so the dump is
248        // stable regardless of insertion order.
249        let edge_sql = format!("{EDGE_SELECT} ORDER BY ns.key, nd.key, e.kind, e.provenance");
250        let mut edge_stmt = self.conn.prepare(&edge_sql)?;
251        let mut edge_rows = edge_stmt.query([])?;
252        let edges = collect_edges(&mut edge_rows)?;
253
254        Ok(FactSet { nodes, edges })
255    }
256
257    /// All nodes of a given kind.
258    ///
259    /// # Errors
260    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
261    /// [`StoreError::Corrupt`] on decode failure.
262    pub fn nodes_by_kind(&self, kind: &NodeKind) -> Result<Vec<Node>, StoreError> {
263        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.kind = ?1 ORDER BY n.key");
264        let mut stmt = self.conn.prepare(&sql)?;
265        let mut rows = stmt.query([kind.as_str()])?;
266        collect_nodes(&mut rows)
267    }
268
269    /// Every node in the store, ordered by key. Unlike [`Store::export_factset`]
270    /// this decodes no edges, so it is cheap for node-only scans (e.g. search).
271    ///
272    /// # Errors
273    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
274    /// [`StoreError::Corrupt`] on decode failure.
275    pub fn all_nodes(&self) -> Result<Vec<Node>, StoreError> {
276        let sql = format!("SELECT {NODE_COLS} FROM nodes n ORDER BY n.key");
277        let mut stmt = self.conn.prepare(&sql)?;
278        let mut rows = stmt.query([])?;
279        collect_nodes(&mut rows)
280    }
281
282    /// Edges whose source is the node with the given key.
283    ///
284    /// # Errors
285    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
286    pub fn edges_from(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
287        let sql = format!("{EDGE_SELECT} WHERE ns.key = ?1 ORDER BY e.id");
288        let mut stmt = self.conn.prepare(&sql)?;
289        let mut rows = stmt.query([key])?;
290        collect_edges(&mut rows)
291    }
292
293    /// Edges whose destination is the node with the given key.
294    ///
295    /// # Errors
296    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
297    pub fn edges_to(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
298        let sql = format!("{EDGE_SELECT} WHERE nd.key = ?1 ORDER BY e.id");
299        let mut stmt = self.conn.prepare(&sql)?;
300        let mut rows = stmt.query([key])?;
301        collect_edges(&mut rows)
302    }
303
304    /// All edges with the given provenance.
305    ///
306    /// # Errors
307    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
308    pub fn edges_by_provenance(&self, provenance: Provenance) -> Result<Vec<Edge>, StoreError> {
309        let sql = format!("{EDGE_SELECT} WHERE e.provenance = ?1 ORDER BY e.id");
310        let mut stmt = self.conn.prepare(&sql)?;
311        let mut rows = stmt.query([provenance.as_str()])?;
312        collect_edges(&mut rows)
313    }
314
315    /// Delete all edges with the given provenance, returning how many were
316    /// removed. Used to re-derive a whole provenance class authoritatively (e.g.
317    /// `inferred` edges when re-running inference with different parameters).
318    ///
319    /// # Errors
320    /// Returns [`StoreError::Sqlite`] on write failure.
321    pub fn delete_edges_by_provenance(&self, provenance: Provenance) -> Result<u64, StoreError> {
322        let n = self.conn.execute(
323            "DELETE FROM edges WHERE provenance = ?1",
324            [provenance.as_str()],
325        )?;
326        Ok(u64::try_from(n).unwrap_or(0))
327    }
328
329    /// Delete all edges carrying the given `src_ref`, returning how many were
330    /// removed. Lets one producer of `inferred` edges (e.g. the embedding layer,
331    /// or a Graphify import) re-derive its own edges authoritatively without
332    /// touching edges another producer contributed.
333    ///
334    /// # Errors
335    /// Returns [`StoreError::Sqlite`] on write failure.
336    pub fn delete_edges_by_src_ref(&self, src_ref: &str) -> Result<u64, StoreError> {
337        let n = self
338            .conn
339            .execute("DELETE FROM edges WHERE src_ref = ?1", [src_ref])?;
340        Ok(u64::try_from(n).unwrap_or(0))
341    }
342
343    /// Apply an import layer to the live graph **and** persist it durably under
344    /// `src_ref`, validating as it goes: this ref's prior edges are cleared
345    /// (an authoritative re-import), the layer's nodes are upserted, and each
346    /// edge is applied only if both endpoints resolve. Dangling edges — cross-
347    /// references to code that is not present — are dropped, and only the
348    /// validated (trimmed) layer is persisted, so stale data is never stored.
349    ///
350    /// This is the "validate on import" half; [`Store::reapply_imports`] is the
351    /// "validate on sync" half, re-checking layers against the rebuilt graph.
352    ///
353    /// # Errors
354    /// Returns [`StoreError::Json`] if `facts` cannot be (de)serialized,
355    /// [`StoreError::InvalidEdge`] on a malformed edge, or [`StoreError::Sqlite`]
356    /// on write failure.
357    pub fn apply_import_layer(
358        &mut self,
359        src_ref: &str,
360        facts: &FactSet,
361    ) -> Result<ImportApplied, StoreError> {
362        let tx = self.conn.transaction()?;
363        // Authoritative re-import: drop this ref's prior edges from the live graph.
364        tx.execute("DELETE FROM edges WHERE src_ref = ?1", [src_ref])?;
365        for node in &facts.nodes {
366            upsert_node(&tx, node)?;
367        }
368        let (kept, applied) = apply_edges_pruning(&tx, &facts.edges)?;
369        let trimmed = FactSet {
370            nodes: facts.nodes.clone(),
371            edges: kept,
372        };
373        put_import_row(&tx, src_ref, &trimmed)?;
374        tx.commit()?;
375        Ok(ImportApplied {
376            layers: 1,
377            nodes: facts.nodes.len(),
378            ..applied
379        })
380    }
381
382    /// Remove the persisted import layer for `src_ref`, returning whether one
383    /// existed. Does not remove edges already in the live graph (use
384    /// [`Store::delete_edges_by_src_ref`] for that).
385    ///
386    /// # Errors
387    /// Returns [`StoreError::Sqlite`] on write failure.
388    pub fn delete_import(&self, src_ref: &str) -> Result<bool, StoreError> {
389        let n = self
390            .conn
391            .execute("DELETE FROM imports WHERE src_ref = ?1", [src_ref])?;
392        Ok(n > 0)
393    }
394
395    /// The `src_ref`s of all persisted import layers, ordered.
396    ///
397    /// # Errors
398    /// Returns [`StoreError::Sqlite`] on query failure.
399    pub fn import_refs(&self) -> Result<Vec<String>, StoreError> {
400        let mut stmt = self
401            .conn
402            .prepare("SELECT src_ref FROM imports ORDER BY src_ref")?;
403        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
404        let mut out = Vec::new();
405        for row in rows {
406            out.push(row?);
407        }
408        Ok(out)
409    }
410
411    /// Re-apply every persisted import layer on top of the current graph and
412    /// **re-validate** it: all import nodes are upserted first (so cross-layer
413    /// and self references resolve), then each edge is applied; an edge whose
414    /// endpoint is now absent — a cross-reference to code a sync removed — is
415    /// pruned from the persisted layer, not merely skipped. So the durable store
416    /// keeps only still-correct data. Idempotent; safe to run after each rebuild.
417    ///
418    /// # Errors
419    /// Returns [`StoreError::Json`] if a stored layer cannot be (de)serialized,
420    /// or [`StoreError::Sqlite`] on write failure.
421    pub fn reapply_imports(&mut self) -> Result<ImportApplied, StoreError> {
422        let layers = self.load_import_layers()?;
423        let tx = self.conn.transaction()?;
424        // Pass 1: upsert every layer's nodes so intra-import edges resolve
425        // regardless of which layer defines the endpoint.
426        for (_, facts) in &layers {
427            for node in &facts.nodes {
428                upsert_node(&tx, node)?;
429            }
430        }
431        // Pass 2: apply edges, pruning (and rewriting) any that dangle.
432        let mut applied = ImportApplied {
433            layers: layers.len(),
434            ..ImportApplied::default()
435        };
436        for (src_ref, facts) in &layers {
437            applied.nodes += facts.nodes.len();
438            let (kept, counts) = apply_edges_pruning(&tx, &facts.edges)?;
439            applied.edges_applied += counts.edges_applied;
440            applied.edges_pruned += counts.edges_pruned;
441            if kept.len() != facts.edges.len() {
442                let trimmed = FactSet {
443                    nodes: facts.nodes.clone(),
444                    edges: kept,
445                };
446                put_import_row(&tx, src_ref, &trimmed)?;
447            }
448        }
449        tx.commit()?;
450        Ok(applied)
451    }
452
453    /// Load and decode every persisted import layer as `(src_ref, FactSet)`, in
454    /// `src_ref` order.
455    fn load_import_layers(&self) -> Result<Vec<(String, FactSet)>, StoreError> {
456        let mut stmt = self
457            .conn
458            .prepare("SELECT src_ref, facts FROM imports ORDER BY src_ref")?;
459        let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
460        let mut out = Vec::new();
461        for row in rows {
462            let (src_ref, json) = row?;
463            out.push((src_ref, serde_json::from_str::<FactSet>(&json)?));
464        }
465        Ok(out)
466    }
467
468    /// Neighbouring nodes reachable from `key` in the given direction. Returns
469    /// an empty vector if the node does not exist.
470    ///
471    /// # Errors
472    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
473    /// [`StoreError::Corrupt`] on failure.
474    pub fn neighbors(&self, key: &str, dir: Direction) -> Result<Vec<Node>, StoreError> {
475        let out = format!(
476            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.dst \
477             JOIN nodes s ON s.id = e.src WHERE s.key = ?1"
478        );
479        let inc = format!(
480            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.src \
481             JOIN nodes d ON d.id = e.dst WHERE d.key = ?1"
482        );
483        // Order by output column 1 (the node key) so results are deterministic
484        // across SQLite versions/plans. Positional ordering avoids both the
485        // ambiguity of a bare `key` (present in every joined table) and the fact
486        // that a table-qualified name cannot be used after the `Both` UNION.
487        let sql = match dir {
488            Direction::Outgoing => format!("{out} ORDER BY 1"),
489            Direction::Incoming => format!("{inc} ORDER BY 1"),
490            Direction::Both => format!("{out} UNION {inc} ORDER BY 1"),
491        };
492        let mut stmt = self.conn.prepare(&sql)?;
493        let mut rows = stmt.query([key])?;
494        collect_nodes(&mut rows)
495    }
496
497    /// Fetch the cached context bundle for `key` as `(fingerprint, json)`, if
498    /// present. The caller compares the fingerprint to the node's current one to
499    /// decide whether the entry is fresh (see [`crate::context`]).
500    ///
501    /// # Errors
502    /// Returns [`StoreError::Sqlite`] on query failure.
503    pub fn context_cache_get(&self, key: &str) -> Result<Option<(String, String)>, StoreError> {
504        let row = self
505            .conn
506            .query_row(
507                "SELECT fingerprint, json FROM node_context WHERE key = ?1",
508                [key],
509                |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
510            )
511            .optional()?;
512        Ok(row)
513    }
514
515    /// Fetch just the cached fingerprint for `key`, without reading the (larger)
516    /// JSON payload — for a cheap freshness check.
517    ///
518    /// # Errors
519    /// Returns [`StoreError::Sqlite`] on query failure.
520    pub fn context_cache_fingerprint(&self, key: &str) -> Result<Option<String>, StoreError> {
521        let fp = self
522            .conn
523            .query_row(
524                "SELECT fingerprint FROM node_context WHERE key = ?1",
525                [key],
526                |r| r.get::<_, String>(0),
527            )
528            .optional()?;
529        Ok(fp)
530    }
531
532    /// Store (or replace) the cached context bundle for `key`.
533    ///
534    /// # Errors
535    /// Returns [`StoreError::Sqlite`] on write failure.
536    pub fn context_cache_put(
537        &self,
538        key: &str,
539        fingerprint: &str,
540        json: &str,
541    ) -> Result<(), StoreError> {
542        self.conn.execute(
543            "INSERT INTO node_context (key, fingerprint, json) VALUES (?1, ?2, ?3)
544             ON CONFLICT(key) DO UPDATE SET
545                 fingerprint = excluded.fingerprint, json = excluded.json",
546            [key, fingerprint, json],
547        )?;
548        Ok(())
549    }
550
551    /// Delete the cached context entry for `key`, returning whether one existed.
552    ///
553    /// # Errors
554    /// Returns [`StoreError::Sqlite`] on write failure.
555    pub fn context_cache_delete(&self, key: &str) -> Result<bool, StoreError> {
556        let n = self
557            .conn
558            .execute("DELETE FROM node_context WHERE key = ?1", [key])?;
559        Ok(n > 0)
560    }
561
562    /// Every key with a cached context entry, ordered. Used to prune entries for
563    /// nodes that no longer exist.
564    ///
565    /// # Errors
566    /// Returns [`StoreError::Sqlite`] on query failure.
567    pub fn context_cache_keys(&self) -> Result<Vec<String>, StoreError> {
568        let mut stmt = self
569            .conn
570            .prepare("SELECT key FROM node_context ORDER BY key")?;
571        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
572        let mut out = Vec::new();
573        for row in rows {
574            out.push(row?);
575        }
576        Ok(out)
577    }
578}
579
580// --- Free helpers operating on a `Connection` (a `Transaction` derefs to one) ---
581
582fn node_row_id(conn: &Connection, key: &str) -> rusqlite::Result<Option<i64>> {
583    conn.query_row("SELECT id FROM nodes WHERE key = ?1", [key], |r| r.get(0))
584        .optional()
585}
586
587fn upsert_node(conn: &Connection, node: &Node) -> Result<(), StoreError> {
588    let meta = serde_json::to_string(&node.meta)?;
589    let (span_start, span_end) = match node.span {
590        Some(s) => (Some(i64::from(s.start)), Some(i64::from(s.end))),
591        None => (None, None),
592    };
593    conn.execute(
594        "INSERT INTO nodes (key, kind, name, path, lang, blob_hash, span_start, span_end, meta)
595         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
596         ON CONFLICT(key) DO UPDATE SET
597             kind = excluded.kind, name = excluded.name, path = excluded.path,
598             lang = excluded.lang, blob_hash = excluded.blob_hash,
599             span_start = excluded.span_start, span_end = excluded.span_end,
600             meta = excluded.meta",
601        params![
602            node.key,
603            node.kind.as_str(),
604            node.name,
605            node.path,
606            node.lang,
607            node.blob_hash,
608            span_start,
609            span_end,
610            meta,
611        ],
612    )?;
613    Ok(())
614}
615
616fn insert_edge(conn: &Connection, edge: &Edge) -> Result<(), StoreError> {
617    validate_edge(edge)?;
618    let src_id =
619        node_row_id(conn, &edge.src)?.ok_or_else(|| StoreError::UnknownNode(edge.src.clone()))?;
620    let dst_id =
621        node_row_id(conn, &edge.dst)?.ok_or_else(|| StoreError::UnknownNode(edge.dst.clone()))?;
622    insert_edge_row(conn, edge, src_id, dst_id)
623}
624
625/// Apply `edge` only if both endpoints already resolve to nodes, returning
626/// whether it was **applied** (both endpoints resolved; a duplicate of an
627/// existing edge is a harmless no-op via `ON CONFLICT DO NOTHING` but still
628/// reports `true`). A missing endpoint returns `false` rather than erroring —
629/// the caller prunes such dangling cross-references from the import layer.
630fn insert_edge_if_present(conn: &Connection, edge: &Edge) -> Result<bool, StoreError> {
631    validate_edge(edge)?;
632    let (Some(src_id), Some(dst_id)) =
633        (node_row_id(conn, &edge.src)?, node_row_id(conn, &edge.dst)?)
634    else {
635        return Ok(false);
636    };
637    insert_edge_row(conn, edge, src_id, dst_id)?;
638    Ok(true)
639}
640
641/// Apply `edges`, keeping those whose endpoints resolve and pruning the rest.
642/// Returns the kept edges plus the applied/pruned counts (in an [`ImportApplied`]
643/// whose `layers`/`nodes` are left zero for the caller to fill).
644fn apply_edges_pruning(
645    conn: &Connection,
646    edges: &[Edge],
647) -> Result<(Vec<Edge>, ImportApplied), StoreError> {
648    let mut kept = Vec::with_capacity(edges.len());
649    let mut counts = ImportApplied::default();
650    for edge in edges {
651        if insert_edge_if_present(conn, edge)? {
652            kept.push(edge.clone());
653            counts.edges_applied += 1;
654        } else {
655            counts.edges_pruned += 1;
656        }
657    }
658    Ok((kept, counts))
659}
660
661/// Upsert a persisted import layer row. Free helper so it can run inside the same
662/// transaction as an apply/prune pass.
663fn put_import_row(conn: &Connection, src_ref: &str, facts: &FactSet) -> Result<(), StoreError> {
664    let json = serde_json::to_string(facts)?;
665    conn.execute(
666        "INSERT INTO imports (src_ref, facts) VALUES (?1, ?2)
667         ON CONFLICT(src_ref) DO UPDATE SET facts = excluded.facts, imported_at = datetime('now')",
668        params![src_ref, json],
669    )?;
670    Ok(())
671}
672
673/// The provenance/confidence invariant guard shared by the strict and tolerant
674/// edge inserts.
675fn validate_edge(edge: &Edge) -> Result<(), StoreError> {
676    if edge.is_valid() {
677        Ok(())
678    } else {
679        Err(StoreError::InvalidEdge(format!(
680            "confidence must be present iff provenance is inferred (src={}, dst={})",
681            edge.src, edge.dst
682        )))
683    }
684}
685
686/// Insert an edge row given already-resolved endpoint ids. Edges are a set: a
687/// duplicate `(src, dst, kind, provenance)` is a no-op via `ON CONFLICT … DO
688/// NOTHING`, so re-applying a fact set never accumulates duplicates. Other
689/// constraint violations (guarded in Rust above) still surface.
690fn insert_edge_row(
691    conn: &Connection,
692    edge: &Edge,
693    src_id: i64,
694    dst_id: i64,
695) -> Result<(), StoreError> {
696    conn.execute(
697        "INSERT INTO edges (src, dst, kind, provenance, confidence, src_ref)
698         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
699         ON CONFLICT(src, dst, kind, provenance) DO NOTHING",
700        params![
701            src_id,
702            dst_id,
703            edge.kind.as_str(),
704            edge.provenance.as_str(),
705            edge.confidence,
706            edge.src_ref,
707        ],
708    )?;
709    Ok(())
710}
711
712fn collect_nodes(rows: &mut rusqlite::Rows) -> Result<Vec<Node>, StoreError> {
713    let mut out = Vec::new();
714    while let Some(row) = rows.next()? {
715        out.push(row_to_node(row)?);
716    }
717    Ok(out)
718}
719
720fn collect_edges(rows: &mut rusqlite::Rows) -> Result<Vec<Edge>, StoreError> {
721    let mut out = Vec::new();
722    while let Some(row) = rows.next()? {
723        out.push(row_to_edge(row)?);
724    }
725    Ok(out)
726}
727
728fn row_to_node(row: &rusqlite::Row) -> Result<Node, StoreError> {
729    let kind: String = row.get("kind")?;
730    let span_start: Option<i64> = row.get("span_start")?;
731    let span_end: Option<i64> = row.get("span_end")?;
732    let span = match (span_start, span_end) {
733        (Some(s), Some(e)) => Some(Span::new(to_u32(s)?, to_u32(e)?)),
734        _ => None,
735    };
736    let meta: String = row.get("meta")?;
737    Ok(Node {
738        key: row.get("key")?,
739        kind: NodeKind::from_token(&kind),
740        name: row.get("name")?,
741        path: row.get("path")?,
742        lang: row.get("lang")?,
743        blob_hash: row.get("blob_hash")?,
744        span,
745        meta: serde_json::from_str(&meta)?,
746    })
747}
748
749fn row_to_edge(row: &rusqlite::Row) -> Result<Edge, StoreError> {
750    let kind: String = row.get("kind")?;
751    let provenance: String = row.get("provenance")?;
752    let provenance = Provenance::from_token(&provenance)
753        .ok_or_else(|| StoreError::Corrupt(format!("unknown provenance: {provenance}")))?;
754    Ok(Edge {
755        src: row.get("src")?,
756        dst: row.get("dst")?,
757        kind: EdgeKind::from_token(&kind),
758        provenance,
759        confidence: row.get("confidence")?,
760        src_ref: row.get("src_ref")?,
761    })
762}
763
764fn to_u32(v: i64) -> Result<u32, StoreError> {
765    u32::try_from(v).map_err(|_| StoreError::Corrupt(format!("span offset out of range: {v}")))
766}
767
768#[cfg(test)]
769mod tests {
770    use super::Store;
771    use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
772    use crate::provenance::Provenance;
773
774    fn sample_node(key: &str) -> Node {
775        Node {
776            key: key.to_owned(),
777            kind: NodeKind::Fn,
778            name: "sample".to_owned(),
779            path: Some("src/lib.rs".to_owned()),
780            lang: Some("rust".to_owned()),
781            blob_hash: Some("deadbeef".to_owned()),
782            span: Some(Span::new(10, 42)),
783            meta: serde_json::json!({"vis": "pub"}),
784        }
785    }
786
787    #[test]
788    fn open_in_memory_applies_schema() {
789        let store = Store::open_in_memory().expect("open");
790        assert_eq!(store.node_count().expect("count"), 0);
791        assert_eq!(store.schema_version().expect("version"), 5);
792    }
793
794    #[test]
795    fn upsert_and_get_round_trips_all_fields() {
796        let store = Store::open_in_memory().expect("open");
797        let node = sample_node("sym:rust:src/lib.rs#sample");
798        store.upsert_node(&node).expect("upsert");
799        let got = store.get_node(&node.key).expect("get").expect("present");
800        assert_eq!(got, node);
801    }
802
803    #[test]
804    fn upsert_updates_in_place() {
805        let store = Store::open_in_memory().expect("open");
806        let mut node = sample_node("k");
807        store.upsert_node(&node).expect("insert");
808        node.name = "renamed".to_owned();
809        node.kind = NodeKind::Struct;
810        store.upsert_node(&node).expect("update");
811        assert_eq!(store.node_count().expect("count"), 1);
812        let got = store.get_node("k").expect("get").expect("present");
813        assert_eq!(got.name, "renamed");
814        assert_eq!(got.kind, NodeKind::Struct);
815    }
816
817    #[test]
818    fn edge_with_unknown_endpoint_is_rejected() {
819        let store = Store::open_in_memory().expect("open");
820        store
821            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
822            .expect("a");
823        let edge = Edge::derived("a", "missing", EdgeKind::Calls);
824        let err = store.insert_edge(&edge).expect_err("should reject");
825        assert!(matches!(err, super::StoreError::UnknownNode(k) if k == "missing"));
826    }
827
828    #[test]
829    fn inferred_edge_requires_confidence() {
830        let store = Store::open_in_memory().expect("open");
831        store
832            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
833            .expect("a");
834        store
835            .upsert_node(&Node::new("b", NodeKind::Fn, "b"))
836            .expect("b");
837        // Hand-build an inferred edge with no confidence to violate the invariant.
838        let bad = Edge {
839            src: "a".to_owned(),
840            dst: "b".to_owned(),
841            kind: EdgeKind::References,
842            provenance: Provenance::Inferred,
843            confidence: None,
844            src_ref: None,
845        };
846        assert!(matches!(
847            store.insert_edge(&bad).expect_err("reject"),
848            super::StoreError::InvalidEdge(_)
849        ));
850    }
851
852    #[test]
853    fn apply_factset_is_atomic() {
854        let mut store = Store::open_in_memory().expect("open");
855        // Second edge references a missing node, so the whole set must roll back.
856        let facts = FactSet::new()
857            .with_node(Node::new("a", NodeKind::Fn, "a"))
858            .with_node(Node::new("b", NodeKind::Fn, "b"))
859            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
860            .with_edge(Edge::derived("a", "ghost", EdgeKind::Calls));
861        assert!(store.apply_factset(&facts).is_err());
862        assert_eq!(store.node_count().expect("count"), 0, "rolled back");
863        assert_eq!(store.edge_count().expect("count"), 0, "rolled back");
864    }
865
866    #[test]
867    fn neighbors_and_provenance_queries() {
868        let mut store = Store::open_in_memory().expect("open");
869        let facts = FactSet::new()
870            .with_node(Node::new("a", NodeKind::Fn, "a"))
871            .with_node(Node::new("b", NodeKind::Fn, "b"))
872            .with_node(Node::new("c", NodeKind::Fn, "c"))
873            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
874            .with_edge(Edge::inferred("a", "c", EdgeKind::References, 0.5));
875        store.apply_factset(&facts).expect("apply");
876
877        let out = store.neighbors("a", Direction::Outgoing).expect("out");
878        let mut keys: Vec<_> = out.iter().map(|n| n.key.clone()).collect();
879        keys.sort();
880        assert_eq!(keys, ["b", "c"]);
881
882        assert!(
883            store
884                .neighbors("b", Direction::Outgoing)
885                .expect("b out")
886                .is_empty()
887        );
888        assert_eq!(
889            store
890                .neighbors("b", Direction::Incoming)
891                .expect("b in")
892                .len(),
893            1
894        );
895
896        let inferred = store
897            .edges_by_provenance(Provenance::Inferred)
898            .expect("inf");
899        assert_eq!(inferred.len(), 1);
900        assert_eq!(inferred[0].confidence, Some(0.5));
901    }
902
903    #[test]
904    fn neighbors_of_absent_node_is_empty() {
905        let store = Store::open_in_memory().expect("open");
906        assert!(
907            store
908                .neighbors("nope", Direction::Both)
909                .expect("q")
910                .is_empty()
911        );
912    }
913
914    #[test]
915    fn get_missing_node_is_none() {
916        let store = Store::open_in_memory().expect("open");
917        assert!(store.get_node("absent").expect("get").is_none());
918    }
919
920    #[test]
921    fn nodes_by_kind_and_edges_to() {
922        let mut store = Store::open_in_memory().expect("open");
923        let facts = FactSet::new()
924            .with_node(Node::new("f1", NodeKind::Fn, "f1"))
925            .with_node(Node::new("f2", NodeKind::Fn, "f2"))
926            .with_node(Node::new("s1", NodeKind::Struct, "s1"))
927            .with_edge(Edge::derived("f1", "s1", EdgeKind::References))
928            .with_edge(Edge::derived("f2", "s1", EdgeKind::References));
929        store.apply_factset(&facts).expect("apply");
930
931        let fns = store.nodes_by_kind(&NodeKind::Fn).expect("fns");
932        assert_eq!(
933            fns.iter().map(|n| n.key.as_str()).collect::<Vec<_>>(),
934            ["f1", "f2"]
935        );
936        assert!(
937            store
938                .nodes_by_kind(&NodeKind::Enum)
939                .expect("enums")
940                .is_empty()
941        );
942
943        let into_s1 = store.edges_to("s1").expect("edges_to");
944        assert_eq!(into_s1.len(), 2);
945        assert!(into_s1.iter().all(|e| e.dst == "s1"));
946    }
947
948    #[test]
949    fn open_persists_across_reopen() {
950        let path =
951            std::env::temp_dir().join(format!("roteiro-open-test-{}.db", std::process::id()));
952        std::fs::remove_file(&path).ok();
953        {
954            let store = Store::open(&path).expect("open");
955            store
956                .upsert_node(&sample_node("persisted"))
957                .expect("upsert");
958        }
959        {
960            let store = Store::open(&path).expect("reopen");
961            assert_eq!(store.node_count().expect("count"), 1);
962            assert_eq!(store.schema_version().expect("version"), 5);
963            assert!(store.get_node("persisted").expect("get").is_some());
964        }
965        std::fs::remove_file(&path).expect("cleanup");
966    }
967
968    fn graphify_layer(dst: &str) -> FactSet {
969        FactSet::new()
970            .with_node(Node::new("graphify:doc1", NodeKind::Doc, "Doc 1"))
971            .with_edge({
972                let mut e = Edge::inferred("graphify:doc1", dst, EdgeKind::References, 0.9);
973                e.src_ref = Some("import:graphify".to_owned());
974                e
975            })
976    }
977
978    /// A persisted import layer is re-applied after a `rebuild` wipes the graph,
979    /// so imported facts survive a code-changing sync.
980    #[test]
981    fn imports_survive_rebuild() {
982        let mut store = Store::open_in_memory().expect("open");
983        let derived = FactSet::new().with_node(Node::new("file:a.rs", NodeKind::File, "a.rs"));
984        store.rebuild(&derived, Some("tree1")).expect("rebuild");
985
986        // apply_import_layer applies to the live graph and persists in one step.
987        let applied = store
988            .apply_import_layer("import:graphify", &graphify_layer("file:a.rs"))
989            .expect("apply import");
990        assert_eq!(applied.edges_applied, 1);
991        assert_eq!(applied.edges_pruned, 0);
992        assert_eq!(store.import_refs().expect("refs"), vec!["import:graphify"]);
993
994        // Simulate a code-changing sync: the derived graph is rebuilt (still has
995        // file:a.rs), which drops the imported doc + edge from the live graph.
996        store.rebuild(&derived, Some("tree2")).expect("rebuild2");
997        assert!(store.get_node("graphify:doc1").expect("get").is_none());
998
999        // Re-applying imports restores them; nothing is pruned (target present).
1000        let applied = store.reapply_imports().expect("reapply");
1001        assert_eq!(applied.layers, 1);
1002        assert_eq!(applied.nodes, 1);
1003        assert_eq!(applied.edges_applied, 1);
1004        assert_eq!(applied.edges_pruned, 0);
1005        assert!(store.get_node("graphify:doc1").expect("get").is_some());
1006        assert_eq!(store.edges_from("graphify:doc1").expect("edges").len(), 1);
1007    }
1008
1009    /// When a sync removes an edge's target (e.g. a deleted file), re-applying
1010    /// **prunes** that stale cross-reference from the persisted layer — it is not
1011    /// kept and retried forever. The import node itself is preserved.
1012    #[test]
1013    fn reapply_prunes_stale_cross_references() {
1014        let mut store = Store::open_in_memory().expect("open");
1015        let derived = FactSet::new().with_node(Node::new("file:gone.rs", NodeKind::File, "g"));
1016        store.rebuild(&derived, Some("t1")).expect("rebuild");
1017        store
1018            .apply_import_layer("import:graphify", &graphify_layer("file:gone.rs"))
1019            .expect("import");
1020
1021        // Code-changing sync: file:gone.rs is deleted from the derived graph.
1022        store
1023            .rebuild(&FactSet::new(), Some("t2"))
1024            .expect("rebuild2");
1025        let applied = store.reapply_imports().expect("reapply");
1026        assert_eq!(applied.nodes, 1);
1027        assert_eq!(applied.edges_applied, 0);
1028        assert_eq!(
1029            applied.edges_pruned, 1,
1030            "the edge to the deleted file is pruned"
1031        );
1032        assert!(store.get_node("graphify:doc1").expect("get").is_some());
1033
1034        // The prune is durable: a second reapply finds nothing left to prune,
1035        // proving the stale edge was removed from the persisted layer.
1036        let again = store.reapply_imports().expect("reapply2");
1037        assert_eq!(again.edges_applied, 0);
1038        assert_eq!(again.edges_pruned, 0, "already pruned; not retried");
1039    }
1040
1041    /// `apply_import_layer` validates on import: a dangling edge in the incoming
1042    /// layer is dropped and never persisted.
1043    #[test]
1044    fn apply_import_layer_prunes_on_import() {
1045        let mut store = Store::open_in_memory().expect("open");
1046        let present = || FactSet::new().with_node(Node::new("file:a.rs", NodeKind::File, "a"));
1047        store.rebuild(&present(), Some("t")).expect("rebuild");
1048
1049        let layer = graphify_layer("file:a.rs").with_edge({
1050            // Points at a file that does not exist → pruned on import.
1051            let mut e = Edge::inferred("graphify:doc1", "file:ghost.rs", EdgeKind::References, 0.9);
1052            e.src_ref = Some("import:graphify".to_owned());
1053            e
1054        });
1055        let applied = store
1056            .apply_import_layer("import:graphify", &layer)
1057            .expect("import");
1058        assert_eq!(applied.edges_applied, 1);
1059        assert_eq!(applied.edges_pruned, 1);
1060
1061        // A rebuild + reapply confirms only the valid edge was persisted.
1062        store.rebuild(&present(), Some("t2")).expect("rebuild2");
1063        let re = store.reapply_imports().expect("reapply");
1064        assert_eq!(re.edges_applied, 1);
1065        assert_eq!(re.edges_pruned, 0, "ghost edge was not persisted");
1066    }
1067
1068    /// `apply_import_layer` replaces the layer for a ref; `delete_import` removes.
1069    #[test]
1070    fn apply_import_replaces_and_delete_removes() {
1071        let mut store = Store::open_in_memory().expect("open");
1072        let a = FactSet::new().with_node(Node::new("graphify:x", NodeKind::Doc, "x"));
1073        let b = FactSet::new().with_node(Node::new("graphify:y", NodeKind::Doc, "y"));
1074        store.apply_import_layer("import:graphify", &a).expect("a");
1075        store.apply_import_layer("import:graphify", &b).expect("b");
1076        assert_eq!(
1077            store.import_refs().expect("refs").len(),
1078            1,
1079            "same ref replaced"
1080        );
1081        assert!(store.delete_import("import:graphify").expect("del"));
1082        assert!(store.import_refs().expect("refs").is_empty());
1083        assert!(!store.delete_import("import:graphify").expect("del again"));
1084    }
1085}