Skip to content

Commit 41f0df4

Browse files
committed
Unify error code in LibsqlError for local and remote
We currently have divergence in what error code means in local and remote setups. Fix the consistency issue by making error code mean the basic one, which is what the SQL over HTTP protocol returns. However, add SQLite extended error codes to LibSqlError and populate that for the local setup. We'll add that for remote too, but that requires a SQL over HTTP protocol extension and server side change.
1 parent 6735e2b commit 41f0df4

File tree

6 files changed

+122
-4
lines changed

6 files changed

+122
-4
lines changed

packages/libsql-client/src/__tests__/client.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,64 @@ describe("transaction()", () => {
16061606
}
16071607
});
16081608

1609+
// Test to verify constraint error codes
1610+
// - code: base error code (e.g., SQLITE_CONSTRAINT) - consistent across local and remote
1611+
// - extendedCode: extended error code (e.g., SQLITE_CONSTRAINT_PRIMARYKEY) - available when supported
1612+
(server !== "test_v1" ? describe : describe.skip)(
1613+
"constraint error codes",
1614+
() => {
1615+
test(
1616+
"PRIMARY KEY constraint violation",
1617+
withClient(async (c) => {
1618+
await c.execute("DROP TABLE IF EXISTS t_pk_test");
1619+
await c.execute(
1620+
"CREATE TABLE t_pk_test (id INTEGER PRIMARY KEY, name TEXT)",
1621+
);
1622+
await c.execute("INSERT INTO t_pk_test VALUES (1, 'first')");
1623+
1624+
try {
1625+
await c.execute(
1626+
"INSERT INTO t_pk_test VALUES (1, 'duplicate')",
1627+
);
1628+
throw new Error("Expected PRIMARY KEY constraint error");
1629+
} catch (e: any) {
1630+
expect(e.code).toBe("SQLITE_CONSTRAINT");
1631+
if (e.extendedCode !== undefined) {
1632+
expect(e.extendedCode).toBe(
1633+
"SQLITE_CONSTRAINT_PRIMARYKEY",
1634+
);
1635+
}
1636+
}
1637+
}),
1638+
);
1639+
1640+
test(
1641+
"UNIQUE constraint violation",
1642+
withClient(async (c) => {
1643+
await c.execute("DROP TABLE IF EXISTS t_unique_test");
1644+
await c.execute(
1645+
"CREATE TABLE t_unique_test (id INTEGER, name TEXT UNIQUE)",
1646+
);
1647+
await c.execute(
1648+
"INSERT INTO t_unique_test VALUES (1, 'unique_name')",
1649+
);
1650+
1651+
try {
1652+
await c.execute(
1653+
"INSERT INTO t_unique_test VALUES (2, 'unique_name')",
1654+
);
1655+
throw new Error("Expected UNIQUE constraint error");
1656+
} catch (e: any) {
1657+
expect(e.code).toBe("SQLITE_CONSTRAINT");
1658+
if (e.extendedCode !== undefined) {
1659+
expect(e.extendedCode).toBe("SQLITE_CONSTRAINT_UNIQUE");
1660+
}
1661+
}
1662+
}),
1663+
);
1664+
},
1665+
);
1666+
16091667
(isSqld ? test : test.skip)("embedded replica test", async () => {
16101668
const remote = createClient(config);
16111669
const embedded = createClient({

packages/libsql-client/src/hrana.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export abstract class HranaTransaction implements Transaction {
157157
mappedError.message,
158158
i,
159159
mappedError.code,
160+
mappedError.extendedCode,
160161
mappedError.rawCode,
161162
mappedError.cause instanceof Error
162163
? mappedError.cause
@@ -338,6 +339,7 @@ export async function executeHranaBatch(
338339
mappedError.message,
339340
i,
340341
mappedError.code,
342+
mappedError.extendedCode,
341343
mappedError.rawCode,
342344
mappedError.cause instanceof Error
343345
? mappedError.cause
@@ -400,7 +402,8 @@ export function resultSetFromHrana(hranaRows: hrana.RowsResult): ResultSet {
400402
export function mapHranaError(e: unknown): unknown {
401403
if (e instanceof hrana.ClientError) {
402404
const code = mapHranaErrorCode(e);
403-
return new LibsqlError(e.message, code, undefined, e);
405+
// TODO: Parse extendedCode once the SQL over HTTP protocol supports it
406+
return new LibsqlError(e.message, code, undefined, undefined, e);
404407
}
405408
return e;
406409
}

packages/libsql-client/src/sqlite3.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export class Sqlite3Client implements Client {
175175
e.message,
176176
i,
177177
e.code,
178+
e.extendedCode,
178179
e.rawCode,
179180
e.cause instanceof Error ? e.cause : undefined,
180181
);
@@ -217,6 +218,7 @@ export class Sqlite3Client implements Client {
217218
e.message,
218219
i,
219220
e.code,
221+
e.extendedCode,
220222
e.rawCode,
221223
e.cause instanceof Error ? e.cause : undefined,
222224
);
@@ -351,6 +353,7 @@ export class Sqlite3Transaction implements Transaction {
351353
e.message,
352354
i,
353355
e.code,
356+
e.extendedCode,
354357
e.rawCode,
355358
e.cause instanceof Error ? e.cause : undefined,
356359
);
@@ -568,7 +571,52 @@ function executeMultiple(db: Database.Database, sql: string): void {
568571

569572
function mapSqliteError(e: unknown): unknown {
570573
if (e instanceof Database.SqliteError) {
571-
return new LibsqlError(e.message, e.code, e.rawCode, e);
574+
const extendedCode = e.code;
575+
const code = mapToBaseCode(e.rawCode);
576+
return new LibsqlError(e.message, code, extendedCode, e.rawCode, e);
572577
}
573578
return e;
574579
}
580+
581+
// Map SQLite raw error code to base error code string.
582+
// Extended error codes are (base | (extended << 8)), so base = rawCode & 0xFF
583+
function mapToBaseCode(rawCode: number | undefined): string {
584+
if (rawCode === undefined) {
585+
return "SQLITE_UNKNOWN";
586+
}
587+
const baseCode = rawCode & 0xff;
588+
return (
589+
sqliteErrorCodes[baseCode] ?? `SQLITE_UNKNOWN_${baseCode.toString()}`
590+
);
591+
}
592+
593+
const sqliteErrorCodes: Record<number, string> = {
594+
1: "SQLITE_ERROR",
595+
2: "SQLITE_INTERNAL",
596+
3: "SQLITE_PERM",
597+
4: "SQLITE_ABORT",
598+
5: "SQLITE_BUSY",
599+
6: "SQLITE_LOCKED",
600+
7: "SQLITE_NOMEM",
601+
8: "SQLITE_READONLY",
602+
9: "SQLITE_INTERRUPT",
603+
10: "SQLITE_IOERR",
604+
11: "SQLITE_CORRUPT",
605+
12: "SQLITE_NOTFOUND",
606+
13: "SQLITE_FULL",
607+
14: "SQLITE_CANTOPEN",
608+
15: "SQLITE_PROTOCOL",
609+
16: "SQLITE_EMPTY",
610+
17: "SQLITE_SCHEMA",
611+
18: "SQLITE_TOOBIG",
612+
19: "SQLITE_CONSTRAINT",
613+
20: "SQLITE_MISMATCH",
614+
21: "SQLITE_MISUSE",
615+
22: "SQLITE_NOLFS",
616+
23: "SQLITE_AUTH",
617+
24: "SQLITE_FORMAT",
618+
25: "SQLITE_RANGE",
619+
26: "SQLITE_NOTADB",
620+
27: "SQLITE_NOTICE",
621+
28: "SQLITE_WARNING",
622+
};

packages/libsql-core/src/api.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,12 +489,15 @@ export type InArgs = Array<InValue> | Record<string, InValue>;
489489
export class LibsqlError extends Error {
490490
/** Machine-readable error code. */
491491
code: string;
492+
/** Extended error code with more specific information (e.g., SQLITE_CONSTRAINT_PRIMARYKEY). */
493+
extendedCode?: string;
492494
/** Raw numeric error code */
493495
rawCode?: number;
494496

495497
constructor(
496498
message: string,
497499
code: string,
500+
extendedCode?: string,
498501
rawCode?: number,
499502
cause?: Error,
500503
) {
@@ -503,6 +506,7 @@ export class LibsqlError extends Error {
503506
}
504507
super(message, { cause });
505508
this.code = code;
509+
this.extendedCode = extendedCode;
506510
this.rawCode = rawCode;
507511
this.name = "LibsqlError";
508512
}
@@ -517,10 +521,11 @@ export class LibsqlBatchError extends LibsqlError {
517521
message: string,
518522
statementIndex: number,
519523
code: string,
524+
extendedCode?: string,
520525
rawCode?: number,
521526
cause?: Error,
522527
) {
523-
super(message, code, rawCode, cause);
528+
super(message, code, extendedCode, rawCode, cause);
524529
this.statementIndex = statementIndex;
525530
this.name = "LibsqlBatchError";
526531
}

packages/libsql-core/src/uri.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ function percentDecode(text: string): string {
145145
`URL component has invalid percent encoding: ${e}`,
146146
"URL_INVALID",
147147
undefined,
148+
undefined,
148149
e,
149150
);
150151
}

testing/hrana-test-server/server_v3.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import c3
1515
import from_proto
1616
import to_proto
17+
from sqlite3_error_map import sqlite_error_code_to_name
1718
import proto.hrana.http_pb2
1819
import proto.hrana.ws_pb2
1920

@@ -649,7 +650,9 @@ class ResponseError(RuntimeError):
649650
def __init__(self, message, code=None):
650651
if isinstance(message, c3.SqliteError):
651652
if code is None:
652-
code = message.error_name
653+
# Use base error code (error_code & 0xFF) instead of extended code
654+
base_code = message.error_code & 0xFF if message.error_code else None
655+
code = sqlite_error_code_to_name.get(base_code)
653656
message = str(message)
654657
super().__init__(message)
655658
self.code = code

0 commit comments

Comments
 (0)