Skip to content

Commit da3cb0a

Browse files
authored
libsql-client: Report batch statement index on error (#329)
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.
2 parents c5b8e89 + df00ade commit da3cb0a

File tree

4 files changed

+341
-44
lines changed

4 files changed

+341
-44
lines changed

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

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,110 @@ describe("batch()", () => {
882882
}),
883883
);
884884

885+
test(
886+
"batch error reports statement index - error at index 0",
887+
withClient(async (c) => {
888+
try {
889+
await c.batch(
890+
["SELECT invalid_column", "SELECT 1", "SELECT 2"],
891+
"read",
892+
);
893+
throw new Error("Expected batch to fail");
894+
} catch (e: any) {
895+
expect(e.name).toBe("LibsqlBatchError");
896+
expect(e.statementIndex).toBe(0);
897+
expect(e.code).toBeDefined();
898+
}
899+
}),
900+
);
901+
902+
test(
903+
"batch error reports statement index - error at index 1",
904+
withClient(async (c) => {
905+
try {
906+
await c.batch(
907+
["SELECT 1", "SELECT invalid_column", "SELECT 2"],
908+
"read",
909+
);
910+
throw new Error("Expected batch to fail");
911+
} catch (e: any) {
912+
expect(e.name).toBe("LibsqlBatchError");
913+
expect(e.statementIndex).toBe(1);
914+
expect(e.code).toBeDefined();
915+
}
916+
}),
917+
);
918+
919+
test(
920+
"batch error reports statement index - error at index 2",
921+
withClient(async (c) => {
922+
try {
923+
await c.batch(
924+
["SELECT 1", "SELECT 2", "SELECT invalid_column"],
925+
"read",
926+
);
927+
throw new Error("Expected batch to fail");
928+
} catch (e: any) {
929+
expect(e.name).toBe("LibsqlBatchError");
930+
expect(e.statementIndex).toBe(2);
931+
expect(e.code).toBeDefined();
932+
}
933+
}),
934+
);
935+
936+
test(
937+
"batch error with write mode reports statement index",
938+
withClient(async (c) => {
939+
await c.execute("DROP TABLE IF EXISTS t");
940+
await c.execute("CREATE TABLE t (a UNIQUE)");
941+
await c.execute("INSERT INTO t VALUES (1)");
942+
943+
try {
944+
await c.batch(
945+
[
946+
"INSERT INTO t VALUES (2)",
947+
"INSERT INTO t VALUES (3)",
948+
"INSERT INTO t VALUES (1)", // Duplicate, will fail
949+
"INSERT INTO t VALUES (4)",
950+
],
951+
"write",
952+
);
953+
throw new Error("Expected batch to fail");
954+
} catch (e: any) {
955+
expect(e.name).toBe("LibsqlBatchError");
956+
expect(e.statementIndex).toBe(2);
957+
expect(e.code).toBeDefined();
958+
}
959+
960+
// Verify rollback happened
961+
const rs = await c.execute("SELECT COUNT(*) FROM t");
962+
expect(rs.rows[0][0]).toBe(1);
963+
}),
964+
);
965+
966+
test(
967+
"batch error in in-memory database reports statement index",
968+
withInMemoryClient(async (c) => {
969+
await c.execute("CREATE TABLE t (a)");
970+
971+
try {
972+
await c.batch(
973+
[
974+
"INSERT INTO t VALUES (1)",
975+
"SELECT invalid_column FROM t",
976+
"INSERT INTO t VALUES (2)",
977+
],
978+
"write",
979+
);
980+
throw new Error("Expected batch to fail");
981+
} catch (e: any) {
982+
expect(e.name).toBe("LibsqlBatchError");
983+
expect(e.statementIndex).toBe(1);
984+
expect(e.code).toBeDefined();
985+
}
986+
}),
987+
);
988+
885989
test(
886990
"batch with a lot of different statements",
887991
withClient(async (c) => {
@@ -1216,6 +1320,79 @@ describe("transaction()", () => {
12161320
await txn.commit();
12171321
}),
12181322
);
1323+
1324+
test(
1325+
"batch error reports statement index in transaction",
1326+
withClient(async (c) => {
1327+
const txn = await c.transaction("write");
1328+
1329+
try {
1330+
await txn.batch([
1331+
"DROP TABLE IF EXISTS t",
1332+
"CREATE TABLE t (a UNIQUE)",
1333+
"INSERT INTO t VALUES (1), (2), (3)",
1334+
"INSERT INTO t VALUES (1)", // Duplicate, will fail at index 3
1335+
"INSERT INTO t VALUES (4), (5)",
1336+
]);
1337+
throw new Error("Expected batch to fail");
1338+
} catch (e: any) {
1339+
expect(e.name).toBe("LibsqlBatchError");
1340+
expect(e.statementIndex).toBe(3);
1341+
expect(e.code).toBeDefined();
1342+
}
1343+
1344+
// Transaction should still be usable after batch error
1345+
const rs = await txn.execute("SELECT SUM(a) FROM t");
1346+
expect(rs.rows[0][0]).toBe(6);
1347+
1348+
await txn.commit();
1349+
}),
1350+
);
1351+
1352+
test(
1353+
"batch error reports statement index - error at first statement in transaction",
1354+
withClient(async (c) => {
1355+
const txn = await c.transaction("read");
1356+
1357+
try {
1358+
await txn.batch([
1359+
"SELECT invalid_column",
1360+
"SELECT 1",
1361+
"SELECT 2",
1362+
]);
1363+
throw new Error("Expected batch to fail");
1364+
} catch (e: any) {
1365+
expect(e.name).toBe("LibsqlBatchError");
1366+
expect(e.statementIndex).toBe(0);
1367+
expect(e.code).toBeDefined();
1368+
}
1369+
1370+
txn.close();
1371+
}),
1372+
);
1373+
1374+
test(
1375+
"batch error reports statement index - error at middle statement in transaction",
1376+
withClient(async (c) => {
1377+
const txn = await c.transaction("read");
1378+
1379+
try {
1380+
await txn.batch([
1381+
"SELECT 1",
1382+
"SELECT 2",
1383+
"SELECT invalid_column",
1384+
"SELECT 3",
1385+
]);
1386+
throw new Error("Expected batch to fail");
1387+
} catch (e: any) {
1388+
expect(e.name).toBe("LibsqlBatchError");
1389+
expect(e.statementIndex).toBe(2);
1390+
expect(e.code).toBeDefined();
1391+
}
1392+
1393+
txn.close();
1394+
}),
1395+
);
12191396
});
12201397

