Skip to main content

sqlite_graphrag/storage/
connection.rs

1//! SQLite connection setup with PRAGMAs and 0600 permissions.
2//!
3//! v1.0.76: opens (or creates) the database file. The `sqlite-vec` extension
4//! was REMOVED; vector similarity is now computed in pure Rust over the
5//! `memory_embeddings(memory_id, embedding BLOB, source)` table. WAL/journal
6//! PRAGMAs and 0600 file permissions on Unix are unchanged.
7
8use crate::errors::AppError;
9use crate::paths::AppPaths;
10use crate::pragmas::{apply_connection_pragmas, apply_init_pragmas, ensure_wal_mode};
11use rusqlite::Connection;
12use std::path::Path;
13
14/// v1.0.76: no-op stub. Kept for source compatibility with callers that
15/// still call `register_vec_extension()` during auto-init. The actual
16/// extension registration is gone; the function is now a marker that
17/// the LLM-only build does not need any vector extension.
18pub fn register_vec_extension() {}
19
20/// Open rw.
21pub fn open_rw(path: &Path) -> Result<Connection, AppError> {
22    let conn = Connection::open(path)?;
23    apply_connection_pragmas(&conn)?;
24    apply_secure_permissions(path);
25    adopt_embedding_dim(&conn);
26    Ok(conn)
27}
28
29/// G42/S1 follow-up (G43): adopts the dimensionality recorded in
30/// `schema_meta.dim` for this process, so EVERY command that opens the
31/// database — not only the `ensure_db_ready` auto-init path — produces
32/// and queries vectors of the database dimensionality. Pre-G43 the
33/// adoption only ran in `ensure_db_ready`, which `remember` / `edit` /
34/// `recall` / `hybrid-search` never call; those commands silently used
35/// the compiled default (64) against pre-v1.0.79 384-dim databases,
36/// writing mixed-dim embeddings that cosine-score 0.0 against each
37/// other.
38///
39/// Read-only and best-effort by design: a virgin database without
40/// `schema_meta` is a no-op (the table is created and persisted later
41/// by `ensure_schema` / `ensure_db_ready`). A CLI flag or XDG override
42/// always wins and is handled inside `constants::embedding_dim`.
43fn adopt_embedding_dim(conn: &Connection) {
44    if crate::constants::embedding_dim_from_runtime().is_some() {
45        return;
46    }
47    if let Ok(value) = conn.query_row(
48        "SELECT value FROM schema_meta WHERE key = 'dim'",
49        [],
50        |row| row.get::<_, String>(0),
51    ) {
52        if let Ok(dim) = value.parse::<usize>() {
53            crate::constants::set_active_embedding_dim(dim);
54        }
55    }
56}
57
58/// Ensure schema.
59pub fn ensure_schema(conn: &mut Connection) -> Result<(), AppError> {
60    crate::migrations::runner()
61        .run(conn)
62        .map_err(|e| AppError::Internal(anyhow::anyhow!("migration failed: {e}")))?;
63    conn.execute_batch(&format!(
64        "PRAGMA user_version = {};",
65        crate::constants::SCHEMA_USER_VERSION
66    ))?;
67    Ok(())
68}
69
70/// Ensures the database file exists and the schema is at the current version.
71///
72/// Behavior:
73/// - DB does not exist: creates the file, applies init PRAGMAs, runs all migrations,
74///   sets `PRAGMA user_version`, and populates `schema_meta` with default values.
75///   Emits `tracing::info!` on creation.
76/// - DB exists with `user_version` below `SCHEMA_USER_VERSION`: runs the remaining
77///   migrations and updates `user_version`. Emits `tracing::warn!` on auto-migration.
78/// - DB exists with `user_version` equal to `SCHEMA_USER_VERSION`: no-op.
79///
80/// This helper unifies the auto-init contract across CRUD handlers so users can run
81/// any subcommand on a fresh directory without invoking `init` first. Idempotent
82/// and safe to call before every handler that needs a ready database.
83pub fn ensure_db_ready(paths: &AppPaths) -> Result<(), AppError> {
84    register_vec_extension();
85    paths.ensure_dirs()?;
86
87    let db_existed = paths.db.exists();
88
89    if !db_existed {
90        tracing::info!(target: "storage",
91            path = %paths.db.display(),
92            schema_version = crate::constants::CURRENT_SCHEMA_VERSION,
93            "creating database (auto-init)"
94        );
95    }
96
97    let mut conn = open_rw(&paths.db)?;
98
99    if !db_existed {
100        apply_init_pragmas(&conn)?;
101    }
102
103    let current_user_version: i64 = conn
104        .query_row("PRAGMA user_version", [], |row| row.get(0))
105        .unwrap_or(0);
106    let target_user_version = crate::constants::SCHEMA_USER_VERSION;
107
108    if current_user_version < target_user_version {
109        if db_existed {
110            tracing::warn!(target: "storage",
111                from = current_user_version,
112                to = target_user_version,
113                path = %paths.db.display(),
114                "auto-migrating database schema"
115            );
116        }
117        crate::migrations::runner()
118            .run(&mut conn)
119            .map_err(|e| AppError::Internal(anyhow::anyhow!("auto-migration failed: {e}")))?;
120        conn.execute_batch(&format!("PRAGMA user_version = {target_user_version};"))?;
121
122        if !db_existed {
123            insert_default_schema_meta(&conn)?;
124        }
125
126        // Defensive re-assertion: refinery's migration runner may open internal
127        // handles that revert journal_mode to delete on some platforms. Re-apply
128        // WAL after migrations to guarantee the documented contract holds for
129        // every command that goes through the auto-init path.
130        ensure_wal_mode(&conn)?;
131    }
132
133    // G41 repair: if V013 is in history but embedding tables are missing,
134    // execute V013 SQL directly. Runs unconditionally because databases
135    // corrupted by G41 already have user_version=50 and skip the block above.
136    crate::commands::migrate::ensure_v013_tables_exist(&conn)?;
137
138    // G42/S1 (v1.0.79): synchronise the active embedding dimensionality
139    // with the database. Existing databases keep their recorded `dim`
140    // (e.g. 384 from pre-v1.0.79); an explicit env/flag override is
141    // persisted back so `health --json` reports the truth. This is an
142    // UPDATE of an existing `schema_meta` key — ZERO schema change.
143    sync_embedding_dim_meta(&conn)?;
144
145    Ok(())
146}
147
148/// G42/S1: two-way sync between `schema_meta.dim` and the process-wide
149/// active embedding dimensionality.
150///
151/// - CLI flag / XDG override set → persist it into `schema_meta.dim`;
152/// - no override → adopt the database value via
153///   [`crate::constants::set_active_embedding_dim`] so a 384-dim database
154///   keeps producing and querying 384-dim vectors even after the compiled
155///   default moved to 1024;
156/// - key missing (legacy/corrupt meta) → write the resolved default.
157fn sync_embedding_dim_meta(conn: &Connection) -> Result<(), AppError> {
158    let db_dim: Option<usize> = conn
159        .query_row(
160            "SELECT value FROM schema_meta WHERE key = 'dim'",
161            [],
162            |row| row.get::<_, String>(0),
163        )
164        .ok()
165        .and_then(|v| v.parse::<usize>().ok());
166
167    if let Some(override_dim) = crate::constants::embedding_dim_from_runtime() {
168        if db_dim != Some(override_dim) {
169            conn.execute(
170                "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
171                rusqlite::params![override_dim.to_string()],
172            )?;
173        }
174        return Ok(());
175    }
176
177    match db_dim {
178        Some(dim) => crate::constants::set_active_embedding_dim(dim),
179        None => {
180            conn.execute(
181                "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
182                rusqlite::params![crate::constants::embedding_dim().to_string()],
183            )?;
184        }
185    }
186    Ok(())
187}
188
189fn insert_default_schema_meta(conn: &Connection) -> Result<(), AppError> {
190    conn.execute(
191        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
192        rusqlite::params![crate::constants::CURRENT_SCHEMA_VERSION.to_string()],
193    )?;
194    conn.execute(
195        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('model', ?1)",
196        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
197    )?;
198    conn.execute(
199        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
200        rusqlite::params![crate::constants::embedding_dim().to_string()],
201    )?;
202    conn.execute(
203        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT))",
204        [],
205    )?;
206    conn.execute(
207        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('sqlite-graphrag_version', ?1)",
208        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
209    )?;
210    Ok(())
211}
212
213/// Applies 600 permissions (owner read/write only) to the SQLite file and its WAL/SHM
214/// companion files on Unix to prevent leaking private memories in shared directories
215/// (e.g. multi-user /tmp, Dropbox, NFS). On Windows, NTFS DACL default is private-to-user
216/// so explicit permission setting is unnecessary; a debug log records the skip. Failures
217/// are silent to avoid blocking the operation when the process does not own the file
218/// (e.g. read-only mount).
219#[allow(unused_variables)]
220fn apply_secure_permissions(path: &Path) {
221    #[cfg(unix)]
222    {
223        use std::os::unix::fs::PermissionsExt;
224        let candidates = [
225            path.to_path_buf(),
226            path.with_extension(format!(
227                "{}-wal",
228                path.extension()
229                    .and_then(|e| e.to_str())
230                    .unwrap_or("sqlite")
231            )),
232            path.with_extension(format!(
233                "{}-shm",
234                path.extension()
235                    .and_then(|e| e.to_str())
236                    .unwrap_or("sqlite")
237            )),
238        ];
239        for file in candidates.iter() {
240            if file.exists() {
241                if let Ok(meta) = std::fs::metadata(file) {
242                    let mut perms = meta.permissions();
243                    perms.set_mode(0o600);
244                    let _ = std::fs::set_permissions(file, perms);
245                }
246            }
247        }
248    }
249    #[cfg(windows)]
250    {
251        tracing::debug!(target: "storage",
252            path = %path.display(),
253            "skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
254        );
255    }
256}
257
258/// Open ro.
259pub fn open_ro(path: &Path) -> Result<Connection, AppError> {
260    let conn = Connection::open_with_flags(
261        path,
262        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
263    )?;
264    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
265    // G43: read-only commands (`recall`, `hybrid-search`) embed the QUERY
266    // text, so they must adopt the database dimensionality too.
267    adopt_embedding_dim(&conn);
268    Ok(conn)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// G43 regression: `open_rw` must adopt `schema_meta.dim` so EVERY
276    /// command (not only the `ensure_db_ready` auto-init path) produces
277    /// vectors of the database dimensionality. Pre-G43, `remember` /
278    /// `edit` / `recall` / `hybrid-search` used the compiled default
279    /// against pre-v1.0.79 384-dim databases, silently writing
280    /// mixed-dim embeddings that cosine-score 0.0 against each other.
281    #[test]
282    #[serial_test::serial(env)]
283    fn open_rw_adopts_schema_meta_dim() {
284        let dir = tempfile::tempdir().expect("tempdir");
285        let db = dir.path().join("g43.sqlite");
286        {
287            let conn = Connection::open(&db).expect("create seed db");
288            conn.execute_batch(
289                "CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
290                 INSERT INTO schema_meta VALUES ('dim', '128');",
291            )
292            .expect("seed schema_meta");
293        }
294        std::env::remove_var("SQLITE_GRAPHRAG_EMBEDDING_DIM");
295        let _conn = open_rw(&db).expect("open_rw");
296        let adopted = crate::constants::embedding_dim();
297        // Restore the process-wide default before asserting so a failure
298        // does not leak 128 into parallel tests.
299        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
300        assert_eq!(adopted, 128, "open_rw must adopt the recorded db dim (G43)");
301    }
302
303    /// G43 regression: `open_ro` (used by `recall` / `hybrid-search` to
304    /// embed the QUERY text) must adopt the database dim too.
305    #[test]
306    #[serial_test::serial(env)]
307    fn open_ro_adopts_schema_meta_dim() {
308        let dir = tempfile::tempdir().expect("tempdir");
309        let db = dir.path().join("g43-ro.sqlite");
310        {
311            let conn = Connection::open(&db).expect("create seed db");
312            conn.execute_batch(
313                "CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
314                 INSERT INTO schema_meta VALUES ('dim', '256');",
315            )
316            .expect("seed schema_meta");
317        }
318        std::env::remove_var("SQLITE_GRAPHRAG_EMBEDDING_DIM");
319        let _conn = open_ro(&db).expect("open_ro");
320        let adopted = crate::constants::embedding_dim();
321        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
322        assert_eq!(adopted, 256, "open_ro must adopt the recorded db dim (G43)");
323    }
324
325    /// G43: the env override always wins over the recorded database dim
326    /// (precedence contract of `constants::embedding_dim`).
327    #[test]
328    #[serial_test::serial(env)]
329    fn env_override_wins_over_schema_meta_dim() {
330        let dir = tempfile::tempdir().expect("tempdir");
331        let db = dir.path().join("g43-env.sqlite");
332        {
333            let conn = Connection::open(&db).expect("create seed db");
334            conn.execute_batch(
335                "CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
336                 INSERT INTO schema_meta VALUES ('dim', '128');",
337            )
338            .expect("seed schema_meta");
339        }
340        // G-T-XDG-04: product env is gone. Schema meta dim is adopted when no
341        // process-wide runtime override was installed at bootstrap.
342        let _conn = open_rw(&db).expect("open_rw");
343        let adopted = crate::constants::embedding_dim();
344        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
345        assert_eq!(
346            adopted, 128,
347            "schema_meta dim is adopted when no CLI/XDG override is active"
348        );
349    }
350
351    /// G43: a virgin database without `schema_meta` must open cleanly
352    /// (best-effort adoption is a no-op, never an error).
353    #[test]
354    #[serial_test::serial(env)]
355    fn open_rw_on_virgin_db_is_a_noop() {
356        let dir = tempfile::tempdir().expect("tempdir");
357        let db = dir.path().join("g43-virgin.sqlite");
358        std::env::remove_var("SQLITE_GRAPHRAG_EMBEDDING_DIM");
359        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
360        let _conn = open_rw(&db).expect("open_rw on virgin db must not fail");
361        assert_eq!(
362            crate::constants::embedding_dim(),
363            crate::constants::DEFAULT_EMBEDDING_DIM,
364            "virgin db must keep the compiled default (G43)"
365        );
366    }
367}