Merge pull request #1586 from onceMisery/feat/opt-connections
fix: handle connection loss state for new queries
This commit is contained in:
commit
ee29694776
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue