From 1f9467233e0d2c32e81ef8d391fb406d661da6ba Mon Sep 17 00:00:00 2001 From: benhoweblog-dot <285478817+benhoweblog-dot@users.noreply.github.com> Date: Sun, 17 May 2026 17:17:47 -0400 Subject: [PATCH] Expose sqlite3 interrupt --- README.md | 1 + cpp/DBHostObject.cpp | 18 +++++++++++++ docs/docs/api.md | 20 +++++++++++++++ example/src/tests/queries.ts | 50 ++++++++++++++++++++++++++++++++++++ src/functions.ts | 1 + src/functions.web.ts | 4 +++ src/types.ts | 9 +++++++ 7 files changed, 103 insertions(+) diff --git a/README.md b/README.md index 0d5e2f58..0bd6fac8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Some of the big supported features: - Custom tokenizers - Load runtime extensions - JSONB support +- Native query interruption via `db.interrupt()` It also contains a simple [Key-Value store](https://op-engineering.github.io/op-sqlite/docs/key_value_storage) you can use without adding one more dependency to your app. diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index a31c48c5..59664b96 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -277,6 +277,24 @@ void DBHostObject::create_jsi_functions(jsi::Runtime &rt) { return {}; }); + function_map["interrupt"] = HFN(this) { + if (invalidated) { + throw std::runtime_error("[op-sqlite][interrupt] database is closed"); + } + +#ifdef OP_SQLITE_USE_LIBSQL + throw std::runtime_error("[op-sqlite][interrupt] sqlite3_interrupt is not " + "supported with libsql"); +#else + if (db == nullptr) { + throw std::runtime_error("[op-sqlite][interrupt] database is null"); + } + + sqlite3_interrupt(db); + return {}; +#endif + }); + function_map["delete"] = HFN(this) { invalidated = true; diff --git a/docs/docs/api.md b/docs/docs/api.md index 053568d1..066ddfe6 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -93,6 +93,26 @@ On web, `execute()` runs the full SQL string passed to it. On native, `execute()` currently runs only the first prepared statement. If you need identical behavior across platforms, avoid multi-statement SQL strings. +## Interrupting a Query + +On native, `interrupt()` aborts any pending database operation on this connection. It is safe to call from a thread different from the one running the operation. The interrupted query returns `SQLITE_INTERRUPT`; any in-flight transaction is rolled back. This calls SQLite's native [`sqlite3_interrupt()`](https://sqlite.org/c3ref/interrupt.html). + +```tsx +const query = db.execute(longRunningQuery); + +setTimeout(() => { + db.interrupt(); +}, 100); + +try { + await query; +} catch (error) { + // SQLITE_INTERRUPT +} +``` + +On web, `interrupt()` is not supported. + ### Execute with Host Objects It’s possible to return HostObjects when using a query. The benefit is that HostObjects are only created in C++ and only when you try to access a value inside of them a C++ value → JS value conversion happens. This means creation is fast, property access is slow. The use case is clear if you are returning **massive** amount of objects but only displaying/accessing a few of them at the time. diff --git a/example/src/tests/queries.ts b/example/src/tests/queries.ts index 9f12a73b..2b899174 100644 --- a/example/src/tests/queries.ts +++ b/example/src/tests/queries.ts @@ -107,6 +107,56 @@ describe("Queries tests", () => { } }); + it("interrupt is safe to call with no in-flight query", () => { + if (isLibsql() || isTurso()) { + return; + } + + let threw = false; + try { + db.interrupt(); + } catch (_e) { + threw = true; + } + + expect(threw).toEqual(false); + }); + + it("interrupt aborts an in-flight query and rolls back the transaction", async () => { + if (isLibsql() || isTurso()) { + return; + } + + await db.execute("DROP TABLE IF EXISTS InterruptTest;"); + await db.execute("CREATE TABLE InterruptTest (n INTEGER);"); + + const longQuery = ` + WITH RECURSIVE seq(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 100000000 + ) + INSERT INTO InterruptTest SELECT n FROM seq; + `; + + const queryPromise = db.execute(longQuery); + + await new Promise((resolve) => setTimeout(resolve, 50)); + db.interrupt(); + + let interrupted = false; + try { + await queryPromise; + } catch (e: any) { + interrupted = /interrupt|interrupted|abort|code 9|SQLITE_INTERRUPT/i.test( + String(e?.message ?? e), + ); + } + + expect(interrupted).toEqual(true); + + const count = await db.execute("SELECT COUNT(*) AS n FROM InterruptTest;"); + expect(count.rows[0]!.n).toEqual(0); + }); + it("executeSync", () => { const res = db.executeSync("SELECT 1"); expect(res.rowsAffected).toEqual(0); diff --git a/src/functions.ts b/src/functions.ts index b1d388c5..ca88b238 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -85,6 +85,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { setReservedBytes: db.setReservedBytes, getReservedBytes: db.getReservedBytes, close: db.close, + interrupt: db.interrupt, closeAsync: async () => { db.close(); }, diff --git a/src/functions.web.ts b/src/functions.web.ts index 0f27627e..2bd71608 100644 --- a/src/functions.web.ts +++ b/src/functions.web.ts @@ -188,6 +188,7 @@ function enhanceWebDb( closeAsync: async () => { await db.closeAsync?.(); }, + interrupt: unsupported("interrupt"), delete: unsupported("delete"), attach: unsupported("attach"), detach: unsupported("detach"), @@ -352,6 +353,9 @@ async function createWebDb(params: { dbId, }); }, + interrupt: () => { + throwSyncApiError("interrupt"); + }, delete: () => { throwSyncApiError("delete"); }, diff --git a/src/types.ts b/src/types.ts index 8610bada..874b43ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,7 @@ export type PreparedStatement = { export type _InternalDB = { close: () => void; closeAsync?: () => Promise; + interrupt: () => void; delete: (location?: string) => void; attach: (params: { secondaryDbFileName: string; @@ -153,6 +154,14 @@ export type _InternalDB = { export type DB = { close: () => void; closeAsync: () => Promise; + /** + * Aborts any pending database operation on this connection. + * + * Calls SQLite's native sqlite3_interrupt(). Safe to call from a thread + * different from the one running the operation. An interrupted operation + * returns SQLITE_INTERRUPT and any in-flight transaction is rolled back. + */ + interrupt: () => void; delete: (location?: string) => void; attach: (params: { secondaryDbFileName: string;