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}