1use 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 Code(fn(&Connection) -> rusqlite::Result<()>),
29}
30
31pub struct Migration {
32 pub version: i64,
33 pub name: &'static str,
34 pub up: Step,
35 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
71pub enum VersionStorage {
72 #[default]
73 UserVersion,
74 Table { table: String, key: String },
77 VersionTable {
83 table: String,
84 version_column: String,
85 applied_at_column: String,
86 },
87}
88
89impl VersionStorage {
90 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#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct LegacyVersionTable {
104 pub table: String,
105 pub version_column: String,
106}
107
108pub type VerifyBackup = fn(&Path) -> std::result::Result<(), String>;
110#[derive(Clone)]
111pub struct MigrateOptions {
112 pub version_storage: VersionStorage,
114 pub backup: bool,
116 pub backup_options: Option<DbOptions>,
118 pub forward_compatible: Option<fn(&Connection) -> bool>,
121 pub optimize_after: bool,
123 pub legacy_versions: Option<LegacyVersionTable>,
125 pub backup_name: Option<fn(current: i64, target: i64) -> String>,
130 pub verify_backup: Option<VerifyBackup>,
133 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 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 pub forward_compatible: bool,
180 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 conn.execute_batch(&format!("PRAGMA user_version = {version};"))
198}
199
200pub 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
241fn 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 VersionStorage::VersionTable { .. } => Err(rusqlite::Error::InvalidQuery),
264 }
265}
266
267fn 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
298fn 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
320fn 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
342fn 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
368fn 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
385fn 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 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
426fn 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 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
506pub 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
520pub 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
534pub 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 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
647pub fn rollback_to(conn: &Connection, migrations: &[Migration], target: i64) -> Result<Vec<i64>> {
650 rollback_to_with_storage(conn, migrations, target, &VersionStorage::UserVersion)
651}
652
653pub 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
709pub 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
723pub 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#[derive(Debug)]
755pub struct OpenOrDegrade {
756 pub conn: Connection,
758 pub report: MigrationReport,
760 pub degraded: Option<Degraded>,
763}
764
765#[derive(Clone, Debug, PartialEq, Eq)]
767pub struct Degraded {
768 pub reason: String,
770 pub quarantined: Option<PathBuf>,
774 pub quarantine_incomplete: bool,
780 pub in_memory: bool,
782}
783
784impl DbOptions {
785 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 pub fn open_writer_migrated_or_degrade(
815 &self,
816 migrations: &[Migration],
817 options: &MigrateOptions,
818 policy: RecoveryPolicy,
819 ) -> Result<OpenOrDegrade> {
820 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 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 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
928fn 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}