fix(query): preserve structured backend errors

This commit is contained in:
miracle authored and GitHub committed 2026-08-02 16:37:46 +08:00
1 parent 2e9dcdc26b
commit bcafc4a474
58 files changed
+4182 -554

No files matched your search

@@ -76,6 +76,17 @@ function deferred<T>() {
return { promise, resolve, reject };
}
function structuredTimeoutError() {
return {
version: 1 as const,
code: "DBX-JDBC-2002",
messageKey: "backendErrors.jdbc.operationTimedOut",
messageParams: { stage: "execute" },
source: "jdbcAgent" as const,
operationOutcome: "unknown" as const,
};
}
describe("queryStore multi-statement errors", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -115,7 +126,7 @@ describe("queryStore multi-statement errors", () => {
success,
executionTimeMs: result.execution_time_ms,
affectedRows: result.affected_rows,
error: success ? undefined : String(result.rows[0]?.[0] ?? ""),
error: success ? undefined : result.error,
});
});
return results;
@@ -123,9 +134,10 @@ describe("queryStore multi-statement errors", () => {
});
it("opens the first error result from a mixed result batch", async () => {
const structuredError = structuredTimeoutError();
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["Error"], execution_error: true, rows: [["no such table: missing"]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["Error"], execution_error: true, error: structuredError, rows: [["no such table: missing"]], affected_rows: 0, execution_time_ms: 1 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
@@ -136,6 +148,10 @@ describe("queryStore multi-statement errors", () => {
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.activeResultIndex).toBe(1);
expect(tab.result?.columns).toEqual(["Error"]);
expect(tab.result?.error).toEqual(structuredError);
expect(tab.batchSqlExecution?.items[1]?.errorDetails).toEqual(structuredError);
expect(tab.batchSqlExecution?.items[1]?.error).not.toBe(structuredError.code);
expect(tab.batchSqlExecution?.items[1]?.error).not.toBe("[object Object]");
});
it("updates live per-statement progress before the batch promise resolves", async () => {
@@ -195,6 +211,24 @@ describe("queryStore multi-statement errors", () => {
});
});
it("preserves a structured top-level batch failure", async () => {
const structuredError = structuredTimeoutError();
mocks.executeMultiWithProgress.mockRejectedValueOnce(structuredError);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, "SELECT 1;\nSELECT 2");
await store.executeTabSql(tabId, "SELECT 1;\nSELECT 2");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.result?.error).toEqual(structuredError);
expect(tab.result?.rows[0]?.[0]).toEqual(expect.any(String));
expect(tab.result?.rows[0]?.[0]).not.toBe(structuredError.code);
expect(tab.result?.rows[0]?.[0]).not.toBe("[object Object]");
expect(tab.batchSqlExecution?.items[0]?.errorDetails).toEqual(structuredError);
expect(tab.batchSqlExecution?.items[0]?.error).toBe(tab.result?.rows[0]?.[0]);
});
it("keeps completed progress and records a later top-level batch failure", async () => {
mocks.executeMultiWithProgress.mockImplementationOnce((_connectionId, _database, _sql, onProgress, _schema, options) => {
onProgress({
@@ -359,7 +393,7 @@ describe("queryStore multi-statement errors", () => {
database: "FooDB",
query_timeout_secs: 30,
});
mocks.executeMulti.mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]).mockResolvedValueOnce([{ columns: ["Error"], rows: [["Database does not exist"]], affected_rows: 0, execution_time_ms: 1 }]);
mocks.executeMulti.mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]).mockResolvedValueOnce([{ columns: ["Error"], rows: [["Database does not exist"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabA = store.createTab("sqlserver-1", "FooDB", "Tab A", "query", "dbo");
+17 -15
View File
@@ -61,7 +61,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useExportTracker } from "@/composables/useExportTracker";
import { recordQueryCancellationLatency, resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { formatError } from "@/lib/backend/errorUtils";
import { formatError, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
@@ -278,7 +278,7 @@ function applyBatchSqlProgress(
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
error?: BackendError;
},
continueOnError: boolean,
) {
@@ -289,7 +289,8 @@ function applyBatchSqlProgress(
item.status = progress.success ? "success" : "error";
item.executionTimeMs = progress.executionTimeMs;
item.affectedRows = progress.affectedRows;
item.error = progress.error;
item.errorDetails = progress.error;
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
batch.completed = Math.max(batch.completed, progress.completed);
if ((progress.success || continueOnError) && progress.completed < batch.total) {
const next = batch.items[progress.statementIndex + 1];
@@ -310,7 +311,8 @@ function reconcileBatchSqlResults(tab: QueryTab, executionId: string, results: Q
item.status = failed ? "error" : "success";
item.executionTimeMs = result.execution_time_ms;
item.affectedRows = result.affected_rows;
item.error = failed ? String(result.rows[0]?.[0] ?? "") : undefined;
item.errorDetails = failed ? result.error : undefined;
item.error = failed ? (result.error ? translateBackendError(i18n.global.t, result.error) : String(result.rows[0]?.[0] ?? "")) : undefined;
}
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
}
@@ -321,7 +323,8 @@ function failBatchSqlExecution(tab: QueryTab, executionId: string, error: unknow
const item = batch.items.find((candidate) => candidate.status === "running") ?? batch.items.find((candidate) => candidate.status === "pending");
if (!item) return;
item.status = cancelled ? "cancelled" : "error";
item.error = cancelled ? undefined : error instanceof Error ? error.message : String(error);
item.errorDetails = cancelled ? undefined : (normalizeBackendError(error) ?? undefined);
item.error = cancelled ? undefined : translateBackendError(i18n.global.t, error);
batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
}
@@ -329,7 +332,7 @@ function finishBatchSqlExecution(tab: QueryTab, executionId: string, cancelled:
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
if (cancelled) {
const cancelledError = [...batch.items].reverse().find((item) => item.status === "error" && /cancel|取消/i.test(item.error ?? ""));
const cancelledError = [...batch.items].reverse().find((item) => item.status === "error" && (item.errorDetails?.code === "DBX-JDBC-2003" || /cancel|取消/i.test(item.error ?? "")));
if (cancelledError) {
cancelledError.status = "cancelled";
cancelledError.error = undefined;
@@ -384,9 +387,7 @@ function sqlServerUseDatabaseFromStatement(statement: string | undefined): strin
}
function isSqlServerBatchErrorResult(result: QueryResult): boolean {
// SQL Server batch errors can arrive without execution_error metadata.
// A standalone USE statement cannot legitimately return an Error column.
return result.execution_error === true || (result.columns.length === 1 && result.columns[0] === "Error" && result.rows.length > 0);
return result.execution_error === true;
}
function sapHanaCurrentSchemaFromResult(result: QueryResult): string | undefined {
@@ -2717,13 +2718,14 @@ export const useQueryStore = defineStore("query", () => {
}
function toErrorResult(e: any): NonNullable<QueryTab["result"]> {
const raw = e instanceof Error ? e.message : String(e);
// Single funnel for every query execution failure, so backend messages DBX
// knows about are shown in the active locale rather than as raw English.
const message = translateBackendError(i18n.global.t, raw);
const error = normalizeBackendError(e) ?? undefined;
const message = translateBackendError(i18n.global.t, e);
return markQueryResultRowsRaw({
columns: ["Error"],
execution_error: true,
error,
rows: [[message]],
affected_rows: 0,
execution_time_ms: 0,
@@ -3422,7 +3424,7 @@ export const useQueryStore = defineStore("query", () => {
connStore.invalidateCompletionCache(tab.connectionId, String(currentDb));
}
} catch (e: any) {
allResults.push(annotateQueryResultSource({ columns: ["Error"], rows: [[e?.message ?? String(e)]], affected_rows: 0, execution_time_ms: 0 }, command, undefined, undefined, sourceRange));
allResults.push(annotateQueryResultSource(toErrorResult(e), command, undefined, undefined, sourceRange));
}
}
queryExecutionLog("info", "redis:done", { traceId, commandCount: commands.length, elapsed: elapsed() });
@@ -3431,7 +3433,7 @@ export const useQueryStore = defineStore("query", () => {
if (current?.executionId === executionId) {
if (openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (allResults.length > 1) {
const activeResultIndex = allResults.findIndex((r) => !r.columns.includes("Error"));
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
current.results = allResults;
current.activeResultIndex = resultIndex;
@@ -3770,7 +3772,7 @@ export const useQueryStore = defineStore("query", () => {
} else if (allResults.length > 1) {
// Open grouped output on the first non-error result when possible so
// mixed success/error batches land on the most useful table first.
const activeResultIndex = allResults.findIndex((result) => !result.columns.includes("Error"));
const activeResultIndex = allResults.findIndex((result) => !isQueryExecutionErrorResult(result));
const resultIndex = preservedResultIndex(allResults, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
current.results = allResults;
current.activeResultIndex = resultIndex;
@@ -3839,7 +3841,7 @@ export const useQueryStore = defineStore("query", () => {
if (current?.executionId === executionId && openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
if (current?.executionId === executionId && allResults.length > 0) {
clearResultNavigationState(current);
const errorResultIndex = allResults.findIndex((result) => result.columns.includes("Error") || elasticsearchHttpErrorStatus(result) !== undefined);
const errorResultIndex = allResults.findIndex((result) => isQueryExecutionErrorResult(result) || elasticsearchHttpErrorStatus(result) !== undefined);
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : 0;
current.results = allResults.length > 1 ? allResults : undefined;
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;