Skip to content

Make SQLite setup failures reportable and recoverable on the legacy (non-JSI) bridge - #1981

Open
joker-777 wants to merge 1 commit into
Nozbe:masterfrom
kenhub:fix/sqlite-setup-errors
Open

joker-777 wants to merge 1 commit into
Nozbe:masterfrom
kenhub:fix/sqlite-setup-errors

Conversation

@joker-777

Copy link
Copy Markdown

Fixes two defects in the legacy (non-JSI) SQLite adapter path, i.e. SQLiteAdapter with jsi: false on iOS and Android. The JSI path (native/shared/DatabaseBridge.cpp) is unchanged.

Related upstream issues: #1845 (app crashes at launch instead of reaching onSetUpError), #1609 (adapter unusable after a failed migration).

Defect 1: setup failures crash instead of rejecting (iOS) / are uncaught (Android)

On iOS, WMDatabase, WMDatabaseDriver and WMDatabaseBridge raised NSExceptions inside async RCT_EXPORT_METHODs. React Native's TurboModule interop only converts an NSException to a JS error for synchronous methods; for async ones it re-throws and the process dies. So a database file that cannot be opened crash-loops at launch and the JS onSetUpError callback never runs. Android's setUpWithSchema had no try/catch, unlike initialize and setUpWithMigrations.

Fix: WMDatabase open / setUserVersion and the driver's setUpWithSchema / setUpWithMigrations report through NSError ** (same pattern as the other WMDatabase methods), driverWithName: / databaseWithPath: return nil with an error when the database cannot be opened, and the bridge rejects with the existing db.<method>.error codes. Android's setUpWithSchema is wrapped like setUpWithMigrations.

Defect 2: no driver after a failed migration

Both bridges called disconnectDriver after a failed migration, which removed the driver for the tag. On iOS it also removed the queue, so the operations queued during startup never settled and every later call, including unsafeResetDatabase, hung silently ([nil addObject:] is a no-op). On Android every later call was rejected with No driver with tag N available. The database itself is consistent after a failed migration (it runs in one transaction with rollback), so there was nothing preventing recovery except the missing driver.

Fix: on a failed schema setup or migration, the operations queued while waiting for setup are rejected with the setup error, and the driver stays connected so JS can recover (e.g. database.unsafeResetDatabase() from the app's onSetUpError handler). If the database could not be opened at all, there is no driver to keep: iOS now rejects later calls instead of hanging, Android keeps a Connection.Failed state and rejects them with the open error.

On Android the bridge now creates the driver with the plain constructor and calls unsafeResetDatabase / migrate on it (so the driver instance survives a failure instead of being lost in a throwing constructor, which also leaked its open SQLiteDatabase). WMDatabaseDriver.migrate becomes package-private for that.

Failure paths, before and after

Path Before After
iOS: database file cannot be opened during initialize NSException OpenFailed → process crash, onSetUpError never runs initialize rejects with db.initialize.error; onSetUpError runs; later calls reject with No driver with tag N available
iOS: database cannot be (re)opened during setUpWithSchema / setUpWithMigrations NSException → crash promise rejects; queued operations rejected with the same error; later calls reject
iOS: schema SQL fails in setUpWithSchema NSException SetUpWithSchemaFailed → crash rejects with db.setUpWithSchema.error; queued operations rejected with the setup error; driver stays connected, unsafeResetDatabase works
iOS: migration SQL fails or user_version cannot be set rejected, but driver and queue removed: queued promises never settle, every later call (incl. unsafeResetDatabase) hangs forever rejects with db.setUpWithMigrations.error; queued operations rejected with the setup error; driver stays connected, unsafeResetDatabase works
iOS: DB version does not match the migration set's fromVersion NSException IncompatibleMigrations → crash rejects with Incompatible migration set applied. DB: X, migration: Y (same recovery as above)
iOS: unexpected WMDatabaseCompatibility in initialize NSException BadArgument → crash rejects with db.initialize.error
iOS: operation for a tag with neither driver nor queue silently dropped, promise never settles rejects with No driver with tag N available
Android: setUpWithSchema throws (open fails, schema SQL fails) uncaught exception from a @ReactMethod → native crash rejects; queued operations rejected with the setup error; driver stays connected if the DB opened
Android: setUpWithMigrations throws rejected, but driver removed: queued and all later calls rejected with No driver with tag N available rejects; queued operations rejected with the setup error; driver stays connected, unsafeResetDatabase works
Android: database cannot be opened in setUpWith* (crash / "No driver") rejects; later calls rejected with the open error (Connection.Failed)

Unchanged on purpose: Android initialize still throws IllegalStateException when the tag is already set up (iOS rejects), and the JSI path.

What was and was not verified

  • Android: verified on an emulator. native/androidTest (./gradlew connectedAndroidTest, React Native 0.74.6 test app, Pixel-class AVD, API 36 x86_64) was built with this branch and run with a dev bundle served by Metro. All 244 shared adapter tests pass in both the async-mode and JSI-mode configurations, including errors when migration fails with the new async-bridge assertions (queued operation rejected with the setup error, then unsafeResetDatabase, write and query on the same adapter) and all other migration/reset tests. The Java bridge changes compiled as part of that build. Two local, uncommitted tweaks were needed only to run on a modern emulator: adding x86_64 to the test app's abiFilters (the AVD has no 32-bit x86 ABI) and forwarding Metro with adb reverse.
  • iOS was not compiled here. This machine has no Xcode, so the iOS changes were reviewed by reading only. They mirror the NSError ** / BRIDGE_METHOD / WITH_DRIVER patterns already used in these files and the existing try/catch in the Android bridge. The iOS side still needs yarn test:ios on a Mac, ideally with a deliberately broken migration (and an unreadable database file) to exercise onSetUpError → unsafeResetDatabase.
  • The JS adapter itself is unchanged: it already handles a rejected setUpWithMigrations / setUpWithSchema (_initPromise rejects and devSetupCallback calls onSetUpError) and keeps dispatching to the same tag afterwards.
  • Regression test: the shared adapter test errors when migration fails (src/adapters/__tests__/commonTests.js) now also checks, for the asynchronous (non-JSI) SQLite dispatcher, that an operation issued while the failing migration runs is rejected with the setup error, and that the same adapter can then unsafeResetDatabase, write and query. It runs on device through src/adapters/sqlite/integrationTest.js (async-mode configuration; the JSI configuration is skipped because the JSI bridge asserts on operations before setup) and under jest through the sqlite-node bridge.
  • src/adapters/sqlite/sqlite-node/DatabaseBridge.js is the JS mirror of the native bridges and had the same disconnect-on-failure behaviour, so it now mirrors the fix (keep driver, reject queued operations with the setup error, catch setUpWithSchema). Verified under jest with Node 22: the new test fails against the old node bridge (No driver for with tag N available) and passes with the new one; the full src/adapters/sqlite suite (135 tests) passes, and eslint is clean.

🤖 Generated with Claude Code

…non-JSI) bridge

