* [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)
164 lines
5.6 KiB
Rust
164 lines
5.6 KiB
Rust
//! Per-connection cancellation handles, stored independently of the pool
|
|
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use rusqlite::InterruptHandle;
|
|
use sqlx::mysql::MySqlConnectOptions;
|
|
use tokio_postgres::CancelToken;
|
|
|
|
use crate::db::tls::TlsDecision;
|
|
|
|
/// PostgreSQL cancel data. `cancel_token` stores the socket address (incl.
|
|
/// SSH tunnel endpoint) so `cancel_query` needs no host. `tls_config` is the
|
|
/// rustls config used to **build** the connection (or `None` for NoTls) so the
|
|
/// cancel connection reuses the exact same TLS decision.
|
|
#[derive(Clone)]
|
|
pub struct PgCancel {
|
|
pub cancel_token: CancelToken,
|
|
pub tls_decision: TlsDecision,
|
|
pub tls_config: Option<std::sync::Arc<rustls::ClientConfig>>,
|
|
}
|
|
|
|
/// MySQL cancel data. `conn_id` is the `CONNECTION_ID()` of the dedicated
|
|
/// connection currently running a query (one active per connection because the
|
|
/// pool lock serializes queries). `connect_options` lets `cancel` open a
|
|
/// brand-new connection (bypassing the pool) to run `KILL QUERY ?`.
|
|
#[derive(Clone)]
|
|
pub struct MySqlCancel {
|
|
pub conn_id: Option<i64>,
|
|
pub connect_options: MySqlConnectOptions,
|
|
}
|
|
|
|
/// SQLite cancel data — a cloneable, thread-safe interrupt handle.
|
|
/// (`InterruptHandle` itself is not `Clone` in rusqlite 0.31, so it's kept
|
|
/// behind an `Arc`.)
|
|
#[derive(Clone)]
|
|
pub struct SqliteCancel {
|
|
handle: Arc<InterruptHandle>,
|
|
}
|
|
|
|
impl SqliteCancel {
|
|
pub fn new(handle: InterruptHandle) -> Self {
|
|
Self { handle: Arc::new(handle) }
|
|
}
|
|
pub fn interrupt(&self) {
|
|
self.handle.interrupt();
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub enum CancelHandle {
|
|
Pg(PgCancel),
|
|
MySql(MySqlCancel),
|
|
Sqlite(SqliteCancel),
|
|
}
|
|
|
|
/// Send + Sync registry keyed by connection id. `cancel_query` takes only
|
|
/// this `Mutex` (NOT the pool lock).
|
|
#[derive(Default)]
|
|
pub struct CancelRegistry {
|
|
map: Mutex<HashMap<String, CancelHandle>>,
|
|
}
|
|
|
|
impl CancelRegistry {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn set_pg(&self, id: &str, c: PgCancel) {
|
|
self.set(id, CancelHandle::Pg(c));
|
|
}
|
|
pub fn set_mysql(&self, id: &str, c: MySqlCancel) {
|
|
self.set(id, CancelHandle::MySql(c));
|
|
}
|
|
pub fn set_sqlite(&self, id: &str, c: SqliteCancel) {
|
|
self.set(id, CancelHandle::Sqlite(c));
|
|
}
|
|
pub fn set_mysql_conn_id(&self, id: &str, conn_id: Option<i64>) {
|
|
let mut g = self.map.lock().unwrap();
|
|
if let Some(CancelHandle::MySql(m)) = g.get_mut(id) {
|
|
m.conn_id = conn_id;
|
|
}
|
|
}
|
|
fn set(&self, id: &str, h: CancelHandle) {
|
|
self.map.lock().unwrap().insert(id.to_string(), h);
|
|
}
|
|
pub fn get(&self, id: &str) -> Option<CancelHandle> {
|
|
self.map.lock().unwrap().get(id).cloned()
|
|
}
|
|
pub fn remove(&self, id: &str) {
|
|
self.map.lock().unwrap().remove(id);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sqlite_insert_and_drain() {
|
|
let reg = CancelRegistry::new();
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
let handle = conn.get_interrupt_handle();
|
|
reg.set_sqlite("c1", SqliteCancel::new(handle));
|
|
assert!(matches!(reg.get("c1"), Some(CancelHandle::Sqlite(_))));
|
|
reg.remove("c1");
|
|
assert!(reg.get("c1").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_interrupt_aborts_running_query() {
|
|
use std::sync::{Arc, Mutex};
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
conn.execute_batch("CREATE TABLE t(n); INSERT INTO t VALUES (0);")
|
|
.unwrap();
|
|
let handle = conn.get_interrupt_handle();
|
|
let conn2 = Arc::new(Mutex::new(conn));
|
|
let c = conn2.clone();
|
|
let done = Arc::new(Mutex::new(None::<Result<usize, String>>));
|
|
let d = done.clone();
|
|
let worker = std::thread::spawn(move || {
|
|
let l = c.lock().unwrap();
|
|
// `query()` binds params only — the first sqlite3_step (where the
|
|
// interrupt lands) happens in `rs.next()`, so errors must be
|
|
// propagated with `?` rather than swallowed by `is_ok()`.
|
|
let r = l
|
|
.prepare("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200000000) SELECT count(*) FROM c")
|
|
.unwrap()
|
|
.query([])
|
|
.and_then(|mut rs| {
|
|
let mut n = 0;
|
|
while rs.next()?.is_some() {
|
|
n += 1;
|
|
}
|
|
Ok(n)
|
|
});
|
|
*d.lock().unwrap() = Some(r.map_err(|e| e.to_string()));
|
|
});
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
handle.interrupt();
|
|
worker.join().unwrap();
|
|
let outcome = done.lock().unwrap().clone();
|
|
assert!(
|
|
matches!(&outcome, Some(Err(e)) if e.to_lowercase().contains("interrupted")),
|
|
"cancelled query must report interrupted; got {outcome:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mysql_overwrites_single_active_slot() {
|
|
let reg = CancelRegistry::new();
|
|
reg.set_mysql("c1", MySqlCancel { conn_id: Some(1), connect_options: fake_opts() });
|
|
reg.set_mysql("c1", MySqlCancel { conn_id: Some(2), connect_options: fake_opts() });
|
|
match reg.get("c1") {
|
|
Some(CancelHandle::MySql(m)) => assert_eq!(m.conn_id, Some(2)),
|
|
_ => panic!("expected MySql"),
|
|
}
|
|
}
|
|
|
|
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
|
|
sqlx::mysql::MySqlConnectOptions::new()
|
|
.host("127.0.0.1").port(1).username("u").password("p").database("d")
|
|
}
|
|
} |