From df00ade96d6c02dbebb8c66513ce47663ed476ab Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Sat, 1 Nov 2025 10:21:07 +0200 Subject: [PATCH] libsql-client: Report batch statement index on error If a batch statement fails, the SQL over HTTP protocol does report back exactly what step failed. However, we also need to propagate that information to the caller. --- .../src/__tests__/client.test.ts | 177 ++++++++++++++++++ packages/libsql-client/src/hrana.ts | 78 ++++++-- packages/libsql-client/src/sqlite3.ts | 112 ++++++++--- packages/libsql-core/src/api.ts | 18 ++ 4 files changed, 341 insertions(+), 44 deletions(-) diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index eaa5a87b..bbf2ec86 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -882,6 +882,110 @@ describe("batch()", () => { }), ); + test( + "batch error reports statement index - error at index 0", + withClient(async (c) => { + try { + await c.batch( + ["SELECT invalid_column", "SELECT 1", "SELECT 2"], + "read", + ); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(0); + expect(e.code).toBeDefined(); + } + }), + ); + + test( + "batch error reports statement index - error at index 1", + withClient(async (c) => { + try { + await c.batch( + ["SELECT 1", "SELECT invalid_column", "SELECT 2"], + "read", + ); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(1); + expect(e.code).toBeDefined(); + } + }), + ); + + test( + "batch error reports statement index - error at index 2", + withClient(async (c) => { + try { + await c.batch( + ["SELECT 1", "SELECT 2", "SELECT invalid_column"], + "read", + ); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(2); + expect(e.code).toBeDefined(); + } + }), + ); + + test( + "batch error with write mode reports statement index", + withClient(async (c) => { + await c.execute("DROP TABLE IF EXISTS t"); + await c.execute("CREATE TABLE t (a UNIQUE)"); + await c.execute("INSERT INTO t VALUES (1)"); + + try { + await c.batch( + [ + "INSERT INTO t VALUES (2)", + "INSERT INTO t VALUES (3)", + "INSERT INTO t VALUES (1)", // Duplicate, will fail + "INSERT INTO t VALUES (4)", + ], + "write", + ); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(2); + expect(e.code).toBeDefined(); + } + + // Verify rollback happened + const rs = await c.execute("SELECT COUNT(*) FROM t"); + expect(rs.rows[0][0]).toBe(1); + }), + ); + + test( + "batch error in in-memory database reports statement index", + withInMemoryClient(async (c) => { + await c.execute("CREATE TABLE t (a)"); + + try { + await c.batch( + [ + "INSERT INTO t VALUES (1)", + "SELECT invalid_column FROM t", + "INSERT INTO t VALUES (2)", + ], + "write", + ); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(1); + expect(e.code).toBeDefined(); + } + }), + ); + test( "batch with a lot of different statements", withClient(async (c) => { @@ -1216,6 +1320,79 @@ describe("transaction()", () => { await txn.commit(); }), ); + + test( + "batch error reports statement index in transaction", + withClient(async (c) => { + const txn = await c.transaction("write"); + + try { + await txn.batch([ + "DROP TABLE IF EXISTS t", + "CREATE TABLE t (a UNIQUE)", + "INSERT INTO t VALUES (1), (2), (3)", + "INSERT INTO t VALUES (1)", // Duplicate, will fail at index 3 + "INSERT INTO t VALUES (4), (5)", + ]); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(3); + expect(e.code).toBeDefined(); + } + + // Transaction should still be usable after batch error + const rs = await txn.execute("SELECT SUM(a) FROM t"); + expect(rs.rows[0][0]).toBe(6); + + await txn.commit(); + }), + ); + + test( + "batch error reports statement index - error at first statement in transaction", + withClient(async (c) => { + const txn = await c.transaction("read"); + + try { + await txn.batch([ + "SELECT invalid_column", + "SELECT 1", + "SELECT 2", + ]); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(0); + expect(e.code).toBeDefined(); + } + + txn.close(); + }), + ); + + test( + "batch error reports statement index - error at middle statement in transaction", + withClient(async (c) => { + const txn = await c.transaction("read"); + + try { + await txn.batch([ + "SELECT 1", + "SELECT 2", + "SELECT invalid_column", + "SELECT 3", + ]); + throw new Error("Expected batch to fail"); + } catch (e: any) { + expect(e.name).toBe("LibsqlBatchError"); + expect(e.statementIndex).toBe(2); + expect(e.code).toBeDefined(); + } + + txn.close(); + }), + ); }); (hasHrana2 ? describe : describe.skip)("executeMultiple()", () => { diff --git a/packages/libsql-client/src/hrana.ts b/packages/libsql-client/src/hrana.ts index f3134fb4..d97b44f2 100644 --- a/packages/libsql-client/src/hrana.ts +++ b/packages/libsql-client/src/hrana.ts @@ -6,7 +6,7 @@ import type { TransactionMode, InArgs, } from "@libsql/core/api"; -import { LibsqlError } 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"; @@ -134,16 +134,37 @@ export abstract class HranaTransaction implements Transaction { } const resultSets = []; - for (const rowsPromise of rowsPromises) { - const rows = await rowsPromise; - if (rows === undefined) { - throw new LibsqlError( - "Statement in a transaction was not executed, " + - "probably because the transaction has been rolled back", - "TRANSACTION_CLOSED", - ); + for (let i = 0; i < rowsPromises.length; i++) { + try { + const rows = await rowsPromises[i]; + if (rows === undefined) { + throw new LibsqlBatchError( + "Statement in a transaction was not executed, " + + "probably because the transaction has been rolled back", + i, + "TRANSACTION_CLOSED", + ); + } + resultSets.push(resultSetFromHrana(rows)); + } catch (e) { + if (e instanceof LibsqlBatchError) { + throw e; + } + // Map hrana errors to LibsqlError first, then wrap in LibsqlBatchError + const mappedError = mapHranaError(e); + if (mappedError instanceof LibsqlError) { + throw new LibsqlBatchError( + mappedError.message, + i, + mappedError.code, + mappedError.rawCode, + mappedError.cause instanceof Error + ? mappedError.cause + : undefined, + ); + } + throw mappedError; } - resultSets.push(resultSetFromHrana(rows)); } return resultSets; } catch (e) { @@ -295,15 +316,36 @@ export async function executeHranaBatch( const resultSets = []; await beginPromise; - for (const stmtPromise of stmtPromises) { - const hranaRows = await stmtPromise; - if (hranaRows === undefined) { - throw new LibsqlError( - "Statement in a batch was not executed, probably because the transaction has been rolled back", - "TRANSACTION_CLOSED", - ); + for (let i = 0; i < stmtPromises.length; i++) { + try { + const hranaRows = await stmtPromises[i]; + if (hranaRows === undefined) { + throw new LibsqlBatchError( + "Statement in a batch was not executed, probably because the transaction has been rolled back", + i, + "TRANSACTION_CLOSED", + ); + } + resultSets.push(resultSetFromHrana(hranaRows)); + } catch (e) { + if (e instanceof LibsqlBatchError) { + throw e; + } + // Map hrana errors to LibsqlError first, then wrap in LibsqlBatchError + const mappedError = mapHranaError(e); + if (mappedError instanceof LibsqlError) { + throw new LibsqlBatchError( + mappedError.message, + i, + mappedError.code, + mappedError.rawCode, + mappedError.cause instanceof Error + ? mappedError.cause + : undefined, + ); + } + throw mappedError; } - resultSets.push(resultSetFromHrana(hranaRows)); } await commitPromise; diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index 93e91f1e..66d44f4d 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -15,7 +15,7 @@ import type { InArgs, Replicated, } from "@libsql/core/api"; -import { LibsqlError } from "@libsql/core/api"; +import { LibsqlError, LibsqlBatchError } from "@libsql/core/api"; import type { ExpandedConfig } from "@libsql/core/config"; import { expandConfig, isInMemoryConfig } from "@libsql/core/config"; import { @@ -148,18 +148,39 @@ export class Sqlite3Client implements Client { const db = this.#getDb(); try { executeStmt(db, transactionModeToBegin(mode), this.#intMode); - const resultSets = stmts.map((stmt) => { - if (!db.inTransaction) { - throw new LibsqlError( - "The transaction has been rolled back", - "TRANSACTION_CLOSED", + const resultSets = []; + for (let i = 0; i < stmts.length; i++) { + try { + if (!db.inTransaction) { + throw new LibsqlBatchError( + "The transaction has been rolled back", + i, + "TRANSACTION_CLOSED", + ); + } + const stmt = stmts[i]; + const normalizedStmt: InStatement = Array.isArray(stmt) + ? { sql: stmt[0], args: stmt[1] || [] } + : stmt; + resultSets.push( + executeStmt(db, normalizedStmt, this.#intMode), ); + } catch (e) { + if (e instanceof LibsqlBatchError) { + throw e; + } + if (e instanceof LibsqlError) { + throw new LibsqlBatchError( + e.message, + i, + e.code, + e.rawCode, + e.cause instanceof Error ? e.cause : undefined, + ); + } + throw e; } - const normalizedStmt: InStatement = Array.isArray(stmt) - ? { sql: stmt[0], args: stmt[1] || [] } - : stmt; - return executeStmt(db, normalizedStmt, this.#intMode); - }); + } executeStmt(db, "COMMIT", this.#intMode); return resultSets; } finally { @@ -175,15 +196,33 @@ export class Sqlite3Client implements Client { try { executeStmt(db, "PRAGMA foreign_keys=off", this.#intMode); executeStmt(db, transactionModeToBegin("deferred"), this.#intMode); - const resultSets = stmts.map((stmt) => { - if (!db.inTransaction) { - throw new LibsqlError( - "The transaction has been rolled back", - "TRANSACTION_CLOSED", - ); + const resultSets = []; + for (let i = 0; i < stmts.length; i++) { + try { + if (!db.inTransaction) { + throw new LibsqlBatchError( + "The transaction has been rolled back", + i, + "TRANSACTION_CLOSED", + ); + } + resultSets.push(executeStmt(db, stmts[i], this.#intMode)); + } catch (e) { + if (e instanceof LibsqlBatchError) { + throw e; + } + if (e instanceof LibsqlError) { + throw new LibsqlBatchError( + e.message, + i, + e.code, + e.rawCode, + e.cause instanceof Error ? e.cause : undefined, + ); + } + throw e; } - return executeStmt(db, stmt, this.#intMode); - }); + } executeStmt(db, "COMMIT", this.#intMode); return resultSets; } finally { @@ -291,13 +330,34 @@ export class Sqlite3Transaction implements Transaction { async batch( stmts: Array, ): Promise> { - return stmts.map((stmt) => { - this.#checkNotClosed(); - const normalizedStmt: InStatement = Array.isArray(stmt) - ? { sql: stmt[0], args: stmt[1] || [] } - : stmt; - return executeStmt(this.#database, normalizedStmt, this.#intMode); - }); + const resultSets = []; + for (let i = 0; i < stmts.length; i++) { + try { + this.#checkNotClosed(); + const stmt = stmts[i]; + const normalizedStmt: InStatement = Array.isArray(stmt) + ? { sql: stmt[0], args: stmt[1] || [] } + : stmt; + resultSets.push( + executeStmt(this.#database, normalizedStmt, this.#intMode), + ); + } catch (e) { + if (e instanceof LibsqlBatchError) { + throw e; + } + if (e instanceof LibsqlError) { + throw new LibsqlBatchError( + e.message, + i, + e.code, + e.rawCode, + e.cause instanceof Error ? e.cause : undefined, + ); + } + throw e; + } + } + return resultSets; } async executeMultiple(sql: string): Promise { diff --git a/packages/libsql-core/src/api.ts b/packages/libsql-core/src/api.ts index 04899d12..4e815128 100644 --- a/packages/libsql-core/src/api.ts +++ b/packages/libsql-core/src/api.ts @@ -504,3 +504,21 @@ export class LibsqlError extends Error { this.name = "LibsqlError"; } } + +/** Error thrown by the client during batch operations. */ +export class LibsqlBatchError extends LibsqlError { + /** The zero-based index of the statement that failed in the batch. */ + statementIndex: number; + + constructor( + message: string, + statementIndex: number, + code: string, + rawCode?: number, + cause?: Error, + ) { + super(message, code, rawCode, cause); + this.statementIndex = statementIndex; + this.name = "LibsqlBatchError"; + } +}