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