mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-10 06:52:18 +08:00
✨ feat(saved-query): 支持已存查询后端持久化与连接重绑
- 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法 - 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段 - 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配 - 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接 - 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock - 补充后端与前端迁移回归测试
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
134
frontend/src/utils/savedQueryPersistence.test.ts
Normal file
134
frontend/src/utils/savedQueryPersistence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
283
frontend/src/utils/savedQueryPersistence.ts
Normal file
283
frontend/src/utils/savedQueryPersistence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
12
frontend/wailsjs/go/app/App.d.ts
vendored
12
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user