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
This commit is contained in:
t8y2
2026-05-11 00:18:09 +08:00
parent 741783445d
commit f456ddd979
13 changed files with 230 additions and 87 deletions
+14
View File
@@ -984,6 +984,17 @@ export const useConnectionStore = defineStore("connection", () => {
return isSchemaAware(getConfig(connectionId)?.db_type);
}
const COMPLETION_CACHE_MAX = 50;
function evictOldestCacheEntries(cache: Record<string, unknown>, 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<SqlCompletionTable[]> {
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) => ({
+21 -3
View File
@@ -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<string | null>(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<typeof setTimeout> | 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;
+12 -4
View File
@@ -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<SavedSqlFile[]>([]);
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 {