Skip to main content

cadmus_core/db/
backup.rs

1use crate::db::version::{MigrationHash, current_migration_hash};
2use crate::version::GitVersion;
3use anyhow::{Context, Error};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use sqlx::Connection;
7use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection, SqlitePool};
8use std::ffi::{CStr, CString, c_int};
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11use tokio::fs;
12
13/// Subdirectory under the database directory where backups are stored.
14const BACKUP_DIR: &str = "backups";
15/// Filename of the TOML manifest that tracks all known backups.
16const MANIFEST_FILE: &str = ".cadmus-db-index.toml";
17/// SQLite database name passed to the online backup API (`"main"`).
18const MAIN_DB_NAME: &str = "main";
19/// Number of pages copied per `sqlite3_backup_step` iteration.
20const BACKUP_PAGE_COUNT: c_int = 100;
21/// Milliseconds to sleep when the backup step returns `SQLITE_BUSY` or `SQLITE_LOCKED`.
22const BACKUP_BUSY_SLEEP_MS: u64 = 25;
23
24/// SQLite result code indicating success.
25const SQLITE_OK: c_int = 0;
26/// SQLite result code indicating the backup has finished.
27const SQLITE_DONE: c_int = 101;
28/// SQLite result code indicating the database is busy.
29const SQLITE_BUSY: c_int = 5;
30/// SQLite result code indicating a table-level lock conflict.
31const SQLITE_LOCKED: c_int = 6;
32
33/// File suffixes for SQLite companion files (WAL and shared-memory).
34const SQLITE_COMPANION_SUFFIXES: [&str; 2] = ["-wal", "-shm"];
35
36/// Manifest that tracks all database backups.
37#[derive(Debug, Default, Serialize, Deserialize)]
38pub struct BackupManifest {
39    /// All known database backups, in creation order.
40    #[serde(default)]
41    pub entries: Vec<BackupEntry>,
42}
43
44/// Metadata for a single database backup.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct BackupEntry {
47    /// The Cadmus version that created this backup.
48    pub version: GitVersion,
49    /// The backup filename (relative to the backup directory).
50    pub file: String,
51    /// The UTC datetime when the backup was created.
52    pub created_at: DateTime<Utc>,
53    /// The schema migration hash embedded in the build that created this backup.
54    pub migration_hash: MigrationHash,
55}
56
57/// Errors that can occur when restoring a database backup.
58#[derive(Debug, thiserror::Error)]
59pub enum RestoreError {
60    /// No backup exists in the manifest for the target version.
61    #[error("no backup found in manifest for version {version}")]
62    NoBackupFound { version: GitVersion },
63    /// The manifest references a backup file that no longer exists on disk.
64    ///
65    /// Under normal operation this variant is unreachable: [`DbBackupManager::find_best_backup`]
66    /// filters out entries with missing files before a restore is attempted.
67    /// Receiving this error indicates an invariant violation (e.g. the file was
68    /// removed between the lookup and the restore).
69    #[error("backup file '{file}' referenced by manifest does not exist on disk")]
70    BackupFileMissing { file: String },
71    /// An I/O or other low-level error occurred during the restore.
72    #[error(transparent)]
73    Io(#[from] Error),
74}
75
76/// Manages versioned SQLite database backups.
77#[derive(Clone)]
78pub struct DbBackupManager {
79    db_dir: PathBuf,
80    current_version: GitVersion,
81}
82
83impl DbBackupManager {
84    /// Creates a backup manager for the database directory.
85    ///
86    /// `db_dir` is the directory containing `cadmus.sqlite`. Backups are stored
87    /// in `db_dir/backups/`.
88    pub fn new(db_dir: PathBuf, current_version: GitVersion) -> Self {
89        Self {
90            db_dir,
91            current_version,
92        }
93    }
94
95    /// Returns the path to the backup directory.
96    fn backup_dir(&self) -> PathBuf {
97        self.db_dir.join(BACKUP_DIR)
98    }
99
100    /// Returns the path to the backup manifest.
101    fn manifest_path(&self) -> PathBuf {
102        self.backup_dir().join(MANIFEST_FILE)
103    }
104
105    /// Creates a backup of the current database using the SQLite online backup API.
106    ///
107    /// The backup is stored as `backups/cadmus-v<version>.sqlite`. The manifest is
108    /// updated and old backups exceeding the retention limit are removed.
109    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
110    pub async fn create_backup(
111        &self,
112        pool: &SqlitePool,
113        retention: usize,
114    ) -> Result<PathBuf, Error> {
115        if retention == 0 {
116            return Err(anyhow::anyhow!(
117                "cannot create backup: db_backup_retention is 0"
118            ));
119        }
120
121        fs::create_dir_all(self.backup_dir())
122            .await
123            .context("failed to create backup directory")?;
124
125        let filename = format!("cadmus-{}.sqlite", self.current_version);
126        let backup_path = self.backup_dir().join(&filename);
127        let tmp_path = self.backup_dir().join(format!("{filename}.tmp"));
128
129        remove_sqlite_files(&tmp_path)
130            .await
131            .context("failed to clean up temporary backup files")?;
132
133        online_backup(pool, &tmp_path).await?;
134
135        rename_sqlite_files(&tmp_path, &backup_path)
136            .await
137            .context("failed to promote completed backup")?;
138
139        let created_at = Utc::now();
140        let migration_hash = current_migration_hash();
141
142        self.update_manifest_and_cleanup(&filename, created_at, migration_hash, retention)
143            .await?;
144
145        tracing::info!(
146            version = %self.current_version,
147            file = %filename,
148            path = %backup_path.display(),
149            "created database backup"
150        );
151
152        Ok(backup_path)
153    }
154
155    /// Restores the best available backup for the target version.
156    ///
157    /// The restore is performed in three steps to avoid losing the active
158    /// database if an error occurs mid-way:
159    ///
160    /// 1. Copy the backup to a staging path (`cadmus-v<version>-restore-staged.sqlite`).
161    /// 2. Rename the active database to `cadmus-v<newer_version>-demoted.sqlite`.
162    /// 3. Rename the staged copy to the active path.
163    ///
164    /// If step 3 fails the demoted database is renamed back to `active_path` as
165    /// a best-effort rollback. Demoted files are not tracked in the manifest and
166    /// are never automatically deleted — they remain as a safety net for manual
167    /// recovery.
168    ///
169    /// Returns the path of the restored backup file on success.
170    ///
171    /// # Errors
172    ///
173    /// - [`RestoreError::NoBackupFound`] — no manifest entry exists for the target version.
174    /// - [`RestoreError::BackupFileMissing`] — the manifest references a file that is absent on disk.
175    /// - [`RestoreError::Io`] — an I/O or filesystem error occurred during the restore.
176    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
177    pub async fn restore_best_backup(
178        &self,
179        active_path: &Path,
180        newer_version: &GitVersion,
181    ) -> Result<PathBuf, RestoreError> {
182        let Some(entry) = self.find_best_backup(&self.current_version)? else {
183            tracing::warn!(
184                target_version = %self.current_version,
185                "no database backup found for downgrade; continuing with current database"
186            );
187            return Err(RestoreError::NoBackupFound {
188                version: self.current_version.clone(),
189            });
190        };
191
192        let backup_path = self.backup_dir().join(&entry.file);
193        if !backup_path.exists() {
194            tracing::error!(
195                file = %entry.file,
196                path = %backup_path.display(),
197                "backup file referenced by manifest does not exist (invariant violation)"
198            );
199            return Err(RestoreError::BackupFileMissing {
200                file: entry.file.clone(),
201            });
202        }
203
204        let demoted_filename = format!("cadmus-{}-demoted.sqlite", newer_version);
205        let demoted_path = self.backup_dir().join(&demoted_filename);
206
207        let staged_path = self.backup_dir().join(format!(
208            "cadmus-{}-restore-staged.sqlite",
209            self.current_version
210        ));
211        remove_sqlite_files(&staged_path)
212            .await
213            .context("failed to clean up staged restore files")?;
214
215        copy_sqlite_files(&backup_path, &staged_path)
216            .await
217            .context("failed to stage backup files for restore")?;
218
219        rename_sqlite_files(active_path, &demoted_path)
220            .await
221            .context("failed to demote active database")?;
222
223        if let Err(e) = rename_sqlite_files(&staged_path, active_path).await {
224            if let Err(rollback_err) = rename_sqlite_files(&demoted_path, active_path).await {
225                tracing::error!(
226                    error = %rollback_err,
227                    "failed to roll back demoted database after restore promotion failure"
228                );
229            }
230            return Err(RestoreError::Io(
231                e.context("failed to promote staged restore"),
232            ));
233        }
234
235        tracing::info!(
236            was_version = %newer_version,
237            restored_version = %entry.version,
238            backup_file = %entry.file,
239            "restored database from backup"
240        );
241
242        Ok(backup_path)
243    }
244
245    /// Finds the best backup for the target version.
246    ///
247    /// Prefers an exact version match. Otherwise, returns the newest backup whose
248    /// version is less than or equal to the target version.
249    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
250    pub fn find_best_backup(
251        &self,
252        target_version: &GitVersion,
253    ) -> Result<Option<BackupEntry>, Error> {
254        let manifest = self.read_manifest()?;
255
256        let current_hash = current_migration_hash();
257        let candidates: Vec<_> = manifest
258            .entries
259            .into_iter()
260            .filter(|e| e.version <= *target_version)
261            .filter(|e| {
262                let compatible = e.migration_hash == current_hash;
263                if !compatible {
264                    tracing::warn!(
265                        version = %e.version,
266                        file = %e.file,
267                        backup_migration_hash = %e.migration_hash,
268                        current_migration_hash = %current_hash,
269                        "skipping backup with incompatible migration hash"
270                    );
271                }
272                compatible
273            })
274            .filter(|e| {
275                let exists = self.backup_dir().join(&e.file).exists();
276                if !exists {
277                    tracing::warn!(
278                        version = %e.version,
279                        file = %e.file,
280                        "skipping manifest entry whose backup file is missing"
281                    );
282                }
283                exists
284            })
285            .collect();
286
287        if candidates.is_empty() {
288            return Ok(None);
289        }
290
291        let best = candidates
292            .into_iter()
293            .max_by(|a, b| a.version.cmp(&b.version))
294            .expect("candidates should not be empty after filtering");
295
296        Ok(Some(best))
297    }
298
299    /// Reads the backup manifest from disk.
300    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
301    fn read_manifest(&self) -> Result<BackupManifest, Error> {
302        if self.manifest_path().exists() {
303            crate::helpers::load_toml::<BackupManifest, _>(&self.manifest_path())
304                .context("failed to read backup manifest")
305        } else {
306            Ok(BackupManifest::default())
307        }
308    }
309
310    /// Writes the backup manifest to disk.
311    fn write_manifest(&self, manifest: &BackupManifest) -> Result<(), Error> {
312        crate::helpers::save_toml(manifest, self.manifest_path())
313            .context("failed to write backup manifest")
314    }
315
316    /// Updates the manifest with a new backup entry and removes old backups.
317    ///
318    /// The current version's entry is always retained. Old backups from other
319    /// versions are removed until the total count is within `retention`.
320    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
321    async fn update_manifest_and_cleanup(
322        &self,
323        filename: &str,
324        created_at: DateTime<Utc>,
325        migration_hash: MigrationHash,
326        retention: usize,
327    ) -> Result<(), Error> {
328        let mut manifest = self.read_manifest()?;
329
330        manifest
331            .entries
332            .retain(|e| e.version != self.current_version);
333
334        let new_entry = BackupEntry {
335            version: self.current_version.clone(),
336            file: filename.to_string(),
337            created_at,
338            migration_hash,
339        };
340
341        manifest.entries.push(new_entry);
342
343        if manifest.entries.len() > retention {
344            let (current, mut others): (Vec<_>, Vec<_>) = manifest
345                .entries
346                .drain(..)
347                .partition(|e| e.version == self.current_version);
348
349            others.sort_by(|a, b| a.version.cmp(&b.version));
350
351            let max_others = retention.saturating_sub(current.len());
352            let entries_to_remove = others.len().saturating_sub(max_others);
353            let candidates: Vec<_> = others.drain(..entries_to_remove).collect();
354
355            let mut failed: Vec<BackupEntry> = Vec::new();
356
357            for entry in candidates {
358                let file_path = self.backup_dir().join(&entry.file);
359
360                if file_path.exists()
361                    && let Err(e) = remove_sqlite_files(&file_path).await
362                {
363                    tracing::warn!(
364                        version = %entry.version,
365                        file = %entry.file,
366                        error = %e,
367                        "failed to remove old database backup, will retry on next cleanup"
368                    );
369                    failed.push(entry);
370                    continue;
371                }
372
373                tracing::debug!(
374                    version = %entry.version,
375                    file = %entry.file,
376                    "removed old database backup"
377                );
378            }
379
380            others.extend(failed);
381            manifest.entries = others;
382            manifest.entries.extend(current);
383        }
384
385        self.write_manifest(&manifest)
386    }
387}
388
389/// Renames an SQLite database file and its WAL/SHM companions.
390#[cfg_attr(feature = "tracing", tracing::instrument(skip(src, dest)))]
391async fn rename_sqlite_files(src: &Path, dest: &Path) -> Result<(), Error> {
392    fs::rename(src, dest)
393        .await
394        .with_context(|| format!("failed to rename {} to {}", src.display(), dest.display()))?;
395
396    for suffix in SQLITE_COMPANION_SUFFIXES {
397        let src_extra = add_suffix(src, suffix);
398        if src_extra.exists() {
399            let dest_extra = add_suffix(dest, suffix);
400            fs::rename(&src_extra, &dest_extra).await.with_context(|| {
401                format!(
402                    "failed to rename {} to {}",
403                    src_extra.display(),
404                    dest_extra.display()
405                )
406            })?;
407        }
408    }
409
410    Ok(())
411}
412
413/// Copies an SQLite database file and its WAL/SHM companions.
414#[cfg_attr(feature = "tracing", tracing::instrument(skip(src, dest)))]
415async fn copy_sqlite_files(src: &Path, dest: &Path) -> Result<(), Error> {
416    fs::copy(src, dest)
417        .await
418        .with_context(|| format!("failed to copy {} to {}", src.display(), dest.display()))?;
419
420    for suffix in SQLITE_COMPANION_SUFFIXES {
421        let src_extra = add_suffix(src, suffix);
422        if src_extra.exists() {
423            let dest_extra = add_suffix(dest, suffix);
424            fs::copy(&src_extra, &dest_extra).await.with_context(|| {
425                format!(
426                    "failed to copy {} to {}",
427                    src_extra.display(),
428                    dest_extra.display()
429                )
430            })?;
431        }
432    }
433
434    Ok(())
435}
436
437/// Removes an SQLite database file and its WAL/SHM companions.
438#[cfg_attr(feature = "tracing", tracing::instrument(skip(path)))]
439async fn remove_sqlite_files(path: &Path) -> Result<(), Error> {
440    if path.exists() {
441        fs::remove_file(path)
442            .await
443            .with_context(|| format!("failed to remove {}", path.display()))?;
444    }
445
446    for suffix in SQLITE_COMPANION_SUFFIXES {
447        let extra = add_suffix(path, suffix);
448        if extra.exists() {
449            fs::remove_file(&extra)
450                .await
451                .with_context(|| format!("failed to remove {}", extra.display()))?;
452        }
453    }
454
455    Ok(())
456}
457
458/// Adds a suffix to a path before the file extension.
459fn add_suffix(path: &Path, suffix: &str) -> PathBuf {
460    let mut s = path.as_os_str().to_os_string();
461    s.push(suffix);
462    PathBuf::from(s)
463}
464
465/// Creates an online backup of `src_pool` at `dest_path`.
466///
467/// Uses the SQLite online backup API so the source database remains open and
468/// WAL state is handled automatically.
469///
470/// The backup step loop is synchronous but runs directly in the async task
471/// rather than inside `spawn_blocking`. The locked handles guarantee that
472/// SQLx's background worker is not using these connections during the backup,
473/// and the borrow checker cannot move both a connection and its borrowed handle
474/// into a `spawn_blocking` closure.
475#[cfg_attr(feature = "tracing", tracing::instrument(skip(src_pool, dest_path)))]
476async fn online_backup(src_pool: &SqlitePool, dest_path: &Path) -> Result<(), Error> {
477    let dest_url = format!("sqlite://{}", dest_path.display());
478    let dest_options = SqliteConnectOptions::from_str(&dest_url)
479        .context("failed to parse destination database URL")?
480        .create_if_missing(true);
481    let mut dest_conn = SqliteConnection::connect_with(&dest_options)
482        .await
483        .context("failed to open destination database connection")?;
484    let mut src_conn = src_pool
485        .acquire()
486        .await
487        .context("failed to acquire source database connection")?;
488
489    let mut src_handle = src_conn
490        .lock_handle()
491        .await
492        .context("failed to lock source database handle")?;
493    let mut dest_handle = dest_conn
494        .lock_handle()
495        .await
496        .context("failed to lock destination database handle")?;
497
498    let src_ptr = src_handle.as_raw_handle().as_ptr();
499    let dest_ptr = dest_handle.as_raw_handle().as_ptr();
500
501    run_backup_steps(src_ptr, dest_ptr, dest_path)?;
502
503    Ok(())
504}
505
506/// Synchronous backup step loop using the SQLite C API.
507///
508/// # Safety
509///
510/// The caller must ensure `src` and `dest` are valid `sqlite3*` pointers and
511/// that the SQLite handles they belong to remain locked for the duration of
512/// this call.
513#[cfg_attr(feature = "tracing", tracing::instrument(skip(src, dest)))]
514fn run_backup_steps(
515    src: *mut libsqlite3_sys::sqlite3,
516    dest: *mut libsqlite3_sys::sqlite3,
517    dest_path: &Path,
518) -> Result<(), Error> {
519    let main_name = CString::new(MAIN_DB_NAME).expect("MAIN_DB_NAME is a valid C string");
520
521    let backup = unsafe {
522        libsqlite3_sys::sqlite3_backup_init(dest, main_name.as_ptr(), src, main_name.as_ptr())
523    };
524
525    if backup.is_null() {
526        let msg = unsafe { sqlite_error_message(dest) };
527        return Err(Error::msg(format!(
528            "failed to initialize backup to {}: {}",
529            dest_path.display(),
530            msg
531        )));
532    }
533
534    let max_retries: u32 = (30_000 / BACKUP_BUSY_SLEEP_MS) as u32;
535    let mut busy_retries: u32 = 0;
536    let mut rc: c_int;
537    let mut done = false;
538
539    while !done {
540        rc = unsafe { libsqlite3_sys::sqlite3_backup_step(backup, BACKUP_PAGE_COUNT) };
541
542        match rc {
543            SQLITE_OK => {}
544            SQLITE_DONE => {
545                done = true;
546            }
547            SQLITE_BUSY | SQLITE_LOCKED => {
548                busy_retries += 1;
549                if busy_retries > max_retries {
550                    unsafe {
551                        libsqlite3_sys::sqlite3_backup_finish(backup);
552                    }
553                    return Err(Error::msg(format!(
554                        "online backup timed out waiting for SQLite lock for {}",
555                        dest_path.display()
556                    )));
557                }
558                unsafe { libsqlite3_sys::sqlite3_sleep(BACKUP_BUSY_SLEEP_MS as c_int) };
559            }
560            _ => {
561                let msg = unsafe { sqlite_error_message(dest) };
562                unsafe {
563                    libsqlite3_sys::sqlite3_backup_finish(backup);
564                }
565                return Err(Error::msg(format!(
566                    "online backup failed for {}: {} (code {})",
567                    dest_path.display(),
568                    msg,
569                    rc
570                )));
571            }
572        }
573    }
574
575    let finish_rc = unsafe { libsqlite3_sys::sqlite3_backup_finish(backup) };
576    if finish_rc != SQLITE_OK {
577        let msg = unsafe { sqlite_error_message(dest) };
578        return Err(Error::msg(format!(
579            "online backup finish failed for {}: {} (code {})",
580            dest_path.display(),
581            msg,
582            finish_rc
583        )));
584    }
585
586    Ok(())
587}
588
589/// Returns the SQLite error message for a database handle.
590///
591/// # Safety
592///
593/// `db` must be a valid `sqlite3*` pointer.
594unsafe fn sqlite_error_message(db: *mut libsqlite3_sys::sqlite3) -> String {
595    let msg = unsafe { libsqlite3_sys::sqlite3_errmsg(db) };
596    if msg.is_null() {
597        return "unknown SQLite error".to_string();
598    }
599
600    unsafe { CStr::from_ptr(msg) }
601        .to_string_lossy()
602        .into_owned()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use crate::db::Database;
609    use crate::db::runtime::RUNTIME;
610    use std::str::FromStr;
611
612    #[test]
613    fn test_add_suffix() {
614        let path = PathBuf::from("/tmp/cadmus.sqlite");
615        assert_eq!(
616            add_suffix(&path, "-wal"),
617            PathBuf::from("/tmp/cadmus.sqlite-wal")
618        );
619    }
620
621    #[test]
622    fn test_create_backup_writes_file_and_manifest() {
623        let dir = tempfile::tempdir().expect("failed to create temp dir");
624        let db_path = dir.path().join("cadmus.sqlite");
625        let mut db = Database::new(&db_path).expect("failed to create database");
626        db.init(0).expect("failed to run migrations");
627
628        let version = GitVersion::from_str("v0.10.0").unwrap();
629        let manager = DbBackupManager::new(dir.path().to_path_buf(), version.clone());
630
631        let backup_path =
632            RUNTIME.block_on(async { manager.create_backup(db.pool(), 2).await.unwrap() });
633
634        assert!(backup_path.exists(), "backup file should exist");
635
636        let manifest = manager.read_manifest().expect("failed to read manifest");
637        assert_eq!(manifest.entries.len(), 1);
638        assert_eq!(manifest.entries[0].version, version);
639        assert_eq!(manifest.entries[0].migration_hash, current_migration_hash());
640        assert!(manifest.entries[0].file.contains("v0.10.0"));
641    }
642
643    #[test]
644    fn test_backup_retention_removes_oldest_backups() {
645        let dir = tempfile::tempdir().expect("failed to create temp dir");
646        let db_path = dir.path().join("cadmus.sqlite");
647        let mut db = Database::new(&db_path).expect("failed to create database");
648        db.init(0).expect("failed to run migrations");
649
650        let v1 = GitVersion::from_str("v0.9.0").unwrap();
651        let v2 = GitVersion::from_str("v0.10.0").unwrap();
652        let v3 = GitVersion::from_str("v0.11.0").unwrap();
653
654        RUNTIME.block_on(async {
655            let manager = DbBackupManager::new(dir.path().to_path_buf(), v1.clone());
656            manager.create_backup(db.pool(), 2).await.unwrap();
657
658            let manager = DbBackupManager::new(dir.path().to_path_buf(), v2.clone());
659            manager.create_backup(db.pool(), 2).await.unwrap();
660
661            let manager = DbBackupManager::new(dir.path().to_path_buf(), v3.clone());
662            manager.create_backup(db.pool(), 2).await.unwrap();
663        });
664
665        let manager = DbBackupManager::new(dir.path().to_path_buf(), v3.clone());
666        let manifest = manager.read_manifest().expect("failed to read manifest");
667        assert_eq!(manifest.entries.len(), 2);
668        assert!(
669            manifest.entries.iter().any(|e| e.version == v2),
670            "v2 should be retained"
671        );
672        assert!(
673            manifest.entries.iter().any(|e| e.version == v3),
674            "v3 (current) should be retained"
675        );
676    }
677
678    #[test]
679    fn test_restore_best_backup_replaces_active_database() {
680        let dir = tempfile::tempdir().expect("failed to create temp dir");
681        let db_path = dir.path().join("cadmus.sqlite");
682        let mut db = Database::new(&db_path).expect("failed to create database");
683        db.init(0).expect("failed to run migrations");
684
685        let older_version = GitVersion::from_str("v0.9.0").unwrap();
686        let newer_version = GitVersion::from_str("v0.10.0").unwrap();
687
688        let backup_path = RUNTIME.block_on(async {
689            let manager = DbBackupManager::new(dir.path().to_path_buf(), older_version.clone());
690            manager.create_backup(db.pool(), 2).await.unwrap()
691        });
692
693        // Simulate a newer database by stamping it and closing it.
694        let migration_hash = crate::db::version::current_migration_hash();
695        RUNTIME.block_on(async {
696            crate::db::version::stamp_db_version(db.pool(), &newer_version, &migration_hash)
697                .await
698                .unwrap();
699        });
700        db.close();
701
702        let restored = RUNTIME.block_on(async {
703            let manager = DbBackupManager::new(dir.path().to_path_buf(), older_version.clone());
704            manager
705                .restore_best_backup(&db_path, &newer_version)
706                .await
707                .expect("restore failed")
708        });
709        assert_eq!(restored, backup_path, "should restore the older backup");
710
711        // Reopen and verify the restored database does not have the newer version stamp.
712        let db = Database::new(&db_path).expect("failed to reopen database");
713        let stored_version = RUNTIME.block_on(async {
714            crate::db::version::read_db_version(db.pool())
715                .await
716                .unwrap()
717        });
718        assert_ne!(
719            stored_version.as_ref(),
720            Some(&newer_version),
721            "restored database should not retain the newer version stamp"
722        );
723    }
724
725    #[test]
726    fn test_online_backup_preserves_data() {
727        let src_dir = tempfile::Builder::new()
728            .prefix("cadmus-backup-src-")
729            .tempdir()
730            .expect("failed to create source temp dir");
731        let dest_dir = tempfile::Builder::new()
732            .prefix("cadmus-backup-dest-")
733            .tempdir()
734            .expect("failed to create dest temp dir");
735
736        let db_path = src_dir.path().join("cadmus.sqlite");
737        let mut db = Database::new(&db_path).expect("failed to create database");
738        db.init(0).expect("failed to run migrations");
739
740        let test_version = GitVersion::from_str("v1.2.3").unwrap();
741        let migration_hash = crate::db::version::current_migration_hash();
742
743        RUNTIME.block_on(async {
744            crate::db::version::stamp_db_version(db.pool(), &test_version, &migration_hash)
745                .await
746                .expect("failed to stamp test version");
747
748            let backup_path = dest_dir.path().join("backup.sqlite");
749            online_backup(db.pool(), &backup_path)
750                .await
751                .expect("online backup failed");
752
753            let backup_url = format!("sqlite://{}", backup_path.display());
754            let backup_pool = SqlitePool::connect(&backup_url)
755                .await
756                .expect("failed to open backup database");
757
758            let version = crate::db::version::read_db_version(&backup_pool)
759                .await
760                .expect("failed to query backup")
761                .expect("backup should have a version stamp");
762
763            assert_eq!(
764                version, test_version,
765                "backup should contain the stamped version"
766            );
767
768            backup_pool.close().await;
769        });
770    }
771
772    #[test]
773    fn test_find_best_backup_skips_missing_files() {
774        let dir = tempfile::tempdir().expect("failed to create temp dir");
775        let db_path = dir.path().join("cadmus.sqlite");
776        let mut db = Database::new(&db_path).expect("failed to create database");
777        db.init(0).expect("failed to run migrations");
778
779        let v090 = GitVersion::from_str("v0.9.0").unwrap();
780        let v095 = GitVersion::from_str("v0.9.5").unwrap();
781        let v100 = GitVersion::from_str("v0.10.0").unwrap();
782
783        RUNTIME.block_on(async {
784            let manager = DbBackupManager::new(dir.path().to_path_buf(), v090.clone());
785            manager.create_backup(db.pool(), 10).await.unwrap();
786
787            let manager = DbBackupManager::new(dir.path().to_path_buf(), v095.clone());
788            manager.create_backup(db.pool(), 10).await.unwrap();
789        });
790
791        // Manually delete the v0.9.5 backup file, leaving its manifest entry
792        // intact — this simulates a previously failed cleanup.
793        let stale_file = dir
794            .path()
795            .join("backups")
796            .join(format!("cadmus-{}.sqlite", v095));
797        std::fs::remove_file(&stale_file).expect("failed to remove stale backup file");
798
799        // find_best_backup called with v1.0.0 as the target should skip the
800        // stale v0.9.5 entry and return the valid v0.9.0 backup instead.
801        let manager = DbBackupManager::new(dir.path().to_path_buf(), v100.clone());
802        let best = manager
803            .find_best_backup(&v100)
804            .expect("find_best_backup failed")
805            .expect("should find a valid backup");
806        assert_eq!(
807            best.version, v090,
808            "should skip the stale v0.9.5 entry and return v0.9.0"
809        );
810    }
811
812    #[test]
813    fn test_find_best_backup_selects_closest_older_version() {
814        let dir = tempfile::tempdir().expect("failed to create temp dir");
815        let db_path = dir.path().join("cadmus.sqlite");
816        let mut db = Database::new(&db_path).expect("failed to create database");
817        db.init(0).expect("failed to run migrations");
818
819        let v090 = GitVersion::from_str("v0.9.0").unwrap();
820        let v095 = GitVersion::from_str("v0.9.5").unwrap();
821        let v100 = GitVersion::from_str("v0.10.0").unwrap();
822
823        RUNTIME.block_on(async {
824            let manager = DbBackupManager::new(dir.path().to_path_buf(), v090.clone());
825            manager.create_backup(db.pool(), 2).await.unwrap();
826
827            let manager = DbBackupManager::new(dir.path().to_path_buf(), v100.clone());
828            manager.create_backup(db.pool(), 2).await.unwrap();
829        });
830
831        let manager = DbBackupManager::new(dir.path().to_path_buf(), v095.clone());
832        let best = manager
833            .find_best_backup(&v095)
834            .expect("find_best_backup failed")
835            .expect("should find a backup");
836        assert_eq!(best.version, v090, "v0.9.0 is the closest older backup");
837    }
838}