fix(errors): unify backend error protocol and preserve db detail
This commit is contained in:
1 parent
f1617bbd87
commit
c8c40ba839
24 files changed
+1197
-182
No files matched your search
@@ -156,7 +156,7 @@ async function save() {
|
||||
await store.saveProfiles(cloneProfiles(draft.value));
|
||||
toast(t("settings.tunnelsSaved"));
|
||||
} catch (error) {
|
||||
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, String(error)) }), 5000);
|
||||
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ async function testSelected() {
|
||||
testResult.value = { ok: true, message: message ? t("settings.tunnelsTestSuccess") + ": " + message : t("settings.tunnelsTestSuccess") };
|
||||
} catch (error) {
|
||||
if (!testGuard.isCurrent(requestId, profile)) return;
|
||||
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, String(error)) }) };
|
||||
testResult.value = { ok: false, message: t("settings.tunnelsTestFailed", { message: translateBackendError(t, error) }) };
|
||||
} finally {
|
||||
if (testGuard.isCurrent(requestId, selectedSsh.value || selectedProxy.value)) isTesting.value = false;
|
||||
}
|
||||
|
||||
@@ -3008,7 +3008,7 @@ async function aiTestConn() {
|
||||
aiTestResult.value = "success";
|
||||
} catch (e: any) {
|
||||
aiTestResult.value = "error";
|
||||
aiTestError.value = translateBackendError(t, e?.message || String(e));
|
||||
aiTestError.value = translateBackendError(t, e);
|
||||
} finally {
|
||||
aiTesting.value = false;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ async function refreshTree() {
|
||||
try {
|
||||
await connectionStore.refreshAllTree();
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ async function loadTopics(force = false) {
|
||||
try {
|
||||
await topicSelectRef.value?.loadTopics();
|
||||
} catch (e: unknown) {
|
||||
error.value = translateBackendError(t, formatError(e));
|
||||
error.value = translateBackendError(t, e);
|
||||
} finally {
|
||||
topicsLoading.value = false;
|
||||
}
|
||||
@@ -261,7 +261,7 @@ async function loadRuntimeClients() {
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (isRuntimeLoadCurrent(loadSeq, currentKey)) {
|
||||
error.value = translateBackendError(t, formatError(e)) || String(e);
|
||||
error.value = translateBackendError(t, e);
|
||||
}
|
||||
} finally {
|
||||
if (loadSeq === runtimeLoadSeq) {
|
||||
|
||||
@@ -77,7 +77,7 @@ async function loadData() {
|
||||
throw firstError;
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ async function installExtension(name: string) {
|
||||
await loadData();
|
||||
emit("changed");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
} finally {
|
||||
installing.value = null;
|
||||
}
|
||||
@@ -122,7 +122,7 @@ async function dropExtension(name: string) {
|
||||
await loadData();
|
||||
emit("changed");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
} finally {
|
||||
dropping.value = null;
|
||||
}
|
||||
|
||||
@@ -2136,7 +2136,7 @@ async function confirmPasteTable() {
|
||||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||||
toast(t("contextMenu.pasteTableCancelledAfterPartial"), 5000);
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -779,7 +779,7 @@ async function loadMoreObjectGroupChildren() {
|
||||
try {
|
||||
await connectionStore.loadMoreObjectGroupChildren(node);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,7 +788,7 @@ async function loadAllObjectGroupChildren() {
|
||||
try {
|
||||
await connectionStore.loadAllObjectGroupChildren(node);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1150,7 +1150,7 @@ async function openObjectBrowser() {
|
||||
await toggle();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
openDriverStoreForInstallError(e?.message || String(e));
|
||||
}
|
||||
}
|
||||
@@ -1163,7 +1163,7 @@ async function openUserAdmin() {
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.openUserAdmin(node.connectionId);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1175,7 +1175,7 @@ async function openProcessList() {
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.openProcessList(node.connectionId);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1193,7 +1193,7 @@ async function openServerDashboard() {
|
||||
queryStore.openMysqlDashboard(node.connectionId);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,7 +1205,7 @@ async function openDamengJobAdmin() {
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.openDamengJobAdmin(node.connectionId);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,7 +1240,7 @@ async function newQuery() {
|
||||
const options = await getDatabaseOptions(node.connectionId);
|
||||
queryStore.createTab(node.connectionId, resolveDefaultDatabase(connection, options), undefined, "query");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
openDriverStoreForInstallError(e?.message || String(e));
|
||||
}
|
||||
}
|
||||
@@ -1312,7 +1312,7 @@ async function newSelectTemplate() {
|
||||
});
|
||||
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,7 +1332,7 @@ async function newInsertTemplate() {
|
||||
});
|
||||
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1351,7 +1351,7 @@ async function newUpdateTemplate() {
|
||||
});
|
||||
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1370,7 +1370,7 @@ async function newDeleteTemplate() {
|
||||
});
|
||||
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1424,7 +1424,7 @@ async function refresh() {
|
||||
try {
|
||||
await connectionStore.refreshTreeNode(node);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
openDriverStoreForInstallError(e?.message || String(e), node);
|
||||
}
|
||||
}
|
||||
@@ -2539,7 +2539,7 @@ async function confirmEditDatabaseProperties() {
|
||||
showEditDatabasePropertiesDialog.value = false;
|
||||
await connectionStore.loadDatabases(node.connectionId, { force: true });
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
} finally {
|
||||
editDatabasePropertiesLoading.value = false;
|
||||
}
|
||||
@@ -2611,7 +2611,7 @@ async function confirmEditSchemaComment() {
|
||||
showEditSchemaCommentDialog.value = false;
|
||||
await connectionStore.loadSchemas(node.connectionId, node.database, { force: true });
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e) }), 5000);
|
||||
} finally {
|
||||
schemaCommentLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
|
||||
await copyToClipboard(String(port));
|
||||
toast(t("contextMenu.finalProxyPortCopied", { port }), 2000);
|
||||
} catch (error: any) {
|
||||
toast(t("grid.copyFailed", { message: translateBackendError(t, error?.message || String(error)) }), 5000);
|
||||
toast(t("grid.copyFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
|
||||
try {
|
||||
await revealPathInFileManager(path);
|
||||
} catch (error: any) {
|
||||
toast(translateBackendError(t, typeof error === "string" ? error : error?.message || String(error)), 5000);
|
||||
toast(translateBackendError(t, error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
|
||||
if (liveNode) liveNode.isExpanded = true;
|
||||
toast(t("nacos.namespaceCreated", { name: namespaceName }), 3000);
|
||||
} catch (error: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error?.message || String(error)) }), 5000);
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
} finally {
|
||||
createNacosNamespaceLoading.value = false;
|
||||
}
|
||||
@@ -322,7 +322,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
|
||||
await connectionStore.loadNacosNamespaces(node.connectionId, { force: true });
|
||||
toast(t("nacos.namespaceUpdated", { name: namespaceName }), 3000);
|
||||
} catch (error: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error?.message || String(error)) }), 5000);
|
||||
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
} finally {
|
||||
editNacosNamespaceLoading.value = false;
|
||||
}
|
||||
|
||||
+108
-1
@@ -240,6 +240,23 @@ describe("backend error translation", () => {
|
||||
expect(translateBackendError(t, error)).toBe(`${t(error.messageKey)}\n\n${error.detail}`);
|
||||
});
|
||||
|
||||
test("shows a native adapter code with the DuckDB detail", () => {
|
||||
const t = translatorFor("en");
|
||||
const error = {
|
||||
version: 1,
|
||||
code: "DBX-JDBC-4001",
|
||||
messageKey: "backendErrors.jdbc.sqlFailed",
|
||||
messageParams: { stage: "execute" },
|
||||
source: "jdbcAgent",
|
||||
operationOutcome: "unknown",
|
||||
origin: { subsystem: "database", adapter: "native", driver: "duckdb" },
|
||||
diagnostics: { category: "sql", stage: "execute", adapterCode: "duckdb_execute_failed" },
|
||||
detail: "Catalog Error: Table missing_table does not exist",
|
||||
} as const;
|
||||
|
||||
expect(translateBackendError(t, error)).toBe(`${t(error.messageKey, error.messageParams)}\n\n[duckdb_execute_failed] ${error.detail}`);
|
||||
});
|
||||
|
||||
test("hides internal Agent error data from structured error details", () => {
|
||||
const t = translatorFor("zh-CN");
|
||||
const detail = 'driver: bad connection\nDBX_AGENT_ERROR_DATA:{"category":null,"agentSessionId":"session-1"}';
|
||||
@@ -262,7 +279,7 @@ describe("backend error translation", () => {
|
||||
["non-finite params", { messageParams: { retryAfter: Number.POSITIVE_INFINITY } }],
|
||||
["non-string detail", { detail: 42 }],
|
||||
["object detail", { detail: { message: "database failure" } }],
|
||||
["unknown source", { source: "http" }],
|
||||
["non-string source", { source: 42 }],
|
||||
["unknown outcome", { operationOutcome: "completed" }],
|
||||
])("rejects malformed structured envelopes with %s", (_name, override) => {
|
||||
expect(
|
||||
@@ -278,12 +295,102 @@ describe("backend error translation", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("accepts unknown compatibility sources and extensible origins", () => {
|
||||
const error = normalizeBackendError({
|
||||
version: 1,
|
||||
code: "DBX-DB-4001",
|
||||
messageKey: "backendErrors.jdbc.sqlFailed",
|
||||
messageParams: { stage: "execute" },
|
||||
source: "nativeDatabase",
|
||||
operationOutcome: "unknown",
|
||||
origin: { subsystem: "database", adapter: "native", driver: "postgresql" },
|
||||
detail: "relation missing_table does not exist",
|
||||
});
|
||||
expect(error?.source).toBe("nativeDatabase");
|
||||
expect(error?.origin?.driver).toBe("postgresql");
|
||||
});
|
||||
|
||||
test("falls back to legacy text for plain HTTP and Tauri failures", () => {
|
||||
const t = translatorFor("zh-CN");
|
||||
const error = new BackendErrorException("legacy backend failure");
|
||||
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
|
||||
expect(translateBackendError(t, error)).toBe(`${t("backendErrors.legacy")}\n\nlegacy backend failure`);
|
||||
});
|
||||
|
||||
test("preserves JSON envelopes carried by strings and Error messages", () => {
|
||||
const envelope = {
|
||||
version: 1,
|
||||
code: "DBX-JDBC-4001",
|
||||
messageKey: "backendErrors.jdbc.sqlFailed",
|
||||
messageParams: { stage: "execute" },
|
||||
source: "jdbcAgent",
|
||||
operationOutcome: "unknown",
|
||||
detail: "relation missing_table does not exist",
|
||||
} as const;
|
||||
expect(normalizeBackendError(JSON.stringify(envelope))).toEqual(envelope);
|
||||
expect(normalizeBackendError(new Error(JSON.stringify(envelope)))).toEqual(envelope);
|
||||
});
|
||||
|
||||
test("retains bounded diagnostics from unknown rejection objects", () => {
|
||||
const error = new BackendErrorException({ reason: "database worker returned a vendor diagnostic" });
|
||||
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
|
||||
expect(error.backendError.detail).toBe("database worker returned a vendor diagnostic");
|
||||
expect(new BackendErrorException({ reason: "x".repeat(70_000) }).backendError.detail).toHaveLength(64 * 1024);
|
||||
});
|
||||
|
||||
test("normalizes structured errors across Error realms and module copies", () => {
|
||||
const envelope = {
|
||||
version: 1,
|
||||
code: "DBX-JDBC-5001",
|
||||
messageKey: "backendErrors.jdbc.protocolFailed",
|
||||
messageParams: {},
|
||||
source: "jdbcAgent" as const,
|
||||
operationOutcome: "unknown" as const,
|
||||
detail: "connection reset by peer",
|
||||
};
|
||||
const copiedError = Object.assign(new Error("Backend request failed"), {
|
||||
name: "BackendErrorException",
|
||||
backendError: envelope,
|
||||
});
|
||||
const workerError = { name: "BackendErrorException", message: JSON.stringify(envelope) };
|
||||
|
||||
expect(normalizeBackendError(copiedError)).toEqual(envelope);
|
||||
expect(normalizeBackendError(workerError)).toEqual(envelope);
|
||||
});
|
||||
|
||||
test("does not recurse forever through cyclic error wrappers", () => {
|
||||
const self: Record<string, unknown> = {};
|
||||
self.error = self;
|
||||
expect(normalizeBackendError(self)).toBeNull();
|
||||
expect(() => new BackendErrorException(self)).not.toThrow();
|
||||
|
||||
const first: Record<string, unknown> = {};
|
||||
const second: Record<string, unknown> = {};
|
||||
first.backendError = second;
|
||||
second.error = first;
|
||||
expect(normalizeBackendError(first)).toBeNull();
|
||||
expect(() => new BackendErrorException(first)).not.toThrow();
|
||||
});
|
||||
|
||||
test("stops at a finite wrapper depth", () => {
|
||||
let wrapper: Record<string, unknown> = { error: { message: "deep legacy error" } };
|
||||
for (let index = 0; index < 32; index += 1) wrapper = { backendError: wrapper };
|
||||
|
||||
expect(normalizeBackendError(wrapper)).toBeNull();
|
||||
});
|
||||
|
||||
test("does not stringify a structured envelope as [object Object]", () => {
|
||||
expect(
|
||||
formatError({
|
||||
version: 1,
|
||||
code: "DBX-JDBC-5001",
|
||||
messageKey: "backendErrors.jdbc.protocolFailed",
|
||||
messageParams: {},
|
||||
source: "jdbcAgent",
|
||||
operationOutcome: "unknown",
|
||||
}),
|
||||
).toBe("DBX-JDBC-5001");
|
||||
});
|
||||
});
|
||||
|
||||
// Matching on message text only works while both sides agree on the wording, so
|
||||
|
||||
@@ -128,7 +128,10 @@ function translateStructuredBackendError(t: BackendErrorTranslate, error: Backen
|
||||
const translated = t(error.messageKey, error.messageParams);
|
||||
const summary = translated !== error.messageKey ? translated : t("backendErrors.unknown");
|
||||
const detail = error.detail ? sanitizeBackendErrorMessage(error.detail).trim() : undefined;
|
||||
return detail && detail !== summary ? `${summary}\n\n${detail}` : summary;
|
||||
const rawAdapterCode = error.diagnostics?.adapterCode;
|
||||
const adapterCode = typeof rawAdapterCode === "string" && /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(rawAdapterCode) ? rawAdapterCode : undefined;
|
||||
const diagnosticDetail = detail && adapterCode ? `[${adapterCode}] ${detail}` : (detail ?? adapterCode);
|
||||
return diagnosticDetail && diagnosticDetail !== summary ? `${summary}\n\n${diagnosticDetail}` : summary;
|
||||
}
|
||||
|
||||
export function translateBackendError(t: BackendErrorTranslate, error: unknown): string {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { backendResponseError, importAgentDriver, installJdbcPluginLocal } from "@/lib/backend/http";
|
||||
import { BackendErrorException } from "@/lib/backend/errorUtils";
|
||||
|
||||
const envelope = {
|
||||
version: 1,
|
||||
code: "DBX-JDBC-4001",
|
||||
messageKey: "backendErrors.jdbc.sqlFailed",
|
||||
messageParams: { stage: "execute" },
|
||||
source: "jdbcAgent",
|
||||
operationOutcome: "unknown",
|
||||
detail: "Incorrect syntax near SELECT",
|
||||
} as const;
|
||||
|
||||
describe("HTTP backend error parsing", () => {
|
||||
test.each([
|
||||
["direct envelope", JSON.stringify(envelope), envelope],
|
||||
["nested envelope", JSON.stringify({ error: envelope }), envelope],
|
||||
["legacy text", "relation missing_table does not exist", undefined],
|
||||
["malformed JSON text", "{not-json", undefined],
|
||||
])("preserves %s body diagnostics", async (_name, body, expected) => {
|
||||
const error = await backendResponseError(new Response(body, { status: 500 }));
|
||||
if (expected) {
|
||||
expect(error.backendError).toEqual(expected);
|
||||
} else {
|
||||
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
|
||||
expect(error.backendError.detail).toBe(body);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses a stable summary for an empty body", async () => {
|
||||
const error = await backendResponseError(new Response("", { status: 503 }));
|
||||
expect(error.backendError.code).toBe("DBX-LEGACY-0001");
|
||||
expect(error.backendError.detail).toBeUndefined();
|
||||
expect(error.message).toBe("Backend request failed");
|
||||
});
|
||||
|
||||
test("keeps a safe SQL diagnostic in a JSON envelope unchanged", async () => {
|
||||
const error = await backendResponseError(new Response(JSON.stringify(envelope), { status: 400 }));
|
||||
expect(error.backendError.detail).toBe("Incorrect syntax near SELECT");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["JDBC plugin upload", () => installJdbcPluginLocal(new File(["plugin"], "plugin.zip"))],
|
||||
["Agent driver upload", () => importAgentDriver("postgres", new File(["driver"], "driver.zip"))],
|
||||
])("normalizes %s multipart failures through the nested backend envelope", async (_name, upload) => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: envelope }), { status: 400 })));
|
||||
|
||||
const error = await upload().catch((value: unknown) => value);
|
||||
expect(error).toBeInstanceOf(BackendErrorException);
|
||||
expect(error).toMatchObject({
|
||||
backendError: expect.objectContaining({ code: envelope.code, detail: envelope.detail }),
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["JDBC plugin upload", () => installJdbcPluginLocal(new File(["plugin"], "plugin.zip"))],
|
||||
["Agent driver upload", () => importAgentDriver("postgres", new File(["driver"], "driver.zip"))],
|
||||
])("normalizes %s multipart failures through the direct backend envelope", async (_name, upload) => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(envelope), { status: 400 })));
|
||||
|
||||
const error = await upload().catch((value: unknown) => value);
|
||||
expect(error).toBeInstanceOf(BackendErrorException);
|
||||
expect(error).toMatchObject({
|
||||
backendError: expect.objectContaining({ code: envelope.code, detail: envelope.detail }),
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,21 @@ export interface BackendError {
|
||||
code: string;
|
||||
messageKey: string;
|
||||
messageParams: Record<string, BackendErrorParam>;
|
||||
source: "jdbcAgent" | "jdbcAgentLegacy" | "legacyBackend";
|
||||
/** Compatibility provenance. New callers should prefer origin metadata. */
|
||||
source: string;
|
||||
operationOutcome: "not_started" | "unknown";
|
||||
origin?: {
|
||||
subsystem: string;
|
||||
adapter: string;
|
||||
driver?: string;
|
||||
};
|
||||
detail?: string;
|
||||
diagnostics?: Record<string, unknown>;
|
||||
helpUrl?: string;
|
||||
}
|
||||
|
||||
const MAX_FALLBACK_CHARS = 64 * 1024;
|
||||
const MAX_ERROR_PARSE_DEPTH = 16;
|
||||
const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:";
|
||||
|
||||
export function sanitizeBackendErrorMessage(message: string): string {
|
||||
@@ -45,25 +53,75 @@ function isBackendError(value: unknown): value is BackendError {
|
||||
!candidate.messageParams ||
|
||||
typeof candidate.messageParams !== "object" ||
|
||||
Array.isArray(candidate.messageParams) ||
|
||||
!["jdbcAgent", "jdbcAgentLegacy", "legacyBackend"].includes(String(candidate.source)) ||
|
||||
typeof candidate.source !== "string" ||
|
||||
candidate.source.length === 0 ||
|
||||
candidate.source.length > 64 ||
|
||||
!["not_started", "unknown"].includes(String(candidate.operationOutcome))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.origin !== undefined) {
|
||||
const origin = candidate.origin;
|
||||
const originRecord = origin as Record<string, unknown>;
|
||||
if (
|
||||
!origin ||
|
||||
typeof origin !== "object" ||
|
||||
Array.isArray(origin) ||
|
||||
typeof originRecord.subsystem !== "string" ||
|
||||
typeof originRecord.adapter !== "string" ||
|
||||
originRecord.subsystem.length > 64 ||
|
||||
originRecord.adapter.length > 64 ||
|
||||
(originRecord.driver !== undefined && (typeof originRecord.driver !== "string" || originRecord.driver.length > 64))
|
||||
) {
|
||||
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;
|
||||
return normalizeBackendErrorAtDepth(error, new WeakSet<object>(), 0);
|
||||
}
|
||||
|
||||
function normalizeBackendErrorAtDepth(error: unknown, seen: WeakSet<object>, depth: number): BackendError | null {
|
||||
if (depth > MAX_ERROR_PARSE_DEPTH) return null;
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
if (seen.has(error)) return null;
|
||||
seen.add(error);
|
||||
|
||||
if ("name" in error && error.name === "BackendErrorException" && "backendError" in error) {
|
||||
const normalized = normalizeBackendErrorAtDepth((error as { backendError: unknown }).backendError, seen, depth + 1);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
try {
|
||||
return normalizeBackendErrorAtDepth(JSON.parse(error), seen, depth + 1);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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;
|
||||
const normalized = normalizeBackendErrorAtDepth(backendError, seen, depth + 1);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
if (error && typeof error === "object" && "error" in error) {
|
||||
const nested = (error as { error: unknown }).error;
|
||||
if (isBackendError(nested)) return nested;
|
||||
const normalized = normalizeBackendErrorAtDepth(nested, seen, depth + 1);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(error.message);
|
||||
const normalized = normalizeBackendErrorAtDepth(parsed, seen, depth + 1);
|
||||
if (normalized) return normalized;
|
||||
} catch {
|
||||
// Keep checking compatibility wrappers before falling back to plain text.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -73,7 +131,8 @@ export class BackendErrorException extends Error {
|
||||
|
||||
constructor(error: unknown) {
|
||||
const backendError = normalizeRawBackendError(error);
|
||||
const fallbackMessage = sanitizeBackendErrorMessage(typeof error === "string" ? error : error instanceof Error ? error.message : "Backend request failed");
|
||||
const fallbackDetail = boundedFallbackText(error);
|
||||
const fallbackMessage = sanitizeBackendErrorMessage(fallbackDetail ?? "Backend request failed");
|
||||
super(backendError?.detail ? sanitizeBackendErrorMessage(backendError.detail) : fallbackMessage);
|
||||
this.name = "BackendErrorException";
|
||||
this.backendError = backendError ?? {
|
||||
@@ -83,23 +142,46 @@ export class BackendErrorException extends Error {
|
||||
messageParams: {},
|
||||
source: "legacyBackend",
|
||||
operationOutcome: "unknown",
|
||||
detail: fallbackMessage,
|
||||
origin: { subsystem: "backend", adapter: "legacy" },
|
||||
...(fallbackDetail ? { detail: sanitizeBackendErrorMessage(fallbackDetail) } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function boundedFallbackText(error: unknown): string | undefined {
|
||||
return boundedFallbackTextAtDepth(error, new WeakSet<object>(), 0);
|
||||
}
|
||||
|
||||
function boundedFallbackTextAtDepth(error: unknown, seen: WeakSet<object>, depth: number): string | undefined {
|
||||
if (depth > MAX_ERROR_PARSE_DEPTH) return undefined;
|
||||
|
||||
let text: string | undefined;
|
||||
if (typeof error === "string") {
|
||||
text = error;
|
||||
} else if (error instanceof Error) {
|
||||
text = error.message;
|
||||
} else if (error && typeof error === "object") {
|
||||
if (seen.has(error)) return undefined;
|
||||
seen.add(error);
|
||||
const candidate = error as Record<string, unknown>;
|
||||
for (const key of ["message", "reason", "detail"]) {
|
||||
if (typeof candidate[key] === "string") {
|
||||
text = candidate[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!text && "error" in candidate) text = boundedFallbackTextAtDepth(candidate.error, seen, depth + 1);
|
||||
if (!text && "backendError" in candidate) text = boundedFallbackTextAtDepth(candidate.backendError, seen, depth + 1);
|
||||
}
|
||||
const normalized = text?.trim();
|
||||
if (!normalized) return undefined;
|
||||
return Array.from(normalized).slice(0, MAX_FALLBACK_CHARS).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an unknown error value into a human-readable string.
|
||||
* Handles Error objects, strings, null/undefined, and other types.
|
||||
@@ -117,6 +199,7 @@ function normalizeRawBackendError(error: unknown): BackendError | null {
|
||||
export function formatError(e: unknown): string {
|
||||
const backendError = normalizeBackendError(e);
|
||||
if (backendError?.detail) return sanitizeBackendErrorMessage(backendError.detail);
|
||||
if (backendError) return backendError.code;
|
||||
|
||||
if (e instanceof Error) {
|
||||
return sanitizeBackendErrorMessage(e.message);
|
||||
|
||||
@@ -251,7 +251,7 @@ async function put<T>(url: string, body: unknown): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function backendResponseError(response: Response): Promise<BackendErrorException> {
|
||||
export async function backendResponseError(response: Response): Promise<BackendErrorException> {
|
||||
const text = await response.text();
|
||||
let payload: unknown = text;
|
||||
try {
|
||||
@@ -287,7 +287,7 @@ export async function testConnectionWithInfo(config: ConnectionConfig): Promise<
|
||||
if (response.status === 404) {
|
||||
return normalizeConnectionTestResult(await testConnection(config), config);
|
||||
}
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (!response.ok) throw await backendResponseError(response);
|
||||
return normalizeConnectionTestResult(await response.json(), config);
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ export async function importJdbcDrivers(pathsOrFiles: (string | File)[]): Promis
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ export async function installJdbcPluginLocal(pathOrFile: string | File): Promise
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!uploadRes.ok) throw new Error(await uploadRes.text());
|
||||
if (!uploadRes.ok) throw await backendResponseError(uploadRes);
|
||||
return uploadRes.json();
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ export async function importAgentsFromZip(fileOrPath: string | File, operationId
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
const result: { count: number } = await res.json();
|
||||
return result.count;
|
||||
}
|
||||
@@ -556,7 +556,7 @@ export async function importAgentDriver(dbType: string, pathOrFile: string | Fil
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!uploadRes.ok) throw new Error(await uploadRes.text());
|
||||
if (!uploadRes.ok) throw await backendResponseError(uploadRes);
|
||||
}
|
||||
|
||||
export const importAgentJar = importAgentDriver;
|
||||
@@ -1293,7 +1293,7 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -1389,7 +1389,7 @@ export async function aiAgentStream(
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -1492,7 +1492,7 @@ export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configu
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export async function loadMaxAgentTurns(): Promise<number> {
|
||||
@@ -1505,7 +1505,7 @@ export async function saveMaxAgentTurns(maxAgentTurns: number): Promise<void> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ maxAgentTurns }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export async function loadMaxRetries(): Promise<number> {
|
||||
@@ -1518,7 +1518,7 @@ export async function saveMaxRetries(maxRetries: number): Promise<void> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ maxRetries }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
@@ -1820,7 +1820,7 @@ export async function previewSqlFile(fileOrPath: string | File): Promise<SqlFile
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1890,7 +1890,7 @@ export async function startTransfer(request: TransferRequest, onProgress: (progr
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
|
||||
// 2. SSE to listen for progress
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -1952,7 +1952,7 @@ export async function previewTableImportFile(fileOrPath: string | File | TableIm
|
||||
previewLimit: options.previewLimit,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
const formData = new FormData();
|
||||
@@ -1964,7 +1964,7 @@ export async function previewTableImportFile(fileOrPath: string | File | TableIm
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1975,7 +1975,7 @@ export async function importTableFile(request: TableImportRequest, onProgress: (
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
|
||||
// 2. SSE to listen for progress
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -2029,7 +2029,7 @@ export async function exportDatabaseSql(request: DatabaseExportRequest, onProgre
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
|
||||
// 2. SSE to listen for progress
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -2691,7 +2691,7 @@ export async function nacosSearchConfigContent(connectionId: string, req: NacosC
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ connectionId, req }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (!response.ok) throw await backendResponseError(response);
|
||||
if (!response.body) throw new Error("Nacos content search did not return a response stream");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -2738,7 +2738,7 @@ export async function nacosExportConfigs(connectionId: string, selector: NacosCo
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ connectionId, selector, fileName }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (!response.ok) throw await backendResponseError(response);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
@@ -2758,7 +2758,7 @@ export async function nacosPreviewConfigImport(connectionId: string, targetNames
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (!response.ok) throw await backendResponseError(response);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -3039,7 +3039,7 @@ export async function documentDownloadGridFsFile(connectionId: string, database:
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ connectionId, database, bucket, fileId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
const data = (await res.json()) as number[];
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
@@ -3058,7 +3058,7 @@ export async function documentUploadGridFsFile(connectionId: string, database: s
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@ import { BackendErrorException, type BackendError } from "@/lib/backend/errorUti
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
|
||||
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
|
||||
|
||||
/** Normalize Tauri rejections once at the public backend boundary. */
|
||||
async function invokeBackend<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
return await invoke<T>(command, args);
|
||||
} catch (error) {
|
||||
throw error instanceof BackendErrorException ? error : new BackendErrorException(error);
|
||||
}
|
||||
}
|
||||
import type {
|
||||
ConnectionConfig,
|
||||
ConnectionTestResult,
|
||||
@@ -835,7 +844,7 @@ export async function setAiGlobalCustomInstructions(content: string): Promise<vo
|
||||
}
|
||||
|
||||
export async function testConnection(config: ConnectionConfig): Promise<string> {
|
||||
return invoke("test_connection", { config });
|
||||
return invokeBackend("test_connection", { config });
|
||||
}
|
||||
|
||||
export async function testConnectionWithInfo(config: ConnectionConfig): Promise<ConnectionTestResult> {
|
||||
@@ -851,31 +860,31 @@ export async function testConnectionWithInfo(config: ConnectionConfig): Promise<
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
|
||||
return invoke("connect_db", { config, clientAttempt });
|
||||
return invokeBackend("connect_db", { config, clientAttempt });
|
||||
}
|
||||
|
||||
export async function connectionDatabaseInfo(connectionId: string, database?: string): Promise<DatabaseConnectionInfo | undefined> {
|
||||
const info = await invoke<DatabaseConnectionInfo | null>("connection_database_info", { connectionId, database });
|
||||
const info = await invokeBackend<DatabaseConnectionInfo | null>("connection_database_info", { connectionId, database });
|
||||
return info ?? undefined;
|
||||
}
|
||||
|
||||
export async function saveConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo): Promise<void> {
|
||||
return invoke("save_connection_database_info", {
|
||||
return invokeBackend("save_connection_database_info", {
|
||||
connectionId,
|
||||
databaseInfo,
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectionFinalProxyPort(config: ConnectionConfig): Promise<number> {
|
||||
return invoke("connection_final_proxy_port", { config });
|
||||
return invokeBackend("connection_final_proxy_port", { config });
|
||||
}
|
||||
|
||||
export async function disconnectDb(connectionId: string, clientAttempt?: number): Promise<void> {
|
||||
return invoke("disconnect_db", { connectionId, clientAttempt });
|
||||
return invokeBackend("disconnect_db", { connectionId, clientAttempt });
|
||||
}
|
||||
|
||||
export async function checkConnectionHealth(connectionId: string): Promise<void> {
|
||||
return invoke("check_connection_health", { connectionId });
|
||||
return invokeBackend("check_connection_health", { connectionId });
|
||||
}
|
||||
|
||||
export async function connectionIdentifierQuote(connectionId: string, database?: string): Promise<string | undefined> {
|
||||
@@ -3405,7 +3414,7 @@ export async function startTransfer(request: TransferRequest, onProgress: (progr
|
||||
await invoke("start_transfer", { request });
|
||||
} catch (e) {
|
||||
unlisten?.();
|
||||
reject(e);
|
||||
reject(e instanceof BackendErrorException ? e : new BackendErrorException(e));
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -3561,7 +3570,7 @@ export async function importTableFile(request: TableImportRequest, onProgress: (
|
||||
return summary;
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e;
|
||||
throw e instanceof BackendErrorException ? e : new BackendErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3707,7 +3716,7 @@ export async function startTableExport(request: TableExportRequest, onProgress:
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
|
||||
if (event.payload.status === "Error") {
|
||||
finish(() => rejectTerminal(new Error(event.payload.errorMessage || "Export failed")));
|
||||
finish(() => rejectTerminal(new BackendErrorException(event.payload.errorMessage || "Export failed")));
|
||||
} else {
|
||||
finish(() => resolveTerminal(event.payload));
|
||||
}
|
||||
@@ -3720,7 +3729,7 @@ export async function startTableExport(request: TableExportRequest, onProgress:
|
||||
settled = true;
|
||||
unlisten?.();
|
||||
}
|
||||
throw error;
|
||||
throw error instanceof BackendErrorException ? error : new BackendErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3752,7 +3761,7 @@ export async function startQueryResultExport(request: QueryResultExportRequest,
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
|
||||
if (event.payload.status === "Error") {
|
||||
finish(() => rejectTerminal(new Error(event.payload.errorMessage || "Export failed")));
|
||||
finish(() => rejectTerminal(new BackendErrorException(event.payload.errorMessage || "Export failed")));
|
||||
} else {
|
||||
finish(() => resolveTerminal(event.payload));
|
||||
}
|
||||
@@ -3765,7 +3774,7 @@ export async function startQueryResultExport(request: QueryResultExportRequest,
|
||||
settled = true;
|
||||
unlisten?.();
|
||||
}
|
||||
throw error;
|
||||
throw error instanceof BackendErrorException ? error : new BackendErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,4 +39,24 @@ describe("appendQueryResultSegment spatial merge", () => {
|
||||
// Every row keeps its own SRID; a later page does not overwrite earlier cells.
|
||||
expect(merged.spatial_values).toEqual([[4326], [3857], [3857], [4490]]);
|
||||
});
|
||||
|
||||
it("raises the structured segment error instead of reconstructing from the Error row", () => {
|
||||
const previous = make(1);
|
||||
const segment = {
|
||||
...make(0),
|
||||
execution_error: true as const,
|
||||
rows: [["legacy row text"]],
|
||||
error: {
|
||||
version: 1 as const,
|
||||
code: "DBX-JDBC-4001",
|
||||
messageKey: "backendErrors.jdbc.sqlFailed",
|
||||
messageParams: { stage: "execute" },
|
||||
source: "jdbcAgent" as const,
|
||||
operationOutcome: "unknown" as const,
|
||||
detail: "relation missing_table does not exist",
|
||||
},
|
||||
} as unknown as QueryResult;
|
||||
|
||||
expect(() => appendQueryResultSegment(previous, segment, 100)).toThrow("relation missing_table does not exist");
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,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, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
|
||||
import { BackendErrorException, 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";
|
||||
@@ -161,7 +161,9 @@ function exactTotalFromIncompletePage(result: QueryResult, pageLimit: number | u
|
||||
}
|
||||
|
||||
export function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, maxRows: number): QueryResult {
|
||||
if (segment.execution_error) throw new Error(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
|
||||
if (segment.execution_error) {
|
||||
throw segment.error ? new BackendErrorException(segment.error) : new BackendErrorException(String(segment.rows[0]?.[0] ?? "Failed to load the next result segment"));
|
||||
}
|
||||
if (previous.columns.length !== segment.columns.length || previous.columns.some((column, index) => column !== segment.columns[index])) {
|
||||
throw new Error("Result columns changed while loading the next segment");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user