Skip to content

Commit ec2c466

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 ec2c466

File tree

5 files changed

+113
-3
lines changed

5 files changed

+113
-3
lines changed

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,59 @@ 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+
describe("constraint error codes", () => {
1613+
test(
1614+
"PRIMARY KEY constraint violation",
1615+
withClient(async (c) => {
1616+
await c.execute("DROP TABLE IF EXISTS t_pk_test");
1617+
await c.execute(
1618+
"CREATE TABLE t_pk_test (id INTEGER PRIMARY KEY, name TEXT)",
1619+
);
1620+
await c.execute("INSERT INTO t_pk_test VALUES (1, 'first')");
1621+
1622+
try {
1623+
await c.execute(
1624+
"INSERT INTO t_pk_test VALUES (1, 'duplicate')",
1625+
);
1626+
throw new Error("Expected PRIMARY KEY constraint error");
1627+
} catch (e: any) {
1628+
expect(e.code).toBe("SQLITE_CONSTRAINT");
1629+
if (e.extendedCode !== undefined) {
1630+
expect(e.extendedCode).toBe("SQLITE_CONSTRAINT_PRIMARYKEY");
1631+
}
1632+
}
1633+
}),
1634+
);
1635+
1636+
test(
1637+
"UNIQUE constraint violation",
1638+
withClient(async (c) => {
1639+
await c.execute("DROP TABLE IF EXISTS t_unique_test");
1640+
await c.execute(
1641+
"CREATE TABLE t_unique_test (id INTEGER, name TEXT UNIQUE)",
1642+
);
1643+
await c.execute(
1644+
"INSERT INTO t_unique_test VALUES (1, 'unique_name')",
1645+
);
1646+
1647+
try {
1648+
await c.execute(
1649+
"INSERT INTO t_unique_test VALUES (2, 'unique_name')",
1650+
);
1651+
throw new Error("Expected UNIQUE constraint error");
1652+
} catch (e: any) {
1653+
expect(e.code).toBe("SQLITE_CONSTRAINT");
1654+
if (e.extendedCode !== undefined) {
1655+
expect(e.extendedCode).toBe("SQLITE_CONSTRAINT_UNIQUE");
1656+
}
1657+
}
1658+
}),
1659+
);
1660+
});
1661+
16091662
(isSqld ? test : test.skip)("embedded replica test", async () => {
16101663
const remote = createClient(config);
16111664
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
}

0 commit comments

Comments
 (0)