feat(saved-query): 支持已存查询后端持久化与连接重绑

- 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法
- 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段
- 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配
- 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接
- 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock
- 补充后端与前端迁移回归测试
This commit is contained in:
Syngnat
2026-06-15 12:20:57 +08:00
parent 0b9f0448c8
commit eca9601ab0
20 changed files with 1870 additions and 66 deletions

View File

@@ -58,6 +58,7 @@ import {
mergeSecurityUpdateStatusWithLegacySource,
startSecurityUpdateFromBootstrap,
} from './utils/secureConfigBootstrap';
import { bootstrapSavedQueries } from './utils/savedQueryPersistence';
import {
LEGACY_PERSIST_KEY,
hasLegacyMigratableSensitiveItems,
@@ -225,6 +226,7 @@ function App() {
const setGlobalProxy = useStore(state => state.setGlobalProxy);
const replaceConnections = useStore(state => state.replaceConnections);
const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy);
const replaceSavedQueries = useStore(state => state.replaceSavedQueries);
const shortcutOptions = useStore(state => state.shortcutOptions);
const updateShortcut = useStore(state => state.updateShortcut);
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
@@ -480,6 +482,33 @@ function App() {
};
}, [isStoreHydrated]);
useEffect(() => {
if (!isStoreHydrated) {
return;
}
let cancelled = false;
const loadSavedQueries = async () => {
try {
await bootstrapSavedQueries({
backend: (window as any).go?.app?.App,
replaceSavedQueries: (queries) => {
if (!cancelled) {
replaceSavedQueries(queries);
}
},
});
} catch (err) {
console.warn('Failed to bootstrap saved queries', err);
}
};
void loadSavedQueries();
return () => {
cancelled = true;
};
}, [isStoreHydrated, replaceSavedQueries]);
const normalizeSecurityUpdateStatus = useCallback((status?: Partial<SecurityUpdateStatus> | null): SecurityUpdateStatus => {
const fallback = createEmptySecurityUpdateStatus();
return {

View File

@@ -458,6 +458,7 @@ describe('QueryEditor external SQL save', () => {
storeState.addTab.mockReset();
storeState.setActiveContext.mockReset();
storeState.saveQuery.mockReset();
storeState.saveQuery.mockImplementation(async (query: SavedQuery) => query);
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
storeState.queryOptions = {

View File

@@ -5192,7 +5192,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return rawTitle;
};
const persistQuery = (payload: { id: string; name: string; createdAt?: number }) => {
const persistQuery = async (payload: { id: string; name: string; createdAt?: number }) => {
const sql = getCurrentQuery();
const saved = {
id: payload.id,
@@ -5202,16 +5202,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
dbName: currentDb || tab.dbName || '',
createdAt: payload.createdAt ?? Date.now(),
};
saveQuery(saved);
const persisted = await saveQuery(saved);
addTab({
...tab,
title: payload.name,
title: persisted.name,
query: sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
savedQueryId: payload.id,
savedQueryId: persisted.id,
});
return saved;
return persisted;
};
const openSaveQueryModal = (mode: 'save' | 'rename') => {
@@ -5254,8 +5254,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
const saveName = existed?.name || resolveDefaultQueryName();
persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt });
message.success('查询已保存!');
try {
await persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt });
message.success('查询已保存!');
} catch (error) {
message.error('保存查询失败: ' + (error instanceof Error ? error.message : String(error)));
}
};
const handleRenameQuery = () => {
@@ -5384,7 +5388,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`;
persistQuery({
await persistQuery({
id: nextSavedId,
name: String(values.name || '').trim() || '未命名查询',
createdAt: existed?.createdAt,
@@ -5392,6 +5396,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
message.success(saveModalMode === 'rename' ? '查询已重命名!' : '查询已保存!');
setIsSaveModalOpen(false);
} catch (e) {
if (e instanceof Error) {
message.error('保存查询失败: ' + e.message);
}
}
};

View File

@@ -226,7 +226,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -423,6 +423,11 @@ const Sidebar: React.FC<{
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]);
const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]);
const unmatchedSavedQueries = useMemo(
() => savedQueries.filter((query) => query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId)),
[connectionIdSet, savedQueries],
);
const v2RailConnectionGroups = useMemo(
() => buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder),
[connections, connectionTags, sidebarRootOrder],
@@ -838,10 +843,28 @@ const Sidebar: React.FC<{
orderedNodes.push(...Array.from(tagNodesById.values()));
orderedNodes.push(...Array.from(ungroupedNodesById.values()));
if (unmatchedSavedQueries.length > 0) {
orderedNodes.push({
title: '未匹配已存查询',
key: 'unmatched-saved-queries',
icon: <FolderOpenOutlined />,
type: 'unmatched-saved-queries',
isLeaf: false,
selectable: false,
children: unmatchedSavedQueries.map((query) => ({
title: query.name,
key: query.id,
icon: <FileTextOutlined />,
type: 'saved-query',
dataRef: query,
isLeaf: true,
})),
});
}
const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root');
return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes;
});
}, [connections, connectionTags, sidebarRootOrder]);
}, [connections, connectionTags, sidebarRootOrder, unmatchedSavedQueries]);
const handleDuplicateConnection = async (conn: SavedConnection) => {
if (!conn?.id) return;
@@ -2505,7 +2528,7 @@ const Sidebar: React.FC<{
}, []);
const onLoadData = async ({ key, children, dataRef, type }: any) => {
if (type === 'tag') return;
if (type === 'tag' || type === 'unmatched-saved-queries') return;
if (hasSidebarLazyChildren(children)) return;
if (type === 'connection') {
@@ -4642,7 +4665,7 @@ const Sidebar: React.FC<{
return;
}
saveQuery({
const persisted = await saveQuery({
...renameSavedQueryTarget,
name: nextName,
});
@@ -4651,8 +4674,8 @@ const Sidebar: React.FC<{
if (node.key === renameSavedQueryTarget.id) {
return {
...node,
title: nextName,
dataRef: { ...(node.dataRef || renameSavedQueryTarget), name: nextName },
title: persisted.name,
dataRef: { ...(node.dataRef || renameSavedQueryTarget), ...persisted },
};
}
return node.children ? { ...node, children: updateSavedQueryNode(node.children) } : node;
@@ -4662,16 +4685,51 @@ const Sidebar: React.FC<{
setTreeData(nextTreeData);
tabs
.filter(tab => tab.type === 'query' && (tab.savedQueryId === renameSavedQueryTarget.id || tab.id === renameSavedQueryTarget.id))
.forEach(tab => updateQueryTabDraft(tab.id, { title: nextName }));
.forEach(tab => updateQueryTabDraft(tab.id, { title: persisted.name }));
message.success('查询已重命名');
setIsRenameSavedQueryModalOpen(false);
setRenameSavedQueryTarget(null);
renameSavedQueryForm.resetFields();
} catch (e) {
// Validate failed
if (e instanceof Error) {
message.error('重命名查询失败: ' + e.message);
}
}
};
const isSavedQueryUnmatched = useCallback((query: SavedQuery): boolean => {
return query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId);
}, [connectionIdSet]);
const handleRebindSavedQuery = useCallback(async (query: SavedQuery, target: SavedConnection) => {
if (!query?.id || !target?.id) return;
try {
const backendApp = (window as any).go?.app?.App;
let persisted: SavedQuery;
if (typeof backendApp?.RebindSavedQuery === 'function') {
persisted = await backendApp.RebindSavedQuery(query.id, target.id);
await saveQuery(persisted);
} else {
persisted = await saveQuery({
...query,
connectionId: target.id,
originalConnectionId: query.originalConnectionId || query.connectionId,
bindingStatus: 'active',
});
}
message.success(`查询已绑定到 ${target.name || target.id}`);
tabs
.filter(tab => tab.type === 'query' && (tab.savedQueryId === query.id || tab.id === query.id))
.forEach(tab => updateQueryTabDraft(tab.id, {
title: persisted.name,
connectionId: persisted.connectionId,
dbName: persisted.dbName,
}));
} catch (error) {
message.error('绑定查询失败: ' + (error instanceof Error ? error.message : String(error)));
}
}, [saveQuery, tabs, updateQueryTabDraft]);
// --- 函数/存储过程操作 ---
const openRoutineDefinition = (node: any) => {
const { routineName, routineType, dbName, id } = node.dataRef;
@@ -7436,7 +7494,24 @@ const Sidebar: React.FC<{
// 已存查询节点的右键菜单
if (node.type === 'saved-query') {
const q = node.dataRef;
const q = node.dataRef as SavedQuery;
const rebindMenuItems: MenuProps['items'] = isSavedQueryUnmatched(q)
? [
{
key: 'rebind-query',
label: '绑定到连接',
icon: <LinkOutlined />,
disabled: connections.length === 0,
children: connections.length > 0
? connections.map((conn) => ({
key: `rebind-query-${conn.id}`,
label: conn.name || conn.id,
onClick: () => void handleRebindSavedQuery(q, conn),
}))
: undefined,
},
]
: [];
return [
{
key: 'open-query',
@@ -7454,6 +7529,7 @@ const Sidebar: React.FC<{
});
}
},
...rebindMenuItems,
{ type: 'divider' },
{
key: 'rename-query',
@@ -7471,8 +7547,13 @@ const Sidebar: React.FC<{
title: '确认删除',
content: `确定要删除已保存的查询 "${q.name}" 吗?此操作不可恢复。`,
okButtonProps: { danger: true },
onOk: () => {
deleteQuery(q.id);
onOk: async () => {
try {
await deleteQuery(q.id);
} catch (e) {
message.error('删除查询失败: ' + (e instanceof Error ? e.message : String(e)));
throw e;
}
// 从树中移除节点
const removeNode = (list: TreeNode[]): TreeNode[] =>
list

View File

@@ -21,6 +21,7 @@ export type SidebarTreeNodeType =
| 'v2-table-section'
| 'queries-folder'
| 'saved-query'
| 'unmatched-saved-queries'
| 'external-sql-root'
| 'external-sql-directory'
| 'external-sql-folder'

View File

@@ -24,6 +24,7 @@ const resolveDevHarnessMode = (): string => {
if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window as any).go?.aiservice?.Service)) {
const mockConnections: any[] = [];
const mockSavedQueries: any[] = [];
const mockConnectionSecrets = new Map<string, any>();
const mockProviders: any[] = [];
const mockProviderSecrets = new Map<string, string>();
@@ -154,6 +155,29 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
return cloneBrowserMockValue(view);
};
const saveMockQuery = (input: any) => {
const nextId = String(input?.id || `saved-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const view = {
id: nextId,
name: String(input?.name || '未命名查询'),
sql: String(input?.sql || ''),
connectionId: String(input?.connectionId || ''),
dbName: String(input?.dbName || ''),
createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : Date.now(),
connectionFingerprint: typeof input?.connectionFingerprint === 'string' ? input.connectionFingerprint : undefined,
fingerprintVersion: typeof input?.fingerprintVersion === 'string' ? input.fingerprintVersion : undefined,
bindingStatus: typeof input?.bindingStatus === 'string' ? input.bindingStatus : undefined,
originalConnectionId: typeof input?.originalConnectionId === 'string' ? input.originalConnectionId : undefined,
};
const index = mockSavedQueries.findIndex((item) => item.id === nextId);
if (index >= 0) {
mockSavedQueries[index] = view;
} else {
mockSavedQueries.push(view);
}
return cloneBrowserMockValue(view);
};
const saveMockGlobalProxy = (input: any) => {
const nextPassword = String(input?.password ?? '');
mockGlobalProxy = {
@@ -251,9 +275,30 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
GetTableData: async () => ({ columns: [], rows: [], total: 0 }),
GetTableColumns: async () => [],
ExecuteQuery: async () => ({ columns: [], rows: [], time: 0 }),
GetSavedQueries: async () => [],
SaveQuery: async () => null,
DeleteQuery: async () => null,
GetSavedQueries: async () => cloneBrowserMockValue(mockSavedQueries),
SaveQuery: async (input: any) => saveMockQuery(input),
ImportSavedQueries: async (payload: any) => {
const items = Array.isArray(payload) ? payload : payload?.queries;
(Array.isArray(items) ? items : []).forEach((item) => saveMockQuery(item));
return cloneBrowserMockValue(mockSavedQueries);
},
DeleteQuery: async (id: string) => {
const index = mockSavedQueries.findIndex((item) => item.id === id);
if (index >= 0) {
mockSavedQueries.splice(index, 1);
}
return null;
},
RebindSavedQuery: async (id: string, connectionId: string) => {
const existing = mockSavedQueries.find((item) => item.id === id);
if (!existing) throw new Error('saved query not found');
return saveMockQuery({
...existing,
connectionId,
originalConnectionId: existing.originalConnectionId || existing.connectionId,
bindingStatus: 'active',
});
},
GetAppInfo: async () => ({}),
GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }),
CheckForUpdates: async () => ({ success: false }),

View File

@@ -52,6 +52,13 @@ import {
sanitizeTabDisplaySettings,
type TabDisplaySettings,
} from "./utils/tabDisplay";
import {
captureLegacySavedQueriesSnapshot,
deleteSavedQueryFromBackend,
sanitizeSavedQueries,
saveSavedQueryToBackend,
type SavedQueryBackend,
} from "./utils/savedQueryPersistence";
export interface AppearanceSettings extends DataGridDisplaySettings {
uiVersion: "legacy" | "v2";
@@ -111,7 +118,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 10;
const PERSIST_VERSION = 11;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const PERSIST_WRITE_DEBOUNCE_MS = 160;
const MAX_PERSISTED_QUERY_TABS = 20;
@@ -138,6 +145,13 @@ const isFrontendTestRuntime = (): boolean => {
return env.MODE === "test" || env.VITEST === true || env.VITEST === "true";
};
const resolveSavedQueryBackend = (): SavedQueryBackend | undefined => {
if (typeof window === "undefined") {
return undefined;
}
return (window as unknown as { go?: { app?: { App?: SavedQueryBackend } } }).go?.app?.App;
};
const createDebouncedPersistStorage = <S>(
getStorage: () => StateStorage,
debounceMs = PERSIST_WRITE_DEBOUNCE_MS,
@@ -1282,8 +1296,9 @@ interface AppState {
context: { connectionId: string; dbName: string } | null,
) => void;
saveQuery: (query: SavedQuery) => void;
deleteQuery: (id: string) => void;
replaceSavedQueries: (queries: SavedQuery[]) => void;
saveQuery: (query: SavedQuery) => Promise<SavedQuery>;
deleteQuery: (id: string) => Promise<void>;
saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void;
deleteExternalSQLDirectory: (id: string) => void;
@@ -1387,33 +1402,6 @@ interface AppState {
setAIActiveSessionId: (sessionId: string | null) => void;
}
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
if (!Array.isArray(value)) return [];
const result: SavedQuery[] = [];
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
const id =
toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
const sql = toTrimmedString(raw.sql);
const connectionId = toTrimmedString(raw.connectionId);
const dbName = toTrimmedString(raw.dbName);
if (!sql || !connectionId || !dbName) return;
result.push({
id,
name:
toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`,
sql,
connectionId,
dbName,
createdAt: Number.isFinite(Number(raw.createdAt))
? Number(raw.createdAt)
: Date.now(),
});
});
return result;
};
const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => {
if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS;
const result: SqlSnippet[] = [];
@@ -2745,24 +2733,34 @@ export const useStore = create<AppState>()(
})),
setActiveContext: (context) => set({ activeContext: context }),
saveQuery: (query) =>
replaceSavedQueries: (queries) =>
set({ savedQueries: sanitizeSavedQueries(queries) }),
saveQuery: async (query) => {
const saved = await saveSavedQueryToBackend(
resolveSavedQueryBackend(),
query,
);
set((state) => {
// If query with same ID exists, update it
const existing = state.savedQueries.find((q) => q.id === query.id);
const existing = state.savedQueries.find((q) => q.id === saved.id);
if (existing) {
return {
savedQueries: state.savedQueries.map((q) =>
q.id === query.id ? query : q,
q.id === saved.id ? saved : q,
),
};
}
return { savedQueries: [...state.savedQueries, query] };
}),
return { savedQueries: [...state.savedQueries, saved] };
});
return saved;
},
deleteQuery: (id) =>
deleteQuery: async (id) => {
await deleteSavedQueryFromBackend(resolveSavedQueryBackend(), id);
set((state) => ({
savedQueries: state.savedQueries.filter((q) => q.id !== id),
})),
}));
},
saveExternalSQLDirectory: (directory) =>
set((state) => {
@@ -3252,6 +3250,7 @@ export const useStore = create<AppState>()(
const state = unwrapPersistedAppState(
persistedState,
) as Partial<AppState>;
captureLegacySavedQueriesSnapshot(state.savedQueries, state.connections);
const nextState: Partial<AppState> = { ...state };
nextState.connections = sanitizeConnections(state.connections);
const safeTabs = sanitizeQueryTabs(state.tabs);
@@ -3271,7 +3270,7 @@ export const useStore = create<AppState>()(
nextState.connectionTags,
nextState.connections,
);
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
delete nextState.savedQueries;
nextState.externalSQLDirectories = sanitizeExternalSQLDirectories(
state.externalSQLDirectories,
);
@@ -3340,6 +3339,7 @@ export const useStore = create<AppState>()(
const state = unwrapPersistedAppState(
persistedState,
) as Partial<AppState>;
captureLegacySavedQueriesSnapshot(state.savedQueries, state.connections);
const safeTabs = sanitizeQueryTabs(state.tabs);
const persistedConnections =
state.connections === undefined
@@ -3365,7 +3365,7 @@ export const useStore = create<AppState>()(
sidebarRootOrder: persistedSidebarRootOrder,
tabs: safeTabs,
activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs),
savedQueries: sanitizeSavedQueries(state.savedQueries),
savedQueries: currentState.savedQueries,
externalSQLDirectories: sanitizeExternalSQLDirectories(
state.externalSQLDirectories,
),
@@ -3416,7 +3416,6 @@ export const useStore = create<AppState>()(
activeTabId: sanitizeActiveTabId(state.activeTabId, tabs),
connectionTags: state.connectionTags,
sidebarRootOrder: state.sidebarRootOrder,
savedQueries: state.savedQueries,
externalSQLDirectories: state.externalSQLDirectories,
theme: state.theme,
appearance: state.appearance,

View File

@@ -466,6 +466,10 @@ export interface SavedQuery {
connectionId: string;
dbName: string;
createdAt: number;
connectionFingerprint?: string;
fingerprintVersion?: string;
bindingStatus?: "active" | "rebound" | "orphan" | string;
originalConnectionId?: string;
}
export interface SqlSnippet {

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest';
import type { SavedQuery } from '../types';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
import {
bootstrapSavedQueries,
readLegacySavedQueriesFromPayload,
stripLegacySavedQueries,
} from './savedQueryPersistence';
const createMemoryStorage = () => {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value);
},
};
};
describe('saved query persistence', () => {
it('imports legacy localStorage queries into backend and clears the legacy field', async () => {
const storage = createMemoryStorage();
storage.setItem(LEGACY_PERSIST_KEY, JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'app',
password: 'secret',
},
},
],
theme: 'dark',
savedQueries: [
{
id: 'saved-1',
name: 'Orders',
sql: ' select * from orders;\n',
connectionId: 'conn-1',
dbName: 'app',
createdAt: 100,
},
],
},
version: 10,
}));
let backendQueries: SavedQuery[] = [];
const ImportSavedQueries = vi.fn(async (payload: { queries: SavedQuery[] }) => {
backendQueries = payload.queries;
return backendQueries;
});
const GetSavedQueries = vi.fn(async () => backendQueries);
const replaceSavedQueries = vi.fn();
const result = await bootstrapSavedQueries({
storage,
replaceSavedQueries,
backend: {
ImportSavedQueries,
GetSavedQueries,
},
});
expect(result).toEqual({ importedLegacyCount: 1, loadedCount: 1 });
expect(ImportSavedQueries).toHaveBeenCalledWith(expect.objectContaining({
queries: [
expect.objectContaining({
id: 'saved-1',
sql: ' select * from orders;\n',
}),
],
legacyConnections: [
expect.objectContaining({
id: 'conn-1',
config: expect.objectContaining({
host: 'db.local',
}),
}),
],
}));
expect(replaceSavedQueries).toHaveBeenCalledWith([
expect.objectContaining({
id: 'saved-1',
sql: ' select * from orders;\n',
}),
]);
const cleanedPayload = JSON.parse(storage.getItem(LEGACY_PERSIST_KEY) || '{}');
expect(cleanedPayload.state.theme).toBe('dark');
expect(cleanedPayload.state.savedQueries).toBeUndefined();
});
it('reads and strips legacy saved queries without altering other persisted state', () => {
const payload = JSON.stringify({
state: {
savedQueries: [
{
id: 'saved-1',
name: 'Analytics',
sql: '\nselect 1;',
connectionId: 'conn-1',
dbName: 'warehouse',
createdAt: 200,
},
{
id: 'invalid',
name: 'Missing context',
sql: 'select 2;',
},
],
sidebarWidth: 320,
},
});
expect(readLegacySavedQueriesFromPayload(payload)).toEqual([
expect.objectContaining({
id: 'saved-1',
sql: '\nselect 1;',
}),
]);
const stripped = JSON.parse(stripLegacySavedQueries(payload));
expect(stripped.state.savedQueries).toBeUndefined();
expect(stripped.state.sidebarWidth).toBe(320);
});
});

View File

@@ -0,0 +1,283 @@
import type { SavedConnection, SavedQuery } from '../types';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
export interface SavedQueryImportPayload {
queries: SavedQuery[];
legacyConnections?: SavedConnection[];
}
export interface SavedQueryBackend {
GetSavedQueries?: () => Promise<SavedQuery[]>;
SaveQuery?: (query: SavedQuery) => Promise<SavedQuery | null | undefined>;
ImportSavedQueries?: (payload: SavedQueryImportPayload) => Promise<SavedQuery[]>;
DeleteQuery?: (id: string) => Promise<void>;
RebindSavedQuery?: (id: string, connectionId: string) => Promise<SavedQuery>;
}
export interface SavedQueryBootstrapArgs {
backend?: SavedQueryBackend;
replaceSavedQueries: (queries: SavedQuery[]) => void;
storage?: StorageLike;
}
export interface SavedQueryBootstrapResult {
importedLegacyCount: number;
loadedCount: number;
}
let capturedLegacySavedQuerySource: SavedQueryImportPayload = { queries: [] };
const toTrimmedString = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return fallback;
};
const unwrapPersistedAppState = (payload: unknown): Record<string, unknown> => {
if (!payload || typeof payload !== 'object') {
return {};
}
const raw = payload as Record<string, unknown>;
if (raw.state && typeof raw.state === 'object') {
return raw.state as Record<string, unknown>;
}
return raw;
};
const sanitizeSavedQuery = (value: unknown, index: number): SavedQuery | null => {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
const id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
const sql = typeof raw.sql === 'string' ? raw.sql : toTrimmedString(raw.sql);
const connectionId = toTrimmedString(raw.connectionId);
const dbName = toTrimmedString(raw.dbName);
if (!sql.trim() || !connectionId || !dbName) {
return null;
}
const query: SavedQuery = {
id,
name: toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`,
sql,
connectionId,
dbName,
createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(),
};
const connectionFingerprint = toTrimmedString(raw.connectionFingerprint);
const fingerprintVersion = toTrimmedString(raw.fingerprintVersion);
const bindingStatus = toTrimmedString(raw.bindingStatus);
const originalConnectionId = toTrimmedString(raw.originalConnectionId);
if (connectionFingerprint) query.connectionFingerprint = connectionFingerprint;
if (fingerprintVersion) query.fingerprintVersion = fingerprintVersion;
if (bindingStatus) query.bindingStatus = bindingStatus;
if (originalConnectionId) query.originalConnectionId = originalConnectionId;
return query;
};
export const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
if (!Array.isArray(value)) {
return [];
}
const result: SavedQuery[] = [];
const seen = new Set<string>();
value.forEach((item, index) => {
const query = sanitizeSavedQuery(item, index);
if (!query || seen.has(query.id)) {
return;
}
seen.add(query.id);
result.push(query);
});
return result;
};
const mergeSavedQueriesById = (...groups: SavedQuery[][]): SavedQuery[] => {
const result: SavedQuery[] = [];
const indexById = new Map<string, number>();
groups.flat().forEach((query) => {
if (!query.id) {
return;
}
const existingIndex = indexById.get(query.id);
if (existingIndex === undefined) {
indexById.set(query.id, result.length);
result.push(query);
return;
}
result[existingIndex] = query;
});
return result;
};
const sanitizeLegacyConnections = (value: unknown): SavedConnection[] => {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is SavedConnection => !!item && typeof item === 'object');
};
const mergeLegacyConnectionsById = (...groups: SavedConnection[][]): SavedConnection[] => {
const result: SavedConnection[] = [];
const indexById = new Map<string, number>();
groups.flat().forEach((connection) => {
const id = toTrimmedString((connection as { id?: unknown }).id);
if (!id) {
return;
}
const existingIndex = indexById.get(id);
if (existingIndex === undefined) {
indexById.set(id, result.length);
result.push(connection);
return;
}
result[existingIndex] = connection;
});
return result;
};
const mergeSavedQueryImportSources = (...sources: SavedQueryImportPayload[]): SavedQueryImportPayload => {
return {
queries: mergeSavedQueriesById(...sources.map((source) => source.queries || [])),
legacyConnections: mergeLegacyConnectionsById(...sources.map((source) => source.legacyConnections || [])),
};
};
export const captureLegacySavedQueriesSnapshot = (value: unknown, legacyConnections?: unknown): void => {
const nextQueries = sanitizeSavedQueries(value);
if (nextQueries.length === 0) {
return;
}
capturedLegacySavedQuerySource = mergeSavedQueryImportSources(capturedLegacySavedQuerySource, {
queries: nextQueries,
legacyConnections: sanitizeLegacyConnections(legacyConnections),
});
};
export const readLegacySavedQuerySourceFromPayload = (payload: string | null | undefined): SavedQueryImportPayload => {
if (!payload || typeof payload !== 'string') {
return { queries: [] };
}
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const state = unwrapPersistedAppState(parsed);
return {
queries: sanitizeSavedQueries(state.savedQueries),
legacyConnections: sanitizeLegacyConnections(state.connections),
};
} catch {
return { queries: [] };
}
};
export const readLegacySavedQueriesFromPayload = (payload: string | null | undefined): SavedQuery[] => (
readLegacySavedQuerySourceFromPayload(payload).queries
);
export const stripLegacySavedQueries = (payload: string | null | undefined): string => {
if (!payload || typeof payload !== 'string') {
return '';
}
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const state = unwrapPersistedAppState(parsed);
if (state.savedQueries === undefined) {
return payload;
}
delete state.savedQueries;
return JSON.stringify(parsed);
} catch {
return payload;
}
};
const resolveStorage = (storage?: StorageLike): StorageLike | undefined => {
if (storage) {
return storage;
}
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
};
const readLegacySavedQuerySourceFromStorage = (storage?: StorageLike): SavedQueryImportPayload => {
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
return readLegacySavedQuerySourceFromPayload(rawPayload);
};
const cleanupLegacySavedQueriesFromStorage = (storage?: StorageLike): void => {
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const sanitizedPayload = stripLegacySavedQueries(rawPayload);
if (sanitizedPayload && sanitizedPayload !== rawPayload) {
storage?.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
}
};
export const saveSavedQueryToBackend = async (
backend: SavedQueryBackend | undefined,
query: SavedQuery,
): Promise<SavedQuery> => {
const sanitized = sanitizeSavedQuery(query, 0);
if (!sanitized) {
throw new Error('保存查询缺少 SQL、连接或数据库上下文');
}
if (typeof backend?.SaveQuery !== 'function') {
return sanitized;
}
const saved = await backend.SaveQuery(sanitized);
return sanitizeSavedQuery(saved || sanitized, 0) || sanitized;
};
export const deleteSavedQueryFromBackend = async (
backend: SavedQueryBackend | undefined,
id: string,
): Promise<void> => {
if (typeof backend?.DeleteQuery === 'function') {
await backend.DeleteQuery(id);
}
};
export async function bootstrapSavedQueries(args: SavedQueryBootstrapArgs): Promise<SavedQueryBootstrapResult> {
const storage = resolveStorage(args.storage);
const storageLegacySource = readLegacySavedQuerySourceFromStorage(storage);
const legacySource = mergeSavedQueryImportSources(capturedLegacySavedQuerySource, storageLegacySource);
const legacyQueries = legacySource.queries;
let importedLegacyCount = 0;
if (legacyQueries.length > 0) {
if (typeof args.backend?.ImportSavedQueries === 'function') {
await args.backend.ImportSavedQueries(legacySource);
importedLegacyCount = legacyQueries.length;
capturedLegacySavedQuerySource = { queries: [] };
cleanupLegacySavedQueriesFromStorage(storage);
} else if (typeof args.backend?.SaveQuery === 'function') {
for (const query of legacyQueries) {
await args.backend.SaveQuery(query);
}
importedLegacyCount = legacyQueries.length;
capturedLegacySavedQuerySource = { queries: [] };
cleanupLegacySavedQueriesFromStorage(storage);
}
}
let loadedQueries: SavedQuery[] = [];
if (typeof args.backend?.GetSavedQueries === 'function') {
loadedQueries = sanitizeSavedQueries(await args.backend.GetSavedQueries());
}
if (loadedQueries.length === 0 && importedLegacyCount === 0 && legacyQueries.length > 0) {
loadedQueries = legacyQueries;
}
args.replaceSavedQueries(loadedQueries);
return {
importedLegacyCount,
loadedCount: loadedQueries.length,
};
}

