diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index a0d7e2f..2d6f906 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -2083,6 +2083,9 @@ describe("timeout option (local files)", () => { throw new Error("Expected PRIMARY KEY constraint error"); } catch (e: any) { expect(e.code).toBe("SQLITE_CONSTRAINT"); + // rawCode is now populated for remote errors too (gh-117). + expect(e.rawCode).toBeDefined(); + expect(e.rawCode & 0xff).toBe(19); if (e.extendedCode !== undefined) { expect(e.extendedCode).toBe( "SQLITE_CONSTRAINT_PRIMARYKEY", @@ -2110,6 +2113,9 @@ describe("timeout option (local files)", () => { throw new Error("Expected UNIQUE constraint error"); } catch (e: any) { expect(e.code).toBe("SQLITE_CONSTRAINT"); + // rawCode is now populated for remote errors too (gh-117). + expect(e.rawCode).toBeDefined(); + expect(e.rawCode & 0xff).toBe(19); if (e.extendedCode !== undefined) { expect(e.extendedCode).toBe("SQLITE_CONSTRAINT_UNIQUE"); } diff --git a/packages/libsql-client/src/__tests__/sqlite_error_codes.test.ts b/packages/libsql-client/src/__tests__/sqlite_error_codes.test.ts new file mode 100644 index 0000000..7790e94 --- /dev/null +++ b/packages/libsql-client/src/__tests__/sqlite_error_codes.test.ts @@ -0,0 +1,47 @@ +import { expect } from "@jest/globals"; + +import { mapToBaseCode, mapToRawCode } from "../sqlite_error_codes.js"; + +describe("mapToBaseCode()", () => { + test("maps a primary result code", () => { + expect(mapToBaseCode(1)).toBe("SQLITE_ERROR"); + expect(mapToBaseCode(19)).toBe("SQLITE_CONSTRAINT"); + }); + + test("strips the extended bits", () => { + // SQLITE_CONSTRAINT_PRIMARYKEY = 19 | (6 << 8) = 1555 + expect(mapToBaseCode(1555)).toBe("SQLITE_CONSTRAINT"); + }); + + test("falls back for unknown or missing codes", () => { + expect(mapToBaseCode(undefined)).toBe("SQLITE_UNKNOWN"); + expect(mapToBaseCode(99)).toBe("SQLITE_UNKNOWN_99"); + }); +}); + +describe("mapToRawCode()", () => { + test("maps a base SQLITE_* code back to its number", () => { + expect(mapToRawCode("SQLITE_ERROR")).toBe(1); + expect(mapToRawCode("SQLITE_CONSTRAINT")).toBe(19); + expect(mapToRawCode("SQLITE_BUSY")).toBe(5); + }); + + test("resolves an extended code to its base number", () => { + expect(mapToRawCode("SQLITE_CONSTRAINT_PRIMARYKEY")).toBe(19); + expect(mapToRawCode("SQLITE_CONSTRAINT_UNIQUE")).toBe(19); + expect(mapToRawCode("SQLITE_IOERR_WRITE")).toBe(10); + }); + + test("returns undefined for non-SQLite and missing codes", () => { + expect(mapToRawCode(undefined)).toBeUndefined(); + expect(mapToRawCode("HRANA_PROTO_ERROR")).toBeUndefined(); + expect(mapToRawCode("SERVER_ERROR")).toBeUndefined(); + expect(mapToRawCode("SQLITE_NOT_A_REAL_CODE")).toBeUndefined(); + }); + + test("round-trips with mapToBaseCode", () => { + for (const raw of [1, 5, 10, 19, 25, 28]) { + expect(mapToRawCode(mapToBaseCode(raw))).toBe(raw); + } + }); +}); diff --git a/packages/libsql-client/src/hrana.ts b/packages/libsql-client/src/hrana.ts index 588796b..bc95bf0 100644 --- a/packages/libsql-client/src/hrana.ts +++ b/packages/libsql-client/src/hrana.ts @@ -7,8 +7,9 @@ import type { InArgs, } from "@libsql/core/api"; import { LibsqlError, LibsqlBatchError } from "@libsql/core/api"; -import type { SqlCache } from "./sql_cache.js"; import { transactionModeToBegin, ResultSetImpl } from "@libsql/core/util"; +import type { SqlCache } from "./sql_cache.js"; +import { mapToRawCode } from "./sqlite_error_codes.js"; export abstract class HranaTransaction implements Transaction { #mode: TransactionMode; @@ -402,8 +403,16 @@ export function resultSetFromHrana(hranaRows: hrana.RowsResult): ResultSet { export function mapHranaError(e: unknown): unknown { if (e instanceof hrana.ClientError) { const code = mapHranaErrorCode(e); + // The SQL-over-HTTP protocol only carries the string error code, so + // derive the raw numeric code from it when the code is a SQLITE_* one. // TODO: Parse extendedCode once the SQL over HTTP protocol supports it - return new LibsqlError(e.message, code, undefined, undefined, e); + return new LibsqlError( + e.message, + code, + undefined, + mapToRawCode(code), + e, + ); } return e; } diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index 3044da3..a56f033 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -23,6 +23,7 @@ import { transactionModeToBegin, ResultSetImpl, } from "@libsql/core/util"; +import { mapToBaseCode } from "./sqlite_error_codes.js"; export * from "@libsql/core/api"; @@ -824,46 +825,3 @@ function mapSqliteError(e: unknown): unknown { } return e; } - -// Map SQLite raw error code to base error code string. -// Extended error codes are (base | (extended << 8)), so base = rawCode & 0xFF -function mapToBaseCode(rawCode: number | undefined): string { - if (rawCode === undefined) { - return "SQLITE_UNKNOWN"; - } - const baseCode = rawCode & 0xff; - return ( - sqliteErrorCodes[baseCode] ?? `SQLITE_UNKNOWN_${baseCode.toString()}` - ); -} - -const sqliteErrorCodes: Record = { - 1: "SQLITE_ERROR", - 2: "SQLITE_INTERNAL", - 3: "SQLITE_PERM", - 4: "SQLITE_ABORT", - 5: "SQLITE_BUSY", - 6: "SQLITE_LOCKED", - 7: "SQLITE_NOMEM", - 8: "SQLITE_READONLY", - 9: "SQLITE_INTERRUPT", - 10: "SQLITE_IOERR", - 11: "SQLITE_CORRUPT", - 12: "SQLITE_NOTFOUND", - 13: "SQLITE_FULL", - 14: "SQLITE_CANTOPEN", - 15: "SQLITE_PROTOCOL", - 16: "SQLITE_EMPTY", - 17: "SQLITE_SCHEMA", - 18: "SQLITE_TOOBIG", - 19: "SQLITE_CONSTRAINT", - 20: "SQLITE_MISMATCH", - 21: "SQLITE_MISUSE", - 22: "SQLITE_NOLFS", - 23: "SQLITE_AUTH", - 24: "SQLITE_FORMAT", - 25: "SQLITE_RANGE", - 26: "SQLITE_NOTADB", - 27: "SQLITE_NOTICE", - 28: "SQLITE_WARNING", -}; diff --git a/packages/libsql-client/src/sqlite_error_codes.ts b/packages/libsql-client/src/sqlite_error_codes.ts new file mode 100644 index 0000000..56093bd --- /dev/null +++ b/packages/libsql-client/src/sqlite_error_codes.ts @@ -0,0 +1,70 @@ +// SQLite primary result codes. See https://www.sqlite.org/rescode.html +// Extended codes are (base | (extended << 8)), so base = rawCode & 0xFF. +export const sqliteErrorCodes: Record = { + 1: "SQLITE_ERROR", + 2: "SQLITE_INTERNAL", + 3: "SQLITE_PERM", + 4: "SQLITE_ABORT", + 5: "SQLITE_BUSY", + 6: "SQLITE_LOCKED", + 7: "SQLITE_NOMEM", + 8: "SQLITE_READONLY", + 9: "SQLITE_INTERRUPT", + 10: "SQLITE_IOERR", + 11: "SQLITE_CORRUPT", + 12: "SQLITE_NOTFOUND", + 13: "SQLITE_FULL", + 14: "SQLITE_CANTOPEN", + 15: "SQLITE_PROTOCOL", + 16: "SQLITE_EMPTY", + 17: "SQLITE_SCHEMA", + 18: "SQLITE_TOOBIG", + 19: "SQLITE_CONSTRAINT", + 20: "SQLITE_MISMATCH", + 21: "SQLITE_MISUSE", + 22: "SQLITE_NOLFS", + 23: "SQLITE_AUTH", + 24: "SQLITE_FORMAT", + 25: "SQLITE_RANGE", + 26: "SQLITE_NOTADB", + 27: "SQLITE_NOTICE", + 28: "SQLITE_WARNING", +}; + +const rawCodeByName: Record = Object.fromEntries( + Object.entries(sqliteErrorCodes).map(([raw, name]) => [name, Number(raw)]), +); + +// Map a SQLite raw numeric error code to its base error code string. +export function mapToBaseCode(rawCode: number | undefined): string { + if (rawCode === undefined) { + return "SQLITE_UNKNOWN"; + } + const baseCode = rawCode & 0xff; + return ( + sqliteErrorCodes[baseCode] ?? `SQLITE_UNKNOWN_${baseCode.toString()}` + ); +} + +// Map a SQLite error code string back to its base raw numeric code. Accepts +// extended names (e.g. "SQLITE_CONSTRAINT_PRIMARYKEY") and resolves them to the +// base code (19). Returns undefined for anything not recognised, including the +// client's own non-SQLite codes (e.g. "HRANA_PROTO_ERROR"). +export function mapToRawCode(code: string | undefined): number | undefined { + if (code === undefined) { + return undefined; + } + let name = code; + while (name.length > "SQLITE_".length) { + const rawCode = rawCodeByName[name]; + if (rawCode !== undefined) { + return rawCode; + } + const lastUnderscore = name.lastIndexOf("_"); + if (lastUnderscore <= "SQLITE".length) { + break; + } + name = name.slice(0, lastUnderscore); + } + return undefined; +}