Files
gridline/src-tauri/src/commands/backup.rs
T
adrianbonpin 04e8ed3300 v0.7.5: bundled PG tools, schema CRUD, object search, copy-as-DDL, object dependencies (#11)
* chore: bump version to 0.7.5 (Task 1.1)

* feat(db): pure object DDL/search/depend builders (Task 1.2)

* feat(models): ObjectSearchHit, DependencyInfo, PgToolPaths, tool source fields (Task 1.3)

* feat(backup): bundle-aware pg tool resolution, system-first fallback (Task 2.1)

* feat(objects): schema CRUD commands + integration test (Task 2.2)

* feat(objects): search_objects command (current-schema, all types) (Task 2.3)

* feat(objects): get_object_ddl for all browsable types (Task 2.4)

* feat(objects): pg_depend object dependencies + schema contents (Task 2.5)

* feat(ipc): register object management commands (Task 3.1)

* feat(ipc): frontend wrappers + types for object management (Task 3.2)

* feat(build): declare bundled pg_tools resources (Task 3.3)

* test(capabilities): lock objects capability PG-only for search/ddl/dependencies (Task 3.4)

* feat(ui): Cmd+K object search palette in DB viewer (Task 4.1)

* feat(ui): schema CRUD menu + dependency dialog (Task 4.2)

* feat(ui): copy-as-DDL + dependency view context menu in Objects (Task 4.3)

* feat(ui): dependency check before table drop + bundled-tool status (Task 4.4)

* docs: v0.7.5 release notes + status table + bundled-tools (Task 5.1)

* ci: build/verify bundled pg client tools per platform before tauri build (Task 5.2)

* fix(objects): report pg_rewrite dependencies as the dependent view (pg_class)

pg_depend records view dependencies via the view's internal rewrite rule
(classid = pg_rewrite). The user-facing dependent object is the VIEW itself,
so map that classid to pg_class (name still resolved through ev_class).
Fixes the live-PG integration test object_dependencies_for_table_includes_view
and makes the dependency dialog readable for view dependents.

* test: add idempotent PG integration-test seed script

Seeds the first PG test db (GRIDLINE_TEST_SRC) with the objects the
#[ignore] integration tests assert against: users (+users_id_seq),
products (3 rows), orders (3 rows), order_summary view, audit_log +
get_user functions, user_role enum. Idempotent: DROP + recreate.

* fix(build): regenerate multi-resolution icons + roadmap Keychain item

- icon.ico previously contained a single 16x16 frame (Windows scaled it
  up -> blurry taskbar/start-menu icon). Regenerated from the 512px source
  via 'tauri icon': ICO now has 16/24/32/48/64/256 frames, icns has full
  @2x coverage up to 1024px, PNGs re-rendered from the same source.
- Added icons/icon.png (512px) to bundle.icon so Linux hicolor installs
  a high-DPI entry.
- ROADMAP: log the 'Enable Keychain' toggle (currently a form-only
  placeholder) under Next up -> Connection & credentials.

* docs(readme): dynamic version-free download links + 3-col table

- release.yml: set releaseAssetNamePattern to '[name]_[platform]_[arch][setup][ext]'
  (version-free), so asset filenames are stable across releases:
  Gridline_darwin_aarch64.dmg, Gridline_windows_x64-setup.exe,
  Gridline_linux_amd64.deb, Gridline_linux_x86_64.rpm, etc.
- README: both download tables now 3-column (OS | Architecture | Download)
  and link via GitHub's releases/latest/download/<file> redirect — they
  always point at the newest published release, no per-release edits.
- AGENTS.md: releases checklist updated — download links stay version-free.

* docs(readme): platform-per-column download table (macOS | Windows | Linux)

Table now mirrors the release layout: one column per OS with a logo row
and a download-links row underneath. Links stay version-free via
releases/latest/download/<file> (releaseAssetNamePattern in release.yml).

* docs(readme): revert download table to clean image-less 3-col layout

Logo row was hard to read on GitHub dark mode; the plain OS | Architecture
| Download table is cleaner and still uses dynamic releases/latest links.
2026-08-05 20:46:36 +08:00

584 lines
18 KiB
Rust

use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, Manager, 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
// ---------------------------------------------------------------------------
fn get_version(tool: &str) -> Option<String> {
Command::new(tool)
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s)
}
fn bundled_bin_name(tool: &str) -> String {
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
}
/// Pure resolution decision (unit-testable): system > bundled > bare name.
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
if system_ok { return (tool.to_string(), Some("system".to_string())); }
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
(tool.to_string(), None)
}
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
}
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump");
let (r, _) = resolve_tool(app, "pg_restore");
let (p, _) = resolve_tool(app, "psql");
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
/// Shapes the tool status from resolved tool paths + sources. Headless so the
/// Tauri command stays thin and the status logic stays unit-testable.
fn build_pg_tool_status(
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
PgToolStatus {
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
pg_dump_version: get_version(dump),
pg_restore_version: get_version(restore),
pg_dump_source: dump_src,
pg_restore_source: restore_src,
}
}
#[tauri::command]
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus {
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump");
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore");
build_pg_tool_status(&dump, &restore, dump_src, restore_src)
}
// ---------------------------------------------------------------------------
// 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, tools: &PgToolPaths) -> Result<(), String> {
let mut args = build_dump_args(conn, options);
args.push(format!("--file={}", options.file_path));
let result = Command::new(&tools.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, tools: &PgToolPaths) -> Result<(), String> {
if options.format == "plain" {
let result = Command::new(&tools.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(&tools.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]>,
tools: &PgToolPaths,
) -> 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(&tools.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(&tools.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,
options: BackupOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
// 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 connections = store.get_connections().map_err(|e| e.to_string())?;
connections
.into_iter()
.find(|c| c.id == connection_id)
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
};
// Get password from keychain
let password =
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
.unwrap_or_default()
.unwrap_or_default();
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();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_dump(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn pg_restore(
connection_id: String,
options: RestoreOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
let conn = {
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()
.find(|c| c.id == connection_id)
.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 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();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_restore(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
#[tauri::command]
pub async fn db_sync(
options: SyncOptions,
state: State<'_, crate::AppState>,
app_handle: AppHandle,
) -> Result<String, String> {
let job_id = uuid::Uuid::new_v4().to_string();
// Get both connections from store
let (source_conn, target_conn) = {
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
.iter()
.find(|c| c.id == options.source_connection_id)
.ok_or_else(|| {
format!(
"Source connection not found: {}",
options.source_connection_id
)
})?
.clone();
let tgt = connections
.iter()
.find(|c| c.id == options.target_connection_id)
.ok_or_else(|| {
format!(
"Target connection not found: {}",
options.target_connection_id
)
})?
.clone();
(src, tgt)
};
// Get passwords
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 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 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();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref(), &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
Ok(job_id)
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/// Builds command-line args using hardcoded values for testing.
/// Mirrors the logic used by `pg_dump` and `pg_restore` commands.
#[cfg(test)]
pub(crate) fn build_args_for_test(
tool: &str,
dbname: &str,
format: &str,
file_path: &str,
no_owner: bool,
schema: Option<&str>,
tables: Option<Vec<&str>>,
) -> Vec<String> {
let mut args: Vec<String> = vec![
"--host=localhost".into(),
"--port=5432".into(),
"--username=postgres".into(),
format!("--dbname={dbname}"),
];
match format {
"custom" => args.push("--format=c".into()),
"tar" => args.push("--format=t".into()),
"directory" => args.push("--format=d".into()),
_ => {} // "plain" is default
}
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}"));
}
}
if tool == "pg_dump" {
args.push(format!("--file={file_path}"));
} else {
// pg_restore
args.push(file_path.into());
}
args
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "backup.test.rs"]
mod tests;