feat(sqlite): add database backup action

* feat(sqlite): add database backup action

* fix(sqlite): sanitize default backup file name
This commit is contained in:
LC
2026-06-13 18:39:12 +08:00
committed by GitHub
parent 34dd18d46d
commit 2d98b9fdd2
17 changed files with 347 additions and 2 deletions
@@ -48,6 +48,7 @@ import {
Clipboard,
UsersRound,
Lock,
HardDriveDownload,
} from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -62,7 +63,7 @@ import { uuid } from "@/lib/utils";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
import { canTreeNodeShowExpander, treeItemPaddingLeft, usesFullWidthTreeLabel } from "@/lib/sidebarTreeItemLayout";
import { buildTableSelectSql } from "@/lib/tableSelectSql";
import { connectionFilePath } from "@/lib/connectionFile";
import { connectionFilePath, defaultSqliteBackupFileName, isMemorySqlitePath, sqliteBackupSourcePath } from "@/lib/connectionFile";
import { revealPathInFileManager } from "@/lib/tauri";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/queryEditorTableDrop";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
@@ -2138,6 +2139,44 @@ async function revealDatabaseFile() {
}
}
const sqliteBackupSource = computed<string | null>(() => {
if (props.node.type !== "connection" || !props.node.connectionId) return null;
const config = connectionStore.getConfig(props.node.connectionId);
if (!config) return null;
return sqliteBackupSourcePath(config);
});
const canBackupSqliteDatabase = computed(() => {
const source = sqliteBackupSource.value;
if (!source || !props.node.connectionId) return false;
return isTauriRuntime() && (!isMemorySqlitePath(source) || connectionStore.connectedIds.has(props.node.connectionId));
});
async function backupSqliteDatabase() {
const connId = props.node.connectionId;
const config = connId ? connectionStore.getConfig(connId) : undefined;
const sourcePath = sqliteBackupSource.value;
if (!connId || !config || !sourcePath) return;
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const destinationPath = await save({
defaultPath: defaultSqliteBackupFileName(config),
filters: [{ name: "SQLite", extensions: ["db", "sqlite", "sqlite3"] }],
});
if (!destinationPath) return;
toast(t("contextMenu.backupSqliteDatabaseInProgress"), 2000);
if (!isMemorySqlitePath(sourcePath)) {
await connectionStore.ensureConnected(connId);
}
await api.backupSqliteDatabase(connId, destinationPath);
toast(t("contextMenu.backupSqliteDatabaseSuccess"), 3000);
} catch (e: any) {
toast(t("contextMenu.backupSqliteDatabaseFailed", { message: e?.message || String(e) }), 5000);
}
}
async function disconnectConnection() {
if (props.node.connectionId) {
try {
@@ -2740,6 +2779,13 @@ function treeItemMenuItems(): ContextMenuItem[] {
icon: FolderOpen,
});
}
if (canBackupSqliteDatabase.value) {
items.push({
label: t("contextMenu.backupSqliteDatabase"),
action: backupSqliteDatabase,
icon: HardDriveDownload,
});
}
items.push({ label: t("contextMenu.duplicateConnection"), action: duplicateConnection, icon: CopyPlus });
items.push({ label: "", separator: true });
items.push({
+4
View File
@@ -1039,6 +1039,10 @@
exportDatabase: "Export Database",
exportDatabaseSuccess: "Exported {count} tables, up to {limit} rows each",
exportDatabaseFailed: "Failed to export database: {message}",
backupSqliteDatabase: "Backup SQLite Database",
backupSqliteDatabaseInProgress: "Backing up SQLite database...",
backupSqliteDatabaseSuccess: "SQLite database backed up",
backupSqliteDatabaseFailed: "Failed to backup SQLite database: {message}",
importData: "Import Data",
exportData: "Export Data",
exportStructure: "Export Structure",
+4
View File
@@ -893,6 +893,10 @@
exportDatabase: "Exportar base de datos",
exportDatabaseSuccess: "Se exportaron {count} tablas, hasta {limit} filas cada una",
exportDatabaseFailed: "Error al exportar la base de datos: {message}",
backupSqliteDatabase: "Backup SQLite Database",
backupSqliteDatabaseInProgress: "Backing up SQLite database...",
backupSqliteDatabaseSuccess: "SQLite database backed up",
backupSqliteDatabaseFailed: "Failed to backup SQLite database: {message}",
importData: "Importar datos",
exportData: "Exportar datos",
exportStructure: "Exportar estructura",
+4
View File
@@ -995,6 +995,10 @@
exportDatabase: "Esporta Database",
exportDatabaseSuccess: "Esportate {count} tabelle, fino a {limit} righe ciascuna",
exportDatabaseFailed: "Impossibile esportare il database: {message}",
backupSqliteDatabase: "Backup SQLite Database",
backupSqliteDatabaseInProgress: "Backing up SQLite database...",
backupSqliteDatabaseSuccess: "SQLite database backed up",
backupSqliteDatabaseFailed: "Failed to backup SQLite database: {message}",
importData: "Importa Dati",
exportData: "Esporta Dati",
exportStructure: "Esporta Struttura",
+4
View File
@@ -995,6 +995,10 @@
exportDatabase: "Exportar Banco de Dados",
exportDatabaseSuccess: "{count} tabelas exportadas, até {limit} linhas cada",
exportDatabaseFailed: "Falha ao exportar banco de dados: {message}",
backupSqliteDatabase: "Backup SQLite Database",
backupSqliteDatabaseInProgress: "Backing up SQLite database...",
backupSqliteDatabaseSuccess: "SQLite database backed up",
backupSqliteDatabaseFailed: "Failed to backup SQLite database: {message}",
importData: "Importar Dados",
exportData: "Exportar Dados",
exportStructure: "Exportar Estrutura",
+4
View File
@@ -1038,6 +1038,10 @@
exportDatabase: "导出数据库",
exportDatabaseSuccess: "已导出 {count} 张表,每表最多 {limit} 行",
exportDatabaseFailed: "导出数据库失败:{message}",
backupSqliteDatabase: "备份 SQLite 数据库",
backupSqliteDatabaseInProgress: "正在备份 SQLite 数据库...",
backupSqliteDatabaseSuccess: "SQLite 数据库已备份",
backupSqliteDatabaseFailed: "备份 SQLite 数据库失败:{message}",
importData: "导入数据",
exportData: "导出数据",
exportStructure: "导出表结构",
+4
View File
@@ -974,6 +974,10 @@
exportDatabase: "匯出資料庫",
exportDatabaseSuccess: "已匯出 {count} 張資料表,每張最多 {limit} 行",
exportDatabaseFailed: "匯出資料庫失敗:{message}",
backupSqliteDatabase: "備份 SQLite 資料庫",
backupSqliteDatabaseInProgress: "正在備份 SQLite 資料庫...",
backupSqliteDatabaseSuccess: "SQLite 資料庫已備份",
backupSqliteDatabaseFailed: "備份 SQLite 資料庫失敗:{message}",
importData: "匯入資料",
exportData: "匯出資料",
exportStructure: "匯出資料表結構",
+1
View File
@@ -97,6 +97,7 @@ export const deleteSavedSqlFile = forward("deleteSavedSqlFile");
export const savedSqlStorageDir = forward("savedSqlStorageDir");
export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir");
export const revealPathInFileManager = forward("revealPathInFileManager");
export const backupSqliteDatabase = forward("backupSqliteDatabase");
export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
// Schema
+26
View File
@@ -48,3 +48,29 @@ export function connectionFilePath(config: Pick<ConnectionConfig, "db_type" | "h
export function isLocalFileDb(config: Pick<ConnectionConfig, "db_type" | "host" | "connection_string">): boolean {
return connectionFilePath(config) !== null;
}
export function isMemorySqlitePath(path: string | undefined | null): boolean {
return (path ?? "").trim().toLowerCase() === ":memory:";
}
export function sqliteBackupSourcePath(config: Pick<ConnectionConfig, "db_type" | "host">): string | null {
if (config.db_type !== "sqlite") return null;
const host = (config.host ?? "").trim();
return host || null;
}
export function defaultSqliteBackupFileName(config: Pick<ConnectionConfig, "host" | "name">): string {
const source = (config.host || config.name || "database").trim();
const rawFileName = isMemorySqlitePath(source) ? "memory.db" : source.split(/[\\/]/).filter(Boolean).pop() || "database.db";
const fileName = sanitizeFileName(rawFileName) || "database.db";
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex <= 0) return `${fileName}.backup.db`;
return `${fileName.slice(0, dotIndex)}.backup${fileName.slice(dotIndex)}`;
}
function sanitizeFileName(fileName: string): string {
return fileName
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.trim()
.replace(/[. ]+$/g, "");
}
+4
View File
@@ -376,6 +376,10 @@ export async function revealPathInFileManager(_path: string): Promise<void> {
throw new Error("Reveal in file manager is only available in the desktop app.");
}
export async function backupSqliteDatabase(_connectionId: string, _destinationPath: string): Promise<void> {
throw new Error("SQLite backup is only available in the desktop app.");
}
export async function syncSavedSqlDirectory(_request: SavedSqlSyncRequest): Promise<void> {
throw new Error("SQL directory sync is only available in the desktop app.");
}
+4
View File
@@ -999,6 +999,10 @@ export async function revealPathInFileManager(path: string): Promise<void> {
return invoke("reveal_path_in_file_manager", { path });
}
export async function backupSqliteDatabase(connectionId: string, destinationPath: string): Promise<void> {
return invoke("backup_sqlite_database", { connectionId, destinationPath });
}
export async function syncSavedSqlDirectory(request: SavedSqlSyncRequest): Promise<void> {
return invoke("sync_saved_sql_directory", { request });
}
+1 -1
View File
@@ -26,7 +26,7 @@ tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1"
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
tokio-postgres-rustls = "0.13"
webpki-roots = "0.26"
rusqlite = { version = "0.32", features = ["bundled", "load_extension"] }
rusqlite = { version = "0.32", features = ["bundled", "load_extension", "backup"] }
mysql_async = { version = "0.36", default-features = false, features = ["default-rustls", "client_ed25519", "chrono", "rust_decimal"] }
sqlparser = "0.62.0"
redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tls-rustls-insecure", "tokio-rustls-comp", "sentinel", "cluster-async"] }
+1
View File
@@ -42,6 +42,7 @@ pub mod sql_dialect;
pub mod sql_editability;
pub mod sql_file_import;
pub mod sql_risk;
pub mod sqlite_backup;
pub mod storage;
pub mod table_export;
pub mod table_import;
+188
View File
@@ -0,0 +1,188 @@
use std::path::{Path, PathBuf};
use rusqlite::DatabaseName;
use crate::db::sqlite::{is_memory_database_path, SqliteHandle};
use crate::path_utils::expand_tilde;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqliteBackupOptions {
pub source_path: String,
pub destination_path: String,
}
pub async fn backup_sqlite_database(pool: SqliteHandle, options: SqliteBackupOptions) -> Result<(), String> {
let destination = prepare_destination_path(&options)?;
let temp = backup_temp_path(&destination);
tokio::task::spawn_blocking(move || {
pool.with_connection(|conn| {
conn.backup(DatabaseName::Main, &temp, None).map_err(|e| format!("SQLite backup failed: {e}"))
})?;
replace_backup_file(&temp, &destination)
})
.await
.map_err(|e| e.to_string())?
}
fn prepare_destination_path(options: &SqliteBackupOptions) -> Result<PathBuf, String> {
let destination = normalize_user_path(&options.destination_path, "Destination path is empty")?;
ensure_no_nul(&options.destination_path)?;
if let Some(parent) = destination.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
}
if destination.is_dir() {
return Err("Backup destination must be a file".to_string());
}
let source = options.source_path.trim();
if !is_memory_database_path(source) && !source.is_empty() {
ensure_no_nul(source)?;
let source = normalize_user_path(source, "Source path is empty")?;
if paths_match(&source, &destination)? {
return Err("Backup destination must be different from the source database file".to_string());
}
}
Ok(destination)
}
fn backup_temp_path(destination: &Path) -> PathBuf {
let file_name = destination.file_name().and_then(|name| name.to_str()).unwrap_or("backup.db");
destination.with_file_name(format!("{file_name}.tmp-{}", uuid::Uuid::new_v4()))
}
fn replace_backup_file(temp: &Path, destination: &Path) -> Result<(), String> {
if destination.exists() {
let backup = existing_backup_path(destination);
std::fs::rename(destination, &backup)
.map_err(|e| format!("Failed to back up existing destination file: {e}"))?;
match std::fs::rename(temp, destination) {
Ok(()) => {
std::fs::remove_file(&backup).ok();
Ok(())
}
Err(err) => {
let _ = std::fs::rename(&backup, destination);
Err(format!("Failed to replace backup destination: {err}"))
}
}
} else {
std::fs::rename(temp, destination).map_err(|e| format!("Failed to move backup into place: {e}"))
}
}
fn existing_backup_path(destination: &Path) -> PathBuf {
let file_name = destination.file_name().and_then(|name| name.to_str()).unwrap_or("backup.db");
destination.with_file_name(format!("{file_name}.backup-{}", uuid::Uuid::new_v4()))
}
fn normalize_user_path(path: &str, empty_message: &str) -> Result<PathBuf, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(empty_message.to_string());
}
Ok(PathBuf::from(expand_tilde(trimmed)))
}
fn ensure_no_nul(path: &str) -> Result<(), String> {
if path.contains('\0') {
Err("Path contains an invalid NUL byte".to_string())
} else {
Ok(())
}
}
fn paths_match(a: &Path, b: &Path) -> Result<bool, String> {
Ok(canonicalize_existing_or_parent(a)? == canonicalize_existing_or_parent(b)?)
}
fn canonicalize_existing_or_parent(path: &Path) -> Result<PathBuf, String> {
if let Ok(canonical) = std::fs::canonicalize(path) {
return Ok(canonical);
}
let parent = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
let parent = std::fs::canonicalize(parent).map_err(|e| e.to_string())?;
Ok(parent.join(path.file_name().ok_or_else(|| "Path is missing a file name".to_string())?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::sqlite::{connect_path, execute_query};
#[tokio::test]
async fn backup_sqlite_database_copies_memory_database() {
let dir = std::env::temp_dir().join(format!("dbx-sqlite-backup-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let destination = dir.join("backup.db");
let pool = connect_path(":memory:").await.expect("connect memory sqlite");
execute_query(&pool, "CREATE TABLE users (name TEXT); INSERT INTO users VALUES ('Ada');")
.await
.expect("seed memory database");
backup_sqlite_database(
pool,
SqliteBackupOptions {
source_path: ":memory:".to_string(),
destination_path: destination.to_string_lossy().to_string(),
},
)
.await
.expect("backup memory database");
let backup = connect_path(destination.to_string_lossy().as_ref()).await.expect("open backup");
let result = execute_query(&backup, "SELECT name FROM users").await.expect("query backup");
assert_eq!(result.rows[0][0], serde_json::json!("Ada"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn prepare_destination_rejects_same_file() {
let dir = std::env::temp_dir().join(format!("dbx-sqlite-backup-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let source = dir.join("source.db");
std::fs::write(&source, "").expect("create source");
let result = prepare_destination_path(&SqliteBackupOptions {
source_path: source.to_string_lossy().to_string(),
destination_path: source.to_string_lossy().to_string(),
});
assert!(result.expect_err("same file should be rejected").contains("different"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn prepare_destination_creates_missing_parent_dir() {
let dir = std::env::temp_dir().join(format!("dbx-sqlite-backup-{}", uuid::Uuid::new_v4()));
let destination = dir.join("nested").join("backup.db");
let result = prepare_destination_path(&SqliteBackupOptions {
source_path: ":memory:".to_string(),
destination_path: destination.to_string_lossy().to_string(),
})
.expect("prepare destination");
assert_eq!(result, destination);
assert!(destination.parent().expect("parent").is_dir());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replace_backup_file_restores_existing_destination_when_replace_fails() {
let dir = std::env::temp_dir().join(format!("dbx-sqlite-backup-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let destination = dir.join("backup.db");
let missing_temp = dir.join("missing.db");
std::fs::write(&destination, "existing").expect("create destination");
let result = replace_backup_file(&missing_temp, &destination);
assert!(result.expect_err("replace should fail").contains("replace"));
assert_eq!(std::fs::read_to_string(&destination).expect("read destination"), "existing");
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -27,6 +27,7 @@ pub mod schema;
pub mod schema_cache;
pub mod schema_diff;
pub mod sql_file;
pub mod sqlite_backup;
pub mod system_fonts;
pub mod tab_runtime_cache;
pub mod table_export;
+49
View File
@@ -0,0 +1,49 @@
use std::sync::Arc;
use tauri::State;
use dbx_core::connection::{AppState, PoolKind};
use dbx_core::db::sqlite::{is_memory_database_path, SqliteHandle};
use dbx_core::models::connection::DatabaseType;
use dbx_core::sqlite_backup::{backup_sqlite_database as backup_sqlite_database_core, SqliteBackupOptions};
#[tauri::command]
pub async fn backup_sqlite_database(
state: State<'_, Arc<AppState>>,
connection_id: String,
destination_path: String,
) -> Result<(), String> {
let source_path = sqlite_source_path(&state, &connection_id).await?;
let pool = if is_memory_database_path(&source_path) {
existing_sqlite_pool(&state, &connection_id)
.await?
.ok_or_else(|| "Open the in-memory SQLite connection before backing it up".to_string())?
} else {
let pool_key = state.get_or_create_pool(&connection_id, None).await?;
existing_sqlite_pool(&state, &pool_key).await?.ok_or_else(|| "Connection is not open".to_string())?
};
backup_sqlite_database_core(pool, SqliteBackupOptions { source_path, destination_path }).await
}
async fn sqlite_source_path(state: &Arc<AppState>, connection_id: &str) -> Result<String, String> {
let configs = state.configs.read().await;
let config = configs.get(connection_id).ok_or_else(|| format!("Connection config not found: {connection_id}"))?;
if config.db_type != DatabaseType::Sqlite {
return Err("SQLite backup is only available for SQLite connections".to_string());
}
let source = config.host.trim();
if source.is_empty() {
return Err("SQLite database path is empty".to_string());
}
Ok(source.to_string())
}
async fn existing_sqlite_pool(state: &Arc<AppState>, pool_key: &str) -> Result<Option<SqliteHandle>, String> {
let connections = state.connections.read().await;
match connections.get(pool_key) {
Some(PoolKind::Sqlite(pool)) => Ok(Some(pool.clone())),
Some(_) => Err("SQLite backup is only available for SQLite connections".to_string()),
None => Ok(None),
}
}
+1
View File
@@ -497,6 +497,7 @@ pub fn run() {
commands::saved_sql::open_saved_sql_storage_dir,
commands::saved_sql::sync_saved_sql_directory,
commands::fs_open::reveal_path_in_file_manager,
commands::sqlite_backup::backup_sqlite_database,
commands::mongo_cmd::mongo_list_databases,
commands::mongo_cmd::mongo_list_collections,
commands::mongo_cmd::document_find_documents,