From 15e49a38a5f949674497332e64752263367a9537 Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Fri, 6 Jan 2017 00:09:37 -0600 Subject: [PATCH 1/8] Add transaction support to Database Reference. --- ios/Firestack/FirestackDatabase.h | 1 + ios/Firestack/FirestackDatabase.m | 54 ++++++++++++++++++++++++++++++- ios/Firestack/FirestackEvents.h | 1 + lib/modules/database/index.js | 37 +++++++++++++++++++++ lib/modules/database/reference.js | 5 +++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/ios/Firestack/FirestackDatabase.h b/ios/Firestack/FirestackDatabase.h index 7659f4d..24eb8e4 100644 --- a/ios/Firestack/FirestackDatabase.h +++ b/ios/Firestack/FirestackDatabase.h @@ -18,6 +18,7 @@ } @property NSMutableDictionary *dbReferences; +@property NSMutableDictionary *transactions; @end diff --git a/ios/Firestack/FirestackDatabase.m b/ios/Firestack/FirestackDatabase.m index bdb10fa..7596be3 100644 --- a/ios/Firestack/FirestackDatabase.m +++ b/ios/Firestack/FirestackDatabase.m @@ -364,6 +364,7 @@ - (id) init self = [super init]; if (self != nil) { _dbReferences = [[NSMutableDictionary alloc] init]; + _transactions = [[NSMutableDictionary alloc] init]; } return self; } @@ -455,7 +456,58 @@ - (id) init } } +RCT_EXPORT_METHOD(beginTransaction:(NSString *) path + withIdentifier:(NSString *) identifier + applyLocally:(BOOL) applyLocally + onComplete:(RCTResponseSenderBlock) onComplete) +{ + NSMutableDictionary *transactionState = [NSMutableDictionary new]; + [_transactions setValue:transactionState forKey:identifier]; + dispatch_semaphore_t sema = dispatch_semaphore_create(0); + [transactionState setObject:sema forKey:@"semaphore"]; + + FIRDatabaseReference *ref = [self getPathRef:path]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [ref runTransactionBlock:^FIRTransactionResult * _Nonnull(FIRMutableData * _Nonnull currentData) { + [self sendEventWithName:DATABASE_TRANSACTION_EVENT + body:@{ + @"id": identifier, + @"originalValue": currentData.value + }]; + // Wait for the event handler to call tryCommitTransaction + dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); + BOOL abort = [transactionState valueForKey:@"abort"]; + id value = [transactionState valueForKey:@"value"]; + [_transactions removeObjectForKey:identifier]; + if (abort) { + return [FIRTransactionResult abort]; + } else { + currentData.value = value; + return [FIRTransactionResult successWithValue:currentData]; + } + } andCompletionBlock:^(NSError * _Nullable error, BOOL committed, FIRDataSnapshot * _Nullable snapshot) { + [self handleCallback:@"transaction" callback:onComplete databaseError:error]; + } withLocalEvents:applyLocally]; + }); +} +RCT_EXPORT_METHOD(tryCommitTransaction:(NSString *) identifier + withData:(NSDictionary *) data + orAbort:(BOOL) abort) +{ + NSMutableDictionary *transactionState = [_transactions valueForKey:identifier]; + if (!transactionState) { + NSLog(@"tryCommitTransaction for unknown ID %@", identifier); + } + dispatch_semaphore_t sema = [transactionState valueForKey:@"semaphore"]; + if (abort) { + [transactionState setValue:@true forKey:@"abort"]; + } else { + id newValue = [data valueForKey:@"value"]; + [transactionState setValue:newValue forKey:@"value"]; + } + dispatch_semaphore_signal(sema); +} RCT_EXPORT_METHOD(on:(NSString *) path modifiersString:(NSString *) modifiersString @@ -610,7 +662,7 @@ - (NSString *) getDBListenerKey:(NSString *) path // Not sure how to get away from this... yet - (NSArray *)supportedEvents { - return @[DATABASE_DATA_EVENT, DATABASE_ERROR_EVENT]; + return @[DATABASE_DATA_EVENT, DATABASE_ERROR_EVENT, DATABASE_TRANSACTION_EVENT]; } diff --git a/ios/Firestack/FirestackEvents.h b/ios/Firestack/FirestackEvents.h index ba976ef..1ea449f 100644 --- a/ios/Firestack/FirestackEvents.h +++ b/ios/Firestack/FirestackEvents.h @@ -29,6 +29,7 @@ static NSString *const DEBUG_EVENT = @"debug"; // Database static NSString *const DATABASE_DATA_EVENT = @"database_event"; static NSString *const DATABASE_ERROR_EVENT = @"database_error"; +static NSString *const DATABASE_TRANSACTION_EVENT = @"database_transaction_update"; static NSString *const DATABASE_VALUE_EVENT = @"value"; static NSString *const DATABASE_CHILD_ADDED_EVENT = @"child_added"; diff --git a/lib/modules/database/index.js b/lib/modules/database/index.js index f4477e7..8463a08 100644 --- a/lib/modules/database/index.js +++ b/lib/modules/database/index.js @@ -19,6 +19,7 @@ export default class Database extends Base { constructor(firestack: Object, options: Object = {}) { super(firestack, options); this.subscriptions = {}; + this.transactions = {}; this.serverTimeOffset = 0; this.persistenceEnabled = false; this.namespace = 'firestack:database'; @@ -33,6 +34,11 @@ export default class Database extends Base { err => this._handleDatabaseError(err) ); + this.transactionListener = FirestackDatabaseEvt.addListener( + 'database_transaction_update', + event => this._handleDatabaseTransaction(event) + ); + this.offsetRef = this.ref('.info/serverTimeOffset'); this.offsetRef.on('value', (snapshot) => { this.serverTimeOffset = snapshot.val() || this.serverTimeOffset; @@ -153,6 +159,37 @@ export default class Database extends Base { FirestackDatabase.goOffline(); } + addTransaction(path, updateCallback, applyLocally, onComplete) { + let id = this._generateTransactionID(); + this.transactions[id] = updateCallback; + return new Promise((resolve, reject) => { + FirestackDatabase.beginTransaction(path, id, applyLocally || false, (error, result) => { + onComplete && onComplete(error); + if (error) + reject(error); + else + resolve(); + delete this.transactions[id]; + }); + }); + } + + _generateTransactionID() { + // 10 char random alphanumeric + return Math.random().toString(36).substr(2, 10); + } + + _handleDatabaseTransaction(event) { + const {id, originalValue} = event; + const updateCallback = this.transactions[id]; + const newValue = updateCallback(originalValue); + let abort = false; + if (newValue === undefined) { + abort = true; + } + FirestackDatabase.tryCommitTransaction(id, {value: newValue}, abort); + } + /** * INTERNALS */ diff --git a/lib/modules/database/reference.js b/lib/modules/database/reference.js index 3471383..caf4edd 100644 --- a/lib/modules/database/reference.js +++ b/lib/modules/database/reference.js @@ -123,6 +123,11 @@ export default class Reference extends ReferenceBase { return this.db.off(path, modifiersString, eventName, origCB); } + transaction(transactionUpdate, onComplete, applyLocally) { + const path = this._dbPath(); + return this.db.addTransaction(path, transactionUpdate, applyLocally, onComplete); + } + /** * MODIFIERS */ From dba8c11d15e9ac92efde92bb77e7ccaf8f85037f Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Fri, 6 Jan 2017 00:24:33 -0600 Subject: [PATCH 2/8] Resolve promise and send callback appropriate result. --- ios/Firestack/FirestackDatabase.m | 25 ++++++++++++++++++++----- lib/modules/database/index.js | 14 ++++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/ios/Firestack/FirestackDatabase.m b/ios/Firestack/FirestackDatabase.m index 7596be3..9c67095 100644 --- a/ios/Firestack/FirestackDatabase.m +++ b/ios/Firestack/FirestackDatabase.m @@ -21,6 +21,7 @@ @interface FirestackDBReference : NSObject @property FIRDatabaseHandle childRemovedHandler; @property FIRDatabaseHandle childMovedHandler; @property FIRDatabaseHandle childValueHandler; ++ (NSDictionary *) snapshotToDict:(FIRDataSnapshot *) snapshot; @end @implementation FirestackDBReference @@ -46,7 +47,7 @@ - (void) addEventHandler:(NSString *) eventName { if (![self isListeningTo:eventName]) { id withBlock = ^(FIRDataSnapshot * _Nonnull snapshot) { - NSDictionary *props = [self snapshotToDict:snapshot]; + NSDictionary *props = [FirestackDBReference snapshotToDict:snapshot]; [self sendJSEvent:DATABASE_DATA_EVENT title:eventName props: @{ @@ -74,7 +75,7 @@ - (void) addSingleEventHandler:(RCTResponseSenderBlock) callback { [_query observeSingleEventOfType:FIRDataEventTypeValue withBlock:^(FIRDataSnapshot * _Nonnull snapshot) { - NSDictionary *props = [self snapshotToDict:snapshot]; + NSDictionary *props = [FirestackDBReference snapshotToDict:snapshot]; callback(@[[NSNull null], @{ @"eventName": @"value", @"path": _path, @@ -131,7 +132,7 @@ - (void) removeEventHandler:(NSString *) name [self unsetListeningOn:name]; } -- (NSDictionary *) snapshotToDict:(FIRDataSnapshot *) snapshot ++ (NSDictionary *) snapshotToDict:(FIRDataSnapshot *) snapshot { NSMutableDictionary *dict = [[NSMutableDictionary alloc] init]; [dict setValue:snapshot.key forKey:@"key"]; @@ -485,8 +486,22 @@ - (id) init currentData.value = value; return [FIRTransactionResult successWithValue:currentData]; } - } andCompletionBlock:^(NSError * _Nullable error, BOOL committed, FIRDataSnapshot * _Nullable snapshot) { - [self handleCallback:@"transaction" callback:onComplete databaseError:error]; + } andCompletionBlock:^(NSError * _Nullable databaseError, BOOL committed, FIRDataSnapshot * _Nullable snapshot) { + if (databaseError != nil) { + NSDictionary *evt = @{ + @"errorCode": [NSNumber numberWithInt:[databaseError code]], + @"errorDetails": [databaseError debugDescription], + @"description": [databaseError description] + }; + onComplete(@[evt]); + } else { + onComplete(@[[NSNull null], @{ + @"committed": [NSNumber numberWithBool:committed], + @"snapshot": [FirestackDBReference snapshotToDict:snapshot], + @"status": @"success", + @"method": @"transaction" + }]); + } } withLocalEvents:applyLocally]; }); } diff --git a/lib/modules/database/index.js b/lib/modules/database/index.js index 8463a08..b3e840b 100644 --- a/lib/modules/database/index.js +++ b/lib/modules/database/index.js @@ -164,11 +164,17 @@ export default class Database extends Base { this.transactions[id] = updateCallback; return new Promise((resolve, reject) => { FirestackDatabase.beginTransaction(path, id, applyLocally || false, (error, result) => { - onComplete && onComplete(error); - if (error) + let snapshot; + if (result.snapshot) + snapshot = new Snapshot(new Reference(this, path.split('/'), null), result.snapshot); + onComplete && onComplete(error, snapshot); + if (error) { reject(error); - else - resolve(); + } + else { + let {committed} = result; + resolve({committed, snapshot}); + } delete this.transactions[id]; }); }); From 69fb950eff0f3be066777bc5f8928c8f5a74c7b1 Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Fri, 6 Jan 2017 01:33:47 -0600 Subject: [PATCH 3/8] Cleaner handling chain. --- lib/modules/database/index.js | 21 ++++----------------- lib/modules/database/reference.js | 10 +++++++++- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/lib/modules/database/index.js b/lib/modules/database/index.js index b3e840b..06579be 100644 --- a/lib/modules/database/index.js +++ b/lib/modules/database/index.js @@ -159,25 +159,12 @@ export default class Database extends Base { FirestackDatabase.goOffline(); } - addTransaction(path, updateCallback, applyLocally, onComplete) { + addTransaction(path, updateCallback, applyLocally) { let id = this._generateTransactionID(); this.transactions[id] = updateCallback; - return new Promise((resolve, reject) => { - FirestackDatabase.beginTransaction(path, id, applyLocally || false, (error, result) => { - let snapshot; - if (result.snapshot) - snapshot = new Snapshot(new Reference(this, path.split('/'), null), result.snapshot); - onComplete && onComplete(error, snapshot); - if (error) { - reject(error); - } - else { - let {committed} = result; - resolve({committed, snapshot}); - } - delete this.transactions[id]; - }); - }); + return promisify('beginTransaction', FirestackDatabase)(path, id, applyLocally || false) + .then((v) => {delete this.transactions[id]; return v;}, + (e) => {delete this.transactions[id]; throw e;}); } _generateTransactionID() { diff --git a/lib/modules/database/reference.js b/lib/modules/database/reference.js index caf4edd..530690b 100644 --- a/lib/modules/database/reference.js +++ b/lib/modules/database/reference.js @@ -125,7 +125,15 @@ export default class Reference extends ReferenceBase { transaction(transactionUpdate, onComplete, applyLocally) { const path = this._dbPath(); - return this.db.addTransaction(path, transactionUpdate, applyLocally, onComplete); + return this.db.addTransaction(path, transactionUpdate, applyLocally) + .then(({ snapshot, committed }) => {snapshot: new Snapshot(this, snapshot), committed}) + .then(({ snapshot, committed }) => { + if (isFunction(onComplete)) onComplete(null, snapshot); + return {snapshot, committed}; + }).catch((e) => { + if (isFunction(onComplete)) return onComplete(e, null); + throw e; + }); } /** From db3f0159f0787b25780490902720e902e6cd506b Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Fri, 6 Jan 2017 03:40:39 -0600 Subject: [PATCH 4/8] Concurrent transaction queue, serialize access to _transactions MutableDictionary. --- ios/Firestack/FirestackDatabase.h | 1 + ios/Firestack/FirestackDatabase.m | 39 ++++++++++++++++++++----------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/ios/Firestack/FirestackDatabase.h b/ios/Firestack/FirestackDatabase.h index 24eb8e4..9f27ff5 100644 --- a/ios/Firestack/FirestackDatabase.h +++ b/ios/Firestack/FirestackDatabase.h @@ -19,6 +19,7 @@ @property NSMutableDictionary *dbReferences; @property NSMutableDictionary *transactions; +@property dispatch_queue_t transactionQueue; @end diff --git a/ios/Firestack/FirestackDatabase.m b/ios/Firestack/FirestackDatabase.m index 9c67095..c0f24d5 100644 --- a/ios/Firestack/FirestackDatabase.m +++ b/ios/Firestack/FirestackDatabase.m @@ -22,6 +22,7 @@ @interface FirestackDBReference : NSObject @property FIRDatabaseHandle childMovedHandler; @property FIRDatabaseHandle childValueHandler; + (NSDictionary *) snapshotToDict:(FIRDataSnapshot *) snapshot; + @end @implementation FirestackDBReference @@ -366,6 +367,7 @@ - (id) init if (self != nil) { _dbReferences = [[NSMutableDictionary alloc] init]; _transactions = [[NSMutableDictionary alloc] init]; + _transactionQueue = dispatch_queue_create("com.fullstackreact.react-native-firestack", DISPATCH_QUEUE_CONCURRENT); } return self; } @@ -462,24 +464,29 @@ - (id) init applyLocally:(BOOL) applyLocally onComplete:(RCTResponseSenderBlock) onComplete) { - NSMutableDictionary *transactionState = [NSMutableDictionary new]; - [_transactions setValue:transactionState forKey:identifier]; - dispatch_semaphore_t sema = dispatch_semaphore_create(0); - [transactionState setObject:sema forKey:@"semaphore"]; - - FIRDatabaseReference *ref = [self getPathRef:path]; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + dispatch_async(_transactionQueue, ^{ + NSMutableDictionary *transactionState = [NSMutableDictionary new]; + + dispatch_semaphore_t sema = dispatch_semaphore_create(0); + [transactionState setObject:sema forKey:@"semaphore"]; + + FIRDatabaseReference *ref = [self getPathRef:path]; [ref runTransactionBlock:^FIRTransactionResult * _Nonnull(FIRMutableData * _Nonnull currentData) { - [self sendEventWithName:DATABASE_TRANSACTION_EVENT - body:@{ - @"id": identifier, - @"originalValue": currentData.value - }]; + dispatch_barrier_async(_transactionQueue, ^{ + [_transactions setValue:transactionState forKey:identifier]; + [self sendEventWithName:DATABASE_TRANSACTION_EVENT + body:@{ + @"id": identifier, + @"originalValue": currentData.value + }]; + }); // Wait for the event handler to call tryCommitTransaction dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); BOOL abort = [transactionState valueForKey:@"abort"]; id value = [transactionState valueForKey:@"value"]; - [_transactions removeObjectForKey:identifier]; + dispatch_barrier_async(_transactionQueue, ^{ + [_transactions removeObjectForKey:identifier]; + }); if (abort) { return [FIRTransactionResult abort]; } else { @@ -510,9 +517,13 @@ - (id) init withData:(NSDictionary *) data orAbort:(BOOL) abort) { - NSMutableDictionary *transactionState = [_transactions valueForKey:identifier]; + __block NSMutableDictionary *transactionState; + dispatch_sync(_transactionQueue, ^{ + transactionState = [_transactions objectForKey: identifier]; + }); if (!transactionState) { NSLog(@"tryCommitTransaction for unknown ID %@", identifier); + return; } dispatch_semaphore_t sema = [transactionState valueForKey:@"semaphore"]; if (abort) { From 1dc491101ab22d72bd02587e53c61f53280c199b Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Sat, 7 Jan 2017 20:03:43 -0600 Subject: [PATCH 5/8] Add to readme --- docs/api/database.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/api/database.md b/docs/api/database.md index 54594f1..2d31183 100644 --- a/docs/api/database.md +++ b/docs/api/database.md @@ -25,6 +25,13 @@ firestack.database() }); ``` +Transaction Support: +```javascript +firestack.database() + .ref('posts/1234/title') + .transaction((title) => 'My Awesome Post'); +``` + ## Unmounted components Listening to database updates on unmounted components will trigger a warning: From 12a3f1cd8cd8975a96d2f674f7980cf6071fc1cf Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Sat, 7 Jan 2017 20:25:12 -0600 Subject: [PATCH 6/8] Wrap update function in try block. --- lib/modules/database/index.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/modules/database/index.js b/lib/modules/database/index.js index 06579be..42b3041 100644 --- a/lib/modules/database/index.js +++ b/lib/modules/database/index.js @@ -174,13 +174,17 @@ export default class Database extends Base { _handleDatabaseTransaction(event) { const {id, originalValue} = event; - const updateCallback = this.transactions[id]; - const newValue = updateCallback(originalValue); - let abort = false; - if (newValue === undefined) { - abort = true; + let newValue; + try { + const updateCallback = this.transactions[id]; + newValue = updateCallback(originalValue); + } finally { + let abort = false; + if (newValue === undefined) { + abort = true; + } + FirestackDatabase.tryCommitTransaction(id, {value: newValue}, abort); } - FirestackDatabase.tryCommitTransaction(id, {value: newValue}, abort); } /** From 5d31027be68b2fa74c5c04bb20ac0ab8c50c16c6 Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Sat, 7 Jan 2017 23:54:49 -0600 Subject: [PATCH 7/8] Fix error 'TypeError: Cannot read property 'snapshot' of undefined' --- lib/modules/database/reference.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/database/reference.js b/lib/modules/database/reference.js index 530690b..b306365 100644 --- a/lib/modules/database/reference.js +++ b/lib/modules/database/reference.js @@ -126,7 +126,7 @@ export default class Reference extends ReferenceBase { transaction(transactionUpdate, onComplete, applyLocally) { const path = this._dbPath(); return this.db.addTransaction(path, transactionUpdate, applyLocally) - .then(({ snapshot, committed }) => {snapshot: new Snapshot(this, snapshot), committed}) + .then((({ snapshot, committed }) => {return {snapshot: new Snapshot(this, snapshot), committed}}).bind(this)) .then(({ snapshot, committed }) => { if (isFunction(onComplete)) onComplete(null, snapshot); return {snapshot, committed}; From 8fe95f28c4d7524bde7887241dc7c0188a86b07d Mon Sep 17 00:00:00 2001 From: Francisco Saldana Date: Sun, 8 Jan 2017 10:19:26 -0600 Subject: [PATCH 8/8] Add timeout --- ios/Firestack/FirestackDatabase.m | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ios/Firestack/FirestackDatabase.m b/ios/Firestack/FirestackDatabase.m index c0f24d5..f605a01 100644 --- a/ios/Firestack/FirestackDatabase.m +++ b/ios/Firestack/FirestackDatabase.m @@ -481,8 +481,12 @@ - (id) init }]; }); // Wait for the event handler to call tryCommitTransaction - dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); - BOOL abort = [transactionState valueForKey:@"abort"]; + // WARNING: This wait occurs on the Firebase Worker Queue + // so if tryCommitTransaction fails to signal the semaphore + // no further blocks will be executed by Firebase until the timeout expires + dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC); + BOOL timedout = dispatch_semaphore_wait(sema, delayTime) != 0; + BOOL abort = [transactionState valueForKey:@"abort"] || timedout; id value = [transactionState valueForKey:@"value"]; dispatch_barrier_async(_transactionQueue, ^{ [_transactions removeObjectForKey:identifier];