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    // Foreign keys must be off around the runner, not inside the migration
244    // files: see `storage::connection::run_migrations_with_foreign_keys_off`.
245    // Measured on a populated 213k-edge database, running the V017 rebuild
246    // through the bare runner emptied `relationships` via ON DELETE CASCADE.
247    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
273/// Compute the SipHasher13 checksum for a migration entry. Matches the
274/// algorithm used by refinery-core 0.9.1 (`name | version | sql`).
275///
276/// The `version` parameter MUST be `i32` (the default
277/// `SchemaVersion` alias in refinery-core) — passing `i64` would
278/// produce a different hash because the SipHasher13 implementation
279/// hashes the value's bit representation, and the two integer types
280/// differ in width. The `int8-versions` feature is NOT enabled.
281fn 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
289/// GAP-E2E-009: dry-run mode for the default migration runner.
290/// Computes the set of pending migrations and any checksum drift
291/// without applying any SQL or rewriting any rows. Returns a
292/// structured `DryRunReport` for the operator to inspect before
293/// running the actual migration.
294fn 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    // Build the set of applied migration versions from the history
299    // table. When the table does not exist, the set is empty and
300    // every embedded migration is "pending".
301    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    // Enumerate the embedded migrations and partition them into
314    // pending (not in history) and checksum-mismatched (in history
315    // but with stale checksum).
316    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: not yet applied.
326            pending.push(MigrationEntry {
327                version: version as i64,
328                name,
329                applied_on: None,
330                checksum: None,
331            });
332            continue;
333        }
334
335        // Already applied — verify the recorded checksum.
336        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        // Migrations absent from history are intentionally NOT inserted.
432        // They must be applied by runner().run() which executes their SQL.
433        // Inserting them marks them as "applied" without running the SQL,
434        // causing phantom registrations (G41).
435    }
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    // 1. Detect whether vec tables are still present in sqlite_master.
462    //    They were created by the v1.0.74 era V002 migration and dropped
463    //    by V013 in v1.0.76. Fresh v1.0.76 databases never had them.
464    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    // 1.5. Sanitize NULL applied_on values before any runner call.
477    let null_rows_fixed = sanitize_null_applied_on(conn)?;
478
479    // 1.6. G41 repair: ensure V013 tables exist if registered but missing.
480    let v013_tables_created = ensure_v013_tables_exist(conn)?;
481
482    // 1.75. Remove vec virtual tables via writable_schema if vec0 is absent.
483    let vec_tables_removed = if vec_tables_were_present {
484        remove_vec_virtual_tables_without_module(conn)?
485    } else {
486        0
487    };
488
489    // 2. Rehash checksums (in case V002 was the offender).
490    let rehash_report = run_rehash(conn, db_path)?;
491    let rehashed = rehash_report.rewritten;
492
493    // 3. Apply pending migrations (V013 will run if it hasn't yet).
494    //    If the user is on v1.0.75 the V013 migration was already applied,
495    //    so this is a no-op; if they're on v1.0.74 the V013 drop will run.
496    //    If vec tables were removed in step 1.75, V013 DROP is a no-op.
497    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    // 4. Detect V013 application by checking the schema_version.
511    //    V013 has version 13, so schema_version >= 13 implies it ran.
512    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
588/// Ensures the BLOB-backed embedding tables from V013 actually exist.
589/// Repairs databases where `run_rehash` registered V013 in the history
590/// without executing its SQL (G41 phantom registration bug).
591pub(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;