Skip to main content

llmy_codegraph/
store.rs

1//! SQLite cache for prebuilt code graphs. The graph is stored as one
2//! serialized snapshot per root keyed by an input fingerprint (relative
3//! paths and sizes), so `llmy codegraph index` can prebuild and the harness
4//! can load-or-rebuild cheaply. A stale fingerprint simply misses.
5
6use std::path::Path;
7
8use color_eyre::eyre::eyre;
9use llmy_types::error::LLMYError;
10use sqlx::Row;
11use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
12
13use crate::builder::BuildResult;
14use crate::model::CodeGraph;
15
16const STORE_SCHEMA_SQL: &str = r#"
17CREATE TABLE IF NOT EXISTS codegraph_snapshot (
18    root TEXT PRIMARY KEY,
19    fingerprint TEXT NOT NULL,
20    graph_json TEXT NOT NULL,
21    files INTEGER NOT NULL,
22    parse_errors INTEGER NOT NULL,
23    created_at TEXT NOT NULL
24);
25"#;
26
27#[derive(Debug, Clone)]
28pub struct CodeGraphStore {
29    pool: SqlitePool,
30    path: String,
31}
32
33impl CodeGraphStore {
34    pub async fn open(path: &str) -> Result<Self, LLMYError> {
35        let fs_path = Path::new(path);
36        if fs_path.is_dir() {
37            return Err(eyre!(
38                "codegraph db path {} is a directory; sqlite needs a file",
39                path
40            )
41            .into());
42        }
43        if let Some(parent) = fs_path.parent()
44            && !parent.as_os_str().is_empty()
45        {
46            tokio::fs::create_dir_all(parent).await?;
47        }
48        let opts = SqliteConnectOptions::new()
49            .filename(path)
50            .create_if_missing(true)
51            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
52        let pool = SqlitePoolOptions::new()
53            .max_connections(2)
54            .connect_with(opts)
55            .await
56            .map_err(|e| eyre!("failed to open codegraph db at {}: {}", path, e))?;
57        for stmt in STORE_SCHEMA_SQL.split(';') {
58            let trimmed = stmt.trim();
59            if trimmed.is_empty() {
60                continue;
61            }
62            sqlx::query(trimmed)
63                .execute(&pool)
64                .await
65                .map_err(|e| eyre!("failed to apply codegraph schema: {}", e))?;
66        }
67        Ok(Self {
68            pool,
69            path: path.to_string(),
70        })
71    }
72
73    pub fn path(&self) -> &str {
74        &self.path
75    }
76
77    pub async fn save(&self, root: &str, result: &BuildResult) -> Result<(), LLMYError> {
78        let graph_json = serde_json::to_string(&result.graph)
79            .map_err(|e| eyre!("failed to serialize code graph: {}", e))?;
80        sqlx::query(
81            "INSERT INTO codegraph_snapshot (root, fingerprint, graph_json, files, parse_errors, created_at) \
82             VALUES (?, ?, ?, ?, ?, ?) \
83             ON CONFLICT(root) DO UPDATE SET \
84             fingerprint = excluded.fingerprint, graph_json = excluded.graph_json, \
85             files = excluded.files, parse_errors = excluded.parse_errors, created_at = excluded.created_at",
86        )
87        .bind(root)
88        .bind(&result.fingerprint)
89        .bind(graph_json)
90        .bind(result.files.len() as i64)
91        .bind(result.total_parse_errors() as i64)
92        .bind(chrono::Utc::now().to_rfc3339())
93        .execute(&self.pool)
94        .await
95        .map_err(|e| eyre!("failed to save codegraph snapshot: {}", e))?;
96        Ok(())
97    }
98
99    /// The cached graph for `root`, but only when the stored fingerprint
100    /// still matches the given one.
101    pub async fn load_fresh(
102        &self,
103        root: &str,
104        fingerprint: &str,
105    ) -> Result<Option<CodeGraph>, LLMYError> {
106        let row =
107            sqlx::query("SELECT fingerprint, graph_json FROM codegraph_snapshot WHERE root = ?")
108                .bind(root)
109                .fetch_optional(&self.pool)
110                .await
111                .map_err(|e| eyre!("failed to load codegraph snapshot: {}", e))?;
112        let Some(row) = row else {
113            return Ok(None);
114        };
115        let stored_fingerprint: String = row.try_get("fingerprint").map_err(|e| eyre!("{}", e))?;
116        if stored_fingerprint != fingerprint {
117            tracing::info!("codegraph cache for {} is stale, rebuilding", root);
118            return Ok(None);
119        }
120        let graph_json: String = row.try_get("graph_json").map_err(|e| eyre!("{}", e))?;
121        let graph = serde_json::from_str::<CodeGraph>(&graph_json)
122            .map_err(|e| eyre!("failed to deserialize cached code graph: {}", e))?;
123        Ok(Some(graph))
124    }
125}