feat: add per-connection read-only mode

Connections default to read-only. SQL editor wraps queries in a
read-only transaction so PostgreSQL rejects mutations. Data mutation
commands (update_row, insert_row, delete_rows) are blocked at the
Rust layer. Toolbar toggle with confirmation dialog lets users
switch to read-write. Badges shown in workspace, table viewer, and
status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:36:19 +03:00
parent 9b9d2cee94
commit 72c362dfae
14 changed files with 224 additions and 9 deletions

View File

@@ -68,6 +68,9 @@ pub async fn delete_connection(
pool.close().await;
}
let mut ro = state.read_only.write().await;
ro.remove(&id);
Ok(())
}
@@ -102,6 +105,9 @@ pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> Tu
let mut pools = state.pools.write().await;
pools.insert(config.id.clone(), pool);
let mut ro = state.read_only.write().await;
ro.insert(config.id.clone(), true);
Ok(())
}
@@ -138,5 +144,28 @@ pub async fn disconnect(state: State<'_, AppState>, id: String) -> TuskResult<()
if let Some(pool) = pools.remove(&id) {
pool.close().await;
}
let mut ro = state.read_only.write().await;
ro.remove(&id);
Ok(())
}
#[tauri::command]
pub async fn set_read_only(
state: State<'_, AppState>,
connection_id: String,
read_only: bool,
) -> TuskResult<()> {
let mut map = state.read_only.write().await;
map.insert(connection_id, read_only);
Ok(())
}
#[tauri::command]
pub async fn get_read_only(
state: State<'_, AppState>,
connection_id: String,
) -> TuskResult<bool> {
Ok(state.is_read_only(&connection_id).await)
}

View File

@@ -107,6 +107,10 @@ pub async fn update_row(
column: String,
value: Value,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
@@ -148,6 +152,10 @@ pub async fn insert_row(
columns: Vec<String>,
values: Vec<Value>,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
@@ -184,6 +192,10 @@ pub async fn delete_rows(
pk_columns: Vec<String>,
pk_values_list: Vec<Vec<Value>>,
) -> TuskResult<u64> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)

View File

@@ -74,16 +74,32 @@ pub async fn execute_query(
connection_id: String,
sql: String,
) -> TuskResult<QueryResult> {
let read_only = state.is_read_only(&connection_id).await;
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let start = Instant::now();
let rows = sqlx::query(&sql)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let rows = if read_only {
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
sqlx::query("SET TRANSACTION READ ONLY")
.execute(&mut *tx)
.await
.map_err(TuskError::Database)?;
let result = sqlx::query(&sql)
.fetch_all(&mut *tx)
.await
.map_err(TuskError::Database);
tx.rollback().await.map_err(TuskError::Database)?;
result?
} else {
sqlx::query(&sql)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?
};
let execution_time_ms = start.elapsed().as_millis();
let mut columns = Vec::new();