From 14eeac9921d6d110ce932f061cfeb54eb2e136ca Mon Sep 17 00:00:00 2001
From: Jonas Strasel
Date: Mon, 22 Jun 2026 16:31:17 +0200
Subject: [PATCH 1/5] feat(connections): add per-connection startup script
Run optional SQL on every new pooled connection (MySQL/SQLite
after_connect, Postgres deadpool post_create) so session settings like
set_config apply to every query. Editable via a new "Advanced" tab in
the connection editor.
Closes #350
---
src-tauri/src/models.rs | 7 ++
src-tauri/src/plugins/driver.rs | 1 +
src-tauri/src/pool_manager.rs | 81 +++++++++++-----
src-tauri/src/pool_manager_tests.rs | 98 ++++++++++++++++++++
src/components/modals/NewConnectionModal.tsx | 44 ++++++++-
src/contexts/DatabaseContext.ts | 1 +
src/i18n/locales/de.json | 6 +-
src/i18n/locales/en.json | 6 +-
src/i18n/locales/es.json | 6 +-
src/i18n/locales/fr.json | 6 +-
src/i18n/locales/it.json | 6 +-
src/i18n/locales/ja.json | 6 +-
src/i18n/locales/ru.json | 6 +-
src/i18n/locales/zh.json | 6 +-
src/utils/connections.ts | 2 +
15 files changed, 247 insertions(+), 35 deletions(-)
diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs
index eba9d080..a97bde45 100644
--- a/src-tauri/src/models.rs
+++ b/src-tauri/src/models.rs
@@ -157,6 +157,13 @@ pub struct ConnectionParams {
pub k8s_resource_name: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub k8s_port: Option,
+ /// SQL run on every new physical connection in the pool (e.g. `SET` /
+ /// `set_config` for session-scoped settings such as bypassing RLS).
+ /// Statements are separated by `;`. Runs per pooled connection so the
+ /// setting applies to every query regardless of which connection the
+ /// pool hands out.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub startup_script: Option,
// Connection ID for stable pooling (not persisted, set at runtime)
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_id: Option,
diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs
index 70aa77b9..b63e7f11 100644
--- a/src-tauri/src/plugins/driver.rs
+++ b/src-tauri/src/plugins/driver.rs
@@ -840,6 +840,7 @@ mod tests {
k8s_resource_type: None,
k8s_resource_name: None,
k8s_port: None,
+ startup_script: None,
connection_id: Some("conn-1".to_string()),
}
}
diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs
index 12351aaa..09bca213 100644
--- a/src-tauri/src/pool_manager.rs
+++ b/src-tauri/src/pool_manager.rs
@@ -1,5 +1,5 @@
use crate::models::ConnectionParams;
-use deadpool_postgres::{Manager as PgPoolManager, Pool as PgPool};
+use deadpool_postgres::{Hook as PgHook, HookError as PgHookError, Manager as PgPoolManager, Pool as PgPool};
use once_cell::sync::Lazy;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::client::{verify_server_cert_signed_by_trust_anchor, WebPkiServerVerifier};
@@ -11,7 +11,7 @@ use rustls::server::ParsedCertificate;
use rustls::{DigitallySignedStruct};
use rustls::{ClientConfig, Error as TlsError, RootCertStore};
use rustls_platform_verifier::BuilderVerifierExt;
-use sqlx::{sqlite::SqliteConnectOptions, MySql, Pool, Sqlite};
+use sqlx::{sqlite::SqliteConnectOptions, Executor, MySql, Pool, Sqlite};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
@@ -469,6 +469,18 @@ fn build_sqlite_connectoptions(params: &ConnectionParams) -> SqliteConnectOption
SqliteConnectOptions::new().filename(params.database.to_string())
}
+/// Return the connection's startup script if it is set and not blank.
+/// Whitespace-only scripts are treated as absent so the per-connection
+/// hook is skipped entirely rather than issuing an empty query.
+fn startup_script(params: &ConnectionParams) -> Option {
+ params
+ .startup_script
+ .as_ref()
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(str::to_owned)
+}
+
pub async fn get_mysql_pool(params: &ConnectionParams) -> Result, String> {
let connection_id = params.connection_id.as_deref();
get_mysql_pool_with_id(params, connection_id).await
@@ -525,12 +537,17 @@ async fn get_mysql_pool_for_database_with_id(
"connectTimeout",
DEFAULT_MYSQL_CONNECT_TIMEOUT_MS,
));
- let pool = tokio::time::timeout(
- connect_timeout,
- sqlx::mysql::MySqlPoolOptions::new()
- .max_connections(10)
- .connect_with(options),
- )
+ let mut pool_options = sqlx::mysql::MySqlPoolOptions::new().max_connections(10);
+ if let Some(script) = startup_script(params) {
+ pool_options = pool_options.after_connect(move |conn, _meta| {
+ let script = script.clone();
+ Box::pin(async move {
+ conn.execute(script.as_str()).await?;
+ Ok(())
+ })
+ });
+ }
+ let pool = tokio::time::timeout(connect_timeout, pool_options.connect_with(options))
.await
.map_err(|_| {
format!(
@@ -597,14 +614,24 @@ pub async fn get_postgres_pool_with_id(
e
})?;
- let pool = PgPool::builder(PgPoolManager::new(cfg, tls_connector))
- .max_size(10)
- .build()
- .map_err(|e| {
- let detail = format_error_chain(&e);
- log::error!("Failed to create PostgreSQL connection pool: {}", detail);
- detail
- })?;
+ let mut builder = PgPool::builder(PgPoolManager::new(cfg, tls_connector)).max_size(10);
+ if let Some(script) = startup_script(params) {
+ builder = builder.post_create(PgHook::async_fn(move |client, _metrics| {
+ let script = script.clone();
+ Box::pin(async move {
+ client
+ .batch_execute(&script)
+ .await
+ .map_err(PgHookError::Backend)?;
+ Ok(())
+ })
+ }));
+ }
+ let pool = builder.build().map_err(|e| {
+ let detail = format_error_chain(&e);
+ log::error!("Failed to create PostgreSQL connection pool: {}", detail);
+ detail
+ })?;
log::info!(
"PostgreSQL connection pool created successfully for: {} (key: {})",
@@ -652,14 +679,20 @@ pub async fn get_sqlite_pool_with_id(
key
);
let options = build_sqlite_connectoptions(params);
- let pool = sqlx::sqlite::SqlitePoolOptions::new()
- .max_connections(5) // SQLite has lower concurrency needs
- .connect_with(options)
- .await
- .map_err(|e| {
- log::error!("Failed to create SQLite connection pool: {}", e);
- e.to_string()
- })?;
+ let mut pool_options = sqlx::sqlite::SqlitePoolOptions::new().max_connections(5); // SQLite has lower concurrency needs
+ if let Some(script) = startup_script(params) {
+ pool_options = pool_options.after_connect(move |conn, _meta| {
+ let script = script.clone();
+ Box::pin(async move {
+ conn.execute(script.as_str()).await?;
+ Ok(())
+ })
+ });
+ }
+ let pool = pool_options.connect_with(options).await.map_err(|e| {
+ log::error!("Failed to create SQLite connection pool: {}", e);
+ e.to_string()
+ })?;
log::info!(
"SQLite connection pool created successfully for: {} (key: {})",
diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs
index 6fb700f2..4c065740 100644
--- a/src-tauri/src/pool_manager_tests.rs
+++ b/src-tauri/src/pool_manager_tests.rs
@@ -372,3 +372,101 @@ mod postgres_tls_connector_tests {
let _ = std::fs::remove_file(&file_path);
}
}
+
+#[cfg(test)]
+mod startup_script_tests {
+ use crate::models::{ConnectionParams, DatabaseSelection};
+ use crate::pool_manager::{close_pool_with_id, get_sqlite_pool_with_id};
+ use tempfile::NamedTempFile;
+
+ fn sqlite_params(path: &str, startup_script: Option<&str>) -> ConnectionParams {
+ ConnectionParams {
+ driver: "sqlite".to_string(),
+ database: DatabaseSelection::Single(path.to_string()),
+ startup_script: startup_script.map(ToOwned::to_owned),
+ ..Default::default()
+ }
+ }
+
+ #[tokio::test]
+ async fn startup_script_runs_on_each_new_connection() {
+ let file = NamedTempFile::new().expect("temp file");
+ let path = file.path().to_str().expect("utf8 path").to_string();
+ // Unique connection id keeps this pool out of other tests' cached pools.
+ let conn_id = format!("startup-runs-{}", ulid::Ulid::new());
+
+ let params = sqlite_params(
+ &path,
+ Some(
+ "CREATE TABLE IF NOT EXISTS startup_marker (id INTEGER); \
+ INSERT INTO startup_marker (id) VALUES (1);",
+ ),
+ );
+
+ let pool = get_sqlite_pool_with_id(¶ms, Some(&conn_id))
+ .await
+ .expect("pool should be created");
+
+ // The marker table only exists if the startup script ran on the
+ // physical connection the pool just handed out.
+ let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM startup_marker")
+ .fetch_one(&pool)
+ .await
+ .expect("startup_marker table should exist");
+ assert!(count >= 1, "expected at least one startup INSERT, got {count}");
+
+ close_pool_with_id(¶ms, Some(&conn_id)).await;
+ }
+
+ #[tokio::test]
+ async fn blank_startup_script_is_skipped() {
+ let file = NamedTempFile::new().expect("temp file");
+ let path = file.path().to_str().expect("utf8 path").to_string();
+ let conn_id = format!("startup-blank-{}", ulid::Ulid::new());
+
+ // A whitespace-only script must be treated as absent: if it were run
+ // as SQL the connection would fail and `SELECT 1` below would error.
+ let params = sqlite_params(&path, Some(" \n "));
+
+ let pool = get_sqlite_pool_with_id(¶ms, Some(&conn_id))
+ .await
+ .expect("pool should be created");
+
+ let (one,): (i64,) = sqlx::query_as("SELECT 1")
+ .fetch_one(&pool)
+ .await
+ .expect("query on pool with blank startup script should work");
+ assert_eq!(one, 1);
+
+ close_pool_with_id(¶ms, Some(&conn_id)).await;
+ }
+
+ #[tokio::test]
+ async fn invalid_startup_script_surfaces_error() {
+ let file = NamedTempFile::new().expect("temp file");
+ let path = file.path().to_str().expect("utf8 path").to_string();
+ let conn_id = format!("startup-invalid-{}", ulid::Ulid::new());
+
+ let params = sqlite_params(&path, Some("THIS IS NOT VALID SQL;"));
+
+ // The pool may build lazily, so the bad script can surface either at
+ // pool creation or on first acquire. Either way the error must reach
+ // the caller rather than silently succeeding.
+ let result = async {
+ let pool = get_sqlite_pool_with_id(¶ms, Some(&conn_id)).await?;
+ sqlx::query("SELECT 1")
+ .execute(&pool)
+ .await
+ .map_err(|e| e.to_string())?;
+ Ok::<_, String>(())
+ }
+ .await;
+
+ assert!(
+ result.is_err(),
+ "invalid startup script should fail the connection"
+ );
+
+ close_pool_with_id(¶ms, Some(&conn_id)).await;
+ }
+}
diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx
index 093a2d2e..26094c83 100644
--- a/src/components/modals/NewConnectionModal.tsx
+++ b/src/components/modals/NewConnectionModal.tsx
@@ -76,6 +76,8 @@ interface ConnectionParams {
k8s_resource_type?: string;
k8s_resource_name?: string;
k8s_port?: number;
+ // SQL run on every new connection (e.g. SET / set_config)
+ startup_script?: string;
}
interface SavedConnection {
@@ -186,7 +188,7 @@ export const NewConnectionModal = ({
// ── tab ──
const [activeTab, setActiveTab] = useState<
- "general" | "databases" | "ssh" | "ssl" | "k8s" | "appearance"
+ "general" | "databases" | "ssh" | "ssl" | "k8s" | "advanced" | "appearance"
>("general");
// ── SSH ──
@@ -1128,6 +1130,34 @@ export const NewConnectionModal = ({
/>
);
+ // ── rendered Advanced tab content (per-connection startup SQL) ──
+ const advancedTabContent = (
+
+
+
+ {t("newConnection.startupScriptDescription", {
+ defaultValue:
+ "SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.",
+ })}
+
diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts
index c4235b3c..66382344 100644
--- a/src/contexts/DatabaseContext.ts
+++ b/src/contexts/DatabaseContext.ts
@@ -49,6 +49,7 @@ export interface SavedConnection {
ssh_connection_id?: string;
k8s_enabled?: boolean;
k8s_connection_id?: string;
+ startup_script?: string;
};
group_id?: string;
sort_order?: number;
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 450773b2..6cd47a76 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -695,7 +695,11 @@
"selectK8sConnection": "K8s-Verbindung auswählen",
"selectTypeFirst": "Zuerst Kontext/Namespace/Typ auswählen",
"useK8s": "Kubernetes-Port-Forward verwenden",
- "useK8sConnection": "Gespeicherte Verbindung"
+ "useK8sConnection": "Gespeicherte Verbindung",
+ "advanced": "Erweitert",
+ "startupScript": "Startskript",
+ "startupScriptDescription": "SQL, das bei jeder neuen Verbindung zu dieser Datenquelle ausgeführt wird. Verwenden Sie es für Sitzungseinstellungen wie SET / set_config (z. B. zum Umgehen von RLS). Trennen Sie Anweisungen mit Semikolons.",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "SSH-Verbindungen",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index f5f1a6dd..1b292502 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -716,7 +716,11 @@
"selectK8sConnection": "Select K8s Connection",
"selectTypeFirst": "Select context/namespace/type first",
"useK8s": "Use Kubernetes Port-Forward",
- "useK8sConnection": "Saved Connection"
+ "useK8sConnection": "Saved Connection",
+ "advanced": "Advanced",
+ "startupScript": "Startup Script",
+ "startupScriptDescription": "SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "SSH Connections",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 21f6d27d..76560c8c 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -700,7 +700,11 @@
"selectK8sConnection": "Selecciona conexión de K8s",
"selectTypeFirst": "Selecciona primero contexto/namespace/tipo",
"useK8s": "Usar Port-Forward de Kubernetes",
- "useK8sConnection": "Conexión guardada"
+ "useK8sConnection": "Conexión guardada",
+ "advanced": "Avanzado",
+ "startupScript": "Script de inicio",
+ "startupScriptDescription": "SQL que se ejecuta en cada nueva conexión a esta fuente de datos. Úsalo para ajustes de sesión como SET / set_config (p. ej., para omitir RLS). Separa las sentencias con punto y coma.",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "Conexiones SSH",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index fb9ee921..a82ad1ee 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -695,7 +695,11 @@
"selectK8sConnection": "Sélectionner une connexion K8s",
"selectTypeFirst": "Sélectionnez d'abord contexte/namespace/type",
"useK8s": "Utiliser le Port-Forward Kubernetes",
- "useK8sConnection": "Connexion enregistrée"
+ "useK8sConnection": "Connexion enregistrée",
+ "advanced": "Avancé",
+ "startupScript": "Script de démarrage",
+ "startupScriptDescription": "SQL exécuté à chaque nouvelle connexion à cette source de données. Utilisez-le pour des paramètres de session tels que SET / set_config (par exemple, pour contourner la RLS). Séparez les instructions par des points-virgules.",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "Connexions SSH",
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 01ebf3ab..6c32ab1c 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -700,7 +700,11 @@
"selectK8sConnection": "Seleziona connessione K8s",
"selectTypeFirst": "Seleziona prima contesto/namespace/tipo",
"useK8s": "Usa Port-Forward Kubernetes",
- "useK8sConnection": "Connessione salvata"
+ "useK8sConnection": "Connessione salvata",
+ "advanced": "Avanzate",
+ "startupScript": "Script di avvio",
+ "startupScriptDescription": "SQL eseguito a ogni nuova connessione a questa origine dati. Usalo per le impostazioni di sessione come SET / set_config (ad es. per bypassare la RLS). Separa le istruzioni con punto e virgola.",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "Connessioni SSH",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 2c1a387f..c85e7718 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -709,7 +709,11 @@
"selectK8sConnection": "K8s 接続を選択",
"selectTypeFirst": "先にコンテキスト/ネームスペース/タイプを選択してください",
"useK8s": "Kubernetes ポートフォワードを使用",
- "useK8sConnection": "保存された接続"
+ "useK8sConnection": "保存された接続",
+ "advanced": "詳細設定",
+ "startupScript": "起動スクリプト",
+ "startupScriptDescription": "このデータソースへの新規接続ごとに実行される SQL です。SET / set_config(例: RLS のバイパス)などのセッション設定に使用します。複数のステートメントはセミコロンで区切ってください。",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "SSH 接続",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index bb8c3ea9..00d52641 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -688,7 +688,11 @@
"selectTypeFirst": "Сначала выберите контекст/пространство имён/тип",
"useK8s": "Использовать проброс портов Kubernetes",
"useK8sConnection": "Сохранённое подключение",
- "appearance": "Внешний вид"
+ "appearance": "Внешний вид",
+ "advanced": "Дополнительно",
+ "startupScript": "Сценарий запуска",
+ "startupScriptDescription": "SQL, выполняемый при каждом новом подключении к этому источнику данных. Используйте его для настроек сеанса, таких как SET / set_config (например, для обхода RLS). Разделяйте операторы точкой с запятой.",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "SSH-подключения",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 2e30c872..fc1614c0 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -663,7 +663,11 @@
"selectK8sConnection": "选择 K8s 连接",
"selectTypeFirst": "请先选择上下文/命名空间/类型",
"useK8s": "使用 Kubernetes 端口转发",
- "useK8sConnection": "已保存的连接"
+ "useK8sConnection": "已保存的连接",
+ "advanced": "高级",
+ "startupScript": "启动脚本",
+ "startupScriptDescription": "每次新建到此数据源的连接时执行的 SQL。可用于会话设置,例如 SET / set_config(如绕过 RLS)。多条语句请用分号分隔。",
+ "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);"
},
"sshConnections": {
"title": "SSH 连接",
diff --git a/src/utils/connections.ts b/src/utils/connections.ts
index 72a267a6..92228091 100644
--- a/src/utils/connections.ts
+++ b/src/utils/connections.ts
@@ -36,6 +36,8 @@ export interface ConnectionParams {
k8s_resource_type?: string;
k8s_resource_name?: string;
k8s_port?: number;
+ /** SQL run on every new connection to this data source (e.g. SET / set_config). */
+ startup_script?: string;
}
/**
From 30b5548f525ff1c00cd5bc411938609561809e6d Mon Sep 17 00:00:00 2001
From: Jonas Strasel
Date: Mon, 22 Jun 2026 16:43:06 +0200
Subject: [PATCH 2/5] fix(connections): include startup script in pool cache
key
build_connection_key hashed only connection_id/host/tls, so editing a
saved connection's startup script reused the cached pool and the new
script never ran until app restart. Fold a SHA-256 of the trimmed
script into the key (only when set, so script-free pools keep their
keys). Addresses the critical review finding on #352.
---
src-tauri/src/pool_manager.rs | 16 ++++++++++++++-
src-tauri/src/pool_manager_tests.rs | 32 +++++++++++++++++++++++++++++
2 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs
index 09bca213..7f8d9c7b 100644
--- a/src-tauri/src/pool_manager.rs
+++ b/src-tauri/src/pool_manager.rs
@@ -11,6 +11,7 @@ use rustls::server::ParsedCertificate;
use rustls::{DigitallySignedStruct};
use rustls::{ClientConfig, Error as TlsError, RootCertStore};
use rustls_platform_verifier::BuilderVerifierExt;
+use sha2::{Digest, Sha256};
use sqlx::{sqlite::SqliteConnectOptions, Executor, MySql, Pool, Sqlite};
use std::collections::HashMap;
use std::sync::Arc;
@@ -116,10 +117,23 @@ pub(crate) fn build_connection_key(
)
};
- if let Some(tls_key) = tls_key {
+ let key = if let Some(tls_key) = tls_key {
format!("{base_key}:{tls_key}")
} else {
base_key
+ };
+
+ // Fold the startup script into the key so editing it forces a fresh pool
+ // (whose new connections run the new script) instead of silently reusing
+ // the cached pool keyed only by connection_id. Hashed to keep the key
+ // bounded; only present when a script is set, so script-free connections
+ // keep their existing keys.
+ match startup_script(params) {
+ Some(script) => {
+ let digest = Sha256::digest(script.as_bytes());
+ format!("{key}:startup:{digest:x}")
+ }
+ None => key,
}
}
diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs
index 4c065740..2153b1d5 100644
--- a/src-tauri/src/pool_manager_tests.rs
+++ b/src-tauri/src/pool_manager_tests.rs
@@ -117,6 +117,38 @@ mod tests {
);
}
+ #[test]
+ fn pool_key_changes_when_startup_script_changes() {
+ let none = connection_params("postgres", Some("require"));
+ let mut script_a = none.clone();
+ script_a.startup_script = Some("SET app.bypass_rls = 'on';".to_string());
+ let mut script_b = none.clone();
+ script_b.startup_script = Some("SET app.bypass_rls = 'off';".to_string());
+
+ let key_none = build_connection_key(&none, Some("conn-1"));
+ let key_a = build_connection_key(&script_a, Some("conn-1"));
+ let key_b = build_connection_key(&script_b, Some("conn-1"));
+
+ // A script changes the key, and different scripts differ — otherwise an
+ // edited startup script would silently reuse the old cached pool.
+ assert_ne!(key_none, key_a);
+ assert_ne!(key_a, key_b);
+ }
+
+ #[test]
+ fn pool_key_ignores_blank_startup_script() {
+ let none = connection_params("postgres", Some("require"));
+ let mut blank = none.clone();
+ blank.startup_script = Some(" \n\t".to_string());
+
+ // Whitespace-only scripts are treated as absent (no hook runs), so they
+ // must not fragment the pool away from the no-script connection.
+ assert_eq!(
+ build_connection_key(&none, Some("conn-1")),
+ build_connection_key(&blank, Some("conn-1"))
+ );
+ }
+
#[test]
fn mysql_options_accept_snake_case_verify_ssl_modes() {
let verify_ca = mysql_params("verify_ca");
From 2186106af8ddae87bcaddaaaa42f58d3383a18bb Mon Sep 17 00:00:00 2001
From: Jonas Strasel
Date: Mon, 22 Jun 2026 21:13:40 +0200
Subject: [PATCH 3/5] fix(connections): attribute startup-script failures and
use SQL editor
A broken startup script now fails fast with a clearly attributed error
instead of a misleading pool timeout or generic connection error:
- MySQL/SQLite preflight the script on a throwaway connection. sqlx
swallows after_connect errors and retries until the acquire timeout,
reporting "pool timed out"; the preflight surfaces "Startup script
failed: ..." up front. Connection failures are returned verbatim so a
bad host/credentials is not mislabelled as a script error.
- Postgres post_create reports the startup script as the cause.
Also swap the Advanced-tab startup-script textarea for the shared
SqlEditorWrapper (Monaco) for syntax highlighting, theming and font
settings, matching the editor and other SQL fields.
---
src-tauri/src/pool_manager.rs | 49 +++++++++++++++++++-
src-tauri/src/pool_manager_tests.rs | 25 ++++------
src/components/modals/NewConnectionModal.tsx | 28 +++++------
3 files changed, 71 insertions(+), 31 deletions(-)
diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs
index 7f8d9c7b..c47bb8c4 100644
--- a/src-tauri/src/pool_manager.rs
+++ b/src-tauri/src/pool_manager.rs
@@ -12,7 +12,7 @@ use rustls::{DigitallySignedStruct};
use rustls::{ClientConfig, Error as TlsError, RootCertStore};
use rustls_platform_verifier::BuilderVerifierExt;
use sha2::{Digest, Sha256};
-use sqlx::{sqlite::SqliteConnectOptions, Executor, MySql, Pool, Sqlite};
+use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, Connection, Executor, MySql, Pool, Sqlite};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
@@ -495,6 +495,42 @@ fn startup_script(params: &ConnectionParams) -> Option {
.map(str::to_owned)
}
+/// Format a startup-script execution failure so the surfaced error clearly
+/// names the startup script as the cause, instead of reading like a bad host
+/// or wrong credentials.
+fn startup_script_error(err: impl std::fmt::Display) -> String {
+ format!("Startup script failed: {err}")
+}
+
+/// Run the startup script once on a throwaway connection so a broken script
+/// fails fast with a clearly attributed error. The per-connection hooks
+/// (`after_connect`/`post_create`) still run it on every pooled connection;
+/// this preflight exists only for early, well-labelled failures: sqlx swallows
+/// `after_connect` errors and retries until the acquire timeout, which would
+/// otherwise report a misleading "pool timed out". A failure to open the
+/// connection is returned verbatim so genuine connectivity problems are not
+/// mislabelled as startup-script errors.
+async fn run_mysql_startup_script(
+ options: &sqlx::mysql::MySqlConnectOptions,
+ script: &str,
+) -> Result<(), String> {
+ let mut conn = options.connect().await.map_err(|e| e.to_string())?;
+ let outcome = conn.execute(script).await;
+ let _ = conn.close().await;
+ outcome.map(|_| ()).map_err(startup_script_error)
+}
+
+/// SQLite counterpart to [`run_mysql_startup_script`].
+async fn run_sqlite_startup_script(
+ options: &SqliteConnectOptions,
+ script: &str,
+) -> Result<(), String> {
+ let mut conn = options.connect().await.map_err(|e| e.to_string())?;
+ let outcome = conn.execute(script).await;
+ let _ = conn.close().await;
+ outcome.map(|_| ()).map_err(startup_script_error)
+}
+
pub async fn get_mysql_pool(params: &ConnectionParams) -> Result, String> {
let connection_id = params.connection_id.as_deref();
get_mysql_pool_with_id(params, connection_id).await
@@ -553,6 +589,14 @@ async fn get_mysql_pool_for_database_with_id(
));
let mut pool_options = sqlx::mysql::MySqlPoolOptions::new().max_connections(10);
if let Some(script) = startup_script(params) {
+ tokio::time::timeout(connect_timeout, run_mysql_startup_script(&options, &script))
+ .await
+ .map_err(|_| {
+ format!(
+ "Timed out running MySQL startup script after {} ms",
+ connect_timeout.as_millis()
+ )
+ })??;
pool_options = pool_options.after_connect(move |conn, _meta| {
let script = script.clone();
Box::pin(async move {
@@ -636,7 +680,7 @@ pub async fn get_postgres_pool_with_id(
client
.batch_execute(&script)
.await
- .map_err(PgHookError::Backend)?;
+ .map_err(|e| PgHookError::message(startup_script_error(format_error_chain(&e))))?;
Ok(())
})
}));
@@ -695,6 +739,7 @@ pub async fn get_sqlite_pool_with_id(
let options = build_sqlite_connectoptions(params);
let mut pool_options = sqlx::sqlite::SqlitePoolOptions::new().max_connections(5); // SQLite has lower concurrency needs
if let Some(script) = startup_script(params) {
+ run_sqlite_startup_script(&options, &script).await?;
pool_options = pool_options.after_connect(move |conn, _meta| {
let script = script.clone();
Box::pin(async move {
diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs
index 2153b1d5..f14654f3 100644
--- a/src-tauri/src/pool_manager_tests.rs
+++ b/src-tauri/src/pool_manager_tests.rs
@@ -474,29 +474,22 @@ mod startup_script_tests {
}
#[tokio::test]
- async fn invalid_startup_script_surfaces_error() {
+ async fn invalid_startup_script_surfaces_attributed_error() {
let file = NamedTempFile::new().expect("temp file");
let path = file.path().to_str().expect("utf8 path").to_string();
let conn_id = format!("startup-invalid-{}", ulid::Ulid::new());
let params = sqlite_params(&path, Some("THIS IS NOT VALID SQL;"));
- // The pool may build lazily, so the bad script can surface either at
- // pool creation or on first acquire. Either way the error must reach
- // the caller rather than silently succeeding.
- let result = async {
- let pool = get_sqlite_pool_with_id(¶ms, Some(&conn_id)).await?;
- sqlx::query("SELECT 1")
- .execute(&pool)
- .await
- .map_err(|e| e.to_string())?;
- Ok::<_, String>(())
- }
- .await;
-
+ // A broken startup script must fail the connection with an error that
+ // clearly names the startup script as the cause, rather than sqlx's
+ // misleading "pool timed out" or a generic connection error.
+ let err = get_sqlite_pool_with_id(¶ms, Some(&conn_id))
+ .await
+ .expect_err("invalid startup script should fail the connection");
assert!(
- result.is_err(),
- "invalid startup script should fail the connection"
+ err.contains("Startup script failed"),
+ "error should be attributed to the startup script, got: {err}"
);
close_pool_with_id(¶ms, Some(&conn_id)).await;
diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx
index 26094c83..82c1adc2 100644
--- a/src/components/modals/NewConnectionModal.tsx
+++ b/src/components/modals/NewConnectionModal.tsx
@@ -28,6 +28,7 @@ import { SlotAnchor } from "../ui/SlotAnchor";
import { useDrivers } from "../../hooks/useDrivers";
import { usePluginSlotRegistry } from "../../hooks/usePluginSlotRegistry";
import { Modal } from "../ui/Modal";
+import { SqlEditorWrapper } from "../ui/SqlEditorWrapper";
import type { PluginManifest } from "../../types/plugins";
import { loadSshConnections, type SshConnection } from "../../utils/ssh";
import {
@@ -1142,19 +1143,20 @@ export const NewConnectionModal = ({
"SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.",
})}