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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -17,6 +17,9 @@ pub enum TuskError {
|
||||
#[error("Not connected: {0}")]
|
||||
NotConnected(String),
|
||||
|
||||
#[error("Connection is in read-only mode")]
|
||||
ReadOnly,
|
||||
|
||||
#[error("{0}")]
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ pub fn run() {
|
||||
commands::connections::connect,
|
||||
commands::connections::switch_database,
|
||||
commands::connections::disconnect,
|
||||
commands::connections::set_read_only,
|
||||
commands::connections::get_read_only,
|
||||
// queries
|
||||
commands::queries::execute_query,
|
||||
// schema
|
||||
|
||||
@@ -6,6 +6,7 @@ use tokio::sync::RwLock;
|
||||
pub struct AppState {
|
||||
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||
pub config_path: RwLock<Option<PathBuf>>,
|
||||
pub read_only: RwLock<HashMap<String, bool>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -13,6 +14,12 @@ impl AppState {
|
||||
Self {
|
||||
pools: RwLock::new(HashMap::new()),
|
||||
config_path: RwLock::new(None),
|
||||
read_only: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_read_only(&self, id: &str) -> bool {
|
||||
let map = self.read_only.read().await;
|
||||
map.get(id).copied().unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user