diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9bad04a4..c0e82445 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -114,35 +114,30 @@ jobs: working-directory: ./packages/libsql-core - name: "Install npm dependencies" run: "npm ci" - - name: "Checkout hrana-test-server" - uses: actions/checkout@v4 - with: - repository: "libsql/hrana-test-server" - path: "packages/libsql-client/hrana-test-server" - name: "Setup Python" uses: actions/setup-python@v4 with: python-version: "3.10" - name: "Install pip dependencies" - run: "pip install -r hrana-test-server/requirements.txt" + run: "pip install -r ../../testing/hrana-test-server/requirements.txt" - name: "Build" run: "npm run build" - name: "Test Hrana 1 over WebSocket" - run: "python hrana-test-server/server_v1.py npm test" + run: "python ../../testing/hrana-test-server/server_v1.py npm test" env: { "URL": "ws://localhost:8080", "SERVER": "test_v1" } - name: "Test Hrana 2 over WebSocket" - run: "python hrana-test-server/server_v2.py npm test" + run: "python ../../testing/hrana-test-server/server_v2.py npm test" env: { "URL": "ws://localhost:8080", "SERVER": "test_v2" } - name: "Test Hrana 2 over HTTP" - run: "python hrana-test-server/server_v2.py npm test" + run: "python ../../testing/hrana-test-server/server_v2.py npm test" env: { "URL": "http://localhost:8080", "SERVER": "test_v2" } # - name: "Test Hrana 3 over WebSocket" - # run: "python hrana-test-server/server_v3.py npm test" + # run: "python ../../testing/hrana-test-server/server_v3.py npm test" # env: {"URL": "ws://localhost:8080", "SERVER": "test_v3"} # - name: "Test Hrana 3 over HTTP" - # run: "python hrana-test-server/server_v3.py npm test" + # run: "python ../../testing/hrana-test-server/server_v3.py npm test" # env: {"URL": "http://localhost:8080", "SERVER": "test_v3"} - name: "Test local file" run: "npm test" @@ -176,17 +171,12 @@ jobs: - name: "Install npm dependencies" run: "npm ci" - - name: "Checkout hrana-test-server" - uses: actions/checkout@v4 - with: - repository: "libsql/hrana-test-server" - path: "packages/libsql-client/hrana-test-server" - name: "Setup Python" uses: actions/setup-python@v4 with: python-version: "3.10" - name: "Install pip dependencies" - run: "pip install -r hrana-test-server/requirements.txt" + run: "pip install -r ../../testing/hrana-test-server/requirements.txt" - name: "Build" run: "npm run build" @@ -194,35 +184,35 @@ jobs: run: "cd smoke_test/workers && npm link ../.." - name: "Local test with Hrana 1 over WebSocket" - run: "cd smoke_test/workers && python ../../hrana-test-server/server_v1.py node --dns-result-order=ipv4first test.js" + run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v1.py node --dns-result-order=ipv4first test.js" env: { "LOCAL": "1", "URL": "ws://localhost:8080" } - name: "Local test with Hrana 2 over WebSocket" - run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" + run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" env: { "LOCAL": "1", "URL": "ws://localhost:8080" } - name: "Local test with Hrana 2 over HTTP" - run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" + run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node --dns-result-order=ipv4first test.js" env: { "LOCAL": "1", "URL": "http://localhost:8080" } # - name: "Local test with Hrana 3 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" # env: {"LOCAL": "1", "URL": "ws://localhost:8080"} # - name: "Local test with Hrana 3 over HTTP" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node --dns-result-order=ipv4first test.js" # env: {"LOCAL": "1", "URL": "http://localhost:8080"} # - name: "Non-local test with Hrana 1 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v1.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v1.py node test.js" # env: {"LOCAL": "0", "URL": "ws://localhost:8080"} # - name: "Non-local test with Hrana 2 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node test.js" # env: {"LOCAL": "0", "URL": "ws://localhost:8080"} # - name: "Non-local test with Hrana 2 over HTTP" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v2.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v2.py node test.js" # env: {"LOCAL": "0", "URL": "http://localhost:8080"} # - name: "Non-local test with Hrana 3 over WebSocket" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node test.js" # env: {"LOCAL": "0", "URL": "ws://localhost:8080"} # - name: "Non-local test with Hrana 3 over HTTP" -# run: "cd smoke_test/workers && python ../../hrana-test-server/server_v3.py node test.js" +# run: "cd smoke_test/workers && python ../../../../testing/hrana-test-server/server_v3.py node test.js" # env: {"LOCAL": "0", "URL": "http://localhost:8080"} # "vercel-test": diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index bbf2ec86..e63b1ec3 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -1606,6 +1606,64 @@ describe("transaction()", () => { } }); +// Test to verify constraint error codes +// - code: base error code (e.g., SQLITE_CONSTRAINT) - consistent across local and remote +// - extendedCode: extended error code (e.g., SQLITE_CONSTRAINT_PRIMARYKEY) - available when supported +(server !== "test_v1" ? describe : describe.skip)( + "constraint error codes", + () => { + test( + "PRIMARY KEY constraint violation", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t_pk_test"); + await c.execute( + "CREATE TABLE t_pk_test (id INTEGER PRIMARY KEY, name TEXT)", + ); + await c.execute("INSERT INTO t_pk_test VALUES (1, 'first')"); + + try { + await c.execute( + "INSERT INTO t_pk_test VALUES (1, 'duplicate')", + ); + throw new Error("Expected PRIMARY KEY constraint error"); + } catch (e: any) { + expect(e.code).toBe("SQLITE_CONSTRAINT"); + if (e.extendedCode !== undefined) { + expect(e.extendedCode).toBe( + "SQLITE_CONSTRAINT_PRIMARYKEY", + ); + } + } + }), + ); + + test( + "UNIQUE constraint violation", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t_unique_test"); + await c.execute( + "CREATE TABLE t_unique_test (id INTEGER, name TEXT UNIQUE)", + ); + await c.execute( + "INSERT INTO t_unique_test VALUES (1, 'unique_name')", + ); + + try { + await c.execute( + "INSERT INTO t_unique_test VALUES (2, 'unique_name')", + ); + throw new Error("Expected UNIQUE constraint error"); + } catch (e: any) { + expect(e.code).toBe("SQLITE_CONSTRAINT"); + if (e.extendedCode !== undefined) { + expect(e.extendedCode).toBe("SQLITE_CONSTRAINT_UNIQUE"); + } + } + }), + ); + }, +); + (isSqld ? test : test.skip)("embedded replica test", async () => { const remote = createClient(config); const embedded = createClient({ diff --git a/packages/libsql-client/src/hrana.ts b/packages/libsql-client/src/hrana.ts index d97b44f2..588796b4 100644 --- a/packages/libsql-client/src/hrana.ts +++ b/packages/libsql-client/src/hrana.ts @@ -157,6 +157,7 @@ export abstract class HranaTransaction implements Transaction { mappedError.message, i, mappedError.code, + mappedError.extendedCode, mappedError.rawCode, mappedError.cause instanceof Error ? mappedError.cause @@ -338,6 +339,7 @@ export async function executeHranaBatch( mappedError.message, i, mappedError.code, + mappedError.extendedCode, mappedError.rawCode, mappedError.cause instanceof Error ? mappedError.cause @@ -400,7 +402,8 @@ export function resultSetFromHrana(hranaRows: hrana.RowsResult): ResultSet { export function mapHranaError(e: unknown): unknown { if (e instanceof hrana.ClientError) { const code = mapHranaErrorCode(e); - return new LibsqlError(e.message, code, undefined, e); + // TODO: Parse extendedCode once the SQL over HTTP protocol supports it + return new LibsqlError(e.message, code, undefined, undefined, e); } return e; } diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index a12895f4..b8a9ad87 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -175,6 +175,7 @@ export class Sqlite3Client implements Client { e.message, i, e.code, + e.extendedCode, e.rawCode, e.cause instanceof Error ? e.cause : undefined, ); @@ -217,6 +218,7 @@ export class Sqlite3Client implements Client { e.message, i, e.code, + e.extendedCode, e.rawCode, e.cause instanceof Error ? e.cause : undefined, ); @@ -351,6 +353,7 @@ export class Sqlite3Transaction implements Transaction { e.message, i, e.code, + e.extendedCode, e.rawCode, e.cause instanceof Error ? e.cause : undefined, ); @@ -568,7 +571,52 @@ function executeMultiple(db: Database.Database, sql: string): void { function mapSqliteError(e: unknown): unknown { if (e instanceof Database.SqliteError) { - return new LibsqlError(e.message, e.code, e.rawCode, e); + const extendedCode = e.code; + const code = mapToBaseCode(e.rawCode); + return new LibsqlError(e.message, code, extendedCode, e.rawCode, e); } 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-core/src/api.ts b/packages/libsql-core/src/api.ts index e22ec94c..471eb22b 100644 --- a/packages/libsql-core/src/api.ts +++ b/packages/libsql-core/src/api.ts @@ -489,12 +489,15 @@ export type InArgs = Array | Record; export class LibsqlError extends Error { /** Machine-readable error code. */ code: string; + /** Extended error code with more specific information (e.g., SQLITE_CONSTRAINT_PRIMARYKEY). */ + extendedCode?: string; /** Raw numeric error code */ rawCode?: number; constructor( message: string, code: string, + extendedCode?: string, rawCode?: number, cause?: Error, ) { @@ -503,6 +506,7 @@ export class LibsqlError extends Error { } super(message, { cause }); this.code = code; + this.extendedCode = extendedCode; this.rawCode = rawCode; this.name = "LibsqlError"; } @@ -517,10 +521,11 @@ export class LibsqlBatchError extends LibsqlError { message: string, statementIndex: number, code: string, + extendedCode?: string, rawCode?: number, cause?: Error, ) { - super(message, code, rawCode, cause); + super(message, code, extendedCode, rawCode, cause); this.statementIndex = statementIndex; this.name = "LibsqlBatchError"; } diff --git a/packages/libsql-core/src/uri.ts b/packages/libsql-core/src/uri.ts index 9642d561..470df4ef 100644 --- a/packages/libsql-core/src/uri.ts +++ b/packages/libsql-core/src/uri.ts @@ -145,6 +145,7 @@ function percentDecode(text: string): string { `URL component has invalid percent encoding: ${e}`, "URL_INVALID", undefined, + undefined, e, ); } diff --git a/testing/hrana-test-server/server_v2.py b/testing/hrana-test-server/server_v2.py index b7024a3b..66a98527 100644 --- a/testing/hrana-test-server/server_v2.py +++ b/testing/hrana-test-server/server_v2.py @@ -12,6 +12,7 @@ import aiohttp.web import c3 +from sqlite3_error_map import sqlite_error_code_to_name logger = logging.getLogger("server") persistent_db_file = os.getenv("PERSISTENT_DB") @@ -521,7 +522,9 @@ class ResponseError(RuntimeError): def __init__(self, message, code=None): if isinstance(message, c3.SqliteError): if code is None: - code = message.error_name + # Use base error code (error_code & 0xFF) instead of extended code + base_code = message.error_code & 0xFF if message.error_code else None + code = sqlite_error_code_to_name.get(base_code) message = str(message) super().__init__(message) self.code = code diff --git a/testing/hrana-test-server/server_v3.py b/testing/hrana-test-server/server_v3.py index 93a4ff5f..056b8543 100644 --- a/testing/hrana-test-server/server_v3.py +++ b/testing/hrana-test-server/server_v3.py @@ -14,6 +14,7 @@ import c3 import from_proto import to_proto +from sqlite3_error_map import sqlite_error_code_to_name import proto.hrana.http_pb2 import proto.hrana.ws_pb2 @@ -649,7 +650,9 @@ class ResponseError(RuntimeError): def __init__(self, message, code=None): if isinstance(message, c3.SqliteError): if code is None: - code = message.error_name + # Use base error code (error_code & 0xFF) instead of extended code + base_code = message.error_code & 0xFF if message.error_code else None + code = sqlite_error_code_to_name.get(base_code) message = str(message) super().__init__(message) self.code = code