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 crate::storage::foreign_keys::{
12    assert_migration_orphaned_nothing, foreign_key_violation_counts,
13    warn_about_pre_existing_violations,
14};
15use rusqlite::Connection;
16use std::path::Path;
17
18/// v1.0.76: no-op stub. Kept for source compatibility with callers that
19/// still call `register_vec_extension()` during auto-init. The actual
20/// extension registration is gone; the function is now a marker that
21/// the LLM-only build does not need any vector extension.
22pub fn register_vec_extension() {}
23
24/// Open rw.
25pub fn open_rw(path: &Path) -> Result<Connection, AppError> {
26    let conn = Connection::open(path)?;
27    apply_connection_pragmas(&conn)?;
28    apply_secure_permissions(path);
29    adopt_embedding_dim(&conn);
30    Ok(conn)
31}
32
33/// G42/S1 follow-up (G43): adopts the dimensionality recorded in
34/// `schema_meta.dim` for this process, so EVERY command that opens the
35/// database — not only the `ensure_db_ready` auto-init path — produces
36/// and queries vectors of the database dimensionality. Pre-G43 the
37/// adoption only ran in `ensure_db_ready`, which `remember` / `edit` /
38/// `recall` / `hybrid-search` never call; those commands silently used
39/// the compiled default (64) against pre-v1.0.79 384-dim databases,
40/// writing mixed-dim embeddings that cosine-score 0.0 against each
41/// other.
42///
43/// Read-only and best-effort by design: a virgin database without
44/// `schema_meta` is a no-op (the table is created and persisted later
45/// by `ensure_schema` / `ensure_db_ready`). A CLI flag or XDG override
46/// always wins and is handled inside `constants::embedding_dim`.
47fn adopt_embedding_dim(conn: &Connection) {
48    if crate::constants::embedding_dim_from_runtime().is_some() {
49        return;
50    }
51    if let Ok(value) = conn.query_row(
52        "SELECT value FROM schema_meta WHERE key = 'dim'",
53        [],
54        |row| row.get::<_, String>(0),
55    ) {
56        if let Ok(dim) = value.parse::<usize>() {
57            crate::constants::set_active_embedding_dim(dim);
58        }
59    }
60}
61
62/// Runs the pending refinery migrations with foreign key enforcement disabled,
63/// then restores it and verifies that nothing was orphaned.
64///
65/// GAP-SG-277 follow-up: `PRAGMA foreign_keys` is a documented no-op while a
66/// transaction is pending, and refinery opens one transaction per migration
67/// (`refinery-core::drivers::rusqlite`). Every `PRAGMA foreign_keys = OFF`
68/// written at the top of a migration file — V006, V008, V009, V010, V013 — has
69/// therefore never taken effect: the connection arrives with enforcement ON
70/// from [`apply_connection_pragmas`] and keeps it for the whole run.
71///
72/// That matters because `DROP TABLE` under enforcement performs an implicit
73/// `DELETE FROM` before dropping, which fires the `ON DELETE CASCADE` of every
74/// child table. `entities` has four such children (`relationships`,
75/// `memory_entities`, `entity_embeddings`, `entity_connect_seen`), so the
76/// rebuild-and-rename pattern those migrations use would silently empty the
77/// whole graph on a populated database. Fresh databases never showed it
78/// because the cascade has nothing to delete when the tables are still empty.
79///
80/// Toggling the pragma here — outside refinery's transaction — is what the
81/// SQLite "making other kinds of table schema changes" procedure prescribes as
82/// its very first step, before the transaction is opened.
83///
84/// # Errors
85/// Returns `Err` when the pragma cannot be toggled, when a migration fails, or
86/// when the migration itself leaves rows orphaned that were not orphaned before.
87///
88/// Pre-existing violations do NOT fail the run. `PRAGMA foreign_key_check`
89/// inspects the whole database, not the slice a migration touched, so a single
90/// dangling row inherited from an older schema — written back when enforcement
91/// was effectively off — used to abort every migration on that file. Since
92/// `ensure_db_ready` migrates on open, and nearly every subcommand calls it,
93/// that turned one legacy row into a database no command could open: even
94/// `cleanup-orphans`, the repair path, failed before reaching its first delete.
95/// The assertion exists to prove that THIS migration broke nothing, and a row
96/// that was already broken proves nothing about it.
97pub(crate) fn run_migrations_with_foreign_keys_off(
98    conn: &mut Connection,
99    failure_label: &str,
100) -> Result<(), AppError> {
101    // Baseline first: what is already broken is not this migration's doing.
102    let before = foreign_key_violation_counts(conn)?;
103
104    conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
105
106    let migrated = crate::migrations::runner()
107        .set_abort_divergent(false)
108        .run(conn)
109        .map_err(|e| AppError::Internal(anyhow::anyhow!("{failure_label}: {e}")));
110
111    // Restore enforcement before propagating, so a failed migration never
112    // hands back a connection that silently accepts orphan rows.
113    let restored = conn.execute_batch("PRAGMA foreign_keys = ON;");
114
115    migrated?;
116    restored?;
117
118    let after = foreign_key_violation_counts(conn)?;
119    assert_migration_orphaned_nothing(&before, &after)?;
120    warn_about_pre_existing_violations(&after);
121    Ok(())
122}
123
124/// Copies the database aside before migrations touch an existing file.
125///
126/// There is no down migration in this project and refinery only moves forward,
127/// so a migration that goes wrong has exactly one remedy: an earlier copy of the
128/// file. Until v1.2.8 none was taken, while `ensure_db_ready` would happily
129/// auto-migrate from inside a plain `recall`.
130///
131/// Uses the SQLite Online Backup API rather than a filesystem copy, because the
132/// database runs in WAL mode: copying the `.sqlite` alone would silently omit
133/// whatever still lives in the `-wal` sidecar.
134///
135/// A failure here ABORTS the migration. Migrating without the one available
136/// remedy is the situation this function exists to prevent, so falling through
137/// on error would defeat it.
138///
139/// # Errors
140/// Returns `Err` when the destination cannot be created or the copy fails.
141fn back_up_before_migrating(
142    conn: &Connection,
143    db_path: &Path,
144    applied_schema_version: i64,
145) -> Result<(), AppError> {
146    let stamp = std::time::SystemTime::now()
147        .duration_since(std::time::UNIX_EPOCH)
148        .map(|d| d.as_secs())
149        .unwrap_or(0);
150    let mut destination = db_path.as_os_str().to_os_string();
151    destination.push(format!(".bak.pre-schema-{applied_schema_version}.{stamp}"));
152    let destination = std::path::PathBuf::from(destination);
153
154    /// Pages copied per `sqlite3_backup_step`, matching the default the
155    /// `backup` subcommand exposes as `--backup-step-size`.
156    const STEP_PAGES: std::os::raw::c_int = 1_000;
157
158    let mut target = Connection::open(&destination)?;
159    {
160        let backup = rusqlite::backup::Backup::new(conn, &mut target)?;
161        backup.run_to_completion(
162            STEP_PAGES,
163            std::time::Duration::from_millis(crate::constants::BACKUP_BUSY_RETRY_DELAY_MS),
164            None,
165        )?;
166    }
167    apply_secure_permissions(&destination);
168
169    tracing::warn!(target: "storage",
170        backup = %destination.display(),
171        "database copied aside before auto-migration"
172    );
173    Ok(())
174}
175
176/// Ensure schema.
177pub fn ensure_schema(conn: &mut Connection) -> Result<(), AppError> {
178    run_migrations_with_foreign_keys_off(conn, "migration failed")?;
179    conn.execute_batch(&format!(
180        "PRAGMA user_version = {};",
181        crate::constants::SCHEMA_USER_VERSION
182    ))?;
183    Ok(())
184}
185
186/// Ensures the database file exists and the schema is at the current version.
187///
188/// Behavior:
189/// - DB does not exist: creates the file, applies init PRAGMAs, runs all migrations,
190///   sets `PRAGMA user_version`, and populates `schema_meta` with default values.
191///   Emits `tracing::info!` on creation.
192/// - DB exists with `user_version` below `SCHEMA_USER_VERSION`: runs the remaining
193///   migrations and updates `user_version`. Emits `tracing::warn!` on auto-migration.
194/// - DB exists with `user_version` equal to `SCHEMA_USER_VERSION`: no-op.
195///
196/// This helper unifies the auto-init contract across CRUD handlers so users can run
197/// any subcommand on a fresh directory without invoking `init` first. Idempotent
198/// and safe to call before every handler that needs a ready database.
199pub fn ensure_db_ready(paths: &AppPaths) -> Result<(), AppError> {
200    register_vec_extension();
201    paths.ensure_dirs()?;
202
203    let db_existed = paths.db.exists();
204
205    if !db_existed {
206        tracing::info!(target: "storage",
207            path = %paths.db.display(),
208            schema_version = crate::constants::CURRENT_SCHEMA_VERSION,
209            "creating database (auto-init)"
210        );
211    }
212
213    let mut conn = open_rw(&paths.db)?;
214
215    if !db_existed {
216        apply_init_pragmas(&conn)?;
217    }
218
219    let current_user_version: i64 = conn
220        .query_row("PRAGMA user_version", [], |row| row.get(0))
221        .unwrap_or(0);
222    let target_user_version = crate::constants::SCHEMA_USER_VERSION;
223
224    // v1.2.8: `user_version` alone cannot gate this. It is an IDENTITY marker —
225    // the constant is 50 so external tools recognise a sqlite-graphrag file at a
226    // glance, and its own doc-comment states that bumping migrations does not
227    // change it. A value that never changes cannot signal "there is something
228    // new to apply": every database that reached 50 would stay there forever,
229    // and V017 would never reach an existing database. The binary would then
230    // accept `crate` (it no longer checks membership) while the un-migrated
231    // schema still carried V008's CHECK and refused the write, surfacing as a
232    // raw SQLite constraint error about a guard the caller cannot see.
233    //
234    // Ask the migration history instead, which is the thing that actually knows.
235    let applied_schema_version: i64 = conn
236        .query_row(
237            "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
238            [],
239            |row| row.get(0),
240        )
241        .unwrap_or(0);
242    let target_schema_version = i64::from(crate::constants::CURRENT_SCHEMA_VERSION);
243
244    let needs_migration = current_user_version < target_user_version
245        || applied_schema_version < target_schema_version;
246
247    if needs_migration {
248        if db_existed {
249            tracing::warn!(target: "storage",
250                from = current_user_version,
251                to = target_user_version,
252                schema_from = applied_schema_version,
253                schema_to = target_schema_version,
254                path = %paths.db.display(),
255                "auto-migrating database schema"
256            );
257            back_up_before_migrating(&conn, &paths.db, applied_schema_version)?;
258        }
259        // GAP-SG-140: `V002__vec_tables.sql` was edited after it had already been
260        // applied in the field, so every legacy database carries a divergent
261        // checksum for it. refinery aborts on divergence by default, which blocks
262        // ALL pending migrations (exit 20) on databases below schema 16. The
263        // divergence is inert: `V013__drop_vec_use_blob_embeddings.sql` already
264        // drops the tables V002 created, so the historical text no longer
265        // describes any live object. Tolerate divergence and keep migrating.
266        run_migrations_with_foreign_keys_off(&mut conn, "auto-migration failed")?;
267        conn.execute_batch(&format!("PRAGMA user_version = {target_user_version};"))?;
268
269        if !db_existed {
270            insert_default_schema_meta(&conn)?;
271        }
272
273        // Defensive re-assertion: refinery's migration runner may open internal
274        // handles that revert journal_mode to delete on some platforms. Re-apply
275        // WAL after migrations to guarantee the documented contract holds for
276        // every command that goes through the auto-init path.
277        ensure_wal_mode(&conn)?;
278    }
279
280    // G41 repair: if V013 is in history but embedding tables are missing,
281    // execute V013 SQL directly. Runs unconditionally because databases
282    // corrupted by G41 already have user_version=50 and skip the block above.
283    crate::commands::migrate::ensure_v013_tables_exist(&conn)?;
284
285    // G42/S1 (v1.0.79): synchronise the active embedding dimensionality
286    // with the database. Existing databases keep their recorded `dim`
287    // (e.g. 384 from pre-v1.0.79); an explicit env/flag override is
288    // persisted back so `health --json` reports the truth. This is an
289    // UPDATE of an existing `schema_meta` key — ZERO schema change.
290    sync_embedding_dim_meta(&conn)?;
291
292    Ok(())
293}
294
295/// G42/S1: two-way sync between `schema_meta.dim` and the process-wide
296/// active embedding dimensionality.
297///
298/// - CLI flag / XDG override set → persist it into `schema_meta.dim`;
299/// - no override → adopt the database value via
300///   [`crate::constants::set_active_embedding_dim`] so a 384-dim database
301///   keeps producing and querying 384-dim vectors even after the compiled
302///   default moved to 1024;
303/// - key missing (legacy/corrupt meta) → write the resolved default.
304fn sync_embedding_dim_meta(conn: &Connection) -> Result<(), AppError> {
305    let db_dim: Option<usize> = conn
306        .query_row(
307            "SELECT value FROM schema_meta WHERE key = 'dim'",
308            [],
309            |row| row.get::<_, String>(0),
310        )
311        .ok()
312        .and_then(|v| v.parse::<usize>().ok());
313
314    if let Some(override_dim) = crate::constants::embedding_dim_from_runtime() {
315        if db_dim != Some(override_dim) {
316            conn.execute(
317                "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
318                rusqlite::params![override_dim.to_string()],
319            )?;
320        }
321        return Ok(());
322    }
323
324    match db_dim {
325        Some(dim) => crate::constants::set_active_embedding_dim(dim),
326        None => {
327            conn.execute(
328                "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
329                rusqlite::params![crate::constants::embedding_dim().to_string()],
330            )?;
331        }
332    }
333    Ok(())
334}
335
336fn insert_default_schema_meta(conn: &Connection) -> Result<(), AppError> {
337    conn.execute(
338        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
339        rusqlite::params![crate::constants::CURRENT_SCHEMA_VERSION.to_string()],
340    )?;
341    conn.execute(
342        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('model', ?1)",
343        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
344    )?;
345    conn.execute(
346        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
347        rusqlite::params![crate::constants::embedding_dim().to_string()],
348    )?;
349    conn.execute(
350        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT))",
351        [],
352    )?;
353    conn.execute(
354        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('sqlite-graphrag_version', ?1)",
355        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
356    )?;
357    Ok(())
358}
359
360/// Applies 600 permissions (owner read/write only) to the SQLite file and its WAL/SHM
361/// companion files on Unix to prevent leaking private memories in shared directories
362/// (e.g. multi-user /tmp, Dropbox, NFS). On Windows, NTFS DACL default is private-to-user
363/// so explicit permission setting is unnecessary; a debug log records the skip. Failures
364/// are silent to avoid blocking the operation when the process does not own the file
365/// (e.g. read-only mount).
366#[allow(unused_variables)]
367fn apply_secure_permissions(path: &Path) {
368    #[cfg(unix)]
369    {
370        use std::os::unix::fs::PermissionsExt;
371        let candidates = [
372            path.to_path_buf(),
373            path.with_extension(format!(
374                "{}-wal",
375                path.extension()
376                    .and_then(|e| e.to_str())
377                    .unwrap_or("sqlite")
378            )),
379            path.with_extension(format!(
380                "{}-shm",
381                path.extension()
382                    .and_then(|e| e.to_str())
383                    .unwrap_or("sqlite")
384            )),
385        ];
386        for file in candidates.iter() {
387            if file.exists() {
388                if let Ok(meta) = std::fs::metadata(file) {
389                    let mut perms = meta.permissions();
390                    perms.set_mode(0o600);
391                    let _ = std::fs::set_permissions(file, perms);
392                }
393            }
394        }
395    }
396    #[cfg(windows)]
397    {
398        tracing::debug!(target: "storage",
399            path = %path.display(),
400            "skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
401        );
402    }
403}
404
405/// Open ro.
406pub fn open_ro(path: &Path) -> Result<Connection, AppError> {
407    let conn = Connection::open_with_flags(
408        path,
409        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
410    )?;
411    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
412    // G43: read-only commands (`recall`, `hybrid-search`) embed the QUERY
413    // text, so they must adopt the database dimensionality too.
414    adopt_embedding_dim(&conn);
415    Ok(conn)
416}
417
418#[cfg(test)]
419mod migration_cascade_tests {
420    use super::*;
421
422    /// The regression that motivated `run_migrations_with_foreign_keys_off`.
423    ///
424    /// Measured on 2026-08-18 against a copy of this workspace's database:
425    /// migrating 16 → 17 through a bare `runner().run(conn)` reported success,
426    /// moved the schema to 17, and left `relationships` at ZERO rows, down from
427    /// 213 029. `V017` rebuilds `entities`, and `DROP TABLE` under foreign key
428    /// enforcement performs an implicit `DELETE FROM` that fires the children's
429    /// `ON DELETE CASCADE`.
430    ///
431    /// Every pre-existing migration test bootstraps an EMPTY database, where a
432    /// cascade has nothing to delete — which is exactly why nine migrations
433    /// shipped this pattern unnoticed. This test therefore inserts rows FIRST
434    /// and asserts they are still there afterwards. Without the guard it fails;
435    /// with it, the edge survives.
436    #[test]
437    fn migrating_a_populated_database_preserves_the_edges() {
438        let tmp = tempfile::TempDir::new().expect("tempdir");
439        let db_path = tmp.path().join("populated.sqlite");
440
441        // Stop one migration short of V017, so the rebuild is still ahead.
442        let mut conn = open_rw(&db_path).expect("open");
443        crate::migrations::runner()
444            .set_abort_divergent(false)
445            .set_target(refinery::Target::Version(16))
446            .run(&mut conn)
447            .expect("migrate to 16");
448
449        conn.execute_batch(
450            "INSERT INTO entities (namespace, name, type) VALUES ('global', 'alpha', 'tool');
451             INSERT INTO entities (namespace, name, type) VALUES ('global', 'beta', 'tool');
452             INSERT INTO relationships (namespace, source_id, target_id, relation)
453               VALUES ('global', 1, 2, 'uses');",
454        )
455        .expect("seed rows");
456
457        let edges_before: i64 = conn
458            .query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))
459            .expect("count before");
460        assert_eq!(edges_before, 1, "fixture must actually have an edge");
461
462        // Enforcement is ON here, exactly as `open_rw` leaves it in production.
463        let enforced: i64 = conn
464            .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
465            .expect("read pragma");
466        assert_eq!(enforced, 1, "the guard is only meaningful with FK enforced");
467
468        run_migrations_with_foreign_keys_off(&mut conn, "test migration failed")
469            .expect("guarded migration must succeed");
470
471        let edges_after: i64 = conn
472            .query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))
473            .expect("count after");
474        assert_eq!(
475            edges_after, 1,
476            "V017 rebuilt `entities` and the cascade emptied `relationships`"
477        );
478
479        let entities_after: i64 = conn
480            .query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))
481            .expect("count entities");
482        assert_eq!(entities_after, 2, "entities must survive the rebuild");
483
484        // Enforcement restored, and no row left pointing at a missing parent.
485        let restored: i64 = conn
486            .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
487            .expect("read pragma");
488        assert_eq!(restored, 1, "enforcement must be back on afterwards");
489        assert!(foreign_key_violation_counts(&conn)
490            .expect("read violations")
491            .is_empty());
492    }
493
494    /// With the vocabulary open, the column must accept a label the old CHECK
495    /// would have refused. Pinned here because it is the schema half of the
496    /// change: `entity_type.rs` can stop folding, and the write still fails if
497    /// V017 never reached the database.
498    #[test]
499    fn the_migrated_column_accepts_a_non_canonical_label() {
500        let tmp = tempfile::TempDir::new().expect("tempdir");
501        let db_path = tmp.path().join("open-vocab.sqlite");
502        let mut conn = open_rw(&db_path).expect("open");
503        run_migrations_with_foreign_keys_off(&mut conn, "test migration failed").expect("migrate");
504
505        conn.execute(
506            "INSERT INTO entities (namespace, name, type) VALUES ('global', 'axum', ?1)",
507            rusqlite::params!["crate"],
508        )
509        .expect("a label outside the canonical thirteen must be storable");
510
511        let stored: String = conn
512            .query_row("SELECT type FROM entities WHERE name = 'axum'", [], |r| {
513                r.get(0)
514            })
515            .expect("read back");
516        assert_eq!(stored, "crate", "the label must survive verbatim");
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    /// G43 regression: `open_rw` must adopt `schema_meta.dim` so EVERY
525    /// command (not only the `ensure_db_ready` auto-init path) produces
526    /// vectors of the database dimensionality. Pre-G43, `remember` /
527    /// `edit` / `recall` / `hybrid-search` used the compiled default
528    /// against pre-v1.0.79 384-dim databases, silently writing
529    /// mixed-dim embeddings that cosine-score 0.0 against each other.
530    #[test]
531    #[serial_test::serial(env)]
532    fn open_rw_adopts_schema_meta_dim() {
533        let dir = tempfile::tempdir().expect("tempdir");
534        let db = dir.path().join("g43.sqlite");
535        {
536            let conn = Connection::open(&db).expect("create seed db");
537            conn.execute_batch(
538                "CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
539                 INSERT INTO schema_meta VALUES ('dim', '128');",
540            )
541            .expect("seed schema_meta");
542        }
543        // GAP-SG-232: nothing to clear, because the product reads no variable
544        // of its own. The dim comes from `--embedding-dim`, then the XDG key
545        // `embedding.dim`, then `schema_meta`, then the compiled default.
546        let _conn = open_rw(&db).expect("open_rw");
547        let adopted = crate::constants::embedding_dim();
548        // Restore the process-wide default before asserting so a failure
549        // does not leak 128 into parallel tests.
550        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
551        assert_eq!(adopted, 128, "open_rw must adopt the recorded db dim (G43)");
552    }
553
554    /// G43 regression: `open_ro` (used by `recall` / `hybrid-search` to
555    /// embed the QUERY text) must adopt the database dim too.
556    #[test]
557    #[serial_test::serial(env)]
558    fn open_ro_adopts_schema_meta_dim() {
559        let dir = tempfile::tempdir().expect("tempdir");
560        let db = dir.path().join("g43-ro.sqlite");
561        {
562            let conn = Connection::open(&db).expect("create seed db");
563            conn.execute_batch(
564                "CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
565                 INSERT INTO schema_meta VALUES ('dim', '256');",
566            )
567            .expect("seed schema_meta");
568        }
569        let _conn = open_ro(&db).expect("open_ro");
570        let adopted = crate::constants::embedding_dim();
571        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
572        assert_eq!(adopted, 256, "open_ro must adopt the recorded db dim (G43)");
573    }
574
575    /// G43: the env override always wins over the recorded database dim
576    /// (precedence contract of `constants::embedding_dim`).
577    #[test]
578    #[serial_test::serial(env)]
579    fn env_override_wins_over_schema_meta_dim() {
580        let dir = tempfile::tempdir().expect("tempdir");
581        let db = dir.path().join("g43-env.sqlite");
582        {
583            let conn = Connection::open(&db).expect("create seed db");
584            conn.execute_batch(
585                "CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
586                 INSERT INTO schema_meta VALUES ('dim', '128');",
587            )
588            .expect("seed schema_meta");
589        }
590        // G-T-XDG-04: product env is gone. Schema meta dim is adopted when no
591        // process-wide runtime override was installed at bootstrap.
592        let _conn = open_rw(&db).expect("open_rw");
593        let adopted = crate::constants::embedding_dim();
594        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
595        assert_eq!(
596            adopted, 128,
597            "schema_meta dim is adopted when no CLI/XDG override is active"
598        );
599    }
600
601    /// G43: a virgin database without `schema_meta` must open cleanly
602    /// (best-effort adoption is a no-op, never an error).
603    #[test]
604    #[serial_test::serial(env)]
605    fn open_rw_on_virgin_db_is_a_noop() {
606        let dir = tempfile::tempdir().expect("tempdir");
607        let db = dir.path().join("g43-virgin.sqlite");
608        crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
609        let _conn = open_rw(&db).expect("open_rw on virgin db must not fail");
610        assert_eq!(
611            crate::constants::embedding_dim(),
612            crate::constants::DEFAULT_EMBEDDING_DIM,
613            "virgin db must keep the compiled default (G43)"
614        );
615    }
616}