v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling (#9)
* feat: demo DB seed/regenerate, backup/restore/sync refinements, SSH/SSL polish - Demo SQLite DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings - Backup/restore: headless-testable core logic, psql -f for plain dumps, sync passes --clean --if-exists - SSH/SSL runtime refinements and connection testing improvements - Data grid: DataTypeIcon component, FK popover, table tree polish - Docs: AGENTS.md/README updates, new screenshots, MIT LICENSE * chore: optimize README screenshots (4.7 MB → 916 KB via pngquant, quality 70-90) * v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling - Bump version to 0.6.0 across package.json, Cargo.toml, tauri.conf.json - Add .github/workflows/release.yml: tag-triggered CI builds macOS (aarch64 + x64), Windows, and Linux installers into a draft GitHub Release - README: installer download table (unsigned note, per-platform files), "how releases are made" section - CellEditor: constraint-aware commit — empty input on nullable columns becomes NULL, NOT NULL text-like types fall back to empty string, all other types blocked with an inline error bubble; replace "Set NULL" checkbox with a NULL row in the FK dropdown / empty enum option - VirtualDataGrid: pending-edit dot → animated pending outline (ring) on staged cells; matching test updates - docs-coverage test: align with rewritten README comparison table
This commit is contained in:
+333
-374
@@ -3,6 +3,37 @@ use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::models::backup::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection params (decoupled from store/keychain so logic is headless-testable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PgConnParams {
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub username: String,
|
||||
pub database: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl PgConnParams {
|
||||
pub fn new(
|
||||
host: String,
|
||||
port: i64,
|
||||
username: String,
|
||||
database: String,
|
||||
password: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
database,
|
||||
password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -16,6 +47,10 @@ fn get_version(tool: &str) -> Option<String> {
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn sanitize_error(s: &str) -> String {
|
||||
crate::commands::test_connection::sanitize_error(s)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detect_pg_tools
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -31,9 +66,237 @@ pub fn detect_pg_tools() -> PgToolStatus {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pg_dump
|
||||
// Core logic (headless-testable — no Tauri, no store, no keychain)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds the base connection args shared by pg_dump and pg_restore.
|
||||
fn base_conn_args(conn: &PgConnParams) -> Vec<String> {
|
||||
vec![
|
||||
format!("--host={}", conn.host),
|
||||
format!("--port={}", conn.port),
|
||||
format!("--username={}", conn.username),
|
||||
format!("--dbname={}", conn.database),
|
||||
]
|
||||
}
|
||||
|
||||
/// Builds pg_dump args (excluding the --file flag, which is added by the caller).
|
||||
fn build_dump_args(conn: &PgConnParams, options: &BackupOptions) -> Vec<String> {
|
||||
let mut args = base_conn_args(conn);
|
||||
|
||||
match options.format.as_str() {
|
||||
"custom" => args.push("--format=c".into()),
|
||||
"tar" => args.push("--format=t".into()),
|
||||
"directory" => args.push("--format=d".into()),
|
||||
_ => {} // "plain" is the default — no format flag needed
|
||||
}
|
||||
|
||||
if options.no_owner {
|
||||
args.push("--no-owner".into());
|
||||
}
|
||||
|
||||
if let Some(ref schema) = options.schema {
|
||||
args.push(format!("--schema={schema}"));
|
||||
}
|
||||
|
||||
if let Some(ref tables) = options.tables {
|
||||
for t in tables {
|
||||
args.push(format!("--table={t}"));
|
||||
}
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Builds pg_restore args (file path is passed positionally by the caller).
|
||||
fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<String> {
|
||||
let mut args = base_conn_args(conn);
|
||||
|
||||
match options.format.as_str() {
|
||||
"custom" => args.push("--format=c".into()),
|
||||
"tar" => args.push("--format=t".into()),
|
||||
"directory" => args.push("--format=d".into()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if options.clean {
|
||||
args.push("--clean".into());
|
||||
args.push("--if-exists".into());
|
||||
}
|
||||
|
||||
if let Some(ref schema) = options.schema {
|
||||
args.push(format!("--schema={schema}"));
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
|
||||
/// Returns `Ok(())` on success or a sanitized error message.
|
||||
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
|
||||
let mut args = build_dump_args(conn, options);
|
||||
args.push(format!("--file={}", options.file_path));
|
||||
|
||||
let result = Command::new("pg_dump")
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args(&args)
|
||||
.output();
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `pg_restore` against `conn`, reading from `options.file_path`.
|
||||
/// Returns `Ok(())` on success or a sanitized error message.
|
||||
///
|
||||
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
|
||||
/// are executed with `psql` instead. The `clean` option is only honored for
|
||||
/// archive formats (custom/tar/directory); the UI disables it for plain.
|
||||
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
|
||||
if options.format == "plain" {
|
||||
let result = Command::new("psql")
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args([
|
||||
format!("--host={}", conn.host),
|
||||
format!("--port={}", conn.port),
|
||||
format!("--username={}", conn.username),
|
||||
format!("--dbname={}", conn.database),
|
||||
format!("--file={}", options.file_path),
|
||||
])
|
||||
.output();
|
||||
|
||||
return match result {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let mut args = build_restore_args(conn, options);
|
||||
args.push(options.file_path.clone());
|
||||
|
||||
let result = Command::new("pg_restore")
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args(&args)
|
||||
.output();
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(sanitize_error(&String::from_utf8_lossy(&output.stderr))),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a DB-to-DB sync: `pg_dump` on `source` piped into `pg_restore` on `target`.
|
||||
/// Returns `Ok(())` on success or a sanitized error message.
|
||||
pub fn run_db_sync(
|
||||
source: &PgConnParams,
|
||||
target: &PgConnParams,
|
||||
schema: Option<&str>,
|
||||
tables: Option<&[String]>,
|
||||
) -> Result<(), String> {
|
||||
// --- Build pg_dump args ---
|
||||
let mut dump_args = base_conn_args(source);
|
||||
dump_args.push("--format=c".into()); // binary custom format for reliable piping
|
||||
dump_args.push("--no-owner".into());
|
||||
|
||||
if let Some(schema) = schema {
|
||||
dump_args.push(format!("--schema={schema}"));
|
||||
}
|
||||
|
||||
if let Some(tables) = tables {
|
||||
for t in tables {
|
||||
dump_args.push(format!("--table={t}"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build pg_restore args ---
|
||||
// --clean --if-exists makes sync work into a non-empty target (the UI
|
||||
// already requires a destructive-overwrite confirmation).
|
||||
let mut restore_args = base_conn_args(target);
|
||||
restore_args.push("--no-owner".into());
|
||||
restore_args.push("--clean".into());
|
||||
restore_args.push("--if-exists".into());
|
||||
|
||||
// --- Spawn pg_dump with piped stdout ---
|
||||
let mut dump_child = Command::new("pg_dump")
|
||||
.env("PGPASSWORD", &source.password)
|
||||
.args(&dump_args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start pg_dump: {e}"))?;
|
||||
|
||||
let dump_stdout = dump_child.stdout.take().unwrap();
|
||||
let dump_stderr_reader = dump_child.stderr.take().unwrap();
|
||||
|
||||
// Read pg_dump stderr in a separate thread so the pipe doesn't block
|
||||
let dump_stderr_handle = std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
let _ = dump_stderr_reader
|
||||
.take(10 * 1024 * 1024) // cap at 10 MiB
|
||||
.read_to_string(&mut buf);
|
||||
buf
|
||||
});
|
||||
|
||||
// --- Run pg_restore with pg_dump stdout as stdin ---
|
||||
let restore_result = Command::new("pg_restore")
|
||||
.env("PGPASSWORD", &target.password)
|
||||
.args(&restore_args)
|
||||
.stdin(dump_stdout)
|
||||
.output();
|
||||
|
||||
// Wait for pg_dump to finish
|
||||
let dump_status = dump_child.wait();
|
||||
let dump_stderr = dump_stderr_handle.join().unwrap_or_default();
|
||||
|
||||
// --- Check results ---
|
||||
let dump_failed = match dump_status {
|
||||
Ok(status) => !status.success(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
if dump_failed {
|
||||
return Err(format!("pg_dump failed: {}", sanitize_error(&dump_stderr)));
|
||||
}
|
||||
|
||||
match restore_result {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(format!(
|
||||
"pg_restore failed: {}",
|
||||
sanitize_error(&String::from_utf8_lossy(&output.stderr))
|
||||
)),
|
||||
Err(e) => Err(format!("pg_restore failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>) {
|
||||
let event = match result {
|
||||
Ok(()) => BackupProgressEvent {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".into(),
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
},
|
||||
Err(e) => BackupProgressEvent {
|
||||
job_id: job_id.to_string(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(e),
|
||||
},
|
||||
};
|
||||
let _ = app_handle.emit("backup-progress", event);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pg_dump(
|
||||
connection_id: String,
|
||||
@@ -45,10 +308,7 @@ pub async fn pg_dump(
|
||||
|
||||
// Get connection from store (scope the std::sync::Mutex lock guard)
|
||||
let conn = {
|
||||
let store = state
|
||||
.db_store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
connections
|
||||
.into_iter()
|
||||
@@ -57,112 +317,30 @@ pub async fn pg_dump(
|
||||
};
|
||||
|
||||
// Get password from keychain
|
||||
let password = crate::commands::keychain::get_connection_password_internal(
|
||||
&app_handle,
|
||||
&connection_id,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
let password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract connection fields before moving into spawn_blocking
|
||||
let host = conn.host.clone();
|
||||
let port = conn.port.unwrap_or(5432);
|
||||
let username = conn.username.unwrap_or_else(|| "postgres".into());
|
||||
let database = conn.database.unwrap_or_else(|| "postgres".into());
|
||||
let file_path = options.file_path.clone();
|
||||
let format = options.format.clone();
|
||||
let no_owner = options.no_owner;
|
||||
let schema = options.schema.clone();
|
||||
let tables = options.tables.clone();
|
||||
let params = PgConnParams::new(
|
||||
conn.host.clone(),
|
||||
conn.port.unwrap_or(5432),
|
||||
conn.username.unwrap_or_else(|| "postgres".into()),
|
||||
conn.database.unwrap_or_else(|| "postgres".into()),
|
||||
password,
|
||||
);
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut args: Vec<String> = vec![
|
||||
format!("--host={host}"),
|
||||
format!("--port={port}"),
|
||||
format!("--username={username}"),
|
||||
format!("--dbname={database}"),
|
||||
];
|
||||
|
||||
match format.as_str() {
|
||||
"custom" => args.push("--format=c".into()),
|
||||
"tar" => args.push("--format=t".into()),
|
||||
"directory" => args.push("--format=d".into()),
|
||||
_ => {} // "plain" is the default — no format flag needed
|
||||
}
|
||||
|
||||
if no_owner {
|
||||
args.push("--no-owner".into());
|
||||
}
|
||||
|
||||
if let Some(ref schema) = schema {
|
||||
args.push(format!("--schema={schema}"));
|
||||
}
|
||||
|
||||
if let Some(ref tables) = tables {
|
||||
for t in tables {
|
||||
args.push(format!("--table={t}"));
|
||||
}
|
||||
}
|
||||
|
||||
args.push(format!("--file={file_path}"));
|
||||
|
||||
let result = Command::new("pg_dump")
|
||||
.env("PGPASSWORD", &password)
|
||||
.args(&args)
|
||||
.output();
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "completed".into(),
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(
|
||||
crate::commands::test_connection::sanitize_error(&stderr),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let result = run_pg_dump(¶ms, &options);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pg_restore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pg_restore(
|
||||
connection_id: String,
|
||||
@@ -173,10 +351,7 @@ pub async fn pg_restore(
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let conn = {
|
||||
let store = state
|
||||
.db_store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
connections
|
||||
.into_iter()
|
||||
@@ -184,105 +359,30 @@ pub async fn pg_restore(
|
||||
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
|
||||
};
|
||||
|
||||
let password = crate::commands::keychain::get_connection_password_internal(
|
||||
&app_handle,
|
||||
&connection_id,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
let password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let host = conn.host.clone();
|
||||
let port = conn.port.unwrap_or(5432);
|
||||
let username = conn.username.unwrap_or_else(|| "postgres".into());
|
||||
let database = conn.database.unwrap_or_else(|| "postgres".into());
|
||||
let file_path = options.file_path.clone();
|
||||
let format = options.format.clone();
|
||||
let clean = options.clean;
|
||||
let schema = options.schema.clone();
|
||||
let params = PgConnParams::new(
|
||||
conn.host.clone(),
|
||||
conn.port.unwrap_or(5432),
|
||||
conn.username.unwrap_or_else(|| "postgres".into()),
|
||||
conn.database.unwrap_or_else(|| "postgres".into()),
|
||||
password,
|
||||
);
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut args: Vec<String> = vec![
|
||||
format!("--host={host}"),
|
||||
format!("--port={port}"),
|
||||
format!("--username={username}"),
|
||||
format!("--dbname={database}"),
|
||||
];
|
||||
|
||||
match format.as_str() {
|
||||
"custom" => args.push("--format=c".into()),
|
||||
"tar" => args.push("--format=t".into()),
|
||||
"directory" => args.push("--format=d".into()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if clean {
|
||||
args.push("--clean".into());
|
||||
args.push("--if-exists".into());
|
||||
}
|
||||
|
||||
if let Some(ref schema) = schema {
|
||||
args.push(format!("--schema={schema}"));
|
||||
}
|
||||
|
||||
args.push(file_path.clone());
|
||||
|
||||
let result = Command::new("pg_restore")
|
||||
.env("PGPASSWORD", &password)
|
||||
.args(&args)
|
||||
.output();
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "completed".into(),
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(
|
||||
crate::commands::test_connection::sanitize_error(&stderr),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let result = run_pg_restore(¶ms, &options);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// db_sync (pg_dump | pg_restore via Unix pipe)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_sync(
|
||||
options: SyncOptions,
|
||||
@@ -293,10 +393,7 @@ pub async fn db_sync(
|
||||
|
||||
// Get both connections from store
|
||||
let (source_conn, target_conn) = {
|
||||
let store = state
|
||||
.db_store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
|
||||
let src = connections
|
||||
@@ -325,190 +422,52 @@ pub async fn db_sync(
|
||||
};
|
||||
|
||||
// Get passwords
|
||||
let src_password = crate::commands::keychain::get_connection_password_internal(
|
||||
&app_handle,
|
||||
&source_conn.id,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
let src_password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &source_conn.id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let tgt_password = crate::commands::keychain::get_connection_password_internal(
|
||||
&app_handle,
|
||||
&target_conn.id,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
let tgt_password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &target_conn.id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract connection fields
|
||||
let src_host = source_conn.host.clone();
|
||||
let src_port = source_conn.port.unwrap_or(5432);
|
||||
let src_username = source_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
let src_database = source_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
let source = PgConnParams::new(
|
||||
source_conn.host.clone(),
|
||||
source_conn.port.unwrap_or(5432),
|
||||
source_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into()),
|
||||
source_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into()),
|
||||
src_password,
|
||||
);
|
||||
|
||||
let tgt_host = target_conn.host.clone();
|
||||
let tgt_port = target_conn.port.unwrap_or(5432);
|
||||
let tgt_username = target_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
let tgt_database = target_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into());
|
||||
let target = PgConnParams::new(
|
||||
target_conn.host.clone(),
|
||||
target_conn.port.unwrap_or(5432),
|
||||
target_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into()),
|
||||
target_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "postgres".into()),
|
||||
tgt_password,
|
||||
);
|
||||
|
||||
let schema = options.schema.clone();
|
||||
let tables = options.tables.clone();
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// --- Build pg_dump args ---
|
||||
let mut dump_args: Vec<String> = vec![
|
||||
format!("--host={src_host}"),
|
||||
format!("--port={src_port}"),
|
||||
format!("--username={src_username}"),
|
||||
format!("--dbname={src_database}"),
|
||||
"--format=c".into(), // binary custom format for reliable piping
|
||||
"--no-owner".into(),
|
||||
];
|
||||
|
||||
if let Some(ref schema) = schema {
|
||||
dump_args.push(format!("--schema={schema}"));
|
||||
}
|
||||
|
||||
if let Some(ref tables) = tables {
|
||||
for t in tables {
|
||||
dump_args.push(format!("--table={t}"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build pg_restore args ---
|
||||
let restore_args: Vec<String> = vec![
|
||||
format!("--host={tgt_host}"),
|
||||
format!("--port={tgt_port}"),
|
||||
format!("--username={tgt_username}"),
|
||||
format!("--dbname={tgt_database}"),
|
||||
"--no-owner".into(),
|
||||
];
|
||||
|
||||
// --- Spawn pg_dump with piped stdout ---
|
||||
let mut dump_child = match Command::new("pg_dump")
|
||||
.env("PGPASSWORD", &src_password)
|
||||
.args(&dump_args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(format!("Failed to start pg_dump: {e}")),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let dump_stdout = dump_child.stdout.take().unwrap();
|
||||
let dump_stderr_reader = dump_child.stderr.take().unwrap();
|
||||
|
||||
// Read pg_dump stderr in a separate thread so the pipe doesn't block
|
||||
let dump_stderr_handle = std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
let _ = dump_stderr_reader
|
||||
.take(10 * 1024 * 1024) // cap at 10 MiB
|
||||
.read_to_string(&mut buf);
|
||||
buf
|
||||
});
|
||||
|
||||
// --- Run pg_restore with pg_dump stdout as stdin ---
|
||||
let restore_result = Command::new("pg_restore")
|
||||
.env("PGPASSWORD", &tgt_password)
|
||||
.args(&restore_args)
|
||||
.stdin(dump_stdout)
|
||||
.output();
|
||||
|
||||
// Wait for pg_dump to finish
|
||||
let dump_status = dump_child.wait();
|
||||
let dump_stderr = dump_stderr_handle.join().unwrap_or_default();
|
||||
|
||||
// --- Check results ---
|
||||
let dump_failed = match dump_status {
|
||||
Ok(status) => !status.success(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
if dump_failed {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(format!(
|
||||
"pg_dump failed: {}",
|
||||
crate::commands::test_connection::sanitize_error(&dump_stderr)
|
||||
)),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
match restore_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "completed".into(),
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(format!(
|
||||
"pg_restore failed: {}",
|
||||
crate::commands::test_connection::sanitize_error(&stderr)
|
||||
)),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "failed".into(),
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(format!("pg_restore failed: {e}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
@@ -574,4 +533,4 @@ pub(crate) fn build_args_for_test(
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "backup.test.rs"]
|
||||
mod tests;
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user