12211398
(hasHrana2 ? describe : describe.skip)("executeMultiple()", () => {

packages/libsql-client/src/hrana.ts

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
TransactionMode,
77
InArgs,
88
} from "@libsql/core/api";
9-
import { LibsqlError } from "@libsql/core/api";
9+
import { LibsqlError, LibsqlBatchError } from "@libsql/core/api";
1010
import type { SqlCache } from "./sql_cache.js";
1111
import { transactionModeToBegin, ResultSetImpl } from "@libsql/core/util";
1212

@@ -134,16 +134,37 @@ export abstract class HranaTransaction implements Transaction {
134134
}
135135

136136
const resultSets = [];
137-
for (const rowsPromise of rowsPromises) {
138-
const rows = await rowsPromise;
139-
if (rows === undefined) {
140-
throw new LibsqlError(
141-
"Statement in a transaction was not executed, " +
142-
"probably because the transaction has been rolled back",
143-
"TRANSACTION_CLOSED",
144-
);
137+
for (let i = 0; i < rowsPromises.length; i++) {
138+
try {
139+
const rows = await rowsPromises[i];
140+
if (rows === undefined) {
141+
throw new LibsqlBatchError(
142+
"Statement in a transaction was not executed, " +
143+
"probably because the transaction has been rolled back",
144+
i,
145+
"TRANSACTION_CLOSED",
146+
);
147+
}
148+
resultSets.push(resultSetFromHrana(rows));
149+
} catch (e) {
150+
if (e instanceof LibsqlBatchError) {
151+
throw e;
152+
}
153+
// Map hrana errors to LibsqlError first, then wrap in LibsqlBatchError
154+
const mappedError = mapHranaError(e);
155+
if (mappedError instanceof LibsqlError) {
156+
throw new LibsqlBatchError(
157+
mappedError.message,
158+
i,
159+
mappedError.code,
160+
mappedError.rawCode,
161+
mappedError.cause instanceof Error
162+
? mappedError.cause
163+
: undefined,
164+
);
165+
}
166+
throw mappedError;
145167
}
146-
resultSets.push(resultSetFromHrana(rows));
147168
}
148169
return resultSets;
149170
} catch (e) {
@@ -295,15 +316,36 @@ export async function executeHranaBatch(
295316

296317
const resultSets = [];
297318
await beginPromise;
298-
for (const stmtPromise of stmtPromises) {
299-
const hranaRows = await stmtPromise;
300-
if (hranaRows === undefined) {
301-
throw new LibsqlError(
302-
"Statement in a batch was not executed, probably because the transaction has been rolled back",
303-
"TRANSACTION_CLOSED",
304-
);
319+
for (let i = 0; i < stmtPromises.length; i++) {
320+
try {
321+
const hranaRows = await stmtPromises[i];
322+
if (hranaRows === undefined) {
323+
throw new LibsqlBatchError(
324+
"Statement in a batch was not executed, probably because the transaction has been rolled back",
325+
i,
326+
"TRANSACTION_CLOSED",
327+
);
328+
}
329+
resultSets.push(resultSetFromHrana(hranaRows));
330+
} catch (e) {
331+
if (e instanceof LibsqlBatchError) {
332+
throw e;
333+
}
334+
// Map hrana errors to LibsqlError first, then wrap in LibsqlBatchError
335+
const mappedError = mapHranaError(e);
336+
if (mappedError instanceof LibsqlError) {
337+
throw new LibsqlBatchError(
338+
mappedError.message,
339+
i,
340+
mappedError.code,
341+
mappedError.rawCode,
342+
mappedError.cause instanceof Error
343+
? mappedError.cause
344+
: undefined,
345+
);
346+
}
347+
throw mappedError;
305348
}
306-
resultSets.push(resultSetFromHrana(hranaRows));
307349
}
308350
await commitPromise;
309351

0 commit comments

Comments
 (0)