From 00156968843c2183f5ecea18239a438a6f951850 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 23 Jun 2026 17:40:11 +0200 Subject: [PATCH 1/3] Support read-only databases --- cpp/DBHostObject.cpp | 5 +++-- cpp/DBHostObject.h | 2 +- cpp/OPSqlite.cpp | 7 ++++++- cpp/bridge.cpp | 11 ++++++++--- cpp/bridge.h | 3 ++- example/src/tests/dbsetup.ts | 15 +++++++++++++++ src/functions.ts | 13 +++---------- src/functions.web.ts | 16 +++++----------- src/types.ts | 21 +++++++++++++++++++++ 9 files changed, 64 insertions(+), 29 deletions(-) diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index cec53059..1c490f26 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -217,6 +217,7 @@ DBHostObject::DBHostObject(jsi::Runtime &rt, std::string &db_name, DBHostObject::DBHostObject(jsi::Runtime &rt, std::string &base_path, std::string &db_name, std::string &path, + bool readOnly, std::string &crsqlite_path, std::string &sqlite_vec_path, std::string &encryption_key) @@ -224,12 +225,12 @@ DBHostObject::DBHostObject(jsi::Runtime &rt, std::string &base_path, thread_pool = std::make_shared(); #ifdef OP_SQLITE_USE_SQLCIPHER - db = opsqlite_open(db_name, path, crsqlite_path, sqlite_vec_path, + db = opsqlite_open(db_name, path, readOnly, crsqlite_path, sqlite_vec_path, encryption_key); #elif OP_SQLITE_USE_LIBSQL db = opsqlite_libsql_open(db_name, path, crsqlite_path); #else - db = opsqlite_open(db_name, path, crsqlite_path, sqlite_vec_path); + db = opsqlite_open(db_name, path, readOnly, crsqlite_path, sqlite_vec_path); #endif create_jsi_functions(rt); }; diff --git a/cpp/DBHostObject.h b/cpp/DBHostObject.h index 7029a152..45ed8d21 100644 --- a/cpp/DBHostObject.h +++ b/cpp/DBHostObject.h @@ -45,7 +45,7 @@ class JSI_EXPORT DBHostObject : public jsi::HostObject { public: // Normal constructor shared between all backends DBHostObject(jsi::Runtime &rt, std::string &base_path, std::string &db_name, - std::string &path, std::string &crsqlite_path, + std::string &path, bool readOnly, std::string &crsqlite_path, std::string &sqlite_vec_path, std::string &encryption_key); #ifdef OP_SQLITE_USE_LIBSQL diff --git a/cpp/OPSqlite.cpp b/cpp/OPSqlite.cpp index 9264bb51..a5647cb8 100644 --- a/cpp/OPSqlite.cpp +++ b/cpp/OPSqlite.cpp @@ -60,6 +60,7 @@ void install(jsi::Runtime &rt, std::string path = std::string(_base_path); std::string location; std::string encryption_key; + bool readOnly = false; if (options.hasProperty(rt, "location")) { location = options.getProperty(rt, "location").asString(rt).utf8(rt); @@ -70,6 +71,10 @@ void install(jsi::Runtime &rt, options.getProperty(rt, "encryptionKey").asString(rt).utf8(rt); } + if (options.hasProperty(rt, "readOnly")) { + readOnly = options.getProperty(rt, "readOnly").asBool(); + } + if (!location.empty()) { if (location == ":memory:") { path = ":memory:"; @@ -81,7 +86,7 @@ void install(jsi::Runtime &rt, } std::shared_ptr db = std::make_shared( - rt, path, name, path, _crsqlite_path, _sqlite_vec_path, encryption_key); + rt, path, name, path, readOnly, _crsqlite_path, _sqlite_vec_path, encryption_key); dbs.emplace_back(db); return jsi::Object::createFromHostObject(rt, db); }); diff --git a/cpp/bridge.cpp b/cpp/bridge.cpp index 8033f9be..d05b213b 100644 --- a/cpp/bridge.cpp +++ b/cpp/bridge.cpp @@ -80,11 +80,12 @@ std::string opsqlite_get_db_path(std::string const &db_name, #ifdef OP_SQLITE_USE_SQLCIPHER sqlite3 *opsqlite_open(std::string const &name, std::string const &path, - std::string const &crsqlite_path, + bool readOnly, std::string const &crsqlite_path, std::string const &sqlite_vec_path, std::string const &encryption_key) { #else sqlite3 *opsqlite_open(std::string const &name, std::string const &path, + bool readOnly, [[maybe_unused]] std::string const &crsqlite_path, [[maybe_unused]] std::string const &sqlite_vec_path) { #endif @@ -92,8 +93,12 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, char *errMsg; sqlite3 *db; - int flags = - SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX; + int flags = SQLITE_OPEN_FULLMUTEX; + if (readOnly) { + flags |= SQLITE_OPEN_READONLY; + } else { + flags |= SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + } int status = sqlite3_open_v2(final_path.c_str(), &db, flags, nullptr); diff --git a/cpp/bridge.h b/cpp/bridge.h index 9c6d53f1..f82720bd 100644 --- a/cpp/bridge.h +++ b/cpp/bridge.h @@ -27,11 +27,12 @@ std::string opsqlite_get_db_path(std::string const &db_name, #ifdef OP_SQLITE_USE_SQLCIPHER sqlite3 *opsqlite_open(std::string const &dbName, std::string const &path, - std::string const &crsqlite_path, + bool readOnly, std::string const &crsqlite_path, std::string const &sqlite_vec_path, std::string const &encryption_key); #else sqlite3 *opsqlite_open(std::string const &name, std::string const &path, + bool readOnly, [[maybe_unused]] std::string const &crsqlite_path, std::string const &sqlite_vec_path); #endif diff --git a/example/src/tests/dbsetup.ts b/example/src/tests/dbsetup.ts index 976849ba..3a701157 100644 --- a/example/src/tests/dbsetup.ts +++ b/example/src/tests/dbsetup.ts @@ -231,6 +231,21 @@ describe("DB setup tests", () => { expect(!!e).toBe(true); } }); + + it("Can open read-only databases", async () => { + const db = open({ + name: 'ignored', + location: ":memory:", + readOnly: true, + }); + expect(!!db).toBe(true); + + try { + await db.execute('CREATE TABLE foo (bar TEXT);'); + } catch (e: any) { + expect(e.message).toContain('attempt to write a readonly database'); + } + }); }); it("Can attach/dettach database", () => { diff --git a/src/functions.ts b/src/functions.ts index ca88b238..aa815fdb 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -5,6 +5,7 @@ import type { BatchQueryResult, DB, DBParams, + OpenOptions, OPSQLiteProxy, QueryResult, Scalar, @@ -363,11 +364,7 @@ export const openRemote = (params: { url: string; authToken: string }): DB => { * Open a connection to a local sqlite or sqlcipher database * If you want libsql remote or sync connections, use openSync or openRemote */ -export const open = (params: { - name: string; - location?: string; - encryptionKey?: string; -}): DB => { +export const open = (params: OpenOptions): DB => { if (params.location?.startsWith("file://")) { console.warn( "[op-sqlite] You are passing a path with 'file://' prefix, it's automatically removed", @@ -385,11 +382,7 @@ export const open = (params: { * Async wrapper around open(). * Useful for cross-platform code that also targets web where openAsync() is required. */ -export const openAsync = async (params: { - name: string; - location?: string; - encryptionKey?: string; -}): Promise => { +export const openAsync = async (params: OpenOptions): Promise => { return open(params); }; diff --git a/src/functions.web.ts b/src/functions.web.ts index 90a648ef..369d8dec 100644 --- a/src/functions.web.ts +++ b/src/functions.web.ts @@ -5,6 +5,7 @@ import type { DB, DBParams, FileLoadResult, + OpenOptions, OPSQLiteProxy, PreparedStatement, QueryResult, @@ -313,11 +314,7 @@ function enhanceWebDb(db: _InternalDB, options: { name?: string; location?: stri return enhancedDb; } -async function createWebDb(params: { - name: string; - location?: string; - encryptionKey?: string; -}): Promise<_InternalDB> { +async function createWebDb(params: OpenOptions): Promise<_InternalDB> { if (params.encryptionKey) { throw new Error("[op-sqlite] SQLCipher is not supported on web."); } @@ -328,6 +325,7 @@ async function createWebDb(params: { const filename = `file:${params.name}?vfs=opfs`; const opened = await promiser("open", { filename, + flags: params.readOnly ? 'r': 'c' }); const dbId = opened?.dbId || opened?.result?.dbId; @@ -444,16 +442,12 @@ async function createWebDb(params: { * Open a connection to a local sqlite database on web. * Web is async-only: use openAsync() and async methods like execute(). */ -export const openAsync = async (params: { - name: string; - location?: string; - encryptionKey?: string; -}): Promise => { +export const openAsync = async (params: OpenOptions): Promise => { const db = await createWebDb(params); return enhanceWebDb(db, params); }; -export const open = (_params: { name: string; location?: string; encryptionKey?: string }): DB => { +export const open = (_params:OpenOptions): DB => { throwSyncApiError("open"); }; diff --git a/src/types.ts b/src/types.ts index 0e8abccb..7a9d36f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,26 @@ export type Scalar = string | number | boolean | null | ArrayBuffer | ArrayBufferView; +export interface OpenOptions { + /** + * The file name of the database to open. + */ + name: string; + /** + * A directory prefix for the database file. + * + * When set to `:memory:`, the name is ignored and an in-memory database is opened instead. + */ + location?: string; + encryptionKey?: string; + /** + * When set to true, the database is opened in read-only mode and any statement attempting to write to the database + * will fail. + * + * This option is only supported for plain SQLite3 and SQLCipher. + */ + readOnly?: boolean; +} + /** * Object returned by SQL Query executions { * insertId: Represent the auto-generated row id if applicable From 75f4e1efd636f392928446585398483c9941cc24 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 26 Jun 2026 09:47:55 +0200 Subject: [PATCH 2/3] Throw for libsql --- cpp/DBHostObject.cpp | 3 +++ example/src/tests/dbsetup.ts | 26 +++++++++++++++++++++----- src/types.ts | 3 ++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index 1c490f26..0b5031b1 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -228,6 +228,9 @@ DBHostObject::DBHostObject(jsi::Runtime &rt, std::string &base_path, db = opsqlite_open(db_name, path, readOnly, crsqlite_path, sqlite_vec_path, encryption_key); #elif OP_SQLITE_USE_LIBSQL + if (readOnly) { + throw std::runtime_error("libsql does not support read-only databases."); + } db = opsqlite_libsql_open(db_name, path, crsqlite_path); #else db = opsqlite_open(db_name, path, readOnly, crsqlite_path, sqlite_vec_path); diff --git a/example/src/tests/dbsetup.ts b/example/src/tests/dbsetup.ts index 3a701157..3eb0b068 100644 --- a/example/src/tests/dbsetup.ts +++ b/example/src/tests/dbsetup.ts @@ -233,11 +233,27 @@ describe("DB setup tests", () => { }); it("Can open read-only databases", async () => { - const db = open({ - name: 'ignored', - location: ":memory:", - readOnly: true, - }); + function openReadOnly() { + return open({ + name: 'ignored', + location: ":memory:", + readOnly: true, + }); + } + + if (isLibsql()) { + // libsql C bindings don't expose a way to open read-only databases, so the option is not supported. + try { + openReadOnly(); + throw new Error('should have failed'); + } catch (e: any) { + expect(e.message).toContain('libsql does not support read-only databases'); + } + + return; + } + + const db = openReadOnly(); expect(!!db).toBe(true); try { diff --git a/src/types.ts b/src/types.ts index 7a9d36f7..79d53db6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,8 @@ export interface OpenOptions { * When set to true, the database is opened in read-only mode and any statement attempting to write to the database * will fail. * - * This option is only supported for plain SQLite3 and SQLCipher. + * This option is only supported for plain SQLite3 and SQLCipher. When enabling this option with libsql enabled, + * opening databases will throw. */ readOnly?: boolean; } From f93eda96312f4e37c9785962483aeedf11ecf808 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 26 Jun 2026 16:14:02 +0200 Subject: [PATCH 3/3] Also throw for turso --- cpp/turso_bridge.cpp | 4 ++++ example/src/tests/dbsetup.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cpp/turso_bridge.cpp b/cpp/turso_bridge.cpp index dbd3f15b..9f4aa245 100644 --- a/cpp/turso_bridge.cpp +++ b/cpp/turso_bridge.cpp @@ -370,8 +370,12 @@ std::string opsqlite_get_db_path(std::string const &db_name, } sqlite3 *opsqlite_open(std::string const &name, std::string const &path, + bool readOnly, [[maybe_unused]] std::string const &crsqlite_path, [[maybe_unused]] std::string const &sqlite_vec_path) { + if (readOnly) { + throw std::runtime_error("turso does not support read-only databases."); + } auto *handle = new TursoDbHandle(); handle->path = opsqlite_get_db_path(name, path); setup_turso_temp_dir(handle->path); diff --git a/example/src/tests/dbsetup.ts b/example/src/tests/dbsetup.ts index 3eb0b068..aaec3dc7 100644 --- a/example/src/tests/dbsetup.ts +++ b/example/src/tests/dbsetup.ts @@ -241,13 +241,13 @@ describe("DB setup tests", () => { }); } - if (isLibsql()) { - // libsql C bindings don't expose a way to open read-only databases, so the option is not supported. + if (isLibsql() || isTurso()) { + // libsql/turso C bindings don't expose a way to open read-only databases, so the option is not supported. try { openReadOnly(); throw new Error('should have failed'); } catch (e: any) { - expect(e.message).toContain('libsql does not support read-only databases'); + expect(e.message).toContain('does not support read-only databases'); } return;