diff --git a/.gitignore b/.gitignore
index e61e7a7e..6357faf9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,9 @@ dist-ssr
# Test coverage
coverage/
+
+# TypeScript build info
+*.tsbuildinfo
+
+# Agent runtime data
+.omo/
diff --git a/.impeccable.md b/.impeccable.md
new file mode 100644
index 00000000..072a1103
--- /dev/null
+++ b/.impeccable.md
@@ -0,0 +1,20 @@
+## Design Context
+
+### Users
+**DBAs and ops engineers** managing production database environments. They work under pressure during incidents, late-night maintenance windows, and routine backup/restore operations. Context: dark office, multiple monitors, monitoring dashboards open, Slack/PD alerts firing. They need speed, precision, and trust — the tool must feel reliable enough for `pg_dump`-level confidence.
+
+### Brand Personality
+**Precise. Powerful. Clean.** The tool should feel like a high-end instrument — surgical, not decorative. Every pixel has intent. The interface communicates capability through density and clarity, not through ornamentation.
+
+### Aesthetic Direction
+- **Visual tone**: Industrial/utilitarian control panel — think Grafana meets a high-end mixing console. Dark-first (DBAs work at night). Steel blue-gray palette with teal/amber accents.
+- **Theme**: Dark mode as primary, light mode secondary
+- **Key differentiation**: The transfer module should feel like a **control room** — not a form wizard. Source and destination visible simultaneously. Job monitoring persistent. Configuration feels like setting up a pipeline, not filling out paperwork.
+- **Anti-references**: No border-left accent stripes, no glassmorphism, no gradient text, no card-within-card nesting, no hero metrics (big numbers with tiny labels)
+
+### Design Principles
+1. **Surgical precision** — Every control is exactly where it needs to be. No hunting for options. Proximity groups related things.
+2. **At-a-glance awareness** — Source, destination, and status visible simultaneously without scrolling or clicking through steps.
+3. **Console not wizard** — DBAs don't need step-by-step handholding. They need configuration panels where all options are visible.
+4. **Trust through transparency** — Show what will happen before it happens. Preview data, show row counts, expose SQL.
+5. **Monitor without noise** — Job progress is always visible at the bottom but never interrupts flow.
diff --git a/AGENTS.md b/AGENTS.md
index a5ab7e76..913c79f6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -228,6 +228,30 @@ git commit -m "feat: description of changes"
git push
```
+## Transfer Module
+
+The transfer module spans frontend and backend with a **console → wizard → background job** architecture:
+
+**Frontend** (`src/components/transfer/`):
+- `TransferPage.vue` - Console-style main page with context bar + operation panel + activity bar
+- `components/transfer/launcher/` - Quick operation launcher (action picker, source/target picker, options)
+- `components/transfer/export/`, `import/`, `migration/`, `structure/` - Detailed step wizards (4-step: source → config → preview → execute)
+- `components/transfer/shared/` - Shared selectors (ConnectionSelector, TableSelector, ColumnSelector, etc.)
+- `components/transfer/tasks/` - Task manager panel/cards
+- `store/transferStore.ts` - Pinia store (jobs, profiles, progress, event subscriptions)
+- `types/transfer.ts` - All transfer types (ExportRequest, ImportRequest, MigrationRequest, TransferJob, etc.)
+- `datasources/transferApi.ts` - Tauri invoke wrappers (16+ commands)
+
+**Backend** (`src-tauri/src/transfer/`):
+- `commands/transfer.rs` - 16 Tauri commands (preview/execute export/import/migration, backup/restore, DDL gen, profiles)
+- `transfer/export.rs` - CSV/JSONL/SQL/Excel export with pagination
+- `transfer/import.rs` - CSV/JSONL/SQL/Excel import with batch inserts
+- `transfer/migration.rs` - Cross-engine migration with type mapping (16 source×target combinations)
+- `transfer/restore.rs` - SQL/CSV/Excel restore
+- `transfer/types.rs` - Rust types mirroring frontend types
+
+**Pattern**: Commands dispatch by `ActiveConnection` variant (Postgres/MySQL/SQLServer/SQLite). Progress emitted via Tauri events (`transfer://progress/{jobId}`). Profiles persisted via `profile_store.rs`.
+
## Common Tasks
**Adding a Tauri Command**:
diff --git a/index.html b/index.html
index a029359a..98d4e69b 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,9 @@
SqlKit
+
+
+
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 0e8e1344..2944413f 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -525,17 +525,17 @@ dependencies = [
[[package]]
name = "calamine"
-version = "0.22.1"
+version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe0ba51a659bb6c8bffd6f7c1c5ffafcafa0c97e4769411d841c3cc5c154ab47"
+checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1"
dependencies = [
"byteorder",
"codepage",
"encoding_rs",
"log",
- "quick-xml 0.30.0",
+ "quick-xml 0.31.0",
"serde",
- "zip 0.6.6",
+ "zip 2.4.2",
]
[[package]]
@@ -965,6 +965,27 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "ctor"
version = "0.2.9"
@@ -4013,9 +4034,9 @@ dependencies = [
[[package]]
name = "quick-xml"
-version = "0.30.0"
+version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
+checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [
"encoding_rs",
"memchr",
@@ -4558,6 +4579,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -5066,6 +5093,7 @@ dependencies = [
"base64 0.22.1",
"calamine",
"chrono",
+ "csv",
"deadpool-postgres",
"hex",
"mysql_async",
@@ -5084,6 +5112,7 @@ dependencies = [
"tauri-plugin-os",
"tauri-plugin-store",
"tauri-plugin-updater",
+ "tempfile",
"thiserror 1.0.69",
"tiberius",
"tokio",
@@ -7539,6 +7568,23 @@ dependencies = [
"flate2",
]
+[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "crossbeam-utils",
+ "displaydoc",
+ "flate2",
+ "indexmap 2.13.0",
+ "memchr",
+ "thiserror 2.0.18",
+ "zopfli",
+]
+
[[package]]
name = "zip"
version = "4.6.1"
@@ -7557,6 +7603,18 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
[[package]]
name = "zstd"
version = "0.13.3"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 3881abfb..2923cfee 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -63,4 +63,8 @@ chrono = "0.4"
rust_decimal = { version = "1", features = [ "db-postgres" ] }
hex = "0.4"
rust_xlsxwriter = "0.64"
-calamine = "0.22"
+calamine = "0.26"
+csv = "1.3"
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/src-tauri/src/commands/converter.rs b/src-tauri/src/commands/converter.rs
index a930ba40..b90b368f 100644
--- a/src-tauri/src/commands/converter.rs
+++ b/src-tauri/src/commands/converter.rs
@@ -170,9 +170,9 @@ mod tests {
#[test]
fn test_convert_query_value_float() {
- let value = QueryValue::Float(3.14);
+ let value = QueryValue::Float(std::f64::consts::PI);
let json = convert_query_value_to_json(&value);
- assert_eq!(json, json!(3.14));
+ assert_eq!(json, json!(std::f64::consts::PI));
}
#[test]
diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs
index 7f2c7262..65103ca4 100644
--- a/src-tauri/src/commands/transfer.rs
+++ b/src-tauri/src/commands/transfer.rs
@@ -3,11 +3,12 @@ use crate::state::{ActiveConnection, AppState};
use crate::transfer::{
auto_map_columns, detect_file, execute_export, execute_import, execute_migration,
generate_ddl_for_engine, load_profiles, preview_export, preview_import, preview_migration,
- save_profiles, DdlRequest, ExportFormat, ExportPreview, ExportRequest, ExportSource,
- FileDetectionResult, ImportFormat, ImportRequest, JobEventPayload, JobProgress,
- MigrationPreview, MigrationRequest, MigrationTablePlan, ObjectSelection, TransferError,
- TransferJob, TransferJobStatus, TransferProfile, TransferProfileKind, TransferResult,
- TransferScope,
+ restore_csv_file_with_progress, restore_sql_file_with_progress,
+ restore_xlsx_file_with_progress, save_profiles, DdlRequest, ExportFormat, ExportPreview,
+ ExportRequest, ExportSource, FileDetectionResult, ImportFormat, ImportRequest, JobEventPayload,
+ JobProgress, MigrationPreview, MigrationRequest, MigrationTablePlan, ObjectSelection,
+ RestoreOptions, RestoreStats, TransferError, TransferJob, TransferJobStatus, TransferProfile,
+ TransferProfileKind, TransferResult, TransferScope,
};
use chrono::Utc;
use serde_json::Value as JsonValue;
@@ -20,6 +21,13 @@ use tauri::{AppHandle, Emitter, State};
use tokio::sync::Mutex;
use uuid::Uuid;
+const MYSQL_SYSTEM_DATABASES: &[&str] =
+ &["mysql", "information_schema", "performance_schema", "sys"];
+// PostgreSQL: template databases are not openable and should always be skipped.
+// Keep `postgres` available because users may intentionally store data there.
+const POSTGRES_SYSTEM_DATABASES: &[&str] = &["template0", "template1"];
+const SQLSERVER_SYSTEM_DATABASES: &[&str] = &["master", "msdb", "tempdb", "model"];
+
#[tauri::command]
pub async fn preview_export_data(
request: ExportRequest,
@@ -610,46 +618,88 @@ async fn get_connection(
async fn list_databases_for_connection(
connection_id: &str,
connections: &Arc>>,
+ exclude_system_databases: bool,
) -> Result, String> {
let connection = get_connection(connection_id, connections).await?;
- let databases = match connection {
+ let (db_type, databases) = match connection {
ActiveConnection::Postgres(adapter) => {
let adapter = adapter.lock().await;
- adapter
- .list_databases()
- .await
- .map_err(|e| e.to_string())?
- .into_iter()
- .map(|db| db.name)
- .collect::>()
+ (
+ Some(DatabaseType::PostgreSQL),
+ adapter
+ .list_databases()
+ .await
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .map(|db| db.name)
+ .collect::>(),
+ )
}
ActiveConnection::MySQL(adapter) => {
let adapter = adapter.lock().await;
- adapter
- .list_databases()
- .await
- .map_err(|e| e.to_string())?
- .into_iter()
- .map(|db| db.name)
- .collect::>()
+ (
+ Some(DatabaseType::MySQL),
+ adapter
+ .list_databases()
+ .await
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .map(|db| db.name)
+ .collect::>(),
+ )
}
ActiveConnection::SQLServer(adapter) => {
let adapter = adapter.lock().await;
- adapter
- .list_databases()
- .await
- .map_err(|e| e.to_string())?
- .into_iter()
- .map(|db| db.name)
- .collect::>()
+ (
+ Some(DatabaseType::SqlServer),
+ adapter
+ .list_databases()
+ .await
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .map(|db| db.name)
+ .collect::>(),
+ )
}
- ActiveConnection::SQLite(_) => vec!["main".to_string()],
+ ActiveConnection::SQLite(_) => (None, vec!["main".to_string()]),
+ };
+
+ let databases = if exclude_system_databases {
+ filter_system_databases_for_whole_server(db_type, databases)
+ } else {
+ databases
};
Ok(databases)
}
+fn should_exclude_system_database(db_type: DatabaseType, database: &str) -> bool {
+ let deny_list = match db_type {
+ DatabaseType::MySQL => MYSQL_SYSTEM_DATABASES,
+ DatabaseType::PostgreSQL => POSTGRES_SYSTEM_DATABASES,
+ DatabaseType::SqlServer => SQLSERVER_SYSTEM_DATABASES,
+ _ => &[],
+ };
+
+ deny_list
+ .iter()
+ .any(|system_db| database.eq_ignore_ascii_case(system_db))
+}
+
+fn filter_system_databases_for_whole_server(
+ db_type: Option,
+ databases: Vec,
+) -> Vec {
+ match db_type {
+ Some(database_type) => databases
+ .into_iter()
+ .filter(|database| !should_exclude_system_database(database_type, database))
+ .collect::>(),
+ None => databases,
+ }
+}
+
async fn list_schemas_for_database(
connection_id: &str,
database: &str,
@@ -750,7 +800,8 @@ async fn expand_selection(
databases_to_expand = selection.schemas.keys().cloned().collect();
}
if databases_to_expand.is_empty() {
- databases_to_expand = list_databases_for_connection(connection_id, connections).await?;
+ databases_to_expand =
+ list_databases_for_connection(connection_id, connections, true).await?;
}
for database in databases_to_expand {
@@ -985,6 +1036,8 @@ async fn ensure_target_database(
match connection {
ActiveConnection::MySQL(adapter) => {
let adapter = adapter.lock().await;
+ // MySQL uses CREATE DATABASE IF NOT EXISTS and continues with the same
+ // server connection, so an additional per-database probe is unnecessary here.
adapter
.execute_query(&format!(
"CREATE DATABASE IF NOT EXISTS {}",
@@ -997,8 +1050,10 @@ async fn ensure_target_database(
ActiveConnection::Postgres(adapter) => {
let adapter = adapter.lock().await;
let mut system_config = adapter.config.clone();
+ let mut target_config = adapter.config.clone();
drop(adapter);
system_config.database = Some("postgres".to_string());
+ target_config.database = Some(database_name.to_string());
let mut system_adapter = PostgresAdapter::new(system_config);
system_adapter
@@ -1020,19 +1075,24 @@ async fn ensure_target_database(
))
.await
.map_err(|e| e.to_string())?;
-
- let mut target_config = system_adapter.get_config().clone();
- target_config.database = Some(database_name.to_string());
- let mut target_adapter = PostgresAdapter::new(target_config);
- target_adapter
- .connect()
- .await
- .map_err(|e| format!("Failed to connect to '{}': {}", database_name, e))?;
}
+
+ let mut target_adapter = PostgresAdapter::new(target_config);
+ target_adapter
+ .connect()
+ .await
+ .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?;
+ target_adapter
+ .execute_query("SELECT 1")
+ .await
+ .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?;
+
Ok(())
}
ActiveConnection::SQLServer(adapter) => {
let adapter = adapter.lock().await;
+ let mut target_config = adapter.config.clone();
+ target_config.database = Some(database_name.to_string());
let exists = adapter
.list_databases()
.await
@@ -1048,12 +1108,32 @@ async fn ensure_target_database(
.await
.map_err(|e| e.to_string())?;
}
+
+ drop(adapter);
+
+ let mut target_adapter = SqlServerAdapter::new(target_config);
+ target_adapter
+ .connect()
+ .await
+ .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?;
+ target_adapter
+ .execute_query("SELECT 1")
+ .await
+ .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?;
+
Ok(())
}
_ => Ok(()),
}
}
+fn target_database_inaccessible_error(database_name: &str, error: &str) -> String {
+ format!(
+ "Target database '{}' is inaccessible: {}",
+ database_name, error
+ )
+}
+
async fn execute_migration_request(
request: MigrationRequest,
source_database: Option<&str>,
@@ -1551,7 +1631,381 @@ fn summarize_migration_outcome(
}
}
+fn summarize_backup_outcome(
+ succeeded_tables: u64,
+ failed_tables: u64,
+ failures: &[String],
+) -> (TransferJobStatus, Option) {
+ let summary = if failures.is_empty() {
+ None
+ } else {
+ Some(format!(
+ "Backup summary: {} succeeded, {} failed [{}]",
+ succeeded_tables,
+ failed_tables,
+ failures.join("; ")
+ ))
+ };
+
+ if succeeded_tables > 0 {
+ (TransferJobStatus::Completed, summary)
+ } else {
+ (
+ TransferJobStatus::Failed,
+ summary.or_else(|| Some("Backup failed".to_string())),
+ )
+ }
+}
+
+fn summarize_restore_outcome(stats: &RestoreStats) -> (TransferJobStatus, Option) {
+ let failed_units = stats
+ .statements_total
+ .saturating_sub(stats.statements_succeeded);
+
+ let summary = if stats.errors.is_empty() {
+ None
+ } else {
+ Some(format!(
+ "Restore summary: {} succeeded, {} failed [{}]",
+ stats.statements_succeeded,
+ failed_units,
+ stats.errors.join("; ")
+ ))
+ };
+
+ if stats.statements_succeeded > 0 {
+ (TransferJobStatus::Completed, summary)
+ } else {
+ (
+ TransferJobStatus::Failed,
+ summary.or_else(|| Some("Restore failed".to_string())),
+ )
+ }
+}
+
+async fn restore_with_connection(
+ connection: ActiveConnection,
+ target_database: Option,
+ file_path: String,
+ file_format: String,
+ target_table: Option,
+ drop_target_first: bool,
+ on_progress: impl FnMut(u64, u64),
+) -> Result {
+ let format = file_format.to_lowercase();
+ let options = RestoreOptions::default();
+
+ fn split_target_table(target_table: &str) -> (Option, String) {
+ target_table
+ .split_once('.')
+ .map(|(schema, table)| (Some(schema.to_string()), table.to_string()))
+ .unwrap_or_else(|| (None, target_table.to_string()))
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ async fn run_restore(
+ adapter: &A,
+ target_database: Option<&str>,
+ file_path: &str,
+ format: &str,
+ target_table: Option<&str>,
+ _drop_target_first: bool,
+ options: &RestoreOptions,
+ mut on_progress: impl FnMut(u64, u64),
+ ) -> Result {
+ if let Some(database) = target_database {
+ match adapter.get_config().db_type {
+ DatabaseType::MySQL => {
+ adapter
+ .execute_query(&format!("USE `{}`", database.replace('`', "``")))
+ .await
+ .map_err(|e| e.to_string())?;
+ }
+ DatabaseType::SqlServer => {
+ adapter
+ .execute_query(&format!("USE [{}]", database.replace(']', "]]")))
+ .await
+ .map_err(|e| e.to_string())?;
+ }
+ _ => {}
+ }
+ }
+
+ match format {
+ "sql" => {
+ restore_sql_file_with_progress(adapter, file_path, options, |current, total| {
+ on_progress(current, total)
+ })
+ .await
+ .map_err(|e| e.to_string())
+ }
+ "csv" => {
+ let table = target_table
+ .ok_or_else(|| "targetTable is required for csv restore format".to_string())?;
+
+ let (target_schema, target_table_name) = split_target_table(table);
+
+ restore_csv_file_with_progress(
+ adapter,
+ file_path,
+ target_schema.as_deref(),
+ &target_table_name,
+ options,
+ &mut on_progress,
+ )
+ .await
+ .map_err(|e| e.to_string())
+ }
+ "xlsx" | "excel" => {
+ let table = target_table
+ .ok_or_else(|| "targetTable is required for xlsx restore format".to_string())?;
+
+ let (target_schema, target_table_name) = split_target_table(table);
+
+ restore_xlsx_file_with_progress(
+ adapter,
+ file_path,
+ target_schema.as_deref(),
+ &target_table_name,
+ options,
+ &mut on_progress,
+ )
+ .await
+ .map_err(|e| e.to_string())
+ }
+ _ => Err(format!(
+ "Unsupported restore file format '{}'. Use sql, csv, xlsx, or excel.",
+ format
+ )),
+ }
+ }
+
+ match connection {
+ ActiveConnection::Postgres(adapter) => {
+ let adapter = adapter.lock().await;
+ if let Some(database) = target_database.as_deref() {
+ if Some(database) != adapter.config.database.as_deref() {
+ let mut temp_config = adapter.config.clone();
+ drop(adapter);
+ temp_config.database = Some(database.to_string());
+ let mut temp = PostgresAdapter::new(temp_config);
+ temp.connect()
+ .await
+ .map_err(|e| format!("Failed to connect to '{}': {}", database, e))?;
+ return run_restore(
+ &temp,
+ None,
+ &file_path,
+ &format,
+ target_table.as_deref(),
+ drop_target_first,
+ &options,
+ on_progress,
+ )
+ .await;
+ }
+ }
+
+ run_restore(
+ &*adapter,
+ None,
+ &file_path,
+ &format,
+ target_table.as_deref(),
+ drop_target_first,
+ &options,
+ on_progress,
+ )
+ .await
+ }
+ ActiveConnection::MySQL(adapter) => {
+ let adapter = adapter.lock().await;
+ run_restore(
+ &*adapter,
+ target_database.as_deref(),
+ &file_path,
+ &format,
+ target_table.as_deref(),
+ drop_target_first,
+ &options,
+ on_progress,
+ )
+ .await
+ }
+ ActiveConnection::SQLServer(adapter) => {
+ let adapter = adapter.lock().await;
+ if let Some(database) = target_database.as_deref() {
+ if Some(database) != adapter.config.database.as_deref() {
+ let mut temp_config = adapter.config.clone();
+ drop(adapter);
+ temp_config.database = Some(database.to_string());
+ let mut temp = SqlServerAdapter::new(temp_config);
+ temp.connect()
+ .await
+ .map_err(|e| format!("Failed to connect to '{}': {}", database, e))?;
+ return run_restore(
+ &temp,
+ target_database.as_deref(),
+ &file_path,
+ &format,
+ target_table.as_deref(),
+ drop_target_first,
+ &options,
+ on_progress,
+ )
+ .await;
+ }
+ }
+
+ run_restore(
+ &*adapter,
+ target_database.as_deref(),
+ &file_path,
+ &format,
+ target_table.as_deref(),
+ drop_target_first,
+ &options,
+ on_progress,
+ )
+ .await
+ }
+ ActiveConnection::SQLite(adapter) => {
+ let adapter = adapter.lock().await;
+ run_restore(
+ &*adapter,
+ None,
+ &file_path,
+ &format,
+ target_table.as_deref(),
+ drop_target_first,
+ &options,
+ on_progress,
+ )
+ .await
+ }
+ }
+}
+
#[tauri::command]
+#[allow(clippy::too_many_arguments)]
+pub async fn restore_backup(
+ connection_id: String,
+ target_database: Option,
+ file_path: String,
+ file_format: String,
+ target_table: Option,
+ drop_target_first: bool,
+ job_id: Option,
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result {
+ let job_id = job_id.unwrap_or_else(|| Uuid::new_v4().to_string());
+ let job_id_clone = job_id.clone();
+ let app_clone = app.clone();
+ let connections = state.connections.clone();
+
+ tokio::spawn(async move {
+ let _ = emit_job_event(
+ &app_clone,
+ &job_id_clone,
+ TransferJobStatus::Queued,
+ "queued",
+ 0,
+ 0,
+ None,
+ None,
+ );
+
+ let connection = match get_connection(&connection_id, &connections).await {
+ Ok(connection) => connection,
+ Err(error) => {
+ let _ = emit_job_event(
+ &app_clone,
+ &job_id_clone,
+ TransferJobStatus::Failed,
+ "failed",
+ 0,
+ 0,
+ None,
+ Some(error),
+ );
+ return;
+ }
+ };
+
+ let _ = emit_job_event(
+ &app_clone,
+ &job_id_clone,
+ TransferJobStatus::Running,
+ "running",
+ 0,
+ 0,
+ None,
+ None,
+ );
+
+ let restore_result = restore_with_connection(
+ connection,
+ target_database,
+ file_path,
+ file_format,
+ target_table,
+ drop_target_first,
+ |current, total| {
+ let _ = emit_job_event(
+ &app_clone,
+ &job_id_clone,
+ TransferJobStatus::Running,
+ "processing",
+ current,
+ total,
+ None,
+ None,
+ );
+ },
+ )
+ .await;
+
+ match restore_result {
+ Ok(stats) => {
+ let (status, summary) = summarize_restore_outcome(&stats);
+ let stage = if status == TransferJobStatus::Completed {
+ "completed"
+ } else {
+ "failed"
+ };
+
+ let _ = emit_job_event(
+ &app_clone,
+ &job_id_clone,
+ status,
+ stage,
+ stats.statements_total,
+ stats.statements_total,
+ None,
+ summary,
+ );
+ }
+ Err(error) => {
+ let _ = emit_job_event(
+ &app_clone,
+ &job_id_clone,
+ TransferJobStatus::Failed,
+ "failed",
+ 0,
+ 0,
+ None,
+ Some(error),
+ );
+ }
+ }
+ });
+
+ Ok(job_id)
+}
+
+#[tauri::command]
+#[allow(clippy::too_many_arguments)]
pub async fn backup_server(
connection_id: String,
selection: ObjectSelection,
@@ -1612,23 +2066,24 @@ pub async fn backup_server(
None,
);
- let mut outcomes: Vec<(String, Result<(), String>)> = Vec::new();
+ let mut failed_tables = 0u64;
+ let mut failures: Vec = Vec::new();
let mut succeeded_tables = 0u64;
for (index, (database, schema, table)) in selected_tables.iter().enumerate() {
let db_output_dir = PathBuf::from(&destination_clone).join(database);
+ let table_label = format!(
+ "{}.{}.{}",
+ database,
+ schema.clone().unwrap_or_else(|| "default".to_string()),
+ table
+ );
+
if let Err(error) = fs::create_dir_all(&db_output_dir)
.map_err(|e| format!("Failed to create backup directory: {}", e))
{
- outcomes.push((
- format!(
- "{}.{}.{}",
- database,
- schema.clone().unwrap_or_else(|| "default".to_string()),
- table
- ),
- Err(error),
- ));
+ failed_tables += 1;
+ failures.push(format!("{}: {}", table_label, error));
let _ = emit_job_event(
&app_clone,
&job_id_clone,
@@ -1655,15 +2110,8 @@ pub async fn backup_server(
{
Ok(columns) => columns,
Err(error) => {
- outcomes.push((
- format!(
- "{}.{}.{}",
- database,
- schema.clone().unwrap_or_else(|| "default".to_string()),
- table
- ),
- Err(error),
- ));
+ failed_tables += 1;
+ failures.push(format!("{}: {}", table_label, error));
let _ = emit_job_event(
&app_clone,
&job_id_clone,
@@ -1704,28 +2152,23 @@ pub async fn backup_server(
&connections_clone,
)
.await;
- let table_label = format!(
- "{}.{}.{}",
- database,
- schema.clone().unwrap_or_else(|| "default".to_string()),
- table
- );
match export_result {
Ok(result) if result.success => {
- outcomes.push((table_label, Ok(())));
succeeded_tables += 1;
}
Ok(result) => {
+ failed_tables += 1;
let first_error = result
.errors
.first()
.map(|error| error.message.clone())
.unwrap_or_else(|| "Export failed".to_string());
- outcomes.push((table_label, Err(first_error)));
+ failures.push(format!("{}: {}", table_label, first_error));
}
Err(error) => {
- outcomes.push((table_label, Err(error)));
+ failed_tables += 1;
+ failures.push(format!("{}: {}", table_label, error));
}
}
@@ -1741,34 +2184,10 @@ pub async fn backup_server(
);
}
- let failed_tables = outcomes
- .iter()
- .filter_map(|(name, result)| result.as_ref().err().map(|_| name.clone()))
- .collect::>();
-
- let status_text = if succeeded_tables > 0 {
- "completed"
- } else {
- "failed"
- };
-
- let summary = format!(
- "Backup {}: {} succeeded, {} failed{}",
- status_text,
- succeeded_tables,
- failed_tables.len(),
- if failed_tables.is_empty() {
- String::new()
- } else {
- format!(" [{}]", failed_tables.join(", "))
- }
- );
+ let (status, summary) =
+ summarize_backup_outcome(succeeded_tables, failed_tables, &failures);
- if succeeded_tables > 0 {
- Ok((TransferJobStatus::Completed, None, total_tables))
- } else {
- Ok((TransferJobStatus::Failed, Some(summary), total_tables))
- }
+ Ok((status, summary, total_tables))
}
.await;
@@ -2220,7 +2639,11 @@ pub async fn run_transfer_profile(
#[cfg(test)]
mod tests {
- use super::{quote_ident_mysql, quote_ident_pg, summarize_migration_outcome};
+ use super::{
+ filter_system_databases_for_whole_server, quote_ident_mysql, quote_ident_pg,
+ summarize_backup_outcome, summarize_migration_outcome,
+ };
+ use crate::database::DatabaseType;
use crate::transfer::TransferJobStatus;
#[test]
@@ -2274,4 +2697,86 @@ mod tests {
assert_eq!(status, TransferJobStatus::Failed);
assert!(summary.unwrap_or_default().contains("2 failed"));
}
+
+ #[test]
+ fn summarize_backup_outcome_all_succeeded_has_no_summary() {
+ let (status, summary) = summarize_backup_outcome(3, 0, &[]);
+
+ assert_eq!(status, TransferJobStatus::Completed);
+ assert!(summary.is_none());
+ }
+
+ #[test]
+ fn summarize_backup_outcome_partial_failures_is_completed_with_summary() {
+ let (status, summary) =
+ summarize_backup_outcome(2, 1, &["db.public.users: disk full".to_string()]);
+
+ assert_eq!(status, TransferJobStatus::Completed);
+ assert!(summary.unwrap_or_default().contains("1 failed"));
+ }
+
+ #[test]
+ fn summarize_backup_outcome_all_failed_is_failed_with_summary() {
+ let (status, summary) =
+ summarize_backup_outcome(0, 2, &["db.public.orders: timeout".to_string()]);
+
+ assert_eq!(status, TransferJobStatus::Failed);
+ assert!(summary.unwrap_or_default().contains("2 failed"));
+ }
+
+ #[test]
+ fn filter_system_databases_mysql_excludes_known_system_databases() {
+ let filtered = filter_system_databases_for_whole_server(
+ Some(DatabaseType::MySQL),
+ vec![
+ "app_db".to_string(),
+ "mysql".to_string(),
+ "information_schema".to_string(),
+ "sys".to_string(),
+ ],
+ );
+
+ assert_eq!(filtered, vec!["app_db".to_string()]);
+ }
+
+ #[test]
+ fn filter_system_databases_postgres_excludes_templates_only() {
+ let filtered = filter_system_databases_for_whole_server(
+ Some(DatabaseType::PostgreSQL),
+ vec![
+ "postgres".to_string(),
+ "template0".to_string(),
+ "template1".to_string(),
+ "app_db".to_string(),
+ ],
+ );
+
+ assert_eq!(filtered, vec!["postgres".to_string(), "app_db".to_string()]);
+ }
+
+ #[test]
+ fn filter_system_databases_sqlserver_excludes_system_databases() {
+ let filtered = filter_system_databases_for_whole_server(
+ Some(DatabaseType::SqlServer),
+ vec![
+ "master".to_string(),
+ "msdb".to_string(),
+ "tempdb".to_string(),
+ "model".to_string(),
+ "tenant_db".to_string(),
+ ],
+ );
+
+ assert_eq!(filtered, vec!["tenant_db".to_string()]);
+ }
+
+ #[test]
+ fn filter_system_databases_sqlite_is_unchanged() {
+ let filtered = filter_system_databases_for_whole_server(
+ Some(DatabaseType::SQLite),
+ vec!["main".to_string()],
+ );
+
+ assert_eq!(filtered, vec!["main".to_string()]);
+ }
}
diff --git a/src-tauri/src/database/adapter.rs b/src-tauri/src/database/adapter.rs
index 8a444188..de98281e 100644
--- a/src-tauri/src/database/adapter.rs
+++ b/src-tauri/src/database/adapter.rs
@@ -109,6 +109,15 @@ pub trait DatabaseAdapter: Send + Sync {
/// - The connection is not active
async fn execute_query(&self, query: &str) -> DbResult;
+ /// Execute a parameterized statement with N rows of M values each.
+ /// Implementations MUST bind via the driver's native parameter API.
+ async fn execute_batch_with_params(
+ &self,
+ statement: &str,
+ column_count: usize,
+ values: Vec>,
+ ) -> DbResult;
+
/// List all databases on the server.
///
/// This method retrieves a list of all databases accessible to the current user.
diff --git a/src-tauri/src/database/mysql.rs b/src-tauri/src/database/mysql.rs
index 157c7294..f4f84719 100644
--- a/src-tauri/src/database/mysql.rs
+++ b/src-tauri/src/database/mysql.rs
@@ -416,6 +416,39 @@ impl DatabaseAdapter for MySQLAdapter {
}
}
+ async fn execute_batch_with_params(
+ &self,
+ statement: &str,
+ column_count: usize,
+ values: Vec>,
+ ) -> DbResult {
+ let mut conn = self.get_conn().await?;
+ let mut total_affected = 0u64;
+
+ for row in values {
+ if row.len() != column_count {
+ return Err(DbError::InvalidQuery(format!(
+ "Expected {} values per row, got {}",
+ column_count,
+ row.len()
+ )));
+ }
+
+ let params = mysql_async::Params::Positional(
+ row.into_iter()
+ .map(|value| Value::Bytes(value.into_bytes()))
+ .collect::>(),
+ );
+
+ conn.exec_drop(statement, params)
+ .await
+ .map_err(mysql_error_to_db_error)?;
+ total_affected += conn.affected_rows();
+ }
+
+ Ok(total_affected)
+ }
+
async fn list_databases(&self) -> DbResult> {
let mut conn = self.get_conn().await?;
diff --git a/src-tauri/src/database/postgres.rs b/src-tauri/src/database/postgres.rs
index 0b654f2e..441044e3 100644
--- a/src-tauri/src/database/postgres.rs
+++ b/src-tauri/src/database/postgres.rs
@@ -24,7 +24,7 @@ use std::fs;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_postgres::{
- types::{FromSql, Kind, Type},
+ types::{FromSql, Kind, ToSql, Type},
Client, NoTls, Row,
};
@@ -734,6 +734,49 @@ impl DatabaseAdapter for PostgresAdapter {
}
}
+ async fn execute_batch_with_params(
+ &self,
+ statement: &str,
+ column_count: usize,
+ values: Vec>,
+ ) -> DbResult {
+ let pool = self
+ .pool
+ .as_ref()
+ .ok_or_else(|| DbError::Connection("Not connected".to_string()))?;
+
+ let client = pool
+ .pool
+ .get()
+ .await
+ .map_err(|e| DbError::Connection(format!("Failed to get connection: {}", e)))?;
+
+ let mut total_affected = 0u64;
+ for row in values {
+ if row.len() != column_count {
+ return Err(DbError::InvalidQuery(format!(
+ "Expected {} values per row, got {}",
+ column_count,
+ row.len()
+ )));
+ }
+
+ let params = row.iter().map(|value| value.as_str()).collect::>();
+ let params_refs = params
+ .iter()
+ .map(|value| value as &(dyn ToSql + Sync))
+ .collect::>();
+
+ let affected = client
+ .execute(statement, ¶ms_refs)
+ .await
+ .map_err(postgres_error_to_db_error)?;
+ total_affected += affected;
+ }
+
+ Ok(total_affected)
+ }
+
async fn list_databases(&self) -> DbResult> {
let query = r#"
SELECT
diff --git a/src-tauri/src/database/sqlite.rs b/src-tauri/src/database/sqlite.rs
index 7aefb501..d4e7fe3a 100644
--- a/src-tauri/src/database/sqlite.rs
+++ b/src-tauri/src/database/sqlite.rs
@@ -442,6 +442,43 @@ impl DatabaseAdapter for SQLiteAdapter {
self.execute_query_internal(query).await
}
+ async fn execute_batch_with_params(
+ &self,
+ statement: &str,
+ column_count: usize,
+ values: Vec>,
+ ) -> DbResult {
+ let pool = self
+ .pool
+ .as_ref()
+ .ok_or_else(|| DbError::Connection("Not connected".to_string()))?;
+
+ let conn = pool.get_conn().await?;
+ let conn_guard = conn
+ .lock()
+ .map_err(|e| DbError::QueryExecution(format!("Failed to lock connection: {}", e)))?;
+
+ let mut total_affected = 0u64;
+ for row in values {
+ if row.len() != column_count {
+ return Err(DbError::InvalidQuery(format!(
+ "Expected {} values per row, got {}",
+ column_count,
+ row.len()
+ )));
+ }
+
+ let affected = conn_guard
+ .execute(statement, rusqlite::params_from_iter(row.iter()))
+ .map_err(|e| DbError::QueryExecution(format!("Failed to execute query: {}", e)))?;
+ total_affected += affected as u64;
+ }
+
+ drop(conn_guard);
+ pool.return_conn(conn)?;
+ Ok(total_affected)
+ }
+
async fn list_databases(&self) -> DbResult> {
Ok(vec![DatabaseSchema {
name: self
diff --git a/src-tauri/src/database/sqlserver.rs b/src-tauri/src/database/sqlserver.rs
index 48657a1b..6919f136 100644
--- a/src-tauri/src/database/sqlserver.rs
+++ b/src-tauri/src/database/sqlserver.rs
@@ -471,6 +471,40 @@ impl DatabaseAdapter for SqlServerAdapter {
}
}
+ async fn execute_batch_with_params(
+ &self,
+ statement: &str,
+ column_count: usize,
+ values: Vec>,
+ ) -> DbResult {
+ let client = self.get_client().await?;
+ let mut client = client.lock().await;
+ let mut total_affected = 0u64;
+
+ for row in values {
+ if row.len() != column_count {
+ return Err(DbError::InvalidQuery(format!(
+ "Expected {} values per row, got {}",
+ column_count,
+ row.len()
+ )));
+ }
+
+ let params = row
+ .iter()
+ .map(|value| value as &dyn tiberius::ToSql)
+ .collect::>();
+
+ client
+ .execute(statement, ¶ms)
+ .await
+ .map_err(|e| DbError::QueryExecution(e.to_string()))?;
+ total_affected += 1;
+ }
+
+ Ok(total_affected)
+ }
+
async fn list_databases(&self) -> DbResult> {
let query = r#"
SELECT
diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs
index f9c88aab..91f78fcb 100644
--- a/src-tauri/src/database/tests.rs
+++ b/src-tauri/src/database/tests.rs
@@ -1,6 +1,7 @@
//! Tests for the database adapter module.
#[cfg(test)]
+#[allow(clippy::module_inception)]
mod tests {
use crate::database::*;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 7d84bb90..db7ab082 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -137,6 +137,7 @@ pub fn run() {
commands::generate_ddl_for_objects,
commands::execute_sql_content,
commands::backup_server,
+ commands::restore_backup,
commands::migrate_server,
commands::save_transfer_profile,
commands::list_transfer_profiles,
diff --git a/src-tauri/src/transfer/export.rs b/src-tauri/src/transfer/export.rs
index f77589a0..759dc3b9 100644
--- a/src-tauri/src/transfer/export.rs
+++ b/src-tauri/src/transfer/export.rs
@@ -9,6 +9,7 @@ use rust_xlsxwriter::{Workbook, Worksheet};
use serde_json::Value as JsonValue;
use super::defaults::*;
+use super::paginate_clause;
use super::progress::*;
use super::types::*;
use crate::database::{DatabaseAdapter, DatabaseType, QueryValue};
@@ -53,6 +54,7 @@ pub async fn execute_export(
&columns,
&request.source,
);
+ let base_has_order_by = request.source.order_by.is_some();
let count_query = build_count_query(
db_type,
@@ -97,7 +99,16 @@ pub async fn execute_export(
let mut offset = 0u64;
while offset < total_rows {
- let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset);
+ let query = format!(
+ "{} {}",
+ base_query,
+ paginate_clause(
+ db_type,
+ offset as usize,
+ batch_size as usize,
+ base_has_order_by
+ )
+ );
let result = adapter
.execute_query(&query)
.await
@@ -133,7 +144,16 @@ pub async fn execute_export(
ExportFormat::Jsonl => {
let mut offset = 0u64;
while offset < total_rows {
- let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset);
+ let query = format!(
+ "{} {}",
+ base_query,
+ paginate_clause(
+ db_type,
+ offset as usize,
+ batch_size as usize,
+ base_has_order_by
+ )
+ );
let result = adapter
.execute_query(&query)
.await
@@ -181,7 +201,16 @@ pub async fn execute_export(
let mut offset = 0u64;
while offset < total_rows {
- let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset);
+ let query = format!(
+ "{} {}",
+ base_query,
+ paginate_clause(
+ db_type,
+ offset as usize,
+ batch_size as usize,
+ base_has_order_by
+ )
+ );
let result = adapter
.execute_query(&query)
.await
@@ -254,7 +283,16 @@ pub async fn execute_export(
let mut row_idx = header_row_offset;
while offset < total_rows {
- let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset);
+ let query = format!(
+ "{} {}",
+ base_query,
+ paginate_clause(
+ db_type,
+ offset as usize,
+ batch_size as usize,
+ base_has_order_by
+ )
+ );
let result = adapter
.execute_query(&query)
.await
@@ -561,7 +599,12 @@ pub async fn preview_export(
&columns,
&request.source,
);
- let query = format!("{} LIMIT {}", base_query, preview_rows);
+ let base_has_order_by = request.source.order_by.is_some();
+ let query = format!(
+ "{} {}",
+ base_query,
+ paginate_clause(db_type, 0, preview_rows as usize, base_has_order_by)
+ );
let result = adapter
.execute_query(&query)
diff --git a/src-tauri/src/transfer/import.rs b/src-tauri/src/transfer/import.rs
index b8055f55..1cc7529a 100644
--- a/src-tauri/src/transfer/import.rs
+++ b/src-tauri/src/transfer/import.rs
@@ -357,7 +357,6 @@ pub async fn execute_import(
let range = workbook
.worksheet_range(&sheet_name)
- .ok_or_else(|| format!("Sheet '{}' not found", sheet_name))?
.map_err(|e| format!("Failed to read sheet '{}': {:?}", sheet_name, e))?;
let has_header = request
@@ -370,11 +369,7 @@ pub async fn execute_import(
let header_row: Vec = if has_header {
rows_iter
.next()
- .map(|row| {
- row.iter()
- .map(|c: &calamine::DataType| c.to_string())
- .collect()
- })
+ .map(|row| row.iter().map(|c| c.to_string()).collect())
.unwrap_or_default()
} else {
request
@@ -386,7 +381,7 @@ pub async fn execute_import(
let mut batch_values: Vec> = Vec::new();
- for row in range.rows() {
+ for row in rows_iter {
let values: Vec = header_row
.iter()
.enumerate()
@@ -401,11 +396,7 @@ pub async fn execute_import(
{
None
} else {
- Some(
- row.get(col_idx)
- .map(|c: &calamine::DataType| c.to_string())
- .unwrap_or_default(),
- )
+ Some(row.get(col_idx).map(|c| c.to_string()).unwrap_or_default())
}
})
.collect();
@@ -640,16 +631,11 @@ pub fn detect_file(file_path: &str) -> Result {
.map_err(|e| format!("Failed to open Excel file for detection: {}", e))?;
let range = workbook
.worksheet_range("Sheet1")
- .ok_or("Sheet 'Sheet1' not found")?
.map_err(|e| format!("Failed to read sheet: {:?}", e))?;
let cols = range
.rows()
.next()
- .map(|row| {
- row.iter()
- .map(|c: &calamine::DataType| c.to_string())
- .collect()
- })
+ .map(|row| row.iter().map(|c| c.to_string()).collect())
.unwrap_or_default();
(cols, None, Some(true))
}
@@ -793,7 +779,6 @@ pub fn preview_import(
let sheet_name = "Sheet1";
let range = workbook
.worksheet_range(sheet_name)
- .ok_or_else(|| format!("Sheet '{}' not found", sheet_name))?
.map_err(|e| format!("Failed to read sheet: {:?}", e))?;
let has_header = true;
@@ -802,11 +787,7 @@ pub fn preview_import(
columns = range
.rows()
.next()
- .map(|row| {
- row.iter()
- .map(|c: &calamine::DataType| c.to_string())
- .collect()
- })
+ .map(|row| row.iter().map(|c| c.to_string()).collect())
.unwrap_or_default();
}
@@ -817,10 +798,7 @@ pub fn preview_import(
if sample_rows.len() >= preview_rows as usize {
break;
}
- let row_values: Vec = row
- .iter()
- .map(|cell: &calamine::DataType| cell.to_string())
- .collect();
+ let row_values: Vec = row.iter().map(|cell| cell.to_string()).collect();
sample_rows.push(row_values);
}
}
diff --git a/src-tauri/src/transfer/migration.rs b/src-tauri/src/transfer/migration.rs
index 403d7d07..11997176 100644
--- a/src-tauri/src/transfer/migration.rs
+++ b/src-tauri/src/transfer/migration.rs
@@ -6,6 +6,7 @@ use crate::database::types::ColumnInfo;
use crate::database::DatabaseType;
use crate::database::{DatabaseAdapter, QueryValue};
+use super::paginate_clause;
use super::progress::*;
use super::types::*;
@@ -217,7 +218,11 @@ async fn migrate_table(
let mut offset = 0u64;
while offset < total_rows {
- let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset);
+ let query = format!(
+ "{} {}",
+ base_query,
+ paginate_clause(source_db_type, offset as usize, batch_size as usize, false)
+ );
let result = source_adapter
.execute_query(&query)
.await
diff --git a/src-tauri/src/transfer/mod.rs b/src-tauri/src/transfer/mod.rs
index 67f940f8..ba10145f 100644
--- a/src-tauri/src/transfer/mod.rs
+++ b/src-tauri/src/transfer/mod.rs
@@ -1,5 +1,7 @@
//! Transfer module for data export, import, and migration.
+use crate::database::DatabaseType;
+
pub mod ddl;
pub mod defaults;
pub mod export;
@@ -7,6 +9,7 @@ pub mod import;
pub mod migration;
pub mod profile_store;
pub mod progress;
+pub mod restore;
pub mod types;
pub use ddl::*;
@@ -16,4 +19,85 @@ pub use import::*;
pub use migration::*;
pub use profile_store::*;
pub use progress::*;
+pub use restore::*;
pub use types::*;
+
+/// Returns the pagination clause for a given DB type.
+///
+/// `base_has_order_by` must be `true` if the base SELECT already has an `ORDER BY`
+/// clause appended. For SQL Server this avoids emitting a second `ORDER BY (SELECT NULL)`
+/// (which would produce invalid T-SQL). For other engines it is informational only.
+pub fn paginate_clause(
+ db_type: DatabaseType,
+ offset: usize,
+ limit: usize,
+ base_has_order_by: bool,
+) -> String {
+ match db_type {
+ DatabaseType::SqlServer => {
+ if base_has_order_by {
+ format!("OFFSET {} ROWS FETCH NEXT {} ROWS ONLY", offset, limit)
+ } else {
+ format!(
+ "ORDER BY (SELECT NULL) OFFSET {} ROWS FETCH NEXT {} ROWS ONLY",
+ offset, limit
+ )
+ }
+ }
+ _ => format!("LIMIT {} OFFSET {}", limit, offset),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::paginate_clause;
+ use crate::database::DatabaseType;
+
+ #[test]
+ fn paginate_clause_uses_limit_offset_for_postgres() {
+ assert_eq!(
+ paginate_clause(DatabaseType::PostgreSQL, 10, 25, false),
+ "LIMIT 25 OFFSET 10"
+ );
+ }
+
+ #[test]
+ fn paginate_clause_uses_limit_offset_for_mysql() {
+ assert_eq!(
+ paginate_clause(DatabaseType::MySQL, 10, 25, false),
+ "LIMIT 25 OFFSET 10"
+ );
+ }
+
+ #[test]
+ fn paginate_clause_uses_limit_offset_for_sqlite() {
+ assert_eq!(
+ paginate_clause(DatabaseType::SQLite, 10, 25, false),
+ "LIMIT 25 OFFSET 10"
+ );
+ }
+
+ #[test]
+ fn paginate_clause_uses_offset_fetch_for_sqlserver_without_order_by() {
+ assert_eq!(
+ paginate_clause(DatabaseType::SqlServer, 10, 25, false),
+ "ORDER BY (SELECT NULL) OFFSET 10 ROWS FETCH NEXT 25 ROWS ONLY"
+ );
+ }
+
+ #[test]
+ fn paginate_clause_skips_synthetic_order_by_when_base_query_has_one() {
+ assert_eq!(
+ paginate_clause(DatabaseType::SqlServer, 10, 25, true),
+ "OFFSET 10 ROWS FETCH NEXT 25 ROWS ONLY"
+ );
+ }
+
+ #[test]
+ fn paginate_clause_ignores_base_order_by_flag_for_non_sqlserver() {
+ assert_eq!(
+ paginate_clause(DatabaseType::PostgreSQL, 10, 25, true),
+ "LIMIT 25 OFFSET 10"
+ );
+ }
+}
diff --git a/src-tauri/src/transfer/restore.rs b/src-tauri/src/transfer/restore.rs
new file mode 100644
index 00000000..16b0233d
--- /dev/null
+++ b/src-tauri/src/transfer/restore.rs
@@ -0,0 +1,671 @@
+use crate::database::{DatabaseAdapter, DatabaseType, DbError, DbResult};
+use calamine::{open_workbook, Data, Reader, Xlsx, XlsxError};
+use serde::Serialize;
+use std::fs;
+use std::io::BufReader;
+
+#[derive(Debug, Clone)]
+pub struct RestoreOptions {
+ pub progress_every: usize,
+ pub csv_delimiter: u8,
+ pub csv_has_header: bool,
+ pub xlsx_sheet_name: Option,
+}
+
+impl Default for RestoreOptions {
+ fn default() -> Self {
+ Self {
+ progress_every: 100,
+ csv_delimiter: b',',
+ csv_has_header: true,
+ xlsx_sheet_name: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct RestoreStats {
+ pub statements_total: u64,
+ pub statements_succeeded: u64,
+ pub rows_inserted: u64,
+ pub errors: Vec,
+}
+
+const CSV_BATCH_SIZE: usize = 500;
+
+/// Restores a SQL dump by executing statements sequentially.
+pub async fn restore_sql_file(
+ adapter: &A,
+ path: &str,
+ options: &RestoreOptions,
+) -> DbResult {
+ restore_sql_file_with_progress(adapter, path, options, |_, _| {}).await
+}
+
+pub(crate) async fn restore_sql_file_with_progress(
+ adapter: &A,
+ path: &str,
+ options: &RestoreOptions,
+ mut on_progress: F,
+) -> DbResult {
+ let content = fs::read_to_string(path)?;
+ let statements = split_sql_statements(&content);
+ let total = statements.len() as u64;
+
+ let mut index = 0usize;
+ let mut stats = RestoreStats {
+ statements_total: total,
+ ..RestoreStats::default()
+ };
+
+ while index < statements.len() {
+ let statement = statements[index].trim();
+ if !statement.is_empty() {
+ match adapter.execute_query(statement).await {
+ Ok(_) => {
+ stats.statements_succeeded += 1;
+ }
+ Err(error) => {
+ stats
+ .errors
+ .push(format!("statement {}: {}", index + 1, error));
+ }
+ }
+ }
+
+ let current = index as u64 + 1;
+ if options.progress_every > 0 && (index + 1).is_multiple_of(options.progress_every)
+ || current == total
+ {
+ on_progress(current, total);
+ }
+
+ index += 1;
+ }
+
+ Ok(stats)
+}
+
+/// Restores CSV data into a target table in 500-row insert batches.
+pub async fn restore_csv_file(
+ adapter: &A,
+ path: &str,
+ target_schema: Option<&str>,
+ target_table: &str,
+ options: &RestoreOptions,
+) -> DbResult {
+ restore_csv_file_with_progress(
+ adapter,
+ path,
+ target_schema,
+ target_table,
+ options,
+ |_, _| {},
+ )
+ .await
+}
+
+pub(crate) async fn restore_csv_file_with_progress(
+ adapter: &A,
+ path: &str,
+ target_schema: Option<&str>,
+ target_table: &str,
+ options: &RestoreOptions,
+ mut on_progress: F,
+) -> DbResult {
+ let mut reader = csv::ReaderBuilder::new()
+ .delimiter(options.csv_delimiter)
+ .has_headers(options.csv_has_header)
+ .from_path(path)
+ .map_err(|error| DbError::InvalidQuery(format!("Failed to parse CSV file: {}", error)))?;
+
+ let columns = if options.csv_has_header {
+ reader
+ .headers()
+ .map_err(|error| {
+ DbError::InvalidQuery(format!("Failed to read CSV headers: {}", error))
+ })?
+ .iter()
+ .map(|value| value.to_string())
+ .collect::>()
+ } else {
+ let first_record = reader
+ .records()
+ .next()
+ .transpose()
+ .map_err(|error| DbError::InvalidQuery(format!("Failed to parse CSV row: {}", error)))?
+ .ok_or_else(|| {
+ DbError::InvalidQuery("CSV file is empty and no headers were provided".to_string())
+ })?;
+
+ (0..first_record.len())
+ .map(|index| format!("column_{}", index + 1))
+ .collect::>()
+ };
+
+ let mut records_reader = csv::ReaderBuilder::new()
+ .delimiter(options.csv_delimiter)
+ .has_headers(options.csv_has_header)
+ .from_path(path)
+ .map_err(|error| DbError::InvalidQuery(format!("Failed to parse CSV file: {}", error)))?;
+ let records = records_reader.records();
+ let mut current_batch: Vec> = Vec::new();
+
+ if columns.is_empty() {
+ return Err(DbError::InvalidQuery(
+ "CSV file has no columns to restore".to_string(),
+ ));
+ }
+
+ let mut stats = RestoreStats::default();
+
+ for record in records {
+ let record = record.map_err(|error| {
+ DbError::InvalidQuery(format!("Failed to parse CSV row: {}", error))
+ })?;
+
+ current_batch.push(
+ record
+ .iter()
+ .map(|value| value.to_string())
+ .collect::>(),
+ );
+
+ if current_batch.len() >= CSV_BATCH_SIZE {
+ let batch_stats = restore_rows_in_batches(
+ adapter,
+ target_schema,
+ target_table,
+ &columns,
+ ¤t_batch,
+ &mut on_progress,
+ )
+ .await?;
+ merge_stats(&mut stats, batch_stats);
+ current_batch.clear();
+ }
+ }
+
+ if !current_batch.is_empty() {
+ let batch_stats = restore_rows_in_batches(
+ adapter,
+ target_schema,
+ target_table,
+ &columns,
+ ¤t_batch,
+ &mut on_progress,
+ )
+ .await?;
+ merge_stats(&mut stats, batch_stats);
+ }
+
+ Ok(stats)
+}
+
+/// Restores XLSX rows from one sheet into a target table in 500-row insert batches.
+pub async fn restore_xlsx_file(
+ adapter: &A,
+ path: &str,
+ target_schema: Option<&str>,
+ target_table: &str,
+ options: &RestoreOptions,
+) -> DbResult {
+ restore_xlsx_file_with_progress(
+ adapter,
+ path,
+ target_schema,
+ target_table,
+ options,
+ |_, _| {},
+ )
+ .await
+}
+
+pub(crate) async fn restore_xlsx_file_with_progress(
+ adapter: &A,
+ path: &str,
+ target_schema: Option<&str>,
+ target_table: &str,
+ options: &RestoreOptions,
+ mut on_progress: F,
+) -> DbResult {
+ let mut workbook: Xlsx> =
+ open_workbook::>, _>(path)
+ .map_err(|error: XlsxError| DbError::InvalidQuery(error.to_string()))?;
+
+ let sheet_name = options
+ .xlsx_sheet_name
+ .clone()
+ .or_else(|| workbook.sheet_names().first().cloned())
+ .ok_or_else(|| DbError::InvalidQuery("XLSX file has no worksheets".to_string()))?;
+
+ let range = workbook
+ .worksheet_range(&sheet_name)
+ .map_err(|error: XlsxError| DbError::InvalidQuery(error.to_string()))?;
+
+ let mut rows_iter = range.rows();
+ let columns = rows_iter
+ .next()
+ .map(|row| {
+ row.iter()
+ .map(|cell: &Data| cell.to_string())
+ .collect::>()
+ })
+ .ok_or_else(|| DbError::InvalidQuery("XLSX sheet is empty".to_string()))?;
+
+ let mut stats = RestoreStats::default();
+ let mut current_batch: Vec> = Vec::new();
+
+ for row in rows_iter {
+ current_batch.push(
+ row.iter()
+ .map(|cell: &Data| cell.to_string())
+ .collect::>(),
+ );
+
+ if current_batch.len() >= CSV_BATCH_SIZE {
+ let batch_stats = restore_rows_in_batches(
+ adapter,
+ target_schema,
+ target_table,
+ &columns,
+ ¤t_batch,
+ &mut on_progress,
+ )
+ .await?;
+ merge_stats(&mut stats, batch_stats);
+ current_batch.clear();
+ }
+ }
+
+ if !current_batch.is_empty() {
+ let batch_stats = restore_rows_in_batches(
+ adapter,
+ target_schema,
+ target_table,
+ &columns,
+ ¤t_batch,
+ &mut on_progress,
+ )
+ .await?;
+ merge_stats(&mut stats, batch_stats);
+ }
+
+ Ok(stats)
+}
+
+pub(crate) fn split_sql_statements(content: &str) -> Vec {
+ fn read_dollar_tag(chars: &[char], start: usize) -> Option<(String, usize)> {
+ if chars.get(start) != Some(&'$') {
+ return None;
+ }
+
+ let mut end = start + 1;
+ while end < chars.len() {
+ if chars[end] == '$' {
+ let tag = chars[start + 1..end].iter().collect::();
+ let valid = if tag.is_empty() {
+ true
+ } else {
+ let mut tag_chars = tag.chars();
+ matches!(tag_chars.next(), Some(ch) if ch.is_ascii_alphabetic() || ch == '_')
+ && tag_chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
+ };
+
+ return if valid { Some((tag, end + 1)) } else { None };
+ }
+ end += 1;
+ }
+
+ None
+ }
+
+ let chars = content.chars().collect::>();
+ let mut current = String::new();
+ let mut statements: Vec = Vec::new();
+
+ let mut index = 0usize;
+ let mut in_single = false;
+ let mut in_double = false;
+ let mut in_line_comment = false;
+ let mut in_block_comment = false;
+ let mut in_dollar: Option = None;
+
+ while index < chars.len() {
+ let ch = chars[index];
+ let next = chars.get(index + 1).copied();
+
+ if let Some(dollar_tag) = in_dollar.as_ref() {
+ if ch == '$' {
+ if let Some((tag, next_index)) = read_dollar_tag(&chars, index) {
+ if &tag == dollar_tag {
+ current.push_str(&chars[index..next_index].iter().collect::());
+ in_dollar = None;
+ index = next_index;
+ continue;
+ }
+ }
+ }
+
+ current.push(ch);
+ index += 1;
+ continue;
+ }
+
+ if in_line_comment {
+ current.push(ch);
+ if ch == '\n' {
+ in_line_comment = false;
+ }
+ index += 1;
+ continue;
+ }
+
+ if in_block_comment {
+ current.push(ch);
+ if ch == '*' && next == Some('/') {
+ current.push('/');
+ in_block_comment = false;
+ index += 2;
+ } else {
+ index += 1;
+ }
+ continue;
+ }
+
+ if !in_single && !in_double && ch == '-' && next == Some('-') {
+ current.push(ch);
+ current.push('-');
+ in_line_comment = true;
+ index += 2;
+ continue;
+ }
+
+ if !in_single && !in_double && ch == '/' && next == Some('*') {
+ current.push(ch);
+ current.push('*');
+ in_block_comment = true;
+ index += 2;
+ continue;
+ }
+
+ if !in_single && !in_double && ch == '$' {
+ if let Some((tag, next_index)) = read_dollar_tag(&chars, index) {
+ current.push_str(&chars[index..next_index].iter().collect::());
+ in_dollar = Some(tag);
+ index = next_index;
+ continue;
+ }
+ }
+
+ if ch == '\'' && !in_double {
+ if in_single && next == Some('\'') {
+ current.push(ch);
+ current.push('\'');
+ index += 2;
+ continue;
+ }
+ in_single = !in_single;
+ current.push(ch);
+ index += 1;
+ continue;
+ }
+
+ if ch == '"' && !in_single {
+ in_double = !in_double;
+ current.push(ch);
+ index += 1;
+ continue;
+ }
+
+ if !in_single && !in_double && ch == ';' {
+ current.push(ch);
+ let statement = current.trim().trim_end_matches(';').trim().to_string();
+ if !statement.is_empty() {
+ statements.push(statement);
+ }
+ current.clear();
+ index += 1;
+ continue;
+ }
+
+ current.push(ch);
+ index += 1;
+ }
+
+ let trailing = current.trim().trim_end_matches(';').trim().to_string();
+ if !trailing.is_empty() {
+ statements.push(trailing);
+ }
+
+ statements
+}
+
+async fn restore_rows_in_batches(
+ adapter: &A,
+ target_schema: Option<&str>,
+ target_table: &str,
+ columns: &[String],
+ rows: &[Vec],
+ on_progress: &mut F,
+) -> DbResult {
+ if target_table.trim().is_empty() {
+ return Err(DbError::InvalidQuery(
+ "Target table is required for tabular restore".to_string(),
+ ));
+ }
+
+ let db_type = adapter.get_config().db_type;
+ let total_batches = if rows.is_empty() {
+ 0
+ } else {
+ rows.len().div_ceil(CSV_BATCH_SIZE)
+ } as u64;
+
+ let mut batch_index = 0usize;
+ let mut stats = RestoreStats {
+ statements_total: total_batches,
+ ..RestoreStats::default()
+ };
+
+ while batch_index < total_batches as usize {
+ let start = batch_index * CSV_BATCH_SIZE;
+ let end = ((batch_index + 1) * CSV_BATCH_SIZE).min(rows.len());
+ let batch = &rows[start..end];
+ let statement = build_insert_statement(db_type, target_schema, target_table, columns);
+
+ let batch_result: DbResult = async {
+ let mut inserted = 0u64;
+ let mut row_index = 0usize;
+ while row_index < batch.len() {
+ inserted += adapter
+ .execute_batch_with_params(
+ &statement,
+ columns.len(),
+ vec![batch[row_index].clone()],
+ )
+ .await?;
+ row_index += 1;
+ }
+ Ok(inserted)
+ }
+ .await;
+
+ match batch_result {
+ Ok(inserted) => {
+ stats.statements_succeeded += 1;
+ stats.rows_inserted += inserted;
+ }
+ Err(error) => {
+ stats
+ .errors
+ .push(format!("batch {}: {}", batch_index + 1, error));
+ }
+ }
+
+ batch_index += 1;
+ on_progress(batch_index as u64, total_batches);
+ }
+
+ Ok(stats)
+}
+
+fn merge_stats(target: &mut RestoreStats, source: RestoreStats) {
+ target.statements_total += source.statements_total;
+ target.statements_succeeded += source.statements_succeeded;
+ target.rows_inserted += source.rows_inserted;
+ target.errors.extend(source.errors);
+}
+
+pub(crate) fn quote_identifier(name: &str, db_type: DatabaseType) -> String {
+ match db_type {
+ DatabaseType::MySQL => format!("`{}`", name.replace('`', "``")),
+ DatabaseType::SqlServer => format!("[{}]", name.replace(']', "]]")),
+ _ => format!("\"{}\"", name.replace('"', "\"\"")),
+ }
+}
+
+fn qualified_table(db_type: DatabaseType, schema: Option<&str>, table: &str) -> String {
+ schema
+ .map(|schema_name| {
+ format!(
+ "{}.{}",
+ quote_identifier(schema_name, db_type),
+ quote_identifier(table, db_type)
+ )
+ })
+ .unwrap_or_else(|| quote_identifier(table, db_type))
+}
+
+fn build_insert_statement(
+ db_type: DatabaseType,
+ target_schema: Option<&str>,
+ target_table: &str,
+ columns: &[String],
+) -> String {
+ let quoted_table = qualified_table(db_type, target_schema, target_table);
+ let quoted_columns = columns
+ .iter()
+ .map(|column| quote_identifier(column, db_type))
+ .collect::>()
+ .join(", ");
+
+ let placeholders = match db_type {
+ DatabaseType::PostgreSQL => (1..=columns.len())
+ .map(|index| format!("${}", index))
+ .collect::>()
+ .join(", "),
+ DatabaseType::SqlServer => (1..=columns.len())
+ .map(|index| format!("@P{}", index))
+ .collect::>()
+ .join(", "),
+ _ => std::iter::repeat_n("?", columns.len())
+ .collect::>()
+ .join(", "),
+ };
+
+ format!(
+ "INSERT INTO {} ({}) VALUES ({})",
+ quoted_table, quoted_columns, placeholders
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::split_sql_statements;
+ use csv::ReaderBuilder;
+ use std::fs;
+ use tempfile::NamedTempFile;
+
+ #[test]
+ fn split_sql_statements_ignores_semicolons_in_strings() {
+ let sql = "INSERT INTO t VALUES ('a; b');\nSELECT 1;\n";
+ let statements = split_sql_statements(sql);
+
+ assert_eq!(statements.len(), 2);
+ assert_eq!(statements[0], "INSERT INTO t VALUES ('a; b')");
+ assert_eq!(statements[1], "SELECT 1");
+ }
+
+ #[test]
+ fn split_sql_statements_ignores_semicolons_in_line_comment() {
+ let sql = "SELECT 1 -- keep ; in comment\n;\nSELECT 2;\n";
+ let statements = split_sql_statements(sql);
+
+ assert_eq!(statements.len(), 2);
+ assert!(statements[0].starts_with("SELECT 1"));
+ assert_eq!(statements[1], "SELECT 2");
+ }
+
+ #[test]
+ fn split_sql_statements_ignores_semicolons_in_block_comment() {
+ let sql = "/* ;;; */\nSELECT 1;\n/* x ; y */\nSELECT 2;\n";
+ let statements = split_sql_statements(sql);
+
+ assert_eq!(statements.len(), 2);
+ assert!(statements[0].contains("SELECT 1"));
+ assert!(statements[1].contains("SELECT 2"));
+ }
+
+ #[test]
+ fn split_sql_statements_handles_same_line_semicolons() {
+ let statements = split_sql_statements("SELECT 1; SELECT 2;");
+ assert_eq!(statements, vec!["SELECT 1", "SELECT 2"]);
+ }
+
+ #[test]
+ fn split_sql_statements_handles_dollar_quoted_function() {
+ let sql = "CREATE FUNCTION demo() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;";
+ let statements = split_sql_statements(sql);
+ assert_eq!(statements.len(), 1);
+ }
+
+ #[test]
+ fn split_sql_statements_handles_tagged_dollar() {
+ let sql =
+ "CREATE FUNCTION demo() RETURNS void AS $body$ BEGIN; PERFORM 1; END; $body$ LANGUAGE plpgsql;";
+ let statements = split_sql_statements(sql);
+ assert_eq!(statements.len(), 1);
+ }
+
+ #[test]
+ fn split_sql_statements_handles_nested_unmatched_dollar() {
+ let sql = "SELECT $tag1$ one $tag2$ two $tag1$; SELECT 2;";
+ let statements = split_sql_statements(sql);
+ assert_eq!(
+ statements,
+ vec!["SELECT $tag1$ one $tag2$ two $tag1$", "SELECT 2"]
+ );
+ }
+
+ #[test]
+ fn csv_parser_handles_quoted_commas() {
+ let file = NamedTempFile::new().expect("temp file");
+ let path = file.path().to_path_buf();
+ let content = "name,note\n\"Jane\",\"hello, world\"\n";
+ fs::write(&path, content).expect("write csv");
+
+ let mut reader = ReaderBuilder::new()
+ .delimiter(b',')
+ .has_headers(true)
+ .from_path(path)
+ .expect("csv reader");
+
+ let headers = reader
+ .headers()
+ .expect("headers")
+ .iter()
+ .map(|value| value.to_string())
+ .collect::>();
+ let record = reader
+ .records()
+ .next()
+ .expect("record")
+ .expect("valid record");
+
+ assert_eq!(headers, vec!["name".to_string(), "note".to_string()]);
+ assert_eq!(record.get(0).unwrap_or_default(), "Jane");
+ assert_eq!(record.get(1).unwrap_or_default(), "hello, world");
+ }
+}
diff --git a/src-tauri/tests/sqlserver_integration.rs b/src-tauri/tests/sqlserver_integration.rs
index 99c5b7f1..9e4fe3c4 100644
--- a/src-tauri/tests/sqlserver_integration.rs
+++ b/src-tauri/tests/sqlserver_integration.rs
@@ -298,7 +298,7 @@ async fn test_xml_and_complex_types() {
match row.get("unique_id").unwrap() {
QueryValue::String(s) => {
println!("GUID: {}", s);
- assert!(s.len() > 0);
+ assert!(!s.is_empty());
}
_ => panic!("Expected String for GUID"),
}
diff --git a/src/assets/index.css b/src/assets/index.css
index 1bbedb9b..e3d6be92 100644
--- a/src/assets/index.css
+++ b/src/assets/index.css
@@ -73,3 +73,308 @@ body {
opacity: 0.6;
}
}
+
+/* Transfer Console Design System — industrial/utilitarian, dark-first for DBA workflows */
+:root {
+ --transfer-surface: oklch(0.97 0.005 250);
+ --transfer-surface-alt: oklch(0.95 0.008 250);
+ --transfer-border: oklch(0.88 0.01 250);
+ --transfer-text: oklch(0.2 0.015 250);
+ --transfer-text-dim: oklch(0.55 0.02 250);
+ --transfer-accent: oklch(0.55 0.15 190);
+ --transfer-accent-soft: oklch(0.55 0.15 190 / 0.1);
+ --transfer-warn: oklch(0.7 0.18 75);
+ --transfer-success: oklch(0.6 0.15 160);
+ --transfer-console-bg: oklch(0.97 0.005 250);
+ --transfer-console-border: oklch(0.9 0.01 250);
+ --transfer-header-bg: oklch(0.99 0.003 250);
+ --transfer-action-bg: oklch(0.96 0.006 250);
+ --transfer-action-hover: oklch(0.94 0.01 250);
+ --transfer-action-active: oklch(0.55 0.15 190 / 0.08);
+ --transfer-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
+ --transfer-display: 'Sofia Sans', 'Inter', system-ui, sans-serif;
+ --transfer-ui: 'Wix Madefor Text', system-ui, sans-serif;
+}
+
+.dark {
+ --transfer-surface: oklch(0.13 0.015 250);
+ --transfer-surface-alt: oklch(0.16 0.018 250);
+ --transfer-border: oklch(0.2 0.015 250);
+ --transfer-text: oklch(0.92 0.008 250);
+ --transfer-text-dim: oklch(0.5 0.02 250);
+ --transfer-accent: oklch(0.65 0.15 190);
+ --transfer-accent-soft: oklch(0.65 0.15 190 / 0.1);
+ --transfer-warn: oklch(0.75 0.18 75);
+ --transfer-success: oklch(0.65 0.15 160);
+ --transfer-console-bg: oklch(0.09 0.01 250);
+ --transfer-console-border: oklch(0.18 0.015 250);
+ --transfer-header-bg: oklch(0.11 0.012 250);
+ --transfer-action-bg: oklch(0.14 0.015 250);
+ --transfer-action-hover: oklch(0.17 0.018 250);
+ --transfer-action-active: oklch(0.65 0.15 190 / 0.08);
+}
+
+/* ── Transfer Console Component Styles ── */
+.transfer-panel {
+ background: var(--transfer-console-bg);
+ border: 1px solid var(--transfer-console-border);
+ border-radius: 8px;
+}
+
+.transfer-card {
+ background: var(--transfer-surface);
+ border: 1px solid var(--transfer-border);
+ border-radius: 6px;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.transfer-card:hover {
+ border-color: var(--transfer-accent);
+ box-shadow: 0 0 0 1px var(--transfer-accent-soft);
+}
+
+.transfer-card-active {
+ border-color: var(--transfer-accent);
+ background: var(--transfer-action-active);
+ box-shadow: 0 0 0 1px var(--transfer-accent-soft);
+}
+
+.transfer-action-btn {
+ background: var(--transfer-action-bg);
+ border: 1px solid var(--transfer-border);
+ border-radius: 6px;
+ color: var(--transfer-text);
+ font-family: var(--transfer-ui);
+ transition: all 0.12s ease;
+ cursor: pointer;
+}
+
+.transfer-action-btn:hover {
+ background: var(--transfer-action-hover);
+ border-color: var(--transfer-accent);
+}
+
+.transfer-action-btn:active,
+.transfer-action-btn-active {
+ background: var(--transfer-action-active);
+ border-color: var(--transfer-accent);
+ box-shadow: 0 0 0 1px var(--transfer-accent-soft);
+}
+
+.transfer-divider {
+ border: none;
+ border-top: 1px solid var(--transfer-border);
+ margin: 0;
+}
+
+.transfer-mono-label {
+ font-family: var(--transfer-mono);
+ font-size: 0.75rem;
+ letter-spacing: -0.01em;
+}
+
+.transfer-status-dot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.transfer-status-dot.running {
+ background: var(--transfer-accent);
+ box-shadow: 0 0 4px var(--transfer-accent);
+}
+
+.transfer-status-dot.completed {
+ background: var(--transfer-success);
+}
+
+.transfer-status-dot.failed {
+ background: var(--transfer-warn);
+}
+
+/* ── Activity Bar ── */
+.transfer-activity-bar {
+ background: var(--transfer-header-bg);
+ border-top: 1px solid var(--transfer-border);
+ font-family: var(--transfer-ui);
+}
+
+.transfer-activity-drawer {
+ background: var(--transfer-console-bg);
+ border-top: 1px solid var(--transfer-console-border);
+}
+
+/* ── Context Bar ── */
+.transfer-context-bar {
+ background: var(--transfer-header-bg);
+ border-bottom: 1px solid var(--transfer-border);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 16px;
+ height: 40px;
+ font-family: var(--transfer-ui);
+ font-size: 0.8125rem;
+}
+
+.transfer-context-segment {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ color: var(--transfer-text-dim);
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+
+.transfer-context-segment strong {
+ color: var(--transfer-text);
+ font-weight: 600;
+}
+
+.transfer-context-separator {
+ color: var(--transfer-border);
+ font-size: 0.625rem;
+}
+
+/* ── Console Action Buttons ── */
+.transfer-action-tile {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ padding: 12px 16px;
+ background: var(--transfer-action-bg);
+ border: 1px solid var(--transfer-border);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.12s ease;
+ font-family: var(--transfer-ui);
+ text-align: left;
+ width: 100%;
+}
+
+.transfer-action-tile:hover {
+ background: var(--transfer-action-hover);
+ border-color: var(--transfer-accent);
+ transform: translateY(-1px);
+}
+
+.transfer-action-tile:active {
+ transform: translateY(0);
+}
+
+.transfer-action-tile-active {
+ background: var(--transfer-action-active);
+ border-color: var(--transfer-accent);
+ box-shadow: 0 0 0 1px var(--transfer-accent-soft);
+}
+
+.transfer-action-tile-icon {
+ width: 20px;
+ height: 20px;
+ color: var(--transfer-text-dim);
+ transition: color 0.12s ease;
+}
+
+.transfer-action-tile:hover .transfer-action-tile-icon,
+.transfer-action-tile-active .transfer-action-tile-icon {
+ color: var(--transfer-accent);
+}
+
+.transfer-action-tile-title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--transfer-text);
+ font-family: var(--transfer-display);
+}
+
+.transfer-action-tile-desc {
+ font-size: 0.6875rem;
+ color: var(--transfer-text-dim);
+ line-height: 1.3;
+}
+
+/* ── Split Console Panels ── */
+.transfer-console-split {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.transfer-console-section {
+ background: var(--transfer-surface);
+ border: 1px solid var(--transfer-border);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.transfer-console-section-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--transfer-border);
+ font-family: var(--transfer-display);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--transfer-text-dim);
+ background: var(--transfer-surface-alt);
+}
+
+.transfer-console-section-body {
+ padding: 12px;
+}
+
+/* ── Console Summary Bar ── */
+.transfer-summary-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: var(--transfer-surface-alt);
+ border: 1px solid var(--transfer-border);
+ border-radius: 6px;
+ font-family: var(--transfer-mono);
+ font-size: 0.6875rem;
+}
+
+.transfer-summary-stat {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--transfer-text-dim);
+}
+
+.transfer-summary-stat strong {
+ color: var(--transfer-text);
+ font-weight: 600;
+}
+
+/* ── Dialog Overrides ── */
+.transfer-dialog-content {
+ font-family: var(--transfer-ui);
+}
+
+/* ── Scrollbar for transfer panels ── */
+.transfer-scroll::-webkit-scrollbar {
+ width: 4px;
+}
+
+.transfer-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.transfer-scroll::-webkit-scrollbar-thumb {
+ background: var(--transfer-border);
+ border-radius: 2px;
+}
+
+.transfer-scroll::-webkit-scrollbar-thumb:hover {
+ background: var(--transfer-text-dim);
+}
diff --git a/src/components/transfer/launcher/ActionPicker.vue b/src/components/transfer/launcher/ActionPicker.vue
new file mode 100644
index 00000000..8be09b59
--- /dev/null
+++ b/src/components/transfer/launcher/ActionPicker.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+ {{ t(`transfer.launcher.actions.${action.id}.title`) || action.title }}
+
+
+ {{ t(`transfer.launcher.actions.${action.id}.desc`) || action.desc }}
+
+
+
+
+
+
diff --git a/src/components/transfer/launcher/JobsDrawer.vue b/src/components/transfer/launcher/JobsDrawer.vue
new file mode 100644
index 00000000..bff218d6
--- /dev/null
+++ b/src/components/transfer/launcher/JobsDrawer.vue
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('transfer.launcher.tabs.running') }}
+
+ {{ activeJobs.length }}
+
+
+
+ {{ t('transfer.launcher.tabs.history') }}
+
+ {{ historyJobs.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('transfer.launcher.jobsRunning', { count: activeJobs.length }) }}
+
+ {{ overallProgress }}%
+
+
+
+
+ {{ t('transfer.launcher.allJobsCompleted') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/transfer/launcher/OptionsPanel.vue b/src/components/transfer/launcher/OptionsPanel.vue
new file mode 100644
index 00000000..32e2120f
--- /dev/null
+++ b/src/components/transfer/launcher/OptionsPanel.vue
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+ {{ t('transfer.launcher.format') }}
+ updateOption('format', v as LauncherFormat)">
+
+
+
+
+
+ SQL Dump (.sql)
+
+
+ CSV (.csv)
+
+
+ Excel (.xlsx)
+
+
+
+
+
+
+
{{ t('transfer.launcher.destination') }}
+
+
+
+ {{ t('common.buttons.browse') }}
+
+
+
+
+
+ {{ t('transfer.launcher.parallelism') }}
+ updateOption('parallelism', Number(v))">
+
+
+
+
+
+ 1 thread
+
+
+ 2 threads
+
+
+ 4 threads
+
+
+ 8 threads
+
+
+
+
+
+
+
+
+
+
+ updateOption('dropTargetFirst', !!v)"
+ />
+ {{ t('transfer.launcher.dropTargetFirst') }}
+
+
+
+
+ {{ t('transfer.launcher.parallelism') }}
+ updateOption('parallelism', Number(v))">
+
+
+
+
+
+ 1 thread
+
+
+ 2 threads
+
+
+ 4 threads
+
+
+ 8 threads
+
+
+
+
+
+
+
+
+
+ {{ t('transfer.launcher.fileFormat') }}
+ updateOption('fileFormat', v as LauncherFormat)">
+
+
+
+
+
+ SQL Dump (.sql)
+
+
+ CSV (.csv)
+
+
+ Excel (.xlsx)
+
+
+
+
+
+
+
{{ t('transfer.launcher.sourceFile') }}
+
+
+
+ {{ t('common.buttons.browse') }}
+
+
+
+
+
+ {{ t('transfer.launcher.targetTable') }}
+ updateOption('targetTable', String(v))"
+ />
+
+
+
+
+
+
+
diff --git a/src/components/transfer/launcher/PresetsBar.vue b/src/components/transfer/launcher/PresetsBar.vue
new file mode 100644
index 00000000..453fe5b2
--- /dev/null
+++ b/src/components/transfer/launcher/PresetsBar.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+ {{ preset.name }}
+
+
+
+
+ {{ t('transfer.launcher.savePreset') }}
+
+
+
+
+
+
+ {{ t('transfer.launcher.presetNamePrompt') }}
+
+
+
+
+
+
+ {{ t('common.buttons.cancel') }}
+
+
+ {{ t('common.buttons.save') }}
+
+
+
+
+
+
+
+
diff --git a/src/components/transfer/launcher/SourcePicker.vue b/src/components/transfer/launcher/SourcePicker.vue
new file mode 100644
index 00000000..9ecbde3a
--- /dev/null
+++ b/src/components/transfer/launcher/SourcePicker.vue
@@ -0,0 +1,262 @@
+
+
+
+
+
+ {{ t('transfer.launcher.connection') }}
+ state = { ...state, connectionId: v }">
+
+
+
+
+
+ {{ conn.name }}
+
+
+
+
+
+
+
{{ t('transfer.launcher.scope') }}
+
state = { ...state, scope: v as LauncherScope }">
+
+
+ {{ t('transfer.launcher.scopes.server') }}
+
+
+
+ {{ t('transfer.launcher.scopes.database') }}
+
+
+
+ {{ t('transfer.launcher.scopes.table') }}
+
+
+
+
+
+
{{ t('transfer.launcher.database') }}
+
+ state = { ...state, database: v }">
+
+
+
+
+
+ {{ db.name }}
+
+
+
+
+
+
+
+
+
{{ t('transfer.launcher.schema') }}
+
+ state = { ...state, schema: v }">
+
+
+
+
+
+ {{ schema }}
+
+
+
+
+
+
+
+
+
{{ t('transfer.launcher.tables') }}
+
+
+
+ {{ state.tables?.length || 0 }} {{ t('transfer.launcher.selected') }}
+
+
+
+ {{ t('transfer.launcher.selectAll') }}
+
+
+ {{ t('transfer.launcher.clear') }}
+
+
+
+
+
+
+
+
+ {{ t('transfer.launcher.noTables') }}
+
+
+ handleTableToggle(table.name, !!v)" />
+ {{ table.name }}
+
+
+
+
+
+
diff --git a/src/components/transfer/launcher/TargetPicker.vue b/src/components/transfer/launcher/TargetPicker.vue
new file mode 100644
index 00000000..64b4a657
--- /dev/null
+++ b/src/components/transfer/launcher/TargetPicker.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+ {{ t('transfer.launcher.connection') }}
+ state = { ...state, connectionId: v }">
+
+
+
+
+
+ {{ conn.name }}
+
+
+
+
+
+
+
{{ t('transfer.launcher.database') }}
+
+ state = { ...state, database: v }">
+
+
+
+
+
+ {{ db.name }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/transfer/launcher/TransferLauncher.vue b/src/components/transfer/launcher/TransferLauncher.vue
new file mode 100644
index 00000000..c7d6d4ed
--- /dev/null
+++ b/src/components/transfer/launcher/TransferLauncher.vue
@@ -0,0 +1,258 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CONN
+ {{ state.source.connectionId ? 'connected' : '—' }}
+
+
▸
+
+ SCOPE
+ {{ state.source.scope || '—' }}
+
+
▸
+
+ DB
+ {{ state.source.database }}
+
+
▸
+
+ SCHEMA
+ {{ state.source.schema }}
+
+
+
+
+
+
+ {{ state.action }}
+
+
▸
+
+ FMT
+ {{ state.options.format || state.options.fileFormat || '—' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ACTION
+ {{ state.action }}
+
+
+ SCOPE
+ {{ state.source.scope || '—' }}
+
+
+ DB
+ {{ state.source.database }}
+
+
+ TABLES
+ {{ state.source.tables.length }}
+
+
+ FORMAT
+ {{ state.options.format || state.options.fileFormat }}
+
+
+ DEST
+ {{ state.options.destination.split('/').pop() || state.options.destination }}
+
+
+
+ STATUS
+
+ {{ isActionValid ? 'READY' : 'INCOMPLETE' }}
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ t(`transfer.launcher.actions.${state.action}.start`) }}
+
+
+
+
+
+
+
diff --git a/src/components/transfer/launcher/index.ts b/src/components/transfer/launcher/index.ts
new file mode 100644
index 00000000..4167faf7
--- /dev/null
+++ b/src/components/transfer/launcher/index.ts
@@ -0,0 +1,8 @@
+export { default as ActionPicker } from './ActionPicker.vue'
+export { default as JobsDrawer } from './JobsDrawer.vue'
+export { default as OptionsPanel } from './OptionsPanel.vue'
+export { default as PresetsBar } from './PresetsBar.vue'
+export { default as SourcePicker } from './SourcePicker.vue'
+export { default as TargetPicker } from './TargetPicker.vue'
+export { default as TransferLauncher } from './TransferLauncher.vue'
+export type { LauncherAction, LauncherFormat, LauncherOptions, LauncherScope, LauncherSource, LauncherState, LauncherTarget } from './types'
diff --git a/src/components/transfer/launcher/types.ts b/src/components/transfer/launcher/types.ts
new file mode 100644
index 00000000..9318737b
--- /dev/null
+++ b/src/components/transfer/launcher/types.ts
@@ -0,0 +1,37 @@
+export type LauncherAction = 'backup' | 'restore' | 'migrate' | 'export'
+
+export type LauncherScope = 'server' | 'database' | 'table'
+
+export type LauncherFormat = 'sql' | 'csv' | 'excel'
+
+export type LauncherOptions = {
+ format?: LauncherFormat
+ destination?: string
+ parallelism?: number
+ filePath?: string
+ fileFormat?: LauncherFormat
+ targetTable?: string
+ dropTargetFirst?: boolean
+ useSourceNames?: boolean
+ customPrefix?: string
+}
+
+export type LauncherSource = {
+ connectionId?: string
+ scope?: LauncherScope
+ database?: string
+ schema?: string
+ tables?: string[]
+}
+
+export type LauncherTarget = {
+ connectionId?: string
+ database?: string
+}
+
+export type LauncherState = {
+ action: LauncherAction
+ source: LauncherSource
+ target: LauncherTarget
+ options: LauncherOptions
+}
diff --git a/src/datasources/transferApi.ts b/src/datasources/transferApi.ts
index c3944607..5202825a 100644
--- a/src/datasources/transferApi.ts
+++ b/src/datasources/transferApi.ts
@@ -60,14 +60,7 @@ export function generateDdl(request: DdlRequest) {
return invoke('generate_ddl_for_objects', { request })
}
-export function backupServer(
- connectionId: string,
- selection: ObjectSelection,
- format: ExportFormat,
- destination: string,
- options: Record,
- jobId?: string,
-) {
+export function backupServer(connectionId: string, selection: ObjectSelection, format: ExportFormat, destination: string, options: Record, jobId?: string) {
return invoke('backup_server', {
connectionId,
selection,
@@ -78,13 +71,7 @@ export function backupServer(
})
}
-export function migrateServer(
- sourceConnectionId: string,
- targetConnectionId: string,
- selection: ObjectSelection,
- options: Record,
- jobId?: string,
-) {
+export function migrateServer(sourceConnectionId: string, targetConnectionId: string, selection: ObjectSelection, options: Record, jobId?: string) {
return invoke('migrate_server', {
sourceConnectionId,
targetConnectionId,
@@ -94,6 +81,18 @@ export function migrateServer(
})
}
+export function restoreBackup(connectionId: string, targetDatabase: string | undefined, filePath: string, fileFormat: 'sql' | 'csv' | 'excel', targetTable: string | undefined, dropTargetFirst: boolean, jobId?: string) {
+ return invoke('restore_backup', {
+ connectionId,
+ jobId,
+ targetDatabase,
+ filePath,
+ fileFormat,
+ targetTable,
+ dropTargetFirst,
+ })
+}
+
export function saveTransferProfile(profile: TransferProfile) {
return invoke('save_transfer_profile', { profile })
}
diff --git a/src/lang/enUS.ts b/src/lang/enUS.ts
index cf508238..f5f0f7c5 100644
--- a/src/lang/enUS.ts
+++ b/src/lang/enUS.ts
@@ -53,6 +53,74 @@ export const enUS = {
},
},
transfer: {
+ launcher: {
+ title: 'Activity Center',
+ whatDoYouWant: 'What do you want to do?',
+ source: 'Source',
+ target: 'Target',
+ options: 'Options',
+ connection: 'Connection',
+ selectConnection: 'Select connection...',
+ scope: 'Scope',
+ scopes: {
+ server: 'Server',
+ database: 'Database',
+ table: 'Table(s)',
+ },
+ database: 'Database',
+ selectDatabase: 'Select database...',
+ newDatabase: 'New Database',
+ schema: 'Schema',
+ selectSchema: 'Select schema...',
+ tables: 'Tables',
+ noTables: 'No tables found.',
+ selected: 'selected',
+ selectAll: 'Select All',
+ clear: 'Clear',
+ format: 'Format',
+ destination: 'Destination File',
+ parallelism: 'Parallelism',
+ sourceFile: 'Source File',
+ fileFormat: 'File format',
+ targetTable: 'Target table',
+ targetTablePlaceholder: 'schema.table or table',
+ dropTargetFirst: 'Drop target objects first',
+ savePreset: 'Save current',
+ presetNamePrompt: 'Enter preset name:',
+ startSuccess: 'Task started',
+ jobStarted: 'Job has been added to the queue.',
+ startFailed: 'Task failed to start',
+ jobsRunning: '{count} jobs running',
+ allJobsCompleted: 'All jobs completed',
+ noRunningJobs: 'No running jobs',
+ noHistoryJobs: 'No history',
+ tabs: {
+ running: 'Running',
+ history: 'History',
+ },
+ actions: {
+ backup: {
+ title: 'Backup',
+ desc: 'Export database to a file',
+ start: 'Start Backup',
+ },
+ restore: {
+ title: 'Restore',
+ desc: 'Import from a backup file',
+ start: 'Start Restore',
+ },
+ migrate: {
+ title: 'Migrate',
+ desc: 'Transfer to another server',
+ start: 'Start Migrate',
+ },
+ export: {
+ title: 'Export',
+ desc: 'Export table data to CSV/Excel',
+ start: 'Start Export',
+ },
+ },
+ },
title: 'Transfer',
subtitle: 'Import, export, and migrate your data',
tabs: {
diff --git a/src/lang/zhCN.ts b/src/lang/zhCN.ts
index 84e9439a..624329af 100644
--- a/src/lang/zhCN.ts
+++ b/src/lang/zhCN.ts
@@ -53,6 +53,74 @@ export const zhCN = {
},
},
transfer: {
+ launcher: {
+ title: '活动中心',
+ whatDoYouWant: '您想要做什么?',
+ source: '源',
+ target: '目标',
+ options: '选项',
+ connection: '连接',
+ selectConnection: '选择连接...',
+ scope: '范围',
+ scopes: {
+ server: '服务器',
+ database: '数据库',
+ table: '表',
+ },
+ database: '数据库',
+ selectDatabase: '选择数据库...',
+ newDatabase: '新建数据库',
+ schema: '模式 (Schema)',
+ selectSchema: '选择模式...',
+ tables: '表',
+ noTables: '未找到表。',
+ selected: '已选择',
+ selectAll: '全选',
+ clear: '清空',
+ format: '格式',
+ destination: '目标文件',
+ parallelism: '并发数',
+ sourceFile: '源文件',
+ fileFormat: '文件格式',
+ targetTable: '目标表',
+ targetTablePlaceholder: 'schema.表名 或 表名',
+ dropTargetFirst: '导入前删除目标对象',
+ savePreset: '保存当前配置',
+ presetNamePrompt: '输入预设名称:',
+ startSuccess: '任务已开始',
+ jobStarted: '任务已加入队列。',
+ startFailed: '任务启动失败',
+ jobsRunning: '{count} 个任务运行中',
+ allJobsCompleted: '所有任务已完成',
+ noRunningJobs: '没有运行中的任务',
+ noHistoryJobs: '暂无历史记录',
+ tabs: {
+ running: '运行中',
+ history: '历史记录',
+ },
+ actions: {
+ backup: {
+ title: '备份',
+ desc: '将数据库导出到文件',
+ start: '开始备份',
+ },
+ restore: {
+ title: '恢复',
+ desc: '从备份文件导入',
+ start: '开始恢复',
+ },
+ migrate: {
+ title: '迁移',
+ desc: '迁移到另一个服务器',
+ start: '开始迁移',
+ },
+ export: {
+ title: '导出',
+ desc: '导出表数据到 CSV/Excel',
+ start: '开始导出',
+ },
+ },
+ },
title: '传输',
subtitle: '导入、导出和迁移您的数据',
tabs: {
diff --git a/src/pages/TransferPage.vue b/src/pages/TransferPage.vue
index 58f4636f..203b1f07 100644
--- a/src/pages/TransferPage.vue
+++ b/src/pages/TransferPage.vue
@@ -1,299 +1,28 @@
-
+
-
-
-
-
- {{ t('transfer.activityCenter.tabs.active') }}
-
- {{ activeJobs.length }}
-
-
-
- {{ t('transfer.activityCenter.tabs.history') }}
-
- {{ historyJobs.length }}
-
-
-
- {{ t('transfer.activityCenter.tabs.savedProfiles') }}
-
- {{ savedProfiles.length }}
-
-
-
-
-
-
-
-
- {{ t('transfer.activityCenter.empty.active') }}
-
-
-
-
-
-
-
- {{ job.name }}
-
- {{ job.scope }}
-
-
-
{{ job.progress.stage }}
-
-
-
- {{ job.progress.current }} / {{ job.progress.total }}
- {{ Math.ceil(job.progress.etaMs / 1000) }}s remaining
-
-
-
- Cancel
-
-
-
-
-
-
-
-
-
- {{ t('transfer.activityCenter.empty.history') }}
-
-
-
-
- {{ group.date }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ job.name }}
-
-
- {{ job.scope }}
-
-
-
-
-
- {{ formatTime(job.startedAt) }}
-
-
-
- {{ formatDuration(job.startedAt, job.finishedAt) }}
-
-
-
- {{ t('transfer.activityCenter.job.error') }}: {{ job.error }}
-
-
-
-
- {{ t('transfer.activityCenter.job.viewLogs') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('transfer.activityCenter.empty.profiles') }}
-
-
-
-
-
-
- {{ profile.name }}
-
-
- {{ profile.kind }}
-
-
-
- {{ t('transfer.activityCenter.profile.lastRun') }}: {{ formatDate(profile.lastRunAt) }}
-
-
-
-
-
- {{ profile.connectionId }}
-
-
-
-
- {{ t('transfer.activityCenter.profile.edit') }}
-
-
- {{ t('transfer.activityCenter.profile.delete') }}
-
-
-
- {{ t('transfer.activityCenter.profile.run') }}
-
-
-
-
-
-
-
+
+
+
+
diff --git a/src/store/transferStore.ts b/src/store/transferStore.ts
index 4f788837..148d3c2a 100644
--- a/src/store/transferStore.ts
+++ b/src/store/transferStore.ts
@@ -223,7 +223,7 @@ export const useTransferStore = defineStore('transfer', () => {
job.id === jobId
? {
...job,
- status: payload.status as any,
+ status: payload.status as TransferJob['status'],
progress: payload.progress,
error: payload.error,
finishedAt: ['completed', 'failed', 'cancelled'].includes(payload.status) ? Date.now() : job.finishedAt,
@@ -279,6 +279,79 @@ export const useTransferStore = defineStore('transfer', () => {
return jobId
}
+ const startExport = async (args: {
+ connectionId: string
+ name: string
+ selection: ObjectSelection
+ format: ExportFormat
+ destination: string
+ options: Record
+ }) => {
+ const requestedJobId = crypto.randomUUID()
+ await subscribeToJob(requestedJobId)
+
+ const newJob: TransferJob = {
+ id: requestedJobId,
+ name: args.name,
+ kind: 'export',
+ scope: 'table',
+ connectionId: args.connectionId,
+ status: 'queued',
+ progress: { stage: 'Initializing...', current: 0, total: 1 },
+ startedAt: Date.now(),
+ }
+ jobs.value = [...jobs.value, newJob]
+
+ const jobId = await backupServer(
+ args.connectionId,
+ args.selection,
+ args.format,
+ args.destination,
+ args.options,
+ requestedJobId,
+ )
+
+ return jobId
+ }
+
+ const startRestore = async (args: {
+ connectionId: string
+ name: string
+ targetDatabase: string | undefined
+ filePath: string
+ fileFormat: 'sql' | 'csv' | 'excel'
+ targetTable: string | undefined
+ dropTargetFirst: boolean
+ }) => {
+ const requestedJobId = crypto.randomUUID()
+ await subscribeToJob(requestedJobId)
+
+ const newJob: TransferJob = {
+ id: requestedJobId,
+ name: args.name,
+ kind: 'import',
+ scope: 'server',
+ connectionId: args.connectionId,
+ status: 'queued',
+ progress: { stage: 'Initializing...', current: 0, total: 1 },
+ startedAt: Date.now(),
+ }
+ jobs.value = [...jobs.value, newJob]
+
+ const { restoreBackup } = await import('@/datasources/transferApi')
+ const jobId = await restoreBackup(
+ args.connectionId,
+ args.targetDatabase,
+ args.filePath,
+ args.fileFormat,
+ args.targetTable,
+ args.dropTargetFirst,
+ requestedJobId,
+ )
+
+ return jobId
+ }
+
const startMigrateServer = async (args: {
sourceConnectionId: string
targetConnectionId: string
@@ -403,6 +476,8 @@ export const useTransferStore = defineStore('transfer', () => {
savedProfiles,
subscribeToJob,
startBackupServer,
+ startExport,
+ startRestore,
startMigrateServer,
saveProfile,
loadProfiles,