Skip to main content

sqlite_graphrag/commands/
migrate.rs

1//! Handler for the `migrate` CLI subcommand.
2
3use 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")]
29/// Migrate args.
30pub struct MigrateArgs {
31    /// Path to the SQLite database file.
32    #[arg(long)]
33    pub db: Option<String>,
34    /// Explicit JSON flag. Accepted as a no-op because output is already JSON by default.
35    #[arg(long, default_value_t = false)]
36    pub json: bool,
37    /// Show already applied migrations without applying new ones.
38    #[arg(long, default_value_t = false)]
39    pub status: bool,
40    /// Rewrite recorded migration checksums to match the current file content
41    /// without re-applying the SQL. Idempotent; safe to re-run.
42    #[arg(long, default_value_t = false)]
43    pub rehash: bool,
44    /// One-shot upgrade for v1.0.74 / v1.0.75 databases: rehash checksums,
45    /// apply the V013 vec-table-drop migration, and report a structured
46    /// summary. Combines `--rehash` and the regular migration runner.
47    #[arg(long, default_value_t = false)]
48    pub to_llm_only: bool,
49    /// Required for `--to-llm-only` to acknowledge that the operation is
50    /// destructive: it permanently removes the `vec_memories`,
51    /// `vec_entities`, and `vec_chunks` virtual tables. The BLOB-backed
52    /// `memory_embeddings` / `entity_embeddings` / `chunk_embeddings`
53    /// tables remain and are the source of truth going forward.
54    #[arg(long, default_value_t = false)]
55    pub drop_vec_tables: bool,
56    /// Preview pending migrations without applying SQL or rewriting
57    /// any rows. Reports the list of migrations that would be applied,
58    /// along with a checksum-validity check, and exits 0 without
59    /// mutating `refinery_schema_history` or any table. Compatible
60    /// with `--status` and `--rehash` for diagnostic-only flows.
61    #[arg(long, default_value_t = false)]
62    pub dry_run: bool,
63    /// Required acknowledgement for non-`--dry-run` invocations of
64    /// the default migration runner. When set, the command emits a
65    /// dry-run-style preview of pending migrations and waits for the
66    /// literal string `yes` on stdin before applying. Without
67    /// `--confirm` the command proceeds in the legacy automatic
68    /// apply mode (preserves backward compatibility for CI scripts
69    /// that already gate via `migrate --status` first).
70    #[arg(long, default_value_t = false)]
71    pub confirm: bool,
72}
73
74#[derive(Serialize)]
75struct MigrateResponse {
76    db_path: String,
77    /// Latest applied migration number from `refinery_schema_history`.
78    /// Emitted as JSON number for cross-command consistency with `health`/`stats`/`init` (since v1.0.35).
79    schema_version: u32,
80    status: String,
81    /// Total execution time in milliseconds from handler start to serialisation.
82    elapsed_ms: u64,
83}
84
85#[derive(Serialize)]
86struct MigrateStatusResponse {
87    db_path: String,
88    applied_migrations: Vec<MigrationEntry>,
89    /// Latest applied migration number. JSON number since v1.0.35.
90    schema_version: u32,
91    elapsed_ms: u64,
92}
93
94#[derive(Serialize)]
95struct DryRunReport {
96    db_path: String,
97    schema_version: u32,
98    /// Names and versions of migrations that would be applied.
99    /// Empty when the database is already at the latest schema.
100    pending_migrations: Vec<MigrationEntry>,
101    /// Number of pending migrations (len of `pending_migrations`).
102    pending_count: u32,
103    /// One row per migration whose recorded checksum mismatches the
104    /// file-derived checksum. Empty when everything is in sync.
105    checksum_mismatches: Vec<RehashEntry>,
106    /// "ok_no_pending" when no migrations would be applied,
107    /// "ok_pending" when there are pending migrations,
108    /// "ok_checksum_drift" when there are no pending migrations but
109    /// existing rows have stale checksums.
110    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    /// One row per migration whose recorded checksum was rewritten.
128    /// Empty array when nothing changed (already up to date).
129    rewritten: Vec<RehashEntry>,
130    /// Number of entries inspected.
131    inspected: usize,
132    /// Rows where `applied_on` was NULL and got backfilled with a timestamp.
133    null_rows_fixed: u64,
134    /// True if the BLOB-backed embedding tables were created by the G41 repair.
135    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    /// True if the vec0 virtual tables existed in the database before the
154    /// command ran. After this command they will be gone.
155    vec_tables_were_present: bool,
156    /// True if V013 was applied during this invocation.
157    v013_applied: bool,
158    /// Rows where `applied_on` was NULL and got backfilled with a timestamp.
159    null_rows_fixed: u64,
160    /// Number of vec0 virtual table entries removed from sqlite_master
161    /// via PRAGMA writable_schema (includes shadow tables).
162    vec_tables_removed_via_writable_schema: usize,
163    /// True if the BLOB-backed embedding tables were created by the G41 repair.
164    v013_tables_created: bool,
165    status: String,
166    elapsed_ms: u64,
167}
168
169/// Run.
170pub fn run(args: MigrateArgs) -> Result<(), AppError> {
171    let start = std::time::Instant::now();
172    let _ = args.json; // --json is a no-op because output is already JSON by default
173    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    // GAP-SG-140: mirror the auto-migration tolerance from
238    // `storage::connection::ensure_db_ready`. A legacy database carries a
239    // divergent checksum for `V002__vec_tables.sql`, whose tables V013 already
240    // dropped. Aborting here would make plain `migrate` fail on exactly the
241    // databases the auto-migration path can now upgrade. `--rehash` remains the
242    // way to normalize `refinery_schema_history` when the operator wants it.
243    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
269/// Compute the SipHasher13 checksum for a migration entry. Matches the
270/// algorithm used by refinery-core 0.9.1 (`name | version | sql`).
271///
272/// The `version` parameter MUST be `i32` (the default
273/// `SchemaVersion` alias in refinery-core) — passing `i64` would
274/// produce a different hash because the SipHasher13 implementation
275/// hashes the value's bit representation, and the two integer types
276/// differ in width. The `int8-versions` feature is NOT enabled.
277fn 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
285/// GAP-E2E-009: dry-run mode for the default migration runner.
286/// Computes the set of pending migrations and any checksum drift
287/// without applying any SQL or rewriting any rows. Returns a
288/// structured `DryRunReport` for the operator to inspect before
289/// running the actual migration.
290fn 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    // Build the set of applied migration versions from the history
295    // table. When the table does not exist, the set is empty and
296    // every embedded migration is "pending".
297    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    // Enumerate the embedded migrations and partition them into
310    // pending (not in history) and checksum-mismatched (in history
311    // but with stale checksum).
312    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: not yet applied.
322            pending.push(MigrationEntry {
323                version: version as i64,
324                name,
325                applied_on: None,
326                checksum: None,
327            });
328            continue;
329        }
330
331        // Already applied — verify the recorded checksum.
332        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        // Migrations absent from history are intentionally NOT inserted.
428        // They must be applied by runner().run() which executes their SQL.
429        // Inserting them marks them as "applied" without running the SQL,
430        // causing phantom registrations (G41).
431    }
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    // 1. Detect whether vec tables are still present in sqlite_master.
458    //    They were created by the v1.0.74 era V002 migration and dropped
459    //    by V013 in v1.0.76. Fresh v1.0.76 databases never had them.
460    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    // 1.5. Sanitize NULL applied_on values before any runner call.
473    let null_rows_fixed = sanitize_null_applied_on(conn)?;
474
475    // 1.6. G41 repair: ensure V013 tables exist if registered but missing.
476    let v013_tables_created = ensure_v013_tables_exist(conn)?;
477
478    // 1.75. Remove vec virtual tables via writable_schema if vec0 is absent.
479    let vec_tables_removed = if vec_tables_were_present {
480        remove_vec_virtual_tables_without_module(conn)?
481    } else {
482        0
483    };
484
485    // 2. Rehash checksums (in case V002 was the offender).
486    let rehash_report = run_rehash(conn, db_path)?;
487    let rehashed = rehash_report.rewritten;
488
489    // 3. Apply pending migrations (V013 will run if it hasn't yet).
490    //    If the user is on v1.0.75 the V013 migration was already applied,
491    //    so this is a no-op; if they're on v1.0.74 the V013 drop will run.
492    //    If vec tables were removed in step 1.75, V013 DROP is a no-op.
493    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    // 4. Detect V013 application by checking the schema_version.
509    //    V013 has version 13, so schema_version >= 13 implies it ran.
510    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
586/// Ensures the BLOB-backed embedding tables from V013 actually exist.
587/// Repairs databases where `run_rehash` registered V013 in the history
588/// without executing its SQL (G41 phantom registration bug).
589pub(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;