diff --git a/docs/content/docs/guides/batch-operations.mdx b/docs/content/docs/guides/batch-operations.mdx index fba643e0..59201032 100644 --- a/docs/content/docs/guides/batch-operations.mdx +++ b/docs/content/docs/guides/batch-operations.mdx @@ -30,6 +30,8 @@ const { rowsAffected } = await db.executeBatchAsync(commands) When the same SQL runs with different values, give one command an array of parameter arrays. The native batch code expands it into separate executions before starting the transaction. +An empty `params` array skips that command. If every command has an empty array, the batch throws or rejects because it has nothing to execute. Omit `params` for a statement that should run once without bindings, such as `CREATE TABLE`. + ```ts await db.executeBatchAsync([ { diff --git a/docs/content/docs/guides/database-lifecycle.mdx b/docs/content/docs/guides/database-lifecycle.mdx index 3ec0116f..103b1acd 100644 --- a/docs/content/docs/guides/database-lifecycle.mdx +++ b/docs/content/docs/guides/database-lifecycle.mdx @@ -5,7 +5,7 @@ description: Open, close, delete, and place database files in an app directory. A SQLite database usually lives in a file. Your app opens a connection to read or change that file, then closes the connection when it is done. Closing a connection leaves the data on disk; deleting the file removes the database. -In NitroSQLite, `open({ name })` opens an existing SQLite file or creates a new one. A database name can have only one active default connection. Close it before opening another default connection with that name, or use `connection: 'independent'` for a separate handle to the same file. See [multiple connections](/docs/guides/multiple-connections). +In NitroSQLite, `open({ name })` opens an existing SQLite file or creates a new one. A database name can have only one active default connection per native NitroSQLite root. Close it before opening another default connection with that name, or use `connection: 'independent'` for a separate handle to the same file. See [multiple connections](/docs/guides/multiple-connections). ```ts import { open } from 'react-native-nitro-sqlite' @@ -31,6 +31,14 @@ Both methods are synchronous. They fail if an operation on that connection is qu On iOS, when a database is being moved from Documents to Application Support, deletion also cleans up copies and SQLite sidecar files from both locations. +## Runtime teardown + +SQLite connections are native resources. A JavaScript runtime can be destroyed while the app process stays alive, such as when an app switches between React Native hosts. Closing a connection preserves committed data on disk and rolls back any unfinished transaction. + +Each native NitroSQLite root owns its default and independent connections. When that root is destroyed, it closes its handles. A replacement runtime can open the same database names without restarting the process. An older root's cleanup does not close connections opened by a newer root. + +Queued operations and prepared statements keep their original connection. After its owner closes it, those operations fail instead of using a replacement connection with the same name. Await pending work and finalize prepared statements before an intentional runtime teardown when your app controls that transition. + ## File location and prepopulated databases By default, the root is the app Documents directory on iOS, the app files directory on Android, and an app-specific Application Support directory on macOS. Use the optional `location` as a relative subdirectory under that root, not as an absolute file path: diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.cpp index 19699e66..eb6e4f14 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.cpp @@ -18,6 +18,19 @@ namespace { constexpr const char* kIndependentPrefix = "nitro-sqlite:"; + // File migration and deletion must account for handles owned by other runtimes. + // Weak references observe those handles without extending their lifetime. + struct ProcessConnections { + std::recursive_mutex lifecycleMutex; + std::vector> connections; + unsigned long long nextConnectionId = 0; + }; + + ProcessConnections& processConnections() { + static ProcessConnections state; + return state; + } + int readOnlyAuthorizer(void*, int action, const char*, const char*, const char*, const char*) { return action == SQLITE_ATTACH ? SQLITE_DENY : SQLITE_OK; } @@ -152,6 +165,12 @@ void SQLiteConnection::close() noexcept { database = nullptr; } +DatabaseConnections::DatabaseConnections() : lifecycleMutex(processConnections().lifecycleMutex) {} + +DatabaseConnections::~DatabaseConnections() { + closeAll(); +} + void DatabaseConnections::open(const std::string& key, const fs::path& path, bool readOnly, const std::optional& encryptionKey) { std::lock_guard lock(lifecycleMutex); @@ -173,8 +192,8 @@ std::string DatabaseConnections::openIndependent(const fs::path& path, bool read } // NUL cannot occur in a filesystem name. The ID cannot collide with a legacy database key. const auto physicalPath = canonicalDatabasePath(path); - const std::string key = std::string(1, '\0') + kIndependentPrefix + std::to_string(++nextConnectionId) + std::string(1, '\0') + - (readOnly ? "r" : "w") + physicalPath.string(); + const std::string key = std::string(1, '\0') + kIndependentPrefix + std::to_string(++processConnections().nextConnectionId) + + std::string(1, '\0') + (readOnly ? "r" : "w") + physicalPath.string(); openKey(key, path, readOnly, encryptionKey); return key; } @@ -201,6 +220,9 @@ void DatabaseConnections::openKey(const std::string& key, const fs::path& path, } auto connection = std::make_shared(connectionLabel(key), physicalPath, readOnly, database.get()); database.release(); + auto& liveConnections = processConnections().connections; + std::erase_if(liveConnections, [](const auto& weak) { return weak.expired(); }); + liveConnections.emplace_back(connection); connections.emplace(key, std::move(connection)); } @@ -250,8 +272,9 @@ std::optional DatabaseConnections::physicalPathForKey(const std::strin std::optional DatabaseConnections::findLivePath(const fs::path& first, const fs::path& second) { std::optional found; withConnectionsLocked([&]() { - for (const auto& [_, connection] : connections) { - if (connection->database == nullptr) { + for (const auto& weak : processConnections().connections) { + const auto connection = weak.lock(); + if (!connection || connection->database == nullptr) { continue; } anyDatabasePath(connection->database, [&](const fs::path& candidate) { @@ -275,9 +298,15 @@ std::optional DatabaseConnections::findLivePath(const fs::path& first, void DatabaseConnections::withConnectionsLocked(const std::function& action) { std::lock_guard lifecycleLock(lifecycleMutex); + std::vector liveConnections; std::vector> locks; - locks.reserve(connections.size()); - for (const auto& [_, connection] : connections) { + for (const auto& weak : processConnections().connections) { + if (auto connection = weak.lock()) { + liveConnections.push_back(std::move(connection)); + } + } + locks.reserve(liveConnections.size()); + for (const auto& connection : liveConnections) { locks.emplace_back(connection->mutex); } action(); @@ -313,7 +342,7 @@ void DatabaseConnections::drop(const std::string& dbName, const fs::path& path, const SQLiteConnectionPtr connectionToClose = live == connections.end() ? nullptr : live->second; withConnectionsLocked([&]() { - if (isPathInUse(target, key) || (otherPath && isPathInUse(*otherPath, key))) { + if (isPathInUse(target, connectionToClose) || (otherPath && isPathInUse(*otherPath, connectionToClose))) { throw NitroSQLiteException(NitroSQLiteExceptionType::SqlExecutionError, "Database is in use by another connection"); } if (!fs::exists(target)) { @@ -331,9 +360,10 @@ void DatabaseConnections::drop(const std::string& dbName, const fs::path& path, }); } -bool DatabaseConnections::isPathInUse(const fs::path& path, const std::string& excludedKey) const { - for (const auto& [key, connection] : connections) { - if (key == excludedKey) { +bool DatabaseConnections::isPathInUse(const fs::path& path, const SQLiteConnectionPtr& excludedConnection) const { + for (const auto& weak : processConnections().connections) { + const auto connection = weak.lock(); + if (!connection || connection == excludedConnection) { continue; } if (connection->database == nullptr) { @@ -346,11 +376,6 @@ bool DatabaseConnections::isPathInUse(const fs::path& path, const std::string& e return false; } -DatabaseConnections& databaseConnections() { - static DatabaseConnections registry; - return registry; -} - void validateDatabaseName(const std::string& dbName) { if (dbName.find('\0') != std::string::npos) { throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, "Database name contains a NUL byte"); diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.hpp index b3ec6856..614b376e 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteDatabaseConnections.hpp @@ -45,12 +45,18 @@ struct SQLiteConnection final : std::enable_shared_from_this { /** Shared ownership of a native connection across pending operations. */ using SQLiteConnectionPtr = std::shared_ptr; -/** Registry of default database names and opaque independent connection IDs. */ +/** Connections owned by one NitroSQLite root. Destruction closes only this owner's handles. */ class DatabaseConnections final { public: + DatabaseConnections(); + ~DatabaseConnections(); + + DatabaseConnections(const DatabaseConnections&) = delete; + DatabaseConnections& operator=(const DatabaseConnections&) = delete; + // Callers hold this while resolving or migrating a database path. It is recursive because - // open, attach and drop take it again after path resolution. - std::recursive_mutex lifecycleMutex; + // open, attach and drop take it again after path resolution. Shared across roots to protect files. + std::recursive_mutex& lifecycleMutex; /** Open a name-based default connection. An existing key is an error. */ void open(const std::string& key, const std::filesystem::path& path, bool readOnly, @@ -68,9 +74,9 @@ class DatabaseConnections final { bool isOpen(const std::string& key); /** Resolve the file path for a registered or encoded independent key. */ std::optional physicalPathForKey(const std::string& key); - /** Find an open connection using either candidate path. */ + /** Find an open connection in any owner using either candidate path. */ std::optional findLivePath(const std::filesystem::path& first, const std::filesystem::path& second); - /** Run @p action while holding the lifecycle and every connection lock. */ + /** Run an internal file operation while holding the lifecycle and all owners' connection locks. */ void withConnectionsLocked(const std::function& action); /** Delete a database after checking that no other connection or attachment uses it. */ void drop(const std::string& dbName, const std::filesystem::path& path, const std::optional& connectionId, @@ -78,13 +84,11 @@ class DatabaseConnections final { private: void openKey(const std::string& key, const std::filesystem::path& path, bool readOnly, const std::optional& encryptionKey); - bool isPathInUse(const std::filesystem::path& path, const std::string& excludedKey) const; + bool isPathInUse(const std::filesystem::path& path, const SQLiteConnectionPtr& excludedConnection) const; std::map connections; - unsigned long long nextConnectionId = 0; }; -DatabaseConnections& databaseConnections(); std::filesystem::path canonicalDatabasePath(const std::filesystem::path& path); void validateDatabaseName(const std::string& dbName); diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.cpp index f2c13187..fc2ce09a 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.cpp @@ -21,7 +21,12 @@ std::vector batchParamsToCommands(const std::vector(*command.params)) { groupedCommand.parameterSets = std::get(*command.params); } else { - groupedCommand.parameterSets.push_back(std::get(*command.params)); + const auto& params = std::get(*command.params); + // An empty JavaScript array matches the flat variant first. It still + // represents an empty group, so only omitted params execute once. + if (!params.empty()) { + groupedCommand.parameterSets.push_back(params); + } } } else { groupedCommand.parameterSets.emplace_back(); @@ -34,10 +39,6 @@ std::vector batchParamsToCommands(const std::vector& commands) { - return sqliteExecuteBatch(sqliteGetOpenDatabase(dbName), commands); -} - SQLiteOperationResult sqliteExecuteBatch(const SQLiteConnectionPtr& connection, const std::vector& commands) { std::lock_guard lock(connection->mutex); if (commands.empty()) { diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.hpp index 9a0cd0dd..b96deae7 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteExecuteBatch.hpp @@ -25,7 +25,6 @@ std::vector batchParamsToCommands(const std::vector& commands); SQLiteOperationResult sqliteExecuteBatch(const std::shared_ptr& connection, const std::vector& commands); } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp index ee3dc4c8..dadd2e0d 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.cpp @@ -10,10 +10,6 @@ namespace margelo::nitro::rnnitrosqlite { -SQLiteOperationResult importSqlFile(const std::string& dbName, const std::string& fileLocation) { - return importSqlFile(sqliteGetOpenDatabase(dbName), fileLocation); -} - SQLiteOperationResult importSqlFile(const SQLiteConnectionPtr& connection, const std::string& fileLocation) { std::lock_guard lock(connection->mutex); std::string line; diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp index bdfc3c69..87b3ba95 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteImportSqlFile.hpp @@ -13,7 +13,6 @@ namespace margelo::nitro::rnnitrosqlite { struct SQLiteConnection; -SQLiteOperationResult importSqlFile(const std::string& dbName, const std::string& fileLocation); SQLiteOperationResult importSqlFile(const std::shared_ptr& connection, const std::string& fileLocation); } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp index e5c6dc34..558b4359 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.cpp @@ -61,16 +61,17 @@ void SQLiteConnection::drainAsync() { } } -void sqliteOpenDb(const std::string& dbName, const std::string& docPath, bool readOnly, const std::optional& encryptionKey) { +void sqliteOpenDb(DatabaseConnections& connections, const std::string& dbName, const std::string& docPath, bool readOnly, + const std::optional& encryptionKey) { #ifdef NITRO_SQLITE_VEC // Register before opening so the connection exposes vec0 + vec_*. margelo::rnnitrosqlitevec::registerVectorExtensions(); #endif const std::string dbPath = readOnly ? docPath + "/" + dbName : get_db_path(dbName, docPath); - databaseConnections().open(dbName, dbPath, readOnly, encryptionKey); + connections.open(dbName, dbPath, readOnly, encryptionKey); } -std::string sqliteOpenConnection(const std::string& dbName, const std::string& docPath, bool readOnly, +std::string sqliteOpenConnection(DatabaseConnections& connections, const std::string& dbName, const std::string& docPath, bool readOnly, const std::optional& encryptionKey) { if (sqlite3_threadsafe() == 0) { throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, @@ -80,54 +81,40 @@ std::string sqliteOpenConnection(const std::string& dbName, const std::string& d margelo::rnnitrosqlitevec::registerVectorExtensions(); #endif const std::string dbPath = readOnly ? docPath + "/" + dbName : get_db_path(dbName, docPath); - return databaseConnections().openIndependent(dbPath, readOnly, encryptionKey); + return connections.openIndependent(dbPath, readOnly, encryptionKey); } -void sqliteCloseDb(const std::string& dbName) { - databaseConnections().close(dbName); -} - -void sqliteCloseAll() { - databaseConnections().closeAll(); -} - -void sqliteAttachDb(const std::string& mainDBName, const std::string& docPath, const std::string& databaseToAttach, +void sqliteAttachDb(const SQLiteConnectionPtr& connection, const std::string& docPath, const std::string& databaseToAttach, const std::string& alias) { - /** - * There is no need to check if mainDBName is opened because sqliteExecuteCommand will do that. - * */ std::string dbPath = get_db_path(databaseToAttach, docPath); std::string statement = "ATTACH DATABASE '" + dbPath + "' AS " + alias; try { - sqliteExecuteCommand(mainDBName, statement); + sqliteExecuteCommand(connection, statement); } catch (NitroSQLiteException& e) { throw NitroSQLiteException(NitroSQLiteExceptionType::UnableToAttachToDatabase, - mainDBName + " was unable to attach another database: " + std::string(e.what())); + connection->name + " was unable to attach another database: " + std::string(e.what())); } } -void sqliteDetachDb(const std::string& mainDBName, const std::string& alias) { - /** - * There is no need to check if mainDBName is opened because sqliteExecuteCommand will do that. - * */ +void sqliteDetachDb(const SQLiteConnectionPtr& connection, const std::string& alias) { std::string statement = "DETACH DATABASE " + alias; try { - sqliteExecuteCommand(mainDBName, statement); + sqliteExecuteCommand(connection, statement); } catch (NitroSQLiteException& e) { throw NitroSQLiteException(NitroSQLiteExceptionType::UnableToAttachToDatabase, - mainDBName + " was unable to detach database: " + std::string(e.what())); + connection->name + " was unable to detach database: " + std::string(e.what())); } } -void sqliteRemoveDb(const std::string& dbName, const std::string& docPath, const std::optional& connectionId, - const std::optional& otherDocPath) { +void sqliteRemoveDb(DatabaseConnections& connections, const std::string& dbName, const std::string& docPath, + const std::optional& connectionId, const std::optional& otherDocPath) { std::optional otherPath; if (otherDocPath) { otherPath = std::filesystem::path(*otherDocPath) / dbName; } - databaseConnections().drop(dbName, std::filesystem::path(docPath) / dbName, connectionId, otherPath); + connections.drop(dbName, std::filesystem::path(docPath) / dbName, connectionId, otherPath); } void bindStatement(sqlite3_stmt* statement, const SQLiteQueryParams& values) { @@ -305,15 +292,6 @@ namespace { } // namespace -SQLiteConnectionPtr sqliteGetOpenDatabase(const std::string& dbName) { - return databaseConnections().get(dbName); -} - -std::shared_ptr sqliteExecute(const std::string& dbName, const std::string& query, - const std::optional& params) { - return sqliteExecute(sqliteGetOpenDatabase(dbName), query, params); -} - std::shared_ptr sqliteExecute(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params) { std::lock_guard lock(connection->mutex); @@ -326,11 +304,6 @@ std::shared_ptr sqliteExecute(const SQLiteConnecti return executeStatement(db, statement.get()); } -SQLiteOperationResult sqliteExecuteCommand(const std::string& dbName, const std::string& query, - const std::optional& params) { - return sqliteExecuteCommand(sqliteGetOpenDatabase(dbName), query, params); -} - SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params) { std::lock_guard lock(connection->mutex); @@ -431,11 +404,10 @@ size_t SQLitePreparedStatement::getExternalMemorySize() const noexcept { return sizeof(*this) + sizeof(State); } -std::shared_ptr sqlitePrepare(const std::string& dbName, const std::string& query) { - auto connection = sqliteGetOpenDatabase(dbName); +std::shared_ptr sqlitePrepare(const SQLiteConnectionPtr& connection, const std::string& query) { std::lock_guard lock(connection->mutex); if (connection->database == nullptr) { - throw NitroSQLiteException::DatabaseNotOpen(dbName); + throw NitroSQLiteException::DatabaseNotOpen(connection->name); } auto statement = prepareStatement(connection->database, query, std::nullopt); diff --git a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp index edae2587..279324fd 100644 --- a/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp +++ b/packages/react-native-nitro-sqlite/cpp/NitroSQLiteOperations.hpp @@ -12,10 +12,11 @@ namespace margelo::nitro::rnnitrosqlite { /** Open the default connection by database name. Read-only mode requires an existing file. */ -void sqliteOpenDb(const std::string& dbName, const std::string& docPath, bool readOnly, const std::optional& encryptionKey); +void sqliteOpenDb(DatabaseConnections& connections, const std::string& dbName, const std::string& docPath, bool readOnly, + const std::optional& encryptionKey); /** Open a separate native handle and return its opaque connection ID. */ -std::string sqliteOpenConnection(const std::string& dbName, const std::string& docPath, bool readOnly, +std::string sqliteOpenConnection(DatabaseConnections& connections, const std::string& dbName, const std::string& docPath, bool readOnly, const std::optional& encryptionKey); /** Prepared SQL statement bound to one native connection. */ @@ -39,29 +40,22 @@ class SQLitePreparedStatement { std::shared_ptr _state; - friend std::shared_ptr sqlitePrepare(const std::string& dbName, const std::string& query); + friend std::shared_ptr sqlitePrepare(const SQLiteConnectionPtr& connection, const std::string& query); }; -void sqliteCloseDb(const std::string& dbName); - /** Delete a database, optionally closing its independent connection first. */ -void sqliteRemoveDb(const std::string& dbName, const std::string& docPath, const std::optional& connectionId = std::nullopt, +void sqliteRemoveDb(DatabaseConnections& connections, const std::string& dbName, const std::string& docPath, + const std::optional& connectionId = std::nullopt, const std::optional& otherDocPath = std::nullopt); -void sqliteAttachDb(const std::string& mainDBName, const std::string& docPath, const std::string& databaseToAttach, +void sqliteAttachDb(const SQLiteConnectionPtr& connection, const std::string& docPath, const std::string& databaseToAttach, const std::string& alias); -void sqliteDetachDb(const std::string& mainDBName, const std::string& alias); - -SQLiteConnectionPtr sqliteGetOpenDatabase(const std::string& dbName); +void sqliteDetachDb(const SQLiteConnectionPtr& connection, const std::string& alias); -std::shared_ptr sqliteExecute(const std::string& dbName, const std::string& query, - const std::optional& params); std::shared_ptr sqliteExecute(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params); -SQLiteOperationResult sqliteExecuteCommand(const std::string& dbName, const std::string& query, - const std::optional& params = std::nullopt); SQLiteOperationResult sqliteExecuteCommand(const SQLiteConnectionPtr& connection, const std::string& query, const std::optional& params = std::nullopt); @@ -70,8 +64,6 @@ SQLiteOperationResult sqliteExecuteCommandGroup(const SQLiteConnectionPtr& conne const std::vector& parameterSets); /** Prepare one SQL statement on an open default or independent connection. */ -std::shared_ptr sqlitePrepare(const std::string& dbName, const std::string& query); - -void sqliteCloseAll(); +std::shared_ptr sqlitePrepare(const SQLiteConnectionPtr& connection, const std::string& query); } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index 2aee3e32..e8ba0048 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -112,16 +112,17 @@ const std::string getOldDocPath(const std::optional& location) { return oldDocPath; } -const std::string getMigratedDocPath(const std::string& dbName, const std::optional& location, bool readOnly = false) { +const std::string getMigratedDocPath(DatabaseConnections& connections, const std::string& dbName, + const std::optional& location, bool readOnly = false) { const auto currentDocPath = getDocPath(location); if (HybridNitroSQLite::migrationDocPath.empty()) { return currentDocPath; } const auto oldDocPath = getOldDocPath(location); std::string selectedPath; - databaseConnections().withConnectionsLocked([&]() { + connections.withConnectionsLocked([&]() { const auto oldPath = std::filesystem::path(oldDocPath) / dbName; - const auto livePath = databaseConnections().findLivePath(oldPath, std::filesystem::path(currentDocPath) / dbName); + const auto livePath = connections.findLivePath(oldPath, std::filesystem::path(currentDocPath) / dbName); if (livePath) { selectedPath = *livePath == oldPath ? oldDocPath : currentDocPath; } else if (readOnly) { @@ -136,12 +137,12 @@ const std::string getMigratedDocPath(const std::string& dbName, const std::optio void HybridNitroSQLite::open(const std::string& dbName, const std::optional& location, std::optional readOnly, const std::optional& encryptionKey) { validateDatabaseName(dbName); - std::lock_guard lock(databaseConnections().lifecycleMutex); - if (databaseConnections().isOpen(dbName)) { + std::lock_guard lock(_connections.lifecycleMutex); + if (_connections.isOpen(dbName)) { throw NitroSQLiteException::DatabaseAlreadyOpen(dbName); } - const auto docPath = getMigratedDocPath(dbName, location, readOnly.value_or(false)); - sqliteOpenDb(dbName, docPath, readOnly.value_or(false), encryptionKey); + const auto docPath = getMigratedDocPath(_connections, dbName, location, readOnly.value_or(false)); + sqliteOpenDb(_connections, dbName, docPath, readOnly.value_or(false), encryptionKey); } std::string HybridNitroSQLite::openConnection(const std::string& dbName, const std::optional& location, @@ -151,31 +152,31 @@ std::string HybridNitroSQLite::openConnection(const std::string& dbName, const s throw NitroSQLiteException(NitroSQLiteExceptionType::DatabaseCannotBeOpened, "Independent connections require a thread-safe SQLite build"); } - std::lock_guard lock(databaseConnections().lifecycleMutex); - const auto docPath = getMigratedDocPath(dbName, location, readOnly.value_or(false)); - return sqliteOpenConnection(dbName, docPath, readOnly.value_or(false), encryptionKey); + std::lock_guard lock(_connections.lifecycleMutex); + const auto docPath = getMigratedDocPath(_connections, dbName, location, readOnly.value_or(false)); + return sqliteOpenConnection(_connections, dbName, docPath, readOnly.value_or(false), encryptionKey); } void HybridNitroSQLite::close(const std::string& dbName) { - sqliteCloseDb(dbName); + _connections.close(dbName); }; bool HybridNitroSQLite::isConnectionOpen(const std::string& connectionId) { - return databaseConnections().isOpen(connectionId); + return _connections.isOpen(connectionId); } void HybridNitroSQLite::drop(const std::string& dbName, const std::optional& location, const std::optional& connectionId) { validateDatabaseName(dbName); - std::lock_guard lock(databaseConnections().lifecycleMutex); + std::lock_guard lock(_connections.lifecycleMutex); const auto currentDocPath = getDocPath(location); if (migrationDocPath.empty()) { - sqliteRemoveDb(dbName, currentDocPath, connectionId); + sqliteRemoveDb(_connections, dbName, currentDocPath, connectionId); return; } const auto oldDocPath = getOldDocPath(location); - const auto preferredPath = databaseConnections().physicalPathForKey(connectionId.value_or(dbName)); + const auto preferredPath = _connections.physicalPathForKey(connectionId.value_or(dbName)); const auto currentPath = std::filesystem::path(currentDocPath) / dbName; std::error_code ec; const bool oldDatabaseExists = std::filesystem::exists(std::filesystem::path(oldDocPath) / dbName, ec); @@ -189,27 +190,27 @@ void HybridNitroSQLite::drop(const std::string& dbName, const std::optional& location) { validateDatabaseName(dbNameToAttach); - std::lock_guard lock(databaseConnections().lifecycleMutex); - if (databaseConnections().get(mainDbName)->readOnly) { + std::lock_guard lock(_connections.lifecycleMutex); + if (_connections.get(mainDbName)->readOnly) { throw NitroSQLiteException(NitroSQLiteExceptionType::UnableToAttachToDatabase, "Cannot attach a database to a read-only connection"); } - const auto attachedDocPath = getMigratedDocPath(dbNameToAttach, location); - sqliteAttachDb(mainDbName, attachedDocPath, dbNameToAttach, alias); + const auto attachedDocPath = getMigratedDocPath(_connections, dbNameToAttach, location); + sqliteAttachDb(_connections.get(mainDbName), attachedDocPath, dbNameToAttach, alias); }; void HybridNitroSQLite::detach(const std::string& mainDbName, const std::string& alias) { - sqliteDetachDb(mainDbName, alias); + sqliteDetachDb(_connections.get(mainDbName), alias); }; std::shared_ptr HybridNitroSQLite::execute(const std::string& dbName, const std::string& query, const std::optional& params) { - return sqliteExecute(dbName, query, params); + return sqliteExecute(_connections.get(dbName), query, params); }; std::shared_ptr>> @@ -217,7 +218,7 @@ HybridNitroSQLite::executeAsync(const std::string& dbName, const std::string& qu const auto copiedParams = copyArrayBufferParamsForBackground(params); SQLiteConnectionPtr connection; try { - connection = sqliteGetOpenDatabase(dbName); + connection = _connections.get(dbName); } catch (...) { return Promise>::rejected(std::current_exception()); } @@ -230,13 +231,13 @@ HybridNitroSQLite::executeAsync(const std::string& dbName, const std::string& qu }; std::shared_ptr HybridNitroSQLite::prepare(const std::string& dbName, const std::string& query) { - return std::make_shared(sqlitePrepare(dbName, query)); + return std::make_shared(sqlitePrepare(_connections.get(dbName), query)); } BatchQueryResult HybridNitroSQLite::executeBatch(const std::string& dbName, const std::vector& batchParams) { const auto commands = batchParamsToCommands(batchParams); - auto result = sqliteExecuteBatch(dbName, commands); + auto result = sqliteExecuteBatch(_connections.get(dbName), commands); return BatchQueryResult(result.rowsAffected); }; @@ -248,7 +249,7 @@ std::shared_ptr> HybridNitroSQLite::executeBatchAsync( const auto copiedCommands = copyArrayBufferParamsForBackground(commands); SQLiteConnectionPtr connection; try { - connection = sqliteGetOpenDatabase(dbName); + connection = _connections.get(dbName); } catch (...) { return Promise::rejected(std::current_exception()); } @@ -260,14 +261,14 @@ std::shared_ptr> HybridNitroSQLite::executeBatchAsync( }; FileLoadResult HybridNitroSQLite::loadFile(const std::string& dbName, const std::string& location) { - const auto result = importSqlFile(dbName, location); + const auto result = importSqlFile(_connections.get(dbName), location); return FileLoadResult(result.commands, result.rowsAffected); }; std::shared_ptr> HybridNitroSQLite::loadFileAsync(const std::string& dbName, const std::string& location) { SQLiteConnectionPtr connection; try { - connection = sqliteGetOpenDatabase(dbName); + connection = _connections.get(dbName); } catch (...) { return Promise::rejected(std::current_exception()); } diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp index a8012cdb..03fd9c01 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp @@ -1,5 +1,6 @@ #pragma once +#include "../NitroSQLiteDatabaseConnections.hpp" #include "../NitroSQLiteTypes.hpp" #include "HybridNitroSQLitePreparedStatementSpec.hpp" #include "HybridNitroSQLiteQueryResultSpec.hpp" @@ -7,7 +8,11 @@ namespace margelo::nitro::rnnitrosqlite { -/** Native database operations exposed to JavaScript through the Nitro hybrid object. */ +/** Native database operations exposed to JavaScript through the Nitro hybrid object. + * Owns its default and independent connections and closes them on destruction. + * Queued operations and prepared statements retain their original connection and + * fail after that connection closes. Other NitroSQLite roots remain usable. + */ class HybridNitroSQLite : public HybridNitroSQLiteSpec { public: HybridNitroSQLite() : HybridObject(TAG) {} @@ -19,6 +24,9 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { // When non-empty, databases found there are resolved as they are opened, attached, or dropped. static std::string migrationDocPath; +private: + DatabaseConnections _connections; + public: // Methods /** Open the default connection, requiring an existing file in read-only mode. */ diff --git a/packages/react-native-nitro-sqlite/src/types.ts b/packages/react-native-nitro-sqlite/src/types.ts index 04f1d2ec..78f1c66e 100644 --- a/packages/react-native-nitro-sqlite/src/types.ts +++ b/packages/react-native-nitro-sqlite/src/types.ts @@ -215,7 +215,9 @@ export interface Transaction { export interface BatchQueryCommand { /** SQL statement to execute. */ query: string - /** One parameter set, or several sets for repeated execution. */ + /** One parameter set, or several sets for repeated execution. + * An empty array skips the command. Omit this field to execute once without bindings. + */ params?: SQLiteQueryParams | SQLiteQueryParams[] } diff --git a/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp index 3100868f..8bd6a0c3 100644 --- a/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp +++ b/packages/react-native-nitro-sqlite/tests/cpp/databaseConnections.test.cpp @@ -80,6 +80,57 @@ int main() { ("nitro-sqlite-connections-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); fs::create_directories(root); try { + // Retained work must observe closure even when it outlives its registry. + const auto runtimePath = root / "runtime.sqlite"; + auto oldOwner = std::make_unique(); + oldOwner->open("runtime.sqlite", runtimePath, false); + const auto oldConnection = oldOwner->get("runtime.sqlite"); + execute(oldConnection, "PRAGMA journal_mode=WAL"); + execute(oldConnection, "CREATE TABLE session (value INTEGER)"); + execute(oldConnection, "INSERT INTO session VALUES (42)"); + expectThrows([&]() { oldOwner->open("runtime.sqlite", runtimePath, false); }, + "duplicate default opens within an owner must remain errors"); + const auto oldIndependentId = oldOwner->openIndependent(runtimePath, false); + const auto oldIndependent = oldOwner->get(oldIndependentId); + + // Overlapping roots may use the same name, but cannot close each other's handles. + DatabaseConnections newOwner; + newOwner.open("runtime.sqlite", runtimePath, false); + const auto newConnection = newOwner.get("runtime.sqlite"); + const auto newIndependentId = newOwner.openIndependent(runtimePath, false); + expect(oldIndependentId != newIndependentId, "independent IDs must remain unique across owners"); + expectThrows([&]() { newOwner.get(oldIndependentId); }, "an owner must not resolve another owner's independent ID"); + expectThrows([&]() { newOwner.drop("runtime.sqlite", runtimePath, std::nullopt); }, + "drop must reject a file still open in another owner with the same key"); + execute(oldConnection, "BEGIN; UPDATE session SET value = 0"); + oldOwner.reset(); + expect(oldConnection->database == nullptr && oldIndependent->database == nullptr, + "owner destruction must close default and independent handles retained by pending work"); + expect(scalar(newConnection, "SELECT value FROM session") == 42, + "owner destruction must roll back unfinished work and preserve the newer handle and WAL session"); + newOwner.close(newIndependentId); + newOwner.close("runtime.sqlite"); + + // File migration and deletion must still see other owners' attached databases. + auto attachedOwner = std::make_unique(); + const auto mainPath = root / "runtime-main.sqlite"; + attachedOwner->open("runtime-main.sqlite", mainPath, false); + execute(attachedOwner->get("runtime-main.sqlite"), "ATTACH DATABASE '" + runtimePath.string() + "' AS attached"); + expect(newOwner.findLivePath(runtimePath, root / "new-location.sqlite") == runtimePath, + "migration must reuse a database attached in another owner"); + expectThrows([&]() { newOwner.drop("runtime.sqlite", runtimePath, std::nullopt); }, + "drop must reject a database attached in another owner"); + attachedOwner.reset(); + + for (int handoff = 0; handoff < 100; ++handoff) { + DatabaseConnections recreatedOwner; + recreatedOwner.open("runtime.sqlite", runtimePath, false); + expect(scalar(recreatedOwner.get("runtime.sqlite"), "SELECT value FROM session") == 42, + "repeated owner destruction and recreation must preserve session data"); + } + newOwner.drop("runtime.sqlite", runtimePath, std::nullopt); + expect(!fs::exists(runtimePath), "expired observers must not prevent deletion"); + DatabaseConnections registry; #ifndef SQLITE_ENABLE_SEE const auto encryptedPath = root / "encrypted.sqlite"; @@ -259,7 +310,7 @@ int main() { "independent database paths containing NUL must be rejected"); registry.closeAll(); fs::remove_all(root); - std::cout << "[PASS] independent connections, lifecycle, attachments, read-only and concurrent writes\n"; + std::cout << "[PASS] runtime ownership, independent connections, lifecycle, attachments, read-only and concurrent writes\n"; return 0; } catch (const std::exception& error) { fs::remove_all(root);