use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use log::warn; use rusqlite::{ params, params_from_iter, types::Value, Connection, DatabaseName, OpenFlags, OptionalExtension, ToSql, Transaction, TransactionBehavior, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::ai::{ AiChatMessage, AiChatSelectionState, AiConfig, AiConfigItem, AiConversation, AiProvider, AiRun, AiRunFifoCategory, AiRunStatus, }; use crate::connection_secrets::{ MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY, NACOS_AUTH_SECRET_PREFIX, NACOS_RNACOS_CONSOLE_PASSWORD_KEY, }; use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle}; use crate::history::{ HistoryConnectionFilter, HistoryConnectionOption, HistoryCursor, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest, HistorySearchResult, MAX_HISTORY, }; use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType, TransportLayerConfig}; use crate::prompt_template::PromptTemplate; use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary}; const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers."; const STORAGE_DB_FILE_NAME: &str = "dbx.db"; const APP_STATE_EDITOR_SETTINGS_KEY: &str = "editor_settings"; const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs"; const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions"; const APP_STATE_TRANSFER_TASK_LIBRARY_KEY: &str = "transfer_task_library"; const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy"; const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings"; const MAX_RETRIES_KEY: &str = "max_retries"; const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions"; const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1"; const SNIPPET_SYNC_IDS_KEY: &str = "snippet_sync_ids"; const SNIPPET_PENDING_CLEANUPS_KEY: &str = "snippet_pending_legacy_cleanups"; const USER_DATA_TABLES: &[&str] = &[ "connections", "connection_secrets", "history", "ai_config", "ai_provider_configs", "ai_conversations", "ai_runs", "sidebar_layout", "app_settings", "app_state", "tunnel_profiles", "mq_token_records", "saved_sql_folders", "saved_sql_files", "ai_configs", "state_store", "prompt_templates", ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DataDbImportResult { Imported, SkippedNoSource, SkippedInvalidSource, SkippedInvalidTarget, SkippedSourceEmpty, SkippedTargetHasData, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SnippetPendingCleanup { pub snippet_id: String, pub expected_content_hash: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnippetSyncState { pub snippet_id: Option, pub pending_cleanup: Option, } fn required_snippet_state_value<'a>(value: &'a str, label: &str) -> Result<&'a str, String> { let value = value.trim(); if value.is_empty() { return Err(format!("{label} cannot be empty")); } Ok(value) } fn validate_snippet_pending_cleanup(mut cleanup: SnippetPendingCleanup) -> Result { cleanup.snippet_id = required_snippet_state_value(&cleanup.snippet_id, "legacy snippet id")?.to_string(); cleanup.expected_content_hash = required_snippet_state_value(&cleanup.expected_content_hash, "legacy content hash")?.to_string(); Ok(cleanup) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SqliteDbFileState { Missing, Empty, Valid, Invalid, } pub fn maybe_import_user_data_db( target_data_dir: &Path, source_data_dir: Option<&Path>, ) -> Result { let Some(source_data_dir) = source_data_dir else { return Ok(DataDbImportResult::SkippedNoSource); }; let source_db_path = source_data_dir.join(STORAGE_DB_FILE_NAME); if !source_db_path.is_file() { return Ok(DataDbImportResult::SkippedNoSource); } if inspect_sqlite_db_file(&source_db_path)? != SqliteDbFileState::Valid { return Ok(DataDbImportResult::SkippedInvalidSource); } let source_conn = open_read_only_sqlite(&source_db_path)?; if !sqlite_db_has_user_data(&source_conn)? { return Ok(DataDbImportResult::SkippedSourceEmpty); } let target_db_path = target_data_dir.join(STORAGE_DB_FILE_NAME); match inspect_sqlite_db_file(&target_db_path)? { SqliteDbFileState::Missing => {} SqliteDbFileState::Empty => { remove_sqlite_db_files(&target_db_path)?; } SqliteDbFileState::Valid => { let target_conn = open_read_only_sqlite(&target_db_path)?; if sqlite_db_has_user_data(&target_conn)? { return Ok(DataDbImportResult::SkippedTargetHasData); } drop(target_conn); remove_sqlite_db_files(&target_db_path)?; } SqliteDbFileState::Invalid => return Ok(DataDbImportResult::SkippedInvalidTarget), } std::fs::create_dir_all(target_data_dir).map_err(|e| format!("Failed to create data dir: {e}"))?; source_conn .backup(DatabaseName::Main, &target_db_path, None) .map_err(|e| format!("Failed to import user data db: {e}"))?; Ok(DataDbImportResult::Imported) } #[derive(Clone)] pub struct Storage { db: SqliteHandle, /// Path to the SQLite database file (`dbx.db`). Its parent directory is the /// application data dir where dbx-managed state (e.g. `known_hosts`) lives. path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TabRuntimeCacheEntry { pub key: String, pub payload: Vec, pub row_count: i64, pub column_count: i64, pub byte_size: i64, pub updated_at: String, pub created_at: i64, pub last_accessed_at: i64, pub owner_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TabRuntimeCacheMetadata { pub key: String, pub row_count: i64, pub column_count: i64, pub byte_size: i64, pub updated_at: String, pub created_at: i64, pub last_accessed_at: i64, pub owner_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TabRuntimeCachePruneResult { pub deleted_entries: usize, pub deleted_bytes: i64, pub orphan_deletions: usize, pub remaining_entries: usize, pub remaining_bytes: i64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DesktopSettings { pub show_tray_icon: bool, pub icon_theme: DesktopIconTheme, #[serde(default)] pub quit_on_close: bool, #[serde(default)] pub close_action_prompted: bool, #[serde(default)] pub debug_logging_enabled: bool, #[serde(default = "default_metadata_cache_max_memory_mb")] pub metadata_cache_max_memory_mb: usize, #[serde(default)] pub duckdb_worker_process_isolation: bool, #[serde(default = "default_duckdb_worker_max_processes")] pub duckdb_worker_max_processes: usize, #[serde(default)] pub saved_sql_sync_dir: Option, #[serde(default)] pub driver_store_dir: Option, #[serde(default)] pub plugin_store_dir: Option, #[serde(default)] pub agent_store_dir: Option, #[serde(default = "default_sidebar_table_page_size")] pub sidebar_table_page_size: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct McpGlobalPolicy { pub read_only: bool, #[serde(default)] pub allow_dangerous_sql: bool, pub allowed_connection_ids: Option>, /// `None` exposes every built-in MCP tool. A list is an explicit /// allowlist and is enforced independently of connection permissions. #[serde(default)] pub allowed_tool_names: Option>, /// Per-connection execution defaults and database overrides. Rules without /// the current execution policy version remain legacy ceilings. #[serde(default)] pub connection_policies: Vec, #[serde(default)] pub query_timeout_secs: Option, } fn default_mcp_connection_execution_mode_configured() -> bool { true } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpConnectionPolicy { pub connection_id: String, /// When true, this connection is read-only even if the MCP-wide policy /// permits writes. #[serde(default)] pub read_only: bool, /// Enables high-risk SQL for this connection. Legacy rules require this /// to remain within the global ceiling; versioned rules use it as the /// connection default and still honor independent connection protections. #[serde(default)] pub allow_dangerous_sql: bool, /// Whether the operation ceiling is explicitly overridden for this /// connection. Missing on older saved policies defaults to true for /// deserialization compatibility; the version marker determines whether /// those fields use legacy ceiling or current override semantics. #[serde(default = "default_mcp_connection_execution_mode_configured")] pub execution_mode_configured: bool, /// Rules without this marker retain the legacy ceiling behavior. New UI /// writes use version 1 for scoped override semantics. #[serde(default)] pub execution_mode_policy_version: Option, /// Limits which databases below this connection can be reached by MCP. /// The default preserves existing installations: all databases remain /// available until a user explicitly narrows the scope. #[serde(default)] pub database_scope: McpDatabaseScope, /// Exact database names allowed when `database_scope` is `selected`. /// An empty selected list intentionally denies every database. #[serde(default)] pub allowed_databases: Vec, /// Optional per-database execution settings. A missing entry inherits the /// connection default, while a present entry takes priority over it. #[serde(default)] pub database_policies: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpDatabasePolicy { /// Exact database name matched after the connection scope has admitted it. pub database_name: String, /// When true, this database rejects writes regardless of the connection /// and global defaults. #[serde(default)] pub read_only: bool, /// Enables high-risk SQL for this database. Connection read-only, /// production protection, scope, and database credentials remain hard limits. #[serde(default)] pub allow_dangerous_sql: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum McpDatabaseScope { #[default] All, Selected, None, } /// Configuration for the optional MCP Streamable HTTP server managed by the /// desktop application. Credentials deliberately do not live here: the /// desktop service stores its token in a private file under the DBX data dir. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpHttpServerSettings { #[serde(default)] pub enabled: bool, #[serde(default = "default_mcp_http_host")] pub host: String, #[serde(default = "default_mcp_http_port")] pub port: u16, #[serde(default = "default_mcp_http_path")] pub path: String, #[serde(default)] pub allow_remote: bool, #[serde(default)] pub allowed_hosts: Vec, #[serde(default)] pub allowed_origins: Vec, } impl Default for McpHttpServerSettings { fn default() -> Self { Self { enabled: false, host: default_mcp_http_host(), port: default_mcp_http_port(), path: default_mcp_http_path(), allow_remote: false, allowed_hosts: Vec::new(), allowed_origins: Vec::new(), } } } fn default_mcp_http_host() -> String { "127.0.0.1".to_string() } fn default_mcp_http_port() -> u16 { 5225 } fn default_mcp_http_path() -> String { "/mcp".to_string() } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpGlobalPolicyState { pub configured: bool, pub read_only: bool, pub allow_dangerous_sql: bool, pub allowed_connection_ids: Option>, #[serde(default)] pub allowed_tool_names: Option>, #[serde(default)] pub connection_policies: Vec, #[serde(default)] pub query_timeout_secs: Option, } impl McpGlobalPolicyState { pub fn policy(&self) -> McpGlobalPolicy { McpGlobalPolicy { read_only: self.read_only, allow_dangerous_sql: self.allow_dangerous_sql, allowed_connection_ids: self.allowed_connection_ids.clone(), allowed_tool_names: self.allowed_tool_names.clone(), connection_policies: self.connection_policies.clone(), query_timeout_secs: self.query_timeout_secs, } } } impl McpGlobalPolicy { /// Produces the single fail-closed representation persisted by the MCP /// policy API. This protects the policy boundary even when a caller does /// not use the desktop settings form (for example, a Web API client). pub fn normalized(&self) -> Self { let allowed_connection_ids = self.allowed_connection_ids.as_ref().map(|ids| { let mut ids = ids.iter().map(|id| id.trim()).filter(|id| !id.is_empty()).map(ToOwned::to_owned).collect::>(); ids.sort(); ids.dedup(); ids }); let allowed_tool_names = self.allowed_tool_names.as_ref().map(|tools| { let mut tools = tools .iter() .map(|tool| tool.trim()) .filter(|tool| !tool.is_empty()) .map(ToOwned::to_owned) .collect::>(); tools.sort(); tools.dedup(); tools }); let mut policies = HashMap::::new(); for rule in &self.connection_policies { let connection_id = rule.connection_id.trim(); if connection_id.is_empty() { continue; } policies .entry(connection_id.to_string()) .and_modify(|current| { // Multiple rules are treated as a conjunction: any // read-only rule wins and high-risk access requires every // duplicate rule to explicitly permit it. if rule.execution_mode_configured { if current.execution_mode_configured { current.read_only |= rule.read_only; current.allow_dangerous_sql &= rule.allow_dangerous_sql; } else { current.read_only = rule.read_only; current.allow_dangerous_sql = rule.allow_dangerous_sql; } current.execution_mode_configured = true; } current.execution_mode_policy_version = match (current.execution_mode_policy_version, rule.execution_mode_policy_version) { (Some(left), Some(right)) if left == crate::mcp_policy::MCP_EXECUTION_POLICY_VERSION && right == left => { Some(left) } _ => None, }; let (scope, databases) = intersect_mcp_database_scopes( current.database_scope, ¤t.allowed_databases, rule.database_scope, &rule.allowed_databases, ); current.database_scope = scope; current.allowed_databases = databases; current.database_policies = merge_mcp_database_policies(¤t.database_policies, &rule.database_policies); }) .or_insert_with(|| McpConnectionPolicy { connection_id: connection_id.to_string(), read_only: rule.read_only, allow_dangerous_sql: rule.allow_dangerous_sql, execution_mode_configured: rule.execution_mode_configured, execution_mode_policy_version: rule.execution_mode_policy_version, database_scope: rule.database_scope, allowed_databases: normalize_mcp_database_names(&rule.allowed_databases), database_policies: normalize_mcp_database_policies(&rule.database_policies), }); } let mut connection_policies = policies.into_values().collect::>(); connection_policies.sort_by(|left, right| left.connection_id.cmp(&right.connection_id)); for rule in &mut connection_policies { if rule.read_only { rule.allow_dangerous_sql = false; } rule.allowed_databases = normalize_mcp_database_names(&rule.allowed_databases); if rule.database_scope != McpDatabaseScope::Selected { rule.allowed_databases.clear(); rule.database_policies.clear(); } else { rule.database_policies .retain(|policy| rule.allowed_databases.binary_search(&policy.database_name).is_ok()); } } Self { read_only: self.read_only, allow_dangerous_sql: !self.read_only && self.allow_dangerous_sql, allowed_connection_ids, allowed_tool_names, connection_policies, query_timeout_secs: self.query_timeout_secs, } } } fn normalize_mcp_database_names(databases: &[String]) -> Vec { let mut databases = databases .iter() .map(|database| database.trim()) .filter(|database| !database.is_empty()) .map(ToOwned::to_owned) .collect::>(); databases.sort(); databases.dedup(); databases } fn normalize_mcp_database_policies(policies: &[McpDatabasePolicy]) -> Vec { let mut normalized = HashMap::::new(); for policy in policies { let database_name = policy.database_name.trim(); if database_name.is_empty() { continue; } normalized .entry(database_name.to_string()) .and_modify(|current| { // Duplicate entries represent independently supplied limits, // so combine them as the strictest possible policy. current.read_only |= policy.read_only; current.allow_dangerous_sql &= policy.allow_dangerous_sql; }) .or_insert_with(|| McpDatabasePolicy { database_name: database_name.to_string(), read_only: policy.read_only, allow_dangerous_sql: !policy.read_only && policy.allow_dangerous_sql, }); } let mut normalized = normalized.into_values().collect::>(); normalized.sort_by(|left, right| left.database_name.cmp(&right.database_name)); for policy in &mut normalized { if policy.read_only { policy.allow_dangerous_sql = false; } } normalized } fn merge_mcp_database_policies(left: &[McpDatabasePolicy], right: &[McpDatabasePolicy]) -> Vec { let mut policies = Vec::with_capacity(left.len() + right.len()); policies.extend_from_slice(left); policies.extend_from_slice(right); normalize_mcp_database_policies(&policies) } fn intersect_mcp_database_scopes( left_scope: McpDatabaseScope, left_databases: &[String], right_scope: McpDatabaseScope, right_databases: &[String], ) -> (McpDatabaseScope, Vec) { use McpDatabaseScope::{All, None, Selected}; match (left_scope, right_scope) { (None, _) | (_, None) => (None, Vec::new()), (All, All) => (All, Vec::new()), (All, Selected) => (Selected, normalize_mcp_database_names(right_databases)), (Selected, All) => (Selected, normalize_mcp_database_names(left_databases)), (Selected, Selected) => { let right = normalize_mcp_database_names(right_databases); let databases = normalize_mcp_database_names(left_databases) .into_iter() .filter(|database| right.binary_search(database).is_ok()) .collect(); (Selected, databases) } } } fn default_sidebar_table_page_size() -> usize { 1000 } pub const DUCKDB_WORKER_MAX_PROCESSES_MIN: usize = 1; pub const DUCKDB_WORKER_MAX_PROCESSES_MAX: usize = 16; pub const DUCKDB_WORKER_MAX_PROCESSES_DEFAULT: usize = 4; pub const METADATA_CACHE_MEMORY_MIN_MB: usize = 16; pub const METADATA_CACHE_MEMORY_RECOMMENDED_MAX_MB: usize = 256; pub const METADATA_CACHE_MEMORY_HARD_MAX_MB: usize = 512; pub const METADATA_CACHE_MEMORY_DEFAULT_MB: usize = 64; pub fn default_metadata_cache_max_memory_mb() -> usize { METADATA_CACHE_MEMORY_DEFAULT_MB } pub fn normalize_metadata_cache_max_memory_mb(value: usize) -> usize { if value > METADATA_CACHE_MEMORY_HARD_MAX_MB { log::warn!( "Metadata cache memory limit {value} MB exceeds the hard limit; falling back to {METADATA_CACHE_MEMORY_DEFAULT_MB} MB" ); METADATA_CACHE_MEMORY_DEFAULT_MB } else { if value > METADATA_CACHE_MEMORY_RECOMMENDED_MAX_MB { log::warn!( "Metadata cache memory limit {value} MB exceeds the recommended {METADATA_CACHE_MEMORY_RECOMMENDED_MAX_MB} MB" ); } value.clamp(METADATA_CACHE_MEMORY_MIN_MB, METADATA_CACHE_MEMORY_HARD_MAX_MB) } } pub fn default_duckdb_worker_max_processes() -> usize { DUCKDB_WORKER_MAX_PROCESSES_DEFAULT } pub fn normalize_duckdb_worker_max_processes(value: usize) -> usize { value.clamp(DUCKDB_WORKER_MAX_PROCESSES_MIN, DUCKDB_WORKER_MAX_PROCESSES_MAX) } impl Default for DesktopSettings { fn default() -> Self { Self { show_tray_icon: true, icon_theme: DesktopIconTheme::Default, quit_on_close: false, close_action_prompted: false, debug_logging_enabled: false, metadata_cache_max_memory_mb: default_metadata_cache_max_memory_mb(), duckdb_worker_process_isolation: false, duckdb_worker_max_processes: default_duckdb_worker_max_processes(), saved_sql_sync_dir: None, driver_store_dir: None, plugin_store_dir: None, agent_store_dir: None, sidebar_table_page_size: default_sidebar_table_page_size(), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DesktopIconTheme { Default, Black, } impl DesktopIconTheme { fn from_settings_value(value: Option<&serde_json::Value>) -> Self { match value.and_then(|value| value.as_str()) { Some("black") => Self::Black, _ => Self::Default, } } } const SCHEMA_STATEMENTS: &[&str] = &[ "CREATE TABLE IF NOT EXISTS connections ( id TEXT PRIMARY KEY, config_json TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS connection_secrets ( connection_id TEXT NOT NULL, key TEXT NOT NULL, secret TEXT NOT NULL, PRIMARY KEY (connection_id, key) )", "CREATE TABLE IF NOT EXISTS history ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL DEFAULT '', connection_name TEXT NOT NULL DEFAULT '', database TEXT NOT NULL DEFAULT '', sql_text TEXT NOT NULL DEFAULT '', executed_at TEXT NOT NULL DEFAULT '', execution_time_ms INTEGER NOT NULL DEFAULT 0, success INTEGER NOT NULL DEFAULT 1, error TEXT, activity_kind TEXT NOT NULL DEFAULT 'query', operation TEXT NOT NULL DEFAULT '', target TEXT NOT NULL DEFAULT '', affected_rows INTEGER, rollback_sql TEXT, details_json TEXT )", "CREATE TABLE IF NOT EXISTS ai_config ( id INTEGER PRIMARY KEY CHECK (id = 1), config_json TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS ai_provider_configs ( provider TEXT PRIMARY KEY, config_json TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS tunnel_profiles ( id TEXT PRIMARY KEY, config_json TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS ai_conversations ( id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', connection_name TEXT NOT NULL DEFAULT '', database TEXT NOT NULL DEFAULT '', messages_json TEXT NOT NULL DEFAULT '[]', queued_input TEXT, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", "CREATE TABLE IF NOT EXISTS ai_runs ( run_id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, session_ids_json TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL, connection_id TEXT NOT NULL DEFAULT '', database TEXT NOT NULL DEFAULT '', schema_name TEXT, pending_confirmation_json TEXT, fifo_category TEXT, pending_input TEXT, max_seq INTEGER, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', FOREIGN KEY (conversation_id) REFERENCES ai_conversations(id) ON DELETE CASCADE )", "CREATE INDEX IF NOT EXISTS idx_ai_runs_conversation_status ON ai_runs(conversation_id, status)", "CREATE TABLE IF NOT EXISTS sidebar_layout ( id INTEGER PRIMARY KEY CHECK (id = 1), layout_json TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS app_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), settings_json TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value_json TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS schema_cache ( cache_key TEXT PRIMARY KEY, payload_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_at_ms INTEGER NOT NULL DEFAULT 0, last_accessed_at_ms INTEGER NOT NULL DEFAULT 0, byte_size INTEGER NOT NULL DEFAULT 0, owner_id TEXT NOT NULL DEFAULT '' )", "CREATE TABLE IF NOT EXISTS tab_runtime_cache ( cache_key TEXT PRIMARY KEY, payload BLOB NOT NULL, row_count INTEGER NOT NULL DEFAULT 0, column_count INTEGER NOT NULL DEFAULT 0, byte_size INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT 0, last_accessed_at INTEGER NOT NULL DEFAULT 0, owner_id TEXT )", "CREATE TABLE IF NOT EXISTS mq_token_records ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, subject TEXT NOT NULL, algorithm TEXT NOT NULL, token_fingerprint TEXT NOT NULL, scope_json TEXT, actions_json TEXT NOT NULL DEFAULT '[]', expires_at TEXT, created_at TEXT NOT NULL, note TEXT NOT NULL DEFAULT '' )", "CREATE INDEX IF NOT EXISTS idx_mq_token_records_connection_subject ON mq_token_records (connection_id, subject, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_mq_token_records_fingerprint ON mq_token_records (token_fingerprint)", "CREATE TABLE IF NOT EXISTS saved_sql_folders ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, parent_folder_id TEXT, name TEXT NOT NULL DEFAULT '', order_index INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", "CREATE TABLE IF NOT EXISTS saved_sql_files ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, folder_id TEXT, name TEXT NOT NULL DEFAULT '', database_name TEXT NOT NULL DEFAULT '', catalog_name TEXT, schema_name TEXT, sql_text TEXT NOT NULL DEFAULT '', order_index INTEGER NOT NULL DEFAULT 0, open_count INTEGER NOT NULL DEFAULT 0, opened_at TEXT, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", "CREATE TABLE IF NOT EXISTS ai_configs ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, model TEXT NOT NULL DEFAULT '', models TEXT NOT NULL DEFAULT '[]', config_json TEXT NOT NULL, is_default INTEGER NOT NULL DEFAULT 0 )", "CREATE TABLE IF NOT EXISTS state_store ( key TEXT PRIMARY KEY, value BLOB NOT NULL, content_type TEXT NOT NULL DEFAULT 'application/octet-stream', version INTEGER NOT NULL DEFAULT 1, payload BLOB DEFAULT x'' )", "CREATE TABLE IF NOT EXISTS prompt_templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, content TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", ]; impl Storage { pub async fn open(db_path: &Path) -> Result { let path = db_path.to_path_buf(); let db_path = db_path.to_string_lossy().to_string(); let db = connect_path_create_if_missing(&db_path).await?; let storage = Self { db, path }; // Best-effort: switching journal mode is itself a lock-sensitive // operation, so a transient failure here (e.g. another process // racing to open the same brand-new database file) must never stop // the app from starting. // Restrict as soon as the file exists, so the guarantee does not // depend on the journal-mode switch or the schema pass succeeding. restrict_db_file_permissions(&storage.path); storage.enable_wal_mode().await; let schema = storage.init_schema().await; // Second pass: the journal sidecars only appear once something has // written to the database, and this runs on the failure path too. restrict_db_file_permissions(&storage.path); schema?; Ok(storage) } /// Multiple `dbx` processes can end up pointed at the same data directory /// (e.g. a portable install shared by several users on one machine). /// WAL mode lets readers and writers proceed without blocking each other, /// which combined with `TransactionBehavior::Immediate` on every write /// transaction (see `conn.transaction_with_behavior` call sites below) /// avoids the instant `SQLITE_BUSY` that a deferred transaction's /// SHARED-to-RESERVED lock upgrade can trigger under concurrent access. /// Never fails: this is a best-effort upgrade, retried a handful of /// times, that logs and gives up rather than blocking startup. async fn enable_wal_mode(&self) { const ATTEMPTS: u32 = 5; for attempt in 1..=ATTEMPTS { let result = self .with_conn(|conn| { conn.query_row("PRAGMA journal_mode=WAL", [], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string()) }) .await; match result { Ok(mode) if mode.eq_ignore_ascii_case("wal") => return, Ok(mode) => { // Non-lock reasons WAL can't apply (e.g. an in-memory // database in tests) won't be fixed by retrying. warn!("dbx.db journal_mode did not switch to WAL (got '{mode}'); concurrent multi-process access may hit 'database is locked' more often"); return; } Err(_) if attempt < ATTEMPTS => { std::thread::sleep(std::time::Duration::from_millis(100 * attempt as u64)); } Err(error) => { warn!("dbx.db could not switch journal_mode to WAL after {ATTEMPTS} attempts: {error}; concurrent multi-process access may hit 'database is locked' more often"); return; } } } } /// Directory containing the SQLite database (`dbx.db`). SSH host keys are /// stored in `/known_hosts` so dbx never touches `~/.ssh`. pub fn data_dir(&self) -> &Path { self.path.parent().unwrap_or_else(|| Path::new(".")) } async fn init_schema(&self) -> Result<(), String> { self.db.with_connection(|conn| { for statement in SCHEMA_STATEMENTS { conn.execute(statement, []).map_err(|e| e.to_string())?; } ensure_history_columns_sync(conn)?; ensure_saved_sql_columns_sync(conn)?; ensure_tab_runtime_cache_columns_sync(conn)?; ensure_schema_cache_columns_sync(conn)?; ensure_ai_configs_columns_sync(conn)?; ensure_state_store_columns_sync(conn)?; ensure_ai_conversations_columns_sync(conn)?; ensure_ai_runs_columns_sync(conn)?; Ok(()) }) } async fn with_conn(&self, f: F) -> Result where T: Send + 'static, F: FnOnce(&mut Connection) -> Result + Send + 'static, { let db = self.db.clone(); tokio::task::spawn_blocking(move || db.with_connection(f)).await.map_err(|e| e.to_string())? } } fn inspect_sqlite_db_file(path: &Path) -> Result { if !path.exists() { return Ok(SqliteDbFileState::Missing); } let metadata = path.metadata().map_err(|e| format!("Failed to inspect db file: {e}"))?; if metadata.len() == 0 { return Ok(SqliteDbFileState::Empty); } if crate::db::sqlite::path_has_sqlite_header(path)? { Ok(SqliteDbFileState::Valid) } else { Ok(SqliteDbFileState::Invalid) } } fn ensure_schema_cache_columns_sync(conn: &Connection) -> Result<(), String> { let mut columns = HashSet::new(); let mut statement = conn.prepare("PRAGMA table_info(schema_cache)").map_err(|error| error.to_string())?; let rows = statement.query_map([], |row| row.get::<_, String>(1)).map_err(|error| error.to_string())?; for row in rows { columns.insert(row.map_err(|error| error.to_string())?); } for (name, definition) in [ ("updated_at_ms", "INTEGER NOT NULL DEFAULT 0"), ("last_accessed_at_ms", "INTEGER NOT NULL DEFAULT 0"), ("byte_size", "INTEGER NOT NULL DEFAULT 0"), ("owner_id", "TEXT NOT NULL DEFAULT ''"), ] { if !columns.contains(name) { conn.execute(&format!("ALTER TABLE schema_cache ADD COLUMN {name} {definition}"), []) .map_err(|error| error.to_string())?; } } conn.execute( "UPDATE schema_cache SET byte_size = length(payload_json) WHERE byte_size = 0 AND payload_json IS NOT NULL", [], ) .map_err(|error| error.to_string())?; conn.execute( "UPDATE schema_cache SET updated_at_ms = COALESCE(CAST(strftime('%s', updated_at) AS INTEGER) * 1000, 0), last_accessed_at_ms = COALESCE(CAST(strftime('%s', updated_at) AS INTEGER) * 1000, 0) WHERE updated_at_ms = 0", [], ) .map_err(|error| error.to_string())?; conn.execute("CREATE INDEX IF NOT EXISTS idx_schema_cache_updated_at_ms ON schema_cache (updated_at_ms)", []) .map_err(|error| error.to_string())?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_schema_cache_owner_lru ON schema_cache (owner_id, last_accessed_at_ms, updated_at_ms, cache_key)", [], ) .map_err(|error| error.to_string())?; Ok(()) } fn open_read_only_sqlite(path: &Path) -> Result { Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|e| format!("Failed to open db read-only: {e}")) } fn sqlite_db_has_user_data(conn: &Connection) -> Result { for table_name in USER_DATA_TABLES { if sqlite_table_has_rows(conn, table_name)? { return Ok(true); } } Ok(false) } fn sqlite_table_has_rows(conn: &Connection, table_name: &str) -> Result { let exists: bool = conn .query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", [table_name], |row| row.get(0), ) .map_err(|e| e.to_string())?; if !exists { return Ok(false); } let sql = format!("SELECT EXISTS(SELECT 1 FROM {table_name} LIMIT 1)"); conn.query_row(&sql, [], |row| row.get(0)).map_err(|e| e.to_string()) } fn remove_sqlite_db_files(db_path: &Path) -> Result<(), String> { for path in [db_path.to_path_buf(), db_path.with_extension("db-wal"), db_path.with_extension("db-shm")] { match std::fs::remove_file(&path) { Ok(()) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(format!("Failed to remove empty target db file {}: {err}", path.display())), } } Ok(()) } /// Every file that can hold database contents: the database itself plus the /// rollback journal, WAL, and shared-memory sidecars. SQLite derives these by /// appending to the database filename, so they are built the same way rather /// than through `Path::with_extension`, which would depend on the database /// being named `*.db`. /// /// Master journals (`-mjXXXXXXXX`) are only written for a /// transaction spanning several attached databases, which the storage handle /// never performs. #[cfg(unix)] fn sqlite_file_set(db_path: &Path) -> [PathBuf; 4] { let sidecar = |suffix: &str| { let mut name = db_path.as_os_str().to_os_string(); name.push(suffix); PathBuf::from(name) }; [db_path.to_path_buf(), sidecar("-journal"), sidecar("-wal"), sidecar("-shm")] } /// Restrict the SQLite files to the sharing model declared by the data /// directory itself. /// /// `connection_secrets` stores connection passwords in plaintext, so the file /// mode is what keeps other local accounts out of the credential store on /// platforms whose per-user data directory is world-traversable: most Linux /// desktops create `~/.local/share` as 0755, unlike `~/Library` on macOS. /// /// A data directory that several local accounts share — the portable layout /// documented on `Storage::open` and `enable_wal_mode` — has to be /// group-writable for those accounts to use it at all, so that is taken as /// the operator opting into group access: world bits are dropped and group /// bits are preserved. Any other directory is treated as single-user and its /// files become owner-only. World-readable is never a supported sharing /// model, because it cannot be narrowed to a set of accounts. /// /// Deliberately best-effort: a portable data directory can live on a /// filesystem without POSIX modes, and a file owned by another user fails /// `chmod` with `EPERM` instead of being re-permissioned behind that user's /// back. Neither case should stop the app from starting. #[cfg(unix)] fn restrict_db_file_permissions(db_path: &Path) { use std::os::unix::fs::PermissionsExt; // S_IWGRP on the directory: the operator made it writable by a group, so // group members are expected to reach the database through it. let group_shared = db_path .parent() .and_then(|dir| std::fs::metadata(dir).ok()) .is_some_and(|dir| dir.permissions().mode() & 0o020 != 0); let keep = if group_shared { 0o770 } else { 0o700 }; for path in sqlite_file_set(db_path) { let Ok(metadata) = std::fs::metadata(&path) else { continue }; let mode = metadata.permissions().mode() & 0o777; // Owner bits, and group bits in a shared directory, are preserved. let restricted = mode & keep; if mode == restricted { continue; } if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(restricted)) { log::debug!("Could not restrict permissions on {}: {err}", path.display()); } } } #[cfg(not(unix))] fn restrict_db_file_permissions(_db_path: &Path) {} fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> { const COLUMNS: &[(&str, &str)] = &[ ("activity_kind", "TEXT NOT NULL DEFAULT 'query'"), ("connection_id", "TEXT NOT NULL DEFAULT ''"), ("operation", "TEXT NOT NULL DEFAULT ''"), ("target", "TEXT NOT NULL DEFAULT ''"), ("affected_rows", "INTEGER"), ("rollback_sql", "TEXT"), ("details_json", "TEXT"), ]; let mut stmt = conn.prepare("SELECT name FROM pragma_table_info('history')").map_err(|e| e.to_string())?; let existing = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; for (name, definition) in COLUMNS { if existing.contains(*name) { continue; } conn.execute(&format!("ALTER TABLE history ADD COLUMN {name} {definition}"), []).map_err(|e| e.to_string())?; } Ok(()) } fn ensure_saved_sql_columns_sync(conn: &Connection) -> Result<(), String> { const FOLDER_COLUMNS: &[(&str, &str)] = &[("parent_folder_id", "TEXT"), ("order_index", "INTEGER NOT NULL DEFAULT 0")]; const FILE_COLUMNS: &[(&str, &str)] = &[ ("catalog_name", "TEXT"), ("order_index", "INTEGER NOT NULL DEFAULT 0"), ("open_count", "INTEGER NOT NULL DEFAULT 0"), ("opened_at", "TEXT"), ]; ensure_table_columns(conn, "saved_sql_folders", FOLDER_COLUMNS)?; ensure_table_columns(conn, "saved_sql_files", FILE_COLUMNS)?; Ok(()) } fn ensure_tab_runtime_cache_columns_sync(conn: &Connection) -> Result<(), String> { const COLUMNS: &[(&str, &str)] = &[ ("created_at", "INTEGER NOT NULL DEFAULT 0"), ("last_accessed_at", "INTEGER NOT NULL DEFAULT 0"), ("owner_id", "TEXT"), ]; ensure_table_columns(conn, "tab_runtime_cache", COLUMNS)?; let now = unix_timestamp_millis(); // Legacy rows must receive a grace period instead of being treated as ancient crash leftovers. conn.execute("UPDATE tab_runtime_cache SET created_at = ?1 WHERE created_at = 0", [now]) .map_err(|e| e.to_string())?; conn.execute("UPDATE tab_runtime_cache SET last_accessed_at = created_at WHERE last_accessed_at = 0", []) .map_err(|e| e.to_string())?; Ok(()) } fn unix_timestamp_millis() -> i64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(i64::MAX as u128) as i64 } fn ensure_ai_configs_columns_sync(conn: &Connection) -> Result<(), String> { const COLUMNS: &[(&str, &str)] = &[ ("model", "TEXT NOT NULL DEFAULT ''"), ("models", "TEXT NOT NULL DEFAULT '[]'"), ("is_default", "INTEGER NOT NULL DEFAULT 0"), ]; ensure_table_columns(conn, "ai_configs", COLUMNS)?; // Create partial unique index (if not exists) conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_configs_default ON ai_configs(is_default) WHERE is_default = 1", [], ) .map_err(|e| e.to_string())?; Ok(()) } /// Adds the queued-input column to `ai_conversations` for databases created /// by earlier iterations of the uncommitted WIP, where the table predates it. fn ensure_ai_conversations_columns_sync(conn: &Connection) -> Result<(), String> { const COLUMNS: &[(&str, &str)] = &[("queued_input", "TEXT")]; ensure_table_columns(conn, "ai_conversations", COLUMNS) } /// Adds the background-run recovery columns (`fifo_category`, `pending_input`, /// `max_seq`) to databases created by earlier iterations of the uncommitted /// WIP, where the `ai_runs` table predates these fields. fn ensure_ai_runs_columns_sync(conn: &Connection) -> Result<(), String> { const COLUMNS: &[(&str, &str)] = &[("fifo_category", "TEXT"), ("pending_input", "TEXT"), ("max_seq", "INTEGER")]; ensure_table_columns(conn, "ai_runs", COLUMNS) } fn ensure_table_columns(conn: &Connection, table_name: &str, columns: &[(&str, &str)]) -> Result<(), String> { let mut stmt = conn.prepare(&format!("SELECT name FROM pragma_table_info('{table_name}')")).map_err(|e| e.to_string())?; let existing = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; for (name, definition) in columns { if existing.contains(*name) { continue; } conn.execute(&format!("ALTER TABLE {table_name} ADD COLUMN {name} {definition}"), []) .map_err(|e| e.to_string())?; } Ok(()) } fn ensure_state_store_columns_sync(conn: &Connection) -> Result<(), String> { conn.execute("CREATE TABLE IF NOT EXISTS state_store (key TEXT PRIMARY KEY, value BLOB NOT NULL, content_type TEXT NOT NULL DEFAULT 'application/octet-stream', version INTEGER NOT NULL DEFAULT 1)", []).map_err(|e| e.to_string())?; // SQLite ALTER TABLE ADD COLUMN rejects parenthesized default expressions. const COLUMNS: &[(&str, &str)] = &[ ("value", "BLOB NOT NULL DEFAULT x''"), ("content_type", "TEXT NOT NULL DEFAULT 'application/octet-stream'"), ("version", "INTEGER NOT NULL DEFAULT 1"), ("payload", "BLOB DEFAULT x''"), ]; ensure_table_columns(conn, "state_store", COLUMNS) } fn ssh_tunnel_secret_segment(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { if hop.id.trim().is_empty() { index.to_string() } else { hop.id.clone() } } fn ssh_tunnel_password_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { format!("{}{}.password", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) } fn ssh_tunnel_key_passphrase_key(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { format!("{}{}.key_passphrase", SSH_TUNNEL_SECRET_PREFIX, ssh_tunnel_secret_segment(index, hop)) } fn transport_layer_secret_segment(index: usize, layer: &TransportLayerConfig) -> String { let id = layer.id().trim(); if id.is_empty() { index.to_string() } else { id.to_string() } } fn transport_layer_ssh_password_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.ssh_password", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn transport_layer_ssh_key_passphrase_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.ssh_key_passphrase", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn transport_layer_proxy_password_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.proxy_password", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn transport_layer_http_tunnel_token_key(index: usize, layer: &TransportLayerConfig) -> String { format!("{}{}.http_tunnel_token", TRANSPORT_LAYER_SECRET_PREFIX, transport_layer_secret_segment(index, layer)) } fn scrub_transport_layer_secrets(config: &mut ConnectionConfig) { for layer in &mut config.transport_layers { match layer { TransportLayerConfig::Ssh(ssh) => { ssh.password.clear(); ssh.key_passphrase.clear(); } TransportLayerConfig::Proxy(proxy) => { proxy.password.clear(); } TransportLayerConfig::HttpTunnel(http) => { http.token.clear(); } } } } fn scrub_mq_auth_secrets(config: &mut ConnectionConfig) { if config.db_type != DatabaseType::MessageQueue { return; } let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else { return; }; match mq_auth_kind(auth) { Some("token") => scrub_json_secret(auth, "token"), Some("basic") => scrub_json_secret(auth, "password"), Some(kind) if is_api_key_auth_kind(kind) => scrub_json_secret(auth, "value"), Some("oauth2") => scrub_json_secret(auth, "clientSecret"), _ => {} } } fn scrub_mq_token_signing_secret(config: &mut ConnectionConfig) { if config.db_type != DatabaseType::MessageQueue { return; } let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else { return; }; scrub_json_secret(signing, "key"); } fn scrub_nacos_auth_secrets(config: &mut ConnectionConfig) { if config.db_type != DatabaseType::Nacos { return; } if let Some(auth) = nacos_auth_object_mut(config.external_config.as_mut()) { if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { scrub_json_secret(auth, "password"); } } if let Some(auth) = nacos_console_auth_object_mut(config.external_config.as_mut()) { if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { scrub_json_secret(auth, "password"); } } } fn delete_secret_prefix_in_tx( tx: &rusqlite::Transaction<'_>, connection_id: &str, key_prefix: &str, ) -> Result<(), String> { let like = format!("{key_prefix}%"); tx.execute("DELETE FROM connection_secrets WHERE connection_id = ?1 AND key LIKE ?2", params![connection_id, like]) .map(|_| ()) .map_err(|e| e.to_string()) } // History fn history_filter_targets_connection(connection: &HistoryConnectionFilter, database: &HistoryDatabaseFilter) -> bool { if !connection.connection_id.is_empty() || !database.connection_id.is_empty() { !connection.connection_id.is_empty() && connection.connection_id == database.connection_id } else { !connection.connection_name.is_empty() && connection.connection_name == database.connection_name } } fn append_history_scope_clause( clauses: &mut Vec, values: &mut Vec, connections: &[HistoryConnectionFilter], databases: &[HistoryDatabaseFilter], ) { let mut alternatives = Vec::new(); for connection in connections { // Database selections narrow only their owning connection; other selected connections remain whole scopes. if databases .iter() .any(|database| !database.database.is_empty() && history_filter_targets_connection(connection, database)) { continue; } if !connection.connection_id.is_empty() { alternatives.push("connection_id = ?".to_string()); values.push(Value::Text(connection.connection_id.clone())); } else if !connection.connection_name.is_empty() { // Legacy JSON entries have no connection ID, so name fallback is limited to empty-ID rows. alternatives.push("(connection_id = '' AND connection_name = ?)".to_string()); values.push(Value::Text(connection.connection_name.clone())); } } for database in databases.iter().filter(|database| !database.database.is_empty()) { if !database.connection_id.is_empty() { alternatives.push("(connection_id = ? AND database = ?)".to_string()); values.push(Value::Text(database.connection_id.clone())); values.push(Value::Text(database.database.clone())); } else if !database.connection_name.is_empty() { alternatives.push("(connection_id = '' AND connection_name = ? AND database = ?)".to_string()); values.push(Value::Text(database.connection_name.clone())); values.push(Value::Text(database.database.clone())); } } if !alternatives.is_empty() { clauses.push(format!("({})", alternatives.join(" OR "))); } } fn escape_history_like_pattern(value: &str) -> String { value.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") } // Only fixed SQL fragments are assembled dynamically; every filter value remains parameter-bound. fn history_search_predicate(request: &HistorySearchRequest) -> (String, Vec) { let mut clauses = Vec::new(); let mut values = Vec::new(); append_history_scope_clause(&mut clauses, &mut values, &request.connections, &request.databases); if let Some(kind) = request.activity_kind.as_ref().filter(|kind| !kind.is_empty()) { clauses.push("activity_kind = ?".to_string()); values.push(Value::Text(kind.clone())); } if let Some(success) = request.success { clauses.push("success = ?".to_string()); values.push(Value::Integer(i64::from(success))); } if let Some(started_at) = request.started_at.as_ref().filter(|value| !value.is_empty()) { clauses.push("julianday(executed_at) >= julianday(?)".to_string()); values.push(Value::Text(started_at.clone())); } if let Some(ended_at) = request.ended_at.as_ref().filter(|value| !value.is_empty()) { clauses.push("julianday(executed_at) <= julianday(?)".to_string()); values.push(Value::Text(ended_at.clone())); } let search_text = request.search_text.trim(); if !search_text.is_empty() { let pattern = format!("%{}%", escape_history_like_pattern(search_text)); let fields = ["sql_text", "connection_name", "database", "operation", "target"]; clauses.push(format!( "({})", fields .iter() .map(|field| format!("{field} LIKE ? ESCAPE '\\' COLLATE NOCASE")) .collect::>() .join(" OR ") )); values.extend(fields.iter().map(|_| Value::Text(pattern.clone()))); } let predicate = if clauses.is_empty() { String::new() } else { format!(" WHERE {}", clauses.join(" AND ")) }; (predicate, values) } fn map_history_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(HistoryEntry { id: row.get(0)?, connection_name: row.get(1)?, database: row.get(2)?, sql: row.get(3)?, executed_at: row.get(4)?, execution_time_ms: row.get::<_, i64>(5)? as u128, success: row.get(6)?, error: row.get(7)?, activity_kind: { let value: String = row.get(8)?; if value.is_empty() { "query".to_string() } else { value } }, connection_id: row.get(9)?, operation: row.get(10)?, target: row.get(11)?, affected_rows: row.get(12)?, rollback_sql: row.get(13)?, details_json: row.get(14)?, }) } impl Storage { pub async fn save_history_entry(&self, entry: &HistoryEntry) -> Result<(), String> { let entry = entry.clone(); self.with_conn(move |conn| { conn.execute( "INSERT OR REPLACE INTO history \ (id, connection_name, database, sql_text, executed_at, execution_time_ms, success, error, \ activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ entry.id, entry.connection_name, entry.database, entry.sql, entry.executed_at, entry.execution_time_ms as i64, entry.success, entry.error, entry.activity_kind, entry.connection_id, entry.operation, entry.target, entry.affected_rows, entry.rollback_sql, entry.details_json ], ) .map_err(|e| e.to_string())?; conn.execute( "DELETE FROM history WHERE id NOT IN \ (SELECT id FROM history ORDER BY executed_at DESC LIMIT ?1)", [MAX_HISTORY as i64], ) .map_err(|e| e.to_string())?; Ok(()) }) .await } pub async fn load_history_entries( &self, limit: usize, offset: usize, activity_kind: Option, ) -> Result, String> { self.with_conn(move |conn| { let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result { Ok(HistoryEntry { id: row.get(0)?, connection_name: row.get(1)?, database: row.get(2)?, sql: row.get(3)?, executed_at: row.get(4)?, execution_time_ms: row.get::<_, i64>(5)? as u128, success: row.get(6)?, error: row.get(7)?, activity_kind: { let value: String = row.get(8)?; if value.is_empty() { "query".to_string() } else { value } }, connection_id: row.get(9)?, operation: row.get(10)?, target: row.get(11)?, affected_rows: row.get(12)?, rollback_sql: row.get(13)?, details_json: row.get(14)?, }) }; if let Some(kind) = activity_kind { let mut stmt = conn .prepare( "SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \ error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \ FROM history WHERE activity_kind = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(params![kind, limit as i64, offset as i64], map_row) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } else { let mut stmt = conn .prepare( "SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \ error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \ FROM history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(params![limit as i64, offset as i64], map_row) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } }) .await } pub async fn search_history_entries(&self, request: HistorySearchRequest) -> Result { self.with_conn(move |conn| { let (predicate, values) = history_search_predicate(&request); // Count before applying the cursor so the UI keeps the full filtered total. let count_sql = format!("SELECT COUNT(*) FROM history{predicate}"); let total = conn .query_row(&count_sql, params_from_iter(values.iter()), |row| row.get::<_, i64>(0)) .map_err(|error| error.to_string())? as usize; let mut page_predicate = predicate; let mut page_values = values; // Keep this predicate aligned with ORDER BY to avoid skipping equal-timestamp rows. if let Some(cursor) = &request.cursor { let cursor_clause = "(executed_at < ? OR (executed_at = ? AND id < ?))"; if page_predicate.is_empty() { page_predicate = format!(" WHERE {cursor_clause}"); } else { page_predicate.push_str(" AND "); page_predicate.push_str(cursor_clause); } page_values.push(Value::Text(cursor.executed_at.clone())); page_values.push(Value::Text(cursor.executed_at.clone())); page_values.push(Value::Text(cursor.id.clone())); } let limit = if request.limit == 0 { 100 } else { request.limit.clamp(1, 200) }; let sql = format!( "SELECT id, connection_name, database, sql_text, executed_at, execution_time_ms, success, \ error, activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json \ FROM history{page_predicate} ORDER BY executed_at DESC, id DESC LIMIT ?" ); page_values.push(Value::Integer((limit + 1) as i64)); let mut stmt = conn.prepare(&sql).map_err(|error| error.to_string())?; let rows = stmt .query_map(params_from_iter(page_values.iter()), map_history_row) .map_err(|error| error.to_string())?; let mut entries = rows.collect::, _>>().map_err(|error| error.to_string())?; let has_more = entries.len() > limit; entries.truncate(limit); let next_cursor = if has_more { entries .last() .map(|entry| HistoryCursor { executed_at: entry.executed_at.clone(), id: entry.id.clone() }) } else { None }; Ok(HistorySearchResult { entries, next_cursor, total }) }) .await } pub async fn load_history_connection_options(&self) -> Result, String> { self.with_conn(|conn| { let mut stmt = conn .prepare( "SELECT connection_id, connection_name, database \ FROM history ORDER BY executed_at DESC, id DESC", ) .map_err(|error| error.to_string())?; let rows = stmt .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))) .map_err(|error| error.to_string())?; let mut options = Vec::::new(); let mut indexes = HashMap::::new(); for row in rows { let (connection_id, connection_name, database) = row.map_err(|error| error.to_string())?; let key = if connection_id.is_empty() { format!("legacy:{connection_name}") } else { format!("id:{connection_id}") }; let index = if let Some(index) = indexes.get(&key) { *index } else { let index = options.len(); indexes.insert(key, index); options.push(HistoryConnectionOption { connection_id, connection_name, databases: Vec::new() }); index }; if !database.is_empty() && !options[index].databases.contains(&database) { options[index].databases.push(database); } } Ok(options) }) .await } pub async fn clear_history(&self) -> Result<(), String> { self.with_conn(|conn| conn.execute("DELETE FROM history", []).map(|_| ()).map_err(|e| e.to_string())).await } pub async fn delete_history_entry(&self, id: &str) -> Result<(), String> { let id = id.to_string(); self.with_conn(move |conn| { conn.execute("DELETE FROM history WHERE id = ?1", [id]).map(|_| ()).map_err(|e| e.to_string()) }) .await } } // AI Config fn ai_provider_key(provider: &AiProvider) -> String { serde_json::to_value(provider).ok().and_then(|value| value.as_str().map(ToOwned::to_owned)).unwrap_or_default() } fn ai_provider_from_key(provider: &str) -> Result { serde_json::from_value(serde_json::Value::String(provider.to_string())) .map_err(|_| format!("Invalid AI provider: {provider}")) } impl Storage { pub async fn save_ai_config(&self, config: &AiConfig) -> Result<(), String> { let json = serde_json::to_string(config).map_err(|e| e.to_string())?; self.with_conn(move |conn| { conn.execute("INSERT OR REPLACE INTO ai_config (id, config_json) VALUES (1, ?1)", [json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn load_ai_config(&self) -> Result, String> { let json: Option = self .with_conn(|conn| { conn.query_row("SELECT config_json FROM ai_config WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string()) }) .await?; json.map(|value| serde_json::from_str(&value).map_err(|e| e.to_string())).transpose() } pub async fn save_ai_provider_config(&self, provider: &str, config: &AiConfig) -> Result<(), String> { let parsed_provider = ai_provider_from_key(provider)?; let mut config = config.clone(); let config_provider = ai_provider_key(&config.provider); if config_provider != provider { warn!( "save_ai_provider_config: config.provider ({}) does not match provider key ({}), normalizing", config_provider, provider ); config.provider = parsed_provider; } let provider = provider.to_string(); let json = serde_json::to_string(&config).map_err(|e| e.to_string())?; self.with_conn(move |conn| { conn.execute( "INSERT OR REPLACE INTO ai_provider_configs (provider, config_json) VALUES (?1, ?2)", params![provider, json], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn load_ai_provider_configs(&self) -> Result, String> { self.with_conn(|conn| { let mut stmt = conn .prepare("SELECT provider, config_json FROM ai_provider_configs") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) .map_err(|e| e.to_string())?; let mut map = HashMap::new(); for row in rows { let (provider, json) = row.map_err(|e| e.to_string())?; match serde_json::from_str::(&json) { Ok(mut config) => { if let Ok(parsed_provider) = ai_provider_from_key(&provider) { let config_provider = ai_provider_key(&config.provider); if config_provider != provider { warn!( "load_ai_provider_configs: stored config.provider ({}) does not match provider key ({}), normalizing", config_provider, provider ); config.provider = parsed_provider; } map.insert(provider, config); } } Err(e) => { warn!("Failed to deserialize AI config for provider '{}': {}", provider, e); } } } Ok(map) }) .await } pub async fn save_ai_configs(&self, configs: &[AiConfigItem]) -> Result<(), String> { let configs = configs.to_vec(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; tx.execute("DELETE FROM ai_configs", []).map_err(|e| e.to_string())?; for config in &configs { let json = serde_json::to_string(&config.config).map_err(|e| e.to_string())?; let models_json = serde_json::to_string(&config.config.models).map_err(|e| e.to_string())?; tx.execute( "INSERT OR REPLACE INTO ai_configs (id, name, model, models, config_json, is_default) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![config.id, config.name, config.config.model, models_json, json, config.is_default as i32], ) .map_err(|e| e.to_string())?; } // Clear old single-config tables — migration is complete, avoids re-migration on empty ai_configs tx.execute("DELETE FROM ai_config", []).map_err(|e| e.to_string())?; tx.execute("DELETE FROM ai_provider_configs", []).map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?; Ok(()) }) .await } pub async fn load_ai_configs(&self) -> Result, String> { self.with_conn(|conn| { let mut stmt = conn .prepare("SELECT id, name, model, models, config_json, is_default FROM ai_configs") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, bool>(5)?, )) }) .map_err(|e| e.to_string())?; let mut configs = Vec::new(); for row in rows { let (id, name, model_col, models_json_col, json, is_default_col) = row.map_err(|e| e.to_string())?; match serde_json::from_str::(&json) { Ok(mut config) => { // 优先使用列值,如果列值为空则从 config_json 回退读取 if model_col.is_empty() { // config.model 已经从 json 解析出来了 } else { config.model = model_col; } if models_json_col.is_empty() || models_json_col == "[]" { // config.models 已经从 json 解析出来了 } else { config.models = serde_json::from_str(&models_json_col).unwrap_or_default(); } let is_default = is_default_col; configs.push(AiConfigItem { id, name, is_default, config }); } Err(e) => { warn!("Failed to deserialize AI config item '{}': {}", id, e); } } } Ok(configs) }) .await } pub async fn set_default_ai_config(&self, config_id: &str) -> Result<(), String> { let config_id = config_id.to_string(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; tx.execute("UPDATE ai_configs SET is_default = 0 WHERE is_default = 1", []).map_err(|e| e.to_string())?; tx.execute("UPDATE ai_configs SET is_default = 1 WHERE id = ?1", params![config_id]) .map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string())?; Ok(()) }) .await } pub async fn save_ai_config_item(&self, config: &AiConfigItem) -> Result<(), String> { let config = config.clone(); self.with_conn(move |conn| { let json = serde_json::to_string(&config.config).map_err(|e| e.to_string())?; let models_json = serde_json::to_string(&config.config.models).map_err(|e| e.to_string())?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; // 如果设该配置为默认,先清除其他默认,避免与 idx_ai_configs_default 冲突 if config.is_default { tx.execute( "UPDATE ai_configs SET is_default = 0 WHERE is_default = 1 AND id != ?1", params![config.id], ) .map_err(|e| e.to_string())?; } tx.execute( "INSERT INTO ai_configs (id, name, model, models, config_json, is_default) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(id) DO UPDATE SET name = excluded.name, model = excluded.model, models = excluded.models, config_json = excluded.config_json, is_default = excluded.is_default", params![config.id, config.name, config.config.model, models_json, json, config.is_default as i32], ) .map_err(|e| { let msg = e.to_string(); // SQLite UNIQUE constraint error contains the table and column name if msg.contains("UNIQUE constraint failed") && msg.contains("ai_configs.name") { format!("ai.configNameExists:{}", config.name) } else { msg } })?; tx.commit().map_err(|e| e.to_string())?; Ok(()) }) .await } pub async fn delete_ai_config(&self, config_id: &str) -> Result<(), String> { let config_id = config_id.to_string(); self.with_conn(move |conn| { conn.execute("DELETE FROM ai_configs WHERE id = ?1", params![config_id]).map_err(|e| e.to_string())?; Ok(()) }) .await } } // Tunnel profiles — shared transport-layer configurations managed in // Settings and referenced from connections via `profile_id`. Secrets stay // inline in `config_json`; that matches the plaintext-at-rest posture of // `connection_secrets` in the same database file. impl Storage { pub async fn load_tunnel_profiles(&self) -> Result, String> { let rows: Vec = self .with_conn(|conn| { let mut stmt = conn .prepare("SELECT config_json FROM tunnel_profiles ORDER BY rowid") .map_err(|e| e.to_string())?; let rows = stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) }) .await?; let mut profiles = Vec::new(); for json in rows { match serde_json::from_str::(&json) { Ok(profile) => profiles.push(profile), Err(e) => warn!("Failed to deserialize tunnel profile: {}", e), } } Ok(profiles) } pub async fn save_tunnel_profiles(&self, profiles: &[TransportLayerConfig]) -> Result<(), String> { for profile in profiles { if profile.id().trim().is_empty() { return Err("Tunnel profile id must not be empty".to_string()); } } let profiles = profiles.to_vec(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; tx.execute("DELETE FROM tunnel_profiles", []).map_err(|e| e.to_string())?; for profile in &profiles { let json = serde_json::to_string(profile).map_err(|e| e.to_string())?; tx.execute( "INSERT INTO tunnel_profiles (id, config_json) VALUES (?1, ?2)", params![profile.id(), json], ) .map_err(|e| e.to_string())?; } tx.commit().map_err(|e| e.to_string()) }) .await } /// Replaces the profile catalog while keeping the secrets already stored /// for a profile when the incoming copy has them scrubbed. Used when /// applying sync snapshots, whose plain (non-encrypted) part strips /// tunnel secrets. pub async fn save_tunnel_profiles_preserving_secrets( &self, profiles: &[TransportLayerConfig], ) -> Result<(), String> { let existing: HashMap = self.load_tunnel_profiles().await?.into_iter().map(|p| (p.id().to_string(), p)).collect(); let merged: Vec = profiles .iter() .map(|profile| { let mut profile = profile.clone(); if let Some(previous) = existing.get(profile.id()) { merge_missing_tunnel_profile_secrets(&mut profile, previous); } profile }) .collect(); self.save_tunnel_profiles(&merged).await } } fn merge_missing_tunnel_profile_secrets(profile: &mut TransportLayerConfig, previous: &TransportLayerConfig) { match (profile, previous) { (TransportLayerConfig::Ssh(current), TransportLayerConfig::Ssh(previous)) => { if current.password.is_empty() { current.password = previous.password.clone(); } if current.key_passphrase.is_empty() { current.key_passphrase = previous.key_passphrase.clone(); } } (TransportLayerConfig::Proxy(current), TransportLayerConfig::Proxy(previous)) => { if current.password.is_empty() { current.password = previous.password.clone(); } } (TransportLayerConfig::HttpTunnel(current), TransportLayerConfig::HttpTunnel(previous)) if current.token.is_empty() => { current.token = previous.token.clone(); } _ => {} } } // App Settings impl Storage { async fn load_app_settings_json(&self) -> Result, String> { let json: Option = self .with_conn(|conn| { conn.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string()) }) .await?; let Some(json) = json else { return Ok(serde_json::Map::new()); }; match serde_json::from_str::(&json).map_err(|e| e.to_string())? { serde_json::Value::Object(map) => Ok(map), _ => Err("app settings JSON must be an object".to_string()), } } async fn save_app_settings_json( &self, settings: &serde_json::Map, ) -> Result<(), String> { let mut settings = settings.clone(); self.with_conn(move |conn| { // Dedicated writers are the only owners of these keys. Keep their // latest values across overlapping legacy settings saves. let current: Option = conn .query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string())?; let dedicated_keys = [MCP_GLOBAL_POLICY_KEY, MAX_RETRIES_KEY]; for key in dedicated_keys { settings.remove(key); } if let Some(current) = current { let current = serde_json::from_str::>(¤t) .map_err(|e| format!("invalid app settings JSON: {e}"))?; for key in dedicated_keys { if let Some(value) = current.get(key) { settings.insert(key.to_string(), value.clone()); } } } let json = serde_json::Value::Object(settings).to_string(); conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn save_password_hash(&self, hash: &str) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; settings.insert("password_hash".to_string(), serde_json::Value::String(hash.to_string())); self.save_app_settings_json(&settings).await } pub async fn load_password_hash(&self) -> Result, String> { let settings = self.load_app_settings_json().await?; Ok(settings.get("password_hash").and_then(|v| v.as_str()).map(|s| s.to_string())) } pub async fn load_mcp_global_policy(&self) -> Result { let result = self .with_conn(|conn| { let json: Option = conn .query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string())?; let Some(json) = json else { let policy = McpGlobalPolicy::default(); return Ok(McpGlobalPolicyState { configured: false, read_only: policy.read_only, allow_dangerous_sql: policy.allow_dangerous_sql, allowed_connection_ids: policy.allowed_connection_ids, allowed_tool_names: policy.allowed_tool_names, connection_policies: policy.connection_policies, query_timeout_secs: policy.query_timeout_secs, }); }; let settings = serde_json::from_str::>(&json) .map_err(|e| format!("invalid app settings JSON: {e}"))?; let Some(value) = settings.get(MCP_GLOBAL_POLICY_KEY) else { let policy = McpGlobalPolicy::default(); return Ok(McpGlobalPolicyState { configured: false, read_only: policy.read_only, allow_dangerous_sql: policy.allow_dangerous_sql, allowed_connection_ids: policy.allowed_connection_ids, allowed_tool_names: policy.allowed_tool_names, connection_policies: policy.connection_policies, query_timeout_secs: policy.query_timeout_secs, }); }; let policy = serde_json::from_value::(value.clone()) .map_err(|e| format!("invalid MCP policy: {e}"))? .normalized(); Ok(McpGlobalPolicyState { configured: true, read_only: policy.read_only, allow_dangerous_sql: policy.allow_dangerous_sql, allowed_connection_ids: policy.allowed_connection_ids, allowed_tool_names: policy.allowed_tool_names, connection_policies: policy.connection_policies, query_timeout_secs: policy.query_timeout_secs, }) }) .await; result.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}")) } pub async fn save_mcp_global_policy(&self, policy: &McpGlobalPolicy) -> Result<(), String> { let policy = serde_json::to_value(policy.normalized()).map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?; self.with_conn(move |conn| { let current: Option = conn .query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string())?; let mut settings = match current { Some(json) => serde_json::from_str::>(&json) .map_err(|e| format!("invalid app settings JSON: {e}"))?, None => serde_json::Map::new(), }; settings.insert(MCP_GLOBAL_POLICY_KEY.to_string(), policy); let json = serde_json::to_string(&settings).map_err(|e| e.to_string())?; conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await .map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}")) } pub async fn load_mcp_http_server_settings(&self) -> Result { let settings = self.load_app_settings_json().await?; match settings.get(MCP_HTTP_SERVER_SETTINGS_KEY) { Some(value) => serde_json::from_value(value.clone()) .map_err(|error| format!("invalid MCP HTTP server settings: {error}")), None => Ok(McpHttpServerSettings::default()), } } pub async fn save_mcp_http_server_settings(&self, settings: &McpHttpServerSettings) -> Result<(), String> { let mut app_settings = self.load_app_settings_json().await?; let value = serde_json::to_value(settings).map_err(|error| error.to_string())?; app_settings.insert(MCP_HTTP_SERVER_SETTINGS_KEY.to_string(), value); self.save_app_settings_json(&app_settings).await } pub async fn save_desktop_settings(&self, desktop_settings: &DesktopSettings) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; settings.remove("run_in_background"); settings.insert("show_tray_icon".to_string(), serde_json::Value::Bool(desktop_settings.show_tray_icon)); settings.insert( "icon_theme".to_string(), serde_json::to_value(desktop_settings.icon_theme).map_err(|e| e.to_string())?, ); settings.insert("quit_on_close".to_string(), serde_json::Value::Bool(desktop_settings.quit_on_close)); settings.insert( "close_action_prompted".to_string(), serde_json::Value::Bool(desktop_settings.close_action_prompted), ); settings.insert( "debug_logging_enabled".to_string(), serde_json::Value::Bool(desktop_settings.debug_logging_enabled), ); settings.insert( "metadata_cache_max_memory_mb".to_string(), serde_json::Value::Number(serde_json::Number::from(normalize_metadata_cache_max_memory_mb( desktop_settings.metadata_cache_max_memory_mb, ))), ); settings.insert( "duckdb_worker_process_isolation".to_string(), serde_json::Value::Bool(desktop_settings.duckdb_worker_process_isolation), ); settings.insert( "duckdb_worker_max_processes".to_string(), serde_json::Value::Number(serde_json::Number::from(normalize_duckdb_worker_max_processes( desktop_settings.duckdb_worker_max_processes, ))), ); match desktop_settings.saved_sql_sync_dir.as_ref().filter(|path| !path.trim().is_empty()) { Some(path) => { settings.insert("saved_sql_sync_dir".to_string(), serde_json::Value::String(path.clone())); } None => { settings.remove("saved_sql_sync_dir"); } } match desktop_settings.driver_store_dir.as_ref().filter(|path| !path.trim().is_empty()) { Some(path) => { settings.insert("driver_store_dir".to_string(), serde_json::Value::String(path.clone())); } None => { settings.remove("driver_store_dir"); } } match desktop_settings.plugin_store_dir.as_ref().filter(|path| !path.trim().is_empty()) { Some(path) => { settings.insert("plugin_store_dir".to_string(), serde_json::Value::String(path.clone())); } None => { settings.remove("plugin_store_dir"); } } match desktop_settings.agent_store_dir.as_ref().filter(|path| !path.trim().is_empty()) { Some(path) => { settings.insert("agent_store_dir".to_string(), serde_json::Value::String(path.clone())); } None => { settings.remove("agent_store_dir"); } } settings.insert( "sidebar_table_page_size".to_string(), serde_json::Value::Number(serde_json::Number::from(desktop_settings.sidebar_table_page_size)), ); self.save_app_settings_json(&settings).await } pub async fn load_desktop_settings(&self) -> Result { let settings = self.load_app_settings_json().await?; Ok(DesktopSettings { show_tray_icon: settings .get("show_tray_icon") .and_then(|value| value.as_bool()) .or_else(|| settings.get("run_in_background").and_then(|value| value.as_bool())) .unwrap_or_else(|| DesktopSettings::default().show_tray_icon), icon_theme: DesktopIconTheme::from_settings_value(settings.get("icon_theme")), quit_on_close: settings .get("quit_on_close") .and_then(|value| value.as_bool()) .unwrap_or_else(|| DesktopSettings::default().quit_on_close), close_action_prompted: settings .get("close_action_prompted") .and_then(|value| value.as_bool()) .unwrap_or_else(|| DesktopSettings::default().close_action_prompted), debug_logging_enabled: settings .get("debug_logging_enabled") .and_then(|value| value.as_bool()) .unwrap_or_else(|| DesktopSettings::default().debug_logging_enabled), metadata_cache_max_memory_mb: settings .get("metadata_cache_max_memory_mb") .and_then(|value| value.as_u64()) .and_then(|value| usize::try_from(value).ok()) .map(normalize_metadata_cache_max_memory_mb) .unwrap_or_else(|| DesktopSettings::default().metadata_cache_max_memory_mb), duckdb_worker_process_isolation: settings .get("duckdb_worker_process_isolation") .and_then(|value| value.as_bool()) .unwrap_or_else(|| DesktopSettings::default().duckdb_worker_process_isolation), duckdb_worker_max_processes: settings .get("duckdb_worker_max_processes") .and_then(|value| value.as_u64()) .and_then(|value| usize::try_from(value).ok()) .map(normalize_duckdb_worker_max_processes) .unwrap_or_else(|| DesktopSettings::default().duckdb_worker_max_processes), saved_sql_sync_dir: settings .get("saved_sql_sync_dir") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), driver_store_dir: settings .get("driver_store_dir") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), plugin_store_dir: settings .get("plugin_store_dir") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), agent_store_dir: settings .get("agent_store_dir") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string), sidebar_table_page_size: settings .get("sidebar_table_page_size") .and_then(|value| value.as_u64()) .map(|value| value as usize) .unwrap_or_else(|| DesktopSettings::default().sidebar_table_page_size), }) } pub async fn save_pinned_tree_node_ids(&self, ids: &[String]) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; let values = ids.iter().map(|id| serde_json::Value::String(id.clone())).collect::>(); settings.insert("pinned_tree_node_ids".to_string(), serde_json::Value::Array(values)); self.save_app_settings_json(&settings).await } pub async fn load_pinned_tree_node_ids(&self) -> Result, String> { let settings = self.load_app_settings_json().await?; let Some(value) = settings.get("pinned_tree_node_ids") else { return Ok(Vec::new()); }; let Some(array) = value.as_array() else { return Ok(Vec::new()); }; Ok(array.iter().filter_map(|item| item.as_str().map(|value| value.to_string())).collect()) } async fn save_app_state_value(&self, key: &str, value: &serde_json::Value) -> Result<(), String> { let key = key.to_string(); let value_json = serde_json::to_string(value).map_err(|e| e.to_string())?; self.with_conn(move |conn| { conn.execute("INSERT OR REPLACE INTO app_state (key, value_json) VALUES (?1, ?2)", params![key, value_json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } async fn load_app_state_value(&self, key: &str) -> Result, String> { let key = key.to_string(); let json: Option = self .with_conn(move |conn| { conn.query_row("SELECT value_json FROM app_state WHERE key = ?1", [key], |row| row.get(0)) .optional() .map_err(|e| e.to_string()) }) .await?; json.map(|value| serde_json::from_str(&value).map_err(|e| e.to_string())).transpose() } pub async fn save_editor_settings(&self, settings: &serde_json::Value) -> Result<(), String> { self.save_app_state_value(APP_STATE_EDITOR_SETTINGS_KEY, settings).await } pub async fn load_editor_settings(&self) -> Result, String> { self.load_app_state_value(APP_STATE_EDITOR_SETTINGS_KEY).await } pub async fn save_open_tabs_state(&self, state: &serde_json::Value) -> Result<(), String> { self.save_app_state_value(APP_STATE_OPEN_TABS_KEY, state).await } pub async fn load_open_tabs_state(&self) -> Result, String> { self.load_app_state_value(APP_STATE_OPEN_TABS_KEY).await } /// Persist open tabs under an isolated state key while sharing the rest of the database. /// /// Desktop development builds use this to avoid overwriting the installed app's /// in-progress SQL when both instances use the same data directory. pub async fn save_open_tabs_state_with_key(&self, key: &str, state: &serde_json::Value) -> Result<(), String> { self.save_app_state_value(key, state).await } /// Load open tabs from an isolated state key. pub async fn load_open_tabs_state_with_key(&self, key: &str) -> Result, String> { self.load_app_state_value(key).await } pub async fn save_saved_sql_editor_positions(&self, positions: &serde_json::Value) -> Result<(), String> { self.save_app_state_value(APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY, positions).await } pub async fn load_saved_sql_editor_positions(&self) -> Result, String> { self.load_app_state_value(APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY).await } /// Persist the saved data-transfer task library (folders + task configs) as /// one JSON document, mirroring the editor-settings app-state pattern. pub async fn save_transfer_task_library(&self, library: &serde_json::Value) -> Result<(), String> { self.save_app_state_value(APP_STATE_TRANSFER_TASK_LIBRARY_KEY, library).await } pub async fn load_transfer_task_library(&self) -> Result, String> { self.load_app_state_value(APP_STATE_TRANSFER_TASK_LIBRARY_KEY).await } pub async fn save_ai_global_custom_instructions(&self, content: &str) -> Result<(), String> { let trimmed = content.trim(); if trimmed.chars().count() > 8000 { return Err("global instructions too long (max 8000 chars)".to_string()); } self.save_app_state_value(APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY, &serde_json::Value::String(trimmed.to_string())) .await } pub async fn load_ai_global_custom_instructions(&self) -> Result { let value = self.load_app_state_value(APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY).await?; Ok(match value { Some(serde_json::Value::String(s)) => s, None | Some(serde_json::Value::Null) => String::new(), other => other.map(|v| v.to_string()).unwrap_or_default(), }) } pub async fn save_ai_chat_selection(&self, selection: &AiChatSelectionState) -> Result<(), String> { let value = serde_json::to_value(selection).map_err(|e| e.to_string())?; self.save_app_state_value(APP_STATE_AI_CHAT_SELECTION_KEY, &value).await } pub async fn load_ai_chat_selection(&self) -> Result, String> { self.load_app_state_value(APP_STATE_AI_CHAT_SELECTION_KEY) .await? .map(|value| serde_json::from_value(value).map_err(|e| e.to_string())) .transpose() } pub async fn load_or_create_local_device_secret(&self) -> Result { let mut settings = self.load_app_settings_json().await?; if let Some(secret) = settings.get("local_device_secret").and_then(|value| value.as_str()) { if !secret.is_empty() { return Ok(secret.to_string()); } } let secret = Uuid::new_v4().to_string(); settings.insert("local_device_secret".to_string(), serde_json::Value::String(secret.clone())); self.save_app_settings_json(&settings).await?; Ok(secret) } pub async fn save_webdav_password_blob(&self, account: &str, blob: &serde_json::Value) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; let mut credentials = settings.remove("webdav_passwords").and_then(|value| value.as_object().cloned()).unwrap_or_default(); credentials.insert(account.to_string(), blob.clone()); settings.insert("webdav_passwords".to_string(), serde_json::Value::Object(credentials)); self.save_app_settings_json(&settings).await } pub async fn load_webdav_password_blob(&self, account: &str) -> Result, String> { let settings = self.load_app_settings_json().await?; Ok(settings .get("webdav_passwords") .and_then(|value| value.as_object()) .and_then(|credentials| credentials.get(account)) .cloned()) } pub async fn delete_webdav_password_blob(&self, account: &str) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; let Some(mut credentials) = settings.remove("webdav_passwords").and_then(|value| value.as_object().cloned()) else { return Ok(()); }; credentials.remove(account); settings.insert("webdav_passwords".to_string(), serde_json::Value::Object(credentials)); self.save_app_settings_json(&settings).await } pub async fn save_webdav_sync_secrets_preference( &self, enabled: bool, blob: Option<&serde_json::Value>, ) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; settings.insert("webdav_sync_secrets_enabled".to_string(), serde_json::Value::Bool(enabled)); if let Some(blob) = blob { settings.insert("webdav_sync_secrets_passphrase".to_string(), blob.clone()); } self.save_app_settings_json(&settings).await } pub async fn load_webdav_sync_secrets_enabled(&self) -> Result { let settings = self.load_app_settings_json().await?; Ok(settings.get("webdav_sync_secrets_enabled").and_then(serde_json::Value::as_bool).unwrap_or(false)) } pub async fn load_webdav_sync_secrets_passphrase_blob(&self) -> Result, String> { let settings = self.load_app_settings_json().await?; Ok(settings.get("webdav_sync_secrets_passphrase").cloned()) } pub async fn delete_webdav_sync_secrets_passphrase_blob(&self) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; settings.remove("webdav_sync_secrets_passphrase"); self.save_app_settings_json(&settings).await } pub async fn save_snippet_sync_id(&self, provider: &str, snippet_id: Option<&str>) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; let mut ids = settings.remove(SNIPPET_SYNC_IDS_KEY).and_then(|value| value.as_object().cloned()).unwrap_or_default(); match snippet_id.map(str::trim).filter(|id| !id.is_empty()) { Some(id) => { ids.insert(provider.to_string(), serde_json::Value::String(id.to_string())); } None => { ids.remove(provider); } } settings.insert(SNIPPET_SYNC_IDS_KEY.to_string(), serde_json::Value::Object(ids)); self.save_app_settings_json(&settings).await } pub async fn load_snippet_sync_id(&self, provider: &str) -> Result, String> { Ok(self.load_snippet_sync_state(provider).await?.snippet_id) } pub async fn save_snippet_migration_state( &self, provider: &str, replacement_snippet_id: &str, legacy_snippet_id: &str, expected_content_hash: &str, ) -> Result<(), String> { let replacement_snippet_id = required_snippet_state_value(replacement_snippet_id, "replacement snippet id")?; let legacy_snippet_id = required_snippet_state_value(legacy_snippet_id, "legacy snippet id")?; let expected_content_hash = required_snippet_state_value(expected_content_hash, "legacy content hash")?; let mut settings = self.load_app_settings_json().await?; let mut ids = settings.remove(SNIPPET_SYNC_IDS_KEY).and_then(|value| value.as_object().cloned()).unwrap_or_default(); ids.insert(provider.to_string(), serde_json::Value::String(replacement_snippet_id.to_string())); settings.insert(SNIPPET_SYNC_IDS_KEY.to_string(), serde_json::Value::Object(ids)); let mut pending_cleanups = settings .remove(SNIPPET_PENDING_CLEANUPS_KEY) .and_then(|value| value.as_object().cloned()) .unwrap_or_default(); pending_cleanups.insert( provider.to_string(), serde_json::to_value(SnippetPendingCleanup { snippet_id: legacy_snippet_id.to_string(), expected_content_hash: expected_content_hash.to_string(), }) .map_err(|e| e.to_string())?, ); settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups)); self.save_app_settings_json(&settings).await } pub async fn load_snippet_sync_state(&self, provider: &str) -> Result { let settings = self.load_app_settings_json().await?; let snippet_id = settings .get(SNIPPET_SYNC_IDS_KEY) .and_then(serde_json::Value::as_object) .and_then(|ids| ids.get(provider)) .and_then(serde_json::Value::as_str) .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string); let pending_cleanup = settings .get(SNIPPET_PENDING_CLEANUPS_KEY) .and_then(serde_json::Value::as_object) .and_then(|cleanups| cleanups.get(provider)) .cloned() .map(serde_json::from_value::) .transpose() .map_err(|e| format!("invalid pending snippet cleanup state: {e}"))? .map(validate_snippet_pending_cleanup) .transpose()?; Ok(SnippetSyncState { snippet_id, pending_cleanup }) } pub async fn clear_snippet_pending_cleanup_if_matches( &self, provider: &str, expected: &SnippetPendingCleanup, ) -> Result { let mut settings = self.load_app_settings_json().await?; let mut pending_cleanups = settings .remove(SNIPPET_PENDING_CLEANUPS_KEY) .and_then(|value| value.as_object().cloned()) .unwrap_or_default(); let Some(current) = pending_cleanups .get(provider) .cloned() .map(serde_json::from_value::) .transpose() .map_err(|e| format!("invalid pending snippet cleanup state: {e}"))? .map(validate_snippet_pending_cleanup) .transpose()? else { settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups)); return Ok(false); }; if current != *expected { settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups)); return Ok(false); } pending_cleanups.remove(provider); settings.insert(SNIPPET_PENDING_CLEANUPS_KEY.to_string(), serde_json::Value::Object(pending_cleanups)); self.save_app_settings_json(&settings).await?; Ok(true) } pub async fn save_max_agent_turns(&self, max_agent_turns: u32) -> Result<(), String> { let mut settings = self.load_app_settings_json().await?; settings.insert( "max_agent_turns".to_string(), serde_json::Value::Number(serde_json::Number::from(crate::agent_loop::clamp_max_agent_turns( max_agent_turns, ))), ); self.save_app_settings_json(&settings).await } pub async fn load_max_agent_turns(&self) -> Result { let settings = self.load_app_settings_json().await?; Ok(settings .get("max_agent_turns") .and_then(serde_json::Value::as_u64) .map(|value| crate::agent_loop::clamp_max_agent_turns(value.min(u32::MAX as u64) as u32)) .unwrap_or(crate::agent_loop::DEFAULT_MAX_AGENT_TURNS)) } pub async fn save_max_retries(&self, max_retries: u32) -> Result<(), String> { let max_retries = crate::ai::clamp_max_retries(max_retries); self.with_conn(move |conn| { let current: Option = conn .query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string())?; let mut settings = match current { Some(json) => serde_json::from_str::>(&json) .map_err(|e| format!("invalid app settings JSON: {e}"))?, None => serde_json::Map::new(), }; settings .insert(MAX_RETRIES_KEY.to_string(), serde_json::Value::Number(serde_json::Number::from(max_retries))); let json = serde_json::Value::Object(settings).to_string(); conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn load_max_retries(&self) -> Result { let settings = self.load_app_settings_json().await?; Ok(settings .get(MAX_RETRIES_KEY) .and_then(serde_json::Value::as_u64) .map(|value| crate::ai::clamp_max_retries(value.min(u32::MAX as u64) as u32)) .unwrap_or(crate::ai::DEFAULT_MAX_RETRIES)) } } // AI Conversations // Terminal runs beyond this many per conversation are pruned on every AI save. // Only the newest terminal run per conversation is ever read at recovery (it // drives the history row's status badge after a restart); the older ones are // pure, unbounded storage + startup-load growth. const KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION: i64 = 2; /// Caps each conversation's terminal run history. `save_ai_run` / /// `save_ai_run_state` persist every run unconditionally and `load_ai_runs` /// loads the whole table at startup, so without this cap repeated completed/ /// failed/cancelled runs grow SQLite storage and recovery work forever. The /// frontend recovery loop dedups to the newest run per conversation, so keeping /// the newest few terminal runs preserves the row status badge exactly while /// bounding the table. Non-terminal statuses (preparing/queued/running/ /// awaiting_write_confirmation/pending_recoverable) are never touched - they /// are the recovery payload. fn prune_terminal_ai_runs(tx: &Transaction<'_>) -> Result<(), String> { tx.execute( "DELETE FROM ai_runs WHERE status IN ('completed', 'failed', 'cancelled', 'interrupted') AND run_id NOT IN ( SELECT run_id FROM ( SELECT run_id, ROW_NUMBER() OVER ( PARTITION BY conversation_id ORDER BY updated_at DESC, run_id DESC ) AS rn FROM ai_runs WHERE status IN ('completed', 'failed', 'cancelled', 'interrupted') ) WHERE rn <= ?1 )", params![KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION], ) .map_err(|e| e.to_string())?; Ok(()) } fn prune_ai_conversations(tx: &Transaction<'_>) -> Result<(), String> { // The 50-row limit is a soft cap: conversations with active or actionable // runs remain reachable even when they exceed the cap. Only terminal, // unprotected conversations compete for the remaining budget. tx.execute( "WITH protected AS ( SELECT DISTINCT conversation_id FROM ai_runs WHERE status IN ('preparing', 'queued', 'running', 'awaiting_write_confirmation', 'pending_recoverable') ), budget AS ( SELECT MAX(0, 50 - COUNT(*)) AS value FROM protected ), keepers AS ( SELECT conversation_id AS id FROM protected UNION SELECT id FROM ( SELECT id FROM ai_conversations WHERE id NOT IN (SELECT conversation_id FROM protected) ORDER BY updated_at DESC LIMIT (SELECT value FROM budget) ) ) DELETE FROM ai_conversations WHERE id NOT IN (SELECT id FROM keepers)", [], ) .map_err(|e| e.to_string())?; // Do not depend on a connection-wide foreign_keys pragma for cleanup. tx.execute("DELETE FROM ai_runs WHERE conversation_id NOT IN (SELECT id FROM ai_conversations)", []) .map_err(|e| e.to_string())?; // Cap terminal run history for the conversations that survive the cap // above (they are deliberately retained), so normal use cannot grow the // ai_runs table without bound. prune_terminal_ai_runs(tx)?; Ok(()) } impl Storage { pub async fn save_ai_conversation(&self, conv: &AiConversation) -> Result<(), String> { let conv = conv.clone(); let messages_json = serde_json::to_string(&conv.messages).map_err(|e| e.to_string())?; self.with_conn(move |conn| { let tx = conn.transaction().map_err(|e| e.to_string())?; tx.execute( "INSERT INTO ai_conversations \ (id, title, connection_name, database, messages_json, queued_input, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET \ title = excluded.title, \ connection_name = excluded.connection_name, \ database = excluded.database, \ messages_json = excluded.messages_json, \ queued_input = excluded.queued_input, \ created_at = excluded.created_at, \ updated_at = excluded.updated_at", params![ conv.id, conv.title, conv.connection_name, conv.database, messages_json, conv.queued_input, conv.created_at, conv.updated_at ], ) .map_err(|e| e.to_string())?; prune_ai_conversations(&tx)?; tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn load_ai_conversations(&self) -> Result, String> { self.with_conn(|conn| { let mut stmt = conn .prepare( "SELECT id, title, connection_name, database, messages_json, queued_input, created_at, updated_at \ FROM ai_conversations ORDER BY updated_at DESC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { let messages_json: String = row.get(4)?; let messages: Vec = serde_json::from_str(&messages_json).map_err(map_from_sql_err)?; Ok(AiConversation { id: row.get(0)?, title: row.get(1)?, connection_name: row.get(2)?, database: row.get(3)?, messages, queued_input: row.get(5)?, created_at: row.get(6)?, updated_at: row.get(7)?, }) }) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) }) .await } pub async fn delete_ai_conversation(&self, id: &str) -> Result<(), String> { let id = id.to_string(); self.with_conn(move |conn| { let tx = conn.transaction().map_err(|e| e.to_string())?; tx.execute("DELETE FROM ai_runs WHERE conversation_id = ?1", [&id]).map_err(|e| e.to_string())?; tx.execute("DELETE FROM ai_conversations WHERE id = ?1", [&id]).map_err(|e| e.to_string())?; prune_ai_conversations(&tx)?; tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn save_ai_run(&self, run: &AiRun) -> Result<(), String> { let run = run.clone(); let session_ids_json = serde_json::to_string(&run.session_ids).map_err(|e| e.to_string())?; let pending_confirmation_json = run.pending_confirmation.map(|value| value.to_string()); let fifo_category = run.fifo_category.map(|c| c.as_str().to_string()); self.with_conn(move |conn| { let tx = conn.transaction().map_err(|e| e.to_string())?; tx.execute( "INSERT OR REPLACE INTO ai_runs (run_id, conversation_id, session_ids_json, status, connection_id, database, schema_name, pending_confirmation_json, fifo_category, pending_input, max_seq, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ run.run_id, run.conversation_id, session_ids_json, run.status.as_str(), run.connection_id, run.database, run.schema, pending_confirmation_json, fifo_category, run.pending_input, run.max_seq, run.created_at, run.updated_at, ], ) .map_err(|e| e.to_string())?; prune_ai_conversations(&tx)?; tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn save_ai_run_state(&self, conv: &AiConversation, run: &AiRun) -> Result<(), String> { let conv = conv.clone(); let run = run.clone(); let messages_json = serde_json::to_string(&conv.messages).map_err(|e| e.to_string())?; let session_ids_json = serde_json::to_string(&run.session_ids).map_err(|e| e.to_string())?; let pending_confirmation_json = run.pending_confirmation.map(|value| value.to_string()); let fifo_category = run.fifo_category.map(|c| c.as_str().to_string()); self.with_conn(move |conn| { let tx = conn.transaction().map_err(|e| e.to_string())?; tx.execute( "INSERT INTO ai_conversations (id, title, connection_name, database, messages_json, queued_input, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, connection_name = excluded.connection_name, database = excluded.database, messages_json = excluded.messages_json, queued_input = excluded.queued_input, created_at = excluded.created_at, updated_at = excluded.updated_at", params![ conv.id, conv.title, conv.connection_name, conv.database, messages_json, conv.queued_input, conv.created_at, conv.updated_at ], ) .map_err(|e| e.to_string())?; tx.execute( "INSERT OR REPLACE INTO ai_runs (run_id, conversation_id, session_ids_json, status, connection_id, database, schema_name, pending_confirmation_json, fifo_category, pending_input, max_seq, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ run.run_id, run.conversation_id, session_ids_json, run.status.as_str(), run.connection_id, run.database, run.schema, pending_confirmation_json, fifo_category, run.pending_input, run.max_seq, run.created_at, run.updated_at, ], ) .map_err(|e| e.to_string())?; prune_ai_conversations(&tx)?; tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn load_ai_runs(&self) -> Result, String> { self.with_conn(|conn| { let mut stmt = conn .prepare( "SELECT run_id, conversation_id, session_ids_json, status, connection_id, database, schema_name, pending_confirmation_json, fifo_category, pending_input, max_seq, created_at, updated_at FROM ai_runs ORDER BY updated_at DESC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { let session_ids_json: String = row.get(2)?; let status: String = row.get(3)?; let pending_confirmation_json: Option = row.get(7)?; let fifo_category: Option = row.get(8)?; let pending_input: Option = row.get(9)?; Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, session_ids_json, status, row.get::<_, String>(4)?, row.get::<_, String>(5)?, row.get::<_, Option>(6)?, pending_confirmation_json, fifo_category, pending_input, row.get::<_, Option>(10)?, row.get::<_, String>(11)?, row.get::<_, String>(12)?, )) }) .map_err(|e| e.to_string())?; rows.map(|row| { let ( run_id, conversation_id, session_ids_json, status, connection_id, database, schema, pending_confirmation_json, fifo_category, pending_input, max_seq, created_at, updated_at, ) = row.map_err(|e| e.to_string())?; Ok(AiRun { run_id, conversation_id, session_ids: serde_json::from_str(&session_ids_json).map_err(|e| e.to_string())?, status: AiRunStatus::parse(&status)?, connection_id, database, schema, pending_confirmation: pending_confirmation_json .map(|json| serde_json::from_str(&json).map_err(|e| e.to_string())) .transpose()?, fifo_category: fifo_category.map(|value| AiRunFifoCategory::parse(&value)).transpose()?, pending_input, max_seq, created_at, updated_at, }) }) .collect::, String>>() }) .await } // Prompt Templates pub async fn load_prompt_templates(&self) -> Result, String> { self.with_conn(|conn| { let mut stmt = conn .prepare( "SELECT id, name, content, created_at, updated_at \ FROM prompt_templates ORDER BY created_at, id", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { Ok(PromptTemplate { id: row.get(0)?, name: row.get(1)?, content: row.get(2)?, created_at: row.get(3)?, updated_at: row.get(4)?, }) }) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) }) .await } pub async fn save_prompt_template(&self, id: &str, name: &str, content: &str) -> Result { let id = id.to_string(); let name = name.trim().to_string(); let content = content.to_string(); // Validation if name.is_empty() { return Err("template name cannot be empty".to_string()); } if name.chars().count() > 50 { return Err("template name too long (max 50 chars)".to_string()); } if content.chars().count() > 8000 { return Err("template content too long (max 8000 chars)".to_string()); } let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); self.with_conn(move |conn| { // Case-insensitive duplicate name check (Unicode-aware). // SQLite LOWER() is ASCII-only, so we compare in Rust where // str::to_lowercase() handles full Unicode case folding. let name_lower = name.to_lowercase(); let mut stmt = conn .prepare("SELECT name FROM prompt_templates WHERE id != ?1") .map_err(|e| e.to_string())?; let duplicate = stmt .query_map(params![id], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .any(|existing| existing.to_lowercase() == name_lower); if duplicate { return Err("duplicate template name".to_string()); } // Check if row exists to decide INSERT vs UPDATE let existing_created_at: Option = conn .query_row("SELECT created_at FROM prompt_templates WHERE id = ?1", params![id], |row| row.get(0)) .optional() .map_err(|e| e.to_string())?; if let Some(created_at) = existing_created_at { // UPDATE — preserve created_at conn.execute( "UPDATE prompt_templates SET name = ?1, content = ?2, updated_at = ?3 WHERE id = ?4", params![name, content, now, id], ) .map_err(|e| e.to_string())?; Ok(PromptTemplate { id, name, content, created_at, updated_at: now }) } else { // INSERT conn.execute( "INSERT INTO prompt_templates (id, name, content, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![id, name, content, now, now], ) .map_err(|e| e.to_string())?; Ok(PromptTemplate { id, name, content, created_at: now.clone(), updated_at: now }) } }) .await } pub async fn delete_prompt_template(&self, id: &str) -> Result<(), String> { let id = id.to_string(); self.with_conn(move |conn| { let rows = conn.execute("DELETE FROM prompt_templates WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; if rows == 0 { Err("template not found".to_string()) } else { Ok(()) } }) .await } } // Connections fn load_mcp_global_policy_in_tx(tx: &rusqlite::Transaction<'_>) -> Result { let settings_json: Option = tx .query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))?; Ok(match settings_json { Some(json) => { let settings = serde_json::from_str::>(&json) .map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid app settings JSON: {e}"))?; match settings.get(MCP_GLOBAL_POLICY_KEY) { Some(value) => serde_json::from_value::(value.clone()) .map_err(|e| format!("MCP_POLICY_UNAVAILABLE: invalid MCP policy: {e}"))? .normalized(), None => McpGlobalPolicy::default(), } } None => McpGlobalPolicy::default(), }) } fn ensure_mcp_connection_change_allowed_in_tx( tx: &rusqlite::Transaction<'_>, target_connection_id: Option<&str>, ) -> Result<(), String> { let policy = load_mcp_global_policy_in_tx(tx)?; if policy.read_only { return Err( "MCP_READ_ONLY: DBX global MCP read-only mode is enabled. Connection changes are blocked.".to_string() ); } if let Some(connection_id) = target_connection_id { if policy.allowed_connection_ids.as_ref().is_some_and(|ids| !ids.iter().any(|id| id == connection_id)) { return Err(format!( "CONNECTION_OUT_OF_SCOPE: connection '{connection_id}' is not allowed by the current DBX MCP policy" )); } } Ok(()) } fn sanitized_connection_config(config: &ConnectionConfig) -> ConnectionConfig { let mut sanitized = config.clone().canonicalized(); sanitized.password = String::new(); scrub_transport_layer_secrets(&mut sanitized); sanitized.redis_sentinel_password = String::new(); sanitized.connection_string = None; sanitized.init_script = None; scrub_mq_auth_secrets(&mut sanitized); scrub_mq_token_signing_secret(&mut sanitized); scrub_nacos_auth_secrets(&mut sanitized); sanitized } fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> { let config = config.clone().canonicalized(); let config_id = config.id.clone(); let sanitized = sanitized_connection_config(&config); let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?; tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json]) .map_err(|e| e.to_string())?; if config.save_password { persist_secret_in_tx(tx, &config.id, "password", &config.password)?; } else { // "Don't save password": write an empty value, which persist_secret_in_tx // turns into a DELETE — the password secret is never persisted (and any // previously stored secret is removed on this save). persist_secret_in_tx(tx, &config.id, "password", "")?; } delete_secret_prefix_in_tx(tx, &config.id, TRANSPORT_LAYER_SECRET_PREFIX)?; for (index, layer) in config.transport_layers.iter().enumerate() { match layer { TransportLayerConfig::Ssh(ssh) => { persist_secret_in_tx(tx, &config.id, &transport_layer_ssh_password_key(index, layer), &ssh.password)?; persist_secret_in_tx( tx, &config.id, &transport_layer_ssh_key_passphrase_key(index, layer), &ssh.key_passphrase, )?; } TransportLayerConfig::Proxy(proxy) => { persist_secret_in_tx( tx, &config.id, &transport_layer_proxy_password_key(index, layer), &proxy.password, )?; } TransportLayerConfig::HttpTunnel(http) => { persist_secret_in_tx( tx, &config.id, &transport_layer_http_tunnel_token_key(index, layer), &http.token, )?; } } } persist_secret_in_tx(tx, &config.id, "redis_sentinel_password", &config.redis_sentinel_password)?; persist_secret_in_tx(tx, &config.id, "ssh_password", "")?; persist_secret_in_tx(tx, &config.id, "ssh_key_passphrase", "")?; persist_secret_in_tx(tx, &config.id, "proxy_password", "")?; delete_secret_prefix_in_tx(tx, &config.id, SSH_TUNNEL_SECRET_PREFIX)?; if let Some(cs) = &config.connection_string { persist_secret_in_tx(tx, &config.id, "connection_string", cs)?; } else { tx.execute( "DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![config.id, "connection_string"], ) .map_err(|e| e.to_string())?; } if let Some(script) = &config.init_script { persist_secret_in_tx(tx, &config.id, "init_script", script)?; } else { tx.execute( "DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![config.id, "init_script"], ) .map_err(|e| e.to_string())?; } persist_mq_auth_secrets_in_tx(tx, &config)?; persist_mq_token_signing_secret_in_tx(tx, &config)?; persist_nacos_auth_secrets_in_tx(tx, &config) } fn insert_connection_copy_next_to_source(entries: &mut Vec, source_id: &str, copy_id: &str) -> bool { let mut index = 0; while index < entries.len() { let entry_type = entries[index].get("type").and_then(serde_json::Value::as_str); if entry_type == Some("connection") && entries[index].get("id").and_then(serde_json::Value::as_str) == Some(source_id) { entries.insert(index + 1, serde_json::json!({ "type": "connection", "id": copy_id })); return true; } if entry_type == Some("group") { if let Some(children) = entries[index].get_mut("children").and_then(serde_json::Value::as_array_mut) { if insert_connection_copy_next_to_source(children, source_id, copy_id) { return true; } } else if let Some(connection_ids) = entries[index].get_mut("connectionIds").and_then(serde_json::Value::as_array_mut) { if let Some(source_index) = connection_ids.iter().position(|id| id.as_str() == Some(source_id)) { connection_ids.insert(source_index + 1, serde_json::Value::String(copy_id.to_string())); return true; } } } index += 1; } false } fn copy_sidebar_layout_entry_in_tx( tx: &rusqlite::Transaction<'_>, source_id: &str, copy_id: &str, ) -> Result<(), String> { let Some(layout_json) = tx .query_row("SELECT layout_json FROM sidebar_layout WHERE id = 1", [], |row| row.get::<_, String>(0)) .optional() .map_err(|error| error.to_string())? else { return Ok(()); }; let mut layout: serde_json::Value = serde_json::from_str(&layout_json).map_err(|error| error.to_string())?; let Some(order) = layout.get_mut("order").and_then(serde_json::Value::as_array_mut) else { return Err("INVALID_SIDEBAR_LAYOUT: sidebar order is not an array".to_string()); }; if !insert_connection_copy_next_to_source(order, source_id, copy_id) { order.push(serde_json::json!({ "type": "connection", "id": copy_id })); } let updated = serde_json::to_string(&layout).map_err(|error| error.to_string())?; tx.execute("UPDATE sidebar_layout SET layout_json = ?1 WHERE id = 1", [updated]) .map(|_| ()) .map_err(|error| error.to_string()) } fn preserve_unreadable_connections_for_replacement( tx: &rusqlite::Transaction<'_>, replacement_ids: &HashSet, ) -> Result, String> { let unreadable_rows = { let mut stmt = tx.prepare("SELECT id, config_json FROM connections").map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) .map_err(|e| e.to_string())?; rows.collect::, _>>() .map_err(|e| e.to_string())? .into_iter() .filter_map(|(id, json)| { serde_json::from_str::(&json).err().map(|error| (id, json, error.to_string())) }) .collect::>() }; tx.execute("DELETE FROM connections", []).map_err(|e| e.to_string())?; let mut preserved_ids = Vec::new(); for (id, json, error) in unreadable_rows { if replacement_ids.contains(&id) { continue; } warn!("Preserving unreadable saved connection '{}' during connection list update: {}", id, error); tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![id, json]) .map_err(|e| e.to_string())?; preserved_ids.push(id); } Ok(preserved_ids) } fn delete_unreferenced_connection_secrets_in_tx( tx: &rusqlite::Transaction<'_>, retained_ids: &[String], ) -> Result<(), String> { if retained_ids.is_empty() { tx.execute("DELETE FROM connection_secrets", []).map_err(|e| e.to_string())?; } else { let placeholders = vec!["?"; retained_ids.len()].join(","); let sql = format!("DELETE FROM connection_secrets WHERE connection_id NOT IN ({placeholders})"); let ids = retained_ids.iter().map(|id| id as &dyn ToSql); tx.execute(&sql, params_from_iter(ids)).map_err(|e| e.to_string())?; } Ok(()) } impl Storage { pub async fn save_connection_metadata_preserving_secrets( &self, configs: &[ConnectionConfig], ) -> Result<(), String> { let configs = configs.to_vec(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; let replacement_ids = configs.iter().map(|config| config.id.clone()).collect::>(); let mut retained_ids = preserve_unreadable_connections_for_replacement(&tx, &replacement_ids)?; for config in &configs { let config = config.canonicalized(); let config_id = config.id.clone(); if !config.save_password { // Metadata-only imports/sync preserve existing secrets by default. // This preference is an exception: retaining the old password would // make a no-save connection silently authenticate without prompting. persist_secret_in_tx(&tx, &config.id, "password", "")?; delete_secret_prefix_in_tx(&tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?; } let mut sanitized = config; sanitized.password = String::new(); scrub_transport_layer_secrets(&mut sanitized); sanitized.redis_sentinel_password = String::new(); sanitized.connection_string = None; sanitized.init_script = None; scrub_mq_auth_secrets(&mut sanitized); scrub_mq_token_signing_secret(&mut sanitized); scrub_nacos_auth_secrets(&mut sanitized); let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?; tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json]) .map_err(|e| e.to_string())?; } retained_ids.extend(configs.iter().map(|config| config.id.clone())); delete_unreferenced_connection_secrets_in_tx(&tx, &retained_ids)?; tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn save_connections(&self, configs: &[ConnectionConfig]) -> Result<(), String> { let configs = configs.to_vec(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; let replacement_ids = configs.iter().map(|config| config.id.clone()).collect::>(); let mut retained_ids = preserve_unreadable_connections_for_replacement(&tx, &replacement_ids)?; for config in &configs { persist_connection_in_tx(&tx, config)?; } retained_ids.extend(configs.iter().map(|config| config.id.clone())); delete_unreferenced_connection_secrets_in_tx(&tx, &retained_ids)?; tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result { let config = config.canonicalized(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; ensure_mcp_connection_change_allowed_in_tx(&tx, None)?; persist_connection_in_tx(&tx, &config)?; tx.commit().map_err(|e| e.to_string())?; Ok(config) }) .await } pub async fn duplicate_connection_for_mcp( &self, source_id: &str, copy_id: &str, copy_name: &str, ) -> Result { let source_id = source_id.to_string(); let copy_id = copy_id.to_string(); let copied_id = copy_id.clone(); let copy_name = copy_name.to_string(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|error| error.to_string())?; ensure_mcp_connection_change_allowed_in_tx(&tx, Some(&source_id))?; let copy_name_lower = copy_name.to_lowercase(); let mut names = tx.prepare("SELECT config_json FROM connections").map_err(|error| error.to_string())?; let duplicate_name = names .query_map([], |row| row.get::<_, String>(0)) .map_err(|error| error.to_string())? .filter_map(Result::ok) .filter_map(|json| serde_json::from_str::(&json).ok()) .any(|connection| connection.name.to_lowercase() == copy_name_lower); drop(names); if duplicate_name { return Err(format!("CONNECTION_ALREADY_EXISTS: connection '{copy_name}' already exists")); } let source_json = tx .query_row("SELECT config_json FROM connections WHERE id = ?1", [&source_id], |row| { row.get::<_, String>(0) }) .optional() .map_err(|error| error.to_string())? .ok_or_else(|| format!("CONNECTION_NOT_FOUND: connection '{source_id}' was not found"))?; let mut copy: ConnectionConfig = serde_json::from_str(&source_json).map_err(|error| error.to_string())?; copy.id = copy_id.clone(); copy.name = copy_name; let copy_json = serde_json::to_string(©).map_err(|error| error.to_string())?; tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![copy_id, copy_json]) .map_err(|error| error.to_string())?; tx.execute( "INSERT INTO connection_secrets (connection_id, key, secret) \ SELECT ?1, key, secret FROM connection_secrets WHERE connection_id = ?2", params![copy.id, source_id], ) .map_err(|error| error.to_string())?; copy_sidebar_layout_entry_in_tx(&tx, &source_id, ©.id)?; tx.commit().map_err(|error| error.to_string())?; Ok(copy) }) .await?; self.load_connections() .await? .into_iter() .find(|connection| connection.id == copied_id) .ok_or_else(|| "CONNECTION_SAVE_ERROR: copied connection could not be reloaded".to_string()) } pub async fn remove_connection_for_mcp(&self, connection_id: &str) -> Result { let connection_id = connection_id.to_string(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; ensure_mcp_connection_change_allowed_in_tx(&tx, Some(&connection_id))?; let removed = tx.execute("DELETE FROM connections WHERE id = ?1", [&connection_id]).map_err(|e| e.to_string())? > 0; if removed { tx.execute("DELETE FROM connection_secrets WHERE connection_id = ?1", [&connection_id]) .map_err(|e| e.to_string())?; } tx.commit().map_err(|e| e.to_string())?; Ok(removed) }) .await } pub async fn save_connection_database_info( &self, connection_id: &str, database_info: Option, ) -> Result<(), String> { let connection_id = connection_id.to_string(); self.with_conn(move |conn| { let json = conn .query_row("SELECT config_json FROM connections WHERE id = ?1", [&connection_id], |row| { row.get::<_, String>(0) }) .optional() .map_err(|error| error.to_string())? .ok_or_else(|| format!("Connection config not found: {connection_id}"))?; let mut config: ConnectionConfig = serde_json::from_str(&json).map_err(|error| error.to_string())?; config.database_info = database_info; let json = serde_json::to_string(&config).map_err(|error| error.to_string())?; conn.execute("UPDATE connections SET config_json = ?1 WHERE id = ?2", params![json, connection_id]) .map(|_| ()) .map_err(|error| error.to_string()) }) .await } /// Update only the persisted MQTT saved-topic metadata for one connection. /// /// Unlike `save_connections`, this is an in-place update and must not replace /// the saved connection list or touch separately stored connection secrets. pub async fn save_connection_mqtt_saved_topics( &self, connection_id: &str, saved_topics: serde_json::Value, ) -> Result<(), String> { let connection_id = connection_id.to_string(); self.with_conn(move |conn| { let json = conn .query_row("SELECT config_json FROM connections WHERE id = ?1", [&connection_id], |row| { row.get::<_, String>(0) }) .optional() .map_err(|error| error.to_string())? .ok_or_else(|| format!("Connection config not found: {connection_id}"))?; let mut config: ConnectionConfig = serde_json::from_str(&json).map_err(|error| error.to_string())?; let mut external_config = config.external_config.take().unwrap_or_else(|| serde_json::json!({})); let Some(external_object) = external_config.as_object_mut() else { return Err("MQTT external_config 必须是 JSON 对象".to_string()); }; external_object.insert("savedTopics".to_string(), saved_topics); config.external_config = Some(external_config); let updated_json = serde_json::to_string(&config).map_err(|error| error.to_string())?; conn.execute("UPDATE connections SET config_json = ?1 WHERE id = ?2", params![updated_json, connection_id]) .map( |updated| { if updated == 0 { Err(format!("Connection config not found: {connection_id}")) } else { Ok(()) } }, ) .map_err(|error| error.to_string())? }) .await } /// Update only the persisted driver identity for an existing connection. /// This is used after runtime driver fallback and deliberately leaves all /// other connection metadata and separately stored secrets untouched. pub async fn save_connection_driver_profile( &self, expected_config: &ConnectionConfig, driver_profile: Option, driver_label: Option, ) -> Result { let expected_config = sanitized_connection_config(expected_config); let connection_id = expected_config.id.clone(); self.with_conn(move |conn| { let Some(json) = conn .query_row("SELECT config_json FROM connections WHERE id = ?1", [&connection_id], |row| { row.get::<_, String>(0) }) .optional() .map_err(|error| error.to_string())? else { return Ok(false); }; let current: ConnectionConfig = serde_json::from_str(&json).map_err(|error| error.to_string())?; let mut current_identity = sanitized_connection_config(¤t); let mut expected_identity = expected_config.clone(); current_identity.note.clear(); current_identity.database_info = None; expected_identity.note.clear(); expected_identity.database_info = None; let identity_matches = current_identity == expected_identity || (current_identity.driver_profile == driver_profile && current_identity.driver_label == driver_label && { current_identity.driver_profile = expected_identity.driver_profile.clone(); current_identity.driver_label = expected_identity.driver_label.clone(); current_identity == expected_identity }); if !identity_matches { return Ok(false); } if current.driver_profile == driver_profile && current.driver_label == driver_label { return Ok(true); } let mut updated = sanitized_connection_config(¤t); updated.driver_profile = driver_profile; updated.driver_label = driver_label; let updated_json = serde_json::to_string(&updated).map_err(|error| error.to_string())?; conn.execute( "UPDATE connections SET config_json = ?1 WHERE id = ?2 AND config_json = ?3", params![updated_json, connection_id, json], ) .map(|updated| updated > 0) .map_err(|error| error.to_string()) }) .await } pub async fn load_connections(&self) -> Result, String> { let rows: Vec<(String, String)> = self .with_conn(|conn| { let mut stmt = conn.prepare("SELECT id, config_json FROM connections").map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) .map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) }) .await?; let mut configs = Vec::new(); for (id, json) in rows { let mut config: ConnectionConfig = match serde_json::from_str(&json) { Ok(config) => config, Err(error) => { warn!("Skipping unreadable saved connection '{}': {}", id, error); continue; } }; config.password = self.get_secret(&id, "password").await?.unwrap_or_default(); for index in 0..config.transport_layers.len() { let layer_for_key = config.transport_layers[index].clone(); match &mut config.transport_layers[index] { TransportLayerConfig::Ssh(ssh) => { ssh.password = self .get_secret(&id, &transport_layer_ssh_password_key(index, &layer_for_key)) .await? .or(match &layer_for_key { TransportLayerConfig::Ssh(layer) if layer.id == "legacy" => { self.get_secret(&id, "ssh_password").await? } TransportLayerConfig::Ssh(layer) => { self.get_secret(&id, &ssh_tunnel_password_key(index, layer)).await? } TransportLayerConfig::Proxy(_) | TransportLayerConfig::HttpTunnel(_) => None, }) .unwrap_or_default(); ssh.key_passphrase = self .get_secret(&id, &transport_layer_ssh_key_passphrase_key(index, &layer_for_key)) .await? .or(match &layer_for_key { TransportLayerConfig::Ssh(layer) if layer.id == "legacy" => { self.get_secret(&id, "ssh_key_passphrase").await? } TransportLayerConfig::Ssh(layer) => { self.get_secret(&id, &ssh_tunnel_key_passphrase_key(index, layer)).await? } TransportLayerConfig::Proxy(_) | TransportLayerConfig::HttpTunnel(_) => None, }) .unwrap_or_default(); } TransportLayerConfig::Proxy(proxy) => { proxy.password = self .get_secret(&id, &transport_layer_proxy_password_key(index, &layer_for_key)) .await? .or(match &layer_for_key { TransportLayerConfig::Proxy(layer) if layer.id == "legacy-proxy" => { self.get_secret(&id, "proxy_password").await? } _ => None, }) .unwrap_or_default(); } TransportLayerConfig::HttpTunnel(http) => { http.token = self .get_secret(&id, &transport_layer_http_tunnel_token_key(index, &layer_for_key)) .await? .unwrap_or_default(); } } } config.redis_sentinel_password = self.get_secret(&id, "redis_sentinel_password").await?.unwrap_or_default(); config.connection_string = self.get_secret(&id, "connection_string").await?; config.init_script = self.get_secret(&id, "init_script").await?; let needs_mq_auth_rewrite = self.hydrate_mq_auth_secrets(&id, &mut config).await?; let needs_mq_token_signing_rewrite = self.hydrate_mq_token_signing_secret(&id, &mut config).await?; let needs_nacos_auth_rewrite = self.hydrate_nacos_auth_secret(&id, &mut config).await?; let needs_external_secret_rewrite = needs_mq_auth_rewrite || needs_mq_token_signing_rewrite || needs_nacos_auth_rewrite; if needs_external_secret_rewrite { let mut sanitized = config.clone().canonicalized(); scrub_mq_auth_secrets(&mut sanitized); scrub_mq_token_signing_secret(&mut sanitized); scrub_nacos_auth_secrets(&mut sanitized); let sanitized_json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?; let update_id = id.clone(); self.with_conn(move |conn| { conn.execute( "UPDATE connections SET config_json = ?1 WHERE id = ?2", params![sanitized_json, update_id], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await?; } configs.push(config.canonicalized()); } Ok(configs) } async fn hydrate_mq_auth_secrets( &self, connection_id: &str, config: &mut ConnectionConfig, ) -> Result { if config.db_type != DatabaseType::MessageQueue { return Ok(false); } let Some(auth) = mq_auth_object_mut(config.external_config.as_mut()) else { return Ok(false); }; let needs_rewrite = match mq_auth_kind(auth) { Some("token") => hydrate_mq_json_secret(self, connection_id, MQ_AUTH_TOKEN_KEY, auth, "token").await?, Some("basic") => { hydrate_mq_json_secret(self, connection_id, MQ_AUTH_PASSWORD_KEY, auth, "password").await? } Some(kind) if is_api_key_auth_kind(kind) => { hydrate_mq_json_secret(self, connection_id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value").await? } Some("oauth2") => { hydrate_mq_json_secret(self, connection_id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret").await? } _ => false, }; Ok(needs_rewrite) } async fn hydrate_mq_token_signing_secret( &self, connection_id: &str, config: &mut ConnectionConfig, ) -> Result { if config.db_type != DatabaseType::MessageQueue { return Ok(false); } let Some(signing) = mq_token_signing_object_mut(config.external_config.as_mut()) else { return Ok(false); }; hydrate_mq_json_secret(self, connection_id, MQ_TOKEN_SIGNING_KEY, signing, "key").await } async fn hydrate_nacos_auth_secret( &self, connection_id: &str, config: &mut ConnectionConfig, ) -> Result { if config.db_type != DatabaseType::Nacos { return Ok(false); } if !config.save_password { let primary_needs_rewrite = nacos_auth_object(config.external_config.as_ref()) .filter(|auth| auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword")) .and_then(|auth| auth.get("password").and_then(serde_json::Value::as_str)) .is_some_and(|password| !password.is_empty()); let console_needs_rewrite = nacos_console_auth_object(config.external_config.as_ref()) .filter(|auth| auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword")) .and_then(|auth| auth.get("password").and_then(serde_json::Value::as_str)) .is_some_and(|password| !password.is_empty()); scrub_nacos_auth_secrets(config); let connection_id = connection_id.to_string(); let key_prefix = format!("{NACOS_AUTH_SECRET_PREFIX}%"); self.with_conn(move |conn| { conn.execute( "DELETE FROM connection_secrets WHERE connection_id = ?1 AND key LIKE ?2", params![connection_id, key_prefix], ) .map(|_| ()) .map_err(|error| error.to_string()) }) .await?; return Ok(primary_needs_rewrite || console_needs_rewrite); } let mut rewritten = false; if let Some(auth) = nacos_auth_object_mut(config.external_config.as_mut()) { if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { rewritten |= hydrate_mq_json_secret(self, connection_id, NACOS_AUTH_PASSWORD_KEY, auth, "password").await?; } } if let Some(auth) = nacos_console_auth_object_mut(config.external_config.as_mut()) { if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { rewritten |= hydrate_mq_json_secret(self, connection_id, NACOS_RNACOS_CONSOLE_PASSWORD_KEY, auth, "password") .await?; } } Ok(rewritten) } } // Saved SQL impl Storage { pub async fn replace_saved_sql_library(&self, library: &SavedSqlLibrary) -> Result<(), String> { let library = library.clone(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; tx.execute("DELETE FROM saved_sql_files", []).map_err(|e| e.to_string())?; tx.execute("DELETE FROM saved_sql_folders", []).map_err(|e| e.to_string())?; for folder in &library.folders { tx.execute( "INSERT INTO saved_sql_folders (id, connection_id, parent_folder_id, name, order_index, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?)", params![ folder.id, folder.connection_id, folder.parent_folder_id, folder.name, folder.order_index, folder.created_at, folder.updated_at ], ) .map_err(|e| e.to_string())?; } for file in &library.files { tx.execute( "INSERT INTO saved_sql_files \ (id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ file.id, file.connection_id, file.folder_id, file.name, file.database, file.catalog, file.schema, file.sql, file.order_index, file.open_count, file.opened_at, file.created_at, file.updated_at ], ) .map_err(|e| e.to_string())?; } tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn load_saved_sql_library(&self) -> Result { self.with_conn(|conn| { let mut folder_stmt = conn .prepare( "SELECT id, connection_id, parent_folder_id, name, order_index, created_at, updated_at \ FROM saved_sql_folders ORDER BY COALESCE(parent_folder_id, ''), order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; let folders = folder_stmt .query_map([], |row| { Ok(SavedSqlFolder { id: row.get(0)?, connection_id: row.get(1)?, parent_folder_id: row.get(2)?, name: row.get(3)?, order_index: row.get(4)?, created_at: row.get(5)?, updated_at: row.get(6)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; let mut file_stmt = conn .prepare( "SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \ FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; let files = file_stmt .query_map([], |row| { Ok(SavedSqlFile { id: row.get(0)?, connection_id: row.get(1)?, folder_id: row.get(2)?, name: row.get(3)?, database: row.get(4)?, catalog: row.get(5)?, schema: row.get(6)?, sql: row.get(7)?, sql_loaded: true, order_index: row.get(8)?, open_count: row.get(9)?, opened_at: row.get(10)?, created_at: row.get(11)?, updated_at: row.get(12)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(SavedSqlLibrary { folders, files }) }) .await } pub async fn load_saved_sql_files_for_sync(&self) -> Result, String> { self.with_conn(|conn| { let mut stmt = conn .prepare( "SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \ FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; let files = stmt .query_map([], |row| { Ok(SavedSqlFile { id: row.get(0)?, connection_id: row.get(1)?, folder_id: row.get(2)?, name: row.get(3)?, database: row.get(4)?, catalog: row.get(5)?, schema: row.get(6)?, sql: row.get(7)?, sql_loaded: true, order_index: row.get(8)?, open_count: row.get(9)?, opened_at: row.get(10)?, created_at: row.get(11)?, updated_at: row.get(12)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(files) }) .await } pub async fn load_saved_sql_library_summary(&self) -> Result { self.with_conn(|conn| { let mut folder_stmt = conn .prepare( "SELECT id, connection_id, parent_folder_id, name, order_index, created_at, updated_at \ FROM saved_sql_folders ORDER BY COALESCE(parent_folder_id, ''), order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; let folders = folder_stmt .query_map([], |row| { Ok(SavedSqlFolder { id: row.get(0)?, connection_id: row.get(1)?, parent_folder_id: row.get(2)?, name: row.get(3)?, order_index: row.get(4)?, created_at: row.get(5)?, updated_at: row.get(6)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; let mut file_stmt = conn .prepare( "SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, order_index, open_count, opened_at, created_at, updated_at \ FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; let files = file_stmt .query_map([], |row| { Ok(SavedSqlFile { id: row.get(0)?, connection_id: row.get(1)?, folder_id: row.get(2)?, name: row.get(3)?, database: row.get(4)?, catalog: row.get(5)?, schema: row.get(6)?, sql: String::new(), sql_loaded: false, order_index: row.get(7)?, open_count: row.get(8)?, opened_at: row.get(9)?, created_at: row.get(10)?, updated_at: row.get(11)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(SavedSqlLibrary { folders, files }) }) .await } pub async fn load_saved_sql_file(&self, id: &str) -> Result, String> { let id = id.to_string(); self.with_conn(move |conn| { let mut stmt = conn .prepare( "SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \ FROM saved_sql_files WHERE id = ?1", ) .map_err(|e| e.to_string())?; match stmt.query_row([id], |row| { Ok(SavedSqlFile { id: row.get(0)?, connection_id: row.get(1)?, folder_id: row.get(2)?, name: row.get(3)?, database: row.get(4)?, catalog: row.get(5)?, schema: row.get(6)?, sql: row.get(7)?, sql_loaded: true, order_index: row.get(8)?, open_count: row.get(9)?, opened_at: row.get(10)?, created_at: row.get(11)?, updated_at: row.get(12)?, }) }) { Ok(file) => Ok(Some(file)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(err) => Err(err.to_string()), } }) .await } pub async fn save_saved_sql_folder(&self, folder: &SavedSqlFolder) -> Result<(), String> { let folder = folder.clone(); self.with_conn(move |conn| { conn.execute( "INSERT INTO saved_sql_folders (id, connection_id, parent_folder_id, name, order_index, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET \ connection_id = excluded.connection_id, \ parent_folder_id = excluded.parent_folder_id, \ name = excluded.name, \ order_index = excluded.order_index, \ updated_at = excluded.updated_at", params![ folder.id, folder.connection_id, folder.parent_folder_id, folder.name, folder.order_index, folder.created_at, folder.updated_at ], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn delete_saved_sql_folder(&self, id: &str) -> Result<(), String> { let id = id.to_string(); self.with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; let mut folder_ids = vec![id.clone()]; let mut index = 0; while index < folder_ids.len() { let parent_id = folder_ids[index].clone(); let mut stmt = tx .prepare("SELECT id FROM saved_sql_folders WHERE parent_folder_id = ?1") .map_err(|e| e.to_string())?; let child_ids = stmt .query_map([parent_id.as_str()], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; folder_ids.extend(child_ids); index += 1; } for folder_id in folder_ids.iter().rev() { tx.execute("DELETE FROM saved_sql_files WHERE folder_id = ?1", [folder_id.as_str()]) .map_err(|e| e.to_string())?; tx.execute("DELETE FROM saved_sql_folders WHERE id = ?1", [folder_id.as_str()]) .map_err(|e| e.to_string())?; } tx.commit().map_err(|e| e.to_string()) }) .await } pub async fn save_saved_sql_file(&self, file: &SavedSqlFile) -> Result<(), String> { let file = file.clone(); self.with_conn(move |conn| { conn.execute( "INSERT INTO saved_sql_files \ (id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET \ connection_id = excluded.connection_id, \ folder_id = excluded.folder_id, \ name = excluded.name, \ database_name = excluded.database_name, \ catalog_name = excluded.catalog_name, \ schema_name = excluded.schema_name, \ sql_text = CASE WHEN ?14 THEN excluded.sql_text ELSE saved_sql_files.sql_text END, \ order_index = excluded.order_index, \ open_count = excluded.open_count, \ opened_at = excluded.opened_at, \ updated_at = excluded.updated_at", params![ file.id, file.connection_id, file.folder_id, file.name, file.database, file.catalog, file.schema, file.sql, file.order_index, file.open_count, file.opened_at, file.created_at, file.updated_at, file.sql_loaded ], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn delete_saved_sql_file(&self, id: &str) -> Result<(), String> { let id = id.to_string(); self.with_conn(move |conn| { conn.execute("DELETE FROM saved_sql_files WHERE id = ?1", [id]).map(|_| ()).map_err(|e| e.to_string()) }) .await } } // Secrets impl Storage { pub async fn get_secret(&self, connection_id: &str, key: &str) -> Result, String> { let connection_id = connection_id.to_string(); let key = key.to_string(); self.with_conn(move |conn| { conn.query_row( "SELECT secret FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![connection_id, key], |row| row.get(0), ) .optional() .map_err(|e| e.to_string()) }) .await } pub async fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String> { let connection_id = connection_id.to_string(); let key = key.to_string(); let secret = secret.to_string(); self.with_conn(move |conn| { conn.execute( "INSERT OR REPLACE INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)", params![connection_id, key, secret], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String> { let connection_id = connection_id.to_string(); let key = key.to_string(); self.with_conn(move |conn| { conn.execute( "DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![connection_id, key], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } } // MQ token records #[cfg(feature = "mq-admin")] impl Storage { pub async fn save_mq_token_record(&self, record: &crate::mq::MqTokenRecord) -> Result<(), String> { let record = record.clone(); self.with_conn(move |conn| { let scope_json = record .scope .as_ref() .map(serde_json::to_string) .transpose() .map_err(|e| e.to_string())?; let actions_json = serde_json::to_string(&record.actions).map_err(|e| e.to_string())?; conn.execute( "INSERT OR REPLACE INTO mq_token_records \ (id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ record.id, record.connection_id, record.subject, record.algorithm.as_str(), record.token_fingerprint, scope_json, actions_json, record.expires_at, record.created_at, record.note ], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn load_mq_token_records( &self, connection_id: &str, subject: Option<&str>, ) -> Result, String> { let connection_id = connection_id.to_string(); let subject = subject.map(str::to_string); self.with_conn(move |conn| { let sql = if subject.is_some() { "SELECT id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note \ FROM mq_token_records WHERE connection_id = ?1 AND subject = ?2 ORDER BY created_at DESC" } else { "SELECT id, connection_id, subject, algorithm, token_fingerprint, scope_json, actions_json, expires_at, created_at, note \ FROM mq_token_records WHERE connection_id = ?1 ORDER BY created_at DESC" }; let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; let rows = if let Some(subject) = subject { stmt.query_map(params![connection_id, subject], mq_token_record_from_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())? } else { stmt.query_map(params![connection_id], mq_token_record_from_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())? }; Ok(rows) }) .await } } // Layout impl Storage { pub async fn save_sidebar_layout(&self, layout: &serde_json::Value) -> Result<(), String> { let json = serde_json::to_string(layout).map_err(|e| e.to_string())?; self.with_conn(move |conn| { conn.execute("INSERT OR REPLACE INTO sidebar_layout (id, layout_json) VALUES (1, ?1)", [json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn load_sidebar_layout(&self) -> Result, String> { let json: Option = self .with_conn(|conn| { conn.query_row("SELECT layout_json FROM sidebar_layout WHERE id = 1", [], |row| row.get(0)) .optional() .map_err(|e| e.to_string()) }) .await?; json.map(|value| serde_json::from_str(&value).map_err(|e| e.to_string())).transpose() } } // Schema cache const SCHEMA_CACHE_MAX_TOTAL_BYTES: i64 = 256 * 1024 * 1024; const SCHEMA_CACHE_MAX_CONNECTION_BYTES: i64 = 64 * 1024 * 1024; const SCHEMA_CACHE_MAX_ENTRIES: usize = 50_000; const SCHEMA_CACHE_MAX_AGE_MILLIS: i64 = 24 * 60 * 60 * 1000; #[derive(Debug, Clone, Copy)] struct SchemaCachePolicy { max_total_bytes: i64, max_connection_bytes: i64, max_entries: usize, max_age_millis: i64, } impl Default for SchemaCachePolicy { fn default() -> Self { Self { max_total_bytes: SCHEMA_CACHE_MAX_TOTAL_BYTES, max_connection_bytes: SCHEMA_CACHE_MAX_CONNECTION_BYTES, max_entries: SCHEMA_CACHE_MAX_ENTRIES, max_age_millis: SCHEMA_CACHE_MAX_AGE_MILLIS, } } } fn schema_cache_owner(cache_key: &str) -> &str { let mut parts = cache_key.split(':'); match (parts.next(), parts.next(), parts.next()) { (Some("object-ddl" | "object-meta"), Some("v1"), Some(owner)) => owner, _ => "", } } fn delete_oldest_schema_cache_entry(conn: &Connection, owner_id: Option<&str>) -> Result { let deleted = match owner_id { Some(owner_id) => conn.execute( "DELETE FROM schema_cache WHERE cache_key = ( SELECT cache_key FROM schema_cache WHERE owner_id = ?1 ORDER BY last_accessed_at_ms ASC, updated_at_ms ASC, cache_key ASC LIMIT 1 )", [owner_id], ), None => conn.execute( "DELETE FROM schema_cache WHERE cache_key = ( SELECT cache_key FROM schema_cache ORDER BY last_accessed_at_ms ASC, updated_at_ms ASC, cache_key ASC LIMIT 1 )", [], ), } .map_err(|error| error.to_string())?; Ok(deleted > 0) } fn prune_schema_cache(conn: &Connection, policy: SchemaCachePolicy, now_ms: i64) -> Result<(), String> { let expires_before = now_ms.saturating_sub(policy.max_age_millis.max(0)); conn.execute("DELETE FROM schema_cache WHERE updated_at_ms = 0 OR updated_at_ms <= ?1", [expires_before]) .map_err(|error| error.to_string())?; loop { let over_budget_owner: Option = conn .query_row( "SELECT owner_id FROM schema_cache GROUP BY owner_id HAVING SUM(byte_size) > ?1 ORDER BY SUM(byte_size) DESC, owner_id ASC LIMIT 1", [policy.max_connection_bytes.max(0)], |row| row.get(0), ) .optional() .map_err(|error| error.to_string())?; let Some(owner_id) = over_budget_owner else { break; }; if !delete_oldest_schema_cache_entry(conn, Some(&owner_id))? { break; } } loop { let (entry_count, total_bytes): (i64, i64) = conn .query_row("SELECT COUNT(*), COALESCE(SUM(byte_size), 0) FROM schema_cache", [], |row| { Ok((row.get(0)?, row.get(1)?)) }) .map_err(|error| error.to_string())?; if entry_count <= policy.max_entries as i64 && total_bytes <= policy.max_total_bytes.max(0) { break; } if !delete_oldest_schema_cache_entry(conn, None)? { break; } } Ok(()) } impl Storage { pub async fn save_schema_cache(&self, cache_key: &str, payload: &serde_json::Value) -> Result<(), String> { self.save_schema_cache_with_policy(cache_key, payload, SchemaCachePolicy::default()).await } async fn save_schema_cache_with_policy( &self, cache_key: &str, payload: &serde_json::Value, policy: SchemaCachePolicy, ) -> Result<(), String> { let cache_key = cache_key.to_string(); let json = serde_json::to_string(payload).map_err(|e| e.to_string())?; let byte_size = json.len().min(i64::MAX as usize) as i64; let owner_id = schema_cache_owner(&cache_key).to_string(); let now_ms = unix_timestamp_millis(); self.with_conn(move |conn| { let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|error| error.to_string())?; transaction .execute( "INSERT INTO schema_cache ( cache_key, payload_json, updated_at, updated_at_ms, last_accessed_at_ms, byte_size, owner_id ) VALUES (?1, ?2, datetime('now'), ?3, ?3, ?4, ?5) ON CONFLICT(cache_key) DO UPDATE SET payload_json = excluded.payload_json, updated_at = excluded.updated_at, updated_at_ms = excluded.updated_at_ms, last_accessed_at_ms = excluded.last_accessed_at_ms, byte_size = excluded.byte_size, owner_id = excluded.owner_id", params![cache_key, json, now_ms, byte_size, owner_id], ) .map_err(|error| error.to_string())?; prune_schema_cache(&transaction, policy, now_ms)?; transaction.commit().map_err(|error| error.to_string()) }) .await } pub async fn load_schema_cache(&self, cache_key: &str) -> Result, String> { let cache_key = cache_key.to_string(); let now_ms = unix_timestamp_millis(); let json: Option = self .with_conn(move |conn| { let transaction = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(|error| error.to_string())?; let json = transaction .query_row( "SELECT payload_json FROM schema_cache WHERE cache_key = ?1 AND updated_at_ms > ?2", params![cache_key, now_ms.saturating_sub(SCHEMA_CACHE_MAX_AGE_MILLIS.max(0))], |row| row.get(0), ) .optional() .map_err(|error| error.to_string())?; if json.is_some() { transaction .execute( "UPDATE schema_cache SET last_accessed_at_ms = ?2 WHERE cache_key = ?1", params![cache_key, now_ms], ) .map_err(|error| error.to_string())?; } transaction.commit().map_err(|error| error.to_string())?; Ok(json) }) .await?; json.map(|value| serde_json::from_str(&value).map_err(|e| e.to_string())).transpose() } pub async fn delete_schema_cache_prefix(&self, prefix: &str) -> Result<(), String> { let prefix = prefix.to_string(); let prefix_len = prefix.len() as i64; self.with_conn(move |conn| { conn.execute( "DELETE FROM schema_cache WHERE cache_key = ?1 OR substr(cache_key, 1, ?2) = ?3", params![prefix.clone(), prefix_len, prefix], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } // State persistence store (CAS-aware key-value store for state machines) pub async fn save_state(&self, key: &str, value: &[u8], content_type: &str) -> Result<(), String> { let key = key.to_string(); let value = value.to_vec(); let content_type = content_type.to_string(); self.with_conn(move |conn| { conn.execute( "INSERT INTO state_store (key, value, content_type, version, payload) \ VALUES (?1, ?2, ?3, 1, x'') \ ON CONFLICT(key) DO UPDATE SET value = excluded.value, content_type = excluded.content_type, \ version = version + 1, payload = excluded.payload", params![key, value, content_type], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn load_state(&self, key: &str) -> Result, String)>, String> { let key = key.to_string(); self.with_conn(move |conn| { let mut stmt = conn .prepare("SELECT value, content_type FROM state_store WHERE key = ?1") .map_err(|e| e.to_string())?; let result: Option<(Vec, String)> = stmt .query_row(params![key], |row| Ok((row.get(0)?, row.get(1)?))) .optional() .map_err(|e| e.to_string())?; Ok(result) }) .await } pub async fn delete_state(&self, key: &str) -> Result<(), String> { let key = key.to_string(); self.with_conn(move |conn| { conn.execute("DELETE FROM state_store WHERE key = ?1", params![key]).map(|_| ()).map_err(|e| e.to_string()) }) .await } pub async fn state_exists(&self, key: &str) -> Result { let key = key.to_string(); self.with_conn(move |conn| { let exists: bool = conn .query_row("SELECT EXISTS(SELECT 1 FROM state_store WHERE key = ?1)", params![key], |row| row.get(0)) .map_err(|e| e.to_string())?; Ok(exists) }) .await } pub async fn get_state_version(&self, key: &str) -> Result, String> { let key = key.to_string(); self.with_conn(move |conn| { conn.prepare("SELECT version FROM state_store WHERE key = ?1") .and_then(|mut stmt| stmt.query_row(params![key], |row| row.get(0)).optional()) .map_err(|e| e.to_string()) }) .await } pub async fn compare_and_swap_state( &self, key: &str, expected_version: Option, new_value: &[u8], content_type: &str, ) -> Result { let key = key.to_string(); let new_value = new_value.to_vec(); let content_type = content_type.to_string(); self.with_conn(move |conn| { let current: Option = conn .prepare("SELECT version FROM state_store WHERE key = ?1") .and_then(|mut stmt| stmt.query_row(params![&key], |row| row.get(0)).optional()) .map_err(|e| e.to_string())?; match (current, expected_version) { (None, None) => { conn.execute( "INSERT INTO state_store (key, value, content_type, version, payload) VALUES (?1, ?2, ?3, 1, x'')", params![key, new_value, content_type], ) .map(|_| true) .map_err(|e| e.to_string()) } (Some(v), Some(expected)) if v == expected => { conn.execute( "UPDATE state_store SET value = ?1, content_type = ?2, version = version + 1 WHERE key = ?3 AND version = ?4", params![new_value, content_type, key, expected], ) .map(|rows| rows > 0) .map_err(|e| e.to_string()) } _ => Ok(false), } }) .await } } // Tab runtime cache impl Storage { pub async fn save_tab_runtime_cache( &self, key: &str, payload: Vec, row_count: i64, column_count: i64, owner_id: Option, ) -> Result<(), String> { let key = key.to_string(); let byte_size = payload.len() as i64; let now = unix_timestamp_millis(); self.with_conn(move |conn| { conn.execute( "INSERT INTO tab_runtime_cache \ (cache_key, payload, row_count, column_count, byte_size, updated_at, created_at, last_accessed_at, owner_id) \ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), ?6, ?6, ?7) \ ON CONFLICT(cache_key) DO UPDATE SET \ payload = excluded.payload, row_count = excluded.row_count, column_count = excluded.column_count, \ byte_size = excluded.byte_size, updated_at = excluded.updated_at, \ last_accessed_at = excluded.last_accessed_at, owner_id = excluded.owner_id", params![key, payload, row_count, column_count, byte_size, now, owner_id], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } pub async fn load_tab_runtime_cache(&self, key: &str) -> Result, String> { let key = key.to_string(); let now = unix_timestamp_millis(); self.with_conn(move |conn| { let entry = conn .query_row( "SELECT cache_key, payload, row_count, column_count, byte_size, updated_at, created_at, last_accessed_at, owner_id \ FROM tab_runtime_cache WHERE cache_key = ?1", [&key], |row| { Ok(TabRuntimeCacheEntry { key: row.get(0)?, payload: row.get(1)?, row_count: row.get(2)?, column_count: row.get(3)?, byte_size: row.get(4)?, updated_at: row.get(5)?, created_at: row.get(6)?, last_accessed_at: now, owner_id: row.get(8)?, }) }, ) .optional() .map_err(|e| e.to_string())?; if entry.is_some() { conn.execute( "UPDATE tab_runtime_cache SET last_accessed_at = ?2 WHERE cache_key = ?1", params![key, now], ) .map_err(|e| e.to_string())?; } Ok(entry) }) .await } pub async fn list_tab_runtime_cache_metadata(&self) -> Result, String> { self.with_conn(move |conn| { let mut statement = conn .prepare( "SELECT cache_key, row_count, column_count, byte_size, updated_at, created_at, last_accessed_at, owner_id \ FROM tab_runtime_cache ORDER BY last_accessed_at ASC, cache_key ASC", ) .map_err(|e| e.to_string())?; let metadata = statement .query_map([], |row| { Ok(TabRuntimeCacheMetadata { key: row.get(0)?, row_count: row.get(1)?, column_count: row.get(2)?, byte_size: row.get(3)?, updated_at: row.get(4)?, created_at: row.get(5)?, last_accessed_at: row.get(6)?, owner_id: row.get(7)?, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(metadata) }) .await } pub async fn prune_tab_runtime_cache( &self, live_keys: Vec, max_bytes: i64, orphan_grace_ms: i64, max_age_ms: Option, ) -> Result { let now = unix_timestamp_millis(); self.with_conn(move |conn| { let live_keys: HashSet = live_keys.into_iter().collect(); let mut statement = conn .prepare( "SELECT cache_key, byte_size, created_at, last_accessed_at \ FROM tab_runtime_cache ORDER BY last_accessed_at ASC, cache_key ASC", ) .map_err(|e| e.to_string())?; let entries = statement .query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?)) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; drop(statement); let mut total_bytes = entries.iter().map(|(_, bytes, _, _)| *bytes).sum::(); let mut deleted = HashSet::new(); let mut orphan_deletions = 0usize; for (key, bytes, created_at, last_accessed_at) in &entries { if live_keys.contains(key) { continue; } let orphan_expired = now.saturating_sub(*created_at) >= orphan_grace_ms.max(0); let age_expired = max_age_ms.is_some_and(|max_age| now.saturating_sub(*last_accessed_at) >= max_age.max(0)); if orphan_expired || age_expired { deleted.insert(key.clone()); total_bytes = total_bytes.saturating_sub(*bytes); if orphan_expired { orphan_deletions += 1; } } } for (key, bytes, _, _) in &entries { if total_bytes <= max_bytes.max(0) { break; } if live_keys.contains(key) || deleted.contains(key) { continue; } deleted.insert(key.clone()); total_bytes = total_bytes.saturating_sub(*bytes); } let deleted_bytes = entries.iter().filter(|(key, _, _, _)| deleted.contains(key)).map(|(_, bytes, _, _)| *bytes).sum(); let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; for key in &deleted { transaction .execute("DELETE FROM tab_runtime_cache WHERE cache_key = ?1", [key]) .map_err(|e| e.to_string())?; } transaction.commit().map_err(|e| e.to_string())?; Ok(TabRuntimeCachePruneResult { deleted_entries: deleted.len(), deleted_bytes, orphan_deletions, remaining_entries: entries.len().saturating_sub(deleted.len()), remaining_bytes: total_bytes, }) }) .await } pub async fn delete_tab_runtime_cache_owner(&self, owner_id: &str) -> Result { let owner_id = owner_id.to_string(); self.with_conn(move |conn| { conn.execute("DELETE FROM tab_runtime_cache WHERE owner_id = ?1", [owner_id]).map_err(|e| e.to_string()) }) .await } pub async fn delete_tab_runtime_cache(&self, key: &str) -> Result<(), String> { let key = key.to_string(); self.with_conn(move |conn| { conn.execute("DELETE FROM tab_runtime_cache WHERE cache_key = ?1", [key]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await } } // JSON migration impl Storage { pub async fn migrate_from_json(&self, data_dir: &Path) -> Result<(), String> { self.migrate_connections_json(data_dir).await?; self.migrate_secrets_json(data_dir).await?; self.migrate_history_json(data_dir).await?; self.migrate_ai_config_json(data_dir).await?; self.migrate_ai_conversations_json(data_dir).await?; self.migrate_sidebar_layout_json(data_dir).await?; Ok(()) } async fn migrate_connections_json(&self, data_dir: &Path) -> Result<(), String> { let path = data_dir.join("connections.json"); if tokio::fs::metadata(&path).await.is_err() { return Ok(()); } let json = tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())?; let configs: Vec = serde_json::from_str(&json).unwrap_or_default(); for config in &configs { let config_json = serde_json::to_string(config).map_err(|e| e.to_string())?; let id = config.id.clone(); self.with_conn(move |conn| { conn.execute( "INSERT OR IGNORE INTO connections (id, config_json) VALUES (?1, ?2)", params![id, config_json], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await?; } let _ = tokio::fs::rename(&path, data_dir.join("connections.json.bak")).await; Ok(()) } async fn migrate_secrets_json(&self, data_dir: &Path) -> Result<(), String> { let path = data_dir.join("secrets.json"); if tokio::fs::metadata(&path).await.is_err() { return Ok(()); } let json = tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())?; let secrets: HashMap = serde_json::from_str(&json).unwrap_or_default(); for (key, secret) in &secrets { let parts: Vec<&str> = key.splitn(3, ':').collect(); if parts.len() == 3 && parts[0] == "connection" { let connection_id = parts[1].to_string(); let field = parts[2].to_string(); let secret = secret.clone(); self.with_conn(move |conn| { conn.execute( "INSERT OR IGNORE INTO connection_secrets (connection_id, key, secret) VALUES (?1, ?2, ?3)", params![connection_id, field, secret], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await?; } } let _ = tokio::fs::rename(&path, data_dir.join("secrets.json.bak")).await; Ok(()) } async fn migrate_history_json(&self, data_dir: &Path) -> Result<(), String> { let path = data_dir.join("query_history.json"); if tokio::fs::metadata(&path).await.is_err() { return Ok(()); } let json = tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())?; let entries: Vec = serde_json::from_str(&json).unwrap_or_default(); for entry in &entries { self.save_history_entry(entry).await?; } let _ = tokio::fs::rename(&path, data_dir.join("query_history.json.bak")).await; Ok(()) } async fn migrate_ai_config_json(&self, data_dir: &Path) -> Result<(), String> { let path = data_dir.join("ai_config.json"); if tokio::fs::metadata(&path).await.is_err() { return Ok(()); } let json = tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())?; let count: i64 = self .with_conn(|conn| { conn.query_row("SELECT COUNT(*) FROM ai_config", [], |row| row.get(0)).map_err(|e| e.to_string()) }) .await?; if count == 0 { self.with_conn(move |conn| { conn.execute("INSERT OR IGNORE INTO ai_config (id, config_json) VALUES (1, ?1)", [json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await?; } let _ = tokio::fs::rename(&path, data_dir.join("ai_config.json.bak")).await; Ok(()) } async fn migrate_ai_conversations_json(&self, data_dir: &Path) -> Result<(), String> { let path = data_dir.join("ai_conversations.json"); if tokio::fs::metadata(&path).await.is_err() { return Ok(()); } let json = tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())?; let conversations: Vec = serde_json::from_str(&json).unwrap_or_default(); for conv in &conversations { let conv = conv.clone(); let messages_json = serde_json::to_string(&conv.messages).map_err(|e| e.to_string())?; self.with_conn(move |conn| { conn.execute( "INSERT OR IGNORE INTO ai_conversations \ (id, title, connection_name, database, messages_json, created_at, updated_at) \ VALUES (?, ?, ?, ?, ?, ?, ?)", params![ conv.id, conv.title, conv.connection_name, conv.database, messages_json, conv.created_at, conv.updated_at ], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await?; } let _ = tokio::fs::rename(&path, data_dir.join("ai_conversations.json.bak")).await; Ok(()) } async fn migrate_sidebar_layout_json(&self, data_dir: &Path) -> Result<(), String> { let path = data_dir.join("sidebar_layout.json"); if tokio::fs::metadata(&path).await.is_err() { return Ok(()); } let json = tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string())?; let count: i64 = self .with_conn(|conn| { conn.query_row("SELECT COUNT(*) FROM sidebar_layout", [], |row| row.get(0)).map_err(|e| e.to_string()) }) .await?; if count == 0 { self.with_conn(move |conn| { conn.execute("INSERT OR IGNORE INTO sidebar_layout (id, layout_json) VALUES (1, ?1)", [json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await?; } let _ = tokio::fs::rename(&path, data_dir.join("sidebar_layout.json.bak")).await; Ok(()) } } fn persist_secret_in_tx( tx: &rusqlite::Transaction<'_>, connection_id: &str, key: &str, secret: &str, ) -> Result<(), String> { if secret.is_empty() { tx.execute("DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![connection_id, key]) .map_err(|e| e.to_string())?; } else { tx.execute( "INSERT OR REPLACE INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)", params![connection_id, key, secret], ) .map_err(|e| e.to_string())?; } Ok(()) } fn persist_mq_auth_secrets_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> { if config.db_type != DatabaseType::MessageQueue { delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?; return Ok(()); } let Some(auth) = mq_auth_object(config.external_config.as_ref()) else { delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?; return Ok(()); }; match mq_auth_kind(auth) { Some("none") => delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?, Some("token") => replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_TOKEN_KEY, auth, "token")?, Some("basic") => replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_PASSWORD_KEY, auth, "password")?, Some(kind) if is_api_key_auth_kind(kind) => { replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_API_KEY_VALUE_KEY, auth, "value")? } Some("oauth2") => { replace_mq_auth_secret_in_tx(tx, &config.id, MQ_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")? } _ => delete_secret_prefix_in_tx(tx, &config.id, MQ_AUTH_SECRET_PREFIX)?, } Ok(()) } fn replace_mq_auth_secret_in_tx( tx: &rusqlite::Transaction<'_>, connection_id: &str, key: &str, auth: &serde_json::Map, field: &str, ) -> Result<(), String> { let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()); let existing = if current.is_none() { get_secret_in_tx(tx, connection_id, key)? } else { None }; delete_secret_prefix_in_tx(tx, connection_id, MQ_AUTH_SECRET_PREFIX)?; match current { Some(secret) => persist_secret_in_tx(tx, connection_id, key, secret), None => match existing { Some(secret) => persist_secret_in_tx(tx, connection_id, key, &secret), None => Ok(()), }, } } fn get_secret_in_tx(tx: &rusqlite::Transaction<'_>, connection_id: &str, key: &str) -> Result, String> { tx.query_row( "SELECT secret FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![connection_id, key], |row| row.get(0), ) .optional() .map_err(|e| e.to_string()) } fn persist_mq_token_signing_secret_in_tx( tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig, ) -> Result<(), String> { if config.db_type != DatabaseType::MessageQueue { delete_secret_prefix_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?; return Ok(()); } let Some(signing) = mq_token_signing_object(config.external_config.as_ref()) else { delete_secret_prefix_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?; return Ok(()); }; persist_json_secret_if_present_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key") } fn persist_nacos_auth_secrets_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> { if config.db_type != DatabaseType::Nacos || !config.save_password { delete_secret_prefix_in_tx(tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?; return Ok(()); } let primary_auth = nacos_auth_object(config.external_config.as_ref()) .filter(|auth| auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword")); let primary = primary_auth .and_then(|auth| auth.get("password").and_then(serde_json::Value::as_str)) .filter(|secret| !secret.is_empty()); let console_auth = nacos_console_auth_object(config.external_config.as_ref()) .filter(|auth| auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword")); let console = console_auth .and_then(|auth| auth.get("password").and_then(serde_json::Value::as_str)) .filter(|secret| !secret.is_empty()); let existing_primary = if primary.is_none() && primary_auth.is_some() { get_secret_in_tx(tx, &config.id, NACOS_AUTH_PASSWORD_KEY)? } else { None }; let existing_console = if console.is_none() && console_auth.is_some() { get_secret_in_tx(tx, &config.id, NACOS_RNACOS_CONSOLE_PASSWORD_KEY)? } else { None }; delete_secret_prefix_in_tx(tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?; if let Some(secret) = primary.or(existing_primary.as_deref()) { persist_secret_in_tx(tx, &config.id, NACOS_AUTH_PASSWORD_KEY, secret)?; } if let Some(secret) = console.or(existing_console.as_deref()) { persist_secret_in_tx(tx, &config.id, NACOS_RNACOS_CONSOLE_PASSWORD_KEY, secret)?; } Ok(()) } fn persist_json_secret_if_present_in_tx( tx: &rusqlite::Transaction<'_>, connection_id: &str, key: &str, auth: &serde_json::Map, field: &str, ) -> Result<(), String> { if let Some(secret) = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) { persist_secret_in_tx(tx, connection_id, key, secret)?; } Ok(()) } async fn hydrate_mq_json_secret( storage: &Storage, connection_id: &str, key: &str, auth: &mut serde_json::Map, field: &str, ) -> Result { if let Some(secret) = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()) { storage.set_secret(connection_id, key, secret).await?; Ok(true) } else if let Some(secret) = storage.get_secret(connection_id, key).await? { auth.insert(field.to_string(), serde_json::Value::String(secret)); Ok(false) } else { Ok(false) } } fn scrub_json_secret(auth: &mut serde_json::Map, field: &str) { if auth.contains_key(field) { auth.insert(field.to_string(), serde_json::Value::String(String::new())); } } fn mq_auth_kind(auth: &serde_json::Map) -> Option<&str> { auth.get("kind").and_then(serde_json::Value::as_str) } fn mq_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map> { value?.get("auth")?.as_object() } fn mq_auth_object_mut( value: Option<&mut serde_json::Value>, ) -> Option<&mut serde_json::Map> { value?.get_mut("auth")?.as_object_mut() } fn mq_token_signing_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map> { value?.get("tokenSigning")?.as_object() } fn mq_token_signing_object_mut( value: Option<&mut serde_json::Value>, ) -> Option<&mut serde_json::Map> { value?.get_mut("tokenSigning")?.as_object_mut() } fn nacos_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map> { value?.get("auth")?.as_object() } fn nacos_auth_object_mut( value: Option<&mut serde_json::Value>, ) -> Option<&mut serde_json::Map> { value?.get_mut("auth")?.as_object_mut() } fn nacos_console_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map> { value?.get("rnacosConsoleAuth")?.as_object() } fn nacos_console_auth_object_mut( value: Option<&mut serde_json::Value>, ) -> Option<&mut serde_json::Map> { value?.get_mut("rnacosConsoleAuth")?.as_object_mut() } fn is_api_key_auth_kind(kind: &str) -> bool { matches!(kind, "apiKey" | "api_key" | "apikey") } #[cfg(feature = "mq-admin")] fn mq_token_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let algorithm: String = row.get(3)?; let scope_json: Option = row.get(5)?; let actions_json: String = row.get(6)?; Ok(crate::mq::MqTokenRecord { id: row.get(0)?, connection_id: row.get(1)?, subject: row.get(2)?, algorithm: serde_json::from_value(serde_json::Value::String(algorithm)).map_err(map_from_sql_err)?, token_fingerprint: row.get(4)?, scope: scope_json.as_deref().map(serde_json::from_str).transpose().map_err(map_from_sql_err)?, actions: serde_json::from_str(&actions_json).map_err(map_from_sql_err)?, expires_at: row.get(7)?, created_at: row.get(8)?, note: row.get(9)?, }) } fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error { rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(err)) } #[cfg(test)] mod tests { use super::{ maybe_import_user_data_db, DataDbImportResult, DesktopIconTheme, DesktopSettings, McpGlobalPolicy, McpGlobalPolicyState, Storage, KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION, MCP_GLOBAL_POLICY_KEY, }; use crate::ai::{ AiActiveModelSelection, AiAssistantMode, AiChatMessage, AiChatSelectionState, AiConversation, AiEffortSelection, AiModelEffortPreference, AiRun, AiRunFifoCategory, AiRunStatus, }; use crate::connection_secrets::NACOS_RNACOS_CONSOLE_PASSWORD_KEY; use crate::connection_secrets::{ MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY, }; use crate::history::{HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest}; use crate::models::connection::{ ConnectionConfig, DatabaseConnectionInfo, DatabaseType, SshTunnelConfig, TransportLayerConfig, }; use crate::saved_sql::SavedSqlFile; use rusqlite::{Connection, TransactionBehavior}; use std::collections::BTreeMap; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; fn temp_db_path(name: &str) -> std::path::PathBuf { let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); std::env::temp_dir().join(format!("dbx-storage-{name}-{}-{stamp}.db", std::process::id())) } /// Data directory with an explicit mode. The process temp directory is /// group-writable on Linux (`/tmp` is 1777), which would otherwise be read /// as the shared-directory sharing model. #[cfg(unix)] fn temp_data_dir_with_mode(name: &str, mode: u32) -> std::path::PathBuf { use std::os::unix::fs::PermissionsExt; let dir = temp_data_dir(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(mode)).unwrap(); dir } #[cfg(unix)] fn file_mode(path: &std::path::Path) -> Option { use std::os::unix::fs::PermissionsExt; std::fs::metadata(path).ok().map(|metadata| metadata.permissions().mode() & 0o777) } #[cfg(unix)] #[tokio::test] async fn open_restricts_the_database_and_its_journals_to_the_owner() { use std::os::unix::fs::PermissionsExt; let dir = temp_data_dir_with_mode("permission-single-user", 0o755); let path = dir.join("dbx.db"); drop(Storage::open(&path).await.unwrap()); // Stand in for an installation created before this hardening existed, // including a rollback journal left behind by an interrupted write. let journal = dir.join("dbx.db-journal"); std::fs::write(&journal, b"").unwrap(); for file in [&path, &journal] { std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o644)).unwrap(); } drop(Storage::open(&path).await.unwrap()); for name in ["dbx.db", "dbx.db-journal", "dbx.db-wal", "dbx.db-shm"] { let Some(mode) = file_mode(&dir.join(name)) else { continue }; assert_eq!(mode & 0o077, 0, "{name} kept group/other bits ({mode:o})"); assert_ne!(mode & 0o600, 0, "{name} lost owner access ({mode:o})"); } let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] #[tokio::test] async fn open_preserves_group_access_in_a_shared_data_directory() { use std::os::unix::fs::PermissionsExt; // Group-writable: the layout the portable notes on `Storage::open` and // `enable_wal_mode` describe, where several local accounts share one // data directory. let dir = temp_data_dir_with_mode("permission-group-shared", 0o770); let path = dir.join("dbx.db"); drop(Storage::open(&path).await.unwrap()); let journal = dir.join("dbx.db-journal"); std::fs::write(&journal, b"").unwrap(); for file in [&path, &journal] { std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o664)).unwrap(); } drop(Storage::open(&path).await.unwrap()); for name in ["dbx.db", "dbx.db-journal"] { let Some(mode) = file_mode(&dir.join(name)) else { continue }; assert_eq!(mode & 0o007, 0, "{name} stayed world-accessible ({mode:o})"); assert_ne!(mode & 0o060, 0, "{name} lost the group access it is shared through ({mode:o})"); } let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] #[tokio::test] async fn open_restricts_the_database_even_when_schema_initialization_fails() { use std::os::unix::fs::PermissionsExt; let dir = temp_data_dir_with_mode("permission-init-failure", 0o755); let path = dir.join("dbx.db"); drop(Storage::open(&path).await.unwrap()); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); // A read-only directory keeps the database file itself writable while // denying the journal SQLite needs, so the schema pass fails. std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); let result = Storage::open(&path).await; std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(result.is_err(), "expected schema initialization to fail on a read-only directory"); let mode = file_mode(&path).expect("database file still exists"); assert_eq!(mode & 0o077, 0, "database kept group/other bits after a failed open ({mode:o})"); let _ = std::fs::remove_dir_all(&dir); } fn temp_data_dir(name: &str) -> std::path::PathBuf { let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); std::env::temp_dir().join(format!("dbx-storage-{name}-{}-{stamp}", std::process::id())) } fn history_entry( id: &str, connection_id: &str, connection_name: &str, database: &str, sql: &str, executed_at: &str, success: bool, ) -> HistoryEntry { HistoryEntry { id: id.to_string(), connection_id: connection_id.to_string(), connection_name: connection_name.to_string(), database: database.to_string(), sql: sql.to_string(), executed_at: executed_at.to_string(), execution_time_ms: 10, success, error: (!success).then(|| "query failed".to_string()), activity_kind: "query".to_string(), operation: "SELECT".to_string(), target: "orders".to_string(), affected_rows: None, rollback_sql: None, details_json: None, } } fn ai_conversation(id: &str, updated_at: &str) -> AiConversation { AiConversation { id: id.to_string(), title: id.to_string(), connection_name: "local".to_string(), database: "db".to_string(), messages: vec![AiChatMessage { role: "user".to_string(), content: id.to_string(), mentions: None, reasoning: None, kind: None, covered_messages: None, }], queued_input: None, created_at: updated_at.to_string(), updated_at: updated_at.to_string(), } } fn ai_run(id: &str, conversation_id: &str, status: AiRunStatus, updated_at: &str) -> AiRun { AiRun { run_id: id.to_string(), conversation_id: conversation_id.to_string(), session_ids: vec![], status, connection_id: "connection".to_string(), database: "db".to_string(), schema: None, pending_confirmation: None, fifo_category: None, pending_input: None, max_seq: None, created_at: updated_at.to_string(), updated_at: updated_at.to_string(), } } #[tokio::test] async fn ai_conversation_soft_cap_never_evicts_protected_runs() { let path = temp_db_path("ai-conversation-soft-cap"); let storage = Storage::open(&path).await.unwrap(); let protected = ai_conversation("protected", "0000"); let protected_run = ai_run("protected-run", "protected", AiRunStatus::Running, "0000"); storage.save_ai_run_state(&protected, &protected_run).await.unwrap(); for index in 0..55 { let timestamp = format!("{index:04}"); storage.save_ai_conversation(&ai_conversation(&format!("terminal-{index}"), ×tamp)).await.unwrap(); } let conversations = storage.load_ai_conversations().await.unwrap(); assert_eq!(conversations.len(), 50); assert!(conversations.iter().any(|conversation| conversation.id == "protected")); assert!(!conversations.iter().any(|conversation| conversation.id == "terminal-0")); assert!(conversations.iter().any(|conversation| conversation.id == "terminal-54")); let _ = std::fs::remove_file(path); } #[tokio::test] async fn ai_conversation_soft_cap_allows_more_than_fifty_protected_runs() { let path = temp_db_path("ai-conversation-protected-overflow"); let storage = Storage::open(&path).await.unwrap(); for index in 0..51 { let id = format!("protected-{index}"); let timestamp = format!("{index:04}"); storage .save_ai_run_state( &ai_conversation(&id, ×tamp), &ai_run(&format!("run-{index}"), &id, AiRunStatus::AwaitingWriteConfirmation, ×tamp), ) .await .unwrap(); } storage.save_ai_conversation(&ai_conversation("terminal-extra", "9999")).await.unwrap(); let conversations = storage.load_ai_conversations().await.unwrap(); assert_eq!(conversations.len(), 51); assert!(conversations.iter().all(|conversation| conversation.id.starts_with("protected-"))); let _ = std::fs::remove_file(path); } #[tokio::test] async fn ai_conversation_soft_cap_protects_pending_recoverable_runs() { let path = temp_db_path("ai-conversation-pending-recoverable-protection"); let storage = Storage::open(&path).await.unwrap(); // A recovered pending-input run (PRD §7 line 93) must be protected like // any other non-terminal run: its draft is not lost to pruning. let protected = ai_conversation("recoverable", "0000"); let mut protected_run = ai_run("recoverable-run", "recoverable", AiRunStatus::PendingRecoverable, "0000"); protected_run.pending_input = Some("recover me".to_string()); storage.save_ai_run_state(&protected, &protected_run).await.unwrap(); for index in 0..55 { let timestamp = format!("{index:04}"); storage.save_ai_conversation(&ai_conversation(&format!("terminal-{index}"), ×tamp)).await.unwrap(); } let conversations = storage.load_ai_conversations().await.unwrap(); assert_eq!(conversations.len(), 50); assert!(conversations.iter().any(|conversation| conversation.id == "recoverable")); assert!(!conversations.iter().any(|conversation| conversation.id == "terminal-0")); let _ = std::fs::remove_file(path); } #[tokio::test] async fn ai_run_roundtrips_fifo_category_and_pending_input() { let path = temp_db_path("ai-run-fifo-category-roundtrip"); let storage = Storage::open(&path).await.unwrap(); let conversation = ai_conversation("fifo-conv", "0000"); let mut run = ai_run("fifo-run", "fifo-conv", AiRunStatus::Queued, "0000"); run.fifo_category = Some(AiRunFifoCategory::NormalSend); run.pending_input = Some("select * from orders limit 5".to_string()); run.max_seq = Some(42); storage.save_ai_run_state(&conversation, &run).await.unwrap(); let loaded = storage.load_ai_runs().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].run_id, "fifo-run"); assert_eq!(loaded[0].status, AiRunStatus::Queued); assert_eq!(loaded[0].fifo_category, Some(AiRunFifoCategory::NormalSend)); assert_eq!(loaded[0].pending_input.as_deref(), Some("select * from orders limit 5")); assert_eq!(loaded[0].max_seq, Some(42)); // The write_confirmation_resume category survives too. let mut resume = ai_run("resume-run", "fifo-conv", AiRunStatus::Queued, "0001"); resume.fifo_category = Some(AiRunFifoCategory::WriteConfirmationResume); storage.save_ai_run(&resume).await.unwrap(); let loaded = storage.load_ai_runs().await.unwrap(); let resume = loaded.iter().find(|run| run.run_id == "resume-run").unwrap(); assert_eq!(resume.fifo_category, Some(AiRunFifoCategory::WriteConfirmationResume)); assert!(resume.pending_input.is_none()); let _ = std::fs::remove_file(path); } #[tokio::test] async fn saving_a_conversation_keeps_its_background_runs() { let path = temp_db_path("ai-conversation-upsert-keeps-runs"); let storage = Storage::open(&path).await.unwrap(); // `ai_runs` declares an ON DELETE CASCADE relationship. Exercise the // snapshot path with enforcement enabled so an accidental REPLACE // (delete + insert) cannot silently erase an active run on restart. storage .with_conn(|conn| conn.execute_batch("PRAGMA foreign_keys = ON").map_err(|e| e.to_string())) .await .unwrap(); let mut conversation = ai_conversation("upsert-conv", "0000"); let run = ai_run("upsert-run", "upsert-conv", AiRunStatus::Running, "0000"); storage.save_ai_run_state(&conversation, &run).await.unwrap(); conversation.updated_at = "0001".to_string(); conversation.queued_input = Some("send later".to_string()); storage.save_ai_conversation(&conversation).await.unwrap(); let runs = storage.load_ai_runs().await.unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].run_id, "upsert-run"); let _ = std::fs::remove_file(path); } #[tokio::test] async fn terminal_ai_runs_are_capped_per_conversation_while_nonterminal_survive() { // Reviewed finding (unbounded terminal run growth): save_ai_run / // save_ai_run_state persist every terminal run and load_ai_runs loads // the whole table at startup, but prune_ai_conversations only caps // conversations and deliberately retains runs for the survivors - so // repeated completed runs grew SQLite storage and recovery work // forever. The storage layer now caps terminal history per // conversation (keeping the newest few, which drive the row status // badge after restart) and never touches recovery-relevant runs. let path = temp_db_path("ai-terminal-runs-capped"); let storage = Storage::open(&path).await.unwrap(); // A non-terminal run must always survive - it is the recovery payload. let conversation = ai_conversation("cap-conv", "0000"); storage .save_ai_run_state(&conversation, &ai_run("active-run", "cap-conv", AiRunStatus::Running, "0000")) .await .unwrap(); // Repeated completed runs (normal use): older ones must be pruned. for index in 0..5 { let timestamp = format!("{index:04}"); storage .save_ai_run(&ai_run(&format!("terminal-{index}"), "cap-conv", AiRunStatus::Completed, ×tamp)) .await .unwrap(); } let runs = storage.load_ai_runs().await.unwrap(); assert!(runs.iter().any(|run| run.run_id == "active-run"), "recovery-relevant run must survive"); let terminal: Vec<_> = runs.iter().filter(|run| run.status == AiRunStatus::Completed).collect(); assert_eq!( terminal.len(), KEEP_TERMINAL_AI_RUNS_PER_CONVERSATION as usize, "only the newest terminal runs per conversation survive" ); assert!(terminal.iter().any(|run| run.run_id == "terminal-4"), "the newest terminal run is retained"); assert!(!runs.iter().any(|run| run.run_id == "terminal-0"), "the oldest terminal runs are pruned"); let _ = std::fs::remove_file(path); } #[tokio::test] async fn ai_conversation_roundtrips_queued_input() { let path = temp_db_path("ai-conversation-queued-input-roundtrip"); let storage = Storage::open(&path).await.unwrap(); let mut conversation = ai_conversation("queued-conv", "0000"); conversation.queued_input = Some("run this after the current task".to_string()); storage.save_ai_conversation(&conversation).await.unwrap(); let loaded = storage.load_ai_conversations().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].id, "queued-conv"); assert_eq!(loaded[0].queued_input.as_deref(), Some("run this after the current task")); // Overwriting clears a stale queued input. conversation.queued_input = None; storage.save_ai_conversation(&conversation).await.unwrap(); let loaded = storage.load_ai_conversations().await.unwrap(); assert!(loaded[0].queued_input.is_none()); let _ = std::fs::remove_file(path); } #[tokio::test] async fn history_search_filters_connection_database_and_legacy_entries() { let path = temp_db_path("history-search-scope"); let storage = Storage::open(&path).await.unwrap(); let entries = [ history_entry("1", "conn-a", "Primary", "sales", "select 1", "2026-07-18T01:00:00Z", true), history_entry("2", "conn-b", "Replica", "sales", "select 2", "2026-07-18T02:00:00Z", true), history_entry("3", "", "Legacy", "archive", "select 3", "2026-07-18T03:00:00Z", true), ]; for entry in &entries { storage.save_history_entry(entry).await.unwrap(); } let result = storage .search_history_entries(HistorySearchRequest { connections: vec![HistoryConnectionFilter { connection_id: "conn-a".to_string(), connection_name: "Primary".to_string(), }], databases: vec![HistoryDatabaseFilter { connection_id: "conn-a".to_string(), connection_name: "Primary".to_string(), database: "sales".to_string(), }], limit: 100, ..Default::default() }) .await .unwrap(); assert_eq!(result.entries.iter().map(|entry| entry.id.as_str()).collect::>(), vec!["1"]); assert_eq!(result.total, 1); let legacy = storage .search_history_entries(HistorySearchRequest { connections: vec![HistoryConnectionFilter { connection_id: String::new(), connection_name: "Legacy".to_string(), }], limit: 100, ..Default::default() }) .await .unwrap(); assert_eq!(legacy.entries[0].id, "3"); let options = storage.load_history_connection_options().await.unwrap(); assert_eq!(options.len(), 3); assert!(options.iter().any(|option| option.connection_id == "conn-a" && option.databases == ["sales"])); assert!(options.iter().any(|option| option.connection_id.is_empty() && option.connection_name == "Legacy")); let _ = std::fs::remove_file(path); } #[tokio::test] async fn history_search_combines_whole_connections_with_narrowed_database_scopes() { let path = temp_db_path("history-search-hierarchical-scope"); let storage = Storage::open(&path).await.unwrap(); let entries = [ history_entry("a-sales", "conn-a", "Primary", "sales", "select 1", "2026-07-18T01:00:00Z", true), history_entry("a-archive", "conn-a", "Primary", "archive", "select 2", "2026-07-18T02:00:00Z", true), history_entry("b-sales", "conn-b", "Replica", "sales", "select 3", "2026-07-18T03:00:00Z", true), history_entry("b-archive", "conn-b", "Replica", "archive", "select 4", "2026-07-18T04:00:00Z", true), ]; for entry in &entries { storage.save_history_entry(entry).await.unwrap(); } let result = storage .search_history_entries(HistorySearchRequest { connections: vec![ HistoryConnectionFilter { connection_id: "conn-a".to_string(), connection_name: "Primary".to_string(), }, HistoryConnectionFilter { connection_id: "conn-b".to_string(), connection_name: "Replica".to_string(), }, ], databases: vec![HistoryDatabaseFilter { connection_id: "conn-b".to_string(), connection_name: "Replica".to_string(), database: "sales".to_string(), }], limit: 100, ..Default::default() }) .await .unwrap(); assert_eq!( result.entries.iter().map(|entry| entry.id.as_str()).collect::>(), vec!["b-sales", "a-archive", "a-sales"] ); assert_eq!(result.total, 3); let _ = std::fs::remove_file(path); } #[tokio::test] async fn history_search_combines_text_status_and_time_filters() { let path = temp_db_path("history-search-fields"); let storage = Storage::open(&path).await.unwrap(); let entries = [ history_entry("1", "conn", "Main", "app", "select 100% from orders", "2026-07-17T23:59:59Z", false), history_entry("2", "conn", "Main", "app", "select 1000 from orders", "2026-07-18T12:00:00Z", false), history_entry("3", "conn", "Main", "app", "select 100% from orders", "2026-07-18T12:00:00Z", true), ]; for entry in &entries { storage.save_history_entry(entry).await.unwrap(); } let result = storage .search_history_entries(HistorySearchRequest { search_text: "100%".to_string(), success: Some(false), started_at: Some("2026-07-18T00:00:00Z".to_string()), ended_at: Some("2026-07-18T23:59:59Z".to_string()), limit: 100, ..Default::default() }) .await .unwrap(); assert!(result.entries.is_empty()); let successful = storage .search_history_entries(HistorySearchRequest { search_text: "100%".to_string(), success: Some(true), started_at: Some("2026-07-18T00:00:00Z".to_string()), ended_at: Some("2026-07-18T23:59:59Z".to_string()), limit: 100, ..Default::default() }) .await .unwrap(); assert_eq!(successful.entries[0].id, "3"); let _ = std::fs::remove_file(path); } #[tokio::test] async fn history_search_cursor_is_stable_for_equal_timestamps() { let path = temp_db_path("history-search-cursor"); let storage = Storage::open(&path).await.unwrap(); for id in ["a", "b", "c"] { storage .save_history_entry(&history_entry(id, "conn", "Main", "app", "select 1", "2026-07-18T12:00:00Z", true)) .await .unwrap(); } let first = storage.search_history_entries(HistorySearchRequest { limit: 2, ..Default::default() }).await.unwrap(); assert_eq!(first.entries.iter().map(|entry| entry.id.as_str()).collect::>(), vec!["c", "b"]); let second = storage .search_history_entries(HistorySearchRequest { cursor: first.next_cursor, limit: 2, ..Default::default() }) .await .unwrap(); assert_eq!(second.entries.iter().map(|entry| entry.id.as_str()).collect::>(), vec!["a"]); assert!(second.next_cursor.is_none()); let _ = std::fs::remove_file(path); } fn ssh_profile(id: &str, password: &str) -> TransportLayerConfig { TransportLayerConfig::Ssh(SshTunnelConfig { id: id.to_string(), name: "Bastion".to_string(), enabled: true, host: "bastion.example.com".to_string(), port: 22, user: "deploy".to_string(), password: password.to_string(), key_path: String::new(), key_passphrase: String::new(), connect_timeout_secs: 5, expose_lan: false, use_ssh_agent: false, ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), allow_exec_channel_proxy: false, profile_id: String::new(), }) } #[tokio::test] async fn tunnel_profiles_roundtrip_and_preserve_secrets() { let path = temp_db_path("tunnel-profiles"); let storage = Storage::open(&path).await.unwrap(); let profile = ssh_profile("profile-1", "s3cret"); storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap(); assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![profile.clone()]); // Applying a scrubbed copy (e.g. from a sync snapshot) keeps stored secrets. let mut scrubbed = profile.clone(); scrubbed.scrub_secrets(); storage.save_tunnel_profiles_preserving_secrets(&[scrubbed.clone()]).await.unwrap(); match &storage.load_tunnel_profiles().await.unwrap()[0] { TransportLayerConfig::Ssh(ssh) => assert_eq!(ssh.password, "s3cret"), other => panic!("expected ssh profile, got {other:?}"), } // A plain save is exact: clearing a secret really clears it. storage.save_tunnel_profiles(&[scrubbed.clone()]).await.unwrap(); assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![scrubbed]); storage.save_tunnel_profiles(&[]).await.unwrap(); assert!(storage.load_tunnel_profiles().await.unwrap().is_empty()); let _ = std::fs::remove_file(path); } #[tokio::test] async fn tunnel_profiles_reject_empty_ids() { let path = temp_db_path("tunnel-profiles-empty-id"); let storage = Storage::open(&path).await.unwrap(); let profile = ssh_profile("", "secret"); assert!(storage.save_tunnel_profiles(&[profile]).await.is_err()); let _ = std::fs::remove_file(path); } fn plain_connection(id: &str, password: &str) -> ConnectionConfig { serde_json::from_value::(serde_json::json!({ "id": id, "name": format!("conn {id}"), "db_type": "postgres", "host": "127.0.0.1", "port": 5432, "username": "postgres", "password": password, "database": "app" })) .unwrap() } #[tokio::test] async fn save_connections_does_not_persist_password_when_save_password_false() { let path = temp_db_path("save-password-false"); let storage = Storage::open(&path).await.unwrap(); let mut config = plain_connection("no-save", "hunter2"); config.save_password = false; storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); assert_eq!(storage.get_secret(&config.id, "password").await.unwrap(), None); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].password, ""); assert!(!loaded[0].save_password); let _ = std::fs::remove_file(path); } #[tokio::test] async fn concurrent_save_connections_from_two_connections_does_not_lock() { // Regression test for issue #6605: multiple `dbx` processes sharing one // data directory (e.g. a portable install shared by several users on // the same machine) each open their own connection to the same // `dbx.db`. Opening two independent `Storage` instances here exercises // the same inter-connection SQLite file locking that separate OS // processes would hit. let path = temp_db_path("concurrent-save-connections"); let storage_a = std::sync::Arc::new(Storage::open(&path).await.unwrap()); let storage_b = std::sync::Arc::new(Storage::open(&path).await.unwrap()); let mut tasks = Vec::new(); for i in 0..20 { let storage = if i % 2 == 0 { storage_a.clone() } else { storage_b.clone() }; let config = plain_connection(&format!("concurrent-{i}"), "hunter2"); tasks.push(tokio::spawn(async move { storage.save_connections(std::slice::from_ref(&config)).await })); } for task in tasks { task.await.unwrap().expect("concurrent save_connections should not fail with 'database is locked'"); } let _ = std::fs::remove_file(path); } #[tokio::test] async fn save_connections_persists_password_when_save_password_true() { let path = temp_db_path("save-password-true"); let storage = Storage::open(&path).await.unwrap(); let config = plain_connection("save-yes", "hunter2"); storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); assert_eq!(storage.get_secret(&config.id, "password").await.unwrap().as_deref(), Some("hunter2")); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded[0].password, "hunter2"); assert!(loaded[0].save_password); let _ = std::fs::remove_file(path); } #[tokio::test] async fn switching_save_password_off_removes_stored_password() { let path = temp_db_path("save-password-switch-off"); let storage = Storage::open(&path).await.unwrap(); let mut config = plain_connection("switch", "hunter2"); storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); assert_eq!(storage.get_secret(&config.id, "password").await.unwrap().as_deref(), Some("hunter2")); config.save_password = false; storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); assert_eq!(storage.get_secret(&config.id, "password").await.unwrap(), None); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded[0].password, ""); assert!(!loaded[0].save_password); let _ = std::fs::remove_file(path); } fn mq_connection(id: &str, token: &str) -> ConnectionConfig { ConnectionConfig { docs_notes_path: None, id: id.to_string(), name: "Pulsar".to_string(), note: String::new(), db_type: DatabaseType::MessageQueue, driver_profile: Some("pulsar".to_string()), driver_label: Some("Apache Pulsar".to_string()), url_params: None, agent_java_options: Vec::new(), host: "127.0.0.1".to_string(), port: 8080, username: String::new(), password: String::new(), database: None, default_schema: None, visible_databases: None, visible_database_patterns: None, visible_schemas: None, show_system_schemas: false, attached_databases: Vec::new(), init_script: None, color: None, transport_layers: Vec::new(), connect_timeout_secs: 30, query_timeout_secs: 300, idle_timeout_secs: 600, keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), client_cert_path: String::new(), client_key_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, redis_connection_mode: None, redis_sentinel_master: String::new(), redis_sentinel_nodes: String::new(), redis_sentinel_username: String::new(), redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), redis_key_separator: ":".to_string(), redis_scan_page_size: None, redis_database_aliases: Default::default(), redis_key_templates: Vec::new(), etcd_endpoints: String::new(), gbase_server: String::new(), informix_server: String::new(), external_config: Some(serde_json::json!({ "systemKind": "pulsar", "adminUrl": "http://127.0.0.1:8080", "auth": { "kind": "token", "token": token } })), jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, save_password: true, read_only: false, is_production: false, production_databases: vec![], database_info: None, } } fn nacos_connection(id: &str, password: &str) -> ConnectionConfig { ConnectionConfig { docs_notes_path: None, id: id.to_string(), name: "Nacos".to_string(), note: String::new(), db_type: DatabaseType::Nacos, driver_profile: None, driver_label: None, url_params: None, agent_java_options: Vec::new(), host: "127.0.0.1".to_string(), port: 8848, username: "nacos".to_string(), password: String::new(), database: None, default_schema: None, visible_databases: None, visible_database_patterns: None, visible_schemas: None, show_system_schemas: false, attached_databases: Vec::new(), init_script: None, color: None, transport_layers: Vec::new(), connect_timeout_secs: 30, query_timeout_secs: 300, idle_timeout_secs: 600, keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), client_cert_path: String::new(), client_key_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, redis_connection_mode: None, redis_sentinel_master: String::new(), redis_sentinel_nodes: String::new(), redis_sentinel_username: String::new(), redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), redis_key_separator: ":".to_string(), redis_scan_page_size: None, redis_database_aliases: Default::default(), redis_key_templates: Vec::new(), etcd_endpoints: String::new(), gbase_server: String::new(), informix_server: String::new(), external_config: Some(serde_json::json!({ "namespace": "public", "group": "DEFAULT_GROUP", "auth": { "kind": "usernamePassword", "username": "nacos", "password": password } })), jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, save_password: true, read_only: false, is_production: false, production_databases: vec![], database_info: None, } } async fn raw_connection_json(storage: &Storage, id: &str) -> String { let id = id.to_string(); storage .with_conn(move |conn| { conn.query_row("SELECT config_json FROM connections WHERE id = ?1", [id], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string()) }) .await .unwrap() } async fn insert_raw_connection(storage: &Storage, config: &ConnectionConfig) { let id = config.id.clone(); let json = serde_json::to_string(config).unwrap(); storage .with_conn(move |conn| { conn.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", rusqlite::params![id, json]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await .unwrap(); } fn mq_token(config: &ConnectionConfig) -> Option<&str> { config.external_config.as_ref()?.get("auth")?.get("token")?.as_str() } fn mq_token_signing_key(config: &ConnectionConfig) -> Option<&str> { config.external_config.as_ref()?.get("tokenSigning")?.get("key")?.as_str() } fn nacos_auth_password(config: &ConnectionConfig) -> Option<&str> { config.external_config.as_ref()?.get("auth")?.get("password")?.as_str() } fn nacos_console_auth_password(config: &ConnectionConfig) -> Option<&str> { config.external_config.as_ref()?.get("rnacosConsoleAuth")?.get("password")?.as_str() } fn nacos_connection_with_console_auth( id: &str, primary_password: &str, console_password: &str, ) -> ConnectionConfig { let mut config = nacos_connection(id, primary_password); config.external_config.as_mut().unwrap()["rnacosConsoleAuth"] = serde_json::json!({ "kind": "usernamePassword", "username": "console", "password": console_password }); config } async fn create_data_dir_with_connection(name: &str, connection_id: &str, token: &str) -> std::path::PathBuf { let data_dir = temp_data_dir(name); let storage = Storage::open(&data_dir.join("dbx.db")).await.unwrap(); storage.save_connections(&[mq_connection(connection_id, token)]).await.unwrap(); drop(storage); data_dir } #[tokio::test] async fn import_user_data_db_copies_source_when_target_is_missing() { let source_dir = create_data_dir_with_connection("import-source", "source-connection", "source-token").await; let target_dir = temp_data_dir("import-target"); let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap(); assert_eq!(result, DataDbImportResult::Imported); let storage = Storage::open(&target_dir.join("dbx.db")).await.unwrap(); let connections = storage.load_connections().await.unwrap(); assert_eq!(connections.len(), 1); assert_eq!(connections[0].id, "source-connection"); assert_eq!(mq_token(&connections[0]), Some("source-token")); } #[tokio::test] async fn import_user_data_db_does_not_overwrite_target_with_user_data() { let source_dir = create_data_dir_with_connection("import-source-existing", "source-connection", "source-token").await; let target_dir = create_data_dir_with_connection("import-target-existing", "target-connection", "target-token").await; let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap(); assert_eq!(result, DataDbImportResult::SkippedTargetHasData); let storage = Storage::open(&target_dir.join("dbx.db")).await.unwrap(); let connections = storage.load_connections().await.unwrap(); assert_eq!(connections.len(), 1); assert_eq!(connections[0].id, "target-connection"); assert_eq!(mq_token(&connections[0]), Some("target-token")); } #[tokio::test] async fn import_user_data_db_recognizes_settings_and_snippets_as_user_data() { let source_dir = temp_data_dir("import-settings-only-source"); let source_storage = Storage::open(&source_dir.join("dbx.db")).await.unwrap(); source_storage .save_desktop_settings(&DesktopSettings { debug_logging_enabled: true, ..DesktopSettings::default() }) .await .unwrap(); source_storage .save_editor_settings(&serde_json::json!({ "snippets": [{ "id": "custom", "prefix": "selc", "body": "SELECT 42" }] })) .await .unwrap(); drop(source_storage); let target_dir = temp_data_dir("import-settings-only-target"); let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap(); assert_eq!(result, DataDbImportResult::Imported); let storage = Storage::open(&target_dir.join("dbx.db")).await.unwrap(); assert!(storage.load_desktop_settings().await.unwrap().debug_logging_enabled); assert_eq!(storage.load_editor_settings().await.unwrap().unwrap()["snippets"][0]["body"], "SELECT 42"); } #[tokio::test] async fn import_user_data_db_does_not_overwrite_target_with_settings() { let source_dir = create_data_dir_with_connection("import-source-settings-target", "source-connection", "source-token").await; let target_dir = temp_data_dir("import-target-settings-only"); let target_storage = Storage::open(&target_dir.join("dbx.db")).await.unwrap(); target_storage .save_editor_settings(&serde_json::json!({ "snippets": [{ "id": "target", "prefix": "tgt", "body": "SELECT 7" }] })) .await .unwrap(); drop(target_storage); let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap(); assert_eq!(result, DataDbImportResult::SkippedTargetHasData); let storage = Storage::open(&target_dir.join("dbx.db")).await.unwrap(); assert_eq!(storage.load_editor_settings().await.unwrap().unwrap()["snippets"][0]["body"], "SELECT 7"); } #[tokio::test] async fn import_user_data_db_replaces_empty_target_schema() { let source_dir = create_data_dir_with_connection("import-source-empty-target", "source-connection", "source-token").await; let target_dir = temp_data_dir("import-empty-target"); let _target_storage = Storage::open(&target_dir.join("dbx.db")).await.unwrap(); let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap(); assert_eq!(result, DataDbImportResult::Imported); let storage = Storage::open(&target_dir.join("dbx.db")).await.unwrap(); let connections = storage.load_connections().await.unwrap(); assert_eq!(connections.len(), 1); assert_eq!(connections[0].id, "source-connection"); } #[tokio::test] async fn import_user_data_db_skips_empty_source_schema() { let source_dir = temp_data_dir("import-empty-source"); let source_storage = Storage::open(&source_dir.join("dbx.db")).await.unwrap(); drop(source_storage); let target_dir = temp_data_dir("import-empty-source-target"); let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap(); assert_eq!(result, DataDbImportResult::SkippedSourceEmpty); assert!(!target_dir.join("dbx.db").exists()); } #[test] fn import_user_data_db_skips_invalid_source_file() { let source_dir = temp_data_dir("import-invalid-source"); std::fs::create_dir_all(&source_dir).unwrap(); std::fs::write(source_dir.join("dbx.db"), b"not sqlite").unwrap(); let target_dir = temp_data_dir("import-invalid-source-target"); let result = maybe_import_user_data_db(&target_dir, Some(&source_dir)).unwrap(); assert_eq!(result, DataDbImportResult::SkippedInvalidSource); assert!(!target_dir.join("dbx.db").exists()); } #[tokio::test] async fn save_connections_preserves_database_info() { let path = temp_db_path("database-info"); let storage = Storage::open(&path).await.unwrap(); let mut config = mq_connection("database-info", "mq-secret"); config.database_info = Some(DatabaseConnectionInfo { product_name: Some("MySQL".to_string()), product_version: Some("8.4.0".to_string()), current_database: Some("app".to_string()), ..DatabaseConnectionInfo::default() }); storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); let raw_json = raw_connection_json(&storage, "database-info").await; assert!(raw_json.contains("8.4.0")); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded[0].database_info, config.database_info); let updated_info = DatabaseConnectionInfo { product_name: Some("MySQL".to_string()), product_version: Some("8.4.1".to_string()), ..DatabaseConnectionInfo::default() }; storage.save_connection_database_info("database-info", Some(updated_info.clone())).await.unwrap(); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded[0].database_info, Some(updated_info)); assert_eq!(mq_token(&loaded[0]), Some("mq-secret")); let _ = std::fs::remove_file(path); } #[tokio::test] async fn save_connection_mqtt_saved_topics_updates_only_target_and_preserves_secrets() { let path = temp_db_path("mqtt-saved-topics"); let storage = Storage::open(&path).await.unwrap(); let target = mq_connection("target", "target-secret"); let untouched = mq_connection("untouched", "untouched-secret"); storage.save_connections(&[target.clone(), untouched.clone()]).await.unwrap(); let saved_topics = serde_json::json!([{ "topic": "sensors/temperature", "qos": "atleastonce", "noLocal": false, }]); storage.save_connection_mqtt_saved_topics(&target.id, saved_topics.clone()).await.unwrap(); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded.len(), 2); let updated = loaded.iter().find(|config| config.id == target.id).unwrap(); assert_eq!(updated.external_config.as_ref().unwrap()["savedTopics"], saved_topics); assert_eq!(mq_token(updated), Some("target-secret")); assert_eq!(loaded.iter().find(|config| config.id == untouched.id), Some(&untouched)); assert_eq!(mq_token(loaded.iter().find(|config| config.id == untouched.id).unwrap()), Some("untouched-secret")); assert!(storage.save_connection_mqtt_saved_topics("missing", serde_json::json!([])).await.is_err()); let _ = std::fs::remove_file(path); } #[tokio::test] async fn save_connection_mqtt_saved_topics_rejects_non_object_external_config() { let path = temp_db_path("mqtt-saved-topics-invalid"); let storage = Storage::open(&path).await.unwrap(); let mut target = mq_connection("target", "target-secret"); target.external_config = Some(serde_json::json!("invalid")); storage.save_connections(std::slice::from_ref(&target)).await.unwrap(); let error = storage.save_connection_mqtt_saved_topics(&target.id, serde_json::json!([])).await.unwrap_err(); assert!(error.contains("external_config")); assert_eq!(storage.load_connections().await.unwrap(), vec![target]); let _ = std::fs::remove_file(path); } #[tokio::test] async fn save_connection_driver_profile_updates_only_the_target_metadata() { let path = temp_db_path("connection-driver-profile"); let storage = Storage::open(&path).await.unwrap(); let target = mq_connection("target", "target-secret"); let untouched = mq_connection("untouched", "untouched-secret"); storage.save_connections(&[target.clone(), untouched.clone()]).await.unwrap(); assert!(storage .save_connection_driver_profile( &target, Some("mongodb-legacy".to_string()), Some("MongoDB (Legacy)".to_string()), ) .await .unwrap()); let mut wrong_type = untouched.clone(); wrong_type.db_type = DatabaseType::MongoDb; assert!(!storage .save_connection_driver_profile(&wrong_type, Some("mongodb-legacy".to_string()), None,) .await .unwrap()); let mut missing = wrong_type; missing.id = "missing".to_string(); assert!(!storage .save_connection_driver_profile(&missing, Some("mongodb-legacy".to_string()), None) .await .unwrap()); let loaded = storage.load_connections().await.unwrap(); let target = loaded.iter().find(|config| config.id == "target").unwrap(); assert_eq!(target.driver_profile.as_deref(), Some("mongodb-legacy")); assert_eq!(target.driver_label.as_deref(), Some("MongoDB (Legacy)")); assert_eq!(mq_token(target), Some("target-secret")); assert_eq!(loaded.iter().find(|config| config.id == "untouched"), Some(&untouched)); let _ = std::fs::remove_file(path); } #[tokio::test] async fn save_connection_driver_profile_rejects_a_stale_connection_config() { let path = temp_db_path("connection-driver-profile-stale"); let storage = Storage::open(&path).await.unwrap(); let original = mq_connection("target", "target-secret"); storage.save_connections(std::slice::from_ref(&original)).await.unwrap(); let mut replacement = original.clone(); replacement.host = "replacement.example.com".to_string(); replacement.name = "Replacement".to_string(); storage.save_connections(std::slice::from_ref(&replacement)).await.unwrap(); assert!(!storage .save_connection_driver_profile( &original, Some("mongodb-legacy".to_string()), Some("MongoDB (Legacy)".to_string()), ) .await .unwrap()); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded, vec![replacement]); let _ = std::fs::remove_file(path); } #[tokio::test] async fn save_connections_moves_mq_auth_token_to_secret_table_and_restores_it() { let path = temp_db_path("mq-token-secrets"); let storage = Storage::open(&path).await.unwrap(); storage.save_connections(&[mq_connection("pulsar", "mq-token-secret")]).await.unwrap(); let raw_json = raw_connection_json(&storage, "pulsar").await; assert!(!raw_json.contains("mq-token-secret")); let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap(); assert_eq!(mq_token(&persisted), Some("")); assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("mq-token-secret")); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(mq_token(&loaded[0]), Some("mq-token-secret")); } #[tokio::test] async fn unreadable_saved_connections_do_not_block_loading_or_get_deleted_by_list_saves() { let path = temp_db_path("unreadable-connection-preservation"); let storage = Storage::open(&path).await.unwrap(); let mut known = mq_connection("known", "known-secret"); storage.save_connections(std::slice::from_ref(&known)).await.unwrap(); let future_json = serde_json::json!({ "id": "future", "name": "Future database", "db_type": "future_database" }) .to_string(); let inserted_json = future_json.clone(); storage .with_conn(move |conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|e| e.to_string())?; tx.execute( "INSERT INTO connections (id, config_json) VALUES (?1, ?2)", rusqlite::params!["future", inserted_json], ) .map_err(|e| e.to_string())?; tx.execute( "INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?1, ?2, ?3)", rusqlite::params!["future", "password", "future-secret"], ) .map_err(|e| e.to_string())?; tx.commit().map_err(|e| e.to_string()) }) .await .unwrap(); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].id, "known"); known.name = "Known updated".to_string(); storage.save_connection_metadata_preserving_secrets(std::slice::from_ref(&known)).await.unwrap(); assert_eq!(raw_connection_json(&storage, "future").await, future_json); assert_eq!(storage.get_secret("future", "password").await.unwrap().as_deref(), Some("future-secret")); storage.save_connections(&[]).await.unwrap(); assert!(storage.load_connections().await.unwrap().is_empty()); assert_eq!(raw_connection_json(&storage, "future").await, future_json); assert_eq!(storage.get_secret("future", "password").await.unwrap().as_deref(), Some("future-secret")); let _ = std::fs::remove_file(path); } #[tokio::test] async fn metadata_save_scrubs_mq_auth_token_and_preserves_existing_secret() { let path = temp_db_path("mq-token-metadata"); let storage = Storage::open(&path).await.unwrap(); let original = mq_connection("pulsar", "existing-token"); storage.save_connections(std::slice::from_ref(&original)).await.unwrap(); let mut metadata = original; metadata.name = "Pulsar renamed".to_string(); if let Some(auth) = metadata.external_config.as_mut().and_then(|value| value.get_mut("auth")) { auth["token"] = serde_json::Value::String("new-token-that-should-not-persist".to_string()); } storage.save_connection_metadata_preserving_secrets(&[metadata]).await.unwrap(); let raw_json = raw_connection_json(&storage, "pulsar").await; assert!(!raw_json.contains("existing-token")); assert!(!raw_json.contains("new-token-that-should-not-persist")); assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("existing-token")); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded[0].name, "Pulsar renamed"); assert_eq!(mq_token(&loaded[0]), Some("existing-token")); } #[tokio::test] async fn load_connections_migrates_legacy_mq_auth_token_out_of_config_json() { let path = temp_db_path("mq-token-legacy-migration"); let storage = Storage::open(&path).await.unwrap(); insert_raw_connection(&storage, &mq_connection("pulsar", "legacy-token")).await; let loaded = storage.load_connections().await.unwrap(); assert_eq!(mq_token(&loaded[0]), Some("legacy-token")); assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap().as_deref(), Some("legacy-token")); let raw_json = raw_connection_json(&storage, "pulsar").await; assert!(!raw_json.contains("legacy-token")); let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap(); assert_eq!(mq_token(&persisted), Some("")); } #[tokio::test] async fn save_connections_deletes_stale_mq_auth_secrets_when_kind_changes() { let path = temp_db_path("mq-auth-kind-change"); let storage = Storage::open(&path).await.unwrap(); storage.save_connections(&[mq_connection("pulsar", "old-token")]).await.unwrap(); let mut config = mq_connection("pulsar", ""); config.external_config = Some(serde_json::json!({ "systemKind": "pulsar", "adminUrl": "http://127.0.0.1:8080", "auth": { "kind": "basic", "username": "admin", "password": "basic-secret" } })); storage.save_connections(&[config]).await.unwrap(); assert_eq!(storage.get_secret("pulsar", MQ_AUTH_TOKEN_KEY).await.unwrap(), None); assert_eq!(storage.get_secret("pulsar", MQ_AUTH_PASSWORD_KEY).await.unwrap().as_deref(), Some("basic-secret")); } #[tokio::test] async fn save_connections_moves_mq_token_signing_key_to_secret_table_and_restores_it() { let path = temp_db_path("mq-token-signing-secret"); let storage = Storage::open(&path).await.unwrap(); let mut config = mq_connection("pulsar", ""); config.external_config = Some(serde_json::json!({ "systemKind": "pulsar", "adminUrl": "http://127.0.0.1:8080", "auth": { "kind": "none" }, "tokenSigning": { "algorithm": "hs256", "key": "broker-signing-secret" } })); storage.save_connections(&[config]).await.unwrap(); let raw_json = raw_connection_json(&storage, "pulsar").await; assert!(!raw_json.contains("broker-signing-secret")); let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap(); assert_eq!(mq_token_signing_key(&persisted), Some("")); assert_eq!( storage.get_secret("pulsar", MQ_TOKEN_SIGNING_KEY).await.unwrap().as_deref(), Some("broker-signing-secret") ); let loaded = storage.load_connections().await.unwrap(); assert_eq!(mq_token_signing_key(&loaded[0]), Some("broker-signing-secret")); } #[tokio::test] async fn save_connections_moves_nacos_auth_password_to_secret_table_and_restores_it() { let path = temp_db_path("nacos-auth-secret"); let storage = Storage::open(&path).await.unwrap(); storage.save_connections(&[nacos_connection("nacos", "nacos-secret")]).await.unwrap(); let raw_json = raw_connection_json(&storage, "nacos").await; assert!(!raw_json.contains("nacos-secret")); let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap(); assert_eq!(nacos_auth_password(&persisted), Some("")); assert_eq!( storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap().as_deref(), Some("nacos-secret") ); let loaded = storage.load_connections().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(nacos_auth_password(&loaded[0]), Some("nacos-secret")); } #[tokio::test] async fn save_connections_moves_separate_rnacos_console_password_to_secret_table() { let path = temp_db_path("rnacos-console-auth-secret"); let storage = Storage::open(&path).await.unwrap(); let mut config = nacos_connection("rnacos", ""); config.external_config = Some(serde_json::json!({ "implementation": "rnacos", "serverAddr": "http://127.0.0.1:8848", "rnacosConsoleAddr": "http://127.0.0.1:10848/rnacos", "rnacosHistoryEnabled": true, "auth": { "kind": "none" }, "rnacosConsoleAuth": { "kind": "usernamePassword", "username": "console", "password": "console-secret" } })); storage.save_connections(&[config]).await.unwrap(); let raw_json = raw_connection_json(&storage, "rnacos").await; assert!(!raw_json.contains("console-secret")); assert_eq!( storage.get_secret("rnacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap().as_deref(), Some("console-secret") ); let loaded = storage.load_connections().await.unwrap(); assert_eq!( loaded[0] .external_config .as_ref() .and_then(|value| value.get("rnacosConsoleAuth")) .and_then(|auth| auth.get("password")) .and_then(serde_json::Value::as_str), Some("console-secret") ); } #[tokio::test] async fn save_connections_does_not_persist_nacos_passwords_when_save_password_is_false() { let path = temp_db_path("nacos-auth-no-save"); let storage = Storage::open(&path).await.unwrap(); let mut config = nacos_connection_with_console_auth("nacos", "primary-secret", "console-secret"); config.save_password = false; storage.save_connections(&[config]).await.unwrap(); assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None); assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None); let loaded = storage.load_connections().await.unwrap(); assert_eq!(nacos_auth_password(&loaded[0]), Some("")); assert_eq!(nacos_console_auth_password(&loaded[0]), Some("")); } #[tokio::test] async fn switching_nacos_password_saving_off_removes_all_stored_auth_secrets() { let path = temp_db_path("nacos-auth-disable-save"); let storage = Storage::open(&path).await.unwrap(); let mut config = nacos_connection_with_console_auth("nacos", "primary-secret", "console-secret"); storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); assert!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap().is_some()); assert!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap().is_some()); config.save_password = false; storage.save_connections(&[config]).await.unwrap(); assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None); assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None); } #[tokio::test] async fn metadata_sync_removes_nacos_auth_secrets_when_password_saving_is_disabled() { let path = temp_db_path("nacos-auth-no-save-metadata-sync"); let storage = Storage::open(&path).await.unwrap(); let mut config = nacos_connection_with_console_auth("nacos", "primary-secret", "console-secret"); storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); config.save_password = false; storage.save_connection_metadata_preserving_secrets(&[config]).await.unwrap(); assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None); assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None); let loaded = storage.load_connections().await.unwrap(); assert_eq!(nacos_auth_password(&loaded[0]), Some("")); assert_eq!(nacos_console_auth_password(&loaded[0]), Some("")); } #[tokio::test] async fn load_connections_cleans_legacy_nacos_passwords_when_saving_is_disabled() { let path = temp_db_path("nacos-auth-no-save-legacy-cleanup"); let storage = Storage::open(&path).await.unwrap(); let mut config = nacos_connection_with_console_auth("nacos", "legacy-primary-secret", "legacy-console-secret"); config.save_password = false; insert_raw_connection(&storage, &config).await; storage.set_secret("nacos", NACOS_AUTH_PASSWORD_KEY, "stale-primary-secret").await.unwrap(); storage.set_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY, "stale-console-secret").await.unwrap(); let loaded = storage.load_connections().await.unwrap(); assert_eq!(nacos_auth_password(&loaded[0]), Some("")); assert_eq!(nacos_console_auth_password(&loaded[0]), Some("")); assert_eq!(storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap(), None); assert_eq!(storage.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None); let raw_json = raw_connection_json(&storage, "nacos").await; assert!(!raw_json.contains("legacy-primary-secret")); assert!(!raw_json.contains("legacy-console-secret")); } #[tokio::test] async fn load_connections_migrates_legacy_nacos_auth_password_out_of_config_json() { let path = temp_db_path("nacos-auth-legacy-migration"); let storage = Storage::open(&path).await.unwrap(); insert_raw_connection(&storage, &nacos_connection("nacos", "legacy-nacos-secret")).await; let loaded = storage.load_connections().await.unwrap(); assert_eq!(nacos_auth_password(&loaded[0]), Some("legacy-nacos-secret")); assert_eq!( storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap().as_deref(), Some("legacy-nacos-secret") ); let raw_json = raw_connection_json(&storage, "nacos").await; assert!(!raw_json.contains("legacy-nacos-secret")); let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap(); assert_eq!(nacos_auth_password(&persisted), Some("")); } #[tokio::test] async fn desktop_settings_default_to_background_enabled() { let path = temp_db_path("desktop-settings-default"); let storage = Storage::open(&path).await.unwrap(); assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings::default()); } #[tokio::test] async fn mcp_global_policy_defaults_unconfigured_and_roundtrips_atomically() { let path = temp_db_path("mcp-global-policy"); let storage = Storage::open(&path).await.unwrap(); assert_eq!( storage.load_mcp_global_policy().await.unwrap(), McpGlobalPolicyState { configured: false, read_only: false, allow_dangerous_sql: false, allowed_connection_ids: None, allowed_tool_names: None, connection_policies: Vec::new(), query_timeout_secs: None, } ); storage.save_password_hash("preserved").await.unwrap(); storage .save_mcp_global_policy(&McpGlobalPolicy { read_only: true, allow_dangerous_sql: true, allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]), query_timeout_secs: Some(120), ..Default::default() }) .await .unwrap(); assert_eq!( storage.load_mcp_global_policy().await.unwrap(), McpGlobalPolicyState { configured: true, read_only: true, allow_dangerous_sql: false, allowed_connection_ids: Some(vec!["conn-1".to_string(), "conn-2".to_string()]), allowed_tool_names: None, connection_policies: Vec::new(), query_timeout_secs: Some(120), } ); assert_eq!(storage.load_password_hash().await.unwrap().as_deref(), Some("preserved")); let settings = storage.load_app_settings_json().await.unwrap(); assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["readOnly"], true); assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowDangerousSql"], false); assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["allowedConnectionIds"][0], "conn-1"); assert_eq!(settings[MCP_GLOBAL_POLICY_KEY]["queryTimeoutSecs"], 120); assert!(settings[MCP_GLOBAL_POLICY_KEY].get("configured").is_none()); storage.save_desktop_settings(&DesktopSettings::default()).await.unwrap(); assert!(storage.load_mcp_global_policy().await.unwrap().read_only); } #[tokio::test] async fn mcp_global_policy_fails_closed_on_malformed_settings() { let path = temp_db_path("mcp-global-policy-malformed"); let storage = Storage::open(&path).await.unwrap(); storage .with_conn(|conn| { conn.execute( "INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [r#"{"mcp_global_policy":{"readOnly":"yes","allowedConnectionIds":null}}"#], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await .unwrap(); let error = storage.load_mcp_global_policy().await.unwrap_err(); assert!(error.starts_with("MCP_POLICY_UNAVAILABLE:")); } #[tokio::test] async fn malformed_app_settings_cannot_be_silently_replaced_by_an_unrelated_save() { let path = temp_db_path("mcp-global-policy-invalid-settings-shape"); let storage = Storage::open(&path).await.unwrap(); storage .with_conn(|conn| { conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", ["[]"]) .map(|_| ()) .map_err(|e| e.to_string()) }) .await .unwrap(); assert!(storage.load_mcp_global_policy().await.unwrap_err().starts_with("MCP_POLICY_UNAVAILABLE:")); assert!(storage.save_password_hash("must-not-reset-policy").await.is_err()); let raw = storage .with_conn(|conn| { conn.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string()) }) .await .unwrap(); assert_eq!(raw, "[]"); let _ = std::fs::remove_file(path); } #[tokio::test] async fn mcp_global_policy_defaults_dangerous_sql_to_disabled_for_existing_settings() { let path = temp_db_path("mcp-global-policy-existing"); let storage = Storage::open(&path).await.unwrap(); storage .with_conn(|conn| { conn.execute( "INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [r#"{"mcp_global_policy":{"readOnly":false,"allowedConnectionIds":null}}"#], ) .map(|_| ()) .map_err(|e| e.to_string()) }) .await .unwrap(); let policy = storage.load_mcp_global_policy().await.unwrap(); assert!(policy.configured); assert!(!policy.allow_dangerous_sql); assert_eq!(policy.query_timeout_secs, None); } #[tokio::test] async fn mcp_connection_mutations_are_atomic_and_recheck_policy() { let path = temp_db_path("mcp-connection-mutation-guard"); let storage = Storage::open(&path).await.unwrap(); let kept = mq_connection("kept", "kept-token"); let removed = mq_connection("removed", "removed-token"); storage.save_connections(&[kept.clone(), removed.clone()]).await.unwrap(); storage .save_mcp_global_policy(&McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: Some(vec![kept.id.clone()]), query_timeout_secs: None, ..Default::default() }) .await .unwrap(); let error = storage.remove_connection_for_mcp(&removed.id).await.unwrap_err(); assert!(error.starts_with("CONNECTION_OUT_OF_SCOPE:")); let mut concurrently_updated = removed.clone(); concurrently_updated.host = "updated-by-web-ui".to_string(); storage.save_connections(&[kept.clone(), concurrently_updated.clone()]).await.unwrap(); let added = mq_connection("added", "added-token"); storage.add_connection_for_mcp(added.clone()).await.unwrap(); let after_add = storage.load_connections().await.unwrap(); assert_eq!(after_add.len(), 3); assert_eq!( after_add.iter().find(|config| config.id == concurrently_updated.id).map(|config| config.host.as_str()), Some("updated-by-web-ui") ); storage .save_mcp_global_policy(&McpGlobalPolicy { read_only: true, allow_dangerous_sql: false, allowed_connection_ids: None, query_timeout_secs: None, ..Default::default() }) .await .unwrap(); let error = storage.remove_connection_for_mcp(&kept.id).await.unwrap_err(); assert!(error.starts_with("MCP_READ_ONLY:")); assert_eq!(storage.load_connections().await.unwrap().len(), 3); // Non-MCP callers remain governed by the ordinary DBX UI permissions. storage.save_connections(std::slice::from_ref(&kept)).await.unwrap(); assert_eq!(storage.load_connections().await.unwrap()[0].id, kept.id); let _ = std::fs::remove_file(path); } #[tokio::test] async fn desktop_settings_fall_back_to_legacy_background_preference() { let path = temp_db_path("desktop-settings-legacy-background"); let storage = Storage::open(&path).await.unwrap(); let mut settings = serde_json::Map::new(); settings.insert("run_in_background".to_string(), serde_json::Value::Bool(false)); storage.save_app_settings_json(&settings).await.unwrap(); assert_eq!( storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: false, ..DesktopSettings::default() } ); } #[test] fn metadata_cache_memory_budget_is_bounded() { assert_eq!(super::normalize_metadata_cache_max_memory_mb(1), 16); assert_eq!(super::normalize_metadata_cache_max_memory_mb(256), 256); assert_eq!(super::normalize_metadata_cache_max_memory_mb(512), 512); assert_eq!(super::normalize_metadata_cache_max_memory_mb(513), 64); } #[tokio::test] async fn schema_cache_prunes_expired_rows_on_write_without_maintaining_during_reads() { let path = temp_db_path("schema-cache-ttl-prune"); let storage = Storage::open(&path).await.unwrap(); storage .save_schema_cache( "object-ddl:v1:conn-a:db:public:old::TABLE:", &serde_json::json!({ "version": 1, "ddl": "old" }), ) .await .unwrap(); storage .with_conn(|conn| { conn.execute( "UPDATE schema_cache SET updated_at = datetime('now', '-25 hours'), updated_at_ms = CAST(strftime('%s', 'now', '-25 hours') AS INTEGER) * 1000", [], ) .map(|_| ()) .map_err(|error| error.to_string()) }) .await .unwrap(); assert_eq!(storage.load_schema_cache("object-ddl:v1:conn-a:db:public:old::TABLE:").await.unwrap(), None); let remaining = storage .with_conn(|conn| { conn.query_row("SELECT COUNT(*) FROM schema_cache", [], |row| row.get::<_, i64>(0)) .map_err(|error| error.to_string()) }) .await .unwrap(); assert_eq!(remaining, 1, "L2 reads must remain indexed point lookups without global maintenance"); storage .save_schema_cache( "object-ddl:v1:conn-a:db:public:new::TABLE:", &serde_json::json!({ "version": 1, "ddl": "new" }), ) .await .unwrap(); let remaining = storage .with_conn(|conn| { conn.query_row("SELECT COUNT(*) FROM schema_cache", [], |row| row.get::<_, i64>(0)) .map_err(|error| error.to_string()) }) .await .unwrap(); assert_eq!(remaining, 1, "the next write must prune the expired row"); let _ = std::fs::remove_file(path); } #[tokio::test] async fn schema_cache_budget_covers_multiple_connections_objects_and_facets() { let path = temp_db_path("schema-cache-capacity"); let storage = Storage::open(&path).await.unwrap(); let policy = super::SchemaCachePolicy { max_total_bytes: 1_100, max_connection_bytes: 700, max_entries: 5, max_age_millis: 86_400_000, }; let payload = serde_json::json!({ "value": "x".repeat(180) }); let keys = [ "object-ddl:v1:conn-a:db:public:accounts::TABLE:", "object-meta:v1:conn-a:db:public:accounts::TABLE:columns:", "object-meta:v1:conn-a:db:public:billing::TABLE:indexes:", "object-ddl:v1:conn-b:db:public:events::TABLE:", "object-meta:v1:conn-b:db:public:events::TABLE:triggers:", "object-meta:v1:conn-c:db:public:audit::TABLE:comment:", ]; for key in keys { storage.save_schema_cache_with_policy(key, &payload, policy).await.unwrap(); } let (entries, bytes, conn_a_bytes) = storage .with_conn(|conn| { conn.query_row( "SELECT COUNT(*), COALESCE(SUM(byte_size), 0), COALESCE(SUM(CASE WHEN owner_id = 'conn-a' THEN byte_size ELSE 0 END), 0) FROM schema_cache", [], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)), ) .map_err(|error| error.to_string()) }) .await .unwrap(); assert!(entries <= policy.max_entries as i64); assert!(bytes <= policy.max_total_bytes); assert!(conn_a_bytes <= policy.max_connection_bytes); assert!(storage.load_schema_cache(keys.last().unwrap()).await.unwrap().is_some()); let _ = std::fs::remove_file(path); } #[tokio::test] async fn schema_cache_lru_keeps_recently_accessed_entries() { let path = temp_db_path("schema-cache-lru"); let storage = Storage::open(&path).await.unwrap(); let policy = super::SchemaCachePolicy { max_total_bytes: i64::MAX, max_connection_bytes: i64::MAX, max_entries: 2, max_age_millis: 86_400_000, }; let first = "object-ddl:v1:conn-a:db:public:first::TABLE:"; let second = "object-meta:v1:conn-b:db:public:second::TABLE:columns:"; let newest = "object-meta:v1:conn-c:db:public:newest::TABLE:indexes:"; storage.save_schema_cache_with_policy(first, &serde_json::json!({ "value": 1 }), policy).await.unwrap(); storage.save_schema_cache_with_policy(second, &serde_json::json!({ "value": 2 }), policy).await.unwrap(); storage .with_conn(move |conn| { conn.execute( "UPDATE schema_cache SET last_accessed_at_ms = CASE cache_key WHEN ?1 THEN 1 ELSE 2 END", rusqlite::params![first], ) .map(|_| ()) .map_err(|error| error.to_string()) }) .await .unwrap(); assert!(storage.load_schema_cache(first).await.unwrap().is_some()); storage.save_schema_cache_with_policy(newest, &serde_json::json!({ "value": 3 }), policy).await.unwrap(); assert!(storage.load_schema_cache(first).await.unwrap().is_some()); assert_eq!(storage.load_schema_cache(second).await.unwrap(), None); assert!(storage.load_schema_cache(newest).await.unwrap().is_some()); let _ = std::fs::remove_file(path); } #[tokio::test] async fn schema_cache_50k_point_reads_stay_below_performance_gate() { const ENTRY_COUNT: usize = 50_000; const SAMPLE_COUNT: usize = 40; let path = temp_db_path("schema-cache-50k-read-performance"); let storage = Storage::open(&path).await.unwrap(); storage .with_conn(|conn| { let transaction = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(|error| error.to_string())?; { let mut statement = transaction .prepare( "INSERT INTO schema_cache ( cache_key, payload_json, updated_at, updated_at_ms, last_accessed_at_ms, byte_size, owner_id ) VALUES (?1, ?2, datetime('now'), ?3, ?3, ?4, ?5)", ) .map_err(|error| error.to_string())?; for index in 0..ENTRY_COUNT { let cache_key = format!("object-meta:v1:conn-{}:db:public:table-{index}::TABLE:columns:", index % 32); let payload = format!(r#"{{"version":1,"value":{index}}}"#); statement .execute(rusqlite::params![ cache_key, payload, super::unix_timestamp_millis(), 32_i64, format!("conn-{}", index % 32), ]) .map_err(|error| error.to_string())?; } } transaction.commit().map_err(|error| error.to_string()) }) .await .unwrap(); let mut samples = Vec::with_capacity(SAMPLE_COUNT); for sample in 0..SAMPLE_COUNT { let index = sample * (ENTRY_COUNT / SAMPLE_COUNT); let cache_key = format!("object-meta:v1:conn-{}:db:public:table-{index}::TABLE:columns:", index % 32); let started = Instant::now(); assert!(storage.load_schema_cache(&cache_key).await.unwrap().is_some()); samples.push(started.elapsed()); } samples.sort_unstable(); let total = samples.iter().copied().sum::(); let average = total / SAMPLE_COUNT as u32; let p95 = samples[(SAMPLE_COUNT * 95 / 100).saturating_sub(1)]; eprintln!("schema_cache_50k_point_reads average={average:?} p95={p95:?}"); assert!(average < Duration::from_millis(20), "50k L2 point-read average {average:?} exceeded 20 ms"); assert!(p95 < Duration::from_millis(50), "50k L2 point-read P95 {p95:?} exceeded 50 ms"); let _ = std::fs::remove_file(path); } #[tokio::test] async fn desktop_settings_preserve_existing_password_hash() { let path = temp_db_path("desktop-settings-preserve-password"); let storage = Storage::open(&path).await.unwrap(); storage.save_password_hash("hash-1").await.unwrap(); storage .save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, quit_on_close: true, close_action_prompted: false, debug_logging_enabled: true, metadata_cache_max_memory_mb: 128, duckdb_worker_process_isolation: false, duckdb_worker_max_processes: DesktopSettings::default().duckdb_worker_max_processes, saved_sql_sync_dir: None, driver_store_dir: Some("/tmp/dbx-drivers".to_string()), plugin_store_dir: Some("/tmp/dbx-plugins".to_string()), agent_store_dir: Some("/tmp/dbx-agents".to_string()), sidebar_table_page_size: DesktopSettings::default().sidebar_table_page_size, }) .await .unwrap(); assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-1".to_string())); assert_eq!( storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, quit_on_close: true, close_action_prompted: false, debug_logging_enabled: true, metadata_cache_max_memory_mb: 128, duckdb_worker_process_isolation: false, duckdb_worker_max_processes: DesktopSettings::default().duckdb_worker_max_processes, saved_sql_sync_dir: None, driver_store_dir: Some("/tmp/dbx-drivers".to_string()), plugin_store_dir: Some("/tmp/dbx-plugins".to_string()), agent_store_dir: Some("/tmp/dbx-agents".to_string()), sidebar_table_page_size: DesktopSettings::default().sidebar_table_page_size, } ); } #[tokio::test] async fn desktop_settings_save_removes_legacy_background_preference() { let path = temp_db_path("desktop-settings-remove-legacy-background"); let storage = Storage::open(&path).await.unwrap(); let mut settings = serde_json::Map::new(); settings.insert("run_in_background".to_string(), serde_json::Value::Bool(false)); storage.save_app_settings_json(&settings).await.unwrap(); storage .save_desktop_settings(&DesktopSettings { icon_theme: DesktopIconTheme::Black, ..DesktopSettings::default() }) .await .unwrap(); let settings = storage.load_app_settings_json().await.unwrap(); assert_eq!(settings.get("run_in_background"), None); assert_eq!(settings.get("show_tray_icon").and_then(|value| value.as_bool()), Some(true)); assert_eq!(settings.get("icon_theme").and_then(|value| value.as_str()), Some("black")); assert_eq!(settings.get("debug_logging_enabled").and_then(|value| value.as_bool()), Some(false)); assert_eq!( settings.get("sidebar_table_page_size").and_then(|value| value.as_u64()), Some(DesktopSettings::default().sidebar_table_page_size as u64) ); } #[tokio::test] async fn desktop_settings_persist_sidebar_table_page_size() { let path = temp_db_path("desktop-settings-sidebar-page-size"); let storage = Storage::open(&path).await.unwrap(); storage .save_desktop_settings(&DesktopSettings { sidebar_table_page_size: 1234, ..DesktopSettings::default() }) .await .unwrap(); assert_eq!(storage.load_desktop_settings().await.unwrap().sidebar_table_page_size, 1234); } #[tokio::test] async fn desktop_settings_persist_duckdb_worker_max_processes() { let path = temp_db_path("desktop-settings-duckdb-worker-max-processes"); let storage = Storage::open(&path).await.unwrap(); storage .save_desktop_settings(&DesktopSettings { duckdb_worker_max_processes: 8, ..DesktopSettings::default() }) .await .unwrap(); assert_eq!(storage.load_desktop_settings().await.unwrap().duckdb_worker_max_processes, 8); } #[tokio::test] async fn max_agent_turns_defaults_and_persists_clamped() { let path = temp_db_path("max-agent-turns"); let storage = Storage::open(&path).await.unwrap(); assert_eq!(storage.load_max_agent_turns().await.unwrap(), crate::agent_loop::DEFAULT_MAX_AGENT_TURNS); storage.save_max_agent_turns(100).await.unwrap(); assert_eq!(storage.load_max_agent_turns().await.unwrap(), 100); // Out-of-range values are clamped on save so raw DB edits cannot disable the safety limit. storage.save_max_agent_turns(0).await.unwrap(); assert_eq!(storage.load_max_agent_turns().await.unwrap(), crate::agent_loop::MIN_MAX_AGENT_TURNS); storage.save_max_agent_turns(u32::MAX).await.unwrap(); assert_eq!(storage.load_max_agent_turns().await.unwrap(), crate::agent_loop::MAX_MAX_AGENT_TURNS); } #[tokio::test] async fn max_retries_defaults_and_persists_clamped() { let path = temp_db_path("max-retries"); let storage = Storage::open(&path).await.unwrap(); assert_eq!(storage.load_max_retries().await.unwrap(), crate::ai::DEFAULT_MAX_RETRIES); storage.save_max_retries(5).await.unwrap(); assert_eq!(storage.load_max_retries().await.unwrap(), 5); storage.save_max_retries(0).await.unwrap(); assert_eq!(storage.load_max_retries().await.unwrap(), 0); // Values above the cap are clamped so raw DB edits cannot bypass the limit. storage.save_max_retries(u32::MAX).await.unwrap(); assert_eq!(storage.load_max_retries().await.unwrap(), crate::ai::MAX_MAX_RETRIES); } #[tokio::test] async fn max_retries_survives_stale_app_settings_save() { let path = temp_db_path("max-retries-stale-save"); let storage = Storage::open(&path).await.unwrap(); let stale_settings = storage.load_app_settings_json().await.unwrap(); storage.save_max_retries(7).await.unwrap(); storage.save_app_settings_json(&stale_settings).await.unwrap(); assert_eq!(storage.load_max_retries().await.unwrap(), 7); } #[tokio::test] async fn password_hash_preserves_existing_desktop_settings() { let path = temp_db_path("password-preserve-desktop-settings"); let storage = Storage::open(&path).await.unwrap(); storage .save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, ..DesktopSettings::default() }) .await .unwrap(); storage.save_password_hash("hash-2").await.unwrap(); assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-2".to_string())); assert_eq!( storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, ..DesktopSettings::default() } ); } #[tokio::test] async fn pinned_tree_node_ids_default_to_empty() { let path = temp_db_path("pinned-tree-default"); let storage = Storage::open(&path).await.unwrap(); assert_eq!(storage.load_pinned_tree_node_ids().await.unwrap(), Vec::::new()); } #[tokio::test] async fn pinned_tree_node_ids_roundtrip_and_preserve_password_hash() { let path = temp_db_path("pinned-tree-roundtrip"); let storage = Storage::open(&path).await.unwrap(); storage.save_password_hash("hash-3").await.unwrap(); storage.save_pinned_tree_node_ids(&["conn-1".to_string(), "conn-1:db:main".to_string()]).await.unwrap(); assert_eq!( storage.load_pinned_tree_node_ids().await.unwrap(), vec!["conn-1".to_string(), "conn-1:db:main".to_string()] ); assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-3".to_string())); } #[tokio::test] async fn app_state_roundtrips_without_polluting_app_settings() { let path = temp_db_path("app-state-roundtrip"); let storage = Storage::open(&path).await.unwrap(); storage.save_password_hash("hash-4").await.unwrap(); storage .save_desktop_settings(&DesktopSettings { icon_theme: DesktopIconTheme::Black, ..DesktopSettings::default() }) .await .unwrap(); storage.save_editor_settings(&serde_json::json!({ "openTabsRestoreMode": "pinned" })).await.unwrap(); storage .save_open_tabs_state(&serde_json::json!({ "tabs": [{ "id": "tab-1", "title": "Pinned", "connectionId": "pg", "database": "app", "sql": "select 1", "pinned": true }], "activeTabId": "tab-1" })) .await .unwrap(); storage .save_saved_sql_editor_positions(&serde_json::json!([{ "savedSqlId": "file-1", "updatedAt": 1 }])) .await .unwrap(); let transfer_task_library = serde_json::json!({ "version": 1, "folders": [], "tasks": [] }); storage.save_transfer_task_library(&transfer_task_library).await.unwrap(); assert_eq!( storage.load_editor_settings().await.unwrap(), Some(serde_json::json!({ "openTabsRestoreMode": "pinned" })) ); assert_eq!( storage.load_open_tabs_state().await.unwrap().and_then(|value| value.get("activeTabId").cloned()), Some(serde_json::json!("tab-1")) ); let development_open_tabs_key = "development_open_tabs"; storage .save_open_tabs_state_with_key( development_open_tabs_key, &serde_json::json!({ "tabs": [{ "id": "tab-2", "title": "Development", "connectionId": "pg", "database": "app", "sql": "select 2" }], "activeTabId": "tab-2" }), ) .await .unwrap(); assert_eq!( storage .load_open_tabs_state_with_key(development_open_tabs_key) .await .unwrap() .and_then(|value| value.get("activeTabId").cloned()), Some(serde_json::json!("tab-2")) ); assert_eq!( storage.load_open_tabs_state().await.unwrap().and_then(|value| value.get("activeTabId").cloned()), Some(serde_json::json!("tab-1")) ); assert_eq!( storage.load_saved_sql_editor_positions().await.unwrap(), Some(serde_json::json!([{ "savedSqlId": "file-1", "updatedAt": 1 }])) ); assert_eq!(storage.load_transfer_task_library().await.unwrap(), Some(transfer_task_library)); assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-4".to_string())); assert_eq!( storage.load_desktop_settings().await.unwrap(), DesktopSettings { icon_theme: DesktopIconTheme::Black, ..DesktopSettings::default() } ); assert_eq!(storage.load_app_settings_json().await.unwrap().get("open_tabs"), None); } #[tokio::test] async fn ai_chat_selection_roundtrips_in_local_app_state() { let path = temp_db_path("ai-chat-selection"); let storage = Storage::open(&path).await.unwrap(); let selection = AiChatSelectionState { version: 1, active: Some(AiActiveModelSelection { config_id: "config-1".to_string(), model_id: "model-1".to_string() }), effort_preferences: vec![AiModelEffortPreference { config_id: "config-1".to_string(), model_id: "model-1".to_string(), selection: AiEffortSelection::Enum("high".to_string()), }], default_mode: Some(AiAssistantMode::Agent), default_templates_by_db_type: BTreeMap::from([("postgresql".to_string(), vec!["tpl-1".to_string()])]), last_used_templates_by_db_type: BTreeMap::from([("mysql".to_string(), vec!["tpl-2".to_string()])]), }; storage.save_ai_chat_selection(&selection).await.unwrap(); assert_eq!(storage.load_ai_chat_selection().await.unwrap(), Some(selection)); assert_eq!(storage.load_app_settings_json().await.unwrap().get("ai_chat_selection_v1"), None); } // Selection JSON written before per-db-type prompt template defaults existed // must still deserialize; the new maps fall back to empty. #[tokio::test] async fn ai_chat_selection_loads_legacy_payload_without_template_defaults() { let path = temp_db_path("ai-chat-selection-legacy"); let storage = Storage::open(&path).await.unwrap(); let legacy = serde_json::json!({ "version": 1, "active": { "configId": "config-1", "modelId": "model-1" }, "effortPreferences": [], "defaultMode": "ask" }); storage.save_app_state_value(super::APP_STATE_AI_CHAT_SELECTION_KEY, &legacy).await.unwrap(); let loaded = storage.load_ai_chat_selection().await.unwrap().unwrap(); assert_eq!( loaded.active, Some(AiActiveModelSelection { config_id: "config-1".to_string(), model_id: "model-1".to_string() }) ); assert!(loaded.default_templates_by_db_type.is_empty()); assert!(loaded.last_used_templates_by_db_type.is_empty()); } // Serialization must omit the per-db-type maps while empty so the payload // stays identical to the pre-defaults format for users without picks. #[test] fn ai_chat_selection_serialization_omits_empty_template_maps() { let json = serde_json::to_value(AiChatSelectionState::default()).unwrap(); let object = json.as_object().unwrap(); assert!(!object.contains_key("defaultTemplatesByDbType")); assert!(!object.contains_key("lastUsedTemplatesByDbType")); } #[tokio::test] async fn tab_runtime_cache_roundtrips_binary_payloads() { let path = temp_db_path("tab-runtime-cache"); let storage = Storage::open(&path).await.unwrap(); storage .save_tab_runtime_cache("tab:1:result", vec![1, 2, 3, 4], 10, 3, Some("connection-1".to_string())) .await .unwrap(); let entry = storage.load_tab_runtime_cache("tab:1:result").await.unwrap().unwrap(); assert_eq!(entry.key, "tab:1:result"); assert_eq!(entry.payload, vec![1, 2, 3, 4]); assert_eq!(entry.row_count, 10); assert_eq!(entry.column_count, 3); assert_eq!(entry.byte_size, 4); assert_eq!(entry.owner_id.as_deref(), Some("connection-1")); assert!(entry.created_at > 0); assert!(entry.last_accessed_at >= entry.created_at); storage.delete_tab_runtime_cache("tab:1:result").await.unwrap(); assert_eq!(storage.load_tab_runtime_cache("tab:1:result").await.unwrap(), None); } #[tokio::test] async fn tab_runtime_cache_pruning_retains_live_entries_and_enforces_byte_budget() { let path = temp_db_path("tab-runtime-cache-prune"); let storage = Storage::open(&path).await.unwrap(); for (key, size) in [("live", 6usize), ("old", 5), ("new", 4)] { storage.save_tab_runtime_cache(key, vec![1; size], 1, 1, Some("connection-1".to_string())).await.unwrap(); } storage .with_conn(|conn| { conn.execute("UPDATE tab_runtime_cache SET last_accessed_at = 1 WHERE cache_key = 'old'", []) .map_err(|e| e.to_string())?; conn.execute("UPDATE tab_runtime_cache SET last_accessed_at = 2 WHERE cache_key = 'new'", []) .map_err(|e| e.to_string())?; Ok(()) }) .await .unwrap(); let result = storage.prune_tab_runtime_cache(vec!["live".to_string()], 10, i64::MAX, None).await.unwrap(); assert_eq!(result.deleted_entries, 1); assert!(storage.load_tab_runtime_cache("live").await.unwrap().is_some()); assert!(storage.load_tab_runtime_cache("old").await.unwrap().is_none()); assert!(storage.load_tab_runtime_cache("new").await.unwrap().is_some()); } #[tokio::test] async fn tab_runtime_cache_pruning_respects_orphan_grace_period() { let path = temp_db_path("tab-runtime-cache-orphan-grace"); let storage = Storage::open(&path).await.unwrap(); storage.save_tab_runtime_cache("fresh", vec![1], 1, 1, None).await.unwrap(); storage.save_tab_runtime_cache("crash-leftover", vec![2], 1, 1, None).await.unwrap(); storage .with_conn(|conn| { conn.execute("UPDATE tab_runtime_cache SET created_at = 1 WHERE cache_key = 'crash-leftover'", []) .map(|_| ()) .map_err(|e| e.to_string()) }) .await .unwrap(); let result = storage.prune_tab_runtime_cache(Vec::new(), 1024, 60_000, None).await.unwrap(); assert_eq!(result.orphan_deletions, 1); assert!(storage.load_tab_runtime_cache("fresh").await.unwrap().is_some()); assert!(storage.load_tab_runtime_cache("crash-leftover").await.unwrap().is_none()); } #[tokio::test] async fn tab_runtime_cache_migrates_legacy_schema_without_dropping_entries() { let path = temp_db_path("tab-runtime-cache-legacy-schema"); { let connection = Connection::open(&path).unwrap(); connection .execute( "CREATE TABLE tab_runtime_cache (cache_key TEXT PRIMARY KEY, payload BLOB NOT NULL, row_count INTEGER NOT NULL DEFAULT 0, column_count INTEGER NOT NULL DEFAULT 0, byte_size INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL)", [], ) .unwrap(); connection .execute("INSERT INTO tab_runtime_cache VALUES ('legacy', X'0102', 1, 1, 2, '2026-01-01')", []) .unwrap(); } let storage = Storage::open(&path).await.unwrap(); let entry = storage.load_tab_runtime_cache("legacy").await.unwrap().unwrap(); assert_eq!(entry.payload, vec![1, 2]); assert!(entry.created_at > 0); assert!(entry.last_accessed_at >= entry.created_at); } #[tokio::test] async fn saved_sql_catalog_column_migrates_legacy_database() { let path = temp_db_path("saved-sql-catalog-migration"); { let connection = Connection::open(&path).unwrap(); connection .execute_batch( "CREATE TABLE saved_sql_files ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, folder_id TEXT, name TEXT NOT NULL DEFAULT '', database_name TEXT NOT NULL DEFAULT '', schema_name TEXT, sql_text TEXT NOT NULL DEFAULT '', order_index INTEGER NOT NULL DEFAULT 0, open_count INTEGER NOT NULL DEFAULT 0, opened_at TEXT, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' ); INSERT INTO saved_sql_files (id, connection_id, name, database_name, sql_text, created_at, updated_at) VALUES ('legacy-sql', 'conn-1', 'legacy.sql', 'sales', 'SELECT 1;', '2026-01-01', '2026-01-01');", ) .unwrap(); } let storage = Storage::open(&path).await.unwrap(); let loaded = storage.load_saved_sql_file("legacy-sql").await.unwrap().unwrap(); assert_eq!(loaded.database, "sales"); assert_eq!(loaded.catalog, None); } #[tokio::test] async fn saved_sql_summary_omits_sql_text_and_loads_file_on_demand() { let path = temp_db_path("saved-sql-summary"); let storage = Storage::open(&path).await.unwrap(); let file = SavedSqlFile { id: "sql-1".to_string(), connection_id: "conn-1".to_string(), folder_id: None, name: "large.sql".to_string(), database: "main".to_string(), catalog: Some("hive".to_string()), schema: None, sql: "SELECT * FROM very_large_table;".repeat(100), sql_loaded: true, order_index: 0, open_count: 0, opened_at: None, created_at: "2026-06-27T00:00:00Z".to_string(), updated_at: "2026-06-27T00:00:00Z".to_string(), }; storage.save_saved_sql_file(&file).await.unwrap(); let summary = storage.load_saved_sql_library_summary().await.unwrap(); assert_eq!(summary.files.len(), 1); assert_eq!(summary.files[0].sql, ""); assert_eq!(summary.files[0].catalog.as_deref(), Some("hive")); assert!(!summary.files[0].sql_loaded); let loaded = storage.load_saved_sql_file("sql-1").await.unwrap().unwrap(); assert_eq!(loaded.sql, file.sql); assert_eq!(loaded.catalog.as_deref(), Some("hive")); assert!(loaded.sql_loaded); let sync_files = storage.load_saved_sql_files_for_sync().await.unwrap(); assert_eq!(sync_files.len(), 1); assert_eq!(sync_files[0].id, file.id); assert_eq!(sync_files[0].sql, file.sql); assert!(sync_files[0].sql_loaded); } #[tokio::test] async fn saved_sql_metadata_update_preserves_unloaded_sql_text() { let path = temp_db_path("saved-sql-preserve-unloaded-text"); let storage = Storage::open(&path).await.unwrap(); let mut file = SavedSqlFile { id: "sql-1".to_string(), connection_id: "conn-1".to_string(), folder_id: None, name: "query.sql".to_string(), database: "main".to_string(), catalog: None, schema: None, sql: "SELECT 1;".to_string(), sql_loaded: true, order_index: 0, open_count: 0, opened_at: None, created_at: "2026-06-27T00:00:00Z".to_string(), updated_at: "2026-06-27T00:00:00Z".to_string(), }; storage.save_saved_sql_file(&file).await.unwrap(); file.name = "renamed.sql".to_string(); file.sql.clear(); file.sql_loaded = false; file.open_count = 1; storage.save_saved_sql_file(&file).await.unwrap(); let loaded = storage.load_saved_sql_file("sql-1").await.unwrap().unwrap(); assert_eq!(loaded.name, "renamed.sql"); assert_eq!(loaded.open_count, 1); assert_eq!(loaded.sql, "SELECT 1;"); } #[tokio::test] async fn saved_sql_catalog_migration_keeps_legacy_rows_in_default_scope_across_restart() { let path = temp_db_path("saved-sql-catalog-restart-migration"); { let connection = Connection::open(&path).unwrap(); connection .execute( "CREATE TABLE saved_sql_files ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, folder_id TEXT, name TEXT NOT NULL DEFAULT '', database_name TEXT NOT NULL DEFAULT '', schema_name TEXT, sql_text TEXT NOT NULL DEFAULT '', order_index INTEGER NOT NULL DEFAULT 0, open_count INTEGER NOT NULL DEFAULT 0, opened_at TEXT, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", [], ) .unwrap(); connection .execute( "INSERT INTO saved_sql_files (id, connection_id, name, database_name, sql_text, created_at, updated_at) VALUES ('legacy', 'conn-1', 'legacy.sql', 'analytics', 'SELECT 1;', '2026-08-12', '2026-08-12')", [], ) .unwrap(); } let storage = Storage::open(&path).await.unwrap(); let legacy = storage.load_saved_sql_file("legacy").await.unwrap().unwrap(); assert_eq!(legacy.catalog, None); let external = SavedSqlFile { id: "external".to_string(), connection_id: "conn-1".to_string(), catalog: Some("iceberg_catalog".to_string()), folder_id: None, name: "external.sql".to_string(), database: "analytics".to_string(), schema: None, sql: "SELECT 2;".to_string(), sql_loaded: true, order_index: 1, open_count: 0, opened_at: None, created_at: "2026-08-12".to_string(), updated_at: "2026-08-12".to_string(), }; storage.save_saved_sql_file(&external).await.unwrap(); drop(storage); let reopened = Storage::open(&path).await.unwrap(); assert_eq!(reopened.load_saved_sql_file("legacy").await.unwrap().unwrap().catalog, None); assert_eq!( reopened.load_saved_sql_file("external").await.unwrap().unwrap().catalog.as_deref(), Some("iceberg_catalog") ); } // ---- AI Config tests ---- use crate::ai::{ AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem, AiEffortLevel, AiModelListItem, AiProvider, AiReasoningLevel, }; fn make_ai_config(name: &str, is_default: bool) -> AiConfigItem { AiConfigItem { id: format!("cfg-{name}"), name: name.to_string(), is_default, config: AiConfig { provider: AiProvider::Openai, api_key: "sk-test".to_string(), auth_method: AiAuthMethod::ApiKey, endpoint: "https://api.openai.com/v1".to_string(), model: "gpt-4o".to_string(), models: Vec::new(), api_style: AiApiStyle::Completions, custom_headers: Default::default(), proxy_enabled: false, proxy_url: String::new(), skip_tls_verify: false, enable_thinking: true, reasoning_level: AiReasoningLevel::Default, max_output_tokens: None, runtime_effort: None, context_window: None, max_retries: None, codex_cli_path: None, codex_cli_env: std::collections::HashMap::new(), claude_code_cli_path: None, claude_code_cli_env: std::collections::HashMap::new(), pi_agent_cli_path: None, pi_agent_cli_env: std::collections::HashMap::new(), opencode_cli_path: None, opencode_cli_env: std::collections::HashMap::new(), cursor_cli_path: None, cursor_cli_env: std::collections::HashMap::new(), grok_cli_path: None, grok_cli_env: std::collections::HashMap::new(), codebuddy_cli_path: None, codebuddy_cli_env: std::collections::HashMap::new(), qoder_cli_path: None, qoder_cli_env: Default::default(), }, } } #[tokio::test] async fn ai_config_save_load_roundtrip() { let db = temp_db_path("ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let mut cfg = make_ai_config("test-config", true); cfg.config.provider = AiProvider::ClaudeCodeCli; cfg.config.model = "claude-sonnet-4-6".to_string(); cfg.config.reasoning_level = AiReasoningLevel::Xhigh; cfg.config.models = vec![AiModelListItem { name: "claude-sonnet-4-6".to_string(), label: Some("Sonnet 4.6".to_string()), supported_effort_levels: vec![AiEffortLevel::Low, AiEffortLevel::High, AiEffortLevel::Xhigh], }]; storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].id, "cfg-test-config"); assert_eq!(loaded[0].name, "test-config"); assert!(loaded[0].is_default); assert_eq!(loaded[0].config.model, "claude-sonnet-4-6"); assert_eq!(loaded[0].config.reasoning_level, AiReasoningLevel::Xhigh); assert_eq!(loaded[0].config.models.len(), 1); assert_eq!(loaded[0].config.models[0].name, "claude-sonnet-4-6"); assert_eq!( loaded[0].config.models[0].supported_effort_levels, vec![AiEffortLevel::Low, AiEffortLevel::High, AiEffortLevel::Xhigh] ); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn opencode_cli_ai_config_roundtrip() { let db = temp_db_path("opencode-cli-ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let mut cfg = make_ai_config("opencode-cli", true); cfg.config.provider = AiProvider::OpenCodeCli; cfg.config.api_key.clear(); cfg.config.endpoint.clear(); cfg.config.model = "openai/gpt-5.4-mini".to_string(); cfg.config.opencode_cli_path = Some("/opt/homebrew/bin/opencode".to_string()); cfg.config.opencode_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string()); storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert!(matches!(loaded[0].config.provider, AiProvider::OpenCodeCli)); assert_eq!(loaded[0].config.model, "openai/gpt-5.4-mini"); assert_eq!(loaded[0].config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode")); assert_eq!( loaded[0].config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str), Some("http://127.0.0.1:7890") ); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn cursor_cli_ai_config_roundtrip() { let db = temp_db_path("cursor-cli-ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let mut cfg = make_ai_config("cursor-cli", true); cfg.config.provider = AiProvider::CursorCli; cfg.config.api_key.clear(); cfg.config.endpoint.clear(); cfg.config.model = "composer-2.5".to_string(); cfg.config.cursor_cli_path = Some("~/.local/bin/agent".to_string()); cfg.config.cursor_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string()); storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert!(matches!(loaded[0].config.provider, AiProvider::CursorCli)); assert_eq!(loaded[0].config.model, "composer-2.5"); assert_eq!(loaded[0].config.cursor_cli_path.as_deref(), Some("~/.local/bin/agent")); assert_eq!( loaded[0].config.cursor_cli_env.get("HTTPS_PROXY").map(String::as_str), Some("http://127.0.0.1:7890") ); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn grok_cli_ai_config_roundtrip() { let db = temp_db_path("grok-cli-ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let mut cfg = make_ai_config("grok-cli", true); cfg.config.provider = AiProvider::GrokCli; cfg.config.api_key = String::new(); cfg.config.auth_method = AiAuthMethod::Bearer; cfg.config.endpoint = String::new(); cfg.config.model = "default".to_string(); cfg.config.api_style = AiApiStyle::Completions; cfg.config.grok_cli_path = Some("/Users/me/.grok/bin/grok".to_string()); storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert!(matches!(loaded[0].config.provider, AiProvider::GrokCli)); assert_eq!(loaded[0].config.model, "default"); assert_eq!(loaded[0].config.grok_cli_path.as_deref(), Some("/Users/me/.grok/bin/grok")); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn codebuddy_cli_ai_config_roundtrip() { let db = temp_db_path("codebuddy-cli-ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let mut cfg = make_ai_config("codebuddy-cli", true); cfg.config.provider = AiProvider::CodeBuddyCli; cfg.config.api_key.clear(); cfg.config.endpoint.clear(); cfg.config.model = "kimi-k2.5".to_string(); cfg.config.codebuddy_cli_path = Some("/opt/homebrew/bin/codebuddy".to_string()); cfg.config.codebuddy_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string()); storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert!(matches!(loaded[0].config.provider, AiProvider::CodeBuddyCli)); assert_eq!(loaded[0].config.model, "kimi-k2.5"); assert_eq!(loaded[0].config.codebuddy_cli_path.as_deref(), Some("/opt/homebrew/bin/codebuddy")); assert_eq!( loaded[0].config.codebuddy_cli_env.get("HTTPS_PROXY").map(String::as_str), Some("http://127.0.0.1:7890") ); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn anthropic_compatible_ai_config_roundtrip() { let db = temp_db_path("anthropic-compatible-ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let mut cfg = make_ai_config("anthropic-compatible", true); cfg.config.provider = AiProvider::AnthropicCompatible; cfg.config.api_key = String::new(); cfg.config.auth_method = AiAuthMethod::Bearer; cfg.config.endpoint = "https://gateway.example.com/anthropic/v1/messages".to_string(); cfg.config.model = "vendor/future-model".to_string(); cfg.config.api_style = AiApiStyle::AnthropicMessages; storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert!(matches!(loaded[0].config.provider, AiProvider::AnthropicCompatible)); assert_eq!(loaded[0].config.auth_method, AiAuthMethod::Bearer); assert_eq!(loaded[0].config.api_style, AiApiStyle::AnthropicMessages); assert_eq!(loaded[0].config.endpoint, "https://gateway.example.com/anthropic/v1/messages"); assert_eq!(loaded[0].config.model, "vendor/future-model"); assert!(loaded[0].config.api_key.is_empty()); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn minimax_ai_config_roundtrip() { let db = temp_db_path("minimax-ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let mut cfg = make_ai_config("minimax", true); cfg.config.provider = AiProvider::MiniMax; cfg.config.api_key = "key".to_string(); cfg.config.auth_method = AiAuthMethod::Bearer; cfg.config.endpoint = "https://api.minimax.io/v1".to_string(); cfg.config.model = "MiniMax-M3".to_string(); cfg.config.api_style = AiApiStyle::Completions; storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert!(matches!(loaded[0].config.provider, AiProvider::MiniMax)); assert_eq!(loaded[0].config.auth_method, AiAuthMethod::Bearer); assert_eq!(loaded[0].config.endpoint, "https://api.minimax.io/v1"); assert_eq!(loaded[0].config.model, "MiniMax-M3"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn ai_config_only_one_default() { let db = temp_db_path("ai-one-default"); let storage = Storage::open(&db).await.unwrap(); let cfg1 = make_ai_config("config-a", true); let cfg2 = make_ai_config("config-b", true); storage.save_ai_config_item(&cfg1).await.unwrap(); // Second default config should succeed and cascade-clear the first storage.save_ai_config_item(&cfg2).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); let defaults: Vec<_> = loaded.iter().filter(|c| c.is_default).collect(); assert_eq!(defaults.len(), 1); assert_eq!(defaults[0].id, "cfg-config-b"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn ai_config_update_existing_to_default() { let db = temp_db_path("ai-update-default"); let storage = Storage::open(&db).await.unwrap(); let cfg1 = make_ai_config("config-a", true); let cfg2 = make_ai_config("config-b", false); storage.save_ai_config_item(&cfg1).await.unwrap(); storage.save_ai_config_item(&cfg2).await.unwrap(); assert_eq!(storage.load_ai_configs().await.unwrap().iter().filter(|c| c.is_default).count(), 1); // Update cfg-b to be default via save_ai_config_item — should succeed and clear cfg-a let cfg2 = make_ai_config("config-b", true); storage.save_ai_config_item(&cfg2).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); let defaults: Vec<_> = loaded.iter().filter(|c| c.is_default).collect(); assert_eq!(defaults.len(), 1); assert_eq!(defaults[0].id, "cfg-config-b"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn ai_config_duplicate_name_error() { let db = temp_db_path("ai-dup-name"); let storage = Storage::open(&db).await.unwrap(); let cfg1 = make_ai_config("same-name", false); storage.save_ai_config_item(&cfg1).await.unwrap(); // Different id, same name → should fail with name conflict let mut cfg2 = make_ai_config("same-name", false); cfg2.id = "cfg-other".to_string(); let err = storage.save_ai_config_item(&cfg2).await.unwrap_err(); assert!(err.contains("ai.configNameExists"), "Expected name conflict error, got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn ai_config_set_default_switches() { let db = temp_db_path("ai-set-default"); let storage = Storage::open(&db).await.unwrap(); let cfg1 = make_ai_config("first", true); let cfg2 = make_ai_config("second", false); storage.save_ai_config_item(&cfg1).await.unwrap(); storage.save_ai_config_item(&cfg2).await.unwrap(); // Switch default to second storage.set_default_ai_config("cfg-second").await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); let first = loaded.iter().find(|c| c.id == "cfg-first").unwrap(); let second = loaded.iter().find(|c| c.id == "cfg-second").unwrap(); assert!(!first.is_default); assert!(second.is_default); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn ai_config_delete_default_no_cascade() { let db = temp_db_path("ai-delete-default"); let storage = Storage::open(&db).await.unwrap(); let cfg1 = make_ai_config("default-one", true); let cfg2 = make_ai_config("other", false); storage.save_ai_config_item(&cfg1).await.unwrap(); storage.save_ai_config_item(&cfg2).await.unwrap(); // Delete the default config storage.delete_ai_config("cfg-default-one").await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); // Remaining config should NOT be auto-promoted to default assert!(!loaded[0].is_default); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn ai_config_save_configs_batch() { let db = temp_db_path("ai-batch"); let storage = Storage::open(&db).await.unwrap(); let configs = vec![make_ai_config("batch-a", true), make_ai_config("batch-b", false), make_ai_config("batch-c", false)]; storage.save_ai_configs(&configs).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 3); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn ai_config_save_configs_clears_old_tables() { let db = temp_db_path("ai-clear-old"); let storage = Storage::open(&db).await.unwrap(); // Pre-populate old tables as if migration hasn't run yet storage.save_ai_config(&make_ai_config("legacy-active", false).config).await.unwrap(); storage.save_ai_provider_config("openai", &make_ai_config("legacy-openai", false).config).await.unwrap(); // save_ai_configs should clear old tables let configs = vec![make_ai_config("new-a", true)]; storage.save_ai_configs(&configs).await.unwrap(); // New table has the saved config let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].name, "new-a"); // Old tables are cleared — prevents re-migration on restart assert!(storage.load_ai_config().await.unwrap().is_none(), "ai_config should be deleted"); let old_providers = storage.load_ai_provider_configs().await.unwrap(); assert!(old_providers.is_empty(), "ai_provider_configs should be deleted"); std::fs::remove_file(&db).ok(); } // --- Prompt Templates --- #[tokio::test] async fn prompt_template_save_new_creates_timestamps() { let db = temp_db_path("pt-save-new"); let storage = Storage::open(&db).await.unwrap(); let result = storage.save_prompt_template("t1", "Production Rules", "SELECT 1").await.unwrap(); assert_eq!(result.id, "t1"); assert_eq!(result.name, "Production Rules"); assert_eq!(result.content, "SELECT 1"); assert!(!result.created_at.is_empty()); assert_eq!(result.created_at, result.updated_at); // Verify it's persisted in load let templates = storage.load_prompt_templates().await.unwrap(); assert_eq!(templates.len(), 1); assert_eq!(templates[0].id, "t1"); assert_eq!(templates[0].created_at, result.created_at); assert_eq!(templates[0].updated_at, result.updated_at); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_save_update_preserves_created_at() { let db = temp_db_path("pt-save-update"); let storage = Storage::open(&db).await.unwrap(); let first = storage.save_prompt_template("t1", "Original Name", "Original content").await.unwrap(); // Ensure some time passes so updated_at changes std::thread::sleep(std::time::Duration::from_millis(1100)); let second = storage.save_prompt_template("t1", "Updated Name", "Updated content").await.unwrap(); assert_eq!(second.id, "t1"); assert_eq!(second.name, "Updated Name"); assert_eq!(second.content, "Updated content"); assert_eq!(second.created_at, first.created_at, "created_at must be preserved on update"); assert_ne!(second.updated_at, first.updated_at, "updated_at must change on update"); // Verify only one row exists let templates = storage.load_prompt_templates().await.unwrap(); assert_eq!(templates.len(), 1); assert_eq!(templates[0].name, "Updated Name"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_save_rejects_blank_name() { let db = temp_db_path("pt-name-blank"); let storage = Storage::open(&db).await.unwrap(); let err = storage.save_prompt_template("t1", "", "content").await.unwrap_err(); assert!(err.contains("cannot be empty"), "expected 'cannot be empty', got: {err}"); let err = storage.save_prompt_template("t1", " ", "content").await.unwrap_err(); assert!(err.contains("cannot be empty"), "expected 'cannot be empty', got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_save_rejects_long_name() { let db = temp_db_path("pt-name-long"); let storage = Storage::open(&db).await.unwrap(); let long_name = "a".repeat(51); let err = storage.save_prompt_template("t1", &long_name, "content").await.unwrap_err(); assert!(err.contains("too long") && err.contains("50"), "expected too long (max 50), got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_accepts_multi_byte_characters_within_char_limit() { let db = temp_db_path("pt-multibyte-name"); let storage = Storage::open(&db).await.unwrap(); // 25 Chinese characters = 75 bytes but only 25 chars — should be allowed under 50 char limit let name25 = "数".repeat(25); // 25 chars, 75 bytes assert_eq!(name25.chars().count(), 25); assert!(name25.len() > 50); // byte length exceeds 50 let result = storage.save_prompt_template("t1", &name25, "content").await.unwrap(); assert_eq!(result.name, name25); // 51 Chinese characters = 153 bytes — should be rejected (51 chars > 50) let name51 = "数".repeat(51); assert_eq!(name51.chars().count(), 51); let err = storage.save_prompt_template("t2", &name51, "content").await.unwrap_err(); assert!(err.contains("too long") && err.contains("50"), "expected too long (max 50), got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_save_rejects_long_content() { let db = temp_db_path("pt-content-long"); let storage = Storage::open(&db).await.unwrap(); let long_content = "a".repeat(8001); let err = storage.save_prompt_template("t1", "Valid Name", &long_content).await.unwrap_err(); assert!(err.contains("too long") && err.contains("8000"), "expected too long (max 8000), got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_save_rejects_duplicate_name_case_insensitive() { let db = temp_db_path("pt-dup-name"); let storage = Storage::open(&db).await.unwrap(); storage.save_prompt_template("t1", "Production Rules", "content 1").await.unwrap(); // Same name, different id → should fail let err = storage.save_prompt_template("t2", "production rules", "content 2").await.unwrap_err(); assert!(err.contains("duplicate"), "expected 'duplicate', got: {err}"); // Same name, same id → should update (not fail) let update = storage.save_prompt_template("t1", "Production Rules", "updated").await.unwrap(); assert_eq!(update.id, "t1"); assert_eq!(update.content, "updated"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_save_rejects_duplicate_name_unicode_case_folding() { let db = temp_db_path("pt-dup-unicode"); let storage = Storage::open(&db).await.unwrap(); // SQLite LOWER() is ASCII-only (U+00C4 'Ä' → no change), but Rust // str::to_lowercase() does full Unicode case folding (Ä → ä). // Both directions must detect the duplicate. storage.save_prompt_template("t1", "Ä规则", "content-upper").await.unwrap(); let err = storage.save_prompt_template("t2", "ä规则", "content-lower").await.unwrap_err(); assert!(err.contains("duplicate"), "expected 'duplicate', got: {err}"); // Reverse: lower-case first, upper-case second. let db = temp_db_path("pt-dup-unicode-2"); let storage = Storage::open(&db).await.unwrap(); storage.save_prompt_template("t1", "ä规则", "content-lower").await.unwrap(); let err = storage.save_prompt_template("t2", "Ä规则", "content-upper").await.unwrap_err(); assert!(err.contains("duplicate"), "expected 'duplicate', got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_load_order_is_stable() { let db = temp_db_path("pt-load-order"); let storage = Storage::open(&db).await.unwrap(); // Insert in reverse order of created_at by sleeping between inserts storage.save_prompt_template("a", "Template A", "a").await.unwrap(); std::thread::sleep(std::time::Duration::from_millis(1100)); storage.save_prompt_template("b", "Template B", "b").await.unwrap(); std::thread::sleep(std::time::Duration::from_millis(1100)); storage.save_prompt_template("c", "Template C", "c").await.unwrap(); let templates = storage.load_prompt_templates().await.unwrap(); assert_eq!(templates.len(), 3); // Order should be by created_at ascending: A first, C last assert_eq!(templates[0].id, "a"); assert_eq!(templates[1].id, "b"); assert_eq!(templates[2].id, "c"); // Insert with same created_at — tie-break by id // We insert d right after c without delay storage.save_prompt_template("d", "Template D", "d").await.unwrap(); let templates = storage.load_prompt_templates().await.unwrap(); assert_eq!(templates.len(), 4); assert_eq!(templates[3].id, "d"); // Second load should give same order let templates2 = storage.load_prompt_templates().await.unwrap(); assert_eq!(templates, templates2); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_delete_unknown_id_errors() { let db = temp_db_path("pt-delete-unknown"); let storage = Storage::open(&db).await.unwrap(); let err = storage.delete_prompt_template("nonexistent").await.unwrap_err(); assert!(err.contains("not found"), "expected 'not found', got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn prompt_template_delete_existing_removes() { let db = temp_db_path("pt-delete-existing"); let storage = Storage::open(&db).await.unwrap(); storage.save_prompt_template("t1", "Template", "content").await.unwrap(); assert_eq!(storage.load_prompt_templates().await.unwrap().len(), 1); storage.delete_prompt_template("t1").await.unwrap(); assert!(storage.load_prompt_templates().await.unwrap().is_empty()); std::fs::remove_file(&db).ok(); } // --- Global Custom Instructions --- #[tokio::test] async fn global_instructions_set_get_roundtrip() { let db = temp_db_path("gi-roundtrip"); let storage = Storage::open(&db).await.unwrap(); let instructions = "Amounts are in cents. Always filter by date range."; storage.save_ai_global_custom_instructions(instructions).await.unwrap(); let loaded = storage.load_ai_global_custom_instructions().await.unwrap(); assert_eq!(loaded, instructions); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn global_instructions_defaults_to_empty() { let db = temp_db_path("gi-default"); let storage = Storage::open(&db).await.unwrap(); let loaded = storage.load_ai_global_custom_instructions().await.unwrap(); assert_eq!(loaded, ""); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn global_instructions_rejects_too_long() { let db = temp_db_path("gi-too-long"); let storage = Storage::open(&db).await.unwrap(); let long = "a".repeat(8001); let err = storage.save_ai_global_custom_instructions(&long).await.unwrap_err(); assert!(err.contains("too long") && err.contains("8000"), "expected too long (max 8000), got: {err}"); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn global_instructions_empty_string_clears() { let db = temp_db_path("gi-clear"); let storage = Storage::open(&db).await.unwrap(); storage.save_ai_global_custom_instructions("Some instructions").await.unwrap(); assert_eq!(storage.load_ai_global_custom_instructions().await.unwrap(), "Some instructions"); // Empty string (trimmed) is allowed — equivalent to clear storage.save_ai_global_custom_instructions("").await.unwrap(); assert_eq!(storage.load_ai_global_custom_instructions().await.unwrap(), ""); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn global_instructions_whitespace_only_trims() { let db = temp_db_path("gi-whitespace"); let storage = Storage::open(&db).await.unwrap(); storage.save_ai_global_custom_instructions(" \n \t ").await.unwrap(); let loaded = storage.load_ai_global_custom_instructions().await.unwrap(); assert_eq!(loaded, ""); std::fs::remove_file(&db).ok(); } #[tokio::test] async fn pending_snippet_cleanup_survives_restart_and_clears_only_when_matched() { let db = temp_db_path("snippet-cleanup-restart"); let storage = Storage::open(&db).await.unwrap(); storage.save_snippet_migration_state("github", "replacement-id", "legacy-id", "content-hash").await.unwrap(); drop(storage); let storage = Storage::open(&db).await.unwrap(); let state = storage.load_snippet_sync_state("github").await.unwrap(); assert_eq!(state.snippet_id.as_deref(), Some("replacement-id")); let pending = state.pending_cleanup.unwrap(); assert_eq!(pending.snippet_id, "legacy-id"); assert_eq!(pending.expected_content_hash, "content-hash"); let mut wrong_pending = pending.clone(); wrong_pending.expected_content_hash = "newer-content-hash".to_string(); assert!(!storage.clear_snippet_pending_cleanup_if_matches("github", &wrong_pending).await.unwrap()); assert!(storage.load_snippet_sync_state("github").await.unwrap().pending_cleanup.is_some()); assert!(storage.clear_snippet_pending_cleanup_if_matches("github", &pending).await.unwrap()); assert!(storage.load_snippet_sync_state("github").await.unwrap().pending_cleanup.is_none()); std::fs::remove_file(&db).ok(); } }