1use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_rw;
7use chrono::Utc;
8use rusqlite::OptionalExtension;
9use serde::Serialize;
10use siphasher::sip::SipHasher13;
11use std::hash::{Hash, Hasher};
12use std::path::Path;
13
14#[derive(clap::Args)]
15#[command(after_long_help = "EXAMPLES:\n \
16 # Apply pending schema migrations\n \
17 sqlite-graphrag migrate\n\n \
18 # Show already-applied migrations without applying new ones\n \
19 sqlite-graphrag migrate --status\n\n \
20 # Migrate a database at a custom path\n \
21 sqlite-graphrag migrate --db /path/to/graphrag.sqlite\n\n \
22 # Rewrite recorded migration checksums to match the current file content.\n \
23 # Use this after upgrading across a version that intentionally changed a\n \
24 # migration file (v1.0.76 is the first release where this is exposed).\n \
25 sqlite-graphrag migrate --rehash\n\n \
26 # Full upgrade: rehash, apply V013 (drop vec tables), verify schema.\n \
27 # Required once for users upgrading from v1.0.74 or v1.0.75.\n \
28 sqlite-graphrag migrate --to-llm-only")]
29pub struct MigrateArgs {
31 #[arg(long)]
33 pub db: Option<String>,
34 #[arg(long, default_value_t = false)]
36 pub json: bool,
37 #[arg(long, default_value_t = false)]
39 pub status: bool,
40 #[arg(long, default_value_t = false)]
43 pub rehash: bool,
44 #[arg(long, default_value_t = false)]
48 pub to_llm_only: bool,
49 #[arg(long, default_value_t = false)]
55 pub drop_vec_tables: bool,
56 #[arg(long, default_value_t = false)]
62 pub dry_run: bool,
63 #[arg(long, default_value_t = false)]
71 pub confirm: bool,
72}
73
74#[derive(Serialize)]
75struct MigrateResponse {
76 db_path: String,
77 schema_version: u32,
80 status: String,
81 elapsed_ms: u64,
83}
84
85#[derive(Serialize)]
86struct MigrateStatusResponse {
87 db_path: String,
88 applied_migrations: Vec<MigrationEntry>,
89 schema_version: u32,
91 elapsed_ms: u64,
92}
93
94#[derive(Serialize)]
95struct DryRunReport {
96 db_path: String,
97 schema_version: u32,
98 pending_migrations: Vec<MigrationEntry>,
101 pending_count: u32,
103 checksum_mismatches: Vec<RehashEntry>,
106 status: String,
111 elapsed_ms: u64,
112}
113
114#[derive(Serialize)]
115struct MigrationEntry {
116 version: i64,
117 name: String,
118 applied_on: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 checksum: Option<String>,
121}
122
123#[derive(Serialize)]
124struct RehashReport {
125 db_path: String,
126 schema_version: u32,
127 rewritten: Vec<RehashEntry>,
130 inspected: usize,
132 null_rows_fixed: u64,
134 v013_tables_created: bool,
136 status: String,
137 elapsed_ms: u64,
138}
139
140#[derive(Serialize, Debug)]
141struct RehashEntry {
142 version: i64,
143 name: String,
144 old_checksum: String,
145 new_checksum: String,
146}
147
148#[derive(Serialize)]
149struct ToLlmOnlyReport {
150 db_path: String,
151 schema_version: u32,
152 rehashed: Vec<RehashEntry>,
153 vec_tables_were_present: bool,
156 v013_applied: bool,
158 null_rows_fixed: u64,
160 vec_tables_removed_via_writable_schema: usize,
163 v013_tables_created: bool,
165 status: String,
166 elapsed_ms: u64,
167}
168
169pub fn run(args: MigrateArgs) -> Result<(), AppError> {
171 let start = std::time::Instant::now();
172 let _ = args.json; let paths = AppPaths::resolve(args.db.as_deref())?;
174 paths.ensure_dirs()?;
175
176 if args.status && (args.rehash || args.to_llm_only) {
177 return Err(AppError::Validation(
178 "--status cannot be combined with --rehash or --to-llm-only".into(),
179 ));
180 }
181 if args.rehash && args.to_llm_only {
182 return Err(AppError::Validation(
183 "--rehash and --to-llm-only are mutually exclusive".into(),
184 ));
185 }
186 if args.to_llm_only && !args.drop_vec_tables {
187 return Err(AppError::Validation(
188 "--to-llm-only requires --drop-vec-tables to acknowledge the destructive drop".into(),
189 ));
190 }
191 if args.dry_run && (args.rehash || args.to_llm_only) {
192 return Err(AppError::Validation(
193 "--dry-run cannot be combined with --rehash or --to-llm-only".into(),
194 ));
195 }
196 if args.confirm && args.dry_run {
197 return Err(AppError::Validation(
198 "--confirm cannot be combined with --dry-run".into(),
199 ));
200 }
201
202 let mut conn = open_rw(&paths.db)?;
203
204 if args.status {
205 let schema_version = latest_schema_version(&conn).unwrap_or(0);
206 let applied = list_applied_migrations(&conn)?;
207 output::emit_json(&MigrateStatusResponse {
208 db_path: paths.db.display().to_string(),
209 applied_migrations: applied,
210 schema_version,
211 elapsed_ms: start.elapsed().as_millis() as u64,
212 })?;
213 return Ok(());
214 }
215
216 if args.rehash {
217 let report = run_rehash(&mut conn, &paths.db)?;
218 output::emit_json(&report)?;
219 return Ok(());
220 }
221
222 if args.to_llm_only {
223 let report = run_to_llm_only(&mut conn, &paths.db)?;
224 output::emit_json(&report)?;
225 return Ok(());
226 }
227
228 if args.dry_run {
229 let report = run_dry_run(&conn, &paths.db)?;
230 output::emit_json(&report)?;
231 return Ok(());
232 }
233
234 sanitize_null_applied_on(&conn)?;
235 ensure_v013_tables_exist(&conn)?;
236
237 crate::storage::connection::run_migrations_with_foreign_keys_off(
248 &mut conn,
249 "migration failed",
250 )?;
251
252 conn.execute_batch(&format!(
253 "PRAGMA user_version = {};",
254 crate::constants::SCHEMA_USER_VERSION
255 ))?;
256
257 let schema_version = latest_schema_version(&conn)?;
258 conn.execute(
259 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
260 rusqlite::params![schema_version],
261 )?;
262
263 output::emit_json(&MigrateResponse {
264 db_path: paths.db.display().to_string(),
265 schema_version,
266 status: "ok".to_string(),
267 elapsed_ms: start.elapsed().as_millis() as u64,
268 })?;
269
270 Ok(())
271}
272
273fn compute_checksum(name: &str, version: i32, sql: &str) -> u64 {
282 let mut hasher = SipHasher13::new();
283 name.hash(&mut hasher);
284 version.hash(&mut hasher);
285 sql.hash(&mut hasher);
286 hasher.finish()
287}
288
289fn run_dry_run(conn: &rusqlite::Connection, db_path: &Path) -> Result<DryRunReport, AppError> {
295 let start = std::time::Instant::now();
296 let schema_version = latest_schema_version(conn).unwrap_or(0);
297
298 let applied_versions: std::collections::BTreeSet<i32> = if history_table_exists(conn) {
302 let mut stmt = conn
303 .prepare_cached("SELECT version FROM refinery_schema_history")
304 .map_err(AppError::Database)?;
305 let rows = stmt
306 .query_map([], |r| r.get::<_, i64>(0))
307 .map_err(AppError::Database)?;
308 rows.filter_map(|r| r.ok()).map(|v| v as i32).collect()
309 } else {
310 std::collections::BTreeSet::new()
311 };
312
313 let mut pending: Vec<MigrationEntry> = Vec::new();
317 let mut mismatches: Vec<RehashEntry> = Vec::new();
318
319 for mig in crate::migrations::runner().get_migrations().iter() {
320 let name = mig.name().to_string();
321 let version = mig.version();
322 let sql = mig.sql().unwrap_or("").to_string();
323
324 if !applied_versions.contains(&version) {
325 pending.push(MigrationEntry {
327 version: version as i64,
328 name,
329 applied_on: None,
330 checksum: None,
331 });
332 continue;
333 }
334
335 let new_checksum = compute_checksum(&name, version, &sql).to_string();
337 if let Ok(existing) = conn.query_row(
338 "SELECT checksum FROM refinery_schema_history WHERE version = ?1",
339 rusqlite::params![version],
340 |r| r.get::<_, String>(0),
341 ) {
342 let existing_trim = existing.trim();
343 if existing_trim != new_checksum {
344 mismatches.push(RehashEntry {
345 version: version as i64,
346 name,
347 old_checksum: existing_trim.to_string(),
348 new_checksum,
349 });
350 }
351 }
352 }
353
354 let pending_count = pending.len() as u32;
355 let status = if !mismatches.is_empty() && pending.is_empty() {
356 "ok_checksum_drift"
357 } else if pending.is_empty() {
358 "ok_no_pending"
359 } else {
360 "ok_pending"
361 };
362
363 Ok(DryRunReport {
364 db_path: db_path.display().to_string(),
365 schema_version,
366 pending_migrations: pending,
367 pending_count,
368 checksum_mismatches: mismatches,
369 status: status.to_string(),
370 elapsed_ms: start.elapsed().as_millis() as u64,
371 })
372}
373
374fn run_rehash(conn: &mut rusqlite::Connection, db_path: &Path) -> Result<RehashReport, AppError> {
375 let start = std::time::Instant::now();
376 let schema_version = latest_schema_version(conn).unwrap_or(0);
377
378 if !history_table_exists(conn) {
379 return Ok(RehashReport {
380 db_path: db_path.display().to_string(),
381 schema_version,
382 rewritten: vec![],
383 inspected: 0,
384 null_rows_fixed: 0,
385 v013_tables_created: false,
386 status: "ok_no_history".to_string(),
387 elapsed_ms: start.elapsed().as_millis() as u64,
388 });
389 }
390
391 let null_rows_fixed = sanitize_null_applied_on(conn)?;
392 let v013_tables_created = ensure_v013_tables_exist(conn)?;
393
394 let mut rewritten: Vec<RehashEntry> = Vec::new();
395 let mut inspected = 0usize;
396
397 for mig in crate::migrations::runner().get_migrations().iter() {
398 if mig.sql().is_none() {
399 continue;
400 }
401 let name = mig.name().to_string();
402 let version = mig.version();
403 let sql = mig.sql().unwrap_or("").to_string();
404 let new_checksum = compute_checksum(&name, version, &sql);
405
406 let row: Option<String> = conn
407 .query_row(
408 "SELECT checksum FROM refinery_schema_history WHERE version = ?1",
409 rusqlite::params![version],
410 |r| r.get(0),
411 )
412 .optional()?;
413
414 inspected += 1;
415 if let Some(existing) = row {
416 let existing_trim = existing.trim();
417 let new_str = new_checksum.to_string();
418 if existing_trim != new_str {
419 conn.execute(
420 "UPDATE refinery_schema_history SET checksum = ?1 WHERE version = ?2",
421 rusqlite::params![new_str, version],
422 )?;
423 rewritten.push(RehashEntry {
424 version: version as i64,
425 name,
426 old_checksum: existing_trim.to_string(),
427 new_checksum: new_str,
428 });
429 }
430 }
431 }
436
437 let status = if rewritten.is_empty() {
438 "ok_no_changes"
439 } else {
440 "ok_rewritten"
441 };
442
443 Ok(RehashReport {
444 db_path: db_path.display().to_string(),
445 schema_version,
446 rewritten,
447 inspected,
448 null_rows_fixed,
449 v013_tables_created,
450 status: status.to_string(),
451 elapsed_ms: start.elapsed().as_millis() as u64,
452 })
453}
454
455fn run_to_llm_only(
456 conn: &mut rusqlite::Connection,
457 db_path: &Path,
458) -> Result<ToLlmOnlyReport, AppError> {
459 let start = std::time::Instant::now();
460
461 let vec_tables_were_present: bool = {
465 let count: i64 = conn
466 .query_row(
467 "SELECT COUNT(*) FROM sqlite_master
468 WHERE type='table' AND name IN ('vec_memories','vec_entities','vec_chunks')",
469 [],
470 |r| r.get(0),
471 )
472 .unwrap_or(0);
473 count > 0
474 };
475
476 let null_rows_fixed = sanitize_null_applied_on(conn)?;
478
479 let v013_tables_created = ensure_v013_tables_exist(conn)?;
481
482 let vec_tables_removed = if vec_tables_were_present {
484 remove_vec_virtual_tables_without_module(conn)?
485 } else {
486 0
487 };
488
489 let rehash_report = run_rehash(conn, db_path)?;
491 let rehashed = rehash_report.rewritten;
492
493 crate::storage::connection::run_migrations_with_foreign_keys_off(conn, "migration failed")?;
498
499 conn.execute_batch(&format!(
500 "PRAGMA user_version = {};",
501 crate::constants::SCHEMA_USER_VERSION
502 ))?;
503
504 let schema_version = latest_schema_version(conn)?;
505 conn.execute(
506 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
507 rusqlite::params![schema_version],
508 )?;
509
510 let v013_applied = schema_version >= 13;
513
514 Ok(ToLlmOnlyReport {
515 db_path: db_path.display().to_string(),
516 schema_version,
517 rehashed,
518 vec_tables_were_present,
519 v013_applied,
520 null_rows_fixed,
521 vec_tables_removed_via_writable_schema: vec_tables_removed,
522 v013_tables_created,
523 status: "ok".to_string(),
524 elapsed_ms: start.elapsed().as_millis() as u64,
525 })
526}
527
528fn history_table_exists(conn: &rusqlite::Connection) -> bool {
529 conn.query_row(
530 "SELECT name FROM sqlite_master WHERE type='table' AND name='refinery_schema_history'",
531 [],
532 |r| r.get::<_, String>(0),
533 )
534 .optional()
535 .ok()
536 .flatten()
537 .is_some()
538}
539
540fn sanitize_null_applied_on(conn: &rusqlite::Connection) -> Result<u64, AppError> {
541 if !history_table_exists(conn) {
542 return Ok(0);
543 }
544 let now = Utc::now().to_rfc3339();
545 let fixed = conn.execute(
546 "UPDATE refinery_schema_history SET applied_on = ?1 WHERE applied_on IS NULL",
547 rusqlite::params![now],
548 )?;
549 Ok(fixed as u64)
550}
551
552fn remove_vec_virtual_tables_without_module(
553 conn: &rusqlite::Connection,
554) -> Result<usize, AppError> {
555 let count: i64 = conn
556 .query_row(
557 "SELECT COUNT(*) FROM sqlite_master
558 WHERE type='table' AND name IN ('vec_memories','vec_entities','vec_chunks')",
559 [],
560 |r| r.get(0),
561 )
562 .unwrap_or(0);
563 if count == 0 {
564 return Ok(0);
565 }
566
567 let drop_works = conn
568 .execute_batch("DROP TABLE IF EXISTS vec_memories;")
569 .is_ok();
570 if drop_works {
571 let _ = conn.execute_batch("DROP TABLE IF EXISTS vec_entities;");
572 let _ = conn.execute_batch("DROP TABLE IF EXISTS vec_chunks;");
573 return Ok(count as usize);
574 }
575
576 conn.execute_batch("PRAGMA writable_schema = ON;")?;
577 let removed = conn.execute(
578 "DELETE FROM sqlite_master WHERE type='table'
579 AND (name LIKE 'vec_memories%' OR name LIKE 'vec_entities%' OR name LIKE 'vec_chunks%')",
580 [],
581 )?;
582 conn.execute_batch("PRAGMA writable_schema = OFF;")?;
583 conn.execute_batch("VACUUM;")?;
584
585 Ok(removed)
586}
587
588pub(crate) fn ensure_v013_tables_exist(conn: &rusqlite::Connection) -> Result<bool, AppError> {
592 let exists: bool = conn
593 .query_row(
594 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_embeddings'",
595 [],
596 |r| r.get::<_, i64>(0),
597 )
598 .unwrap_or(0)
599 > 0;
600 if exists {
601 return Ok(false);
602 }
603
604 if !history_table_exists(conn) {
605 return Ok(false);
606 }
607 let v013_in_history: bool = conn
608 .query_row(
609 "SELECT COUNT(*) FROM refinery_schema_history WHERE version = 13",
610 [],
611 |r| r.get::<_, i64>(0),
612 )
613 .unwrap_or(0)
614 > 0;
615 if !v013_in_history {
616 return Ok(false);
617 }
618
619 let v013_sql = crate::migrations::runner()
620 .get_migrations()
621 .iter()
622 .find(|m| m.version() == 13)
623 .and_then(|m| m.sql().map(|s| s.to_string()));
624
625 if let Some(sql) = v013_sql {
626 conn.execute_batch(&sql)?;
627 tracing::warn!(
628 "G41 repair: V013 was registered but tables missing. \
629 Executed V013 SQL to create embedding tables."
630 );
631 Ok(true)
632 } else {
633 Err(AppError::Internal(anyhow::anyhow!(
634 "V013 migration SQL not found in embedded migrations"
635 )))
636 }
637}
638
639fn list_applied_migrations(conn: &rusqlite::Connection) -> Result<Vec<MigrationEntry>, AppError> {
640 let table_exists: Option<String> = conn
641 .query_row(
642 "SELECT name FROM sqlite_master WHERE type='table' AND name='refinery_schema_history'",
643 [],
644 |r| r.get(0),
645 )
646 .optional()?;
647 if table_exists.is_none() {
648 return Ok(vec![]);
649 }
650 let mut stmt = conn.prepare_cached(
651 "SELECT version, name, applied_on, checksum FROM refinery_schema_history ORDER BY version ASC",
652 )?;
653 let entries = stmt
654 .query_map([], |r| {
655 let checksum: Option<String> = r.get(3)?;
656 Ok(MigrationEntry {
657 version: r.get(0)?,
658 name: r.get(1)?,
659 applied_on: r.get(2)?,
660 checksum: checksum
661 .map(|s| s.trim().to_string())
662 .filter(|s| !s.is_empty()),
663 })
664 })?
665 .collect::<Result<Vec<_>, _>>()?;
666 Ok(entries)
667}
668
669fn latest_schema_version(conn: &rusqlite::Connection) -> Result<u32, AppError> {
670 match conn.query_row(
671 "SELECT version FROM refinery_schema_history ORDER BY version DESC LIMIT 1",
672 [],
673 |row| row.get::<_, i64>(0),
674 ) {
675 Ok(version) => Ok(version.max(0) as u32),
676 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0),
677 Err(err) => Err(AppError::Database(err)),
678 }
679}
680#[cfg(test)]
681#[path = "migrate_tests.rs"]
682mod tests;