diff --git a/android/build.gradle b/android/build.gradle index 75d10447..831ca5d2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -89,6 +89,10 @@ if(useLibsql && useTurso) { throw new GradleException("[OP-SQLITE] Error: libsql and turso backends are mutually exclusive.") } +if(useLibsql && useCRSQLite) { + throw new GradleException("[OP-SQLITE] Error: You cannot use crsqlite with libsql.") +} + if(useSQLCipher) { println "[OP-SQLITE] using sqlcipher." } else if(useTurso) { diff --git a/cpp/DBHostObject.cpp b/cpp/DBHostObject.cpp index 3c11d634..b3fbcc37 100644 --- a/cpp/DBHostObject.cpp +++ b/cpp/DBHostObject.cpp @@ -217,23 +217,20 @@ 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, + bool readOnly, bool failOnCreate, std::string &encryption_key) : base_path(base_path), db_name(db_name), delete_db_name(db_name) { thread_pool = std::make_shared(); #ifdef OP_SQLITE_USE_SQLCIPHER - db = opsqlite_open(db_name, path, readOnly, crsqlite_path, sqlite_vec_path, - encryption_key); + db = opsqlite_open(db_name, path, readOnly, failOnCreate, 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); + db = opsqlite_libsql_open(db_name, path, failOnCreate); #else - db = opsqlite_open(db_name, path, readOnly, crsqlite_path, sqlite_vec_path); + db = opsqlite_open(db_name, path, readOnly, failOnCreate); #endif create_jsi_functions(rt); }; diff --git a/cpp/DBHostObject.hpp b/cpp/DBHostObject.hpp index 9d7d6792..19b1180d 100644 --- a/cpp/DBHostObject.hpp +++ b/cpp/DBHostObject.hpp @@ -45,8 +45,8 @@ 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, bool readOnly, std::string &crsqlite_path, - std::string &sqlite_vec_path, std::string &encryption_key); + std::string &path, bool readOnly, bool failOnCreate, + std::string &encryption_key); #ifdef OP_SQLITE_USE_LIBSQL // Constructor for remoteOpen, purely for remote databases diff --git a/cpp/OPSqlite.cpp b/cpp/OPSqlite.cpp index 4cc78326..31401f2a 100644 --- a/cpp/OPSqlite.cpp +++ b/cpp/OPSqlite.cpp @@ -61,6 +61,7 @@ void install(jsi::Runtime &rt, std::string location; std::string encryption_key; bool readOnly = false; + bool failOnCreate = false; if (options.hasProperty(rt, "location")) { location = options.getProperty(rt, "location").asString(rt).utf8(rt); @@ -75,6 +76,10 @@ void install(jsi::Runtime &rt, readOnly = options.getProperty(rt, "readOnly").asBool(); } + if (options.hasProperty(rt, "failOnCreate")) { + failOnCreate = options.getProperty(rt, "failOnCreate").asBool(); + } + if (!location.empty()) { if (location == ":memory:") { path = ":memory:"; @@ -86,7 +91,7 @@ void install(jsi::Runtime &rt, } std::shared_ptr db = std::make_shared( - rt, path, name, path, readOnly, _crsqlite_path, _sqlite_vec_path, encryption_key); + rt, path, name, path, readOnly, failOnCreate, encryption_key); dbs.emplace_back(db); return jsi::Object::createFromHostObject(rt, db); }); @@ -228,7 +233,7 @@ void expoUpdatesWorkaround(const char *base_path) { std::string path = std::string(base_path); // Open a DB before anything else so that expo-updates does not mess up the // configuration - opsqlite_libsql_open("__dummy", path, ""); + opsqlite_libsql_open("__dummy", path, false); #endif } diff --git a/cpp/bridge.cpp b/cpp/bridge.cpp index 5731e546..0f6debc6 100644 --- a/cpp/bridge.cpp +++ b/cpp/bridge.cpp @@ -81,14 +81,11 @@ 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, - bool readOnly, std::string const &crsqlite_path, - std::string const &sqlite_vec_path, + bool readOnly, bool failOnCreate, 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) { + bool readOnly, bool failOnCreate) { #endif std::string final_path = opsqlite_get_db_path(name, path); char *errMsg; @@ -98,7 +95,10 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, if (readOnly) { flags |= SQLITE_OPEN_READONLY; } else { - flags |= SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + flags |= SQLITE_OPEN_READWRITE; + if (!failOnCreate) { + flags |= SQLITE_OPEN_CREATE; + } } int status = sqlite3_open_v2(final_path.c_str(), &db, flags, nullptr); @@ -135,7 +135,7 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, #ifdef OP_SQLITE_USE_CRSQLITE const char *crsqliteEntryPoint = "sqlite3_crsqlite_init"; - sqlite3_load_extension(db, crsqlite_path.c_str(), crsqliteEntryPoint, + sqlite3_load_extension(db, _crsqlite_path.c_str(), crsqliteEntryPoint, &errMsg); if (errMsg != nullptr) { @@ -146,7 +146,7 @@ sqlite3 *opsqlite_open(std::string const &name, std::string const &path, #ifdef OP_SQLITE_USE_SQLITE_VEC const char *vec_entry_point = "sqlite3_vec_init"; - sqlite3_load_extension(db, sqlite_vec_path.c_str(), vec_entry_point, &errMsg); + sqlite3_load_extension(db, _sqlite_vec_path.c_str(), vec_entry_point, &errMsg); if (errMsg != nullptr) { throw std::runtime_error(errMsg); diff --git a/cpp/bridge.hpp b/cpp/bridge.hpp index d153637b..ad226f84 100644 --- a/cpp/bridge.hpp +++ b/cpp/bridge.hpp @@ -22,19 +22,22 @@ typedef std::function CommitCallback; typedef std::function RollbackCallback; +// Paths to the optional loadable extensions, set once in install() and +// shared by every subsequent open() call instead of being threaded through +// as parameters. +extern std::string _crsqlite_path; +extern std::string _sqlite_vec_path; + std::string opsqlite_get_db_path(std::string const &db_name, std::string const &location); #ifdef OP_SQLITE_USE_SQLCIPHER sqlite3 *opsqlite_open(std::string const &dbName, std::string const &path, - bool readOnly, std::string const &crsqlite_path, - std::string const &sqlite_vec_path, + bool readOnly, bool failOnCreate, 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); + bool readOnly, bool failOnCreate); #endif #ifdef OP_SQLITE_USE_TURSO diff --git a/cpp/libsql/bridge.cpp b/cpp/libsql/bridge.cpp index c545244e..e91a0565 100644 --- a/cpp/libsql/bridge.cpp +++ b/cpp/libsql/bridge.cpp @@ -80,9 +80,16 @@ DB opsqlite_libsql_open_sync(std::string const &name, } DB opsqlite_libsql_open(std::string const &name, std::string const &last_path, - std::string const &crsqlitePath) { + bool failOnCreate) { std::string path = opsqlite_get_db_path(name, last_path); + // libsql_open_file always creates the database file if it is missing, + // there is no "open existing only" flag, so failOnCreate is enforced up + // front by checking for the file's existence. + if (failOnCreate && path != ":memory:" && !std::filesystem::exists(path)) { + throw std::runtime_error("unable to open database file: " + path); + } + int status; libsql_database_t db; libsql_connection_t c; @@ -100,20 +107,6 @@ DB opsqlite_libsql_open(std::string const &name, std::string const &last_path, throw std::runtime_error(err); } -#ifdef OP_SQLITE_USE_CRSQLITE - const char *errMsg; - const char *crsqliteEntryPoint = "sqlite3_crsqlite_init"; - - status = libsql_load_extension(c, crsqlitePath.c_str(), crsqliteEntryPoint, - &errMsg); - - if (status != 0) { - throw std::runtime_error(errMsg); - } else { - LOGI("Loaded CRSQlite successfully"); - } -#endif - return {.db = db, .c = c}; } diff --git a/cpp/libsql/bridge.hpp b/cpp/libsql/bridge.hpp index d870459d..1b6cf2fa 100644 --- a/cpp/libsql/bridge.hpp +++ b/cpp/libsql/bridge.hpp @@ -33,7 +33,7 @@ std::string opsqlite_get_db_path(std::string const &name, std::string const &location); DB opsqlite_libsql_open(std::string const &name, std::string const &path, - std::string const &crsqlitePath); + bool failOnCreate); DB opsqlite_libsql_open_remote(std::string const &url, std::string const &auth_token); diff --git a/cpp/turso_bridge.cpp b/cpp/turso_bridge.cpp index 96e086b3..120ecbd3 100644 --- a/cpp/turso_bridge.cpp +++ b/cpp/turso_bridge.cpp @@ -370,14 +370,23 @@ 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) { + bool readOnly, bool failOnCreate) { 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); + + // Turso's API always creates the database file on open, there is no + // "open existing only" flag, so failOnCreate is enforced up front by + // checking for the file's existence. + if (failOnCreate && handle->path != ":memory:" && + !std::filesystem::exists(handle->path)) { + std::string missing_path = handle->path; + delete handle; + throw std::runtime_error("unable to open database file: " + missing_path); + } + setup_turso_temp_dir(handle->path); turso_database_config_t db_config = { diff --git a/docs/docs/api.md b/docs/docs/api.md index f764f16f..6a0e4c39 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -45,6 +45,23 @@ export const db = open({ If you want to read more about securely storing your encryption key, [read this article](https://ospfranco.com/react-native-security-guide/). Again: **DO NOT OPEN MORE THAN ONE CONNECTION PER DATABASE**. Just export one single db connection for your entire application and reuse it everywhere. +### Open Existing Only (failOnCreate) + +By default, `open()` creates the database file if it doesn't already exist. Pass `failOnCreate: true` to require the file to already exist; if it doesn't, the call throws instead of creating it. This is supported across all backends (plain SQLite3, SQLCipher, libsql and Turso). + +```tsx +import { open } from '@op-engineering/op-sqlite'; + +try { + const db = open({ + name: 'myDb.sqlite', + failOnCreate: true, + }); +} catch (e) { + // The database file did not exist and was not created +} +``` + ### Remote and Sync Open (Libsql/Turso) For remote/sync scenarios, enable either the `libsql` or `turso` backend in your package configuration, then use `openRemote` or `openSync`. diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md new file mode 100644 index 00000000..a0e9e8bb --- /dev/null +++ b/docs/docs/changelog.md @@ -0,0 +1,10 @@ +--- +sidebar_position: 11 +--- + +# API Changes + +## 17.2.0 + +- Added `failOnCreate` option to `open()`. When set to `true`, the database file must already exist; if it doesn't, `open()` throws instead of creating it. Implemented natively across all backends (plain SQLite3, SQLCipher, libsql and Turso). See the [Open Existing Only (failOnCreate)](./api.md#open-existing-only-failoncreate) section for usage. +- Removed support for combining `crsqlite` with `libsql`. Enabling both in `package.json` now fails the build (iOS podspec and Android Gradle) with a clear error instead of silently loading the extension. If you relied on this combination, drop one of the two flags. diff --git a/example/src/tests/dbsetup.ts b/example/src/tests/dbsetup.ts index aaec3dc7..287a7a07 100644 --- a/example/src/tests/dbsetup.ts +++ b/example/src/tests/dbsetup.ts @@ -1,13 +1,13 @@ import { - ANDROID_DATABASE_PATH, - // ANDROID_EXTERNAL_FILES_PATH, - IOS_LIBRARY_PATH, - isIOSEmbeeded, - isLibsql, - isSQLCipher, - isTurso, - moveAssetsDatabase, - open, + ANDROID_DATABASE_PATH, + // ANDROID_EXTERNAL_FILES_PATH, + IOS_LIBRARY_PATH, + isIOSEmbedded, + isLibsql, + isSQLCipher, + isTurso, + moveAssetsDatabase, + open, } from "@op-engineering/op-sqlite"; import { describe, expect, it } from "@op-engineering/op-test"; import { Platform } from "react-native"; @@ -16,490 +16,513 @@ let expectedVersion = "3.51.3"; let flavor = "sqlite"; if (isLibsql()) { - expectedVersion = "3.45.1"; - flavor = "libsql"; + expectedVersion = "3.45.1"; + flavor = "libsql"; } else if (isTurso()) { - expectedVersion = "3.50.4"; - flavor = "turso"; + expectedVersion = "3.50.4"; + flavor = "turso"; } else if (isSQLCipher()) { - expectedVersion = "3.51.3"; - flavor = "sqlcipher"; + expectedVersion = "3.51.3"; + flavor = "sqlcipher"; } // const expectedSqliteVecVersion = 'v0.1.2-alpha.7'; describe("DB setup tests", () => { - // it('Should match the sqlite_vec version', async () => { - // let db = open({ - // name: 'versionTest.sqlite', - // }); - - // const res = db.execute('select vec_version();'); - - // expect(res.rows?._array[0]['vec_version()']).to.equal( - // expectedSqliteVecVersion, - // ); - - // db.close(); - // }); - - // Using the embedded version, you can never be sure which version is used - // It will change from OS version to version - if (!isIOSEmbeeded()) { - it(`Should match the sqlite flavor ${flavor} expected version ${expectedVersion}`, async () => { - const db = open({ - name: "versionTest.sqlite", - encryptionKey: "test", - }); - - const res = await db.execute("select sqlite_version();"); - - expect(res.rows[0]!["sqlite_version()"]).toBe(expectedVersion); - db.close(); - }); - } - - it("Create in memory DB", async () => { - const inMemoryDb = open({ - name: "inMemoryTest.sqlite", - location: ":memory:", - encryptionKey: "test", - }); - - await inMemoryDb.execute("DROP TABLE IF EXISTS User;"); - await inMemoryDb.execute( - "CREATE TABLE User ( id INT PRIMARY KEY, name TEXT NOT NULL, age INT, networth REAL) STRICT;", - ); - - inMemoryDb.close(); - }); - - // if (Platform.OS === "android") { - // it("Create db in external directory Android", async () => { - // const androidDb = open({ - // name: "AndroidSDCardDB.sqlite", - // location: ANDROID_EXTERNAL_FILES_PATH, - // encryptionKey: "test", - // }); - - // await androidDb.execute("DROP TABLE IF EXISTS User;"); - // await androidDb.execute( - // "CREATE TABLE User ( id INT PRIMARY KEY, name TEXT NOT NULL, age INT, networth REAL) STRICT;", - // ); - - // androidDb.close(); - // }); - - // it("Creates db in external nested directory on Android", async () => { - // const androidDb = open({ - // name: "AndroidSDCardDB.sqlite", - // location: `${ANDROID_EXTERNAL_FILES_PATH}/nested`, - // encryptionKey: "test", - // }); - - // await androidDb.execute("DROP TABLE IF EXISTS User;"); - // await androidDb.execute( - // "CREATE TABLE User ( id INT PRIMARY KEY, name TEXT NOT NULL, age INT, networth REAL) STRICT;", - // ); - - // androidDb.close(); - // }); - // } - - // Currently this only tests the function is there - it("Should load extension", async () => { - const db = open({ - name: "extensionDb", - encryptionKey: "test", - }); - - try { - db.loadExtension("path"); - } catch (e) { - // TODO load a sample extension - expect(!!e).toEqual(true); - } finally { - db.delete(); - } - }); - - it("Should delete db", async () => { - const db = open({ - name: "deleteTest", - encryptionKey: "test", - }); - - db.delete(); - }); - - it("Should delete db with absolute path", async () => { - const location = - Platform.OS === "ios" ? IOS_LIBRARY_PATH : ANDROID_DATABASE_PATH; - const db = open({ - name: "deleteTest", - encryptionKey: "test", - location, - }); - - expect(db.getDbPath().includes(location)).toEqual(true); - - db.delete(); - }); - - it("Should create db in custom folder", async () => { - const db = open({ - name: "customFolderTest.sqlite", - encryptionKey: "test", - location: "myFolder", - }); - - const path = db.getDbPath(); - expect(path.includes("myFolder")).toEqual(true); - db.delete(); - }); - - it("Should create nested folders", async () => { - const db = open({ - name: "nestedFolderTest.sqlite", - encryptionKey: "test", - location: "myFolder/nested", - }); - - const path = db.getDbPath(); - expect(path.includes("myFolder/nested")).toEqual(true); - db.delete(); - }); - - it("Moves assets database simple", async () => { - const copied = await moveAssetsDatabase({ filename: "sample.sqlite" }); - - expect(copied).toEqual(true); - }); - - it("Moves assets database with path", async () => { - const copied = await moveAssetsDatabase({ - filename: "sample2.sqlite", - path: "sqlite", - }); - - expect(copied).toEqual(true); - }); - - it("Moves assets database with path and overwrite", async () => { - const copied = await moveAssetsDatabase({ - filename: "sample2.sqlite", - path: "sqlite", - overwrite: true, - }); - - expect(copied).toEqual(true); - - const db = open({ - name: "sample2.sqlite", - encryptionKey: "test", - location: "sqlite", - }); - - const path = db.getDbPath(); - expect(path.includes("sqlite/sample2.sqlite")).toEqual(true); - db.delete(); - }); - - it("Creates new connections per query and closes them", async () => { - for (let i = 0; i < 100; i++) { - const db = open({ - name: "versionTest.sqlite", - encryptionKey: "test", - }); - - await db.execute("select 1;"); - - db.close(); - } - }); - - it("Closes connections correctly", async () => { - try { - const db1 = open({ - name: "closeTest.sqlite", - }); - expect(!!db1).toBe(true); - open({ - name: "closeTest.sqlite", - }); - } catch (e) { - expect(!!e).toBe(true); - } - }); - - it("Can open read-only databases", async () => { - function openReadOnly() { - return open({ - name: 'ignored', - location: ":memory:", - readOnly: true, - }); - } - - 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('does not support read-only databases'); - } - - return; - } - - const db = openReadOnly(); - 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('Should match the sqlite_vec version', async () => { + // let db = open({ + // name: 'versionTest.sqlite', + // }); + + // const res = db.execute('select vec_version();'); + + // expect(res.rows?._array[0]['vec_version()']).to.equal( + // expectedSqliteVecVersion, + // ); + + // db.close(); + // }); + + // Using the embedded version, you can never be sure which version is used + // It will change from OS version to version + if (!isIOSEmbedded()) { + it(`Should match the sqlite flavor ${flavor} expected version ${expectedVersion}`, async () => { + const db = open({ + name: "versionTest.sqlite", + encryptionKey: "test", + }); + + const res = await db.execute("select sqlite_version();"); + + expect(res.rows[0]!["sqlite_version()"]).toBe(expectedVersion); + db.close(); + }); + } + + it("Create in memory DB", async () => { + const inMemoryDb = open({ + name: "inMemoryTest.sqlite", + location: ":memory:", + encryptionKey: "test", + }); + + await inMemoryDb.execute("DROP TABLE IF EXISTS User;"); + await inMemoryDb.execute( + "CREATE TABLE User ( id INT PRIMARY KEY, name TEXT NOT NULL, age INT, networth REAL) STRICT;", + ); + + inMemoryDb.close(); + }); + + // if (Platform.OS === "android") { + // it("Create db in external directory Android", async () => { + // const androidDb = open({ + // name: "AndroidSDCardDB.sqlite", + // location: ANDROID_EXTERNAL_FILES_PATH, + // encryptionKey: "test", + // }); + + // await androidDb.execute("DROP TABLE IF EXISTS User;"); + // await androidDb.execute( + // "CREATE TABLE User ( id INT PRIMARY KEY, name TEXT NOT NULL, age INT, networth REAL) STRICT;", + // ); + + // androidDb.close(); + // }); + + // it("Creates db in external nested directory on Android", async () => { + // const androidDb = open({ + // name: "AndroidSDCardDB.sqlite", + // location: `${ANDROID_EXTERNAL_FILES_PATH}/nested`, + // encryptionKey: "test", + // }); + + // await androidDb.execute("DROP TABLE IF EXISTS User;"); + // await androidDb.execute( + // "CREATE TABLE User ( id INT PRIMARY KEY, name TEXT NOT NULL, age INT, networth REAL) STRICT;", + // ); + + // androidDb.close(); + // }); + // } + + // Currently this only tests the function is there + it("Should load extension", async () => { + const db = open({ + name: "extensionDb", + encryptionKey: "test", + }); + + try { + db.loadExtension("path"); + } catch (e) { + // TODO load a sample extension + expect(!!e).toEqual(true); + } finally { + db.delete(); + } + }); + + it("Should delete db", async () => { + const db = open({ + name: "deleteTest", + encryptionKey: "test", + }); + + db.delete(); + }); + + it("Should delete db with absolute path", async () => { + const location = Platform.OS === "ios" ? IOS_LIBRARY_PATH : ANDROID_DATABASE_PATH; + const db = open({ + name: "deleteTest", + encryptionKey: "test", + location, + }); + + expect(db.getDbPath().includes(location)).toEqual(true); + + db.delete(); + }); + + it("Should create db in custom folder", async () => { + const db = open({ + name: "customFolderTest.sqlite", + encryptionKey: "test", + location: "myFolder", + }); + + const path = db.getDbPath(); + expect(path.includes("myFolder")).toEqual(true); + db.delete(); + }); + + it("Should create nested folders", async () => { + const db = open({ + name: "nestedFolderTest.sqlite", + encryptionKey: "test", + location: "myFolder/nested", + }); + + const path = db.getDbPath(); + expect(path.includes("myFolder/nested")).toEqual(true); + db.delete(); + }); + + it("Moves assets database simple", async () => { + const copied = await moveAssetsDatabase({ filename: "sample.sqlite" }); + + expect(copied).toEqual(true); + }); + + it("Moves assets database with path", async () => { + const copied = await moveAssetsDatabase({ + filename: "sample2.sqlite", + path: "sqlite", + }); + + expect(copied).toEqual(true); + }); + + it("Moves assets database with path and overwrite", async () => { + const copied = await moveAssetsDatabase({ + filename: "sample2.sqlite", + path: "sqlite", + overwrite: true, + }); + + expect(copied).toEqual(true); + + const db = open({ + name: "sample2.sqlite", + encryptionKey: "test", + location: "sqlite", + }); + + const path = db.getDbPath(); + expect(path.includes("sqlite/sample2.sqlite")).toEqual(true); + db.delete(); + }); + + it("Creates new connections per query and closes them", async () => { + for (let i = 0; i < 100; i++) { + const db = open({ + name: "versionTest.sqlite", + encryptionKey: "test", + }); + + await db.execute("select 1;"); + + db.close(); + } + }); + + it("Closes connections correctly", async () => { + try { + const db1 = open({ + name: "closeTest.sqlite", + }); + expect(!!db1).toBe(true); + open({ + name: "closeTest.sqlite", + }); + } catch (e) { + expect(!!e).toBe(true); + } + }); + + it("Can open read-only databases", async () => { + function openReadOnly() { + return open({ + name: "ignored", + location: ":memory:", + readOnly: true, + }); + } + + 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("does not support read-only databases"); + } + + return; + } + + const db = openReadOnly(); + 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("Respects failOnCreate", async () => { + const name = "failOnCreateTest.sqlite"; + + // Ensure a clean slate: the database file must not exist yet + const setupDb = open({ name }); + setupDb.close(); + setupDb.delete(); + + try { + open({ name, failOnCreate: true }); + throw new Error("should have failed"); + } catch (e: any) { + expect(e.message).toContain("unable to open database file"); + } + + // Creating the database file for real should allow a subsequent + // failOnCreate open to succeed, since it already exists + const db = open({ name }); + await db.execute("CREATE TABLE IF NOT EXISTS foo (bar TEXT);"); + db.close(); + + const reopened = open({ name, failOnCreate: true }); + expect(!!reopened).toBe(true); + await reopened.execute("SELECT * FROM foo;"); + reopened.close(); + reopened.delete(); + }); }); it("Can attach/dettach database", () => { - if (isTurso()) { - return; - } - const db = open({ - name: "attachTest.sqlite", - encryptionKey: "test", - }); - let db2 = open({ - name: "attachTest2.sqlite", - encryptionKey: "test", - }); - db2.close(); - - db.attach({ - secondaryDbFileName: "attachTest2.sqlite", - alias: "attach2", - }); - - db.executeSync("DROP TABLE IF EXISTS attach2.test;"); - db.executeSync( - "CREATE TABLE IF NOT EXISTS attach2.test (id INTEGER PRIMARY KEY);", - ); - const res = db.executeSync("INSERT INTO attach2.test (id) VALUES (1);"); - expect(!!res).toBe(true); - - db.detach("attach2"); - - db.delete(); - - db2 = open({ - name: "attachTest2.sqlite", - encryptionKey: "test", - }); - db2.delete(); + if (isTurso()) { + return; + } + const db = open({ + name: "attachTest.sqlite", + encryptionKey: "test", + }); + let db2 = open({ + name: "attachTest2.sqlite", + encryptionKey: "test", + }); + db2.close(); + + db.attach({ + secondaryDbFileName: "attachTest2.sqlite", + alias: "attach2", + }); + + db.executeSync("DROP TABLE IF EXISTS attach2.test;"); + db.executeSync("CREATE TABLE IF NOT EXISTS attach2.test (id INTEGER PRIMARY KEY);"); + const res = db.executeSync("INSERT INTO attach2.test (id) VALUES (1);"); + expect(!!res).toBe(true); + + db.detach("attach2"); + + db.delete(); + + db2 = open({ + name: "attachTest2.sqlite", + encryptionKey: "test", + }); + db2.delete(); }); it("Neutralizes SQL injection payload in attach alias", () => { - if (isTurso()) { - return; - } - const db = open({ - name: "attachInjectionTest.sqlite", - encryptionKey: "test", - }); - let db2 = open({ - name: "attachInjectionTest2.sqlite", - encryptionKey: "test", - }); - db2.close(); - - // Pre-fix, `opsqlite_execute` walked remainingStatement, so the trailing - // `ATTACH ... AS pwned` would have run as a second prepared statement. - // Post-fix the alias is passed via parameter binding (`ATTACH DATABASE ? - // AS ?`), so the whole payload is a single TEXT value used as the - // schema-name; neither `pwned` nor `evil` ends up attached. The - // `:memory:` target in the payload keeps the proof side-effect-free - // across sandboxes in case the pre-fix code path is ever reintroduced. - const maliciousAlias = "evil; ATTACH DATABASE ':memory:' AS pwned; --"; - - db.attach({ - secondaryDbFileName: "attachInjectionTest2.sqlite", - alias: maliciousAlias, - }); - - let pwnedAttached = false; - try { - db.executeSync("SELECT 1 FROM pwned.sqlite_master LIMIT 1;"); - pwnedAttached = true; - } catch { - pwnedAttached = false; - } - expect(pwnedAttached).toBe(false); - - let evilAttached = false; - try { - db.executeSync("SELECT 1 FROM evil.sqlite_master LIMIT 1;"); - evilAttached = true; - } catch { - evilAttached = false; - } - expect(evilAttached).toBe(false); - - db.detach(maliciousAlias); - - db.delete(); - - db2 = open({ - name: "attachInjectionTest2.sqlite", - encryptionKey: "test", - }); - db2.delete(); + if (isTurso()) { + return; + } + const db = open({ + name: "attachInjectionTest.sqlite", + encryptionKey: "test", + }); + let db2 = open({ + name: "attachInjectionTest2.sqlite", + encryptionKey: "test", + }); + db2.close(); + + // Pre-fix, `opsqlite_execute` walked remainingStatement, so the trailing + // `ATTACH ... AS pwned` would have run as a second prepared statement. + // Post-fix the alias is passed via parameter binding (`ATTACH DATABASE ? + // AS ?`), so the whole payload is a single TEXT value used as the + // schema-name; neither `pwned` nor `evil` ends up attached. The + // `:memory:` target in the payload keeps the proof side-effect-free + // across sandboxes in case the pre-fix code path is ever reintroduced. + const maliciousAlias = "evil; ATTACH DATABASE ':memory:' AS pwned; --"; + + db.attach({ + secondaryDbFileName: "attachInjectionTest2.sqlite", + alias: maliciousAlias, + }); + + let pwnedAttached = false; + try { + db.executeSync("SELECT 1 FROM pwned.sqlite_master LIMIT 1;"); + pwnedAttached = true; + } catch { + pwnedAttached = false; + } + expect(pwnedAttached).toBe(false); + + let evilAttached = false; + try { + db.executeSync("SELECT 1 FROM evil.sqlite_master LIMIT 1;"); + evilAttached = true; + } catch { + evilAttached = false; + } + expect(evilAttached).toBe(false); + + db.detach(maliciousAlias); + + db.delete(); + + db2 = open({ + name: "attachInjectionTest2.sqlite", + encryptionKey: "test", + }); + db2.delete(); }); it("Neutralizes SQL injection payload in attach path", () => { - if (isTurso()) { - return; - } - // `opsqlite_get_db_path` just concatenates location + filename, so any - // quote in the filename used to escape the surrounding string literal - // in `ATTACH DATABASE '...'`. Post-fix the path is passed via parameter - // binding, so the embedded quote is just data — no SQL involvement. - const quirkyFileName = "attach'Injection.sqlite"; - const db = open({ - name: "attachPathHostDb.sqlite", - encryptionKey: "test", - }); - const db2 = open({ - name: quirkyFileName, - encryptionKey: "test", - }); - db2.close(); - - db.attach({ - secondaryDbFileName: quirkyFileName, - alias: "quirky", - }); - db.executeSync("DROP TABLE IF EXISTS quirky.canary;"); - db.executeSync( - "CREATE TABLE IF NOT EXISTS quirky.canary (id INTEGER PRIMARY KEY);", - ); - db.executeSync("INSERT INTO quirky.canary (id) VALUES (1);"); - const rows = db.executeSync("SELECT id FROM quirky.canary;").rows; - expect(rows[0]!.id).toBe(1); - - db.detach("quirky"); - db.delete(); - - open({ name: quirkyFileName, encryptionKey: "test" }).delete(); + if (isTurso()) { + return; + } + // `opsqlite_get_db_path` just concatenates location + filename, so any + // quote in the filename used to escape the surrounding string literal + // in `ATTACH DATABASE '...'`. Post-fix the path is passed via parameter + // binding, so the embedded quote is just data — no SQL involvement. + const quirkyFileName = "attach'Injection.sqlite"; + const db = open({ + name: "attachPathHostDb.sqlite", + encryptionKey: "test", + }); + const db2 = open({ + name: quirkyFileName, + encryptionKey: "test", + }); + db2.close(); + + db.attach({ + secondaryDbFileName: quirkyFileName, + alias: "quirky", + }); + db.executeSync("DROP TABLE IF EXISTS quirky.canary;"); + db.executeSync("CREATE TABLE IF NOT EXISTS quirky.canary (id INTEGER PRIMARY KEY);"); + db.executeSync("INSERT INTO quirky.canary (id) VALUES (1);"); + const rows = db.executeSync("SELECT id FROM quirky.canary;").rows; + expect(rows[0]!.id).toBe(1); + + db.detach("quirky"); + db.delete(); + + open({ name: quirkyFileName, encryptionKey: "test" }).delete(); }); it("Detach with injection payload does not execute trailing SQL", () => { - if (isTurso()) { - return; - } - const db = open({ - name: "detachInjectionTest.sqlite", - encryptionKey: "test", - }); - const secondary = open({ - name: "detachInjectionTest2.sqlite", - encryptionKey: "test", - }); - secondary.close(); - - db.executeSync("DROP TABLE IF EXISTS canary;"); - db.executeSync("CREATE TABLE canary (id INTEGER PRIMARY KEY);"); - db.executeSync("INSERT INTO canary (id) VALUES (42);"); - - // Attach a *real* schema named `safe` so the leading DETACH actually - // resolves pre-fix. The trailing `DROP TABLE canary; --` would then - // run as a second prepared statement and the canary row would be gone. - // Post-fix the alias is passed via parameter binding, so the whole - // payload is the schema-name to detach; nothing matches. The core - // proof is that `canary` survives — backends differ on whether a - // missing-schema DETACH errors (sqlite/sqlcipher) or returns cleanly - // (libsql), so we don't assert on the throw shape. - db.attach({ - secondaryDbFileName: "detachInjectionTest2.sqlite", - alias: "safe", - }); - - try { - db.detach("safe; DROP TABLE canary; --"); - } catch { - // Ignored — only the canary check below is load-bearing. - } - - const rows = db.executeSync("SELECT id FROM canary;").rows; - expect(rows.length).toBe(1); - expect(rows[0]!.id).toBe(42); - - // Best-effort cleanup of `safe`. On backends where the malicious - // detach above did not throw, libsql may also have detached the - // `safe` schema (treating the bound text as a literal alias-name - // that didn't match) or left it attached; either way, swallow the - // error so cleanup doesn't fail the test. - try { - db.detach("safe"); - } catch { - // Already detached or never attached — ignore. - } - db.delete(); - - open({ name: "detachInjectionTest2.sqlite", encryptionKey: "test" }).delete(); + if (isTurso()) { + return; + } + const db = open({ + name: "detachInjectionTest.sqlite", + encryptionKey: "test", + }); + const secondary = open({ + name: "detachInjectionTest2.sqlite", + encryptionKey: "test", + }); + secondary.close(); + + db.executeSync("DROP TABLE IF EXISTS canary;"); + db.executeSync("CREATE TABLE canary (id INTEGER PRIMARY KEY);"); + db.executeSync("INSERT INTO canary (id) VALUES (42);"); + + // Attach a *real* schema named `safe` so the leading DETACH actually + // resolves pre-fix. The trailing `DROP TABLE canary; --` would then + // run as a second prepared statement and the canary row would be gone. + // Post-fix the alias is passed via parameter binding, so the whole + // payload is the schema-name to detach; nothing matches. The core + // proof is that `canary` survives — backends differ on whether a + // missing-schema DETACH errors (sqlite/sqlcipher) or returns cleanly + // (libsql), so we don't assert on the throw shape. + db.attach({ + secondaryDbFileName: "detachInjectionTest2.sqlite", + alias: "safe", + }); + + try { + db.detach("safe; DROP TABLE canary; --"); + } catch { + // Ignored — only the canary check below is load-bearing. + } + + const rows = db.executeSync("SELECT id FROM canary;").rows; + expect(rows.length).toBe(1); + expect(rows[0]!.id).toBe(42); + + // Best-effort cleanup of `safe`. On backends where the malicious + // detach above did not throw, libsql may also have detached the + // `safe` schema (treating the bound text as a literal alias-name + // that didn't match) or left it attached; either way, swallow the + // error so cleanup doesn't fail the test. + try { + db.detach("safe"); + } catch { + // Already detached or never attached — ignore. + } + db.delete(); + + open({ name: "detachInjectionTest2.sqlite", encryptionKey: "test" }).delete(); }); if (isSQLCipher()) { - it("Encryption key with single quote survives a round-trip", () => { - // Prior code embedded the key directly into `PRAGMA key = ''`, - // so a quote in the key would either error out or silently set a - // truncated key. Post-fix the key is set via `sqlite3_key_v2`, - // which takes a binary buffer + length — preserving the key - // exactly, so reopening with the same key still decrypts. - const trickyKey = "p'a''ss\"wrd"; - const dbName = "pragmaKeyInjectionTest.sqlite"; - - let db = open({ name: dbName, encryptionKey: trickyKey }); - db.executeSync("DROP TABLE IF EXISTS secret;"); - db.executeSync("CREATE TABLE secret (value TEXT);"); - db.executeSync("INSERT INTO secret (value) VALUES ('ok');"); - db.close(); - - db = open({ name: dbName, encryptionKey: trickyKey }); - const rows = db.executeSync("SELECT value FROM secret;").rows; - expect(rows[0]!.value).toBe("ok"); - db.delete(); - }); + it("Encryption key with single quote survives a round-trip", () => { + // Prior code embedded the key directly into `PRAGMA key = ''`, + // so a quote in the key would either error out or silently set a + // truncated key. Post-fix the key is set via `sqlite3_key_v2`, + // which takes a binary buffer + length — preserving the key + // exactly, so reopening with the same key still decrypts. + const trickyKey = "p'a''ss\"wrd"; + const dbName = "pragmaKeyInjectionTest.sqlite"; + + let db = open({ name: dbName, encryptionKey: trickyKey }); + db.executeSync("DROP TABLE IF EXISTS secret;"); + db.executeSync("CREATE TABLE secret (value TEXT);"); + db.executeSync("INSERT INTO secret (value) VALUES ('ok');"); + db.close(); + + db = open({ name: dbName, encryptionKey: trickyKey }); + const rows = db.executeSync("SELECT value FROM secret;").rows; + expect(rows[0]!.value).toBe("ok"); + db.delete(); + }); } it("Can get db path", () => { - const db = open({ - name: "pathTest.sqlite", - encryptionKey: "test", - }); - - const path = db.getDbPath(); - expect(!!path).toBe(true); - db.close(); + const db = open({ + name: "pathTest.sqlite", + encryptionKey: "test", + }); + + const path = db.getDbPath(); + expect(!!path).toBe(true); + db.close(); }); if (isLibsql()) { - it("Libsql can set reserved bytes", async () => { - const db = open({ name: "test.db" }); - db.setReservedBytes(28); - expect(db.getReservedBytes()).toEqual(28); - db.delete(); - }); + it("Libsql can set reserved bytes", async () => { + const db = open({ name: "test.db" }); + db.setReservedBytes(28); + expect(db.getReservedBytes()).toEqual(28); + db.delete(); + }); } if (isSQLCipher()) { - it("Can open SQLCipher db without encryption key", () => { - const db = open({ - name: "pathTest.sqlite", - }); + it("Can open SQLCipher db without encryption key", () => { + const db = open({ + name: "pathTest.sqlite", + }); - db.close(); - }); + db.close(); + }); } diff --git a/op-sqlite.podspec b/op-sqlite.podspec index 50c0e6cd..c53b4a8b 100644 --- a/op-sqlite.podspec +++ b/op-sqlite.podspec @@ -87,6 +87,10 @@ if use_libsql and use_sqlite_vec then raise "You cannot use sqlite-vec with libsql. libsql already has vector search included." end +if use_libsql and use_crsqlite then + raise "You cannot use crsqlite with libsql." +end + if use_turso and use_sqlite_vec then raise "You cannot use sqlite-vec with turso backend." end @@ -211,11 +215,7 @@ Pod::Spec.new do |s| if use_libsql then xcconfig[:GCC_PREPROCESSOR_DEFINITIONS] += " OP_SQLITE_USE_LIBSQL=1" - if use_crsqlite then - frameworks = ["ios/libsql_experimental.xcframework", "ios/crsqlite.xcframework"] - else - frameworks = ["ios/libsql_experimental.xcframework"] - end + frameworks = ["ios/libsql_experimental.xcframework"] end if use_turso then diff --git a/src/functions.ts b/src/functions.ts index 8bc73661..2128f737 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -1,4 +1,4 @@ -import { NativeModules, Platform } from 'react-native'; +import { NativeModules, Platform } from "react-native"; import type { _InternalDB, _PendingTransaction, @@ -11,7 +11,7 @@ import type { Scalar, SQLBatchTuple, Transaction, -} from './types'; +} from "./types"; declare global { var __OPSQLiteProxy: object | undefined; @@ -19,23 +19,21 @@ declare global { if (global.__OPSQLiteProxy == null) { if (NativeModules.OPSQLite == null) { - throw new Error( - 'Base module not found. Did you do a pod install/clear the gradle cache?' - ); + throw new Error("Base module not found. Did you do a pod install/clear the gradle cache?"); } // Call the synchronous blocking install() function const installed = NativeModules.OPSQLite.install(); if (!installed) { throw new Error( - `Failed to install op-sqlite: The native OPSQLite Module could not be installed! Looks like something went wrong when installing JSI bindings, check the native logs for more info` + `Failed to install op-sqlite: The native OPSQLite Module could not be installed! Looks like something went wrong when installing JSI bindings, check the native logs for more info`, ); } // Check again if the constructor now exists. If not, throw an error. if (global.__OPSQLiteProxy == null) { throw new Error( - 'OPSqlite native object is not available. Something is wrong. Check the native logs for more information.' + "OPSqlite native object is not available. Something is wrong. Check the native logs for more information.", ); } } @@ -60,7 +58,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { const tx = lock.queue.shift(); if (!tx) { - throw new Error('Could not get a operation on database'); + throw new Error("Could not get a operation on database"); } setImmediate(() => { @@ -92,26 +90,20 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { db.close(); }, flushPendingReactiveQueries: db.flushPendingReactiveQueries, - executeBatch: async ( - commands: SQLBatchTuple[] - ): Promise => { + executeBatch: async (commands: SQLBatchTuple[]): Promise => { async function run() { try { - enhancedDb.executeSync('BEGIN TRANSACTION;'); + enhancedDb.executeSync("BEGIN TRANSACTION;"); const res = await db.executeBatch(commands as any[]); - enhancedDb.executeSync('COMMIT;'); + enhancedDb.executeSync("COMMIT;"); await db.flushPendingReactiveQueries(); return res; } catch (executionError) { - try { - enhancedDb.executeSync('ROLLBACK;'); - } catch (rollbackError) { - throw rollbackError; - } + enhancedDb.executeSync("ROLLBACK;"); throw executionError; } finally { @@ -131,10 +123,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { startNextTransaction(); }); }, - executeWithHostObjects: async ( - query: string, - params?: Scalar[] - ): Promise => { + executeWithHostObjects: async (query: string, params?: Scalar[]): Promise => { return params ? await db.executeWithHostObjects(query, params) : await db.executeWithHostObjects(query); @@ -151,19 +140,11 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { executeRawAsync: async (query: string, params?: Scalar[]) => { return db.executeRaw(query, params as Scalar[]); }, - executeAsync: async ( - query: string, - params?: Scalar[] | undefined - ): Promise => { + executeAsync: async (query: string, params?: Scalar[] | undefined): Promise => { return db.execute(query, params); }, - execute: async ( - query: string, - params?: Scalar[] | undefined - ): Promise => { - let res = params - ? await db.execute(query, params) - : await db.execute(query); + execute: async (query: string, params?: Scalar[] | undefined): Promise => { + let res = params ? await db.execute(query, params) : await db.execute(query); if (!res.rows) { const rows: Record[] = []; @@ -202,9 +183,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { execute: stmt.execute, }; }, - transaction: async ( - fn: (tx: Transaction) => Promise - ): Promise => { + transaction: async (fn: (tx: Transaction) => Promise): Promise => { let isFinalized = false; const execute = async (query: string, params?: Scalar[]) => { @@ -212,7 +191,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { throw Error( `OP-Sqlite Error: Database: ${ options.name || options.url - }. Cannot execute query on finalized transaction` + }. Cannot execute query on finalized transaction`, ); } return await enhancedDb.execute(query, params); @@ -223,10 +202,10 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { throw Error( `OP-Sqlite Error: Database: ${ options.name || options.url - }. Cannot execute query on finalized transaction` + }. Cannot execute query on finalized transaction`, ); } - const result = enhancedDb.executeSync('COMMIT;'); + const result = enhancedDb.executeSync("COMMIT;"); await db.flushPendingReactiveQueries(); @@ -239,17 +218,17 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { throw Error( `OP-Sqlite Error: Database: ${ options.name || options.url - }. Cannot execute query on finalized transaction` + }. Cannot execute query on finalized transaction`, ); } - const result = enhancedDb.executeSync('ROLLBACK;'); + const result = enhancedDb.executeSync("ROLLBACK;"); isFinalized = true; return result; }; async function run() { try { - enhancedDb.executeSync('BEGIN TRANSACTION;'); + enhancedDb.executeSync("BEGIN TRANSACTION;"); await fn({ commit, @@ -262,11 +241,7 @@ function enhanceDB(db: _InternalDB, options: DBParams): DB { } } catch (executionError) { if (!isFinalized) { - try { - rollback(); - } catch (rollbackError) { - throw rollbackError; - } + rollback(); } throw executionError; @@ -308,9 +283,7 @@ export const openSync = (params: { remoteEncryptionKey?: string; }): DB => { if (!isLibsql() && !isTurso()) { - throw new Error( - 'This function is only available for libsql or turso backends' - ); + throw new Error("This function is only available for libsql or turso backends"); } const db = OPSQLite.openSync(params); @@ -325,9 +298,7 @@ export const openSync = (params: { */ export const openRemote = (params: { url: string; authToken: string }): DB => { if (!isLibsql() && !isTurso()) { - throw new Error( - 'This function is only available for libsql or turso backends' - ); + throw new Error("This function is only available for libsql or turso backends"); } const db = OPSQLite.openRemote(params); @@ -341,9 +312,9 @@ export const openRemote = (params: { url: string; authToken: string }): DB => { * If you want libsql remote or sync connections, use openSync or openRemote */ export const open = (params: OpenOptions): DB => { - if (params.location?.startsWith('file://')) { + if (params.location?.startsWith("file://")) { console.warn( - "[op-sqlite] You are passing a path with 'file://' prefix, it's automatically removed" + "[op-sqlite] You are passing a path with 'file://' prefix, it's automatically removed", ); params.location = params.location.substring(7); } @@ -402,14 +373,9 @@ export const isTurso = (): boolean => { }; export const isIOSEmbedded = (): boolean => { - if (Platform.OS !== 'ios') { + if (Platform.OS !== "ios") { return false; } return OPSQLite.isIOSEmbedded(); }; - -/** - * @deprecated Use `isIOSEmbedded` instead. This alias will be removed in a future release. - */ -export const isIOSEmbeeded = isIOSEmbedded; diff --git a/src/types.ts b/src/types.ts index 79d53db6..7ea79b08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,15 +7,22 @@ export interface OpenOptions { 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; + /** + * Encryption key when used against backends that support encryption (sqlcipher or libsql) + */ encryptionKey?: string; + /** + * When set to true, database will only try to be opened. If the file does not exist the call will fail and no creation attempt will be made + */ + failOnCreate?: boolean; /** * 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. When enabling this option with libsql enabled, * opening databases will throw. */