diff --git a/cpp/libsql/OPLibsqlBridge.cpp b/cpp/libsql/OPLibsqlBridge.cpp index 9a5b374c..422b0938 100644 --- a/cpp/libsql/OPLibsqlBridge.cpp +++ b/cpp/libsql/OPLibsqlBridge.cpp @@ -750,27 +750,23 @@ opsqlite_libsql_execute_batch(DB const &db, throw std::runtime_error("No SQL commands provided"); } - try { - int affectedRows = 0; - // opsqlite_libsql_execute(db, "BEGIN EXCLUSIVE TRANSACTION", nullptr); - for (int i = 0; i < commandCount; i++) { - auto command = commands->at(i); - // We do not provide a datastructure to receive query data because - // we don't need/want to handle this results in a batch execution - auto result = opsqlite_libsql_execute(db, command.sql, &command.params); - affectedRows += result.affectedRows; - } - // opsqlite_libsql_execute(db, "COMMIT", nullptr); - return BatchResult{ - .affectedRows = affectedRows, - .commands = static_cast(commandCount), - }; - } catch (std::exception &exc) { - // opsqlite_libsql_execute(db, "ROLLBACK", nullptr); - return BatchResult{ - .message = exc.what(), - }; - } + int affectedRows = 0; + // Transaction control (BEGIN/COMMIT/ROLLBACK) is left to the JS side, so + // any exception here must propagate to reject the JS promise instead of + // being swallowed - otherwise the wrapping COMMIT would persist a partial + // batch instead of the ROLLBACK the caller expects. + for (int i = 0; i < commandCount; i++) { + auto command = commands->at(i); + // We do not provide a datastructure to receive query data because + // we don't need/want to handle this results in a batch execution + auto result = opsqlite_libsql_execute(db, command.sql, &command.params); + affectedRows += result.affectedRows; + } + + return BatchResult{ + .affectedRows = affectedRows, + .commands = static_cast(commandCount), + }; } } // namespace opsqlite diff --git a/docs/docs/api.md b/docs/docs/api.md index e4a680d2..57c27ca5 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -291,6 +291,14 @@ const res = await db.executeBatch(commands); console.log(`Batch affected ${result.rowsAffected} rows`); ``` +`executeBatch` runs the `BEGIN`/`COMMIT`/`ROLLBACK` statements that wrap the batch asynchronously, off the JS thread. For very large batches the `COMMIT` is where SQLite actually writes the WAL frames/fsyncs, so this keeps the JS thread free while that happens. + +If you need those transaction boundaries to run synchronously on the JS thread instead, use `executeBatchSync`, which has the same signature and behavior otherwise: + +```tsx +const res = await db.executeBatchSync(commands); +``` + In some scenarios, dynamic applications may need to get some metadata information about the returned result set. ## Blob support diff --git a/example/src/tests/queries.ts b/example/src/tests/queries.ts index 32798e89..b21a1515 100644 --- a/example/src/tests/queries.ts +++ b/example/src/tests/queries.ts @@ -483,6 +483,96 @@ describe("Queries tests", () => { ]); }); + it("executeBatchSync", async () => { + const id1 = chance.integer(); + const name1 = chance.name(); + const age1 = chance.integer(); + const networth1 = chance.floating(); + + const id2 = chance.integer(); + const name2 = chance.name(); + const age2 = chance.integer(); + const networth2 = chance.floating(); + + const commands: SQLBatchTuple[] = [ + ['SELECT * FROM "User"', []], + ['SELECT * FROM "User"'], + [ + 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', + [id1, name1, age1, networth1], + ], + [ + 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', + [[id2, name2, age2, networth2]], + ], + ]; + + await db.executeBatchSync(commands); + + const res = await db.execute("SELECT * FROM User"); + + expect(res.rows).toDeepEqual([ + { id: id1, name: name1, age: age1, networth: networth1, nickname: null }, + { + id: id2, + name: name2, + age: age2, + networth: networth2, + nickname: null, + }, + ]); + }); + + it("executeBatch rolls back on error", async () => { + const id1 = chance.integer(); + const name1 = chance.name(); + const age1 = chance.integer(); + const networth1 = chance.floating(); + + const commands: SQLBatchTuple[] = [ + [ + 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', + [id1, name1, age1, networth1], + ], + ["INSERT INTO [tableThatDoesNotExist] (id) VALUES(1)"], + ]; + + try { + await db.executeBatch(commands); + throw new Error("Should not resolve"); + } catch (e) { + expect(((e as Error)?.message?.length ?? 0) > 0).toBe(true); + } + + const res = await db.execute("SELECT * FROM User"); + expect(res.rows).toDeepEqual([]); + }); + + it("executeBatchSync rolls back on error", async () => { + const id1 = chance.integer(); + const name1 = chance.name(); + const age1 = chance.integer(); + const networth1 = chance.floating(); + + const commands: SQLBatchTuple[] = [ + [ + 'INSERT INTO "User" (id, name, age, networth) VALUES(?, ?, ?, ?)', + [id1, name1, age1, networth1], + ], + ["INSERT INTO [tableThatDoesNotExist] (id) VALUES(1)"], + ]; + + try { + await db.executeBatchSync(commands); + throw new Error("Should not resolve"); + } catch (e) { + expect(((e as Error)?.message?.length ?? 0) > 0).toBe(true); + } + + const res = await db.execute("SELECT * FROM User"); + expect(res.rows).toDeepEqual([]); + }); + it("Batch execute with BLOB", async () => { const db = open({ name: "queries.sqlite", diff --git a/src/functions.ts b/src/functions.ts index f712b94a..ab6dd440 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -91,6 +91,39 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { }, flushPendingReactiveQueries: db.flushPendingReactiveQueries, executeBatch: async (commands: SQLBatchTuple[]): Promise => { + async function run() { + try { + await enhancedDb.execute("BEGIN TRANSACTION;"); + + const res = await db.executeBatch(commands as any[]); + + await enhancedDb.execute("COMMIT;"); + + await db.flushPendingReactiveQueries(); + + return res; + } catch (executionError) { + await enhancedDb.execute("ROLLBACK;"); + + throw executionError; + } finally { + lock.inProgress = false; + startNextTransaction(); + } + } + + return await new Promise((resolve, reject) => { + const tx: _PendingTransaction = { + start: () => { + run().then(resolve).catch(reject); + }, + }; + + lock.queue.push(tx); + startNextTransaction(); + }); + }, + executeBatchSync: async (commands: SQLBatchTuple[]): Promise => { async function run() { try { enhancedDb.executeSync("BEGIN TRANSACTION;"); diff --git a/src/functions.web.ts b/src/functions.web.ts index f0f4867d..d464840b 100644 --- a/src/functions.web.ts +++ b/src/functions.web.ts @@ -300,6 +300,10 @@ function enhanceWebDb(db: _InternalDB, options: { name?: string; location?: stri rowsAffected: 0, }; }, + // Web has no synchronous native APIs, so there is no distinct blocking behavior to offer. + executeBatchSync: async (commands: SQLBatchTuple[]): Promise => { + return enhancedDb.executeBatch(commands); + }, loadFile: async (_location: string): Promise => { throw new Error("[op-sqlite] loadFile() is not supported on web."); }, diff --git a/src/types.ts b/src/types.ts index 74c345d5..ea5484c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -248,10 +248,24 @@ export type DB = { * Executes all the queries in the params inside a single transaction * * It's faster than executing single queries as data is sent to the native side only once + * + * The BEGIN/COMMIT/ROLLBACK statements that wrap the batch are executed asynchronously, + * off the JS thread. Use this over `executeBatchSync` unless you specifically need the + * transaction boundaries to block the JS thread. * @param commands * @returns Promise */ executeBatch: (commands: SQLBatchTuple[]) => Promise; + /** + * Same as `executeBatch` but the BEGIN/COMMIT/ROLLBACK statements that wrap the batch + * are executed synchronously on the JS thread. For large batches this can block the JS + * thread for a noticeable amount of time (the COMMIT is where SQLite writes the WAL + * frames/fsyncs), so prefer `executeBatch` unless you have a specific reason to need + * synchronous transaction boundaries. + * @param commands + * @returns Promise + */ + executeBatchSync: (commands: SQLBatchTuple[]) => Promise; /** * Loads a SQLite Dump from disk. It will be the fastest way to execute a large set of queries as no JS is involved */