use crate::errors::AppError;
use crate::output;
use crate::paths::AppPaths;
use crate::storage::connection::open_rw;
use chrono::Utc;
use rusqlite::OptionalExtension;
use serde::Serialize;
use siphasher::sip::SipHasher13;
use std::hash::{Hash, Hasher};
use std::path::Path;
#[derive(clap::Args)]
#[command(after_long_help = "EXAMPLES:\n \
# Apply pending schema migrations\n \
sqlite-graphrag migrate\n\n \
# Show already-applied migrations without applying new ones\n \
sqlite-graphrag migrate --status\n\n \
# Migrate a database at a custom path\n \
sqlite-graphrag migrate --db /path/to/graphrag.sqlite\n\n \
# Rewrite recorded migration checksums to match the current file content.\n \
# Use this after upgrading across a version that intentionally changed a\n \
# migration file (v1.0.76 is the first release where this is exposed).\n \
sqlite-graphrag migrate --rehash\n\n \
# Full upgrade: rehash, apply V013 (drop vec tables), verify schema.\n \
# Required once for users upgrading from v1.0.74 or v1.0.75.\n \
sqlite-graphrag migrate --to-llm-only")]
pub struct MigrateArgs {
#[arg(long)]
pub db: Option<String>,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub status: bool,
#[arg(long, default_value_t = false)]
pub rehash: bool,
#[arg(long, default_value_t = false)]
pub to_llm_only: bool,
#[arg(long, default_value_t = false)]
pub drop_vec_tables: bool,
#[arg(long, default_value_t = false)]
pub dry_run: bool,
#[arg(long, default_value_t = false)]
pub confirm: bool,
}
#[derive(Serialize)]
struct MigrateResponse {
db_path: String,
schema_version: u32,
status: String,
elapsed_ms: u64,
}
#[derive(Serialize)]
struct MigrateStatusResponse {
db_path: String,
applied_migrations: Vec<MigrationEntry>,
schema_version: u32,
elapsed_ms: u64,
}
#[derive(Serialize)]
struct DryRunReport {
db_path: String,
schema_version: u32,
pending_migrations: Vec<MigrationEntry>,
pending_count: u32,
checksum_mismatches: Vec<RehashEntry>,
status: String,
elapsed_ms: u64,
}
#[derive(Serialize)]
struct MigrationEntry {
version: i64,
name: String,
applied_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
checksum: Option<String>,
}
#[derive(Serialize)]
struct RehashReport {
db_path: String,
schema_version: u32,
rewritten: Vec<RehashEntry>,
inspected: usize,
null_rows_fixed: u64,
v013_tables_created: bool,
status: String,
elapsed_ms: u64,
}
#[derive(Serialize, Debug)]
struct RehashEntry {
version: i64,
name: String,
old_checksum: String,
new_checksum: String,
}
#[derive(Serialize)]
struct ToLlmOnlyReport {
db_path: String,
schema_version: u32,
rehashed: Vec<RehashEntry>,
vec_tables_were_present: bool,
v013_applied: bool,
null_rows_fixed: u64,
vec_tables_removed_via_writable_schema: usize,
v013_tables_created: bool,
status: String,
elapsed_ms: u64,
}
pub fn run(args: MigrateArgs) -> Result<(), AppError> {
let start = std::time::Instant::now();
let _ = args.json; let paths = AppPaths::resolve(args.db.as_deref())?;
paths.ensure_dirs()?;
if args.status && (args.rehash || args.to_llm_only) {
return Err(AppError::Validation(
"--status cannot be combined with --rehash or --to-llm-only".into(),
));
}
if args.rehash && args.to_llm_only {
return Err(AppError::Validation(
"--rehash and --to-llm-only are mutually exclusive".into(),
));
}
if args.to_llm_only && !args.drop_vec_tables {
return Err(AppError::Validation(
"--to-llm-only requires --drop-vec-tables to acknowledge the destructive drop".into(),
));
}
if args.dry_run && (args.rehash || args.to_llm_only) {
return Err(AppError::Validation(
"--dry-run cannot be combined with --rehash or --to-llm-only".into(),
));
}
if args.confirm && args.dry_run {
return Err(AppError::Validation(
"--confirm cannot be combined with --dry-run".into(),
));
}
let mut conn = open_rw(&paths.db)?;
if args.status {
let schema_version = latest_schema_version(&conn).unwrap_or(0);
let applied = list_applied_migrations(&conn)?;
output::emit_json(&MigrateStatusResponse {
db_path: paths.db.display().to_string(),
applied_migrations: applied,
schema_version,
elapsed_ms: start.elapsed().as_millis() as u64,
})?;
return Ok(());
}
if args.rehash {
let report = run_rehash(&mut conn, &paths.db)?;
output::emit_json(&report)?;
return Ok(());
}
if args.to_llm_only {
let report = run_to_llm_only(&mut conn, &paths.db)?;
output::emit_json(&report)?;
return Ok(());
}
if args.dry_run {
let report = run_dry_run(&conn, &paths.db)?;
output::emit_json(&report)?;
return Ok(());
}
sanitize_null_applied_on(&conn)?;
ensure_v013_tables_exist(&conn)?;
crate::migrations::runner()
.set_abort_divergent(false)
.run(&mut conn)
.map_err(|e| AppError::Internal(anyhow::anyhow!("migration failed: {e}")))?;
conn.execute_batch(&format!(
"PRAGMA user_version = {};",
crate::constants::SCHEMA_USER_VERSION
))?;
let schema_version = latest_schema_version(&conn)?;
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
rusqlite::params![schema_version],
)?;
output::emit_json(&MigrateResponse {
db_path: paths.db.display().to_string(),
schema_version,
status: "ok".to_string(),
elapsed_ms: start.elapsed().as_millis() as u64,
})?;
Ok(())
}
fn compute_checksum(name: &str, version: i32, sql: &str) -> u64 {
let mut hasher = SipHasher13::new();
name.hash(&mut hasher);
version.hash(&mut hasher);
sql.hash(&mut hasher);
hasher.finish()
}
fn run_dry_run(conn: &rusqlite::Connection, db_path: &Path) -> Result<DryRunReport, AppError> {
let start = std::time::Instant::now();
let schema_version = latest_schema_version(conn).unwrap_or(0);
let applied_versions: std::collections::BTreeSet<i32> = if history_table_exists(conn) {
let mut stmt = conn
.prepare_cached("SELECT version FROM refinery_schema_history")
.map_err(AppError::Database)?;
let rows = stmt
.query_map([], |r| r.get::<_, i64>(0))
.map_err(AppError::Database)?;
rows.filter_map(|r| r.ok()).map(|v| v as i32).collect()
} else {
std::collections::BTreeSet::new()
};
let mut pending: Vec<MigrationEntry> = Vec::new();
let mut mismatches: Vec<RehashEntry> = Vec::new();
for mig in crate::migrations::runner().get_migrations().iter() {
let name = mig.name().to_string();
let version = mig.version();
let sql = mig.sql().unwrap_or("").to_string();
if !applied_versions.contains(&version) {
pending.push(MigrationEntry {
version: version as i64,
name,
applied_on: None,
checksum: None,
});
continue;
}
let new_checksum = compute_checksum(&name, version, &sql).to_string();
if let Ok(existing) = conn.query_row(
"SELECT checksum FROM refinery_schema_history WHERE version = ?1",
rusqlite::params![version],
|r| r.get::<_, String>(0),
) {
let existing_trim = existing.trim();
if existing_trim != new_checksum {
mismatches.push(RehashEntry {
version: version as i64,
name,
old_checksum: existing_trim.to_string(),
new_checksum,
});
}
}
}
let pending_count = pending.len() as u32;
let status = if !mismatches.is_empty() && pending.is_empty() {
"ok_checksum_drift"
} else if pending.is_empty() {
"ok_no_pending"
} else {
"ok_pending"
};
Ok(DryRunReport {
db_path: db_path.display().to_string(),
schema_version,
pending_migrations: pending,
pending_count,
checksum_mismatches: mismatches,
status: status.to_string(),
elapsed_ms: start.elapsed().as_millis() as u64,
})
}
fn run_rehash(conn: &mut rusqlite::Connection, db_path: &Path) -> Result<RehashReport, AppError> {
let start = std::time::Instant::now();
let schema_version = latest_schema_version(conn).unwrap_or(0);
if !history_table_exists(conn) {
return Ok(RehashReport {
db_path: db_path.display().to_string(),
schema_version,
rewritten: vec![],
inspected: 0,
null_rows_fixed: 0,
v013_tables_created: false,
status: "ok_no_history".to_string(),
elapsed_ms: start.elapsed().as_millis() as u64,
});
}
let null_rows_fixed = sanitize_null_applied_on(conn)?;
let v013_tables_created = ensure_v013_tables_exist(conn)?;
let mut rewritten: Vec<RehashEntry> = Vec::new();
let mut inspected = 0usize;
for mig in crate::migrations::runner().get_migrations().iter() {
if mig.sql().is_none() {
continue;
}
let name = mig.name().to_string();
let version = mig.version();
let sql = mig.sql().unwrap_or("").to_string();
let new_checksum = compute_checksum(&name, version, &sql);
let row: Option<String> = conn
.query_row(
"SELECT checksum FROM refinery_schema_history WHERE version = ?1",
rusqlite::params![version],
|r| r.get(0),
)
.optional()?;
inspected += 1;
if let Some(existing) = row {
let existing_trim = existing.trim();
let new_str = new_checksum.to_string();
if existing_trim != new_str {
conn.execute(
"UPDATE refinery_schema_history SET checksum = ?1 WHERE version = ?2",
rusqlite::params![new_str, version],
)?;
rewritten.push(RehashEntry {
version: version as i64,
name,
old_checksum: existing_trim.to_string(),
new_checksum: new_str,
});
}
}
}
let status = if rewritten.is_empty() {
"ok_no_changes"
} else {
"ok_rewritten"
};
Ok(RehashReport {
db_path: db_path.display().to_string(),
schema_version,
rewritten,
inspected,
null_rows_fixed,
v013_tables_created,
status: status.to_string(),
elapsed_ms: start.elapsed().as_millis() as u64,
})
}
fn run_to_llm_only(
conn: &mut rusqlite::Connection,
db_path: &Path,
) -> Result<ToLlmOnlyReport, AppError> {
let start = std::time::Instant::now();
let vec_tables_were_present: bool = {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name IN ('vec_memories','vec_entities','vec_chunks')",
[],
|r| r.get(0),
)
.unwrap_or(0);
count > 0
};
let null_rows_fixed = sanitize_null_applied_on(conn)?;
let v013_tables_created = ensure_v013_tables_exist(conn)?;
let vec_tables_removed = if vec_tables_were_present {
remove_vec_virtual_tables_without_module(conn)?
} else {
0
};
let rehash_report = run_rehash(conn, db_path)?;
let rehashed = rehash_report.rewritten;
crate::migrations::runner()
.run(conn)
.map_err(|e| AppError::Internal(anyhow::anyhow!("migration failed: {e}")))?;
conn.execute_batch(&format!(
"PRAGMA user_version = {};",
crate::constants::SCHEMA_USER_VERSION
))?;
let schema_version = latest_schema_version(conn)?;
conn.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
rusqlite::params![schema_version],
)?;
let v013_applied = schema_version >= 13;
Ok(ToLlmOnlyReport {
db_path: db_path.display().to_string(),
schema_version,
rehashed,
vec_tables_were_present,
v013_applied,
null_rows_fixed,
vec_tables_removed_via_writable_schema: vec_tables_removed,
v013_tables_created,
status: "ok".to_string(),
elapsed_ms: start.elapsed().as_millis() as u64,
})
}
fn history_table_exists(conn: &rusqlite::Connection) -> bool {
conn.query_row(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refinery_schema_history'",
[],
|r| r.get::<_, String>(0),
)
.optional()
.ok()
.flatten()
.is_some()
}
fn sanitize_null_applied_on(conn: &rusqlite::Connection) -> Result<u64, AppError> {
if !history_table_exists(conn) {
return Ok(0);
}
let now = Utc::now().to_rfc3339();
let fixed = conn.execute(
"UPDATE refinery_schema_history SET applied_on = ?1 WHERE applied_on IS NULL",
rusqlite::params![now],
)?;
Ok(fixed as u64)
}
fn remove_vec_virtual_tables_without_module(
conn: &rusqlite::Connection,
) -> Result<usize, AppError> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name IN ('vec_memories','vec_entities','vec_chunks')",
[],
|r| r.get(0),
)
.unwrap_or(0);
if count == 0 {
return Ok(0);
}
let drop_works = conn
.execute_batch("DROP TABLE IF EXISTS vec_memories;")
.is_ok();
if drop_works {
let _ = conn.execute_batch("DROP TABLE IF EXISTS vec_entities;");
let _ = conn.execute_batch("DROP TABLE IF EXISTS vec_chunks;");
return Ok(count as usize);
}
conn.execute_batch("PRAGMA writable_schema = ON;")?;
let removed = conn.execute(
"DELETE FROM sqlite_master WHERE type='table'
AND (name LIKE 'vec_memories%' OR name LIKE 'vec_entities%' OR name LIKE 'vec_chunks%')",
[],
)?;
conn.execute_batch("PRAGMA writable_schema = OFF;")?;
conn.execute_batch("VACUUM;")?;
Ok(removed)
}
pub(crate) fn ensure_v013_tables_exist(conn: &rusqlite::Connection) -> Result<bool, AppError> {
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_embeddings'",
[],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if exists {
return Ok(false);
}
if !history_table_exists(conn) {
return Ok(false);
}
let v013_in_history: bool = conn
.query_row(
"SELECT COUNT(*) FROM refinery_schema_history WHERE version = 13",
[],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if !v013_in_history {
return Ok(false);
}
let v013_sql = crate::migrations::runner()
.get_migrations()
.iter()
.find(|m| m.version() == 13)
.and_then(|m| m.sql().map(|s| s.to_string()));
if let Some(sql) = v013_sql {
conn.execute_batch(&sql)?;
tracing::warn!(
"G41 repair: V013 was registered but tables missing. \
Executed V013 SQL to create embedding tables."
);
Ok(true)
} else {
Err(AppError::Internal(anyhow::anyhow!(
"V013 migration SQL not found in embedded migrations"
)))
}
}
fn list_applied_migrations(conn: &rusqlite::Connection) -> Result<Vec<MigrationEntry>, AppError> {
let table_exists: Option<String> = conn
.query_row(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refinery_schema_history'",
[],
|r| r.get(0),
)
.optional()?;
if table_exists.is_none() {
return Ok(vec![]);
}
let mut stmt = conn.prepare_cached(
"SELECT version, name, applied_on, checksum FROM refinery_schema_history ORDER BY version ASC",
)?;
let entries = stmt
.query_map([], |r| {
let checksum: Option<String> = r.get(3)?;
Ok(MigrationEntry {
version: r.get(0)?,
name: r.get(1)?,
applied_on: r.get(2)?,
checksum: checksum
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(entries)
}
fn latest_schema_version(conn: &rusqlite::Connection) -> Result<u32, AppError> {
match conn.query_row(
"SELECT version FROM refinery_schema_history ORDER BY version DESC LIMIT 1",
[],
|row| row.get::<_, i64>(0),
) {
Ok(version) => Ok(version.max(0) as u32),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0),
Err(err) => Err(AppError::Database(err)),
}
}
#[cfg(test)]
#[path = "migrate_tests.rs"]
mod tests;