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
13const BACKUP_DIR: &str = "backups";
15const MANIFEST_FILE: &str = ".cadmus-db-index.toml";
17const MAIN_DB_NAME: &str = "main";
19const BACKUP_PAGE_COUNT: c_int = 100;
21const BACKUP_BUSY_SLEEP_MS: u64 = 25;
23
24const SQLITE_OK: c_int = 0;
26const SQLITE_DONE: c_int = 101;
28const SQLITE_BUSY: c_int = 5;
30const SQLITE_LOCKED: c_int = 6;
32
33const SQLITE_COMPANION_SUFFIXES: [&str; 2] = ["-wal", "-shm"];
35
36#[derive(Debug, Default, Serialize, Deserialize)]
38pub struct BackupManifest {
39 #[serde(default)]
41 pub entries: Vec<BackupEntry>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct BackupEntry {
47 pub version: GitVersion,
49 pub file: String,
51 pub created_at: DateTime<Utc>,
53 pub migration_hash: MigrationHash,
55}
56
57#[derive(Debug, thiserror::Error)]
59pub enum RestoreError {
60 #[error("no backup found in manifest for version {version}")]
62 NoBackupFound { version: GitVersion },
63 #[error("backup file '{file}' referenced by manifest does not exist on disk")]
70 BackupFileMissing { file: String },
71 #[error(transparent)]
73 Io(#[from] Error),
74}
75
76#[derive(Clone)]
78pub struct DbBackupManager {
79 db_dir: PathBuf,
80 current_version: GitVersion,
81}
82
83impl DbBackupManager {
84 pub fn new(db_dir: PathBuf, current_version: GitVersion) -> Self {
89 Self {
90 db_dir,
91 current_version,
92 }
93 }
94
95 fn backup_dir(&self) -> PathBuf {
97 self.db_dir.join(BACKUP_DIR)
98 }
99
100 fn manifest_path(&self) -> PathBuf {
102 self.backup_dir().join(MANIFEST_FILE)
103 }
104
105 #[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 #[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 #[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 #[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 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 #[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#[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#[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#[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
458fn 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#[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#[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
589unsafe 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 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 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 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 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}