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::migrations::runner()
244 .set_abort_divergent(false)
245 .run(&mut conn)
246 .map_err(|e| AppError::Internal(anyhow::anyhow!("migration failed: {e}")))?;
247
248 conn.execute_batch(&format!(
249 "PRAGMA user_version = {};",
250 crate::constants::SCHEMA_USER_VERSION
251 ))?;
252
253 let schema_version = latest_schema_version(&conn)?;
254 conn.execute(
255 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
256 rusqlite::params![schema_version],
257 )?;
258
259 output::emit_json(&MigrateResponse {
260 db_path: paths.db.display().to_string(),
261 schema_version,
262 status: "ok".to_string(),
263 elapsed_ms: start.elapsed().as_millis() as u64,
264 })?;
265
266 Ok(())
267}
268
269fn compute_checksum(name: &str, version: i32, sql: &str) -> u64 {
278 let mut hasher = SipHasher13::new();
279 name.hash(&mut hasher);
280 version.hash(&mut hasher);
281 sql.hash(&mut hasher);
282 hasher.finish()
283}
284
285fn run_dry_run(conn: &rusqlite::Connection, db_path: &Path) -> Result<DryRunReport, AppError> {
291 let start = std::time::Instant::now();
292 let schema_version = latest_schema_version(conn).unwrap_or(0);
293
294 let applied_versions: std::collections::BTreeSet<i32> = if history_table_exists(conn) {
298 let mut stmt = conn
299 .prepare_cached("SELECT version FROM refinery_schema_history")
300 .map_err(AppError::Database)?;
301 let rows = stmt
302 .query_map([], |r| r.get::<_, i64>(0))
303 .map_err(AppError::Database)?;
304 rows.filter_map(|r| r.ok()).map(|v| v as i32).collect()
305 } else {
306 std::collections::BTreeSet::new()
307 };
308
309 let mut pending: Vec<MigrationEntry> = Vec::new();
313 let mut mismatches: Vec<RehashEntry> = Vec::new();
314
315 for mig in crate::migrations::runner().get_migrations().iter() {
316 let name = mig.name().to_string();
317 let version = mig.version();
318 let sql = mig.sql().unwrap_or("").to_string();
319
320 if !applied_versions.contains(&version) {
321 pending.push(MigrationEntry {
323 version: version as i64,
324 name,
325 applied_on: None,
326 checksum: None,
327 });
328 continue;
329 }
330
331 let new_checksum = compute_checksum(&name, version, &sql).to_string();
333 if let Ok(existing) = conn.query_row(
334 "SELECT checksum FROM refinery_schema_history WHERE version = ?1",
335 rusqlite::params![version],
336 |r| r.get::<_, String>(0),
337 ) {
338 let existing_trim = existing.trim();
339 if existing_trim != new_checksum {
340 mismatches.push(RehashEntry {
341 version: version as i64,
342 name,
343 old_checksum: existing_trim.to_string(),
344 new_checksum,
345 });
346 }
347 }
348 }
349
350 let pending_count = pending.len() as u32;
351 let status = if !mismatches.is_empty() && pending.is_empty() {
352 "ok_checksum_drift"
353 } else if pending.is_empty() {
354 "ok_no_pending"
355 } else {
356 "ok_pending"
357 };
358
359 Ok(DryRunReport {
360 db_path: db_path.display().to_string(),
361 schema_version,
362 pending_migrations: pending,
363 pending_count,
364 checksum_mismatches: mismatches,
365 status: status.to_string(),
366 elapsed_ms: start.elapsed().as_millis() as u64,
367 })
368}
369
370fn run_rehash(conn: &mut rusqlite::Connection, db_path: &Path) -> Result<RehashReport, AppError> {
371 let start = std::time::Instant::now();
372 let schema_version = latest_schema_version(conn).unwrap_or(0);
373
374 if !history_table_exists(conn) {
375 return Ok(RehashReport {
376 db_path: db_path.display().to_string(),
377 schema_version,
378 rewritten: vec![],
379 inspected: 0,
380 null_rows_fixed: 0,
381 v013_tables_created: false,
382 status: "ok_no_history".to_string(),
383 elapsed_ms: start.elapsed().as_millis() as u64,
384 });
385 }
386
387 let null_rows_fixed = sanitize_null_applied_on(conn)?;
388 let v013_tables_created = ensure_v013_tables_exist(conn)?;
389
390 let mut rewritten: Vec<RehashEntry> = Vec::new();
391 let mut inspected = 0usize;
392
393 for mig in crate::migrations::runner().get_migrations().iter() {
394 if mig.sql().is_none() {
395 continue;
396 }
397 let name = mig.name().to_string();
398 let version = mig.version();
399 let sql = mig.sql().unwrap_or("").to_string();
400 let new_checksum = compute_checksum(&name, version, &sql);
401
402 let row: Option<String> = conn
403 .query_row(
404 "SELECT checksum FROM refinery_schema_history WHERE version = ?1",
405 rusqlite::params![version],
406 |r| r.get(0),
407 )
408 .optional()?;
409
410 inspected += 1;
411 if let Some(existing) = row {
412 let existing_trim = existing.trim();
413 let new_str = new_checksum.to_string();
414 if existing_trim != new_str {
415 conn.execute(
416 "UPDATE refinery_schema_history SET checksum = ?1 WHERE version = ?2",
417 rusqlite::params![new_str, version],
418 )?;
419 rewritten.push(RehashEntry {
420 version: version as i64,
421 name,
422 old_checksum: existing_trim.to_string(),
423 new_checksum: new_str,
424 });
425 }
426 }
427 }
432
433 let status = if rewritten.is_empty() {
434 "ok_no_changes"
435 } else {
436 "ok_rewritten"
437 };
438
439 Ok(RehashReport {
440 db_path: db_path.display().to_string(),
441 schema_version,
442 rewritten,
443 inspected,
444 null_rows_fixed,
445 v013_tables_created,
446 status: status.to_string(),
447 elapsed_ms: start.elapsed().as_millis() as u64,
448 })
449}
450
451fn run_to_llm_only(
452 conn: &mut rusqlite::Connection,
453 db_path: &Path,
454) -> Result<ToLlmOnlyReport, AppError> {
455 let start = std::time::Instant::now();
456
457 let vec_tables_were_present: bool = {
461 let count: i64 = conn
462 .query_row(
463 "SELECT COUNT(*) FROM sqlite_master
464 WHERE type='table' AND name IN ('vec_memories','vec_entities','vec_chunks')",
465 [],
466 |r| r.get(0),
467 )
468 .unwrap_or(0);
469 count > 0
470 };
471
472 let null_rows_fixed = sanitize_null_applied_on(conn)?;
474
475 let v013_tables_created = ensure_v013_tables_exist(conn)?;
477
478 let vec_tables_removed = if vec_tables_were_present {
480 remove_vec_virtual_tables_without_module(conn)?
481 } else {
482 0
483 };
484
485 let rehash_report = run_rehash(conn, db_path)?;
487 let rehashed = rehash_report.rewritten;
488
489 crate::migrations::runner()
494 .run(conn)
495 .map_err(|e| AppError::Internal(anyhow::anyhow!("migration failed: {e}")))?;
496
497 conn.execute_batch(&format!(
498 "PRAGMA user_version = {};",
499 crate::constants::SCHEMA_USER_VERSION
500 ))?;
501
502 let schema_version = latest_schema_version(conn)?;
503 conn.execute(
504 "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
505 rusqlite::params![schema_version],
506 )?;
507
508 let v013_applied = schema_version >= 13;
511
512 Ok(ToLlmOnlyReport {
513 db_path: db_path.display().to_string(),
514 schema_version,
515 rehashed,
516 vec_tables_were_present,
517 v013_applied,
518 null_rows_fixed,
519 vec_tables_removed_via_writable_schema: vec_tables_removed,
520 v013_tables_created,
521 status: "ok".to_string(),
522 elapsed_ms: start.elapsed().as_millis() as u64,
523 })
524}
525
526fn history_table_exists(conn: &rusqlite::Connection) -> bool {
527 conn.query_row(
528 "SELECT name FROM sqlite_master WHERE type='table' AND name='refinery_schema_history'",
529 [],
530 |r| r.get::<_, String>(0),
531 )
532 .optional()
533 .ok()
534 .flatten()
535 .is_some()
536}
537
538fn sanitize_null_applied_on(conn: &rusqlite::Connection) -> Result<u64, AppError> {
539 if !history_table_exists(conn) {
540 return Ok(0);
541 }
542 let now = Utc::now().to_rfc3339();
543 let fixed = conn.execute(
544 "UPDATE refinery_schema_history SET applied_on = ?1 WHERE applied_on IS NULL",
545 rusqlite::params![now],
546 )?;
547 Ok(fixed as u64)
548}
549
550fn remove_vec_virtual_tables_without_module(
551 conn: &rusqlite::Connection,
552) -> Result<usize, AppError> {
553 let count: i64 = conn
554 .query_row(
555 "SELECT COUNT(*) FROM sqlite_master
556 WHERE type='table' AND name IN ('vec_memories','vec_entities','vec_chunks')",
557 [],
558 |r| r.get(0),
559 )
560 .unwrap_or(0);
561 if count == 0 {
562 return Ok(0);
563 }
564
565 let drop_works = conn
566 .execute_batch("DROP TABLE IF EXISTS vec_memories;")
567 .is_ok();
568 if drop_works {
569 let _ = conn.execute_batch("DROP TABLE IF EXISTS vec_entities;");
570 let _ = conn.execute_batch("DROP TABLE IF EXISTS vec_chunks;");
571 return Ok(count as usize);
572 }
573
574 conn.execute_batch("PRAGMA writable_schema = ON;")?;
575 let removed = conn.execute(
576 "DELETE FROM sqlite_master WHERE type='table'
577 AND (name LIKE 'vec_memories%' OR name LIKE 'vec_entities%' OR name LIKE 'vec_chunks%')",
578 [],
579 )?;
580 conn.execute_batch("PRAGMA writable_schema = OFF;")?;
581 conn.execute_batch("VACUUM;")?;
582
583 Ok(removed)
584}
585
586pub(crate) fn ensure_v013_tables_exist(conn: &rusqlite::Connection) -> Result<bool, AppError> {
590 let exists: bool = conn
591 .query_row(
592 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_embeddings'",
593 [],
594 |r| r.get::<_, i64>(0),
595 )
596 .unwrap_or(0)
597 > 0;
598 if exists {
599 return Ok(false);
600 }
601
602 if !history_table_exists(conn) {
603 return Ok(false);
604 }
605 let v013_in_history: bool = conn
606 .query_row(
607 "SELECT COUNT(*) FROM refinery_schema_history WHERE version = 13",
608 [],
609 |r| r.get::<_, i64>(0),
610 )
611 .unwrap_or(0)
612 > 0;
613 if !v013_in_history {
614 return Ok(false);
615 }
616
617 let v013_sql = crate::migrations::runner()
618 .get_migrations()
619 .iter()
620 .find(|m| m.version() == 13)
621 .and_then(|m| m.sql().map(|s| s.to_string()));
622
623 if let Some(sql) = v013_sql {
624 conn.execute_batch(&sql)?;
625 tracing::warn!(
626 "G41 repair: V013 was registered but tables missing. \
627 Executed V013 SQL to create embedding tables."
628 );
629 Ok(true)
630 } else {
631 Err(AppError::Internal(anyhow::anyhow!(
632 "V013 migration SQL not found in embedded migrations"
633 )))
634 }
635}
636
637fn list_applied_migrations(conn: &rusqlite::Connection) -> Result<Vec<MigrationEntry>, AppError> {
638 let table_exists: Option<String> = conn
639 .query_row(
640 "SELECT name FROM sqlite_master WHERE type='table' AND name='refinery_schema_history'",
641 [],
642 |r| r.get(0),
643 )
644 .optional()?;
645 if table_exists.is_none() {
646 return Ok(vec![]);
647 }
648 let mut stmt = conn.prepare_cached(
649 "SELECT version, name, applied_on, checksum FROM refinery_schema_history ORDER BY version ASC",
650 )?;
651 let entries = stmt
652 .query_map([], |r| {
653 let checksum: Option<String> = r.get(3)?;
654 Ok(MigrationEntry {
655 version: r.get(0)?,
656 name: r.get(1)?,
657 applied_on: r.get(2)?,
658 checksum: checksum
659 .map(|s| s.trim().to_string())
660 .filter(|s| !s.is_empty()),
661 })
662 })?
663 .collect::<Result<Vec<_>, _>>()?;
664 Ok(entries)
665}
666
667fn latest_schema_version(conn: &rusqlite::Connection) -> Result<u32, AppError> {
668 match conn.query_row(
669 "SELECT version FROM refinery_schema_history ORDER BY version DESC LIMIT 1",
670 [],
671 |row| row.get::<_, i64>(0),
672 ) {
673 Ok(version) => Ok(version.max(0) as u32),
674 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0),
675 Err(err) => Err(AppError::Database(err)),
676 }
677}
678#[cfg(test)]
679#[path = "migrate_tests.rs"]
680mod tests;