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

@@ -14,29 +14,30 @@ function dataResult(columns: string[]): QueryResult {
describe("isNoSnapshotErrorResult", () => {
it("matches the StarRocks Paimon no-snapshot error surfaced via executeTabSql", () => {
const result = errorResult("Server error: `ERROR HY000 (1064): There is currently no snapshot.`");
const result = { ...errorResult("Server error: `ERROR HY000 (1064): There is currently no snapshot.`"), execution_error: true as const };
expect(isNoSnapshotErrorResult(result)).toBe(true);
});
it("matches case-insensitively", () => {
const result = errorResult("there IS currently NO snapshot");
const result = { ...errorResult("there IS currently NO snapshot"), execution_error: true as const };
expect(isNoSnapshotErrorResult(result)).toBe(true);
});
it("does not match unrelated query errors", () => {
expect(isNoSnapshotErrorResult(errorResult("Unknown table 'tag_test.record_tag_t'"))).toBe(false);
expect(isNoSnapshotErrorResult(errorResult("ERROR 1142: SELECT command denied"))).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("Unknown table 'tag_test.record_tag_t'"), execution_error: true })).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("ERROR 1142: SELECT command denied"), execution_error: true })).toBe(false);
});
it("does not match successful data results", () => {
expect(isNoSnapshotErrorResult(dataResult(["id", "name"]))).toBe(false);
expect(isNoSnapshotErrorResult(dataResult(["Error"]))).toBe(false); // data column literally named Error, no rows
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), rows: [["There is currently no snapshot."]] })).toBe(false);
});
it("returns false for missing or empty results", () => {
expect(isNoSnapshotErrorResult(undefined)).toBe(false);
expect(isNoSnapshotErrorResult(null)).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), rows: [] })).toBe(false);
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), execution_error: true, rows: [] })).toBe(false);
});
});
@@ -60,11 +60,17 @@ describe("SQL Server explain plan", () => {
});
it("surfaces a SQL Server batch error instead of treating it as a plan", () => {
expect(sqlServerExplainResult([result(["Error"], [["Invalid object name 'missing_table'"]])])).toEqual({
expect(sqlServerExplainResult([{ ...result(["Error"], [["Invalid object name 'missing_table'"]]), execution_error: true }])).toEqual({
error: "Invalid object name 'missing_table'",
});
});
it("does not mistake a successful Error column for a SQL Server batch failure", () => {
expect(sqlServerExplainResult([result(["Error"], [["valid query value"]])])).toEqual({
error: "SQL Server did not return ShowPlan XML",
});
});
it("rejects malformed SHOWPLAN XML", () => {
const malformed = result(["Microsoft SQL Server 2005 XML Showplan"], [["<ShowPlanXML><RelOp></ShowPlanXML>"]]);
+13 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { connectionGroupDisplayName, executionSummaryItems, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, QueryTab } from "@/types/database";
@@ -224,6 +224,18 @@ describe("query result source ranges", () => {
});
});
describe("execution summary", () => {
it("uses the explicit execution marker instead of the result column name", () => {
const successfulAlias = { columns: ["Error"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 };
const markedFailure = { columns: ["Error"], rows: [["failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true as const };
expect(executionSummaryItems({ results: [successfulAlias, markedFailure] }).map(({ status, isError }) => ({ status, isError }))).toEqual([
{ status: "success", isError: false },
{ status: "error", isError: true },
]);
});
});
describe("statement execution markers", () => {
it("renders a live marker for a single statement", () => {
const sql = "SELECT 1";
+3 -2
View File
@@ -9,6 +9,7 @@ import { aiSkillForAction } from "@/lib/ai/aiSkills";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { AgentEvent } from "@/lib/backend/tauri";
@@ -674,12 +675,12 @@ function prioritizeSchemas(schemas: string[]): string[] {
}
function extractLastError(result?: QueryResult): string | undefined {
if (!result?.columns.includes("Error")) return undefined;
if (!result || !isQueryExecutionErrorResult(result)) return undefined;
return result.rows[0]?.[0] == null ? undefined : String(result.rows[0][0]);
}
function formatResultPreview(result?: QueryResult): string | undefined {
if (!result || result.columns.includes("Error") || !result.rows.length) return undefined;
if (!result || isQueryExecutionErrorResult(result) || !result.rows.length) return undefined;
const MAX_VALUE_CHARS = 200;
const rows = result.rows.slice(0, 5).map((row) => {
return result.columns
@@ -2,6 +2,87 @@
* Utility functions for consistent error handling across the application.
*/
export type BackendErrorParam = string | number | boolean;
export interface BackendError {
version: 1;
code: string;
messageKey: string;
messageParams: Record<string, BackendErrorParam>;
source: "jdbcAgent" | "jdbcAgentLegacy" | "legacyBackend";
operationOutcome: "not_started" | "unknown";
detail?: string;
diagnostics?: Record<string, unknown>;
helpUrl?: string;
}
function isBackendError(value: unknown): value is BackendError {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
if (
candidate.version !== 1 ||
typeof candidate.code !== "string" ||
!/^DBX-[A-Z][A-Z0-9]*-\d{4}$/.test(candidate.code) ||
typeof candidate.messageKey !== "string" ||
!candidate.messageKey.startsWith("backendErrors.") ||
!candidate.messageParams ||
typeof candidate.messageParams !== "object" ||
Array.isArray(candidate.messageParams) ||
!["jdbcAgent", "jdbcAgentLegacy", "legacyBackend"].includes(String(candidate.source)) ||
!["not_started", "unknown"].includes(String(candidate.operationOutcome))
) {
return false;
}
if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false;
return Object.values(candidate.messageParams).every((param) => typeof param === "string" || typeof param === "boolean" || (typeof param === "number" && Number.isFinite(param)));
}
export function normalizeBackendError(error: unknown): BackendError | null {
if (error instanceof BackendErrorException) return error.backendError;
if (isBackendError(error)) return error;
if (error && typeof error === "object" && "backendError" in error) {
const backendError = (error as { backendError: unknown }).backendError;
if (isBackendError(backendError)) return backendError;
}
if (error && typeof error === "object" && "error" in error) {
const nested = (error as { error: unknown }).error;
if (isBackendError(nested)) return nested;
}
return null;
}
export class BackendErrorException extends Error {
readonly backendError: BackendError;
constructor(error: unknown) {
const backendError = normalizeRawBackendError(error);
const fallbackMessage = typeof error === "string" ? error : error instanceof Error ? error.message : "Backend request failed";
super(backendError?.detail || fallbackMessage);
this.name = "BackendErrorException";
this.backendError = backendError ?? {
version: 1,
code: "DBX-LEGACY-0001",
messageKey: "backendErrors.legacy",
messageParams: {},
source: "legacyBackend",
operationOutcome: "unknown",
detail: fallbackMessage,
};
}
}
function normalizeRawBackendError(error: unknown): BackendError | null {
if (typeof error === "string") {
try {
const parsed: unknown = JSON.parse(error);
return normalizeBackendError(parsed);
} catch {
return null;
}
}
return normalizeBackendError(error);
}
/**
* Formats an unknown error value into a human-readable string.
* Handles Error objects, strings, null/undefined, and other types.
@@ -17,6 +98,9 @@
* }
*/
export function formatError(e: unknown): string {
const backendError = normalizeBackendError(e);
if (backendError?.detail) return backendError.detail;
if (e instanceof Error) {
return e.message;
}
+18 -6
View File
@@ -45,6 +45,7 @@ import type {
TunnelProfile,
} from "@/types/database";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@@ -223,19 +224,19 @@ async function post<T>(url: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
async function get<T>(url: string): Promise<T> {
const res = await fetch(apiUrl(url));
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
async function del<T>(url: string): Promise<T> {
const res = await fetch(apiUrl(url), { method: "DELETE" });
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
@@ -245,10 +246,21 @@ async function put<T>(url: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
async function backendResponseError(response: Response): Promise<BackendErrorException> {
const text = await response.text();
let payload: unknown = text;
try {
payload = JSON.parse(text);
} catch {
// Preserve legacy plain-text responses at the same compatibility boundary.
}
return new BackendErrorException(payload);
}
function qs(params: Record<string, string | number | boolean | undefined>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
@@ -880,7 +892,7 @@ export interface ExecuteMultiProgress {
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
error?: BackendError;
}
export async function executeMultiWithProgress(
@@ -918,7 +930,7 @@ export async function executeMultiWithProgress(
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;
+28 -17
View File
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
@@ -1060,14 +1061,18 @@ export async function executeQuery(
executionMode?: "simple";
},
): Promise<QueryResult> {
return invoke("execute_query", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
try {
return await invoke("execute_query", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
} catch (error) {
throw new BackendErrorException(error);
}
}
export async function executeMulti(
@@ -1089,14 +1094,18 @@ export async function executeMulti(
executionMode?: "simple";
},
): Promise<QueryResult[]> {
return invoke("execute_multi", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
try {
return await invoke("execute_multi", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
} catch (error) {
throw new BackendErrorException(error);
}
}
export interface ExecuteMultiProgress {
@@ -1107,7 +1116,7 @@ export interface ExecuteMultiProgress {
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
error?: BackendError;
}
export async function executeMultiWithProgress(
@@ -1137,6 +1146,8 @@ export async function executeMultiWithProgress(
});
try {
return await invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...invokeOptions });
} catch (error) {
throw new BackendErrorException(error);
} finally {
unlisten();
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { DatabaseType, QueryResult } from "@/types/database";
import * as api from "@/lib/backend/api";
import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
export interface ExplainPlanNode {
id: string;
@@ -376,7 +377,7 @@ export function flattenExplainPlanNodes(nodes: ExplainPlanNode[]): ExplainPlanNo
}
export function sqlServerExplainResult(results: QueryResult[]): { result?: QueryResult; error?: string } {
const errorResult = results.find((result) => result.columns.length === 1 && result.columns[0] === "Error" && result.rows.length > 0);
const errorResult = results.find((result) => isQueryExecutionErrorResult(result) && result.rows.length > 0);
if (errorResult) return { error: String(errorResult.rows[0]?.[0] ?? "") };
const result = results.find((candidate) => firstSqlServerShowplanXml(candidate) !== undefined);
@@ -25,6 +25,6 @@ export function isQueryExecutionErrorResult(result: QueryResult): boolean {
}
export function isNoSnapshotErrorResult(result: QueryResult | undefined | null): boolean {
if (!result || !result.columns.includes("Error") || result.rows.length === 0) return false;
if (!result || !isQueryExecutionErrorResult(result) || result.rows.length === 0) return false;
return NO_SNAPSHOT_ERROR_PATTERN.test(String(result.rows[0]?.[0] ?? ""));
}
@@ -1,14 +1,11 @@
import type { QueryResult, QueryTab } from "@/types/database";
import type { QueryTab } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
export type DataTableDoubleClickAction = "activate" | "open" | "none";
function isErrorResult(result: QueryResult | undefined): boolean {
return result?.columns.length === 1 && result.columns[0] === "Error";
}
export function canActivateExistingDataTableTab(tab: QueryTab, options: { activateExecuting?: boolean } = {}): boolean {
if (tab.isExecuting) return options.activateExecuting !== false;
if (isErrorResult(tab.result)) return false;
if (tab.result && isQueryExecutionErrorResult(tab.result)) return false;
return !!tab.result || !!tab.results?.length;
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -378,7 +379,7 @@ export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" |
});
}
return results.map((result, index) => {
const isError = result.execution_error === true || result.columns.includes("Error");
const isError = isQueryExecutionErrorResult(result);
return {
result,
index,