v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)

* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1)

* [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2)

* [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3)

* [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1)

* [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2)

* [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3)

* [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4)

* [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5)

* [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1)

* [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2)

* [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3)

* [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1)

* [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2)

* [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3)

* [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4)

* [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5)

* [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6)

* [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1)

* [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2)

* fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast

* fix(export): Excel export from overflow menu did nothing + add export success/error toasts

* fix(export): tree kebab export fetches table data when rows not loaded

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
This commit is contained in:
2026-08-08 00:09:36 +08:00
committed by GitHub
parent 9fb3222956
commit 32a7b852ec
59 changed files with 4068 additions and 572 deletions
+250 -11
View File
@@ -17,6 +17,30 @@ use std::time::Instant;
use tauri::State;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// Cancel-error classification
// ---------------------------------------------------------------------------
// The wrapped→raw fallback exists for queries that can't be wrapped (CTEs,
// multi-statement, non-SELECT). A USER CANCELLATION is NOT a wrapping failure:
// swallowing it would re-run the very query the user just cancelled — and for
// SQLite the interrupt flag is consumed by the aborted step, so the re-run
// runs completely free. These helpers let the fallbacks propagate cancellations.
fn is_sqlite_cancel_error(e: &rusqlite::Error) -> bool {
e.sqlite_error_code() == Some(rusqlite::ErrorCode::OperationInterrupted)
}
fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
}
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
match e.as_database_error().and_then(|d| d.code()) {
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
_ => e.to_string().to_lowercase().contains("interrupted"),
}
}
// ---------------------------------------------------------------------------
// QueryHistoryEntry
// ---------------------------------------------------------------------------
@@ -87,6 +111,7 @@ impl From<crate::store::SavedQueryRow> for SavedQueryCommand {
pub(crate) async fn execute_query_inner(
pool_manager: &mut crate::db::pool::ConnectionPoolManager,
db_store: &std::sync::Mutex<crate::store::Store>,
cancel_registry: &crate::cancel::CancelRegistry,
connection_id: &str,
query: &str,
page: i64,
@@ -101,7 +126,25 @@ pub(crate) async fn execute_query_inner(
execute_pg_query(client, query, page, page_size).await
}
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
Some(DbHandle::MySql(pool)) => execute_mysql_query(pool, query, page, page_size).await,
Some(DbHandle::MySql(pool)) => {
// Run on a dedicated pooled connection so the query's MySQL
// CONNECTION_ID can be tracked for cancellation (`KILL QUERY ?`).
let mut conn = pool
.acquire()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
// CAST to SIGNED: MySQL returns CONNECTION_ID() as BIGINT UNSIGNED,
// which sqlx refuses to decode into i64 (ColumnDecode error) — a
// silent unwrap_or(-1) here would make KILL QUERY -1 fail.
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap_or(-1);
cancel_registry.set_mysql_conn_id(connection_id, Some(conn_id));
let result = execute_mysql_query_on(&mut *conn, query, page, page_size).await;
cancel_registry.set_mysql_conn_id(connection_id, None);
result
}
None => {
let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string();
@@ -210,6 +253,7 @@ async fn execute_pg_query(
// Try the wrapped count query first — if this fails we fall back to raw.
let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await {
Ok(row) => row.get::<_, i64>(0),
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_pg_raw(client, trimmed, page, page_size, off).await;
@@ -219,6 +263,7 @@ async fn execute_pg_query(
// Now execute the wrapped data query.
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
Ok(rows) => rows,
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => {
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
@@ -381,6 +426,7 @@ fn execute_sqlite_query(
// Try the wrapped count query first.
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
Ok(n) => n,
Err(e) if is_sqlite_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
@@ -390,6 +436,11 @@ fn execute_sqlite_query(
// Execute the wrapped data query.
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
Ok(result) => result,
// The data-step error is already a String (mapped inside
// execute_sqlite_with_query); classify by the interrupt message.
Err(e) if e.to_lowercase().contains("interrupted") => {
return Err("Query cancelled".to_string());
}
Err(_) => {
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
}
@@ -533,8 +584,8 @@ pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde
serde_json::Value::Null
}
async fn execute_mysql_query(
pool: &sqlx::MySqlPool,
async fn execute_mysql_query_on(
conn: &mut sqlx::mysql::MySqlConnection,
query: &str,
page: i64,
page_size: i64,
@@ -547,21 +598,23 @@ async fn execute_mysql_query(
// Try the wrapped count first; fall back to raw on failure.
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
.fetch_one(pool)
.fetch_one(&mut *conn)
.await
{
Ok(n) => n,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
};
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
.bind(page_size)
.bind(off)
.fetch_all(pool)
.fetch_all(&mut *conn)
.await
{
Ok(rows) => rows,
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
};
let columns: Vec<ColumnInfo> = match data_rows.first() {
@@ -580,7 +633,7 @@ async fn execute_mysql_query(
is_generated: false,
})
.collect(),
None => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
None => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
};
let rows: Vec<Vec<serde_json::Value>> = data_rows
@@ -602,14 +655,14 @@ async fn execute_mysql_query(
/// mirroring the PG `simple_query` raw path. Used when wrapping fails
/// (e.g., multi-statement or non-selectable SQL).
async fn execute_mysql_raw(
pool: &sqlx::MySqlPool,
conn: &mut sqlx::mysql::MySqlConnection,
query: &str,
page: i64,
page_size: i64,
off: i64,
) -> Result<QueryResult, String> {
let rows = sqlx::query(query)
.fetch_all(pool)
.fetch_all(&mut *conn)
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
@@ -748,7 +801,70 @@ pub async fn execute_query(
let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50);
let mut pm = state.pool_manager.lock().await;
execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await
execute_query_inner(
&mut pm,
&state.db_store,
&state.cancel_registry,
&connection_id,
&query,
p,
ps,
)
.await
}
/// Cancel a query currently running on the given connection.
///
/// - PostgreSQL: opens a short-lived cancel connection (reusing the exact TLS
/// decision/config from connect) and sends a cancel request keyed to the
/// original backend.
/// - MySQL: opens a fresh connection and runs `KILL QUERY <conn_id>` for the
/// connection currently running the query (registered per-query).
/// - SQLite: signals the per-connection `InterruptHandle` (thread-safe).
#[tauri::command]
pub async fn cancel_query(
connection_id: String,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
use sqlx::ConnectOptions;
match state.cancel_registry.get(&connection_id) {
Some(crate::cancel::CancelHandle::Pg(pg)) => {
// A Verify/Require decision always carries a built rustls config,
// so the unwrap on the non-Disable branch is safe by construction.
match pg.tls_decision {
crate::db::tls::TlsDecision::Disable => {
pg.cancel_token.cancel_query(tokio_postgres::NoTls).await
}
_ => {
let connector = tokio_postgres_rustls::MakeRustlsConnect::new(
(*pg.tls_config.expect("tls config for non-disable decision")).clone(),
);
pg.cancel_token.cancel_query(connector).await
}
}
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
}
Some(crate::cancel::CancelHandle::MySql(m)) => {
let id = m
.conn_id
.ok_or_else(|| "No active query on this connection".to_string())?;
let mut c = m
.connect_options
.connect()
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
sqlx::query(&format!("KILL QUERY {id}"))
.execute(&mut c)
.await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
Ok(())
}
Some(crate::cancel::CancelHandle::Sqlite(s)) => {
s.interrupt();
Ok(())
}
None => Err("No active cancel handle for this connection".into()),
}
}
#[tauri::command]
@@ -1138,4 +1254,127 @@ mod tests {
let q = mysql_wrap_count("SELECT * FROM t");
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
}
// ------------------------------------------------------------------
// Cancel propagation (v0.7.8 bugfix): a user cancel must NOT be swallowed
// by the wrapped→raw fallback (which would re-run the cancelled query).
// ------------------------------------------------------------------
#[test]
fn is_sqlite_cancel_error_classifies_interrupt() {
let interrupted = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_INTERRUPT),
Some("interrupted".to_string()),
);
assert!(is_sqlite_cancel_error(&interrupted));
let other = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
Some("SQL logic error".to_string()),
);
assert!(!is_sqlite_cancel_error(&other));
}
#[test]
fn sqlite_cancel_aborts_wrapped_query_without_rerun() {
use std::sync::mpsc;
// A slow query whose wrapped COUNT step takes seconds — interrupt() must
// abort it and surface "Query cancelled" instead of falling back to the
// raw re-run (which would consume the interrupt flag and run to
// completion, hiding the cancellation).
let conn = rusqlite::Connection::open_in_memory().unwrap();
let handle = conn.get_interrupt_handle();
let slow = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 50000000) SELECT count(*) AS n FROM c";
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let res = execute_sqlite_query(&conn, slow, 1, 50);
let cancelled = match &res {
Err(msg) => msg.contains("Query cancelled"),
Ok(_) => false,
};
let _ = tx.send(cancelled);
});
std::thread::sleep(std::time::Duration::from_millis(50));
handle.interrupt();
let cancelled = rx
.recv_timeout(std::time::Duration::from_secs(15))
.expect("query thread must finish");
assert!(cancelled, "cancel must abort the wrapped query with 'Query cancelled' instead of re-running it");
}
#[tokio::test]
#[ignore]
async fn pg_cancel_aborts_wrapped_query_without_rerun() {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
.unwrap_or_else(|_| "5432".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
)
.await
.expect("connect to test PG");
let handle = tokio::spawn(async move {
let _ = conn.await;
});
let token = client.cancel_token();
let run = tokio::spawn(async move {
execute_pg_query(&client, "SELECT pg_sleep(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
token
.cancel_query(tokio_postgres::NoTls)
.await
.expect("cancel request");
let res = run.await.expect("query task");
assert!(res.is_err(), "pg_sleep must be cancelled, not re-run; got {res:?}");
assert!(res.unwrap_err().contains("Query cancelled"));
let _ = handle;
}
#[tokio::test]
#[ignore]
async fn mysql_cancel_aborts_wrapped_query_without_rerun() {
use sqlx::ConnectOptions;
let h = std::env::var("GRIDLINE_TEST_MYSQL_HOST").expect("set GRIDLINE_TEST_MYSQL_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
.unwrap_or_else(|_| "3306".into())
.parse()
.unwrap();
let u = std::env::var("GRIDLINE_TEST_MYSQL_USER").expect("set GRIDLINE_TEST_MYSQL_USER");
let pw = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").unwrap_or_default();
let opts = sqlx::mysql::MySqlConnectOptions::new()
.host(&h)
.port(p)
.username(&u)
.password(&pw)
.database(&db);
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_with(opts.clone())
.await
.expect("connect to test MySQL");
let mut conn = pool.acquire().await.expect("acquire");
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
.fetch_one(&mut *conn)
.await
.unwrap();
let run = tokio::spawn(async move {
execute_mysql_query_on(&mut *conn, "SELECT SLEEP(3)", 1, 50).await
});
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let mut killer = opts.connect().await.expect("killer connect");
sqlx::query(&format!("KILL QUERY {conn_id}"))
.execute(&mut killer)
.await
.expect("kill");
let res = run.await.expect("query task");
assert!(res.is_err(), "SLEEP(3) must be killed, not re-run; got {res:?}");
assert!(res.unwrap_err().contains("Query cancelled"));
}
}