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