Merge pull request #1586 from onceMisery/feat/opt-connections

fix: handle connection loss state for new queries
This commit is contained in:
skyler 2026-06-22 22:32:02 +08:00 committed by GitHub
commit ee29694776
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 280 additions and 56 deletions

View File

@ -53,6 +53,7 @@ export const testConnection = forward("testConnection");
export const connectDb = forward("connectDb");
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
export const disconnectDb = forward("disconnectDb");
export const checkConnectionHealth = forward("checkConnectionHealth");
export const closeDatabaseConnection = forward("closeDatabaseConnection");
export const refreshConnections = forward("refreshConnections");
export const saveConnections = forward("saveConnections");

View File

@ -1,4 +1,30 @@
const CONNECTION_ERROR_PATTERNS = ["connection", "broken pipe", "reset by peer", "timed out", "closed", "eof", "i/o error"];
const CONNECTION_ERROR_PATTERNS = [
"connection reset",
"connection refused",
"connection timed out",
"connection closed",
"connection lost",
"connection not found",
"connection config not found",
"not connected",
"closed the connection",
"broken pipe",
"reset by peer",
"socket closed",
"unexpected eof",
"end-of-file",
"end-of-file on communication channel",
"server closed session",
"communicating with the server",
"exceeded maximum idle time",
"agent stdin not available",
"agent stdout not available",
"failed to write to agent stdin",
"failed to flush agent stdin",
"关闭的连接",
"连接已关闭",
"i/o error",
];
export function staleConnectionMessage(error: unknown): string {
if (error instanceof Error) return error.message;

View File

@ -164,6 +164,10 @@ export async function disconnectDb(connectionId: string): Promise<void> {
return post("/api/connection/disconnect", { connectionId });
}
export async function checkConnectionHealth(connectionId: string): Promise<void> {
return post("/api/connection/check-health", { connectionId });
}
export async function closeDatabaseConnection(connectionId: string, database: string): Promise<boolean> {
return post("/api/connection/close-database", { connectionId, database });
}

View File

@ -473,6 +473,10 @@ export async function disconnectDb(connectionId: string): Promise<void> {
return invoke("disconnect_db", { connectionId });
}
export async function checkConnectionHealth(connectionId: string): Promise<void> {
return invoke("check_connection_health", { connectionId });
}
export async function closeDatabaseConnection(connectionId: string, database: string): Promise<boolean> {
return invoke("close_database_connection", { connectionId, database });
}

View File

@ -49,6 +49,7 @@ describe("connectionStore MQ sidebar tree", () => {
vi.doMock("@/lib/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),

View File

@ -260,11 +260,23 @@ export const useConnectionStore = defineStore("connection", () => {
return message;
}
function recordMetadataLoadError(connectionId: string, error: unknown) {
function markConnectionLost(connectionId: string, error: unknown) {
connectedIds.value.delete(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
recordConnectionError(connectionId, error);
}
function recordConnectionLostError(connectionId: string, error: unknown): boolean {
if (shouldMarkDisconnected(error)) {
connectedIds.value.delete(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
markConnectionLost(connectionId, error);
return true;
}
return false;
}
// Metadata loaders keep this internal: match connection-loss errors before recording generic errors.
function recordMetadataLoadError(connectionId: string, error: unknown) {
if (recordConnectionLostError(connectionId, error)) return;
recordConnectionError(connectionId, error);
}
@ -902,7 +914,17 @@ export const useConnectionStore = defineStore("connection", () => {
}
async function ensureConnected(connectionId: string) {
if (connectedIds.value.has(connectionId)) return;
if (connectedIds.value.has(connectionId)) {
// Optimistic: verify backend pool is actually healthy
try {
await api.checkConnectionHealth(connectionId);
return;
} catch {
// Backend pool is dead — remove from connectedIds and reconnect
connectedIds.value.delete(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
}
}
let config = getConfig(connectionId);
if (!config) {
await initFromDisk();
@ -3039,6 +3061,8 @@ export const useConnectionStore = defineStore("connection", () => {
setConnectionError,
clearConnectionError,
recordConnectionError,
markConnectionLost,
recordConnectionLostError,
sidebarLayout,
getConfig,
isTreeNodePinned,

View File

@ -1684,6 +1684,8 @@ export const useQueryStore = defineStore("query", () => {
}
} catch (e: any) {
console.error("[DBX][executeTabSql:error]", { traceId, elapsed: elapsed(), error: e });
// Sync connection state if the error indicates a lost connection
useConnectionStore().recordConnectionLostError(tab.connectionId, e);
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
current.result = toErrorResult(e);
@ -1836,6 +1838,8 @@ export const useQueryStore = defineStore("query", () => {
}
return canceled;
} catch (e: any) {
// Sync connection state if the error indicates a lost connection
if (tab) useConnectionStore().recordConnectionLostError(tab.connectionId, e);
const current = tabs.value.find((t) => t.id === id);
if (current && current.executionId === executionId) {
current.isCancelling = false;
@ -1882,12 +1886,15 @@ export const useQueryStore = defineStore("query", () => {
function notifyConnectionMayBeLost() {
const stuck = tabs.value.filter((t) => t.isExecuting);
if (stuck.length > 0) {
const connStore = useConnectionStore();
stuck.forEach((tab) => {
tab.isExecuting = false;
tab.isCancelling = false;
tab.queryExecutionStartedAt = undefined;
tab.executionId = undefined;
tab.result = toErrorResult(new Error(t("editor.connectionMayBeLost")));
const error = new Error(t("editor.connectionMayBeLost"));
tab.result = toErrorResult(error);
connStore.markConnectionLost(tab.connectionId, error);
});
}
}

View File

@ -1292,6 +1292,32 @@ impl AppState {
self.proxy_tunnels.stop_tunnel(connection_id).await;
}
/// Health-check the base connection pool for a given connection_id.
/// Returns `Ok(())` if the pool exists and is healthy, `Err` otherwise.
/// If the pool is unhealthy it is removed from the map so subsequent
/// `get_or_create_pool` calls will transparently recreate it.
pub async fn check_connection_health(&self, connection_id: &str) -> Result<(), String> {
let db_type = {
let configs = self.configs.read().await;
configs.get(connection_id).map(|c| c.db_type)
};
let pool_key = base_pool_key_for(db_type, connection_id, None, false);
// Check if pool exists first
{
let connections = self.connections.read().await;
if !connections.contains_key(&pool_key) {
return Err("No active connection pool found".to_string());
}
}
// `remove_stale_connection_pool` returns true if the pool was stale (and removed)
if self.remove_stale_connection_pool(&pool_key).await {
return Err("Connection pool is unhealthy".to_string());
}
Ok(())
}
pub async fn refresh_connections(&self) {
// Clone pool handles under a short-lived read lock, then release it
// before performing I/O-heavy health checks to avoid blocking writers.

View File

@ -153,6 +153,7 @@ async fn main() {
.route("/connection/connect", post(routes::connection::connect_db))
.route("/connection/final-proxy-port", post(routes::connection::connection_final_proxy_port))
.route("/connection/disconnect", post(routes::connection::disconnect_db))
.route("/connection/check-health", post(routes::connection::check_connection_health))
.route("/connection/close-database", post(routes::connection::close_database_connection))
.route("/connection/save", post(routes::connection::save_connections))
.route("/connection/list", get(routes::connection::load_connections))

View File

@ -110,6 +110,14 @@ pub async fn disconnect_db(
Ok(Json(()))
}
pub async fn check_connection_health(
State(state): State<Arc<WebState>>,
Json(body): Json<DisconnectRequest>,
) -> Result<Json<()>, AppError> {
state.app.check_connection_health(&body.connection_id).await.map_err(AppError)?;
Ok(Json(()))
}
pub async fn close_database_connection(
State(state): State<Arc<WebState>>,
Json(body): Json<CloseDatabaseConnectionRequest>,

View File

@ -83,3 +83,73 @@ test("failed disconnect keeps the existing connection error", async () => {
restoreStorage();
}
});
test("query errors mentioning connection do not mark the connection disconnected", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.activeConnectionId = "conn-1";
store.recordConnectionLostError("conn-1", new Error('relation "connection" does not exist'));
assert.equal(store.connectedIds.has("conn-1"), true);
assert.equal(store.activeConnectionId, "conn-1");
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
restoreStorage();
}
});
test("known backend connection errors mark the connection disconnected", async () => {
const restoreStorage = installMemoryStorage();
const messages = [
"java.sql.SQLRecoverableException: 关闭的连接",
"java.sql.SQLRecoverableException: 连接已关闭",
"server closed session with no notification",
"server closed the connection unexpectedly",
"Error occurred while creating a new object: error communicating with the server",
"ORA-02396: exceeded maximum idle time, please connect again",
"Agent stdin not available",
"Failed to write to agent stdin",
];
try {
for (const [index, message] of messages.entries()) {
setActivePinia(createPinia());
const store = useConnectionStore();
const connectionId = `conn-${index}`;
store.addEphemeralConnection(conn(connectionId));
store.activeConnectionId = connectionId;
const marked = store.recordConnectionLostError(connectionId, new Error(message));
assert.equal(marked, true, message);
assert.equal(store.connectedIds.has(connectionId), false, message);
assert.equal(store.activeConnectionId, null, message);
}
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
restoreStorage();
}
});
test("explicit lost-connection marker clears state without relying on error text", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.activeConnectionId = "conn-1";
store.markConnectionLost("conn-1", new Error("连接可能已断开,请刷新数据重试"));
assert.equal(store.connectedIds.has("conn-1"), false);
assert.equal(store.activeConnectionId, null);
assert.equal(store.connectionErrors["conn-1"], "连接可能已断开,请刷新数据重试");
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
restoreStorage();
}
});

View File

@ -46,6 +46,15 @@ function oracleConn(id: string): ConnectionConfig {
};
}
function withConnectionHealthMock(handler: typeof fetch): typeof fetch {
return (async (input, init) => {
if (String(input) === "/api/connection/check-health") {
return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } });
}
return handler(input, init);
});
}
async function waitFor(predicate: () => boolean, timeoutMs = 1000) {
const started = Date.now();
while (!predicate()) {
@ -383,7 +392,7 @@ test("completed query executions append result runs and select the latest run",
let executeCount = 0;
connectionStore.addEphemeralConnection(conn("conn-1"));
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
@ -406,7 +415,7 @@ test("completed query executions append result runs and select the latest run",
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const tabId = store.createTab("conn-1", "db", "Query");
@ -438,7 +447,7 @@ test("failed query executions append switchable error result runs", async () =>
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-1"));
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
@ -451,7 +460,7 @@ test("failed query executions append switchable error result runs", async () =>
return new Response("backend exploded", { status: 500 });
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const tabId = store.createTab("conn-1", "db", "Query");
@ -469,6 +478,43 @@ test("failed query executions append switchable error result runs", async () =>
}
});
test("query execution errors mentioning connection keep the connection active", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-1"));
connectionStore.activeConnectionId = "conn-1";
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/execute-multi") {
return new Response('relation "connection" does not exist', { status: 500 });
}
return new Response("unexpected request", { status: 500 });
});
try {
const tabId = store.createTab("conn-1", "db", "Query");
await store.executeTabSql(tabId, "select connection from missing_table");
assert.equal(connectionStore.connectedIds.has("conn-1"), true);
assert.equal(connectionStore.activeConnectionId, "conn-1");
assert.equal(connectionStore.connectionErrors["conn-1"], undefined);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("statement result switching is scoped to the active result run", async () => {
setActivePinia(createPinia());
const store = useQueryStore();
@ -524,7 +570,7 @@ test("normalizes unquoted Oracle query identifiers before loading editable metad
connectionStore.addEphemeralConnection(oracleConn("oracle-1"));
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/execute-multi") {
return new Response(
@ -596,7 +642,7 @@ test("normalizes unquoted Oracle query identifiers before loading editable metad
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const tabId = store.createTab("oracle-1", "ORCL", "Query 1", "query", "app");
@ -626,7 +672,7 @@ test("evicting cached tab results releases multi-result payloads and sessions",
connectionStore.addEphemeralConnection(conn("conn-1"));
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/execute-multi") {
executeCount++;
@ -675,7 +721,7 @@ test("evicting cached tab results releases multi-result payloads and sessions",
);
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const tabIds: string[] = [];
@ -709,7 +755,7 @@ test("result cache eviction keeps recently accessed inactive tabs", async () =>
connectionStore.addEphemeralConnection(conn("conn-1"));
globalThis.fetch = (async (input) => {
globalThis.fetch = withConnectionHealthMock(async (input) => {
const url = String(input);
if (url === "/api/query/execute-multi") {
executeCount++;
@ -743,7 +789,7 @@ test("result cache eviction keeps recently accessed inactive tabs", async () =>
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const tabIds: string[] = [];
@ -775,9 +821,9 @@ test("result cache eviction keeps recently accessed inactive tabs", async () =>
test("closing tabs clears removed result payloads before dropping tab references", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = withConnectionHealthMock(async () => {
return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
});
try {
setActivePinia(createPinia());
const store = useQueryStore();
@ -813,9 +859,9 @@ test("closing tabs clears removed result payloads before dropping tab references
test("closing database tabs removes browser tabs for that database only", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = withConnectionHealthMock(async () => {
return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
});
try {
setActivePinia(createPinia());
@ -863,9 +909,9 @@ test("closing database tabs removes browser tabs for that database only", async
test("closing connection tabs removes every tab for that connection only", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = withConnectionHealthMock(async () => {
return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
});
try {
setActivePinia(createPinia());
@ -910,9 +956,9 @@ test("closing connection tabs removes every tab for that connection only", async
test("releasing connection tabs keeps SQL tabs and closes object tabs", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = withConnectionHealthMock(async () => {
return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
});
try {
setActivePinia(createPinia());
@ -970,9 +1016,9 @@ test("releasing connection tabs keeps SQL tabs and closes object tabs", async ()
test("releasing database tabs keeps SQL tabs and closes table tabs for that database only", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = withConnectionHealthMock(async () => {
return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
});
try {
setActivePinia(createPinia());
@ -1015,9 +1061,9 @@ test("releasing database tabs keeps SQL tabs and closes table tabs for that data
test("disconnecting a connection closes every tab for that connection", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
globalThis.fetch = withConnectionHealthMock(async () => {
return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
});
try {
setActivePinia(createPinia());
@ -1066,7 +1112,7 @@ test("starting a new query clears the previous result payload immediately", asyn
execution_time_ms: 1,
};
globalThis.fetch = (async (input) => {
globalThis.fetch = withConnectionHealthMock(async (input) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), {
@ -1087,7 +1133,7 @@ test("starting a new query clears the previous result payload immediately", asyn
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const execution = store.executeTabSql(tabId, "select 1");
@ -1120,7 +1166,7 @@ test("grid refreshes can preserve the previous result while loading", async () =
};
tab.result = previousResult;
globalThis.fetch = (async (input) => {
globalThis.fetch = withConnectionHealthMock(async (input) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "select 1 order by name", useAgentResultSession: false }), {
@ -1141,7 +1187,7 @@ test("grid refreshes can preserve the previous result while loading", async () =
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const execution = store.executeTabSql(tabId, "select 1 order by name", {
@ -1172,7 +1218,7 @@ test("data tab execution preserves pagination offset metadata", async () => {
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
preparedPagination = true;
@ -1186,7 +1232,7 @@ test("data tab execution preserves pagination offset metadata", async () => {
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 100 OFFSET 100;', {
@ -1224,7 +1270,7 @@ test("activating an empty data tab waits for explicit execution", async () => {
tab.resultPageLimit = 50;
tab.resultPageOffset = 50;
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/execute-multi") {
executeBody = JSON.parse(String(init?.body ?? "{}"));
@ -1234,7 +1280,7 @@ test("activating an empty data tab waits for explicit execution", async () => {
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.reloadEvictedTab(tabId);
@ -1275,7 +1321,7 @@ test("query result export fetches every paginated page", async () => {
has_more: true,
};
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
@ -1306,7 +1352,7 @@ test("query result export fetches every paginated page", async () => {
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const exported = await store.fetchTabResultForExport(tabId);
@ -1350,7 +1396,7 @@ test("query result export treats the known query total as a progress estimate",
has_more: true,
};
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
@ -1378,7 +1424,7 @@ test("query result export treats the known query total as a progress estimate",
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const exported = await store.fetchTabResultForExport(tabId, (info) => progress.push(info));
@ -1416,7 +1462,7 @@ test("jdbc query pagination uses result sessions without capping max rows to one
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
prepareBody = JSON.parse(String(init?.body ?? "{}"));
@ -1453,7 +1499,7 @@ test("jdbc query pagination uses result sessions without capping max rows to one
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.executeTabSql(tabId, "SELECT * FROM CT_Loc");
@ -1518,7 +1564,7 @@ test("table data export fetches every filtered page", async () => {
primaryKeys: ["id"],
};
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/build-table-select-sql") {
const body = JSON.parse(String(init?.body ?? "{}"));
@ -1541,7 +1587,7 @@ test("table data export fetches every filtered page", async () => {
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
const exported = await store.fetchTabResultForExport(tabId);
@ -1599,7 +1645,7 @@ test("query execution finishes without waiting for metadata analysis", async ()
assert.ok(tab);
let resolveMetadata: ((value: Response) => void) | undefined;
globalThis.fetch = (async (input) => {
globalThis.fetch = withConnectionHealthMock(async (input) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "select id from users", useAgentResultSession: false }), {
@ -1619,7 +1665,7 @@ test("query execution finishes without waiting for metadata analysis", async ()
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.executeTabSql(tabId, "select id from users");
@ -1652,7 +1698,7 @@ test("query execution is scoped to the tab client session", async () => {
const tabId = store.createTab("conn-1", "db", "Query");
let executeBody: any;
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), {
@ -1674,7 +1720,7 @@ test("query execution is scoped to the tab client session", async () => {
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.executeTabSql(tabId, "select 1");
@ -1701,7 +1747,7 @@ test("query execution keeps automatically counting total rows in the background"
let resolveCount: ((value: Response) => void) | undefined;
let countBody: any;
globalThis.fetch = (async (input, init) => {
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(
@ -1742,7 +1788,7 @@ test("query execution keeps automatically counting total rows in the background"
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.executeTabSql(tabId, "select id from users");
@ -1786,7 +1832,7 @@ test("paginated query execution keeps the previous total while refreshing it in
tab.resultTotalRowCount = 250;
let resolveCount: ((value: Response) => void) | undefined;
globalThis.fetch = (async (input) => {
globalThis.fetch = withConnectionHealthMock(async (input) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(
@ -1826,7 +1872,7 @@ test("paginated query execution keeps the previous total while refreshing it in
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.executeTabSql(tabId, "select id from users", {
@ -1867,7 +1913,7 @@ test("multi statement execution shows the first result set by default", async ()
connectionStore.addEphemeralConnection(conn("conn-1"));
const tabId = store.createTab("conn-1", "db", "Query");
globalThis.fetch = (async (input) => {
globalThis.fetch = withConnectionHealthMock(async (input) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "set @id = 1; select @id", useAgentResultSession: false }), {
@ -1891,7 +1937,7 @@ test("multi statement execution shows the first result set by default", async ()
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
});
try {
await store.executeTabSql(tabId, "set @id = 1; select @id");
@ -2005,7 +2051,7 @@ test("reorderTab preserves relative order within pinned group", () => {
const tabD = store.createTab("conn-1", "db", "D", "query");
const tabE = store.createTab("conn-1", "db", "E", "query");
// Pin A, B, C leave D, E unpinned
// Pin A, B, C; leave D, E unpinned
store.togglePinnedTab(tabA);
// toggle so orderPinnedFirst runs: [A, B, C, D, E]
store.togglePinnedTab(tabB);

View File

@ -1017,7 +1017,7 @@ pub async fn connection_final_proxy_port(
#[tauri::command]
pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: String) -> Result<(), String> {
state.remove_connection_pools_detached(&connection_id).await;
state.remove_connection_pools(&connection_id).await;
drop_mq_adapters_for_connection_ids(state.inner(), std::slice::from_ref(&connection_id)).await;
state.reset_connection_transport(&connection_id).await;
if connection_id.starts_with("__visible_draft_") {
@ -1043,6 +1043,11 @@ pub async fn refresh_connections(state: State<'_, Arc<AppState>>) -> Result<(),
Ok(())
}
#[tauri::command]
pub async fn check_connection_health(state: State<'_, Arc<AppState>>, connection_id: String) -> Result<(), String> {
state.check_connection_health(&connection_id).await
}
/// Check whether a connection has read-only protection enabled.
/// Returns an error if the connection is read-only, preventing write operations.
pub async fn ensure_connection_writable(

View File

@ -423,6 +423,7 @@ pub fn run() {
commands::connection::disconnect_db,
commands::connection::close_database_connection,
commands::connection::refresh_connections,
commands::connection::check_connection_health,
commands::connection::save_connections,
commands::connection::load_connections,
commands::connection::save_sidebar_layout,