Skip to main content

rightkit_db/
migrate.rs

1//! Ordered, transactional, crash-aware schema migrations.
2//!
3//! The library owns the *mechanism*; apps own the schemas. Rules, taken from the strongest
4//! donors (CodeRight `storage::migrations`, MailRight `db::migrations`, WorkRight `local-store`):
5//!
6//! - versions are contiguous from 1 and default to `PRAGMA user_version`;
7//! - each step runs in its own `BEGIN IMMEDIATE` transaction together with its version bump, so
8//!   a failing step rolls back to the last good version;
9//! - before the first pending step on a non-empty database an online backup is taken to
10//!   `<db>.pre-v<target>.bak` (WAL-inclusive, atomic, owner-only);
11//! - a `<db>.migrating` marker brackets the batch so a crash mid-batch is reported on next open;
12//! - a database newer than this build is refused unless the app's probe says the subset it
13//!   needs is still present (additive forward compatibility);
14//! - rollback replays `down` steps and refuses when any step in the range lacks one.
15
16use crate::recover::{
17    allowed, is_unreadable, quarantine_database_journaled, unfinished_quarantine, RecoveryPolicy,
18    QUARANTINE_JOURNAL_SUFFIX,
19};
20use crate::{DbError, DbOptions, Result};
21use rusqlite::{Connection, ErrorCode, OptionalExtension};
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26pub enum Step {
27    Sql(&'static str),
28    /// Rust step (data backfills, table rebuilds); runs inside the step's transaction.
29    Code(fn(&Connection) -> rusqlite::Result<()>),
30}
31
32pub struct Migration {
33    pub version: i64,
34    pub name: &'static str,
35    pub up: Step,
36    /// Reverse SQL; `None` makes this step unrevertable (rollback refuses to guess).
37    pub down: Option<&'static str>,
38}
39
40impl Migration {
41    pub const fn sql(version: i64, name: &'static str, up: &'static str) -> Self {
42        Self {
43            version,
44            name,
45            up: Step::Sql(up),
46            down: None,
47        }
48    }
49
50    pub const fn with_down(mut self, down: &'static str) -> Self {
51        self.down = Some(down);
52        self
53    }
54
55    pub const fn code(
56        version: i64,
57        name: &'static str,
58        up: fn(&Connection) -> rusqlite::Result<()>,
59    ) -> Self {
60        Self {
61            version,
62            name,
63            up: Step::Code(up),
64            down: None,
65        }
66    }
67}
68
69/// Where migration versions are stored. Table storage uses Blueprint-compatible
70/// `key TEXT PRIMARY KEY, value TEXT NOT NULL` rows, leaving `user_version` untouched.
71#[derive(Clone, Debug, Default, PartialEq, Eq)]
72pub enum VersionStorage {
73    #[default]
74    UserVersion,
75    /// Table names must contain only ASCII letters, digits, or underscores.
76    /// Keys are bound parameters and may contain arbitrary text.
77    Table { table: String, key: String },
78    /// One row per applied version, shaped `"<version_column>" INTEGER PRIMARY KEY,
79    /// "<applied_at_column>" INTEGER NOT NULL` (ViewRight's `_migrations` shape). The applied
80    /// version is the highest stored `version_column`; a rollback deletes the top row. The table is
81    /// created on the first write when absent. Identifiers must be ASCII letters, digits, or
82    /// underscores. `applied_at` stores unix seconds.
83    VersionTable {
84        table: String,
85        version_column: String,
86        applied_at_column: String,
87    },
88}
89
90impl VersionStorage {
91    /// [`VersionStorage::VersionTable`] with the `version` and `applied_at` column names.
92    pub fn version_table(table: impl Into<String>) -> Self {
93        Self::VersionTable {
94            table: table.into(),
95            version_column: "version".into(),
96            applied_at_column: "applied_at".into(),
97        }
98    }
99}
100
101/// An app's existing one-row-per-version table, read once to adopt its applied version (see
102/// [`MigrateOptions::import_legacy_versions`]). Only the highest `version_column` value is used.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct LegacyVersionTable {
105    pub table: String,
106    pub version_column: String,
107}
108
109/// Checks a pre-migration backup before the first step runs; `Err(reason)` stops the migration.
110/// A closure, so an owner can keep state (a key, a message count, a free-space floor) in it.
111pub type VerifyBackup = Arc<dyn Fn(&Path) -> std::result::Result<(), String> + Send + Sync>;
112/// Names the pre-migration backup from the current and target versions (see
113/// [`MigrateOptions::backup_name`]).
114pub type BackupName = Arc<dyn Fn(i64, i64) -> String + Send + Sync>;
115#[derive(Clone)]
116pub struct MigrateOptions {
117    /// Version location shared by reads and transactional migration writes.
118    pub version_storage: VersionStorage,
119    /// Take a pre-migration backup when migrating an existing (version > 0) on-disk database.
120    pub backup: bool,
121    /// Key source for the backup (SQLCipher); `DbOptions::open_writer_migrated` fills this in.
122    pub backup_options: Option<DbOptions>,
123    /// For a database newer than this build: `true` means the subset this build reads/writes is
124    /// intact, so open it read-write without migrating.
125    pub forward_compatible: Option<fn(&Connection) -> bool>,
126    /// Refresh planner statistics after migrating (`PRAGMA optimize`).
127    pub optimize_after: bool,
128    /// Adopt an existing app version table (see [`MigrateOptions::import_legacy_versions`]). Default none.
129    pub legacy_versions: Option<LegacyVersionTable>,
130    /// Name of the pre-migration backup, given the current and target versions. Default
131    /// `<db>.pre-v<target>.bak` beside the database. Must be a plain file name that is not the
132    /// database's own name (a name with a path separator or NUL is refused as
133    /// [`DbError::Backup`]).
134    pub backup_name: Option<BackupName>,
135    /// Checks the pre-migration backup before the first step runs. `Err(reason)` stops the
136    /// migration with [`DbError::Backup`]. The live database is untouched and the backup is kept.
137    pub verify_backup: Option<VerifyBackup>,
138    /// Keeps only the newest `n` pre-migration backups of this database, by modification time:
139    /// after each backup, older files named `<db>.pre-*.bak` in the same directory are removed.
140    /// The backup just taken is always kept. Removal is best effort and never fails the migration.
141    /// Default `None`: every backup is kept.
142    pub backup_retention: Option<usize>,
143}
144
145impl Default for MigrateOptions {
146    fn default() -> Self {
147        Self {
148            version_storage: VersionStorage::default(),
149            backup: true,
150            backup_options: None,
151            forward_compatible: None,
152            optimize_after: true,
153            legacy_versions: None,
154            backup_name: None,
155            verify_backup: None,
156            backup_retention: None,
157        }
158    }
159}
160
161impl MigrateOptions {
162    /// Adopt the applied version of an app's existing version table, once, so its migrations are
163    /// not re-run. Applies only when the configured storage reads version 0 and the legacy table's
164    /// highest version is above zero. The configured storage is then seeded to that version (a
165    /// `VersionTable` gets one row per version up to it), and the migrations up to it are skipped.
166    /// The legacy table is only read, never written. Idempotent: once seeded, the configured
167    /// storage is above zero, so later opens do not consult the legacy table. Rolling back to
168    /// version 0 while the legacy table still exists re-adopts it on the next open, so drop or
169    /// rename that table after adoption. A legacy version above this build's newest migration is
170    /// refused as [`DbError::NewerDatabase`] and nothing is written.
171    pub fn import_legacy_versions(mut self, legacy: LegacyVersionTable) -> Self {
172        self.legacy_versions = Some(legacy);
173        self
174    }
175}
176
177#[derive(Clone, Debug, Default, PartialEq, Eq)]
178pub struct MigrationReport {
179    pub from: i64,
180    pub to: i64,
181    pub applied: Vec<i64>,
182    pub backup: Option<PathBuf>,
183    /// Opened a newer-but-compatible database without migrating.
184    pub forward_compatible: bool,
185    /// A previous batch started but never finished (crash or failed step).
186    pub interrupted_previous: Option<PendingMarker>,
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct PendingMarker {
191    pub from: i64,
192    pub to: i64,
193    pub pid: u32,
194}
195
196pub fn user_version(conn: &Connection) -> Result<i64> {
197    Ok(conn.query_row("PRAGMA user_version", [], |row| row.get(0))?)
198}
199
200fn set_user_version(conn: &Connection, version: i64) -> rusqlite::Result<()> {
201    // `user_version` cannot be bound as a parameter; the value is an i64, never text.
202    conn.execute_batch(&format!("PRAGMA user_version = {version};"))
203}
204
205/// Read the selected migration version without creating a table or row.
206/// Missing table/key means zero; malformed or negative versions are refused.
207pub fn migration_version(conn: &Connection, storage: &VersionStorage) -> Result<i64> {
208    match storage {
209        VersionStorage::UserVersion => user_version(conn),
210        VersionStorage::Table { table, key } => key_value_version(conn, table, key),
211        VersionStorage::VersionTable {
212            table,
213            version_column,
214            ..
215        } => version_row_max(conn, table, version_column),
216    }
217}
218
219fn key_value_version(conn: &Connection, table: &str, key: &str) -> Result<i64> {
220    check_ident(table)?;
221    let exists: bool = conn.query_row(
222        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1 COLLATE NOCASE)",
223        [table],
224        |row| row.get(0),
225    )?;
226    if !exists {
227        return Ok(0);
228    }
229    let value: Option<String> = conn
230        .query_row(
231            &format!("SELECT CAST(\"value\" AS TEXT) FROM \"{table}\" WHERE \"key\"=?1"),
232            [key],
233            |row| row.get(0),
234        )
235        .optional()?;
236    match value {
237        None => Ok(0),
238        Some(value) => value
239            .parse::<i64>()
240            .ok()
241            .filter(|version| *version >= 0)
242            .ok_or_else(|| DbError::InvalidMigrations(format!("invalid stored version {value:?}"))),
243    }
244}
245
246// Called only inside the migration/down transaction, after identifier validation.
247fn set_key_value_version(
248    conn: &Connection,
249    storage: &VersionStorage,
250    version: i64,
251) -> rusqlite::Result<()> {
252    match storage {
253        VersionStorage::UserVersion => set_user_version(conn, version),
254        VersionStorage::Table { table, key } => {
255            conn.execute_batch(&format!(
256                "CREATE TABLE IF NOT EXISTS \"{table}\" (\"key\" TEXT PRIMARY KEY, \"value\" TEXT NOT NULL);"
257            ))?;
258            conn.execute(
259                &format!(
260                    "INSERT INTO \"{table}\"(\"key\",\"value\") VALUES(?1,?2) \
261                     ON CONFLICT(\"key\") DO UPDATE SET \"value\"=excluded.\"value\""
262                ),
263                rusqlite::params![key, version.to_string()],
264            )?;
265            Ok(())
266        }
267        // Routed to `advance_version` / `retreat_version`, never reached through them.
268        VersionStorage::VersionTable { .. } => Err(rusqlite::Error::InvalidQuery),
269    }
270}
271
272/// Make `version` the applied version: a forward step, inside its transaction.
273fn advance_version(
274    conn: &Connection,
275    storage: &VersionStorage,
276    version: i64,
277) -> rusqlite::Result<()> {
278    match storage {
279        VersionStorage::VersionTable {
280            table,
281            version_column,
282            applied_at_column,
283        } => {
284            conn.execute_batch(&format!(
285                "CREATE TABLE IF NOT EXISTS \"{table}\" (\"{version_column}\" INTEGER PRIMARY KEY, \"{applied_at_column}\" INTEGER NOT NULL);"
286            ))?;
287            conn.execute(
288                &format!("DELETE FROM \"{table}\" WHERE \"{version_column}\" = ?1"),
289                [version],
290            )?;
291            conn.execute(
292                &format!(
293                    "INSERT INTO \"{table}\" (\"{version_column}\", \"{applied_at_column}\") VALUES (?1, ?2)"
294                ),
295                rusqlite::params![version, now_secs()],
296            )?;
297            Ok(())
298        }
299        _ => set_key_value_version(conn, storage, version),
300    }
301}
302
303/// Un-apply `version`: a rollback step, so the applied version becomes `version - 1`.
304fn retreat_version(
305    conn: &Connection,
306    storage: &VersionStorage,
307    version: i64,
308) -> rusqlite::Result<()> {
309    match storage {
310        VersionStorage::VersionTable {
311            table,
312            version_column,
313            ..
314        } => {
315            conn.execute(
316                &format!("DELETE FROM \"{table}\" WHERE \"{version_column}\" = ?1"),
317                [version],
318            )?;
319            Ok(())
320        }
321        _ => set_key_value_version(conn, storage, version - 1),
322    }
323}
324
325/// Seed a database at version 0 with `through` as its applied version (legacy adoption). A
326/// `VersionTable` gets one row per version; the other storages take the single value.
327fn adopt_legacy_version(conn: &Connection, storage: &VersionStorage, through: i64) -> Result<()> {
328    conn.execute_batch("BEGIN IMMEDIATE;")?;
329    let seeded = match storage {
330        VersionStorage::VersionTable { .. } => {
331            (1..=through).try_for_each(|version| advance_version(conn, storage, version))
332        }
333        _ => advance_version(conn, storage, through),
334    };
335    match seeded {
336        Ok(()) => {
337            conn.execute_batch("COMMIT;")?;
338            Ok(())
339        }
340        Err(error) => {
341            let _ = conn.execute_batch("ROLLBACK;");
342            Err(DbError::Sqlite(error))
343        }
344    }
345}
346
347/// Highest version stored in a one-row-per-version table; zero when the table is absent or empty.
348fn version_row_max(conn: &Connection, table: &str, version_column: &str) -> Result<i64> {
349    check_ident(table)?;
350    check_ident(version_column)?;
351    let exists: bool = conn.query_row(
352        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1 COLLATE NOCASE)",
353        [table],
354        |row| row.get(0),
355    )?;
356    if !exists {
357        return Ok(0);
358    }
359    let highest: Option<i64> = conn.query_row(
360        &format!("SELECT MAX(CAST(\"{version_column}\" AS INTEGER)) FROM \"{table}\""),
361        [],
362        |row| row.get(0),
363    )?;
364    let version = highest.unwrap_or(0);
365    if version < 0 {
366        return Err(DbError::InvalidMigrations(format!(
367            "invalid stored version {version}"
368        )));
369    }
370    Ok(version)
371}
372
373/// Refuses any identifier that is not a plain ASCII name, before SQL is built from it.
374fn check_storage(storage: &VersionStorage) -> Result<()> {
375    match storage {
376        VersionStorage::UserVersion => Ok(()),
377        VersionStorage::Table { table, .. } => check_ident(table),
378        VersionStorage::VersionTable {
379            table,
380            version_column,
381            applied_at_column,
382        } => {
383            check_ident(table)?;
384            check_ident(version_column)?;
385            check_ident(applied_at_column)
386        }
387    }
388}
389
390/// Where the pre-migration backup goes: `<db>.pre-v<target>.bak`, or the name from
391/// [`MigrateOptions::backup_name`]. The name must be a plain file name other than the database's.
392fn backup_path(db: &Path, options: &MigrateOptions, current: i64, target: i64) -> Result<PathBuf> {
393    let name = db
394        .file_name()
395        .ok_or(DbError::InvalidDatabasePath)?
396        .to_string_lossy()
397        .into_owned();
398    let file = match &options.backup_name {
399        Some(namer) => namer(current, target),
400        None => format!("{name}.pre-v{target}.bak"),
401    };
402    if file.is_empty() || file.contains(['/', '\\', '\0']) || file == "." || file == ".." {
403        return Err(DbError::Backup(format!(
404            "backup name {file:?} must be a plain file name other than the database's"
405        )));
406    }
407    // The backup is published with a replacing rename, so it must never land on the database,
408    // a live sidecar, or a recovery file this crate keeps beside the database. Compared
409    // case-insensitively: a case-insensitive filesystem treats those names as the same file.
410    let lowered = file.to_lowercase();
411    let reserved = [
412        "",
413        "-wal",
414        "-shm",
415        "-journal",
416        ".migrating",
417        ".restore-journal",
418        ".store-lock",
419        ".transition-journal",
420        QUARANTINE_JOURNAL_SUFFIX,
421    ]
422    .iter()
423    .any(|suffix| lowered == format!("{name}{suffix}").to_lowercase());
424    if reserved {
425        return Err(DbError::Backup(format!(
426            "backup name {file:?} is the database, one of its sidecars or a recovery file; choose another name"
427        )));
428    }
429    Ok(db.with_file_name(file))
430}
431
432/// Keeps the `keep` newest pre-migration backups of `db`, counting `newest` (the backup just
433/// taken), and removes the older `<db>.pre-*.bak` files beside it. Best effort: a file that
434/// cannot be removed is left in place.
435fn prune_backups(db: &Path, newest: &Path, keep: usize) {
436    let Some(name) = db.file_name().map(|n| n.to_string_lossy().into_owned()) else {
437        return;
438    };
439    let dir = db
440        .parent()
441        .filter(|p| !p.as_os_str().is_empty())
442        .unwrap_or(Path::new("."));
443    let prefix = format!("{name}.pre-");
444    let Ok(entries) = std::fs::read_dir(dir) else {
445        return;
446    };
447    let mut older: Vec<(SystemTime, PathBuf)> = entries
448        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
449        .filter(|path| path.as_path() != newest)
450        .filter(|path| {
451            path.file_name().is_some_and(|n| {
452                let n = n.to_string_lossy();
453                n.starts_with(&prefix) && n.ends_with(".bak")
454            })
455        })
456        .filter_map(|path| {
457            let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok()?;
458            Some((modified, path))
459        })
460        .collect();
461    // Newest first; the just-taken backup already fills one of the `keep` places.
462    older.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
463    for (_, path) in older.into_iter().skip(keep.saturating_sub(1)) {
464        let _ = std::fs::remove_file(path);
465    }
466}
467
468fn now_secs() -> i64 {
469    SystemTime::now()
470        .duration_since(UNIX_EPOCH)
471        .map(|d| d.as_secs() as i64)
472        .unwrap_or(0)
473}
474
475pub fn validate(migrations: &[Migration]) -> Result<()> {
476    for (index, migration) in migrations.iter().enumerate() {
477        let expected = index as i64 + 1;
478        if migration.version != expected {
479            return Err(DbError::InvalidMigrations(format!(
480                "expected version {expected} at position {index}, found {} ({})",
481                migration.version, migration.name
482            )));
483        }
484    }
485    Ok(())
486}
487
488fn marker_path(db: &Path) -> PathBuf {
489    let mut name = db.as_os_str().to_os_string();
490    name.push(".migrating");
491    PathBuf::from(name)
492}
493
494pub fn pending_marker(db: &Path) -> Option<PendingMarker> {
495    let text = std::fs::read_to_string(marker_path(db)).ok()?;
496    let mut marker = PendingMarker {
497        from: 0,
498        to: 0,
499        pid: 0,
500    };
501    for line in text.lines() {
502        match line.split_once('=')? {
503            ("from", v) => marker.from = v.parse().ok()?,
504            ("to", v) => marker.to = v.parse().ok()?,
505            ("pid", v) => marker.pid = v.parse().ok()?,
506            _ => {}
507        }
508    }
509    Some(marker)
510}
511
512/// Unix seconds when the pending `<db>.migrating` batch started, `0` for a marker written
513/// before the start time was recorded, `None` without a readable marker. A separate reader keeps
514/// [`PendingMarker`] constructible as before.
515pub fn pending_marker_started_at(db: &Path) -> Option<u64> {
516    pending_marker(db)?;
517    let text = std::fs::read_to_string(marker_path(db)).ok()?;
518    Some(
519        text.lines()
520            .find_map(|line| line.strip_prefix("started="))
521            .and_then(|v| v.parse().ok())
522            .unwrap_or(0),
523    )
524}
525
526/// Writes the `<db>.migrating` marker for a batch that moves the schema from version `from` to
527/// `to`. The write is atomic and records the writing process id and the start time (unix
528/// seconds). [`migrate`] writes this same marker; [`pending_marker`] reads it back. Use it to
529/// simulate an interrupted migration in a crash test, instead of a private copy of the format.
530pub fn write_pending_marker(db: &Path, from: i64, to: i64) -> Result<()> {
531    let body = format!(
532        "from={from}\nto={to}\npid={}\nstarted={}\n",
533        std::process::id(),
534        now_secs().max(0)
535    );
536    rightkit_fs::atomic_write(&marker_path(db), body.as_bytes())?;
537    Ok(())
538}
539
540/// True when the version stored under `storage` is above the newest of `migrations`, which means
541/// a newer build wrote this database. It only reads: nothing is migrated, created or written. It
542/// does not consult [`MigrateOptions::import_legacy_versions`].
543pub fn stored_version_is_newer(
544    conn: &Connection,
545    storage: &VersionStorage,
546    migrations: &[Migration],
547) -> Result<bool> {
548    validate(migrations)?;
549    check_storage(storage)?;
550    let max = migrations.last().map_or(0, |m| m.version);
551    Ok(migration_version(conn, storage)? > max)
552}
553
554pub fn migrate(
555    conn: &Connection,
556    db_path: Option<&Path>,
557    migrations: &[Migration],
558    options: &MigrateOptions,
559) -> Result<MigrationReport> {
560    validate(migrations)?;
561    check_storage(&options.version_storage)?;
562    let mut current = migration_version(conn, &options.version_storage)?;
563    let max = migrations.last().map(|m| m.version).unwrap_or(0);
564    let mut report = MigrationReport {
565        from: current,
566        to: current,
567        ..Default::default()
568    };
569    report.interrupted_previous = db_path.and_then(pending_marker);
570
571    // Legacy adoption happens only from version 0 (see `MigrateOptions::import_legacy_versions`).
572    if let Some(legacy) = options.legacy_versions.as_ref().filter(|_| current == 0) {
573        let legacy_version = version_row_max(conn, &legacy.table, &legacy.version_column)?;
574        if legacy_version > 0 {
575            if legacy_version <= max {
576                adopt_legacy_version(conn, &options.version_storage, legacy_version)?;
577            }
578            current = legacy_version;
579            report.from = current;
580            report.to = current;
581        }
582    }
583
584    if current > max {
585        if options.forward_compatible.is_some_and(|probe| probe(conn)) {
586            report.forward_compatible = true;
587            return Ok(report);
588        }
589        return Err(DbError::NewerDatabase { current, max });
590    }
591    let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current).collect();
592    if pending.is_empty() {
593        return Ok(report);
594    }
595
596    if current > 0 && options.backup {
597        if let Some(path) = db_path {
598            let backup = backup_path(path, options, current, max)?;
599            let backup_options = options
600                .backup_options
601                .clone()
602                .unwrap_or_else(|| DbOptions::new(path));
603            crate::backup::online_backup_sized(&backup_options, conn, &backup, None)?;
604            if let Some(verify) = &options.verify_backup {
605                verify(&backup).map_err(|reason| {
606                    DbError::Backup(format!("pre-migration backup failed its check: {reason}"))
607                })?;
608            }
609            if let Some(keep) = options.backup_retention {
610                prune_backups(path, &backup, keep);
611            }
612            report.backup = Some(backup);
613        }
614    }
615
616    if let Some(path) = db_path {
617        write_pending_marker(path, current, max)?;
618    }
619
620    for migration in pending {
621        conn.execute_batch("BEGIN IMMEDIATE;")?;
622        let applied = match &migration.up {
623            Step::Sql(sql) => conn.execute_batch(sql),
624            Step::Code(run) => run(conn),
625        }
626        .and_then(|()| advance_version(conn, &options.version_storage, migration.version));
627        match applied {
628            Ok(()) => {
629                conn.execute_batch("COMMIT;")?;
630                report.applied.push(migration.version);
631                report.to = migration.version;
632            }
633            Err(source) => {
634                let _ = conn.execute_batch("ROLLBACK;");
635                return Err(DbError::MigrationFailed {
636                    version: migration.version,
637                    name: migration.name,
638                    source,
639                });
640            }
641        }
642    }
643
644    if let Some(path) = db_path {
645        let _ = std::fs::remove_file(marker_path(path));
646    }
647    if options.optimize_after {
648        crate::optimize_boundary(conn)?;
649    }
650    Ok(report)
651}
652
653/// Revert to `target` by replaying each step's `down`, highest first, one transaction per step.
654/// Refuses before touching anything unless every step in the range has a `down`.
655pub fn rollback_to(conn: &Connection, migrations: &[Migration], target: i64) -> Result<Vec<i64>> {
656    rollback_to_with_storage(conn, migrations, target, &VersionStorage::UserVersion)
657}
658
659/// Revert using the same version storage selected for [`migrate`].
660pub fn rollback_to_with_storage(
661    conn: &Connection,
662    migrations: &[Migration],
663    target: i64,
664    storage: &VersionStorage,
665) -> Result<Vec<i64>> {
666    validate(migrations)?;
667    check_storage(storage)?;
668    if target < 0 {
669        return Err(DbError::Rollback(
670            "target version must be nonnegative".into(),
671        ));
672    }
673    let current = migration_version(conn, storage)?;
674    if current <= target {
675        return Err(DbError::Rollback(format!(
676            "already at v{current}, at or below target v{target}"
677        )));
678    }
679    let mut plan = Vec::new();
680    for version in ((target + 1)..=current).rev() {
681        let migration = migrations
682            .iter()
683            .find(|m| m.version == version)
684            .ok_or_else(|| DbError::Rollback(format!("v{version} is not a known migration")))?;
685        let down = migration.down.ok_or_else(|| {
686            DbError::Rollback(format!(
687                "v{version} ({}) has no down migration",
688                migration.name
689            ))
690        })?;
691        plan.push((version, down));
692    }
693    let mut reverted = Vec::new();
694    for (version, down) in plan {
695        conn.execute_batch("BEGIN IMMEDIATE;")?;
696        let result = conn
697            .execute_batch(down)
698            .and_then(|()| retreat_version(conn, storage, version));
699        match result {
700            Ok(()) => {
701                conn.execute_batch("COMMIT;")?;
702                reverted.push(version);
703            }
704            Err(error) => {
705                let _ = conn.execute_batch("ROLLBACK;");
706                return Err(DbError::Rollback(format!(
707                    "down v{version} failed and was rolled back: {error}"
708                )));
709            }
710        }
711    }
712    Ok(reverted)
713}
714
715/// True if `table` has `column`, including STORED/VIRTUAL generated columns (`table_xinfo`;
716/// `table_info` omits them, which made MailRight re-run an ALTER on every open).
717pub fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
718    check_ident(table)?;
719    let mut statement = conn.prepare(&format!("PRAGMA table_xinfo({table})"))?;
720    let names = statement.query_map([], |row| row.get::<_, String>(1))?;
721    for name in names {
722        if name? == column {
723            return Ok(true);
724        }
725    }
726    Ok(false)
727}
728
729/// Idempotent `ALTER TABLE .. ADD COLUMN` (SQLite has no `IF NOT EXISTS` for columns).
730/// `table`, `column` and `decl` must be literals from the app, never user input.
731pub fn add_column_if_missing(
732    conn: &Connection,
733    table: &str,
734    column: &str,
735    decl: &str,
736) -> Result<()> {
737    check_ident(table)?;
738    check_ident(column)?;
739    if !column_exists(conn, table, column)? {
740        conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl};"))?;
741    }
742    Ok(())
743}
744
745fn check_ident(ident: &str) -> Result<()> {
746    if !ident.is_empty()
747        && ident
748            .bytes()
749            .all(|b| b.is_ascii_alphanumeric() || b == b'_')
750    {
751        Ok(())
752    } else {
753        Err(DbError::InvalidMigrations(format!(
754            "unsafe SQL identifier {ident:?}"
755        )))
756    }
757}
758
759/// What [`DbOptions::open_writer_migrated_or_degrade`] returns.
760#[derive(Debug)]
761pub struct OpenOrDegrade {
762    /// The writer to use: the database at the path, or a private in-memory store when degraded.
763    pub conn: Connection,
764    /// The migration report for the connection in `conn`.
765    pub report: MigrationReport,
766    /// `Some` when `conn` is not the database as it was stored: it was quarantined and recreated,
767    /// or the app runs on an in-memory store. The app should show it to the user.
768    pub degraded: Option<Degraded>,
769}
770
771/// Why [`DbOptions::open_writer_migrated_or_degrade`] did not use the stored database as-is.
772#[derive(Clone, Debug, PartialEq, Eq)]
773pub struct Degraded {
774    /// The failure that made the stored database unusable.
775    pub reason: String,
776    /// Where the unusable file went, with its sidecars, when it was quarantined. Also set when a
777    /// quarantine stopped part way and could not be undone (see `quarantine_incomplete`): then
778    /// it is where the main file is now.
779    pub quarantined: Option<PathBuf>,
780    /// `true` when a quarantine moved part of the stored database and neither finished nor rolled
781    /// back. The files are split between the original path and `quarantined`; a
782    /// `<db>.quarantine-journal` file records the move and makes later opens through
783    /// [`DbOptions::open_writer_migrated_or_degrade`] stay in memory instead of creating a
784    /// replacement over the split files. A person must reunite or remove them.
785    pub quarantine_incomplete: bool,
786    /// `true` when the connection is a private in-memory store, not a file at the path.
787    pub in_memory: bool,
788}
789
790impl DbOptions {
791    /// Open the writer and bring the schema up to date (see [`migrate`]); the pre-migration
792    /// backup is keyed like the database itself.
793    pub fn open_writer_migrated(
794        &self,
795        migrations: &[Migration],
796        options: &MigrateOptions,
797    ) -> Result<(Connection, MigrationReport)> {
798        let conn = self.open_writer()?;
799        let mut options = options.clone();
800        options.backup_options.get_or_insert_with(|| self.clone());
801        let report = match migrate(&conn, Some(&self.path), migrations, &options) {
802            Ok(report) => report,
803            Err(err) => {
804                // Corruption can first surface here, after `open_at` re-enabled the close
805                // checkpoint. Closing this connection must not checkpoint or delete the `-wal`
806                // before `open_writer_migrated_or_degrade` moves the store aside (rightkit#932).
807                let _ = conn.set_db_config(
808                    rusqlite::config::DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE,
809                    true,
810                );
811                return Err(err);
812            }
813        };
814        Ok((conn, report))
815    }
816
817    /// Open-or-degrade: [`Self::open_writer_migrated`], with recovery for a stored database that
818    /// is corrupt, not a database, or fails its integrity check, at open or during migration.
819    ///
820    /// For such a failure, `policy` decides as in [`Self::open_writer_recovering`]: when it allows
821    /// (`quarantine_corrupt` for a plain database, `quarantine_keyed` for a keyed one), the file
822    /// and its sidecars move aside under the policy's stamp and sidecar options (never deleted by
823    /// default), and the database is created and migrated again. When it does not allow, the file
824    /// is left untouched. Either way, if the store still cannot be opened, the connection is a
825    /// private in-memory store that is migrated the same way only when
826    /// `policy.allow_in_memory_fallback` explicitly acknowledges it (default refuse: the
827    /// original error is returned). When acknowledged, [`OpenOrDegrade::degraded`] says so
828    /// with `in_memory: true`, and the configured key remains applied.
829    ///
830    /// Any other failure (busy, permission, I/O, a newer database, an invalid migration list) is
831    /// returned as an error, because it does not show the stored data is damaged.
832    pub fn open_writer_migrated_or_degrade(
833        &self,
834        migrations: &[Migration],
835        options: &MigrateOptions,
836        policy: RecoveryPolicy,
837    ) -> Result<OpenOrDegrade> {
838        // A quarantine an earlier run could not finish or undo: never create a replacement over
839        // the split files.
840        if let Some(journal) = unfinished_quarantine(&self.path) {
841            let reason = format!(
842                "an earlier quarantine of {} stopped part way and was not undone; see {}",
843                self.path.display(),
844                journal.display()
845            );
846            let refuse = DbError::Fs(rightkit_fs::FsError::io(
847                &journal,
848                std::io::Error::other(reason.clone()),
849            ));
850            return self.degrade_in_memory(migrations, options, policy, reason, None, true, refuse);
851        }
852        let error = match self.open_writer_migrated(migrations, options) {
853            Ok((conn, report)) => {
854                return Ok(OpenOrDegrade {
855                    conn,
856                    report,
857                    degraded: None,
858                })
859            }
860            Err(error) if is_recoverable_damage(&error) => error,
861            Err(error) => return Err(error),
862        };
863        let reason = error.to_string();
864        if !allowed(self, policy) {
865            return self.degrade_in_memory(migrations, options, policy, reason, None, false, error);
866        }
867        let quarantined = match quarantine_database_journaled(
868            &self.path,
869            policy.quarantine_stamp,
870            policy.sidecars,
871        ) {
872            Ok(moved) => moved,
873            Err(failure) => {
874                // Nothing moved (or every move was undone): the store is untouched. Otherwise
875                // the split stays on disk, journaled, and the result says so.
876                let reason = format!("{reason}; quarantine failed: {}", failure.error);
877                let incomplete = failure.stranded_at.is_some();
878                return self.degrade_in_memory(
879                    migrations,
880                    options,
881                    policy,
882                    reason,
883                    failure.stranded_at,
884                    incomplete,
885                    failure.error,
886                );
887            }
888        };
889        match self.open_writer_migrated(migrations, options) {
890            Ok((conn, report)) => Ok(OpenOrDegrade {
891                conn,
892                report,
893                degraded: Some(Degraded {
894                    reason,
895                    quarantined: Some(quarantined),
896                    quarantine_incomplete: false,
897                    in_memory: false,
898                }),
899            }),
900            Err(second) => {
901                let reason = format!("{reason}; reopen after quarantine failed: {second}");
902                self.degrade_in_memory(
903                    migrations,
904                    options,
905                    policy,
906                    reason,
907                    Some(quarantined),
908                    false,
909                    second,
910                )
911            }
912        }
913    }
914
915    #[allow(clippy::too_many_arguments)]
916    fn degrade_in_memory(
917        &self,
918        migrations: &[Migration],
919        options: &MigrateOptions,
920        policy: RecoveryPolicy,
921        reason: String,
922        quarantined: Option<PathBuf>,
923        quarantine_incomplete: bool,
924        refuse: DbError,
925    ) -> Result<OpenOrDegrade> {
926        // In-memory recovery is never automatic: without explicit acknowledgement the
927        // caller gets the original failure, not a store that will not persist.
928        if !policy.allow_in_memory_fallback {
929            return Err(refuse);
930        }
931        let conn = self.open_in_memory()?;
932        let report = migrate(&conn, None, migrations, options)?;
933        Ok(OpenOrDegrade {
934            conn,
935            report,
936            degraded: Some(Degraded {
937                reason,
938                quarantined,
939                quarantine_incomplete,
940                in_memory: true,
941            }),
942        })
943    }
944}
945
946/// Damage that recovery may act on: an unreadable or failed-integrity open (see
947/// [`is_unreadable`]), or a migration step that SQLite reports as not a database or corrupt.
948fn is_recoverable_damage(error: &DbError) -> bool {
949    match error {
950        DbError::MigrationFailed {
951            source: rusqlite::Error::SqliteFailure(failure, _),
952            ..
953        } => matches!(
954            failure.code,
955            ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt
956        ),
957        other => is_unreadable(other),
958    }
959}