View File

@@ -8,6 +8,7 @@ import {
startSecurityUpdateFromBootstrap,
} from './secureConfigBootstrap';
import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage';
import { stripLegacySavedQueries } from './savedQueryPersistence';
const legacyPayload = JSON.stringify({
state: {
@@ -536,6 +537,69 @@ describe('secureConfigBootstrap', () => {
);
});
it('does not restore legacy saved queries when security cleanup runs after saved-query cleanup', async () => {
const args = createBaseArgs();
args.storage.setItem(LEGACY_PERSIST_KEY, JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
savedQueries: [
{
id: 'saved-1',
name: 'Orders',
sql: 'select * from orders',
connectionId: 'legacy-1',
dbName: 'app',
createdAt: 100,
},
],
},
}));
await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockImplementation(async () => {
args.storage.setItem(
LEGACY_PERSIST_KEY,
stripLegacySavedQueries(args.storage.getItem(LEGACY_PERSIST_KEY)),
);
return {
overallStatus: 'completed',
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
issues: [],
};
}),
GetSavedConnections: vi.fn().mockResolvedValue([]),
},
});
const cleaned = JSON.parse(args.storage.getItem(LEGACY_PERSIST_KEY) || '{}');
expect(cleaned.state.savedQueries).toBeUndefined();
expect(cleaned.state.connections).toEqual([]);
expect(cleaned.state.globalProxy).toBeUndefined();
});
it('refreshes backend config and strips source-side secrets when a later round finishes as completed', async () => {
const args = createBaseArgs();

View File

@@ -12,6 +12,7 @@ import {
readLegacyPersistedSecrets,
stripLegacyPersistedSecrets,
} from './legacyConnectionStorage';
import { stripLegacySavedQueries } from './savedQueryPersistence';
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
@@ -312,8 +313,9 @@ const cleanupLegacySourceIfCompleted = (
if (!storage || !rawPayload || status.overallStatus !== 'completed') {
return;
}
const sanitizedPayload = stripLegacyPersistedSecrets(rawPayload);
if (sanitizedPayload && sanitizedPayload !== rawPayload) {
const currentPayload = storage.getItem(LEGACY_PERSIST_KEY) ?? rawPayload;
const sanitizedPayload = stripLegacySavedQueries(stripLegacyPersistedSecrets(currentPayload));
if (sanitizedPayload && sanitizedPayload !== currentPayload) {
storage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
}
};

View File

@@ -76,6 +76,8 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr
export function DeleteConnection(arg1:string):Promise<void>;
export function DeleteQuery(arg1:string):Promise<void>;
export function DeleteSQLDirectory(arg1:string):Promise<connection.QueryResult>;
export function DeleteSQLFile(arg1:string):Promise<connection.QueryResult>;
@@ -136,8 +138,12 @@ export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
export function GetSavedConnections():Promise<Array<connection.SavedConnectionView>>;
export function GetSavedQueries():Promise<Array<connection.SavedQuery>>;
export function GetSecurityUpdateStatus():Promise<app.SecurityUpdateStatus>;
export function GetUnboundSavedQueries():Promise<Array<connection.SavedQuery>>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<Array<connection.SavedConnectionView>>;
@@ -150,6 +156,8 @@ export function ImportLegacyConnections(arg1:Array<connection.SavedConnectionInp
export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
export function ImportSavedQueries(arg1:connection.SavedQueryImportPayload):Promise<Array<connection.SavedQuery>>;
export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
@@ -216,6 +224,8 @@ export function ReadAppLogTail(arg1:number,arg2:string):Promise<connection.Query
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
export function RebindSavedQuery(arg1:string,arg2:string):Promise<connection.SavedQuery>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise<connection.QueryResult>;
@@ -294,6 +304,8 @@ export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<con
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
export function SaveQuery(arg1:connection.SavedQuery):Promise<connection.SavedQuery>;
export function SelectCertificateFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;

View File

@@ -142,6 +142,10 @@ export function DeleteConnection(arg1) {
return window['go']['app']['App']['DeleteConnection'](arg1);
}
export function DeleteQuery(arg1) {
return window['go']['app']['App']['DeleteQuery'](arg1);
}
export function DeleteSQLDirectory(arg1) {
return window['go']['app']['App']['DeleteSQLDirectory'](arg1);
}
@@ -262,10 +266,18 @@ export function GetSavedConnections() {
return window['go']['app']['App']['GetSavedConnections']();
}
export function GetSavedQueries() {
return window['go']['app']['App']['GetSavedQueries']();
}
export function GetSecurityUpdateStatus() {
return window['go']['app']['App']['GetSecurityUpdateStatus']();
}
export function GetUnboundSavedQueries() {
return window['go']['app']['App']['GetUnboundSavedQueries']();
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
@@ -290,6 +302,10 @@ export function ImportLegacyGlobalProxy(arg1) {
return window['go']['app']['App']['ImportLegacyGlobalProxy'](arg1);
}
export function ImportSavedQueries(arg1) {
return window['go']['app']['App']['ImportSavedQueries'](arg1);
}
export function InstallLocalDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4);
}
@@ -422,6 +438,10 @@ export function ReadSQLFile(arg1) {
return window['go']['app']['App']['ReadSQLFile'](arg1);
}
export function RebindSavedQuery(arg1, arg2) {
return window['go']['app']['App']['RebindSavedQuery'](arg1, arg2);
}
export function RedisConnect(arg1) {
return window['go']['app']['App']['RedisConnect'](arg1);
}
@@ -578,6 +598,10 @@ export function SaveGlobalProxy(arg1) {
return window['go']['app']['App']['SaveGlobalProxy'](arg1);
}
export function SaveQuery(arg1) {
return window['go']['app']['App']['SaveQuery'](arg1);
}
export function SelectCertificateFile(arg1, arg2) {
return window['go']['app']['App']['SelectCertificateFile'](arg1, arg2);
}

View File

@@ -1199,6 +1199,68 @@ export namespace connection {
return a;
}
}
export class SavedQuery {
id: string;
name: string;
sql: string;
connectionId: string;
dbName: string;
createdAt: number;
connectionFingerprint?: string;
fingerprintVersion?: string;
bindingStatus?: string;
originalConnectionId?: string;
static createFrom(source: any = {}) {
return new SavedQuery(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.sql = source["sql"];
this.connectionId = source["connectionId"];
this.dbName = source["dbName"];
this.createdAt = source["createdAt"];
this.connectionFingerprint = source["connectionFingerprint"];
this.fingerprintVersion = source["fingerprintVersion"];
this.bindingStatus = source["bindingStatus"];
this.originalConnectionId = source["originalConnectionId"];
}
}
export class SavedQueryImportPayload {
queries: SavedQuery[];
legacyConnections?: SavedConnectionInput[];
static createFrom(source: any = {}) {
return new SavedQueryImportPayload(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.queries = this.convertValues(source["queries"], SavedQuery);
this.legacyConnections = this.convertValues(source["legacyConnections"], SavedConnectionInput);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
@@ -1392,4 +1454,3 @@ export namespace sync {
}
}