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    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
262/// Compute the SipHasher13 checksum for a migration entry. Matches the
263/// algorithm used by refinery-core 0.9.1 (`name | version | sql`).
264///
265/// The `version` parameter MUST be `i32` (the default
266/// `SchemaVersion` alias in refinery-core) — passing `i64` would
267/// produce a different hash because the SipHasher13 implementation
268/// hashes the value's bit representation, and the two integer types
269/// differ in width. The `int8-versions` feature is NOT enabled.
270fn 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
278/// GAP-E2E-009: dry-run mode for the default migration runner.
279/// Computes the set of pending migrations and any checksum drift
280/// without applying any SQL or rewriting any rows. Returns a
281/// structured `DryRunReport` for the operator to inspect before
282/// running the actual migration.
283fn 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    // Build the set of applied migration versions from the history
288    // table. When the table does not exist, the set is empty and
289    // every embedded migration is "pending".
290    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    // Enumerate the embedded migrations and partition them into
303    // pending (not in history) and checksum-mismatched (in history
304    // but with stale checksum).
305    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: not yet applied.
315            pending.push(MigrationEntry {
316                version: version as i64,
317                name,
318                applied_on: None,
319                checksum: None,
320            });
321            continue;
322        }
323
324        // Already applied — verify the recorded checksum.
325        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        // Migrations absent from history are intentionally NOT inserted.
421        // They must be applied by runner().run() which executes their SQL.
422        // Inserting them marks them as "applied" without running the SQL,
423        // causing phantom registrations (G41).
424    }
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    // 1. Detect whether vec tables are still present in sqlite_master.
451    //    They were created by the v1.0.74 era V002 migration and dropped
452    //    by V013 in v1.0.76. Fresh v1.0.76 databases never had them.
453    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    // 1.5. Sanitize NULL applied_on values before any runner call.
466    let null_rows_fixed = sanitize_null_applied_on(conn)?;
467
468    // 1.6. G41 repair: ensure V013 tables exist if registered but missing.
469    let v013_tables_created = ensure_v013_tables_exist(conn)?;
470
471    // 1.75. Remove vec virtual tables via writable_schema if vec0 is absent.
472    let vec_tables_removed = if vec_tables_were_present {
473        remove_vec_virtual_tables_without_module(conn)?
474    } else {
475        0
476    };
477
478    // 2. Rehash checksums (in case V002 was the offender).
479    let rehash_report = run_rehash(conn, db_path)?;
480    let rehashed = rehash_report.rewritten;
481
482    // 3. Apply pending migrations (V013 will run if it hasn't yet).
483    //    If the user is on v1.0.75 the V013 migration was already applied,
484    //    so this is a no-op; if they're on v1.0.74 the V013 drop will run.
485    //    If vec tables were removed in step 1.75, V013 DROP is a no-op.
486    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    // 4. Detect V013 application by checking the schema_version.
502    //    V013 has version 13, so schema_version >= 13 implies it ran.
503    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
579/// Ensures the BLOB-backed embedding tables from V013 actually exist.
580/// Repairs databases where `run_rehash` registered V013 in the history
581/// without executing its SQL (G41 phantom registration bug).
582pub(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;