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