From f456ddd979faf3788755d25110caacf86c55bd00 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 11 May 2026 00:18:09 +0800 Subject: [PATCH] perf: full-stack performance and security hardening Frontend performance: - Debounce DataGrid search (150ms) and replace O(n) row wrapping with index array - Replace QueryStore deep watcher with targeted field-level persistence snapshot - Extract shared nodeConfig computed in TreeItem (eliminates 15x redundant getConfig calls) - Clear tab result data on close to free memory - Add LRU eviction for completion caches (50 entry cap) - Replace savedSql version string join with numeric counter Memory leak fixes: - Store Tauri event unlisten handles and clean up on unmount - Clean up DataGrid search timer on unmount Backend security & resource management: - Add 50MB request body size limit - Reject file uploads >100MB and SQL files >200MB - Clean up SSE channels after transfer/import completes - Recover from poisoned mutex instead of panicking - Delete temporary upload files after preview - Validate SQL file paths to prevent directory traversal --- crates/dbx-core/src/query_cancel.rs | 8 +-- src-web/src/main.rs | 7 ++- src-web/src/routes/sql_file.rs | 48 +++++++++++++++- src-web/src/routes/table_import.rs | 9 ++- src-web/src/routes/transfer.rs | 3 +- src-web/src/state.rs | 6 ++ src/App.vue | 3 +- src/components/grid/DataGrid.vue | 49 +++++++++++++---- src/components/sidebar/TreeItem.vue | 45 ++++++++------- src/composables/useTauriEvents.ts | 85 +++++++++++++++++------------ src/stores/connectionStore.ts | 14 +++++ src/stores/queryStore.ts | 24 +++++++- src/stores/savedSqlStore.ts | 16 ++++-- 13 files changed, 230 insertions(+), 87 deletions(-) diff --git a/crates/dbx-core/src/query_cancel.rs b/crates/dbx-core/src/query_cancel.rs index 29f6ecb67b..b4ae698ff6 100644 --- a/crates/dbx-core/src/query_cancel.rs +++ b/crates/dbx-core/src/query_cancel.rs @@ -10,13 +10,13 @@ pub struct RunningQueries { impl RunningQueries { pub fn register(&self, execution_id: String) -> RegisteredQuery { let token = CancellationToken::new(); - self.inner.lock().expect("running query registry poisoned").insert(execution_id.clone(), token.clone()); + self.inner.lock().unwrap_or_else(|e| e.into_inner()).insert(execution_id.clone(), token.clone()); RegisteredQuery { execution_id, token, running_queries: self.clone() } } pub fn cancel(&self, execution_id: &str) -> bool { - let token = self.inner.lock().expect("running query registry poisoned").get(execution_id).cloned(); + let token = self.inner.lock().unwrap_or_else(|e| e.into_inner()).get(execution_id).cloned(); if let Some(token) = token { token.cancel(); @@ -28,11 +28,11 @@ impl RunningQueries { #[cfg(test)] pub fn has(&self, execution_id: &str) -> bool { - self.inner.lock().expect("running query registry poisoned").contains_key(execution_id) + self.inner.lock().unwrap_or_else(|e| e.into_inner()).contains_key(execution_id) } fn remove(&self, execution_id: &str) { - self.inner.lock().expect("running query registry poisoned").remove(execution_id); + self.inner.lock().unwrap_or_else(|e| e.into_inner()).remove(execution_id); } } diff --git a/src-web/src/main.rs b/src-web/src/main.rs index 8a4f6d06e4..7f818df7a5 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; +use axum::extract::DefaultBodyLimit; use axum::middleware; use axum::routing::{delete, get, post}; use axum::Router; @@ -166,7 +167,11 @@ async fn main() { .with_state(web_state.clone()); // Build app - let mut app = Router::new().nest("/api", api).layer(tower_http::trace::TraceLayer::new_for_http()).layer(cors); + let mut app = Router::new() + .nest("/api", api) + .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) + .layer(tower_http::trace::TraceLayer::new_for_http()) + .layer(cors); // Static file serving if let Ok(static_dir) = std::env::var("DBX_STATIC_DIR") { diff --git a/src-web/src/routes/sql_file.rs b/src-web/src/routes/sql_file.rs index a3a71a73e3..9b3720fd30 100644 --- a/src-web/src/routes/sql_file.rs +++ b/src-web/src/routes/sql_file.rs @@ -75,7 +75,53 @@ pub async fn execute_sql_file( let app = state.app.clone(); let state_clone = state.clone(); + let file_path = std::path::Path::new(&req.file_path); + if !file_path.is_absolute() { + return Err(AppError("File path must be absolute".to_string())); + } + if req.file_path.contains("..") { + return Err(AppError("File path must not contain '..'".to_string())); + } + tokio::spawn(async move { + match std::fs::metadata(&req.file_path) { + Ok(meta) if meta.len() > 200 * 1024 * 1024 => { + let progress = dbx_core::sql::SqlFileProgress { + execution_id: req.execution_id.clone(), + status: dbx_core::sql::SqlFileStatus::Error, + statement_index: 0, + success_count: 0, + failure_count: 0, + affected_rows: 0, + elapsed_ms: 0, + statement_summary: String::new(), + error: Some(format!("File too large: {} bytes (max {} bytes)", meta.len(), 200 * 1024 * 1024)), + }; + if let Ok(json) = serde_json::to_string(&progress) { + let _ = tx.send(json); + } + return; + } + Err(e) => { + let progress = dbx_core::sql::SqlFileProgress { + execution_id: req.execution_id.clone(), + status: dbx_core::sql::SqlFileStatus::Error, + statement_index: 0, + success_count: 0, + failure_count: 0, + affected_rows: 0, + elapsed_ms: 0, + statement_summary: String::new(), + error: Some(e.to_string()), + }; + if let Ok(json) = serde_json::to_string(&progress) { + let _ = tx.send(json); + } + return; + } + _ => {} + } + let file_content = match std::fs::read_to_string(&req.file_path) { Ok(c) => c, Err(e) => { @@ -196,7 +242,7 @@ pub async fn execute_sql_file( let _ = tx.send(json); } - state_clone.sse_channels.write().await.remove(&req.execution_id); + state_clone.remove_sse_channel(&req.execution_id).await; }); Ok(Json(serde_json::json!({ "executionId": execution_id }))) diff --git a/src-web/src/routes/table_import.rs b/src-web/src/routes/table_import.rs index 8187fca07e..ad9b4c96ef 100644 --- a/src-web/src/routes/table_import.rs +++ b/src-web/src/routes/table_import.rs @@ -34,12 +34,17 @@ pub async fn preview_import( let file_name = field.file_name().unwrap_or("upload.csv").to_string(); let data = field.bytes().await.map_err(|e| AppError(e.to_string()))?; + if data.len() > 100 * 1024 * 1024 { + return Err(AppError(format!("File too large: {} bytes (max {} bytes)", data.len(), 100 * 1024 * 1024))); + } + let file_path = tmp_dir.join(&file_name); std::fs::write(&file_path, &data).map_err(|e| AppError(e.to_string()))?; let file_path_str = file_path.to_string_lossy().to_string(); - let preview = table_import::preview_table_import_file_core(&file_path_str).map_err(AppError)?; - + let preview = table_import::preview_table_import_file_core(&file_path_str); + let _ = std::fs::remove_file(&file_path); + let preview = preview.map_err(AppError)?; return Ok(Json(serde_json::to_value(preview).map_err(|e| AppError(e.to_string()))?)); } diff --git a/src-web/src/routes/transfer.rs b/src-web/src/routes/transfer.rs index f3dbddb48a..e03673034f 100644 --- a/src-web/src/routes/transfer.rs +++ b/src-web/src/routes/transfer.rs @@ -139,8 +139,7 @@ pub async fn start_transfer( let _ = tx.send(json); } - // Clean up channel - state_clone.sse_channels.write().await.remove(&req.transfer_id); + state_clone.remove_sse_channel(&req.transfer_id).await; }); Ok(Json(serde_json::json!({ "transferId": transfer_id }))) diff --git a/src-web/src/state.rs b/src-web/src/state.rs index 5981c64d7b..ee0aeabd75 100644 --- a/src-web/src/state.rs +++ b/src-web/src/state.rs @@ -17,3 +17,9 @@ pub struct WebState { pub sse_channels: RwLock>>, pub login_rate_limit: Mutex, } + +impl WebState { + pub async fn remove_sse_channel(&self, id: &str) { + self.sse_channels.write().await.remove(id); + } +} diff --git a/src/App.vue b/src/App.vue index 48e1e74ef7..58fb217c51 100644 --- a/src/App.vue +++ b/src/App.vue @@ -128,7 +128,7 @@ const { getDatabaseOptions } = useDatabaseOptions(); const { openLineageTarget, openDatabaseSearchTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs); const { onExecuteSql, onReloadData, onPaginate, onSort } = useDataGridActions(activeTab); -const { setupTauriListeners } = useTauriEvents({ openTableTarget }); +const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openTableTarget }); const appVersion = ref(""); const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic"); @@ -494,6 +494,7 @@ onMounted(async () => { }); onUnmounted(() => { + cleanupTauriListeners(); window.removeEventListener("keydown", handleKeydown, true); document.removeEventListener("contextmenu", handleContextMenu); }); diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 7064a1a341..b061678b2e 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -219,6 +219,9 @@ const sortCol = ref(null); const sortColIndex = ref(null); const sortDir = ref<"asc" | "desc">("asc"); const searchText = ref(""); +const deferredClientSearchText = ref(""); +let _searchTimer: ReturnType | undefined; + const searchSuggestions = ref([]); const suggestionIndex = ref(-1); const searchInputRef = ref(); @@ -279,9 +282,18 @@ function rowMatchesLocalColumnFilters(data: CellValue[]): boolean { } const localFilteredRows = computed(() => { - const rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex })); - if (!hasLocalColumnFilters.value) return rows; - return rows.filter(({ row, sourceIndex }) => rowMatchesLocalColumnFilters(rowDataWithChanges(row, sourceIndex))); + const rows = props.result.rows; + const indices: number[] = []; + if (!hasLocalColumnFilters.value) { + for (let i = 0; i < rows.length; i++) indices.push(i); + return indices; + } + for (let i = 0; i < rows.length; i++) { + if (rowMatchesLocalColumnFilters(rowDataWithChanges(rows[i], i))) { + indices.push(i); + } + } + return indices; }); function buildLocalFilterOptions(columnIndex: number) { @@ -838,6 +850,17 @@ const isFullPage = computed(() => props.result.rows.length >= pageSize.value); const isResultsContext = computed(() => props.context === "results"); const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value); const clientSearchText = computed(() => (searchText.value.trim() ? searchText.value : "")); +watch(clientSearchText, (value) => { + clearTimeout(_searchTimer); + const q = value.trim().toLowerCase(); + if (!q) { + deferredClientSearchText.value = ""; + return; + } + _searchTimer = setTimeout(() => { + deferredClientSearchText.value = q; + }, 150); +}); function currentWhereInput(): string | undefined { return whereFilterInput.value.trim() || undefined; @@ -936,15 +959,16 @@ function onToolbarRollback() { } const sortedRows = computed(() => { - let rows = localFilteredRows.value; - if (clientSearchText.value) { - const q = clientSearchText.value.toLowerCase(); - rows = rows.filter(({ row, sourceIndex }) => { - const data = rowDataWithChanges(row, sourceIndex); + let indices = localFilteredRows.value; + const q = deferredClientSearchText.value; + if (q) { + const rows = props.result.rows; + indices = indices.filter((sourceIndex) => { + const data = rowDataWithChanges(rows[sourceIndex], sourceIndex); return data.some((cell) => cell !== null && String(cell).toLowerCase().includes(q)); }); } - return rows; + return indices; }); function rowDataWithChanges(row: CellValue[], sourceIndex: number): CellValue[] { @@ -965,7 +989,9 @@ interface RowItem { const displayItems = computed(() => { const cols = props.result.columns; - const items: RowItem[] = sortedRows.value.map(({ row, sourceIndex }) => { + const rows = props.result.rows; + const items: RowItem[] = sortedRows.value.map((sourceIndex) => { + const row = rows[sourceIndex]; const dirty = dirtyRows.value.get(sourceIndex); const data = rowDataWithChanges(row, sourceIndex); const isDirtyCol = row.map((_, colIdx) => dirty?.has(colIdx) ?? false); @@ -989,7 +1015,7 @@ const displayItems = computed(() => { }); const hasVisibleRows = computed(() => displayItems.value.length > 0); const hasActiveFilter = computed( - () => !!clientSearchText.value || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value, + () => !!deferredClientSearchText.value || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value, ); const totalFilterableRowCount = computed(() => props.result.rows.length + newRows.value.length); const emptyTitle = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRows") : t("grid.noRows"))); @@ -2130,6 +2156,7 @@ onUnmounted(() => { if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame); onDdlResizeEnd(); finishCellSelection(); + clearTimeout(_searchTimer); }); const SQL_KEYWORDS = diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 6b1bf5b1fc..b7e5bd5318 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -1112,52 +1112,51 @@ function openFieldLineage() { } const canExpand = !leafTypes.has(props.node.type); +const nodeConfig = computed(() => + props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined, +); const canPin = computed(() => pinnableTypes.has(props.node.type)); const canOpenSqlFileExecution = computed(() => { - const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return !!config && !SQL_FILE_UNSUPPORTED_TYPES.has(config.db_type); + return !!nodeConfig.value && !SQL_FILE_UNSUPPORTED_TYPES.has(nodeConfig.value.db_type); }); const canOpenDiagram = computed(() => { - const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return !!props.node.database && !!config && DIAGRAM_SUPPORTED_TYPES.has(config.db_type); + return !!props.node.database && !!nodeConfig.value && DIAGRAM_SUPPORTED_TYPES.has(nodeConfig.value.db_type); }); const canOpenDatabaseSearch = computed(() => { - const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return !!props.node.database && !!config && DATABASE_SEARCH_SUPPORTED_TYPES.has(config.db_type); + return !!props.node.database && !!nodeConfig.value && DATABASE_SEARCH_SUPPORTED_TYPES.has(nodeConfig.value.db_type); }); const canOpenObjectBrowser = computed(() => { - const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; return ( (props.node.type === "database" || props.node.type === "schema" || props.node.type === "object-browser") && - !!config && - config.db_type !== "redis" && - config.db_type !== "mongodb" && - config.db_type !== "elasticsearch" + !!nodeConfig.value && + nodeConfig.value.db_type !== "redis" && + nodeConfig.value.db_type !== "mongodb" && + nodeConfig.value.db_type !== "elasticsearch" ); }); const canOpenTableImport = computed(() => { - const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; - return ( - props.node.type === "table" && !!props.node.database && !!config && TABLE_IMPORT_SUPPORTED_TYPES.has(config.db_type) - ); -}); -const canOpenStructureEditor = computed(() => { - const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; return ( props.node.type === "table" && !!props.node.database && - !!config && - TABLE_STRUCTURE_SUPPORTED_TYPES.has(config.db_type) + !!nodeConfig.value && + TABLE_IMPORT_SUPPORTED_TYPES.has(nodeConfig.value.db_type) + ); +}); +const canOpenStructureEditor = computed(() => { + return ( + props.node.type === "table" && + !!props.node.database && + !!nodeConfig.value && + TABLE_STRUCTURE_SUPPORTED_TYPES.has(nodeConfig.value.db_type) ); }); const canOpenFieldLineage = computed(() => { - const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; return ( props.node.type === "column" && !!props.node.database && !!props.node.tableName && - !!config && - FIELD_LINEAGE_SUPPORTED_TYPES.has(config.db_type) + !!nodeConfig.value && + FIELD_LINEAGE_SUPPORTED_TYPES.has(nodeConfig.value.db_type) ); }); const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id)); diff --git a/src/composables/useTauriEvents.ts b/src/composables/useTauriEvents.ts index 9dc62c3993..eea207bb84 100644 --- a/src/composables/useTauriEvents.ts +++ b/src/composables/useTauriEvents.ts @@ -5,6 +5,7 @@ import type { NavigationTarget } from "@/composables/useNavigationTargets"; export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarget) => Promise }) { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); + const unlistenHandles: Array<() => void> = []; function setupTauriListeners() { import("@tauri-apps/api/event") @@ -12,53 +13,67 @@ export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarge listen<{ connection_id: string; database: string; schema?: string; table: string }>( "mcp-open-table", async (event) => { - const { connection_id, database, schema, table } = event.payload; + try { + const { connection_id, database, schema, table } = event.payload; + if (!connectionStore.connections.length) await connectionStore.initFromDisk(); + const config = connectionStore.getConfig(connection_id); + if (!config) return; + connectionStore.activeConnectionId = connection_id; + await connectionStore.ensureConnected(connection_id); + if (config.db_type === "redis") { + queryStore.createTab(connection_id, database || "0", `db${database || "0"}`, "redis"); + } else if (config.db_type === "mongodb") { + queryStore.createTab(connection_id, database, table, "mongo"); + } else { + deps.openTableTarget({ connectionId: connection_id, database, schema, tableName: table }); + } + import("@tauri-apps/api/window").then(({ getCurrentWindow }) => + getCurrentWindow() + .setFocus() + .catch(() => {}), + ); + } catch (e) { + console.error("[DBX] mcp-open-table error:", e); + } + }, + ).then((unlisten) => unlistenHandles.push(unlisten)); + + listen("mcp-reload-connections", async () => { + try { + await connectionStore.initFromDisk(); + } catch (e) { + console.error("[DBX] mcp-reload-connections error:", e); + } + }).then((unlisten) => unlistenHandles.push(unlisten)); + + listen<{ connection_id: string; database: string; sql: string }>("mcp-execute-query", async (event) => { + try { + const { connection_id, database, sql } = event.payload; if (!connectionStore.connections.length) await connectionStore.initFromDisk(); const config = connectionStore.getConfig(connection_id); if (!config) return; connectionStore.activeConnectionId = connection_id; await connectionStore.ensureConnected(connection_id); - if (config.db_type === "redis") { - queryStore.createTab(connection_id, database || "0", `db${database || "0"}`, "redis"); - } else if (config.db_type === "mongodb") { - queryStore.createTab(connection_id, database, table, "mongo"); - } else { - deps.openTableTarget({ - connectionId: connection_id, - database, - schema, - tableName: table, - }); - } + const tabId = queryStore.createTab(connection_id, database, undefined, "query"); + queryStore.updateSql(tabId, sql); + await queryStore.executeTabSql(tabId, sql); import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow() .setFocus() .catch(() => {}), ); - }, - ); - listen("mcp-reload-connections", async () => { - await connectionStore.initFromDisk(); - }); - listen<{ connection_id: string; database: string; sql: string }>("mcp-execute-query", async (event) => { - const { connection_id, database, sql } = event.payload; - if (!connectionStore.connections.length) await connectionStore.initFromDisk(); - const config = connectionStore.getConfig(connection_id); - if (!config) return; - connectionStore.activeConnectionId = connection_id; - await connectionStore.ensureConnected(connection_id); - const tabId = queryStore.createTab(connection_id, database, undefined, "query"); - queryStore.updateSql(tabId, sql); - await queryStore.executeTabSql(tabId, sql); - import("@tauri-apps/api/window").then(({ getCurrentWindow }) => - getCurrentWindow() - .setFocus() - .catch(() => {}), - ); - }); + } catch (e) { + console.error("[DBX] mcp-execute-query error:", e); + } + }).then((unlisten) => unlistenHandles.push(unlisten)); }) .catch(() => {}); } - return { setupTauriListeners }; + function cleanupTauriListeners() { + unlistenHandles.forEach((unlisten) => unlisten()); + unlistenHandles.length = 0; + } + + return { setupTauriListeners, cleanupTauriListeners }; } diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index cb3aa82d23..bd26c0f436 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -984,6 +984,17 @@ export const useConnectionStore = defineStore("connection", () => { return isSchemaAware(getConfig(connectionId)?.db_type); } + const COMPLETION_CACHE_MAX = 50; + + function evictOldestCacheEntries(cache: Record, max: number) { + const keys = Object.keys(cache); + if (keys.length <= max) return; + const toRemove = keys.slice(0, keys.length - max); + for (const key of toRemove) { + delete cache[key]; + } + } + async function listCompletionTables(connectionId: string, database: string): Promise { const cacheKey = `${connectionId}:${database}`; if (completionTablesCache.value[cacheKey]) { @@ -1009,6 +1020,7 @@ export const useConnectionStore = defineStore("connection", () => { }), ); completionTablesCache.value[cacheKey] = tableGroups.flat(); + evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; } @@ -1017,6 +1029,7 @@ export const useConnectionStore = defineStore("connection", () => { name: table.name, type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const), })); + evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; } @@ -1031,6 +1044,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); const querySchema = schema || database; completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table); + evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); } return completionColumnsCache.value[cacheKey].map((column) => ({ diff --git a/src/stores/queryStore.ts b/src/stores/queryStore.ts index 12a8faf2aa..e517184669 100644 --- a/src/stores/queryStore.ts +++ b/src/stores/queryStore.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/utils"; -import { ref, watch } from "vue"; +import { ref, watch, computed } from "vue"; import type { DatabaseType, QueryTab } from "@/types/database"; import { orderPinnedFirst } from "@/lib/pinnedItems"; import { canCancelQueryExecution } from "@/lib/queryExecutionState"; @@ -39,9 +39,25 @@ export const useQueryStore = defineStore("query", () => { const activeTabId = ref(restored.activeTabId); const MAX_CACHED_RESULTS = 10; + const _persistSnapshot = computed(() => + tabs.value.map((t) => ({ + id: t.id, + title: t.title, + connectionId: t.connectionId, + database: t.database, + schema: t.schema, + sql: t.sql, + savedSqlId: t.savedSqlId, + pinned: t.pinned, + mode: t.mode, + objectBrowser: t.objectBrowser, + tableMeta: t.tableMeta, + })), + ); + let _persistTimer: ReturnType | null = null; watch( - [tabs, activeTabId], + [_persistSnapshot, activeTabId], () => { if (_persistTimer) clearTimeout(_persistTimer); _persistTimer = setTimeout(() => { @@ -49,7 +65,7 @@ export const useQueryStore = defineStore("query", () => { _persistTimer = null; }, 300); }, - { deep: true }, + { flush: "post" }, ); function findTabByTitle(connectionId: string, database: string, title: string) { @@ -123,6 +139,8 @@ export const useQueryStore = defineStore("query", () => { if (idx < 0) return; if (tabs.value[idx].isExecuting) void cancelTabExecution(id); if (tabs.value[idx].isExplaining) void cancelTabExplain(id); + tabs.value[idx].result = undefined; + tabs.value[idx].results = undefined; tabs.value.splice(idx, 1); if (activeTabId.value === id) { activeTabId.value = tabs.value[Math.min(idx, tabs.value.length - 1)]?.id ?? null; diff --git a/src/stores/savedSqlStore.ts b/src/stores/savedSqlStore.ts index 5258f35a24..80b6366475 100644 --- a/src/stores/savedSqlStore.ts +++ b/src/stores/savedSqlStore.ts @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import { computed, ref } from "vue"; +import { ref } from "vue"; import { uuid } from "@/lib/utils"; import * as api from "@/lib/api"; import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "@/types/database"; @@ -34,13 +34,15 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const files = ref([]); const isLoaded = ref(false); - const version = computed( - () => `${folders.value.length}:${files.value.length}:${files.value.map((f) => f.updatedAt).join("|")}`, - ); + const version = ref(0); + function bumpVersion() { + version.value++; + } function applyLibrary(library: SavedSqlLibrary) { folders.value = library.folders; files.value = library.files; + bumpVersion(); } async function migrateLegacyLocalStorage() { @@ -89,6 +91,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { }; const saved = await api.saveSavedSqlFolder(folder); folders.value = [...folders.value.filter((item) => item.id !== saved.id), saved]; + bumpVersion(); return saved; } @@ -97,12 +100,14 @@ export const useSavedSqlStore = defineStore("savedSql", () => { if (!existing) return; const saved = await api.saveSavedSqlFolder({ ...existing, name, updatedAt: nowIso() }); folders.value = folders.value.map((folder) => (folder.id === id ? saved : folder)); + bumpVersion(); } async function deleteFolder(id: string) { await api.deleteSavedSqlFolder(id); folders.value = folders.value.filter((folder) => folder.id !== id); files.value = files.value.filter((file) => file.folderId !== id); + bumpVersion(); } async function saveFile(input: { @@ -139,6 +144,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { }; const saved = await api.saveSavedSqlFile(file); files.value = [...files.value.filter((item) => item.id !== saved.id), saved]; + bumpVersion(); return saved; } @@ -147,11 +153,13 @@ export const useSavedSqlStore = defineStore("savedSql", () => { if (!existing) return; const saved = await api.saveSavedSqlFile({ ...existing, name, updatedAt: nowIso() }); files.value = files.value.map((file) => (file.id === id ? saved : file)); + bumpVersion(); } async function deleteFile(id: string) { await api.deleteSavedSqlFile(id); files.value = files.value.filter((file) => file.id !== id); + bumpVersion(); } return {