From 3ab08d33e668c13c69ed167737e2f6705ec95171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:07:20 +0100 Subject: [PATCH 1/4] 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 --- CHANGELOG-Unreleased.md | 2 + .../com/nozbe/watermelondb/Connection.java | 14 ++-- .../nozbe/watermelondb/WMDatabaseBridge.java | 36 ++++++++-- .../nozbe/watermelondb/WMDatabaseDriver.java | 2 +- native/ios/WatermelonDB/objc/WMDatabase.h | 6 +- native/ios/WatermelonDB/objc/WMDatabase.m | 32 +++++---- .../ios/WatermelonDB/objc/WMDatabaseBridge.m | 72 +++++++++++++------ .../ios/WatermelonDB/objc/WMDatabaseDriver.h | 5 +- .../ios/WatermelonDB/objc/WMDatabaseDriver.m | 32 +++++---- 9 files changed, 138 insertions(+), 63 deletions(-) diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index 9d43e2bdd..20d068f21 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -11,6 +11,8 @@ - [LokiJS] Multitab sync issue fix - [Android] Added linker flag for building with 16kB page alignment - [TS] make catchError visible to typescript +- [SQLite] [iOS] Database setup failures on the legacy (non-JSI) bridge (database cannot be opened, schema setup or migration fails) now reject the promise and reach `onSetUpError` instead of raising an `NSException` that crashes the app +- [SQLite] [iOS] [Android] After a failed schema setup or migration on the legacy (non-JSI) bridge, operations queued during setup are rejected with the setup error and the driver stays connected, so the app can recover with `database.unsafeResetDatabase()` instead of every call hanging (iOS) or being rejected with "No driver with tag N available" (Android) ### Performance diff --git a/native/android/src/main/java/com/nozbe/watermelondb/Connection.java b/native/android/src/main/java/com/nozbe/watermelondb/Connection.java index c31ded6e4..819f86471 100644 --- a/native/android/src/main/java/com/nozbe/watermelondb/Connection.java +++ b/native/android/src/main/java/com/nozbe/watermelondb/Connection.java @@ -17,12 +17,18 @@ public Waiting(ArrayList queueInWaiting) { } } + // Setup failed and no driver is available - every operation is rejected with the setup error + public static class Failed extends Connection { + public final Exception error; + public Failed(Exception error) { + this.error = error; + } + } + public ArrayList getQueue() { - if (this instanceof Connected) { - return new ArrayList<>(); - } else if (this instanceof Waiting) { + if (this instanceof Waiting) { return ((Waiting) this).queueInWaiting; } - return null; + return new ArrayList<>(); } } \ No newline at end of file diff --git a/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java b/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java index 4f1351253..9244929c0 100644 --- a/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java +++ b/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java @@ -68,16 +68,25 @@ public void initialize(final Integer tag, final String databaseName, final int s @ReactMethod public void setUpWithSchema(final Integer tag, final String databaseName, final String schema, final int schemaVersion, final boolean unsafeNativeReuse, final Promise promise) { - connectDriver(tag, new WMDatabaseDriver(reactContext, databaseName, new Schema(schemaVersion, schema), unsafeNativeReuse), promise); + WMDatabaseDriver driver = null; + try { + driver = new WMDatabaseDriver(reactContext, databaseName, unsafeNativeReuse); + driver.unsafeResetDatabase(new Schema(schemaVersion, schema)); + connectDriver(tag, driver, promise); + } catch (Exception e) { + failSetUp(tag, driver, e, promise); + } } @ReactMethod public void setUpWithMigrations(final Integer tag, final String databaseName, final String migrations, final int fromVersion, final int toVersion, final boolean unsafeNativeReuse, final Promise promise) { + WMDatabaseDriver driver = null; try { - connectDriver(tag, new WMDatabaseDriver(reactContext, databaseName, new MigrationSet(fromVersion, toVersion, migrations), unsafeNativeReuse), promise); + driver = new WMDatabaseDriver(reactContext, databaseName, unsafeNativeReuse); + driver.migrate(new MigrationSet(fromVersion, toVersion, migrations)); + connectDriver(tag, driver, promise); } catch (Exception e) { - disconnectDriver(tag); - promise.reject(e); + failSetUp(tag, driver, e, promise); } } @@ -142,6 +151,8 @@ public WritableArray unsafeGetLocalSynchronously(int tag, String key) { return result; } else if (connection instanceof Connection.Waiting) { throw new Exception("Waiting connection unexpected for unsafeGetLocalSynchronously"); + } else if (connection instanceof Connection.Failed) { + throw ((Connection.Failed) connection).error; } } catch (Exception e) { WritableArray result = Arguments.createArray(); @@ -184,6 +195,8 @@ private void withDriver(final int tag, final Promise promise, final ParamFunctio // try again when driver is ready connection.getQueue().add(() -> withDriver(tag, promise, function, functionName)); connections.put(tag, new Connection.Waiting(connection.getQueue())); + } else if (connection instanceof Connection.Failed) { + promise.reject(functionName, ((Connection.Failed) connection).error); } } catch (Exception e) { promise.reject(functionName, e); @@ -203,14 +216,23 @@ private void connectDriver(int connectionTag, WMDatabaseDriver driver, Promise p promise.resolve(true); } - private void disconnectDriver(int connectionTag) { + // Rejects the operations queued while waiting for setup with the setup error, then keeps the + // driver connected if the database could at least be opened. + // NOTE: Setup runs in a transaction, so after a failed setup the database is still consistent. + // Keeping the driver lets JS recover (e.g. by calling unsafeResetDatabase) instead of every + // subsequent call being rejected with "No driver with tag N available". + private void failSetUp(int connectionTag, WMDatabaseDriver driver, Exception error, Promise promise) { List queue = getQueue(connectionTag); - - connections.remove(connectionTag); + connections.put(connectionTag, new Connection.Failed(error)); for (Runnable operation : queue) { operation.run(); } + + if (driver != null) { + connections.put(connectionTag, new Connection.Connected(driver)); + } + promise.reject(error); } @ReactMethod diff --git a/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseDriver.java b/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseDriver.java index 153483006..154c493d5 100644 --- a/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseDriver.java +++ b/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseDriver.java @@ -206,7 +206,7 @@ public void close() { database.close(); } - private void migrate(MigrationSet migrations) { + void migrate(MigrationSet migrations) { int databaseVersion = database.getUserVersion(); if (databaseVersion != migrations.from) { throw new IllegalArgumentException("Incompatible migration set applied. " + diff --git a/native/ios/WatermelonDB/objc/WMDatabase.h b/native/ios/WatermelonDB/objc/WMDatabase.h index f199fdca7..915049d13 100644 --- a/native/ios/WatermelonDB/objc/WMDatabase.h +++ b/native/ios/WatermelonDB/objc/WMDatabase.h @@ -5,11 +5,12 @@ NS_ASSUME_NONNULL_BEGIN @interface WMDatabase : NSObject @property (readwrite, strong, nonatomic) FMDatabase *fmdb; -@property (readwrite, nonatomic) long userVersion; +@property (readonly, nonatomic) long userVersion; #pragma mark - Initialization -+ (instancetype) databaseWithPath:(NSString *)path; +/// Returns nil (and sets errorPtr) if the database could not be opened ++ (nullable instancetype) databaseWithPath:(NSString *)path error:(NSError **)errorPtr; #pragma mark - Executing queries @@ -22,6 +23,7 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Other database functions - (BOOL) inTransaction:(BOOL (^)(NSError**))transactionBlock error:(NSError**)errorPtr; +- (BOOL) setUserVersion:(long)userVersion error:(NSError **)errorPtr; - (BOOL) unsafeDestroyEverything:(NSError**)errorPtr; @end diff --git a/native/ios/WatermelonDB/objc/WMDatabase.m b/native/ios/WatermelonDB/objc/WMDatabase.m index 04fc2e505..679254306 100644 --- a/native/ios/WatermelonDB/objc/WMDatabase.m +++ b/native/ios/WatermelonDB/objc/WMDatabase.m @@ -5,26 +5,32 @@ @implementation WMDatabase { NSString *_path; } -- (instancetype) initWithPath:(NSString *)path +- (nullable instancetype) initWithPath:(NSString *)path error:(NSError **)errorPtr { if (self = [super init]) { _path = path; _fmdb = [FMDatabase databaseWithPath:path]; - [self open]; + if (![self open:errorPtr]) { + return nil; + } } return self; } -+ (instancetype) databaseWithPath:(NSString *)path ++ (nullable instancetype) databaseWithPath:(NSString *)path error:(NSError **)errorPtr { - return [[self alloc] initWithPath:path]; + return [[self alloc] initWithPath:path error:errorPtr]; } -- (void) open +- (BOOL) open:(NSError **)errorPtr { + // NOTE: Must not raise an NSException - exceptions thrown from async bridge methods crash the app if (![_fmdb open]) { - [NSException raise:@"OpenFailed" format:@"Failed to open the database: %@", _fmdb.lastErrorMessage]; + *errorPtr = [NSError errorWithDomain:@"WMDatabase" code:0 userInfo:@{ + NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to open the database: %@", _fmdb.lastErrorMessage] + }]; + return NO; } // TODO: Experiment with WAL @@ -33,6 +39,7 @@ - (void) open // TODO: Configurable logger NSLog(@"Opened database at %@", _path); + return YES; } #pragma mark - Executing queries @@ -116,13 +123,15 @@ - (long) userVersion return [result longForColumnIndex:0]; } -- (void) setUserVersion:(long)userVersion +- (BOOL) setUserVersion:(long)userVersion error:(NSError **)errorPtr { NSString *sql = [NSString stringWithFormat:@"pragma user_version = %li", userVersion]; - BOOL result = [_fmdb executeUpdate:sql]; - if (!result) { - [NSException raise:@"SetUserVersionFailed" format:@"Failed to set user version: %@", _fmdb.lastErrorMessage]; + if (![_fmdb executeUpdate:sql]) { + *errorPtr = _fmdb.lastError; + return NO; } + + return YES; } - (BOOL) unsafeDestroyEverything:(NSError**)errorPtr @@ -176,8 +185,7 @@ - (BOOL) unsafeDestroyEverything:(NSError**)errorPtr [manager removeItemAtPath:[NSString stringWithFormat:@"%@-shm", _path] error:nil]; // reopen database - [self open]; - return YES; + return [self open:errorPtr]; } } diff --git a/native/ios/WatermelonDB/objc/WMDatabaseBridge.m b/native/ios/WatermelonDB/objc/WMDatabaseBridge.m index 8a3ab356b..ff8ab6987 100644 --- a/native/ios/WatermelonDB/objc/WMDatabaseBridge.m +++ b/native/ios/WatermelonDB/objc/WMDatabaseBridge.m @@ -53,7 +53,11 @@ + (BOOL) requiresMainQueueSetup return reject(@"db.initialize.error", [NSString stringWithFormat:@"A driver with tag %@ is already set up", tag], nil); } - WMDatabaseDriver *driver = [WMDatabaseDriver driverWithName:name]; + NSError *error; + WMDatabaseDriver *driver = [WMDatabaseDriver driverWithName:name error:&error]; + if (!driver) { + return reject(@"db.initialize.error", error.localizedDescription, error); + } WMDatabaseCompatibility compatibility = [driver isCompatibleWithSchemaVersion:[version integerValue]]; if (compatibility == WMDatabaseCompatibilityCompatible) { @@ -67,7 +71,7 @@ + (BOOL) requiresMainQueueSetup return resolve(@{@"code": @"migrations_needed", @"databaseVersion": @(driver.schemaVersion)}); } - [NSException raise:@"BadArgument" format:@"Unexpected WMDatabaseCompatibility"]; + return reject(@"db.initialize.error", @"Unexpected WMDatabaseCompatibility", nil); } BRIDGE_METHOD(setUpWithSchema, @@ -76,10 +80,16 @@ + (BOOL) requiresMainQueueSetup schemaVersion:(nonnull NSNumber *)version ) { - WMDatabaseDriver *driver = [WMDatabaseDriver driverWithName:name]; - [driver setUpWithSchema:schema schemaVersion:[version integerValue]]; - [self connectDriverAsync:tag driver:driver]; - return resolve(@YES); + NSError *error; + WMDatabaseDriver *driver = [WMDatabaseDriver driverWithName:name error:&error]; + + if (driver && [driver setUpWithSchema:schema schemaVersion:[version integerValue] error:&error]) { + [self connectDriverAsync:tag driver:driver]; + return resolve(@YES); + } else { + [self connectDriverAsync:tag driver:driver setUpError:error]; + return reject(@"db.setUpWithSchema.error", error.localizedDescription, error); + } } BRIDGE_METHOD(setUpWithMigrations, @@ -89,14 +99,14 @@ + (BOOL) requiresMainQueueSetup toVersion:(nonnull NSNumber *)toVersion ) { - WMDatabaseDriver *driver = [WMDatabaseDriver driverWithName:name]; NSError *error; + WMDatabaseDriver *driver = [WMDatabaseDriver driverWithName:name error:&error]; - if ([driver setUpWithMigrations:migrationSQL fromVersion:[fromVersion integerValue] toVersion:[toVersion integerValue] error:&error]) { + if (driver && [driver setUpWithMigrations:migrationSQL fromVersion:[fromVersion integerValue] toVersion:[toVersion integerValue] error:&error]) { [self connectDriverAsync:tag driver:driver]; return resolve(@YES); } else { - [self disconnectDriver:tag]; + [self connectDriverAsync:tag driver:driver setUpError:error]; return reject(@"db.setUpWithMigrations.error", error.localizedDescription, error); } } @@ -259,20 +269,29 @@ + (BOOL) requiresMainQueueSetup - (void) connectDriverAsync:(nonnull NSNumber *)tag driver:(WMDatabaseDriver *)driver { - _connections[tag] = driver; - NSArray *queuedOperations = _queue[tag]; - [_queue removeObjectForKey:tag]; - - for (void (^action)(void) in queuedOperations) { - action(); - } + [self connectDriverAsync:tag driver:driver setUpError:nil]; } -- (void) disconnectDriver:(nonnull NSNumber *)tag +// Connects the driver (if setup produced one) and settles the operations queued while waiting for +// it: they are retried if setup succeeded, and rejected with the setup error otherwise. +// NOTE: Setup runs in a transaction, so after a failed setup the database is still consistent and +// the driver stays connected. This lets JS recover (e.g. by calling unsafeResetDatabase) instead +// of every subsequent call hanging on a tag that has neither a driver nor a queue. +- (void) connectDriverAsync:(nonnull NSNumber *)tag + driver:(nullable WMDatabaseDriver *)driver + setUpError:(nullable NSError *)setUpError { - [_connections removeObjectForKey:tag]; - // NOTE: In Swift, queued operations are executed, which seems wrong + if (driver) { + _connections[tag] = driver; + } else { + [_connections removeObjectForKey:tag]; + } + NSArray *queuedOperations = _queue[tag]; [_queue removeObjectForKey:tag]; + + for (void (^action)(NSError * _Nullable) in queuedOperations) { + action(setUpError); + } } - (void) withDriver:(nonnull NSNumber *)tag @@ -293,11 +312,20 @@ - (void) withDriver:(nonnull NSNumber *)tag return resolve(result); } } else { - NSLog(@"Operation for driver %@ enqueued", tag); NSMutableArray *queuedOperations = _queue[tag]; + if (!queuedOperations) { + // NOTE: Without this, the operation would be silently dropped and its promise never settled + NSString *errorName = [NSString stringWithFormat:@"db.%s.error", methodName]; + return reject(errorName, [NSString stringWithFormat:@"No driver with tag %@ available", tag], nil); + } + NSLog(@"Operation for driver %@ enqueued", tag); - // try again when driver is ready - void (^queueOperation)(void) = ^() { + // try again when driver is ready, or fail if setting it up failed + void (^queueOperation)(NSError * _Nullable) = ^(NSError * _Nullable setUpError) { + if (setUpError) { + NSString *errorName = [NSString stringWithFormat:@"db.%s.error", methodName]; + return reject(errorName, setUpError.localizedDescription, setUpError); + } [self withDriver:tag resolve:resolve reject:reject methodName:methodName action:action]; }; diff --git a/native/ios/WatermelonDB/objc/WMDatabaseDriver.h b/native/ios/WatermelonDB/objc/WMDatabaseDriver.h index 0224cc32b..281d3a9b0 100644 --- a/native/ios/WatermelonDB/objc/WMDatabaseDriver.h +++ b/native/ios/WatermelonDB/objc/WMDatabaseDriver.h @@ -15,13 +15,14 @@ typedef NS_ENUM(NSInteger, WMDatabaseCompatibility) { #pragma mark - Initialization -+ (instancetype) driverWithName:(NSString *)dbName; +/// Returns nil (and sets errorPtr) if the database could not be opened ++ (nullable instancetype) driverWithName:(NSString *)dbName error:(NSError **)errorPtr; #pragma mark - Setup - (long) schemaVersion; - (WMDatabaseCompatibility) isCompatibleWithSchemaVersion:(long)version; -- (void) setUpWithSchema:(NSString *)sql schemaVersion:(long)version; +- (BOOL) setUpWithSchema:(NSString *)sql schemaVersion:(long)version error:(NSError **)errorPtr; - (BOOL) setUpWithMigrations:(NSString *)sql fromVersion:(long)fromVersion toVersion:(long)toVersion error:(NSError **)errorPtr; #pragma mark - Database functions diff --git a/native/ios/WatermelonDB/objc/WMDatabaseDriver.m b/native/ios/WatermelonDB/objc/WMDatabaseDriver.m index 020e0832b..487b14aaf 100644 --- a/native/ios/WatermelonDB/objc/WMDatabaseDriver.m +++ b/native/ios/WatermelonDB/objc/WMDatabaseDriver.m @@ -4,19 +4,22 @@ @implementation WMDatabaseDriver #pragma mark - Initialization -- (instancetype) initWithName:(NSString *)dbName +- (nullable instancetype) initWithName:(NSString *)dbName error:(NSError **)errorPtr { if (self = [super init]) { - _db = [WMDatabase databaseWithPath:[self pathForName:dbName]]; + _db = [WMDatabase databaseWithPath:[self pathForName:dbName] error:errorPtr]; + if (!_db) { + return nil; + } _cachedRecords = [NSMutableDictionary dictionary]; } return self; } -+ (instancetype) driverWithName:(NSString *)dbName ++ (nullable instancetype) driverWithName:(NSString *)dbName error:(NSError **)errorPtr { - return [[self alloc] initWithName:dbName]; + return [[self alloc] initWithName:dbName error:errorPtr]; } - (NSString *) pathForName:(NSString *)dbName @@ -59,20 +62,19 @@ - (WMDatabaseCompatibility) isCompatibleWithSchemaVersion:(long)version } } -- (void) setUpWithSchema:(NSString *)sql schemaVersion:(long)version +- (BOOL) setUpWithSchema:(NSString *)sql schemaVersion:(long)version error:(NSError **)errorPtr { - NSError *error; - if (![self unsafeResetDatabaseWithSchema:sql schemaVersion:version error:&error]) { - [NSException raise:@"SetUpWithSchemaFailed" format:@"Error while setting up the database: %@", error]; - } + return [self unsafeResetDatabaseWithSchema:sql schemaVersion:version error:errorPtr]; } - (BOOL) setUpWithMigrations:(NSString *)sql fromVersion:(long)fromVersion toVersion:(long)toVersion error:(NSError **)errorPtr { long databaseVersion = _db.userVersion; if (databaseVersion != fromVersion) { - [NSException raise:@"IncompatibleMigrations" - format:@"Incompatbile migration set applied. DB: %li, migration: %li", databaseVersion, fromVersion]; + *errorPtr = [NSError errorWithDomain:@"WMDatabaseDriver" code:0 userInfo:@{ + NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Incompatible migration set applied. DB: %li, migration: %li", databaseVersion, fromVersion] + }]; + return NO; } __block WMDatabase *db = _db; @@ -80,7 +82,9 @@ - (BOOL) setUpWithMigrations:(NSString *)sql fromVersion:(long)fromVersion toVer if (![db executeStatements:sql error:innerErrorPtr]) { return NO; } - [db setUserVersion:toVersion]; + if (![db setUserVersion:toVersion error:innerErrorPtr]) { + return NO; + } return YES; } error:errorPtr]; @@ -240,7 +244,9 @@ - (BOOL) unsafeResetDatabaseWithSchema:(NSString *)sql schemaVersion:(long)versi if (![db executeStatements:sql error:innerErrorPtr]) { return NO; } - [db setUserVersion:version]; + if (![db setUserVersion:version error:innerErrorPtr]) { + return NO; + } return YES; } error:errorPtr]; From 1a72777f1b4805419545ec8baf780117ea4c92db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:16:34 +0100 Subject: [PATCH 2/4] 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 --- native/ios/WatermelonDB/objc/WMDatabaseBridge.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/native/ios/WatermelonDB/objc/WMDatabaseBridge.m b/native/ios/WatermelonDB/objc/WMDatabaseBridge.m index ff8ab6987..353204139 100644 --- a/native/ios/WatermelonDB/objc/WMDatabaseBridge.m +++ b/native/ios/WatermelonDB/objc/WMDatabaseBridge.m @@ -281,9 +281,11 @@ - (void) connectDriverAsync:(nonnull NSNumber *)tag driver:(nullable WMDatabaseDriver *)driver setUpError:(nullable NSError *)setUpError { - if (driver) { + if (driver && driver.db.fmdb.isOpen) { _connections[tag] = driver; } else { + // NOTE: A driver whose database could not be (re)opened is of no use - every operation + // on it would fail, so treat it like a missing driver [_connections removeObjectForKey:tag]; } NSArray *queuedOperations = _queue[tag]; From bdbb8c2078a3419847310f4df0a340528ebdfef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 08:53:07 +0100 Subject: [PATCH 3/4] 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 --- CHANGELOG-Unreleased.md | 2 +- src/adapters/__tests__/commonTests.js | 62 ++++++++++++++----- .../sqlite/sqlite-node/DatabaseBridge.js | 48 +++++++++----- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/CHANGELOG-Unreleased.md b/CHANGELOG-Unreleased.md index 20d068f21..b3fad2e28 100644 --- a/CHANGELOG-Unreleased.md +++ b/CHANGELOG-Unreleased.md @@ -12,7 +12,7 @@ - [Android] Added linker flag for building with 16kB page alignment - [TS] make catchError visible to typescript - [SQLite] [iOS] Database setup failures on the legacy (non-JSI) bridge (database cannot be opened, schema setup or migration fails) now reject the promise and reach `onSetUpError` instead of raising an `NSException` that crashes the app -- [SQLite] [iOS] [Android] After a failed schema setup or migration on the legacy (non-JSI) bridge, operations queued during setup are rejected with the setup error and the driver stays connected, so the app can recover with `database.unsafeResetDatabase()` instead of every call hanging (iOS) or being rejected with "No driver with tag N available" (Android) +- [SQLite] [iOS] [Android] After a failed schema setup or migration on the legacy (non-JSI) bridge, operations queued during setup are rejected with the setup error and the driver stays connected, so the app can recover with `database.unsafeResetDatabase()` instead of every call hanging (iOS) or being rejected with "No driver with tag N available" (Android). The Node.js SQLite bridge (`sqlite-node`) behaves the same way ### Performance diff --git a/src/adapters/__tests__/commonTests.js b/src/adapters/__tests__/commonTests.js index f4ce5fa2f..683bcdfe9 100644 --- a/src/adapters/__tests__/commonTests.js +++ b/src/adapters/__tests__/commonTests.js @@ -1118,7 +1118,7 @@ export default () => { await adapter.batch([['create', 'tasks', mockTaskRaw({})]]) expect(await adapter.count(taskQuery())).toBe(1) }) - it('errors when migration fails', async (_adapter, AdapterClass, extraAdapterOptions) => { + it('errors when migration fails', async (_adapter, AdapterClass, extraAdapterOptions, platform) => { // launch older version of the app let adapter = new DatabaseAdapterCompat( new AdapterClass({ @@ -1131,22 +1131,23 @@ export default () => { await adapter.batch([['create', 'tasks', mockTaskRaw({})]]) expect(await adapter.count(taskQuery())).toBe(1) // launch newer version of the app with a migration that will fail + const failingMigrations = schemaMigrations({ + migrations: [ + { + toVersion: 2, + steps: [ + // with SQLite, trying to create a duplicate table will fail, but Loki will just ignore it + // so let's insert something that WILL fail + AdapterClass.name === 'LokiJSAdapter' + ? { type: 'bad_type' } + : createTable({ name: 'tasks', columns: [] }), + ], + }, + ], + }) const adapterPromise = adapter.testClone({ schema: { ...testSchema, version: 2 }, - migrations: schemaMigrations({ - migrations: [ - { - toVersion: 2, - steps: [ - // with SQLite, trying to create a duplicate table will fail, but Loki will just ignore it - // so let's insert something that WILL fail - AdapterClass.name === 'LokiJSAdapter' - ? { type: 'bad_type' } - : createTable({ name: 'tasks', columns: [] }), - ], - }, - ], - }), + migrations: failingMigrations, }) // TODO: Make the SQLite, LokiJS adapter behavior consistent @@ -1159,6 +1160,37 @@ export default () => { } else { await expect(adapterPromise).rejects.toBeInstanceOf(Error) } + + if ( + AdapterClass.name === 'SQLiteAdapter' && + adapter.underlyingAdapter._dispatcherType === 'asynchronous' + ) { + // With the asynchronous (non-JSI) bridge, the app must be able to recover from a failed + // migration (e.g. from onSetUpError) by resetting the database with the same adapter + const failedAdapter = new DatabaseAdapterCompat( + new AdapterClass({ + schema: { ...testSchema, version: 2 }, + migrations: failingMigrations, + ...extraAdapterOptions, + }), + ) + // operations issued while the migration is still running are queued by the bridge + const queuedCount = failedAdapter.count(taskQuery()) + await expect(failedAdapter.underlyingAdapter.initializingPromise).rejects.toBeInstanceOf(Error) + if (platform === 'node') { + // NOTE: The node bridge settles every call synchronously, so nothing was actually queued + await queuedCount + } else { + // queued operations are rejected with the setup error instead of hanging forever + await expect(queuedCount).rejects.toBeInstanceOf(Error) + } + + // the driver stays connected, so the database can be reset and used + await failedAdapter.unsafeResetDatabase() + expect(await failedAdapter.count(taskQuery())).toBe(0) + await failedAdapter.batch([['create', 'tasks', mockTaskRaw({})]]) + expect(await failedAdapter.count(taskQuery())).toBe(1) + } }) it('can actually save and read from file system', async (_adapter, AdapterClass, extraAdapterOptions, platform) => { if (AdapterClass.name === 'LokiJSAdapter') { diff --git a/src/adapters/sqlite/sqlite-node/DatabaseBridge.js b/src/adapters/sqlite/sqlite-node/DatabaseBridge.js index 480ff4d28..36e030a8e 100644 --- a/src/adapters/sqlite/sqlite-node/DatabaseBridge.js +++ b/src/adapters/sqlite/sqlite-node/DatabaseBridge.js @@ -55,12 +55,18 @@ class DatabaseBridge { schema: string, schemaVersion: number, resolve: (boolean) => void, - _reject: () => void, + reject: () => void, ): void { - const driver = new DatabaseDriver() - driver.setUpWithSchema(databaseName, schema, schemaVersion) - this.connectDriverAsync(tag, driver) - resolve(true) + let driver + try { + driver = new DatabaseDriver() + driver.setUpWithSchema(databaseName, schema, schemaVersion) + this.connectDriverAsync(tag, driver) + resolve(true) + } catch (error) { + this.failSetUp(tag, driver, error) + this.sendReject(reject, error, 'setUpWithSchema') + } } setUpWithMigrations( @@ -72,8 +78,9 @@ class DatabaseBridge { resolve: (boolean) => void, reject: () => void, ): void { + let driver try { - const driver = new DatabaseDriver() + driver = new DatabaseDriver() driver.setUpWithMigrations(databaseName, { from: fromVersion, to: toVersion, @@ -82,7 +89,7 @@ class DatabaseBridge { this.connectDriverAsync(tag, driver) resolve(true) } catch (error) { - this.disconnectDriver(tag) + this.failSetUp(tag, driver, error) this.sendReject(reject, error, 'setUpWithMigrations') } } @@ -183,9 +190,13 @@ class DatabaseBridge { resolve(result) } else if (connection.status === 'waiting') { // consoleLog('Operation for driver (tagID) enqueued') - // try again when driver is ready - connection.queue.push(() => { - this.withDriver(tag, resolve, reject, functionName, action) + // try again when driver is ready, or fail if setting it up failed + connection.queue.push((setUpError: ?Error) => { + if (setUpError) { + this.sendReject(reject, setUpError, functionName) + } else { + this.withDriver(tag, resolve, reject, functionName, action) + } }) } } catch (error) { @@ -200,11 +211,20 @@ class DatabaseBridge { queue.forEach((operation) => operation()) } - disconnectDriver(tag: number): void { - const { queue = [] } = this.connections[tag] - delete this.connections[tag] + // Rejects the operations queued while waiting for setup with the setup error, and keeps the + // driver connected if its database could be opened. + // NOTE: Setup runs in a transaction, so after a failed setup the database is still consistent. + // Keeping the driver lets the app recover (e.g. by calling unsafeResetDatabase). This mirrors + // the behavior of the native (non-JSI) bridges. + failSetUp(tag: number, driver: ?DatabaseDriver, error: Error): void { + const { queue = [] } = this.connections[tag] || {} + if (driver && driver.database) { + this.connections[tag] = { driver, queue: [], status: 'connected' } + } else { + delete this.connections[tag] + } - queue.forEach((operation) => operation()) + queue.forEach((operation) => operation(error)) } assertNoConnection(tag: number): void { From 3bd28a781c61e38ae87a69c430f5136e9c866816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6hler?= Date: Sat, 31 Oct 2026 09:42:52 +0100 Subject: [PATCH 4/4] 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 --- .../src/main/java/com/nozbe/watermelondb/WMDatabase.java | 4 ++-- src/adapters/sqlite/sqlite-node/DatabaseBridge.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java b/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java index 2f170e0ce..c0a42a22f 100644 --- a/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java +++ b/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java @@ -149,9 +149,9 @@ public String getFromLocalStorage(String key) { private ArrayList getAllTables() { ArrayList allTables = new ArrayList<>(); try (Cursor cursor = rawQuery(Queries.select_tables)) { - cursor.moveToFirst(); + // NOTE: The cursor is empty if everything was destroyed but the schema could not be set up int nameIndex = cursor.getColumnIndex("name"); - if (nameIndex > -1) { + if (nameIndex > -1 && cursor.moveToFirst()) { do { allTables.add(cursor.getString(nameIndex)); } while (cursor.moveToNext()); diff --git a/src/adapters/sqlite/sqlite-node/DatabaseBridge.js b/src/adapters/sqlite/sqlite-node/DatabaseBridge.js index 36e030a8e..cacdd3e6c 100644 --- a/src/adapters/sqlite/sqlite-node/DatabaseBridge.js +++ b/src/adapters/sqlite/sqlite-node/DatabaseBridge.js @@ -212,13 +212,13 @@ class DatabaseBridge { } // Rejects the operations queued while waiting for setup with the setup error, and keeps the - // driver connected if its database could be opened. + // driver connected if its database is open. // NOTE: Setup runs in a transaction, so after a failed setup the database is still consistent. // Keeping the driver lets the app recover (e.g. by calling unsafeResetDatabase). This mirrors // the behavior of the native (non-JSI) bridges. failSetUp(tag: number, driver: ?DatabaseDriver, error: Error): void { const { queue = [] } = this.connections[tag] || {} - if (driver && driver.database) { + if (driver && driver.database && driver.database.instance && driver.database.instance.open) { this.connections[tag] = { driver, queue: [], status: 'connected' } } else { delete this.connections[tag]