diff --git a/.github/workflows/test-cpp.yml b/.github/workflows/test-cpp.yml new file mode 100644 index 00000000..fded1fce --- /dev/null +++ b/.github/workflows/test-cpp.yml @@ -0,0 +1,54 @@ +name: Test C++ + +on: + push: + branches: + - main + paths: + - ".github/workflows/test-cpp.yml" + - "packages/react-native-nitro-sqlite/cpp/databaseMigration.*" + - "packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp" + - "packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.*" + - "packages/react-native-nitro-sqlite/tests/cpp/**" + pull_request: + paths: + - ".github/workflows/test-cpp.yml" + - "packages/react-native-nitro-sqlite/cpp/databaseMigration.*" + - "packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp" + - "packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.*" + - "packages/react-native-nitro-sqlite/tests/cpp/**" + +jobs: + test: + name: Database migration tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + + - name: Build bundled SQLite + run: | + clang \ + -std=c11 \ + -DSQLITE_THREADSAFE=2 \ + -c packages/react-native-nitro-sqlite/cpp/sqlite/sqlite3.c \ + -o /tmp/sqlite3.o + + - name: Build migration tests + run: | + clang++ \ + -std=c++20 \ + -Wall \ + -Wextra \ + -Werror \ + -Ipackages/react-native-nitro-sqlite/cpp \ + -Ipackages/react-native-nitro-sqlite/cpp/sqlite \ + packages/react-native-nitro-sqlite/cpp/databaseMigration.cpp \ + packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp \ + /tmp/sqlite3.o \ + -ldl \ + -lm \ + -pthread \ + -o /tmp/databaseMigrationTests + + - name: Run migration tests + run: /tmp/databaseMigrationTests diff --git a/README.md b/README.md index b5c7e208..51528ae4 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,23 @@ nitroSqliteFlags="-DSQLITE_ENABLE_FTS5=1" To put the database in an app group (e.g. for extensions), set `RNNitroSQLite_AppGroup` in your `Info.plist` to the app group ID and add the App Groups capability in Xcode. +## Database location (iOS) + +By default, databases are stored in the app's **Documents** directory. If your app enables file sharing (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`), that directory — including your raw database and its `-wal`/`-shm` journal files — becomes visible to users in the Files app, where they can be shared, modified, or deleted from outside your app. + +To store databases in `Library/Application Support` instead (persistent, backed up, and never user-visible), set `RNNitroSQLite_DatabaseLocation` in your `Info.plist`: + +```xml +RNNitroSQLite_DatabaseLocation +ApplicationSupport +``` + +Supported values are `Documents` (the default) and `ApplicationSupport`. + +Databases created while the app was still using the Documents directory are automatically moved to `Library/Application Support` the first time they are opened or attached after enabling this option, so existing users keep their data. Deleting a database also removes any copy left in Documents by an interrupted migration. If you later remove the option, databases already moved to `Library/Application Support` are **not** moved back. + +This option has no effect when `RNNitroSQLite_AppGroup` is set, since app group databases live in the shared container. + --- # Exports diff --git a/packages/react-native-nitro-sqlite/cpp/databaseMigration.cpp b/packages/react-native-nitro-sqlite/cpp/databaseMigration.cpp new file mode 100644 index 00000000..1919d8d1 --- /dev/null +++ b/packages/react-native-nitro-sqlite/cpp/databaseMigration.cpp @@ -0,0 +1,123 @@ +#include "databaseMigration.hpp" +#include "logs.hpp" +#include +#include + +namespace margelo::nitro::rnnitrosqlite { + +namespace fs = std::filesystem; + +namespace { + + constexpr std::size_t kDatabaseFileCount = 4; + using DatabaseFiles = std::array; + + DatabaseFiles getDatabaseFiles(const std::string& dbName); + bool copyDatabaseFiles(const DatabaseFiles& files, const fs::path& fromDirectory, const fs::path& toDirectory); + void removeAuxiliaryDatabaseFiles(const DatabaseFiles& files, const fs::path& directory); + +} // namespace + +fs::path migrateDatabase(const std::string& dbName, const fs::path& fromDirectory, const fs::path& toDirectory) { + const auto files = getDatabaseFiles(dbName); + std::error_code ec; + const bool sourceExists = fs::exists(fromDirectory / dbName, ec); + + if (ec) { + LOGW("Failed to inspect database %s in its old location: %s", dbName.c_str(), ec.message().c_str()); + return fromDirectory; + } + + if (!sourceExists) { + // A completed migration may have been interrupted after deleting the database but before + // deleting its journals. The destination is already authoritative in that state. + removeAuxiliaryDatabaseFiles(files, fromDirectory); + return toDirectory; + } + + // A database in the old directory is the live copy. Clear every database generation file at + // the destination before copying so SQLite never pairs the source with a stale journal. + if (!removeDatabaseFiles(dbName, toDirectory)) { + return fromDirectory; + } + + fs::create_directories(toDirectory, ec); + if (ec) { + LOGW("Failed to create database migration directory %s: %s", toDirectory.string().c_str(), ec.message().c_str()); + return fromDirectory; + } + + if (!copyDatabaseFiles(files, fromDirectory, toDirectory)) { + return fromDirectory; + } + + // Delete the database first. If this fails, every source journal must remain beside it so the + // caller can safely keep using the old location. Leftover journals after a successful database + // deletion are harmless and are removed on the next migration attempt. + if (!fs::remove(fromDirectory / dbName, ec) || ec) { + LOGW("Failed to remove migrated database %s from its old location: %s", dbName.c_str(), ec.message().c_str()); + return fromDirectory; + } + + removeAuxiliaryDatabaseFiles(files, fromDirectory); + return toDirectory; +} + +bool removeDatabaseFiles(const std::string& dbName, const fs::path& directory) { + const auto files = getDatabaseFiles(dbName); + + for (const auto& file : files) { + std::error_code ec; + fs::remove(directory / file, ec); + if (ec) { + LOGW("Failed to remove database file %s: %s", file.c_str(), ec.message().c_str()); + return false; + } + } + + return true; +} + +namespace { + + DatabaseFiles getDatabaseFiles(const std::string& dbName) { + return {dbName, dbName + "-journal", dbName + "-wal", dbName + "-shm"}; + } + + bool copyDatabaseFiles(const DatabaseFiles& files, const fs::path& fromDirectory, const fs::path& toDirectory) { + for (const auto& file : files) { + std::error_code ec; + const bool sourceExists = fs::exists(fromDirectory / file, ec); + + if (ec) { + LOGW("Failed to inspect database file %s: %s", file.c_str(), ec.message().c_str()); + return false; + } + + if (!sourceExists) { + continue; + } + + if (!fs::copy_file(fromDirectory / file, toDirectory / file, ec) || ec) { + LOGW("Failed to migrate database file %s: %s", file.c_str(), ec.message().c_str()); + return false; + } + } + + return true; + } + + void removeAuxiliaryDatabaseFiles(const DatabaseFiles& files, const fs::path& directory) { + for (std::size_t index = 1; index < files.size(); index++) { + const auto& file = files[index]; + std::error_code ec; + fs::remove(directory / file, ec); + if (ec) { + LOGW("Failed to remove database file %s: %s", file.c_str(), ec.message().c_str()); + } + } + } + +} // namespace + +} // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/cpp/databaseMigration.hpp b/packages/react-native-nitro-sqlite/cpp/databaseMigration.hpp new file mode 100644 index 00000000..a9668ac6 --- /dev/null +++ b/packages/react-native-nitro-sqlite/cpp/databaseMigration.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace margelo::nitro::rnnitrosqlite { + +std::filesystem::path migrateDatabase(const std::string& dbName, const std::filesystem::path& fromDirectory, + const std::filesystem::path& toDirectory); + +bool removeDatabaseFiles(const std::string& dbName, const std::filesystem::path& directory); + +} // 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 74983e0a..94c576dd 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -1,12 +1,14 @@ #include "HybridNitroSQLite.hpp" #include "HybridNitroSQLiteQueryResult.hpp" #include "NitroSQLiteException.hpp" +#include "databaseMigration.hpp" #include "importSqlFile.hpp" #include "logs.hpp" #include "macros.hpp" #include "operations.hpp" #include "sqliteExecuteBatch.hpp" #include +#include #include #include #include @@ -66,8 +68,26 @@ const std::string getDocPath(const std::optional& location) { return tempDocPath; } +const std::string getOldDocPath(const std::optional& location) { + std::string oldDocPath = HybridNitroSQLite::migrationDocPath; + if (location) { + oldDocPath = oldDocPath + "/" + *location; + } + + return oldDocPath; +} + +const std::string getMigratedDocPath(const std::string& dbName, const std::optional& location) { + const auto currentDocPath = getDocPath(location); + if (HybridNitroSQLite::migrationDocPath.empty()) { + return currentDocPath; + } + + return migrateDatabase(dbName, getOldDocPath(location), currentDocPath).string(); +} + void HybridNitroSQLite::open(const std::string& dbName, const std::optional& location) { - const auto docPath = getDocPath(location); + const auto docPath = getMigratedDocPath(dbName, location); sqliteOpenDb(dbName, docPath); } @@ -76,18 +96,28 @@ void HybridNitroSQLite::close(const std::string& dbName) { }; void HybridNitroSQLite::drop(const std::string& dbName, const std::optional& location) { - const auto docPath = getDocPath(location); - sqliteRemoveDb(dbName, docPath); + const auto currentDocPath = getDocPath(location); + if (migrationDocPath.empty()) { + sqliteRemoveDb(dbName, currentDocPath); + return; + } + + const auto oldDocPath = getOldDocPath(location); + std::error_code ec; + const bool oldDatabaseExists = std::filesystem::exists(std::filesystem::path(oldDocPath) / dbName, ec); + if (ec) { + LOGW("Failed to inspect database %s in its old location: %s", dbName.c_str(), ec.message().c_str()); + } + + sqliteRemoveDb(dbName, oldDatabaseExists || ec ? oldDocPath : currentDocPath); + removeDatabaseFiles(dbName, oldDocPath); + removeDatabaseFiles(dbName, currentDocPath); }; void HybridNitroSQLite::attach(const std::string& mainDbName, const std::string& dbNameToAttach, const std::string& alias, const std::optional& location) { - std::string tempDocPath = std::string(docPath); - if (location) { - tempDocPath = tempDocPath + "/" + *location; - } - - sqliteAttachDb(mainDbName, tempDocPath, dbNameToAttach, alias); + const auto attachedDocPath = getMigratedDocPath(dbNameToAttach, location); + sqliteAttachDb(mainDbName, attachedDocPath, dbNameToAttach, alias); }; void HybridNitroSQLite::detach(const std::string& mainDbName, const std::string& alias) { diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp index a5331964..0c29298a 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.hpp @@ -14,6 +14,10 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { public: static std::string docPath; + // Directory databases were stored in by previous app versions, when the platform layer has + // relocated docPath (e.g. iOS with RNNitroSQLite_DatabaseLocation set to "ApplicationSupport"). + // When non-empty, databases found there are resolved as they are opened, attached, or dropped. + static std::string migrationDocPath; public: // Methods @@ -43,5 +47,6 @@ class HybridNitroSQLite : public HybridNitroSQLiteSpec { }; inline std::string HybridNitroSQLite::docPath = ""; +inline std::string HybridNitroSQLite::migrationDocPath = ""; } // namespace margelo::nitro::rnnitrosqlite diff --git a/packages/react-native-nitro-sqlite/ios/OnLoad.mm b/packages/react-native-nitro-sqlite/ios/OnLoad.mm index 6ce72584..b1fbb24b 100644 --- a/packages/react-native-nitro-sqlite/ios/OnLoad.mm +++ b/packages/react-native-nitro-sqlite/ios/OnLoad.mm @@ -30,9 +30,32 @@ + (void)load { documentPath = [storeUrl path]; } else { - // Get iOS app's document directory (to safely store database .sqlite3 file) - NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true); - documentPath = [paths objectAtIndex:0]; + NSString *databaseLocation = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"RNNitroSQLite_DatabaseLocation"]; + + if ([databaseLocation isEqualToString:@"ApplicationSupport"]) { + // Library/Application Support is persistent, backed up, and never exposed to the user + // via the Files app (unlike the Documents directory, which becomes user-visible when + // the app enables file sharing). + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true); + documentPath = [paths objectAtIndex:0]; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + if (![fileManager fileExistsAtPath:documentPath]) { + [fileManager createDirectoryAtPath:documentPath withIntermediateDirectories:YES attributes:nil error:nil]; + } + + // Databases created before this option was enabled still live in Documents; each one is + // moved over when it is opened (see HybridNitroSQLite::open). + NSString *documentsDirectory = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true) objectAtIndex:0]; + HybridNitroSQLite::migrationDocPath = [documentsDirectory UTF8String]; + } else { + if (databaseLocation != nil && ![databaseLocation isEqualToString:@"Documents"]) { + NSLog(@"Invalid RNNitroSQLite_DatabaseLocation value provided (%@). Supported values are \"Documents\" and \"ApplicationSupport\". Falling back to \"Documents\".", databaseLocation); + } + // Get iOS app's document directory (to safely store database .sqlite3 file) + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true); + documentPath = [paths objectAtIndex:0]; + } } HybridNitroSQLite::docPath = [documentPath UTF8String]; diff --git a/packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp b/packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp new file mode 100644 index 00000000..f8b717c0 --- /dev/null +++ b/packages/react-native-nitro-sqlite/tests/cpp/databaseMigration.test.cpp @@ -0,0 +1,387 @@ +#include "databaseMigration.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace margelo::nitro::rnnitrosqlite; + +namespace { + +constexpr std::array kDatabaseSuffixes = {"", "-journal", "-wal", "-shm"}; + +void expect(bool condition, const std::string& message); + +class TemporaryDirectory { +public: + TemporaryDirectory() { + const auto uniqueId = std::chrono::steady_clock::now().time_since_epoch().count(); + path = fs::temp_directory_path() / ("nitro-sqlite-migration-" + std::to_string(uniqueId)); + fs::create_directories(path); + } + + ~TemporaryDirectory() { + std::error_code ec; + fs::remove_all(path, ec); + } + + fs::path path; +}; + +class SQLiteDatabase { +public: + explicit SQLiteDatabase(const fs::path& path) { + const int result = sqlite3_open(path.string().c_str(), &database); + if (result == SQLITE_OK) { + return; + } + + const std::string message = database == nullptr ? sqlite3_errstr(result) : sqlite3_errmsg(database); + sqlite3_close_v2(database); + database = nullptr; + throw std::runtime_error("failed to open SQLite database: " + message); + } + + ~SQLiteDatabase() { + sqlite3_close_v2(database); + } + + SQLiteDatabase(const SQLiteDatabase&) = delete; + SQLiteDatabase& operator=(const SQLiteDatabase&) = delete; + + void execute(const std::string& sql) { + char* errorMessage = nullptr; + const int result = sqlite3_exec(database, sql.c_str(), nullptr, nullptr, &errorMessage); + if (result == SQLITE_OK) { + return; + } + + const std::string message = errorMessage == nullptr ? sqlite3_errmsg(database) : errorMessage; + sqlite3_free(errorMessage); + throw std::runtime_error("SQLite statement failed: " + message); + } + + std::string queryText(const std::string& sql) { + sqlite3_stmt* statement = nullptr; + int result = sqlite3_prepare_v2(database, sql.c_str(), -1, &statement, nullptr); + if (result != SQLITE_OK) { + throw std::runtime_error("failed to prepare SQLite query: " + std::string(sqlite3_errmsg(database))); + } + + result = sqlite3_step(statement); + if (result != SQLITE_ROW) { + const std::string message = sqlite3_errmsg(database); + sqlite3_finalize(statement); + throw std::runtime_error("SQLite query returned no row: " + message); + } + + const auto* value = reinterpret_cast(sqlite3_column_text(statement, 0)); + const std::string text = value == nullptr ? "" : value; + result = sqlite3_finalize(statement); + if (result != SQLITE_OK) { + throw std::runtime_error("failed to finalize SQLite query: " + std::string(sqlite3_errmsg(database))); + } + + return text; + } + + void flushCache() { + const int result = sqlite3_db_cacheflush(database); + if (result != SQLITE_OK) { + throw std::runtime_error("failed to flush SQLite cache: " + std::string(sqlite3_errmsg(database))); + } + } + + void abandonWithoutClosing() { + database = nullptr; + } + +private: + sqlite3* database = nullptr; +}; + +void migratesDatabaseAndEveryJournalType(); +void removesStaleDestinationJournalsMissingFromSource(); +void fallsBackWithoutChangingSourceFilesWhenDestinationCleanupFails(); +void removesOrphanedSourceJournalsAfterAnInterruptedMigration(); +void removesEveryDatabaseGenerationFile(); +void recoversCommittedWalAfterMigration(); +void rollsBackHotJournalAfterMigration(); +void runWithoutCleanShutdown(const std::function& action); +void writeFile(const fs::path& path, const std::string& contents); +std::string readFile(const fs::path& path); + +} // namespace + +int main() { + struct TestCase { + const char* name; + void (*run)(); + }; + + const TestCase tests[] = { + {"migrates database and every journal type", migratesDatabaseAndEveryJournalType}, + {"removes stale destination journals missing from source", removesStaleDestinationJournalsMissingFromSource}, + {"falls back without changing source files when destination cleanup fails", + fallsBackWithoutChangingSourceFilesWhenDestinationCleanupFails}, + {"removes orphaned source journals after an interrupted migration", removesOrphanedSourceJournalsAfterAnInterruptedMigration}, + {"removes every database generation file", removesEveryDatabaseGenerationFile}, + {"recovers committed WAL content after migration", recoversCommittedWalAfterMigration}, + {"rolls back a hot journal after migration", rollsBackHotJournalAfterMigration}, + }; + + int failures = 0; + for (const auto& test : tests) { + try { + test.run(); + std::cout << "[PASS] " << test.name << '\n'; + } catch (const std::exception& error) { + failures++; + std::cerr << "[FAIL] " << test.name << ": " << error.what() << '\n'; + } + } + + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +namespace { + +void migratesDatabaseAndEveryJournalType() { + TemporaryDirectory temporaryDirectory; + const auto source = temporaryDirectory.path / "Documents"; + const auto destination = temporaryDirectory.path / "Application Support"; + const std::string dbName = "database.sqlite"; + + for (const auto* suffix : kDatabaseSuffixes) { + const std::string fileName = dbName + suffix; + writeFile(source / fileName, "source:" + fileName); + writeFile(destination / fileName, "stale:" + fileName); + } + + const auto resolvedDirectory = migrateDatabase(dbName, source, destination); + + expect(resolvedDirectory == destination, "the destination should be selected after a successful migration"); + for (const auto* suffix : kDatabaseSuffixes) { + const std::string fileName = dbName + suffix; + expect(!fs::exists(source / fileName), "the source file should be removed: " + fileName); + expect(readFile(destination / fileName) == "source:" + fileName, "the source should replace the stale file: " + fileName); + } +} + +void removesStaleDestinationJournalsMissingFromSource() { + TemporaryDirectory temporaryDirectory; + const auto source = temporaryDirectory.path / "Documents"; + const auto destination = temporaryDirectory.path / "Application Support"; + const std::string dbName = "database.sqlite"; + + writeFile(source / dbName, "source database"); + writeFile(destination / dbName, "stale database"); + for (std::size_t index = 1; index < kDatabaseSuffixes.size(); index++) { + writeFile(destination / (dbName + kDatabaseSuffixes[index]), "stale journal"); + } + + const auto resolvedDirectory = migrateDatabase(dbName, source, destination); + + expect(resolvedDirectory == destination, "the destination should be selected after migration"); + expect(readFile(destination / dbName) == "source database", "the source database should replace the stale database"); + for (std::size_t index = 1; index < kDatabaseSuffixes.size(); index++) { + expect(!fs::exists(destination / (dbName + kDatabaseSuffixes[index])), "stale destination journals should be removed"); + } +} + +void fallsBackWithoutChangingSourceFilesWhenDestinationCleanupFails() { + TemporaryDirectory temporaryDirectory; + const auto source = temporaryDirectory.path / "Documents"; + const auto destination = temporaryDirectory.path / "Application Support"; + const std::string dbName = "database.sqlite"; + + for (const auto* suffix : kDatabaseSuffixes) { + const std::string fileName = dbName + suffix; + writeFile(source / fileName, "source:" + fileName); + } + writeFile(destination / dbName / "child", "prevents directory removal"); + + const auto resolvedDirectory = migrateDatabase(dbName, source, destination); + + expect(resolvedDirectory == source, "the source should remain selected when destination cleanup fails"); + for (const auto* suffix : kDatabaseSuffixes) { + const std::string fileName = dbName + suffix; + expect(readFile(source / fileName) == "source:" + fileName, "fallback should preserve the source file: " + fileName); + } +} + +void removesOrphanedSourceJournalsAfterAnInterruptedMigration() { + TemporaryDirectory temporaryDirectory; + const auto source = temporaryDirectory.path / "Documents"; + const auto destination = temporaryDirectory.path / "Application Support"; + const std::string dbName = "database.sqlite"; + + writeFile(destination / dbName, "migrated database"); + for (std::size_t index = 1; index < kDatabaseSuffixes.size(); index++) { + writeFile(source / (dbName + kDatabaseSuffixes[index]), "orphaned journal"); + } + + const auto resolvedDirectory = migrateDatabase(dbName, source, destination); + + expect(resolvedDirectory == destination, "the completed migration should keep using the destination"); + expect(readFile(destination / dbName) == "migrated database", "the migrated database should remain unchanged"); + for (std::size_t index = 1; index < kDatabaseSuffixes.size(); index++) { + expect(!fs::exists(source / (dbName + kDatabaseSuffixes[index])), "orphaned source journals should be removed"); + } +} + +void removesEveryDatabaseGenerationFile() { + TemporaryDirectory temporaryDirectory; + const auto directory = temporaryDirectory.path / "Database"; + const std::string dbName = "database.sqlite"; + + for (const auto* suffix : kDatabaseSuffixes) { + writeFile(directory / (dbName + suffix), "database generation file"); + } + + expect(removeDatabaseFiles(dbName, directory), "database file cleanup should succeed"); + for (const auto* suffix : kDatabaseSuffixes) { + expect(!fs::exists(directory / (dbName + suffix)), "database generation files should be removed"); + } +} + +void recoversCommittedWalAfterMigration() { + TemporaryDirectory temporaryDirectory; + const auto source = temporaryDirectory.path / "Documents"; + const auto destination = temporaryDirectory.path / "Application Support"; + const auto control = temporaryDirectory.path / "without-wal.sqlite"; + const std::string dbName = "wal.sqlite"; + const auto sourceDatabase = source / dbName; + + fs::create_directories(source); + { + SQLiteDatabase database(sourceDatabase); + database.execute("PRAGMA journal_mode=WAL"); + database.execute("CREATE TABLE records (id INTEGER PRIMARY KEY, value TEXT NOT NULL)"); + } + + runWithoutCleanShutdown([&]() { + SQLiteDatabase database(sourceDatabase); + database.execute("PRAGMA wal_autocheckpoint=0"); + database.execute("INSERT INTO records (value) VALUES ('committed in WAL')"); + database.abandonWithoutClosing(); + }); + + expect(fs::exists(sourceDatabase.string() + "-wal"), "the crashed writer should leave a WAL file"); + fs::copy_file(sourceDatabase, control); + { + SQLiteDatabase database(control); + expect(database.queryText("SELECT COUNT(*) FROM records") == "0", "the committed row should exist only in the WAL fixture"); + } + + const auto resolvedDirectory = migrateDatabase(dbName, source, destination); + + expect(resolvedDirectory == destination, "the WAL database should migrate to the destination"); + SQLiteDatabase migratedDatabase(destination / dbName); + expect(migratedDatabase.queryText("SELECT value FROM records WHERE id = 1") == "committed in WAL", + "opening the migrated database should recover committed WAL content"); + expect(migratedDatabase.queryText("PRAGMA integrity_check") == "ok", "the migrated WAL database should pass integrity_check"); +} + +void rollsBackHotJournalAfterMigration() { + TemporaryDirectory temporaryDirectory; + const auto source = temporaryDirectory.path / "Documents"; + const auto destination = temporaryDirectory.path / "Application Support"; + const auto control = temporaryDirectory.path / "without-journal.sqlite"; + const std::string dbName = "rollback.sqlite"; + const auto sourceDatabase = source / dbName; + + fs::create_directories(source); + { + SQLiteDatabase database(sourceDatabase); + database.execute("PRAGMA journal_mode=DELETE"); + database.execute("PRAGMA synchronous=FULL"); + database.execute("CREATE TABLE records (id INTEGER PRIMARY KEY, value TEXT NOT NULL)"); + database.execute("INSERT INTO records (value) VALUES ('committed value')"); + } + + runWithoutCleanShutdown([&]() { + SQLiteDatabase database(sourceDatabase); + database.execute("PRAGMA journal_mode=DELETE"); + database.execute("PRAGMA synchronous=FULL"); + database.execute("BEGIN IMMEDIATE"); + database.execute("UPDATE records SET value = 'uncommitted value' WHERE id = 1"); + database.flushCache(); + database.abandonWithoutClosing(); + }); + + expect(fs::exists(sourceDatabase.string() + "-journal"), "the crashed writer should leave a rollback journal"); + fs::copy_file(sourceDatabase, control); + { + SQLiteDatabase database(control); + expect(database.queryText("SELECT value FROM records WHERE id = 1") == "uncommitted value", + "the database fixture should require its rollback journal"); + } + + const auto resolvedDirectory = migrateDatabase(dbName, source, destination); + + expect(resolvedDirectory == destination, "the rollback-journal database should migrate to the destination"); + SQLiteDatabase migratedDatabase(destination / dbName); + expect(migratedDatabase.queryText("SELECT value FROM records WHERE id = 1") == "committed value", + "opening the migrated database should roll back the interrupted transaction"); + expect(migratedDatabase.queryText("PRAGMA integrity_check") == "ok", + "the migrated rollback-journal database should pass integrity_check"); +} + +void runWithoutCleanShutdown(const std::function& action) { + const pid_t child = fork(); + if (child == -1) { + throw std::runtime_error("failed to fork crash-test child process"); + } + + if (child == 0) { + try { + action(); + _exit(EXIT_SUCCESS); + } catch (const std::exception& error) { + std::fprintf(stderr, "crash-test child failed: %s\n", error.what()); + _exit(EXIT_FAILURE); + } + } + + int status = 0; + pid_t waitResult; + do { + waitResult = waitpid(child, &status, 0); + } while (waitResult == -1 && errno == EINTR); + + expect(waitResult == child, "failed to wait for crash-test child process"); + expect(WIFEXITED(status) && WEXITSTATUS(status) == EXIT_SUCCESS, "crash-test child process failed"); +} + +void writeFile(const fs::path& path, const std::string& contents) { + fs::create_directories(path.parent_path()); + std::ofstream file(path, std::ios::binary); + file << contents; + expect(file.good(), "failed to write test file: " + path.string()); +} + +std::string readFile(const fs::path& path) { + std::ifstream file(path, std::ios::binary); + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; +} + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +} // namespace diff --git a/scripts/clang-format.sh b/scripts/clang-format.sh index 42ef4c94..65151974 100755 --- a/scripts/clang-format.sh +++ b/scripts/clang-format.sh @@ -2,6 +2,7 @@ CPP_DIRS=( "packages/react-native-nitro-sqlite/cpp" + "packages/react-native-nitro-sqlite/tests/cpp" ) if which clang-format >/dev/null; then