feat(backup): add scheduled database backups

This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-07-16 18:49:15 +08:00
1 parent cc518e8753
commit f08a0b7f7f
26 files changed
+2491 -137

No files matched your search

+10 -1
View File
@@ -3,12 +3,21 @@ use tauri::{AppHandle, Emitter, State};
use crate::commands::connection::AppState;
pub use dbx_core::database_export::{DatabaseExportRequest, ExportProgress, ExportStatus};
pub use dbx_core::database_export::{DatabaseBackupSnapshot, DatabaseExportRequest, ExportProgress, ExportStatus};
fn emit_progress(app: &AppHandle, progress: ExportProgress) {
let _ = app.emit("database-export-progress", progress);
}
#[tauri::command]
pub async fn begin_database_backup_snapshot(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
) -> Result<DatabaseBackupSnapshot, String> {
dbx_core::database_export::begin_database_backup_snapshot_core(&state, &connection_id, &database).await
}
#[tauri::command]
pub async fn export_database_sql(
app: AppHandle,
+48
View File
@@ -82,6 +82,43 @@ pub async fn is_sqlite_database_file(path: String) -> Result<bool, String> {
path_has_sqlite_header(&resolved)
}
fn validate_database_backup_file(raw: &str) -> Result<PathBuf, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("backup file path is empty".to_string());
}
let expanded = expand_tilde(trimmed);
let path = PathBuf::from(&expanded);
if !path.is_absolute() {
return Err(format!("backup file path is not absolute: {expanded}"));
}
if path.extension().and_then(|extension| extension.to_str()).map(|extension| extension.eq_ignore_ascii_case("sql"))
!= Some(true)
{
return Err(format!("backup file must use the .sql extension: {expanded}"));
}
let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
if !file_name.starts_with("dbx-backup__") {
return Err(format!("backup file name is not managed by DBX: {expanded}"));
}
Ok(path)
}
#[tauri::command]
pub async fn delete_database_backup_files(paths: Vec<String>) -> Result<usize, String> {
let resolved = paths.iter().map(|path| validate_database_backup_file(path)).collect::<Result<Vec<_>, _>>()?;
let mut deleted = 0;
for path in resolved {
match tokio::fs::remove_file(&path).await {
Ok(()) => deleted += 1,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("failed to delete backup file {}: {error}", path.display())),
}
}
Ok(deleted)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -127,6 +164,17 @@ mod tests {
assert_eq!(resolved, dir);
}
#[test]
fn database_backup_file_requires_absolute_sql_path() {
assert!(validate_database_backup_file("relative/backup.sql").is_err());
let invalid_extension = if cfg!(windows) { "C:/tmp/backup.txt" } else { "/tmp/backup.txt" };
assert!(validate_database_backup_file(invalid_extension).is_err());
let unmanaged = if cfg!(windows) { "C:/tmp/backup.sql" } else { "/tmp/backup.sql" };
assert!(validate_database_backup_file(unmanaged).is_err());
let valid = if cfg!(windows) { "C:/tmp/dbx-backup__nightly.SQL" } else { "/tmp/dbx-backup__nightly.SQL" };
assert!(validate_database_backup_file(valid).is_ok());
}
#[test]
fn sqlite_header_is_detected() {
let path = std::env::temp_dir().join(format!("dbx-sqlite-header-{}.conf", uuid::Uuid::new_v4()));