Files
dbx/crates/dbx-core/src/external/duckdb_cache.rs
T
e9ee982843 Preserve ordered SSH and proxy connection layers (#802)
* Preserve ordered SSH and proxy connection layers

Replace separate SSH/proxy connection fields with ordered transport_layers while keeping legacy migration and secret fallback paths intact. The UI now edits SSH tunnel/proxy layers in configured order without a global SSH enable gate.

Constraint: Existing saved SSH tunnels, proxy settings, and secret-store keys must continue to load through migration.
Rejected: Folding proxy fields into SshTunnelConfig | mixes proxy semantics into an SSH-specific structure.
Confidence: high
Scope-risk: moderate
Directive: Keep SSH/proxy structs provider-specific; put cross-layer chaining in transport_layer_tunnel orchestration.
Tested: git diff --check; cargo fmt --check; cargo check --workspace; cargo test -p dbx-core --lib; vue-tsc --noEmit --project apps/desktop/tsconfig.json; tsc -p packages/node-core/tsconfig.json --noEmit; oxlint --vue-plugin apps/desktop/src; tsx --tsconfig apps/desktop/tsconfig.json --test packages/app-tests/*.test.ts; tsx --test packages/node-core/tests/*.test.ts
Not-tested: Live external SSH/proxy/database endpoint integration.

* Keep ordered transport changes CI-format clean

Constraint: CI pnpm check failed only on oxfmt formatting for two desktop TypeScript files
Confidence: high
Scope-risk: narrow
Directive: Keep generated/editor config changes out of this PR fix commit
Tested: PATH="/Volumes/data/code/rust/dbx/node_modules/.bin:/Users/hekx/.codex/tmp/arg0/codex-arg0nuL34n:/Users/hekx/.cargo/bin:/Users/hekx/.local/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/libpq/bin:/Volumes/data/Users/hekx/.opencode/bin:/Users/hekx/.bun/bin:/Volumes/data/sdks/flutter/bin:/Volumes/data/Users/hekx/.cargo/bin:/Users/hekx/.local/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/libpq/bin:/Volumes/data/Users/hekx/.opencode/bin:/Users/hekx/.bun/bin:/Volumes/data/Users/hekx/Library/pnpm:/Volumes/data/sdks/flutter/bin:/Volumes/data/Users/hekx/.cargo/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/opt/X11/bin:/Library/Apple/usr/bin:/Applications/Wireshark.app/Contents/MacOS:/usr/local/go/bin:/opt/homebrew/bin:/opt/podman/bin:/Applications/Ghostty.app/Contents/MacOS" rtk node scripts/run-check.mjs
Not-tested: GitHub Actions rerun not observed locally

* Keep Rust checks warning-clean

Constraint: cargo clippy --workspace --all-targets --all-features -- -D warnings failed across core, web, and tauri crates
Rejected: Broad workspace-level clippy suppression | kept allows local to long-argument command/API boundaries
Confidence: high
Scope-risk: moderate
Directive: Preserve src-tauri/tauri.conf.json as an unrelated local change outside this commit
Tested: rtk cargo clippy --workspace --all-targets --all-features -- -D warnings
Not-tested: Full GitHub Actions rerun not observed locally

* Reuse existing proxy tunnels on retry

Proxy tunnel startup now mirrors SSH tunnel behavior by returning the existing local port for an active connection id instead of replacing the managed handle. A second map check aborts a just-spawned duplicate handle if a concurrent retry won the race before insertion, preventing orphaned listeners while keeping the change narrow.

Constraint: Reviewer requested proxy tunnel behavior align with SSH local-port reuse

Rejected: Always overwrite and abort the previous handle | less consistent with SSH behavior and churns listeners during retries

Confidence: high

Scope-risk: narrow

Tested: cargo fmt --check --all

Tested: cargo test -p dbx-core db::proxy_tunnel::tests::start_tunnel_reuses_existing_local_port

Tested: cargo clippy -p dbx-core --all-targets -- -D warnings

---------

Co-authored-by: hekx <hekx@momandeMac-mini.local>
Co-authored-by: caisin <caisin@caisins-Mac-mini.local>
2026-06-06 23:22:29 +08:00

172 lines
6.0 KiB
Rust

use std::sync::Arc;
use super::types::{CacheState, ExternalTableRef, ExternalTableSnapshot};
/// Loads an external table snapshot into an in-memory DuckDB connection.
pub fn load_snapshot_to_duckdb(con: &duckdb::Connection, snapshot: &ExternalTableSnapshot) -> Result<(), String> {
let table_name = sanitize_table_name(&snapshot.table_ref.table_name);
con.execute(&format!("DROP TABLE IF EXISTS \"{}\"", table_name), [])
.map_err(|e| format!("Failed to drop table: {e}"))?;
let col_defs: Vec<String> = snapshot
.columns
.iter()
.map(|c| {
format!(
"\"{}\" {}{}",
c.name.replace('"', "\"\""),
c.duckdb_type,
if c.is_nullable { "" } else { " NOT NULL" }
)
})
.collect();
let pk_cols: Vec<&str> = snapshot.columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.as_str()).collect();
let mut create_sql = format!("CREATE TABLE \"{}\" ({}", table_name, col_defs.join(", "));
if !pk_cols.is_empty() {
create_sql.push_str(&format!(
", PRIMARY KEY ({})",
pk_cols.iter().map(|c| format!("\"{}\"", c.replace('"', "\"\""))).collect::<Vec<_>>().join(", ")
));
}
create_sql.push(')');
con.execute(&create_sql, []).map_err(|e| format!("Failed to create table: {e}"))?;
if snapshot.rows.is_empty() {
return Ok(());
}
let placeholders: Vec<&str> = (0..snapshot.columns.len()).map(|_| "?").collect();
let insert_sql = format!("INSERT INTO \"{}\" VALUES ({})", table_name, placeholders.join(", "));
let mut stmt = con.prepare(&insert_sql).map_err(|e| format!("Failed to prepare insert: {e}"))?;
for row in &snapshot.rows {
let params: Vec<Box<dyn duckdb::ToSql>> = row
.iter()
.enumerate()
.map(|(i, val)| {
let col_type = snapshot.columns.get(i).map(|c| c.duckdb_type.as_str()).unwrap_or("VARCHAR");
json_to_duckdb_param(val, col_type)
})
.collect();
let param_refs: Vec<&dyn duckdb::ToSql> = params.iter().map(|p| p.as_ref()).collect();
stmt.execute(param_refs.as_slice()).map_err(|e| format!("Failed to insert row: {e}"))?;
}
Ok(())
}
fn json_to_duckdb_param(val: &serde_json::Value, col_type: &str) -> Box<dyn duckdb::ToSql> {
match val {
serde_json::Value::Null => Box::new(None::<String>),
serde_json::Value::Bool(b) => Box::new(*b),
serde_json::Value::Number(n) => {
if col_type.contains("INT") {
Box::new(n.as_i64().unwrap_or(0))
} else if col_type.contains("DOUBLE") || col_type.contains("FLOAT") || col_type.contains("DECIMAL") {
Box::new(n.as_f64().unwrap_or(0.0))
} else {
Box::new(n.to_string())
}
}
serde_json::Value::String(s) => Box::new(s.clone()),
_ => Box::new(val.to_string()),
}
}
/// Sanitize a source table name into a stable DuckDB table identifier.
pub fn sanitize_table_name(name: &str) -> String {
let sanitized: String = name.chars().map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }).collect();
if sanitized.is_empty() {
"_unnamed_".to_string()
} else if sanitized.chars().next().is_some_and(|c| c.is_ascii_digit()) {
format!("_{sanitized}")
} else {
sanitized
}
}
/// Manages a DuckDB cache for one external source.
pub struct ExternalPool {
pub source: Arc<dyn super::traits::ExternalTabularSource>,
pub cache: Arc<std::sync::Mutex<duckdb::Connection>>,
pub cache_state: std::sync::Mutex<CacheState>,
pub table_map: std::sync::Mutex<std::collections::HashMap<String, ExternalTableRef>>,
}
impl ExternalPool {
pub fn new(
source: Arc<dyn super::traits::ExternalTabularSource>,
cache: Arc<std::sync::Mutex<duckdb::Connection>>,
) -> Self {
Self {
source,
cache,
cache_state: std::sync::Mutex::new(CacheState::Empty),
table_map: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
pub async fn refresh_cache(&self) -> Result<(), String> {
{
let mut state = self.cache_state.lock().map_err(|e| e.to_string())?;
*state = CacheState::Loading;
}
let tables = self.source.list_tables().await?;
let mut new_table_map = std::collections::HashMap::new();
for table_ref in &tables {
let snapshot = self.source.load_table(table_ref).await?;
let mut sanitized_name = sanitize_table_name(&table_ref.table_name);
if new_table_map.contains_key(&sanitized_name) {
let base = sanitized_name.clone();
let mut suffix = 2;
loop {
sanitized_name = format!("{base}_{suffix}");
if !new_table_map.contains_key(&sanitized_name) {
break;
}
suffix += 1;
}
}
let cache = self.cache.clone();
tokio::task::spawn_blocking(move || {
let con = cache.lock().map_err(|e| e.to_string())?;
load_snapshot_to_duckdb(&con, &snapshot)
})
.await
.map_err(|e| e.to_string())??;
new_table_map.insert(sanitized_name, table_ref.clone());
}
{
let mut map = self.table_map.lock().map_err(|e| e.to_string())?;
*map = new_table_map;
}
{
let mut state = self.cache_state.lock().map_err(|e| e.to_string())?;
*state = CacheState::Fresh;
}
Ok(())
}
}
impl std::fmt::Debug for ExternalPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExternalPool")
.field("source", &self.source.display_name())
.field("cache_state", &self.cache_state)
.finish()
}
}