use crate::errors::AppError;
use crate::paths::AppPaths;
use crate::pragmas::{apply_connection_pragmas, apply_init_pragmas, ensure_wal_mode};
use crate::storage::foreign_keys::{
assert_migration_orphaned_nothing, foreign_key_violation_counts,
warn_about_pre_existing_violations,
};
use rusqlite::Connection;
use std::path::Path;
pub fn register_vec_extension() {}
pub fn open_rw(path: &Path) -> Result<Connection, AppError> {
let conn = Connection::open(path)?;
apply_connection_pragmas(&conn)?;
apply_secure_permissions(path);
adopt_embedding_dim(&conn);
Ok(conn)
}
fn adopt_embedding_dim(conn: &Connection) {
if crate::constants::embedding_dim_from_runtime().is_some() {
return;
}
if let Ok(value) = conn.query_row(
"SELECT value FROM schema_meta WHERE key = 'dim'",
[],
|row| row.get::<_, String>(0),
) {
if let Ok(dim) = value.parse::<usize>() {
crate::constants::set_active_embedding_dim(dim);
}
}
}
pub(crate) fn run_migrations_with_foreign_keys_off(
conn: &mut Connection,
failure_label: &str,
) -> Result<(), AppError> {
let before = foreign_key_violation_counts(conn)?;
conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
let migrated = crate::migrations::runner()
.set_abort_divergent(false)
.run(conn)
.map_err(|e| AppError::Internal(anyhow::anyhow!("{failure_label}: {e}")));
let restored = conn.execute_batch("PRAGMA foreign_keys = ON;");
migrated?;
restored?;
let after = foreign_key_violation_counts(conn)?;
assert_migration_orphaned_nothing(&before, &after)?;
warn_about_pre_existing_violations(&after);
Ok(())
}
fn back_up_before_migrating(
conn: &Connection,
db_path: &Path,
applied_schema_version: i64,
) -> Result<(), AppError> {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut destination = db_path.as_os_str().to_os_string();
destination.push(format!(".bak.pre-schema-{applied_schema_version}.{stamp}"));
let destination = std::path::PathBuf::from(destination);
const STEP_PAGES: std::os::raw::c_int = 1_000;
let mut target = Connection::open(&destination)?;
{
let backup = rusqlite::backup::Backup::new(conn, &mut target)?;
backup.run_to_completion(
STEP_PAGES,
std::time::Duration::from_millis(crate::constants::BACKUP_BUSY_RETRY_DELAY_MS),
None,
)?;
}
apply_secure_permissions(&destination);
tracing::warn!(target: "storage",
backup = %destination.display(),
"database copied aside before auto-migration"
);
Ok(())
}
pub fn ensure_schema(conn: &mut Connection) -> Result<(), AppError> {
run_migrations_with_foreign_keys_off(conn, "migration failed")?;
conn.execute_batch(&format!(
"PRAGMA user_version = {};",
crate::constants::SCHEMA_USER_VERSION
))?;
Ok(())
}
pub fn ensure_db_ready(paths: &AppPaths) -> Result<(), AppError> {
register_vec_extension();
paths.ensure_dirs()?;
let db_existed = paths.db.exists();
if !db_existed {
tracing::info!(target: "storage",
path = %paths.db.display(),
schema_version = crate::constants::CURRENT_SCHEMA_VERSION,
"creating database (auto-init)"
);
}
let mut conn = open_rw(&paths.db)?;
if !db_existed {
apply_init_pragmas(&conn)?;
}
let current_user_version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap_or(0);
let target_user_version = crate::constants::SCHEMA_USER_VERSION;
let applied_schema_version: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
[],
|row| row.get(0),
)
.unwrap_or(0);
let target_schema_version = i64::from(crate::constants::CURRENT_SCHEMA_VERSION);
let needs_migration = current_user_version < target_user_version
|| applied_schema_version < target_schema_version;
if needs_migration {
if db_existed {
tracing::warn!(target: "storage",
from = current_user_version,
to = target_user_version,
schema_from = applied_schema_version,
schema_to = target_schema_version,
path = %paths.db.display(),
"auto-migrating database schema"
);
back_up_before_migrating(&conn, &paths.db, applied_schema_version)?;
}
run_migrations_with_foreign_keys_off(&mut conn, "auto-migration failed")?;
conn.execute_batch(&format!("PRAGMA user_version = {target_user_version};"))?;
if !db_existed {
insert_default_schema_meta(&conn)?;
}
ensure_wal_mode(&conn)?;
}
crate::commands::migrate::ensure_v013_tables_exist(&conn)?;
sync_embedding_dim_meta(&conn)?;
Ok(())
}
fn sync_embedding_dim_meta(conn: &Connection) -> Result<(), AppError> {
let db_dim: Option<usize> = conn
.query_row(
"SELECT value FROM schema_meta WHERE key = 'dim'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|v| v.parse::<usize>().ok());
if let Some(override_dim) = crate::constants::embedding_dim_from_runtime() {
if db_dim != Some(override_dim) {
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
rusqlite::params![override_dim.to_string()],
)?;
}
return Ok(());
}
match db_dim {
Some(dim) => crate::constants::set_active_embedding_dim(dim),
None => {
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
rusqlite::params![crate::constants::embedding_dim().to_string()],
)?;
}
}
Ok(())
}
fn insert_default_schema_meta(conn: &Connection) -> Result<(), AppError> {
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
rusqlite::params![crate::constants::CURRENT_SCHEMA_VERSION.to_string()],
)?;
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('model', ?1)",
rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
)?;
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('dim', ?1)",
rusqlite::params![crate::constants::embedding_dim().to_string()],
)?;
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT))",
[],
)?;
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('sqlite-graphrag_version', ?1)",
rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
)?;
Ok(())
}
#[allow(unused_variables)]
fn apply_secure_permissions(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let candidates = [
path.to_path_buf(),
path.with_extension(format!(
"{}-wal",
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("sqlite")
)),
path.with_extension(format!(
"{}-shm",
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("sqlite")
)),
];
for file in candidates.iter() {
if file.exists() {
if let Ok(meta) = std::fs::metadata(file) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = std::fs::set_permissions(file, perms);
}
}
}
}
#[cfg(windows)]
{
tracing::debug!(target: "storage",
path = %path.display(),
"skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
);
}
}
pub fn open_ro(path: &Path) -> Result<Connection, AppError> {
let conn = Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)?;
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
adopt_embedding_dim(&conn);
Ok(conn)
}
#[cfg(test)]
mod migration_cascade_tests {
use super::*;
#[test]
fn migrating_a_populated_database_preserves_the_edges() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let db_path = tmp.path().join("populated.sqlite");
let mut conn = open_rw(&db_path).expect("open");
crate::migrations::runner()
.set_abort_divergent(false)
.set_target(refinery::Target::Version(16))
.run(&mut conn)
.expect("migrate to 16");
conn.execute_batch(
"INSERT INTO entities (namespace, name, type) VALUES ('global', 'alpha', 'tool');
INSERT INTO entities (namespace, name, type) VALUES ('global', 'beta', 'tool');
INSERT INTO relationships (namespace, source_id, target_id, relation)
VALUES ('global', 1, 2, 'uses');",
)
.expect("seed rows");
let edges_before: i64 = conn
.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))
.expect("count before");
assert_eq!(edges_before, 1, "fixture must actually have an edge");
let enforced: i64 = conn
.query_row("PRAGMA foreign_keys", [], |r| r.get(0))
.expect("read pragma");
assert_eq!(enforced, 1, "the guard is only meaningful with FK enforced");
run_migrations_with_foreign_keys_off(&mut conn, "test migration failed")
.expect("guarded migration must succeed");
let edges_after: i64 = conn
.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))
.expect("count after");
assert_eq!(
edges_after, 1,
"V017 rebuilt `entities` and the cascade emptied `relationships`"
);
let entities_after: i64 = conn
.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))
.expect("count entities");
assert_eq!(entities_after, 2, "entities must survive the rebuild");
let restored: i64 = conn
.query_row("PRAGMA foreign_keys", [], |r| r.get(0))
.expect("read pragma");
assert_eq!(restored, 1, "enforcement must be back on afterwards");
assert!(foreign_key_violation_counts(&conn)
.expect("read violations")
.is_empty());
}
#[test]
fn the_migrated_column_accepts_a_non_canonical_label() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let db_path = tmp.path().join("open-vocab.sqlite");
let mut conn = open_rw(&db_path).expect("open");
run_migrations_with_foreign_keys_off(&mut conn, "test migration failed").expect("migrate");
conn.execute(
"INSERT INTO entities (namespace, name, type) VALUES ('global', 'axum', ?1)",
rusqlite::params!["crate"],
)
.expect("a label outside the canonical thirteen must be storable");
let stored: String = conn
.query_row("SELECT type FROM entities WHERE name = 'axum'", [], |r| {
r.get(0)
})
.expect("read back");
assert_eq!(stored, "crate", "the label must survive verbatim");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[serial_test::serial(env)]
fn open_rw_adopts_schema_meta_dim() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("g43.sqlite");
{
let conn = Connection::open(&db).expect("create seed db");
conn.execute_batch(
"CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO schema_meta VALUES ('dim', '128');",
)
.expect("seed schema_meta");
}
let _conn = open_rw(&db).expect("open_rw");
let adopted = crate::constants::embedding_dim();
crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
assert_eq!(adopted, 128, "open_rw must adopt the recorded db dim (G43)");
}
#[test]
#[serial_test::serial(env)]
fn open_ro_adopts_schema_meta_dim() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("g43-ro.sqlite");
{
let conn = Connection::open(&db).expect("create seed db");
conn.execute_batch(
"CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO schema_meta VALUES ('dim', '256');",
)
.expect("seed schema_meta");
}
let _conn = open_ro(&db).expect("open_ro");
let adopted = crate::constants::embedding_dim();
crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
assert_eq!(adopted, 256, "open_ro must adopt the recorded db dim (G43)");
}
#[test]
#[serial_test::serial(env)]
fn env_override_wins_over_schema_meta_dim() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("g43-env.sqlite");
{
let conn = Connection::open(&db).expect("create seed db");
conn.execute_batch(
"CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO schema_meta VALUES ('dim', '128');",
)
.expect("seed schema_meta");
}
let _conn = open_rw(&db).expect("open_rw");
let adopted = crate::constants::embedding_dim();
crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
assert_eq!(
adopted, 128,
"schema_meta dim is adopted when no CLI/XDG override is active"
);
}
#[test]
#[serial_test::serial(env)]
fn open_rw_on_virgin_db_is_a_noop() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("g43-virgin.sqlite");
crate::constants::set_active_embedding_dim(crate::constants::DEFAULT_EMBEDDING_DIM);
let _conn = open_rw(&db).expect("open_rw on virgin db must not fail");
assert_eq!(
crate::constants::embedding_dim(),
crate::constants::DEFAULT_EMBEDDING_DIM,
"virgin db must keep the compiled default (G43)"
);
}
}