fix(sqlserver): attribute login and catalog errors, keep sidebar search read-only
This commit is contained in:
16 files changed
+542
-35
No files matched your search
@@ -383,25 +383,28 @@ async function loadSidebarSearchTargets(query: string, preservesNodeSubtree?: (n
|
||||
function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearchTask[], refreshedNodeIds?: Set<string>, preservesNodeSubtree?: (node: TreeNode) => boolean, ancestorPreservesSearchSubtree = false, scheduledNodeIds?: Set<string>) {
|
||||
const preservesSearchSubtree = ancestorPreservesSearchSubtree || (!!refreshedNodeIds && !!preservesNodeSubtree?.(node));
|
||||
if (refreshedNodeIds && node.type === "connection" && node.connectionId) {
|
||||
if (store.connectedIds.has(node.connectionId) && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) {
|
||||
const connectionIsConnected = store.connectedIds.has(node.connectionId);
|
||||
if (connectionIsConnected && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) {
|
||||
const connectionId = node.connectionId;
|
||||
scheduledNodeIds?.add(node.id);
|
||||
tasks.push(() => store.loadConnectedConnectionRootForSidebarSearch(connectionId));
|
||||
tasks.push(() => store.loadConnectedConnectionRootForSidebarSearch(connectionId, { sidebarSearch: true }));
|
||||
}
|
||||
if (node.connectionId !== store.activeConnectionId) return;
|
||||
// 搜索不得替用户建连:只有连接中的连接(含当前激活的那个)才继续刷新子树。
|
||||
// 断开或连不上的连接直接跳过,后台搜索不会因此弹出凭据输入或写入整段连接错误。
|
||||
if (!connectionIsConnected || node.connectionId !== store.activeConnectionId) return;
|
||||
}
|
||||
if (refreshedNodeIds && isSimpleObjectSearchParent(node)) {
|
||||
if (!scheduledNodeIds || !scheduledNodeIds.has(node.id)) {
|
||||
scheduledNodeIds?.add(node.id);
|
||||
if (preservesSearchSubtree) {
|
||||
if (refreshedNodeIds.delete(node.id)) {
|
||||
tasks.push(() => store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery }));
|
||||
tasks.push(() => store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true }));
|
||||
}
|
||||
} else {
|
||||
const wasCollapsed = node.isExpanded !== true;
|
||||
refreshedNodeIds.add(node.id);
|
||||
if (wasCollapsed) searchExpansionState.markFiltered(node.id, true);
|
||||
tasks.push(() => store.refreshTreeNode(node));
|
||||
tasks.push(() => store.refreshTreeNode(node, { sidebarSearch: true }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -410,7 +413,7 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch
|
||||
scheduledNodeIds?.add(node.id);
|
||||
const wasCollapsed = node.isExpanded !== true;
|
||||
searchExpansionState.markFiltered(node.id, wasCollapsed);
|
||||
tasks.push(() => store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery }));
|
||||
tasks.push(() => store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true }));
|
||||
}
|
||||
if (refreshedNodeIds && node.children) {
|
||||
for (const child of node.children) {
|
||||
@@ -419,11 +422,11 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch
|
||||
scheduledNodeIds?.add(child.id);
|
||||
if (preservesSearchSubtree) {
|
||||
if (searchExpansionState.markUnfiltered(child.id)) {
|
||||
tasks.push(() => store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery }));
|
||||
tasks.push(() => store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true }));
|
||||
}
|
||||
} else {
|
||||
searchExpansionState.markFiltered(child.id, !child.isExpanded);
|
||||
tasks.push(() => store.loadObjectGroupChildren(child, { force: true }));
|
||||
tasks.push(() => store.loadObjectGroupChildren(child, { force: true, sidebarSearch: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,12 +441,12 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch
|
||||
} else if (!store.restoreFilteredObjectGroupChildren(node)) {
|
||||
// Nothing was captured because the group had not been loaded before the
|
||||
// search, so there is no previous list to put back.
|
||||
tasks.push(() => store.loadObjectGroupChildren(node, { force: true }));
|
||||
tasks.push(() => store.loadObjectGroupChildren(node, { force: true, sidebarSearch: true }));
|
||||
}
|
||||
} else if (simpleObjectParentTypes.has(node.type)) {
|
||||
const shouldCollapse = searchAutoExpandedNodeIds.has(node.id);
|
||||
tasks.push(async () => {
|
||||
await store.refreshTreeNode(node);
|
||||
await store.refreshTreeNode(node, { sidebarSearch: true });
|
||||
if (shouldCollapse) node.isExpanded = false;
|
||||
});
|
||||
}
|
||||
|
||||
+15
-2
@@ -12,7 +12,7 @@ describe("ConnectionTree global search loading", () => {
|
||||
// a searchable group instead of an empty child list.
|
||||
expect(source).toMatch(/isSidebarSearchContainer\(node\) && needsSidebarObjectGroupDiscovery\(node, searchableObjectGroupTypes\)/);
|
||||
expect(source).not.toMatch(/isSidebarSearchContainer\(node\) && !node\.children\?\.length/);
|
||||
expect(source).toContain("store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })");
|
||||
expect(source).toContain("store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true })");
|
||||
expect(source).toContain("searchExpansionState.markFiltered(node.id, wasCollapsed)");
|
||||
expect(source).toMatch(/if \(refreshedNodeIds && node\.children\) \{[\s\S]*?searchableObjectGroupTypes\.has\(child\.type\)/);
|
||||
});
|
||||
@@ -29,7 +29,20 @@ describe("ConnectionTree global search loading", () => {
|
||||
// Clearing a remote search must put back the pages the user had loaded
|
||||
// through "load more"; only an uncaptured group falls back to a reload.
|
||||
expect(source).toContain("} else if (!store.restoreFilteredObjectGroupChildren(node)) {");
|
||||
expect(source).toMatch(/restoreFilteredObjectGroupChildren\(node\)\) \{[\s\S]*?tasks\.push\(\(\) => store\.loadObjectGroupChildren\(node, \{ force: true \}\)\);/);
|
||||
expect(source).toMatch(/restoreFilteredObjectGroupChildren\(node\)\) \{[\s\S]*?tasks\.push\(\(\) => store\.loadObjectGroupChildren\(node, \{ force: true, sidebarSearch: true \}\)\);/);
|
||||
});
|
||||
|
||||
it("never lets the global search connect a connection on the user's behalf", () => {
|
||||
// Searching the sidebar is a background projection. Reconnecting here is what
|
||||
// surfaced a raw SQL Server TLS/login error for a connection whose saved
|
||||
// database no longer exists, so only already-connected connections may be
|
||||
// refreshed and every search-driven load is tagged for the store.
|
||||
expect(source).toContain("const connectionIsConnected = store.connectedIds.has(node.connectionId);");
|
||||
expect(source).toMatch(/if \(!connectionIsConnected \|\| node\.connectionId !== store\.activeConnectionId\) return;/);
|
||||
// Search-driven loads are tagged so the store degrades a failed reconnect to a
|
||||
// one-line skip hint instead of the raw driver error (see the store spec).
|
||||
expect(source.match(/sidebarSearch: true/g)?.length ?? 0).toBeGreaterThanOrEqual(7);
|
||||
expect(source).not.toMatch(/tasks\.push\(\(\) => store\.connect\(/);
|
||||
});
|
||||
|
||||
it("limits concurrent metadata loads without dropping a task", async () => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import az from "@/i18n/locales/az";
|
||||
import en from "@/i18n/locales/en";
|
||||
import es from "@/i18n/locales/es";
|
||||
import itLocale from "@/i18n/locales/it";
|
||||
import ja from "@/i18n/locales/ja";
|
||||
import ko from "@/i18n/locales/ko";
|
||||
import ptBR from "@/i18n/locales/pt-BR";
|
||||
import tr from "@/i18n/locales/tr";
|
||||
import zhCN from "@/i18n/locales/zh-CN";
|
||||
import zhTW from "@/i18n/locales/zh-TW";
|
||||
|
||||
// The sidebar-search skip hint is a single-line projection of a driver error
|
||||
// (see connectionStore.recordSidebarSearchConnectionFailure). Every locale has
|
||||
// to own the `{message}` placeholder, so this reads the locale SOURCES as text:
|
||||
// non-English locales are `export default withEnglishFallback({ ... })`, and the
|
||||
// import-time deep merge would hide a locale that silently dropped the key.
|
||||
function localeSource(name: string): string {
|
||||
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function declaredString(source: string, key: string): string | undefined {
|
||||
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
|
||||
}
|
||||
|
||||
const localeNames = ["az", "en", "es", "it", "ja", "ko", "pt-BR", "tr", "zh-CN", "zh-TW"];
|
||||
|
||||
describe("sidebar search skip hint i18n parity", () => {
|
||||
it("english declares the key with its message placeholder", () => {
|
||||
const label = declaredString(localeSource("en"), "searchConnectionSkipped");
|
||||
expect(label).toContain("{message}");
|
||||
});
|
||||
|
||||
it.each(localeNames)("%s declares the key with the message placeholder in its source file", (name) => {
|
||||
const label = declaredString(localeSource(name), "searchConnectionSkipped");
|
||||
expect(label, `${name}.ts must declare sidebar.searchConnectionSkipped`).toBeTruthy();
|
||||
expect(label).toContain("{message}");
|
||||
});
|
||||
|
||||
it.each(localeNames)("%s anchors the key inside the sidebar namespace", (name) => {
|
||||
const source = localeSource(name);
|
||||
expect(source.indexOf("searchConnectionSkipped:")).toBeGreaterThan(source.indexOf("sortConnectionsDescending:"));
|
||||
expect(source.indexOf("searchConnectionSkipped:")).toBeLessThan(source.indexOf("searchScopeConnection:"));
|
||||
});
|
||||
});
|
||||
|
||||
const localeModules: Array<[string, Record<string, unknown>]> = [
|
||||
["az", az],
|
||||
["en", en],
|
||||
["es", es],
|
||||
["it", itLocale],
|
||||
["ja", ja],
|
||||
["ko", ko],
|
||||
["pt-BR", ptBR],
|
||||
["tr", tr],
|
||||
["zh-CN", zhCN],
|
||||
["zh-TW", zhTW],
|
||||
];
|
||||
|
||||
describe("sidebar search skip hint resolution", () => {
|
||||
it.each(localeModules)("%s renders the driver detail inside the hint", (name, messages) => {
|
||||
const sidebar = (messages.sidebar ?? {}) as typeof en.sidebar;
|
||||
const i18n = createI18n({ legacy: false, locale: name, messages: { [name]: { sidebar } } });
|
||||
const hint = i18n.global.t("sidebar.searchConnectionSkipped", { message: "tls handshake eof" });
|
||||
expect(hint).not.toBe("sidebar.searchConnectionSkipped");
|
||||
expect(hint).toContain("tls handshake eof");
|
||||
i18n.dispose();
|
||||
});
|
||||
});
|
||||
@@ -431,6 +431,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "Əl ilə sıralama",
|
||||
sortConnectionsAscending: "Ad: A–Z",
|
||||
sortConnectionsDescending: "Ad: Z–A",
|
||||
searchConnectionSkipped: "Axtarış bu əlaqəni atladı: {message}",
|
||||
searchScopeConnection: "Əlaqə",
|
||||
searchScopeDatabase: "Verilənlər bazası",
|
||||
searchScopeSchema: "Sxem",
|
||||
|
||||
@@ -638,6 +638,7 @@ export default {
|
||||
sortConnectionsManual: "Manual order",
|
||||
sortConnectionsAscending: "Name: A–Z",
|
||||
sortConnectionsDescending: "Name: Z–A",
|
||||
searchConnectionSkipped: "Search skipped this connection: {message}",
|
||||
searchScopeConnection: "Connection",
|
||||
searchScopeDatabase: "Database",
|
||||
searchScopeSchema: "Schema",
|
||||
|
||||
@@ -630,6 +630,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "Orden manual",
|
||||
sortConnectionsAscending: "Nombre: A–Z",
|
||||
sortConnectionsDescending: "Nombre: Z–A",
|
||||
searchConnectionSkipped: "La búsqueda omitió esta conexión: {message}",
|
||||
searchScopeConnection: "Conexión",
|
||||
searchScopeDatabase: "Base de datos",
|
||||
searchScopeSchema: "Schema",
|
||||
|
||||
@@ -628,6 +628,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "Ordine manuale",
|
||||
sortConnectionsAscending: "Nome: A–Z",
|
||||
sortConnectionsDescending: "Nome: Z–A",
|
||||
searchConnectionSkipped: "La ricerca ha ignorato questa connessione: {message}",
|
||||
searchScopeConnection: "Connessione",
|
||||
searchScopeDatabase: "Database",
|
||||
searchScopeSchema: "Schema",
|
||||
|
||||
@@ -629,6 +629,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "手動順",
|
||||
sortConnectionsAscending: "名前: A–Z",
|
||||
sortConnectionsDescending: "名前: Z–A",
|
||||
searchConnectionSkipped: "検索ではこの接続をスキップしました: {message}",
|
||||
searchScopeConnection: "接続",
|
||||
searchScopeDatabase: "データベース",
|
||||
searchScopeSchema: "スキーマ",
|
||||
|
||||
@@ -636,6 +636,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "수동 순서",
|
||||
sortConnectionsAscending: "이름: 가나다순",
|
||||
sortConnectionsDescending: "이름: 다나가순",
|
||||
searchConnectionSkipped: "검색에서 이 연결을 건너뛰었습니다: {message}",
|
||||
searchScopeConnection: "연결",
|
||||
searchScopeDatabase: "데이터베이스",
|
||||
searchScopeSchema: "스키마",
|
||||
|
||||
@@ -629,6 +629,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "Ordem manual",
|
||||
sortConnectionsAscending: "Nome: A–Z",
|
||||
sortConnectionsDescending: "Nome: Z–A",
|
||||
searchConnectionSkipped: "A busca ignorou esta conexão: {message}",
|
||||
searchScopeConnection: "Conexão",
|
||||
searchScopeDatabase: "Banco de dados",
|
||||
searchScopeSchema: "Schema",
|
||||
|
||||
@@ -433,6 +433,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "Elle sıralama",
|
||||
sortConnectionsAscending: "Ad: A–Z",
|
||||
sortConnectionsDescending: "Ad: Z–A",
|
||||
searchConnectionSkipped: "Arama bu bağlantıyı atladı: {message}",
|
||||
searchScopeConnection: "Bağlantı",
|
||||
searchScopeDatabase: "Veritabanı",
|
||||
searchScopeSchema: "Şema",
|
||||
|
||||
@@ -554,6 +554,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "手动排序",
|
||||
sortConnectionsAscending: "名称:A–Z",
|
||||
sortConnectionsDescending: "名称:Z–A",
|
||||
searchConnectionSkipped: "搜索已跳过该连接:{message}",
|
||||
searchScopeConnection: "连接",
|
||||
searchScopeDatabase: "数据库",
|
||||
searchScopeSchema: "Schema",
|
||||
|
||||
@@ -632,6 +632,7 @@ export default withEnglishFallback({
|
||||
sortConnectionsManual: "手動排序",
|
||||
sortConnectionsAscending: "名稱:A–Z",
|
||||
sortConnectionsDescending: "名稱:Z–A",
|
||||
searchConnectionSkipped: "搜尋已略過此連線:{message}",
|
||||
searchScopeConnection: "連線",
|
||||
searchScopeDatabase: "資料庫",
|
||||
searchScopeSchema: "Schema",
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
// Verbatim shape of the WeChat report: an encrypted handshake that fails on a
|
||||
// SQL Server 2014 instance, then a no-encryption fallback that reaches the login
|
||||
// stage and is rejected because the saved database no longer exists (4060).
|
||||
const LEGACY_SQLSERVER_CONNECT_ERROR = [
|
||||
"SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof",
|
||||
"",
|
||||
"This may be caused by an old SQL Server TLS/encryption configuration. If you are connecting to SQL Server 2008/2008 R2/2012 or another legacy instance, try SQL Server legacy compatibility mode. It first behaves like encrypt=false and, when explicitly enabled, DBX can also fall back to the SQL Server legacy compatibility driver for TLS 1.0 encrypted transport. Only use this mode on trusted networks, VPNs, or SSH tunnels.",
|
||||
"",
|
||||
"Automatic native legacy fallback also failed: login-only encryption failed: SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof",
|
||||
"no-encryption compatibility fallback failed: SQL Server connection failed: Token error: '无法打开登录所请求的数据库 \"cwxt2025\"。登录失败。' on server iZw1wl8nyooomlZ executing on line 1 (code: 4060, state: 1, class: 11)",
|
||||
].join("\n");
|
||||
|
||||
function sqlServerConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "mssql-1",
|
||||
name: "iZw1wl8nyooomlZ",
|
||||
db_type: "sqlserver",
|
||||
host: "10.0.0.9",
|
||||
port: 1433,
|
||||
username: "sa",
|
||||
password: "secret",
|
||||
database: "cwxt2025",
|
||||
read_only: false,
|
||||
...overrides,
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
function connectionTree(connection: ConnectionConfig): { connectionNode: TreeNode; tablesGroup: TreeNode } {
|
||||
const tablesGroup: TreeNode = {
|
||||
id: `${connection.id}:${connection.database}:__tables`,
|
||||
label: "tree.tables",
|
||||
type: "group-tables",
|
||||
connectionId: connection.id,
|
||||
database: connection.database,
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
const connectionNode: TreeNode = {
|
||||
id: connection.id,
|
||||
label: connection.name,
|
||||
type: "connection",
|
||||
connectionId: connection.id,
|
||||
isExpanded: true,
|
||||
children: [
|
||||
{
|
||||
id: `${connection.id}:${connection.database}`,
|
||||
label: String(connection.database),
|
||||
type: "database",
|
||||
connectionId: connection.id,
|
||||
database: connection.database,
|
||||
isExpanded: true,
|
||||
children: [tablesGroup],
|
||||
},
|
||||
],
|
||||
};
|
||||
return { connectionNode, tablesGroup };
|
||||
}
|
||||
|
||||
function mockBackend(connectDb: ReturnType<typeof vi.fn>, overrides: Record<string, unknown> = {}) {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockRejectedValue(new Error("back end pool is gone")),
|
||||
connectDb,
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("connectionStore sidebar search connection failures", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
i18n.global.locale.value = "en";
|
||||
});
|
||||
|
||||
it("keeps only a one-line skip hint when a background search load cannot reconnect", async () => {
|
||||
const connectDb = vi.fn().mockRejectedValue(new Error(LEGACY_SQLSERVER_CONNECT_ERROR));
|
||||
mockBackend(connectDb);
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = sqlServerConnection();
|
||||
const { connectionNode, tablesGroup } = connectionTree(connection);
|
||||
store.connections = [connection];
|
||||
store.treeNodes = [connectionNode];
|
||||
store.connectedIds = new Set([connection.id]);
|
||||
store.activeConnectionId = connection.id;
|
||||
|
||||
await expect(store.loadObjectGroupChildren(tablesGroup, { force: true, sidebarSearch: true })).rejects.toThrow();
|
||||
|
||||
expect(connectDb).toHaveBeenCalled();
|
||||
const recorded = store.connectionErrors[connection.id];
|
||||
// Search is a background projection: the node keeps one actionable line
|
||||
// instead of the raw driver transcript with the misleading TLS hint.
|
||||
expect(recorded).toBe(`Search skipped this connection: ${LEGACY_SQLSERVER_CONNECT_ERROR.split("\n")[0]}`);
|
||||
expect(recorded).not.toContain("\n");
|
||||
expect(recorded).not.toContain("This may be caused by an old SQL Server TLS/encryption configuration");
|
||||
expect(store.connectedIds.has(connection.id)).toBe(false);
|
||||
}, 15_000);
|
||||
|
||||
it("still surfaces the full driver error for the same failure outside a search load", async () => {
|
||||
const connectDb = vi.fn().mockRejectedValue(new Error(LEGACY_SQLSERVER_CONNECT_ERROR));
|
||||
mockBackend(connectDb);
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = sqlServerConnection();
|
||||
const { connectionNode, tablesGroup } = connectionTree(connection);
|
||||
store.connections = [connection];
|
||||
store.treeNodes = [connectionNode];
|
||||
store.connectedIds = new Set([connection.id]);
|
||||
|
||||
await expect(store.loadObjectGroupChildren(tablesGroup, { force: true })).rejects.toThrow();
|
||||
|
||||
// An explicit action keeps the whole error so the user can read the real cause.
|
||||
expect(store.connectionErrors[connection.id]).toBe(LEGACY_SQLSERVER_CONNECT_ERROR);
|
||||
}, 15_000);
|
||||
|
||||
// A live connection whose metadata query fails with a query-level error (SQL
|
||||
// Server code 229, SELECT permission denied) must survive a background search:
|
||||
// before the markConnectionLost gate, any non-cancelled search-driven failure
|
||||
// silently disconnected it and cleared activeConnectionId.
|
||||
const PERMISSION_DENIED_ERROR = 'SQL Server query failed: Token error: \'The SELECT permission was denied on the object "tables", database "cwxt2025", schema "dbo".\' on server iZw1wl8nyooomlZ (code: 229, state: 1, class: 16)';
|
||||
|
||||
const CONNECTION_LEVEL_ERROR = "SQL Server connection failed: An error occured during the attempt of performing I/O: connection reset by peer";
|
||||
|
||||
async function connectStoreWithLiveConnection(listTablesError: string) {
|
||||
const connectDb = vi.fn();
|
||||
const checkConnectionHealth = vi.fn().mockResolvedValue(undefined);
|
||||
const listTables = vi.fn().mockRejectedValue(new Error(listTablesError));
|
||||
mockBackend(connectDb, { checkConnectionHealth, listTables });
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = sqlServerConnection();
|
||||
const { connectionNode, tablesGroup } = connectionTree(connection);
|
||||
store.connections = [connection];
|
||||
store.treeNodes = [connectionNode];
|
||||
store.connectedIds = new Set([connection.id]);
|
||||
store.activeConnectionId = connection.id;
|
||||
return { store, connection, tablesGroup, connectDb };
|
||||
}
|
||||
|
||||
it("keeps a live connection connected when a search load fails with a query-level permission error", async () => {
|
||||
const { store, connection, tablesGroup, connectDb } = await connectStoreWithLiveConnection(PERMISSION_DENIED_ERROR);
|
||||
|
||||
await expect(store.loadObjectGroupChildren(tablesGroup, { force: true, sidebarSearch: true })).rejects.toThrow(PERMISSION_DENIED_ERROR);
|
||||
|
||||
// A permission error is a query-level problem: the search records its one-line
|
||||
// skip hint but must not disconnect the user's live connection.
|
||||
expect(connectDb).not.toHaveBeenCalled();
|
||||
expect(store.connectionErrors[connection.id]).toBe(`Search skipped this connection: ${PERMISSION_DENIED_ERROR}`);
|
||||
expect(store.connectedIds.has(connection.id)).toBe(true);
|
||||
expect(store.activeConnectionId).toBe(connection.id);
|
||||
}, 15_000);
|
||||
|
||||
it("still marks a live connection lost when a search load fails with a connection-level error", async () => {
|
||||
const { store, connection, tablesGroup } = await connectStoreWithLiveConnection(CONNECTION_LEVEL_ERROR);
|
||||
|
||||
await expect(store.loadObjectGroupChildren(tablesGroup, { force: true, sidebarSearch: true })).rejects.toThrow(CONNECTION_LEVEL_ERROR);
|
||||
|
||||
// A transport-level failure really did kill the connection: passive
|
||||
// disconnect (and the one-line skip hint) still applies.
|
||||
expect(store.connectionErrors[connection.id]).toBe(`Search skipped this connection: ${CONNECTION_LEVEL_ERROR}`);
|
||||
expect(store.connectedIds.has(connection.id)).toBe(false);
|
||||
expect(store.activeConnectionId).toBeNull();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -402,6 +402,10 @@ interface LoadTreeOptions {
|
||||
connectedOnly?: boolean;
|
||||
expectedSidebarSearchQuery?: string;
|
||||
searchFilter?: string;
|
||||
// Set by the sidebar search walker: the load runs in the background, so a connection that
|
||||
// cannot be reached must not leave the raw driver error on the node (see
|
||||
// withSidebarSearchLoad).
|
||||
sidebarSearch?: boolean;
|
||||
// Explicit actions can load the unfiltered backing group while the global search
|
||||
// continues to control presentation; normal watcher refreshes still reject mismatches.
|
||||
allowGlobalSearchMismatch?: boolean;
|
||||
@@ -1486,9 +1490,62 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏搜索是后台投影:搜索为了读取元数据而被动重连失败时,不能把驱动的原始错误
|
||||
* (旧版 SQL Server 可能是一整段 TLS/加密提示)留在连接节点上,也不能每输入一个字就重试一次。
|
||||
* 搜索驱动的加载在这里登记,ensureConnected() 据此识别并降级这类失败。
|
||||
*/
|
||||
const sidebarSearchLoadCounts = new Map<string, number>();
|
||||
|
||||
function isSidebarSearchLoad(connectionId: string): boolean {
|
||||
return (sidebarSearchLoadCounts.get(connectionId) ?? 0) > 0;
|
||||
}
|
||||
|
||||
async function withSidebarSearchLoad<T>(connectionId: string | null | undefined, work: () => Promise<T>): Promise<T> {
|
||||
const id = connectionId?.trim();
|
||||
if (!id) return work();
|
||||
sidebarSearchLoadCounts.set(id, (sidebarSearchLoadCounts.get(id) ?? 0) + 1);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
const remaining = (sidebarSearchLoadCounts.get(id) ?? 1) - 1;
|
||||
if (remaining > 0) sidebarSearchLoadCounts.set(id, remaining);
|
||||
else sidebarSearchLoadCounts.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function sidebarSearchSkipMessage(error: unknown): string {
|
||||
const firstLine =
|
||||
connectionErrorMessage(error)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? "";
|
||||
const detail = firstLine.length > 240 ? `${firstLine.slice(0, 240)}…` : firstLine;
|
||||
return i18n.global.t("sidebar.searchConnectionSkipped", { message: detail });
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台搜索重连失败:按被动断链处理(连接确实不可用了),但把驱动错误换成一行提示,
|
||||
* 这样节点上只留一句“搜索已跳过该连接”,用户显式连接时仍能看到完整错误。
|
||||
* markLost=false:查询阶段连接仍然存活(如权限拒绝),只留一行跳过提示,
|
||||
* 不替用户断开正在使用的连接。
|
||||
*/
|
||||
function recordSidebarSearchConnectionFailure(connectionId: string, error: unknown, markLost = true) {
|
||||
if (markLost) markConnectionLost(connectionId, error);
|
||||
setConnectionError(connectionId, sidebarSearchSkipMessage(error));
|
||||
}
|
||||
|
||||
// Metadata loaders keep this internal: match connection-loss errors before recording generic errors.
|
||||
function recordMetadataLoadError(connectionId: string, error: unknown, load?: TreeNodeLoadHandle) {
|
||||
if (load && !load.isCurrent()) return;
|
||||
// 搜索驱动的后台加载失败按“搜索跳过该连接”降级:搜索只是投影动作,不能把驱动原始错误
|
||||
// (旧版 SQL Server 会附带整段 TLS/加密提示)留在连接节点上。取消/被取代的尝试沿用原有清错处理。
|
||||
if (isSidebarSearchLoad(connectionId) && !isCancelledConnectionAttempt(error) && !isSupersededConnectionAttempt(error)) {
|
||||
// 元数据查询失败未必意味着连接已死:只有连接级错误(与 recordConnectionLostError 同一判定)
|
||||
// 才被动断链;权限拒绝等查询期错误保持连接,只降级为一行跳过提示。
|
||||
recordSidebarSearchConnectionFailure(connectionId, error, shouldMarkDisconnected(error));
|
||||
return;
|
||||
}
|
||||
if (recordConnectionLostError(connectionId, error)) return;
|
||||
recordConnectionError(connectionId, error);
|
||||
}
|
||||
@@ -4696,7 +4753,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
clearConnectionError(connectionId);
|
||||
return;
|
||||
}
|
||||
recordConnectionError(connectionId, e);
|
||||
// 后台搜索触发的重连失败只留一行提示,避免把完整驱动错误(如旧版 SQL Server 的 TLS 提示)
|
||||
// 记到连接节点上,也避免搜索每轮都重复报同一个错。
|
||||
if (isSidebarSearchLoad(connectionId) && !isSupersededConnectionAttempt(e)) recordSidebarSearchConnectionFailure(connectionId, e);
|
||||
else recordConnectionError(connectionId, e);
|
||||
clearConnectionNodeLoading(connectionId);
|
||||
throw e;
|
||||
} finally {
|
||||
@@ -5116,7 +5176,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
|
||||
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string, options?: { sidebarSearch?: boolean }): Promise<void> {
|
||||
if (options?.sidebarSearch) {
|
||||
return withSidebarSearchLoad(connectionId, () => loadConnectedConnectionRootForSidebarSearch(connectionId, { ...options, sidebarSearch: false }));
|
||||
}
|
||||
if (!connectedIds.value.has(connectionId)) return;
|
||||
const config = getConfig(connectionId);
|
||||
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||||
@@ -6260,7 +6323,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions) {
|
||||
async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions): Promise<void> {
|
||||
if (options?.sidebarSearch) {
|
||||
return withSidebarSearchLoad(node.connectionId, () => loadObjectGroupChildren(node, { ...options, sidebarSearch: false }));
|
||||
}
|
||||
// Queued search/refresh tasks can outlive disconnect, which removes their nodes.
|
||||
if (!treeNodeInSidebarTree(node)) return;
|
||||
const packageOwnerId = packageMemberGroupOwnerId(node);
|
||||
@@ -7281,7 +7347,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions) {
|
||||
async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions): Promise<void> {
|
||||
if (options?.sidebarSearch) {
|
||||
return withSidebarSearchLoad(node.connectionId, () => loadTreeNodeChildren(node, { ...options, sidebarSearch: false }));
|
||||
}
|
||||
if (node.type === "connection" && node.connectionId) {
|
||||
const config = getConfig(node.connectionId);
|
||||
if (config?.db_type === "redis") {
|
||||
@@ -7393,7 +7462,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTreeNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) {
|
||||
async function refreshTreeNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean; sidebarSearch?: boolean }): Promise<void> {
|
||||
if (options?.sidebarSearch) {
|
||||
return withSidebarSearchLoad(node.connectionId, () => refreshTreeNode(node, { ...options, sidebarSearch: false }));
|
||||
}
|
||||
invalidateCompletionCachesForNode(node);
|
||||
invalidateMetadataCachesForNode(node, options);
|
||||
if (objectTypesForGroupNode(node.type)) {
|
||||
|
||||
Reference in new issue
Block a user