* Make SQLite setup failures reportable and recoverable on the legacy bridge

On the non-JSI native path, a database that could not be opened, a schema
setup that failed or a migration whose SQL failed either crashed the app
(iOS raised an NSException inside an async RCT_EXPORT_METHOD, which React
Native cannot convert to a promise rejection; Android's setUpWithSchema had
no try/catch) or left the adapter unusable (both platforms disconnected the
driver after a failed migration, so iOS dropped the queued operations and
hung every later call, and Android rejected every later call with "No
driver with tag N available"). The JS onSetUpError callback therefore never
ran on iOS, and on neither platform could the app recover by calling
database.unsafeResetDatabase().

iOS: WMDatabase open and setUserVersion, and the driver's setUpWithSchema
and setUpWithMigrations, now report through NSError, and the bridge rejects
the promise. Android: setUpWithSchema is wrapped like setUpWithMigrations.

Both: after a failed schema setup or migration, the operations queued while
waiting for setup are rejected with the setup error, and the driver stays
connected (setup runs in a transaction, so the database is consistent), so
unsafeResetDatabase and other calls keep working. If the database could not
be opened at all, later calls are rejected (iOS: "No driver with tag N
available" instead of hanging; Android: with the open error).

The JSI path (native/shared/DatabaseBridge.cpp) is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Do not keep an iOS driver whose database could not be reopened

If setUpWithSchema deleted the database file and reopening it failed, the
driver was still published as connected, so later operations ran against a
closed database. Treat such a driver like a missing one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test recovery after a failed migration and mirror it in the node bridge

The shared adapter test "errors when migration fails" now also checks, for
the asynchronous (non-JSI) SQLite dispatcher, that an operation issued
while the failing migration runs is rejected with the setup error instead
of hanging, and that the same adapter can then reset the database and
write and query again. It runs on device through
src/adapters/sqlite/integrationTest.js and under jest through the
sqlite-node bridge.

sqlite-node/DatabaseBridge.js is the JS mirror of the native bridges and
still disconnected the driver after a failed migration (and let
setUpWithSchema throw), so it gets the same treatment: setup failures are
rejected, queued operations are rejected with the setup error, and the
driver stays connected when its database could be opened.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Let reset recover from a failed schema setup

Android: after unsafeDestroyEverything the schema transaction may fail,
leaving sqlite_master empty. The next reset then crashed in getAllTables,
which read from the empty cursor without checking moveToFirst.

Node bridge: keep the driver after a failed setup only if its SQLite handle
is actually open, matching the iOS bridge; unsafeDestroyEverything can throw
between closing the handle and reopening it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@StasDoskalenko

Copy link
Copy Markdown

Hi @joker-777, thank you for such a thorough write-up, the before/after table is great!

In case it's useful: I maintain NitromelonDB, a maintained fork of WatermelonDB (New Architecture, Nitro Modules). It's New Architecture only, so the legacy (non-JSI) bridge this PR fixes doesn't exist there anymore. If you're on the New Architecture, you'd be very welcome to give it a try (migration guide). The fork was also rewritten in TypeScript (strict mode) to reduce the chance of bugs slipping through.

The JSI counterpart (#1983) does still apply to the fork, though, so I'll leave a note there too. Thanks!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants