{{ item.sql || t("executionSummary.noSql") }}
+ {{ t("executionSummary.lineColumn", { line: item.errorPosition.line, column: item.errorPosition.column }) }}
{{ item.error }}
+
+
+
diff --git a/apps/desktop/src/components/layout/querySurfaces.ts b/apps/desktop/src/components/layout/querySurfaces.ts
index 4b162ac903..cfc16e7f60 100644
--- a/apps/desktop/src/components/layout/querySurfaces.ts
+++ b/apps/desktop/src/components/layout/querySurfaces.ts
@@ -29,6 +29,7 @@ export interface QueryEditorSurfaceHandle {
executeRedisCommand(command: string): Promise
;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
+ focusErrorPosition(offset: number): boolean;
}
export interface QueryResultSurfaceHandle {
@@ -38,6 +39,7 @@ export interface QueryResultSurfaceHandle {
handleModRTarget(target: Element): boolean;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
+ focusErrorPosition(offset: number): boolean;
}
export interface ContentAreaSurfaceProps {
@@ -97,6 +99,7 @@ export interface ContentAreaSurfaceEmits {
structureEditorClose: [tabId: string];
previewStatement: [tabId: string, range: StatementRange | null];
focusStatement: [tabId: string, range: StatementRange | null];
+ focusErrorOffset: [tabId: string, offset: number];
openSettings: [initialTab?: string, initialSection?: string];
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
toggleZenMode: [];
diff --git a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts
index 141625ce32..66a87a8fad 100644
--- a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts
+++ b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts
@@ -230,6 +230,20 @@ describe("backend error translation", () => {
expect(sanitizeBackendErrorMessage(message)).toBe(message);
});
+ test("strips the SQL error-position transport suffix from raw messages", () => {
+ const message = 'ERROR: relation "missing" does not exist\nDBX_SQL_ERROR_POSITION:15';
+ const expected = 'ERROR: relation "missing" does not exist';
+
+ expect(sanitizeBackendErrorMessage(message)).toBe(expected);
+ expect(formatError(new Error(message))).toBe(expected);
+ });
+
+ test("strips the position suffix that precedes appended context text", () => {
+ const message = "ERROR: boom\nDBX_SQL_ERROR_POSITION:7; cleanup failed";
+
+ expect(sanitizeBackendErrorMessage(message)).toBe("ERROR: boom; cleanup failed");
+ });
+
test("normalizes Error and structural message objects before translation", () => {
const t = translatorFor("zh-CN");
const message = "file does not exist: /tmp/missing.sqlite";
@@ -327,6 +341,44 @@ describe("backend error translation", () => {
).toBeNull();
});
+ test("accepts an optional driver-reported error position", () => {
+ const error = normalizeBackendError({
+ version: 1,
+ code: "DBX-JDBC-4001",
+ messageKey: "backendErrors.jdbc.sqlFailed",
+ messageParams: { stage: "execute" },
+ source: "jdbcAgent",
+ operationOutcome: "unknown",
+ detail: 'ERROR: relation "missing" does not exist',
+ errorPosition: { line: 2, column: 6, offset: 12 },
+ });
+
+ expect(error?.errorPosition).toEqual({ line: 2, column: 6, offset: 12 });
+
+ const t = translatorFor("zh-CN");
+ expect(translateBackendError(t, error)).toBe(`${t("backendErrors.jdbc.sqlFailed", { stage: "execute" })}\n\nERROR: relation "missing" does not exist`);
+ });
+
+ test.each([
+ ["zero line", { line: 0, column: 1, offset: 0 }],
+ ["negative column", { line: 1, column: -1, offset: 0 }],
+ ["non-integer offset", { line: 1, column: 1, offset: 1.5 }],
+ ["string line", { line: "1", column: 1, offset: 0 }],
+ ["array position", [1, 1, 0]],
+ ])("rejects a malformed error position with %s", (_name, errorPosition) => {
+ expect(
+ normalizeBackendError({
+ version: 1,
+ code: "DBX-JDBC-4001",
+ messageKey: "backendErrors.jdbc.sqlFailed",
+ messageParams: { stage: "execute" },
+ source: "jdbcAgent",
+ operationOutcome: "unknown",
+ errorPosition,
+ }),
+ ).toBeNull();
+ });
+
test("accepts unknown compatibility sources and extensible origins", () => {
const error = normalizeBackendError({
version: 1,
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts
index 52a1a86e89..e043852433 100644
--- a/apps/desktop/src/i18n/locales/az.ts
+++ b/apps/desktop/src/i18n/locales/az.ts
@@ -1311,6 +1311,8 @@ export default withEnglishFallback({
queryTimeoutError: "Sorğunun vaxt limiti bitdi ({seconds}s). Verilənlər bazası əlaqəsinin işlək olub-olmadığını yoxlayın.",
changeConnectionTimeout: "Əlaqənin vaxt limitini dəyiş",
changeQueryTimeout: "Sorğunun vaxt limitini dəyiş",
+ locateError: "Xətanı tap ({line}. sətir, {column}. sütun)",
+ errorPositionUnavailable: "Xəta mövqeyi redaktorun cari məzmununa uyğun gəlmir, ona görə də tapıla bilmir.",
connectionMayBeLost: "Əlaqə kəsilmiş ola bilər. Məlumatları yeniləyin və yenidən cəhd edin.",
showResultsPane: "Nəticələri göstər",
hideResultsPane: "Nəticələri gizlət",
@@ -1655,6 +1657,7 @@ export default withEnglishFallback({
returnedTable: "{count} sütunlu nəticə cədvəli qaytarıldı",
noTable: "Nəticə cədvəli qaytarılmadı",
noSql: "SQL mövcud deyil",
+ lineColumn: "{line}. sətir, {column}. sütun",
navigationHint: "SQL-ə önbaxış üçün sətrə klikləyin; redaktorda ona keçmək üçün iki dəfə klikləyin.",
recoveryPrompt: "#{statement} əmri uğursuz oldu; {count} əmr işlədilmədi.",
stop: "Dayandır",
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 1c6f0c1000..ec7ee4db08 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1442,6 +1442,8 @@ export default {
queryTimeoutError: "Query timed out ({seconds}s). Check whether the database connection is healthy.",
changeConnectionTimeout: "Change connection timeout",
changeQueryTimeout: "Change query timeout",
+ locateError: "Locate error (line {line}, col {column})",
+ errorPositionUnavailable: "The error position no longer matches the editor content, so it cannot be located.",
connectionMayBeLost: "The connection may have been lost. Refresh data and try again.",
showResultsPane: "Show results",
hideResultsPane: "Hide results",
@@ -1786,6 +1788,7 @@ export default {
returnedTable: "Returned a {count}-column result table",
noTable: "No result table returned",
noSql: "SQL unavailable",
+ lineColumn: "line {line}, col {column}",
navigationHint: "Click a row to preview its SQL; double-click to focus it in the editor.",
recoveryPrompt: "Statement #{statement} failed; {count} statements were not run.",
stop: "Stop",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 5d1c0e1d96..42e3440103 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1412,6 +1412,8 @@ export default withEnglishFallback({
queryTimeoutError: "La consulta agotó el tiempo ({seconds}s). Comprueba la conexión a la base de datos.",
changeConnectionTimeout: "Cambiar tiempo de espera de conexión",
changeQueryTimeout: "Cambiar tiempo de espera de consulta",
+ locateError: "Localizar error (línea {line}, columna {column})",
+ errorPositionUnavailable: "La posición del error ya no coincide con el contenido del editor, por lo que no se puede localizar.",
connectionMayBeLost: "Es posible que la conexión se haya perdido. Actualiza los datos e inténtalo de nuevo.",
showResultsPane: "Mostrar resultados",
hideResultsPane: "Ocultar resultados",
@@ -1709,6 +1711,7 @@ export default withEnglishFallback({
returnedTable: "Devolvió una tabla de resultados de {count} columnas",
noTable: "No se devolvió ninguna tabla de resultados",
noSql: "SQL no disponible",
+ lineColumn: "línea {line}, columna {column}",
navigationHint: "Haz clic para previsualizar el SQL; haz doble clic para enfocarlo en el editor.",
recoveryPrompt: "Falló la sentencia #{statement}; no se ejecutaron {count} sentencias.",
stop: "Detener",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 02d0eb8fc0..c9ee7584f6 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1410,6 +1410,8 @@ export default withEnglishFallback({
queryTimeoutError: "Timeout della query ({seconds}s). Verifica se la connessione al database è integra.",
changeConnectionTimeout: "Modifica timeout connessione",
changeQueryTimeout: "Modifica timeout query",
+ locateError: "Individua errore (riga {line}, colonna {column})",
+ errorPositionUnavailable: "La posizione dell'errore non corrisponde più al contenuto dell'editor, quindi non è possibile individuarla.",
connectionMayBeLost: "La connessione potrebbe essere andata perduta. Aggiorna i dati e riprova.",
showResultsPane: "Mostra risultati",
hideResultsPane: "Nascondi risultati",
@@ -1707,6 +1709,7 @@ export default withEnglishFallback({
returnedTable: "Restituita una tabella con {count} colonne",
noTable: "Nessuna tabella restituita",
noSql: "SQL non disponibile",
+ lineColumn: "riga {line}, colonna {column}",
navigationHint: "Fai clic per visualizzare l'SQL; fai doppio clic per selezionarlo nell'editor.",
recoveryPrompt: "L'istruzione #{statement} non è riuscita; {count} istruzioni non sono state eseguite.",
stop: "Interrompi",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 1ade9e0536..0ca2dc4db0 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1431,6 +1431,8 @@ export default withEnglishFallback({
queryTimeoutError: "クエリがタイムアウトしました({seconds}秒)。データベース接続が正常か確認してください。",
changeConnectionTimeout: "接続タイムアウトを変更",
changeQueryTimeout: "クエリタイムアウトを変更",
+ locateError: "エラー位置へ移動({line} 行目 {column} 桁目)",
+ errorPositionUnavailable: "エラー位置が現在のエディター内容と一致しないため、位置を特定できません。",
connectionMayBeLost: "接続が失われた可能性があります。データを更新して再試行してください。",
showResultsPane: "結果を表示",
hideResultsPane: "結果を非表示",
@@ -1729,6 +1731,7 @@ export default withEnglishFallback({
returnedTable: "{count}列の結果テーブルを返しました",
noTable: "結果テーブルは返されませんでした",
noSql: "SQLを取得できません",
+ lineColumn: "{line} 行目 {column} 桁目",
navigationHint: "クリックでSQLをプレビューし、ダブルクリックでエディター内を選択します。",
recoveryPrompt: "ステートメント #{statement} が失敗し、残り {count} 件は未実行です。",
stop: "停止",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 243646eb7f..6ea97f7620 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -1332,6 +1332,8 @@ export default withEnglishFallback({
queryTimeoutError: "쿼리 시간 초과 ({seconds}초). 데이터베이스 연결이 정상인지 확인하세요.",
changeConnectionTimeout: "연결 제한 시간 변경",
changeQueryTimeout: "쿼리 제한 시간 변경",
+ locateError: "오류 위치로 이동 ({line}행 {column}열)",
+ errorPositionUnavailable: "오류 위치가 현재 편집기 내용과 일치하지 않아 위치를 찾을 수 없습니다.",
connectionMayBeLost: "연결이 끊어졌을 수 있습니다. 데이터를 새로고침하고 다시 시도하세요.",
showResultsPane: "결과 표시",
hideResultsPane: "결과 숨기기",
@@ -1658,6 +1660,7 @@ export default withEnglishFallback({
returnedTable: "{count}열 결과 테이블을 반환했습니다",
noTable: "반환된 결과 테이블이 없습니다",
noSql: "SQL을 가져올 수 없습니다",
+ lineColumn: "{line}행 {column}열",
navigationHint: "행을 클릭하여 SQL을 미리 보고 두 번 클릭하여 편집기에서 선택하세요.",
recoveryPrompt: "구문 #{statement} 실행에 실패하여 나머지 {count}개 구문이 실행되지 않았습니다.",
stop: "중지",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 56dfbb15ef..26518cee57 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1411,6 +1411,8 @@ export default withEnglishFallback({
queryTimeoutError: "A consulta expirou ({seconds}s). Verifique se a conexão com o banco de dados está saudável.",
changeConnectionTimeout: "Alterar tempo limite da conexão",
changeQueryTimeout: "Alterar tempo limite da consulta",
+ locateError: "Localizar erro (linha {line}, coluna {column})",
+ errorPositionUnavailable: "A posição do erro não corresponde mais ao conteúdo do editor, portanto não é possível localizá-la.",
connectionMayBeLost: "A conexão pode ter sido perdida. Atualize os dados e tente novamente.",
showResultsPane: "Mostrar resultados",
hideResultsPane: "Ocultar resultados",
@@ -1709,6 +1711,7 @@ export default withEnglishFallback({
returnedTable: "Retornou uma tabela de resultado com {count} colunas",
noTable: "Nenhuma tabela de resultado retornada",
noSql: "SQL indisponível",
+ lineColumn: "linha {line}, coluna {column}",
navigationHint: "Clique para visualizar o SQL; clique duas vezes para selecioná-lo no editor.",
recoveryPrompt: "A instrução #{statement} falhou; {count} instruções não foram executadas.",
stop: "Parar",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index 175b8a395c..8ba7562ab8 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -1313,6 +1313,8 @@ export default withEnglishFallback({
queryTimeoutError: "Sorgu zaman aşımına uğradı ({seconds} sn). Veritabanı bağlantısının sağlıklı olup olmadığını denetleyin.",
changeConnectionTimeout: "Bağlantı zaman aşımını değiştir",
changeQueryTimeout: "Sorgu zaman aşımını değiştir",
+ locateError: "Hatayı bul ({line}. satır, {column}. sütun)",
+ errorPositionUnavailable: "Hata konumu düzenleyicinin geçerli içeriğiyle eşleşmediği için bulunamıyor.",
connectionMayBeLost: "Bağlantı kopmuş olabilir. Verileri yenileyip tekrar deneyin.",
showResultsPane: "Sonuçları göster",
hideResultsPane: "Sonuçları gizle",
@@ -1656,6 +1658,7 @@ export default withEnglishFallback({
returnedTable: "{count} sütunlu bir sonuç tablosu döndürdü",
noTable: "Sonuç tablosu döndürülmedi",
noSql: "SQL kullanılamıyor",
+ lineColumn: "{line}. satır, {column}. sütun",
navigationHint: "SQL'ini önizlemek için bir satıra tıklayın; düzenleyicide odaklamak için çift tıklayın.",
recoveryPrompt: "#{statement} numaralı ifade başarısız oldu; {count} ifade çalıştırılmadı.",
stop: "Durdur",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index a136091178..cb3fde243a 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1357,6 +1357,8 @@ export default withEnglishFallback({
queryTimeoutError: "查询超时 ({seconds}s),请检查数据库连接是否正常",
changeConnectionTimeout: "修改超时时间",
changeQueryTimeout: "修改查询超时时间",
+ locateError: "定位错误(第 {line} 行,第 {column} 列)",
+ errorPositionUnavailable: "错误位置与当前编辑器内容不一致,无法定位。",
connectionMayBeLost: "连接可能已断开,请刷新数据重试",
showResultsPane: "显示结果",
hideResultsPane: "收起",
@@ -1701,6 +1703,7 @@ export default withEnglishFallback({
returnedTable: "返回 {count} 列结果表",
noTable: "未返回结果表",
noSql: "无法获取 SQL",
+ lineColumn: "第 {line} 行,第 {column} 列",
navigationHint: "单击预览对应 SQL,双击在编辑器中聚焦并选中。",
recoveryPrompt: "语句 #{statement} 执行失败,剩余 {count} 条未执行。",
stop: "停止",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 279bc53b68..583f3b6a43 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1410,6 +1410,8 @@ export default withEnglishFallback({
queryTimeoutError: "查詢逾時 ({seconds}s),請檢查資料庫連線是否正常",
changeConnectionTimeout: "修改連線逾時時間",
changeQueryTimeout: "修改查詢逾時時間",
+ locateError: "定位錯誤(第 {line} 行,第 {column} 列)",
+ errorPositionUnavailable: "錯誤位置與目前編輯器內容不一致,無法定位。",
connectionMayBeLost: "連線可能已斷開,請重新整理資料重試",
showResultsPane: "顯示結果",
hideResultsPane: "收起結果",
@@ -1708,6 +1710,7 @@ export default withEnglishFallback({
returnedTable: "回傳 {count} 欄的結果表",
noTable: "未回傳結果表",
noSql: "無法取得 SQL",
+ lineColumn: "第 {line} 行,第 {column} 列",
navigationHint: "按一下預覽 SQL,按兩下在編輯器中聚焦並選取。",
recoveryPrompt: "語句 #{statement} 執行失敗,剩餘 {count} 條未執行。",
stop: "停止",
diff --git a/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts b/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts
new file mode 100644
index 0000000000..552076a2b4
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts
@@ -0,0 +1,188 @@
+import { describe, expect, it } from "vitest";
+
+import type { BackendError, SqlErrorPosition } from "@/lib/backend/errorUtils";
+import { mapExecutedOffsetToSource, scalarPositionToUtf16Offset, sqlErrorEditorOffset } from "@/lib/sql/errorPosition";
+import type { QueryResult } from "@/types/database";
+
+function backendError(position?: SqlErrorPosition): BackendError {
+ return {
+ version: 1,
+ code: "DBX-JDBC-4001",
+ messageKey: "backendErrors.jdbc.sqlFailed",
+ messageParams: { stage: "execute" },
+ source: "jdbcAgent",
+ operationOutcome: "unknown",
+ detail: "ERROR: relation does not exist",
+ ...(position ? { errorPosition: position } : {}),
+ };
+}
+
+function errorResult(options: { editorStatement?: string; sourceFrom?: number; sourceTo?: number; executedStatement?: string; position?: SqlErrorPosition }): QueryResult {
+ const statement = options.editorStatement ?? "";
+ return {
+ columns: ["Error"],
+ rows: [["ERROR: relation does not exist"]],
+ affected_rows: 0,
+ execution_time_ms: 0,
+ execution_error: true,
+ error: backendError(options.position),
+ sourceStatement: statement,
+ sourceFrom: options.sourceFrom ?? 0,
+ sourceTo: options.sourceTo ?? statement.length,
+ ...(options.executedStatement ? { executedStatement: options.executedStatement } : {}),
+ };
+}
+
+describe("sqlErrorEditorOffset", () => {
+ it("maps a first-line column to an absolute editor offset", () => {
+ const sql = "SELECT * FROM no_such_table";
+ const result = errorResult({
+ editorStatement: sql,
+ position: { line: 1, column: 15, offset: 14 },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql: sql, result })).toEqual({ offset: 14, line: 1, column: 15 });
+ });
+
+ it("maps a later-line column", () => {
+ const statement = "SELECT *\nFROM missing";
+ const result = errorResult({
+ editorStatement: statement,
+ position: { line: 2, column: 6, offset: 14 },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql: statement, result })).toEqual({ offset: 14, line: 2, column: 6 });
+ });
+
+ it("adds the statement's absolute start offset in a multi-statement editor", () => {
+ const statement = "SELECT *\nFROM missing";
+ const editorSql = "SELECT 1;\n" + statement;
+ const start = editorSql.indexOf(statement);
+ const result = errorResult({
+ editorStatement: statement,
+ sourceFrom: start,
+ sourceTo: start + statement.length,
+ position: { line: 2, column: 6, offset: 14 },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql, result })).toEqual({ offset: start + 14, line: 2, column: 6 });
+ });
+
+ it("converts scalar-value columns to UTF-16 offsets for astral characters", () => {
+ const statement = "SELECT '😀' FROM t";
+ // Code-point column of FROM: S E L E C T space ' 😀 ' space = 11 chars, so FROM is column 12.
+ const result = errorResult({
+ editorStatement: statement,
+ position: { line: 1, column: 12, offset: 11 },
+ });
+
+ // UTF-16 offset: the emoji occupies two code units, so 'FROM' starts at 12.
+ expect(sqlErrorEditorOffset({ editorSql: statement, result })).toEqual({ offset: 12, line: 1, column: 12 });
+ });
+
+ it("returns undefined when the result carries no position", () => {
+ const sql = "SELECT 1";
+ const result = errorResult({ editorStatement: sql });
+
+ expect(sqlErrorEditorOffset({ editorSql: sql, result })).toBeUndefined();
+ });
+
+ it("returns undefined when the statement no longer matches the editor", () => {
+ const result = errorResult({
+ editorStatement: "SELECT * FROM gone",
+ position: { line: 1, column: 15, offset: 14 },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql: "SELECT 1", result })).toBeUndefined();
+ });
+
+ it("clamps instead of failing when the position line is beyond the statement", () => {
+ const sql = "SELECT 1";
+ const result = errorResult({
+ editorStatement: sql,
+ position: { line: 5, column: 1, offset: 4 },
+ });
+
+ // The position is relative to the statement, so a bad line clamps to its end.
+ expect(sqlErrorEditorOffset({ editorSql: sql, result })?.offset).toBe(sql.length);
+ });
+
+ describe("when the executed statement differs from the source", () => {
+ it("projects an appended LIMIT/OFFSET position back onto the source", () => {
+ const source = "SELECT * FROM t";
+ const executed = "SELECT * FROM t LIMIT 100 OFFSET 0;";
+ const tableOffset = executed.indexOf("FROM t") + "FROM ".length;
+ const result = errorResult({
+ editorStatement: source,
+ executedStatement: executed,
+ position: { line: 1, column: tableOffset + 1, offset: tableOffset },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("FROM t") + "FROM ".length);
+ });
+
+ it("projects a pagination wrapper position back onto the inner statement", () => {
+ const source = "SELECT * FROM t";
+ const executed = "SELECT * FROM (SELECT * FROM t) AS dbx_page LIMIT 100 OFFSET 0;";
+ const tableOffset = executed.indexOf("FROM t") + "FROM ".length;
+ const result = errorResult({
+ editorStatement: source,
+ executedStatement: executed,
+ position: { line: 1, column: tableOffset + 1, offset: tableOffset },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("FROM t") + "FROM ".length);
+ });
+
+ it("projects an injected hidden-key column position back onto the source", () => {
+ const source = "SELECT * FROM users";
+ const executed = 'SELECT *, "id" AS "__dbx_hidden_pk" FROM users LIMIT 100 OFFSET 0;';
+ const tableOffset = executed.indexOf("FROM users") + "FROM ".length;
+ const result = errorResult({
+ editorStatement: source,
+ executedStatement: executed,
+ position: { line: 1, column: tableOffset + 1, offset: tableOffset },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("users"));
+ });
+
+ it("keeps a multi-line position aligned when the wrapper is on the first line only", () => {
+ const source = "SELECT *\nFROM users\nWHERE id = 1";
+ const executed = `SELECT * FROM (${source}) AS dbx_page LIMIT 100 OFFSET 0;`;
+ const executedMissing = executed.indexOf("users");
+ const result = errorResult({
+ editorStatement: source,
+ executedStatement: executed,
+ // `users` is on line 2 of both texts.
+ position: { line: 2, column: 6, offset: executedMissing + 5 },
+ });
+
+ expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("users"));
+ });
+ });
+});
+
+describe("scalarPositionToUtf16Offset", () => {
+ it("converts scalar line/column to a UTF-16 offset", () => {
+ expect(scalarPositionToUtf16Offset("SELECT '😀' FROM t", 1, 12)).toBe(12);
+ });
+
+ it("clamps a column past the end of its line", () => {
+ expect(scalarPositionToUtf16Offset("SELECT 1\nFROM t", 1, 999)).toBe("SELECT 1".length);
+ });
+});
+
+describe("mapExecutedOffsetToSource", () => {
+ it("returns the offset unchanged when both texts are identical", () => {
+ expect(mapExecutedOffsetToSource("SELECT 1", "SELECT 1", 4)).toBe(4);
+ });
+
+ it("maps an append-only rewrite through the shared prefix", () => {
+ expect(mapExecutedOffsetToSource("SELECT 1 LIMIT 10", "SELECT 1", 4)).toBe(4);
+ });
+
+ it("clamps offsets in the appended region to the source end", () => {
+ expect(mapExecutedOffsetToSource("SELECT 1 LIMIT 10", "SELECT 1", 15)).toBe("SELECT 1".length);
+ });
+});
diff --git a/apps/desktop/src/lib/backend/errorUtils.ts b/apps/desktop/src/lib/backend/errorUtils.ts
index 6dbf63e845..5de31d1ee7 100644
--- a/apps/desktop/src/lib/backend/errorUtils.ts
+++ b/apps/desktop/src/lib/backend/errorUtils.ts
@@ -4,6 +4,19 @@
export type BackendErrorParam = string | number | boolean;
+/**
+ * Driver-reported SQL error position, relative to the statement text that was
+ * actually sent to the database. `line`/`column` are 1-based and counted in
+ * Unicode scalar values; `offset` is the 0-based scalar-value index.
+ *
+ * Currently only populated by the native PostgreSQL driver.
+ */
+export interface SqlErrorPosition {
+ line: number;
+ column: number;
+ offset: number;
+}
+
export interface BackendError {
version: 1;
code: string;
@@ -20,6 +33,7 @@ export interface BackendError {
detail?: string;
diagnostics?: Record;
helpUrl?: string;
+ errorPosition?: SqlErrorPosition;
}
export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
@@ -27,20 +41,25 @@ export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
const MAX_FALLBACK_CHARS = 64 * 1024;
const MAX_ERROR_PARSE_DEPTH = 16;
const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:";
+// Rust-side transport suffix carrying a driver cursor position. It is stripped
+// before a structured envelope is built, but metadata/catalog errors can surface
+// as raw strings, so strip it here so it never reaches the UI.
+const SQL_ERROR_POSITION_MARKER_PATTERN = /\nDBX_SQL_ERROR_POSITION:\d+/g;
export function sanitizeBackendErrorMessage(message: string): string {
- const markerIndex = message.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER);
- if (markerIndex < 0) return message;
+ const withoutPositionMarker = message.replace(SQL_ERROR_POSITION_MARKER_PATTERN, "");
+ const markerIndex = withoutPositionMarker.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER);
+ if (markerIndex < 0) return withoutPositionMarker;
- const rawData = message.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim();
+ const rawData = withoutPositionMarker.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim();
try {
const data: unknown = JSON.parse(rawData);
- if (!data || typeof data !== "object" || Array.isArray(data)) return message;
+ if (!data || typeof data !== "object" || Array.isArray(data)) return withoutPositionMarker;
} catch {
- return message;
+ return withoutPositionMarker;
}
- return message.slice(0, markerIndex).trimEnd();
+ return withoutPositionMarker.slice(0, markerIndex).trimEnd();
}
function isBackendError(value: unknown): value is BackendError {
@@ -79,9 +98,29 @@ function isBackendError(value: unknown): value is BackendError {
}
}
if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false;
+ // Optional driver-reported position. Malformed values are rejected so a
+ // corrupted envelope never drives a wrong editor jump, while a missing field
+ // stays valid (all non-PostgreSQL errors and older backends).
+ if (candidate.errorPosition !== undefined) {
+ if (!isValidErrorPosition(candidate.errorPosition)) return false;
+ }
return Object.values(candidate.messageParams).every((param) => typeof param === "string" || typeof param === "boolean" || (typeof param === "number" && Number.isFinite(param)));
}
+function isValidErrorPosition(value: unknown): value is SqlErrorPosition {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const { line, column, offset } = value as Record;
+ return isPositiveInt(line) && isPositiveInt(column) && isNonNegativeInt(offset);
+}
+
+function isPositiveInt(value: unknown): boolean {
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
+}
+
+function isNonNegativeInt(value: unknown): boolean {
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
+}
+
export function normalizeBackendError(error: unknown): BackendError | null {
return normalizeBackendErrorAtDepth(error, new WeakSet