Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 17 additions & 27 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -176,53 +171,48 @@ 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"
- name: "Install npm dependencies of the Workers test"
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":
Expand Down
58 changes: 58 additions & 0 deletions packages/libsql-client/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion packages/libsql-client/src/hrana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -338,6 +339,7 @@ export async function executeHranaBatch(
mappedError.message,
i,
mappedError.code,
mappedError.extendedCode,
mappedError.rawCode,
mappedError.cause instanceof Error
? mappedError.cause
Expand Down Expand Up @@ -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;
}
Expand Down
50 changes: 49 additions & 1 deletion packages/libsql-client/src/sqlite3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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<number, string> = {
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",
};
7 changes: 6 additions & 1 deletion packages/libsql-core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,12 +489,15 @@ export type InArgs = Array<InValue> | Record<string, InValue>;
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,
) {
Expand All @@ -503,6 +506,7 @@ export class LibsqlError extends Error {
}
super(message, { cause });
this.code = code;
this.extendedCode = extendedCode;
this.rawCode = rawCode;
this.name = "LibsqlError";
}
Expand All @@ -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";
}
Expand Down
1 change: 1 addition & 0 deletions packages/libsql-core/src/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ function percentDecode(text: string): string {
`URL component has invalid percent encoding: ${e}`,
"URL_INVALID",
undefined,
undefined,
e,
);
}
Expand Down
5 changes: 4 additions & 1 deletion testing/hrana-test-server/server_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion testing/hrana-test-server/server_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down