From f41a807ab3c0dcacd23a13abfed15371d3d2e570 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sat, 10 Oct 2026 22:42:53 -0600 Subject: [PATCH] multiple fixes that could cause DB crashes after initial install --- BUILDING.md | 6 +- android/app/build.gradle | 7 +- android/app/src/main/AndroidManifest.xml | 2 + .../app/src/main/assets/capacitor.config.json | 4 +- android/app/src/main/res/xml/backup_rules.xml | 13 ++ .../main/res/xml/data_extraction_rules.xml | 20 +++ capacitor.config.json | 2 +- capacitor.config.ts | 6 +- .../PLAN-startup-db-failure-recovery.md | 129 ++++++++++++++++++ doc/CHANGES/history/fix-startup-db-crash.md | 124 +++++++++++++++++ .../CODE_QUALITY_DEEP_ANALYSIS.md | 0 electron/capacitor.config.json | 2 +- electron/capacitor.config.ts | 5 +- ios/App/App.xcodeproj/project.pbxproj | 16 +-- src/db-sql/migration.ts | 4 +- src/services/migrationService.ts | 23 ++-- .../platforms/CapacitorPlatformService.ts | 6 +- src/test/capacitorConfig.test.ts | 37 +++++ src/test/migrations.test.ts | 44 +++++- 19 files changed, 414 insertions(+), 36 deletions(-) create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 doc/CHANGES/PLAN-startup-db-failure-recovery.md create mode 100644 doc/CHANGES/history/fix-startup-db-crash.md rename CODE_QUALITY_DEEP_ANALYSIS.md => doc/CODE_QUALITY_DEEP_ANALYSIS.md (100%) create mode 100644 src/test/capacitorConfig.test.ts diff --git a/BUILDING.md b/BUILDING.md index 48e9bb6b..950863bb 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -1146,7 +1146,7 @@ export GEM_PATH=$shortened_path ##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here: ```bash -cd ios/App && xcrun agvtool new-version 72 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.6;/g" App.xcodeproj/project.pbxproj && cd - +cd ios/App && xcrun agvtool new-version 74 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.8;/g" App.xcodeproj/project.pbxproj && cd - # Unfortunately this edits Info.plist directly. #xcrun agvtool new-marketing-version 0.4.5 ``` @@ -1434,8 +1434,8 @@ The recommended way to build for Android is using the automated build script: ##### 1. Bump the version in package.json, then update these versions & run: ```bash -perl -p -i -e 's/versionCode .*/versionCode 73/g' android/app/build.gradle -perl -p -i -e 's/versionName .*/versionName "1.4.7"/g' android/app/build.gradle +perl -p -i -e 's/versionCode .*/versionCode 74/g' android/app/build.gradle +perl -p -i -e 's/versionName .*/versionName "1.4.8"/g' android/app/build.gradle ``` ##### 2. Build diff --git a/android/app/build.gradle b/android/app/build.gradle index 37d0a37e..8af5d74e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -29,6 +29,8 @@ android { compileSdk rootProject.ext.compileSdkVersion compileOptions { + // Backports java.time (API 26+) to older Android; the daily notification plugin uses it. + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_21 } @@ -37,8 +39,8 @@ android { applicationId "app.timesafari.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 73 - versionName "1.4.7" + versionCode 74 + versionName "1.4.8" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -101,6 +103,7 @@ repositories { } dependencies { + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" implementation fileTree(include: ['*.jar'], dir: 'libs') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 69b09a54..fea4baa4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..a953a14f --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/capacitor.config.json b/capacitor.config.json index 915bbc26..3ef0fd53 100644 --- a/capacitor.config.json +++ b/capacitor.config.json @@ -30,7 +30,7 @@ "splashImmersive": true }, "CapacitorSQLite": { - "iosDatabaseLocation": "Library/CapacitorDatabase", + "iosDatabaseLocation": "Documents", "iosIsEncryption": false, "iosBiometric": { "biometricAuth": false, diff --git a/capacitor.config.ts b/capacitor.config.ts index adbf52e2..5c1d5830 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -34,8 +34,10 @@ const config: CapacitorConfig = { splashFullScreen: true, splashImmersive: true }, - CapSQLite: { - iosDatabaseLocation: 'Library/CapacitorDatabase', + // The key must match the plugin's registered name or every setting here is ignored. + CapacitorSQLite: { + // Installed iOS apps keep their database in Documents; changing this strands existing data. + iosDatabaseLocation: 'Documents', iosIsEncryption: false, iosBiometric: { biometricAuth: false, diff --git a/doc/CHANGES/PLAN-startup-db-failure-recovery.md b/doc/CHANGES/PLAN-startup-db-failure-recovery.md new file mode 100644 index 00000000..5c952b75 --- /dev/null +++ b/doc/CHANGES/PLAN-startup-db-failure-recovery.md @@ -0,0 +1,129 @@ +# Plan: Startup Database Failure — Diagnostics and Degraded Mode + +## Goal + +When the local database cannot be opened, a user should (1) get a useful +report to the developers with one tap, and (2) keep using whatever +parts of the app do not need local storage, with a clear path to recovery. + +Requirements: + +1. A failure report reaches the developers without the user composing an email, + and it carries enough to identify the cause on the first report. +2. The report is sent only with the user's consent and holds no keys, seed + phrases, or contact data. +3. A user whose database is down can browse public content (feed, projects, + claims) instead of being held on the recovery screen. +4. Every action that needs the database or an identity is visibly disabled with + the real reason, and points to the recovery screen. +5. Recovery steps never delete the database without keeping a copy. + +## Startup failure behavior + +- `CapacitorPlatformService._initialize()` + (`src/services/platforms/CapacitorPlatformService.ts`) opens the database in + three steps: "create connection", "open database", "run migrations". A + failure throws `Failed to initialize database (step: ): `. +- `src/utils/startupError.ts` recognizes that error by its message and hands it + to `StartView` through `sessionStorage`. +- Three places catch it and redirect to `/start`: the router `errorHandler` + and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue + global error handler (`src/main.common.ts`). +- The guard calls `retrieveAccountDids()` for every route except the + identity-setup routes, so with the database down every route except + `/account`, `/test` and `/logs` lands on `/start`. +- `StartView` shows the recovery screen: Try Again, Email/Copy Details, + "Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over" + (`resetDatabase()`, which copies the file aside before deleting it). +- API reads work without an identity: `getHeaders()` + (`src/libs/endorserServer.ts:430`) omits the Authorization header when there + is no DID. +- Every path that creates, saves, or signs with an identity reads the `secret` + and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`, + `retrieveFullyDecryptedAccount`), so no action that needs a DID can work while + the database is down. + +## Part A: One-tap diagnostics report + +Contents of the report: + +- the startup error message (step and cause) and stack +- app version, platform, OS version, device model +- Android: WebView version and installer package (`getInstallerPackageName`) +- whether a database file existed before the first connection attempt of this + process, which shows whether Android restored a backup at install time +- whether the SQLite plugin loaded (a plugin load failure surfaces as a + `CapacitorSQLitePlugin:` prefix on the cause) +- the `migrations` table contents, when the database opens far enough to read it + +Delivery: a POST to an endpoint on a server the project runs. The recovery +screen shows the exact payload and a Send button; the email option stays for +users who decline. + +The database-existed flag needs a native-side or `Filesystem.stat` check before +`createConnection`; `databaseDirectoryUri()` already resolves the directory on +Android and iOS. + +## Part B: Degraded mode + +- Change the router guard so that a database startup error records the error + and lets read-only routes through: home feed, projects, project and claim + views, and discovery. Identity-setup routes and anything that writes stay + redirected to `/start`. +- Show a persistent banner on every page while in degraded mode: "Your + device's storage isn't working. You can look around, but you can't act yet. + Fix it →", linking to the recovery screen. +- Disable buttons that need a DID or the database (give, offer, create project, + confirm, invite, contacts) with that same reason, instead of the + "someone must register you" text an unregistered user sees. +- Default `apiServer` in every settings fallback. `$accountSettings()` + (`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or + the caller's `defaults` (often `{}`) on its early-return and error paths, so + `apiServer` can be undefined; `HomeView` works around this on its own. +- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a + Copy → Start Over. + +## Part C (optional): An identity that does not need SQLite + +Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`). +In degraded mode the app could generate an identity, require the user to write +down the seed phrase, and keep the mnemonic in `@capacitor/preferences` +(installed, and independent of SQLite). The user would then have a DID someone +can register. When the database opens again, the app imports the identity from +Preferences and deletes the Preferences copy. + +Trade-off: the mnemonic sits unencrypted in Preferences, about the same +protection as the database's secret stored next to the data it encrypts. +Worth building only if startup failures keep being reported with the plugin's +encryption setting (`androidIsEncryption: false`) in effect. + +## Open questions + +- Which server and endpoint receive diagnostics, and how long reports are kept. +- Whether to quarantine a corrupt database automatically ("file is not a + database", "malformed") and start fresh. Automatic quarantine trades a + visible failure for a silent switch to a new identity, and some "malformed" + databases are recoverable with `REINDEX` or `.recover`; the manual Start Over + button covers this case. +- Whether Android backup should include `databases/`. Including it restores a + user's identity on a new phone; it also gives the Google backup a copy of the + keys together with the secret that decrypts them. The backup rules + (`android/app/src/main/res/xml/backup_rules.xml`, + `data_extraction_rules.xml`) include it. + +## Rejected + +- An IndexedDB (absurd-sql) database as a fallback inside the Capacitor + WebView: untested there, and two stores that both hold keys risk losing an + identity when they disagree. + +## Checklist + +- [ ] A1. Decide the diagnostics endpoint and payload retention. +- [ ] A2. Capture "database file existed before first connection" during startup. +- [ ] A3. Build the report payload and the Send Diagnostics button with preview. +- [ ] B1. Let read-only routes through the router guard on a database startup error. +- [ ] B2. Degraded-mode banner and disabled-action reasons. +- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks. +- [ ] B4. Reorder the recovery screen. +- [ ] C1. (Optional) Preferences-backed identity with import on recovery. diff --git a/doc/CHANGES/history/fix-startup-db-crash.md b/doc/CHANGES/history/fix-startup-db-crash.md new file mode 100644 index 00000000..80001d20 --- /dev/null +++ b/doc/CHANGES/history/fix-startup-db-crash.md @@ -0,0 +1,124 @@ +# Fix: Startup Database Error on Android + +## Symptom + +On some Android installs from the Play Store, the app showed a "Startup +Error" on every launch: + +``` +[CapacitorPlatformService] Failed to initialize database +``` + +Reinstalling did not help. The failure never appeared on developer devices +installed through adb or Android Studio. Full symptom report: +`startup-db-error-symptoms.md`. + +## Causes + +### 1. The SQLite plugin ignored its configuration (main cause) + +`capacitor.config.ts` listed the SQLite settings under the key `CapSQLite`. +The plugin registers as `CapacitorSQLite` and reads only that key, so every +setting under `CapSQLite` was ignored and the plugin defaults applied: + +- **Android:** `isEncryption` defaults to true, so on every launch the plugin + created an Android Keystore master key and opened an + `EncryptedSharedPreferences` file (`sqlite_encrypted_shared_prefs`) to hold a + database passphrase. If that step throws, the plugin fails to load and every + `createConnection` call is rejected. +- **iOS:** the database location defaults to `Documents`, not the configured + `Library/CapacitorDatabase`. +- **Electron:** the plugin reads `plugins.CapacitorSQLite` directly, so a + bundled config with the wrong key would throw at plugin startup. + +The Keystore step fails on Android in two ways: + +- **Restore without the key.** Android Auto Backup and device-to-device + transfer restore the encrypted preferences file, but Keystore keys never + leave the device. The restored file can't be decrypted, so the plugin fails on + every launch, and a reinstall from Play restores the same file again. +- **Device Keystore bugs.** Some manufacturers' Keystore implementations fail + without any restore. + +The app never used plugin encryption: it opens the database with +`encrypted = false` and mode `"no-encryption"`, and never stores a passphrase. +The Keystore setup protected nothing. + +### 2. A migration could be applied but not recorded + +The migration runner ran each migration's SQL in one transaction and recorded +its name in the `migrations` table in a second one. If the app died between the +two, the migration ran again on the next launch. Migration 001's +`INSERT INTO secret (id, ...) VALUES (1, ...)` then failed with +`UNIQUE constraint failed: secret.id` on every launch. The window is +milliseconds, but once hit the failure is permanent, and Auto Backup carries it +to a reinstall. + +## Changes + +| Area | Change | +|---|---| +| `capacitor.config.ts`, `electron/capacitor.config.ts`, `android/app/src/main/assets/capacitor.config.json` | Key renamed to `CapacitorSQLite`, so `androidIsEncryption: false` takes effect and the Keystore step never runs. `iosDatabaseLocation` set to `Documents`, the location every iOS install uses. | +| `capacitor.config.json`, `electron/capacitor.config.json` | `iosDatabaseLocation` set to `Documents` to match. These files already used the correct key. | +| `src/services/platforms/CapacitorPlatformService.ts` | `databaseDirectoryUri()` points at `Documents` on iOS, so "Save a Copy of My Data" and "Start Over" find the real database file. | +| `src/services/migrationService.ts` | Each migration's `INSERT INTO migrations` is appended to its own statement batch, which the native plugin runs as one transaction. | +| `src/db-sql/migration.ts` | Migration 001 uses `INSERT OR IGNORE` for the `secret` and `settings` rows, so a re-run never replaces the existing secret. | +| `android/app/src/main/AndroidManifest.xml`, `res/xml/backup_rules.xml`, `res/xml/data_extraction_rules.xml` | Cloud backup and device transfer exclude the plugin's encrypted preference files. Everything else is backed up, including the database. | +| `src/test/migrations.test.ts` | Tests for a crash right after a migration commits, and for a re-run of 001 keeping the secret. | +| `src/test/capacitorConfig.test.ts` | Fails if the plugin config key or the iOS location is wrong. | + +## Security + +Encryption of user keys is unchanged. The identity and seed phrase are encrypted +by the app (`simpleEncrypt` with the random secret from migration 001) and stored +in `accounts`. The plugin's encryption flag only controlled whole-database +SQLCipher encryption, which the app never used; the database file was plain +SQLite before and after this change. App sandboxing keeps the file private to +the app on both platforms. + +## Compatibility with existing installs + +- **Android:** the database has no password whether plugin encryption is on or + off, so it opens the same way. The leftover encrypted preferences file is + never read again. +- **iOS:** the database stays in `Documents`. Setting the location back to + `Library/CapacitorDatabase` would not be safe: the plugin moves databases into + a custom location only when that folder does not exist yet, and installs from + between May and August 2025 have that folder with an outdated database in it. +- **Migrations:** existing installs already have 001 recorded, so its edit + never runs for them. The recording change affects only migrations that have + not run. + +## Verification + +- **Reproduced before the fix** on an Android emulator: run the app once, copy + `shared_prefs` out with `run-as app.timesafari.app`, uninstall (which deletes + the Keystore key), reinstall, put the copied `shared_prefs` back, and launch. + The startup error appeared. +- **Unit tests:** `pkgx npm run test:unit -- src/test/migrations.test.ts + src/test/capacitorConfig.test.ts`. The two migration tests fail on the + earlier code and pass on the fixed code. +- **Android resources:** `./gradlew :app:processDebugResources` builds, and + the merged manifest carries both backup-rule attributes. +- **After the fix:** run the same emulator steps on a fixed build, and also + restore `shared_prefs` captured from a pre-fix build. The app should start + normally either way. + +## Follow-up + +The one-tap diagnostics report and a degraded mode for users without a working +database are planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`. + +## History + +- 2025-05-27: `capacitor.config.json` set `iosDatabaseLocation` to + `Library/CapacitorDatabase` under the correct `CapacitorSQLite` key + (commit 7b4f9d65). +- 2025-08-14: the config moved to `capacitor.config.ts` under the key + `CapSQLite`, which the plugin does not read (commit a2840675). Plugin + encryption setup ran on Android from then on, and iOS databases moved to the + default `Documents` location. +- 2026-10-10: commit d6ccf0e4 added the failed step and underlying cause to the + startup error, made migration 008 tolerate duplicate contact DIDs and labels + pointing at deleted contacts, and added the recovery screen. +- 2026-10-10: this fix (config key, atomic migration records, backup rules). diff --git a/CODE_QUALITY_DEEP_ANALYSIS.md b/doc/CODE_QUALITY_DEEP_ANALYSIS.md similarity index 100% rename from CODE_QUALITY_DEEP_ANALYSIS.md rename to doc/CODE_QUALITY_DEEP_ANALYSIS.md diff --git a/electron/capacitor.config.json b/electron/capacitor.config.json index 059bceac..40b27b55 100644 --- a/electron/capacitor.config.json +++ b/electron/capacitor.config.json @@ -30,7 +30,7 @@ "splashImmersive": true }, "CapacitorSQLite": { - "iosDatabaseLocation": "Library/CapacitorDatabase", + "iosDatabaseLocation": "Documents", "iosIsEncryption": false, "iosBiometric": { "biometricAuth": false, diff --git a/electron/capacitor.config.ts b/electron/capacitor.config.ts index 24ef38c6..72d6db66 100644 --- a/electron/capacitor.config.ts +++ b/electron/capacitor.config.ts @@ -31,8 +31,9 @@ const config: CapacitorConfig = { splashFullScreen: true, splashImmersive: true }, - CapSQLite: { - iosDatabaseLocation: 'Library/CapacitorDatabase', + // The key must match the plugin's registered name or every setting here is ignored. + CapacitorSQLite: { + iosDatabaseLocation: 'Documents', iosIsEncryption: false, iosBiometric: { biometricAuth: false, diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index cccb52cf..c74a486a 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = GM3FS5JQPH; ENABLE_APP_SANDBOX = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -552,7 +552,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.6; + MARKETING_VERSION = 1.4.8; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = GM3FS5JQPH; ENABLE_APP_SANDBOX = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -582,7 +582,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.6; + MARKETING_VERSION = 1.4.8; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -600,7 +600,7 @@ CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = GM3FS5JQPH; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -614,7 +614,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.4.6; + MARKETING_VERSION = 1.4.8; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension; @@ -638,7 +638,7 @@ CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = GM3FS5JQPH; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -652,7 +652,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.4.6; + MARKETING_VERSION = 1.4.8; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index b4649e32..d03051ac 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -108,7 +108,7 @@ const MIGRATIONS = [ secretBase64 TEXT NOT NULL ); - INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}'); + INSERT OR IGNORE INTO secret (id, secretBase64) VALUES (1, '${secretBase64}'); CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -144,7 +144,7 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid); - INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}'); + INSERT OR IGNORE INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}'); CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/services/migrationService.ts b/src/services/migrationService.ts index 1e0a1ba8..045c4c1b 100644 --- a/src/services/migrationService.ts +++ b/src/services/migrationService.ts @@ -153,6 +153,17 @@ class MigrationRegistry { // Create a singleton instance of the migration registry const migrationRegistry = new MigrationRegistry(); +/** The migration's SQL followed by the statement that records it as applied. */ +function withMigrationRecord(migration: Migration): string { + // A trailing "--" comment ends at its newline, so check what's left without comments. + const terminated = migration.sql + .replace(/--[^\n]*/g, "") + .trim() + .endsWith(";"); + const name = migration.name.replace(/'/g, "''"); + return `${migration.sql}\n${terminated ? "" : ";\n"}INSERT INTO migrations (name) VALUES ('${name}');\n`; +} + /** * Register a migration with the migration service * @@ -282,19 +293,15 @@ export async function runMigrations( logger.debug(`[Migration] Executing SQL for: ${migration.name}`); logger.debug(`[Migration] SQL content: ${migration.sql}`); - // Execute the migration SQL directly - it should be atomic - // The SQL itself should handle any necessary transactions - const execResult = await sqlExec(migration.sql); + // The record goes in the same batch as the migration, which the native + // plugin runs as one transaction: if the app dies between the two, a + // migration that can't run twice (like 001) would fail every launch. + const execResult = await sqlExec(withMigrationRecord(migration)); logger.debug( `[Migration] SQL execution result: ${JSON.stringify(execResult)}`, ); - // Record that the migration was applied - await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ - migration.name, - ]); - logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`); appliedCount++; } catch (error) { diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index ab0cb56d..e3d0af90 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -462,10 +462,10 @@ export class CapacitorPlatformService return uri.replace(/\/files\/?$/, "/databases"); } if (platform === "ios") { - // Matches iosDatabaseLocation "Library/CapacitorDatabase" in capacitor.config + // Matches iosDatabaseLocation "Documents" in capacitor.config const { uri } = await Filesystem.getUri({ - path: "CapacitorDatabase", - directory: Directory.Library, + path: "", + directory: Directory.Documents, }); return uri.replace(/\/$/, ""); } diff --git a/src/test/capacitorConfig.test.ts b/src/test/capacitorConfig.test.ts new file mode 100644 index 00000000..cf452006 --- /dev/null +++ b/src/test/capacitorConfig.test.ts @@ -0,0 +1,37 @@ +/** + * The SQLite plugin reads its settings only under its registered name, + * "CapacitorSQLite". Under any other key the settings are silently ignored and + * the plugin defaults apply, which on Android means Keystore-backed encryption + * setup that can fail on every launch. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; + +const repo = join(__dirname, "..", ".."); + +function read(relativePath: string): string { + return readFileSync(join(repo, relativePath), "utf8"); +} + +describe("SQLite plugin config", () => { + it.each(["capacitor.config.ts", "electron/capacitor.config.ts"])( + "%s uses the plugin's registered name", + (file) => { + const source = read(file); + expect(source).toMatch(/^\s*CapacitorSQLite:\s*\{/m); + expect(source).toMatch(/androidIsEncryption:\s*false/); + expect(source).toMatch(/iosDatabaseLocation:\s*'Documents'/); + }, + ); + + it("the synced Android config uses the plugin's registered name", () => { + const config = JSON.parse( + read("android/app/src/main/assets/capacitor.config.json"), + ); + expect(config.plugins.CapacitorSQLite.androidIsEncryption).toBe(false); + expect(config.plugins.CapacitorSQLite.iosDatabaseLocation).toBe( + "Documents", + ); + }); +}); diff --git a/src/test/migrations.test.ts b/src/test/migrations.test.ts index 736a9bd5..288b5178 100644 --- a/src/test/migrations.test.ts +++ b/src/test/migrations.test.ts @@ -40,8 +40,12 @@ async function newDatabase(): Promise { return db; } -async function migrate(db: SqlJsDatabase): Promise { - const sqlExec = async (sql: string, params?: unknown[]): Promise => { +/** @param afterExec runs after each successful sqlExec, e.g. to simulate the app dying */ +async function migrate( + db: SqlJsDatabase, + afterExec?: (sql: string) => void, +): Promise { + const rawExec = async (sql: string, params?: unknown[]): Promise => { if (params && params.length > 0) { db.run(sql, params); return; @@ -65,6 +69,10 @@ async function migrate(db: SqlJsDatabase): Promise { db.run(sql); } }; + const sqlExec = async (sql: string, params?: unknown[]): Promise => { + await rawExec(sql, params); + afterExec?.(sql); + }; const sqlQuery = async ( sql: string, params?: unknown[], @@ -99,6 +107,38 @@ describe("migrations", () => { ).toHaveLength(1); }); + it("survives the app dying right after a migration commits", async () => { + const db = await newDatabase(); + await expect( + migrate(db, (sql) => { + if (sql.includes("CREATE TABLE IF NOT EXISTS accounts")) { + throw new Error("app killed"); + } + }), + ).rejects.toThrow("app killed"); + + await migrate(db); + + const clean = await newDatabase(); + await migrate(clean); + const names = "SELECT name FROM migrations ORDER BY name"; + expect(rows(db, names)).toEqual(rows(clean, names)); + }); + + it("re-running 001 keeps the existing secret", async () => { + const db = await newDatabase(); + await migrate(db); + const secret = rows(db, "SELECT secretBase64 FROM secret"); + db.run("DELETE FROM migrations WHERE name = '001_initial'"); + + await migrate(db); + + expect(rows(db, "SELECT secretBase64 FROM secret")).toEqual(secret); + expect( + rows(db, "SELECT name FROM migrations WHERE name = '001_initial'"), + ).toHaveLength(1); + }); + it("008 keeps the newest contact when a DID was saved twice", async () => { const db = await newDatabase(); await migrate(db);