/** * @flow * Firestore Transaction representation wrapper */ import { getAppEventName, SharedEventEmitter } from '../../utils/events'; import { getLogger } from '../../utils/log'; import { getNativeModule } from '../../utils/native'; import Transaction from './Transaction'; import type Firestore from './'; let transactionId = 0; /** * Uses the push id generator to create a transaction id * @returns {number} * @private */ const generateTransactionId = (): number => transactionId++; export type TransactionMeta = { id: number, stack: string[], reject?: Function, resolve?: Function, transaction: Transaction, updateFunction: (transaction: Transaction) => Promise, }; type TransactionEvent = { id: number, type: 'update' | 'error' | 'complete', error: ?{ code: string, message: string }, }; /** * @class TransactionHandler */ export default class TransactionHandler { _firestore: Firestore; _pending: { [number]: { meta: TransactionMeta, transaction: Transaction, }, }; constructor(firestore: Firestore) { this._pending = {}; this._firestore = firestore; SharedEventEmitter.addListener( getAppEventName(this._firestore, 'firestore_transaction_event'), this._handleTransactionEvent.bind(this) ); } /** * ------------- * INTERNAL API * ------------- */ /** * Add a new transaction and start it natively. * @param updateFunction */ _add( updateFunction: (transaction: Transaction) => Promise ): Promise { const id = generateTransactionId(); // $FlowExpectedError: Transaction has to be populated const meta: TransactionMeta = { id, updateFunction, stack: new Error().stack .split('\n') .slice(4) .join('\n'), }; this._pending[id] = { meta, transaction: new Transaction(this._firestore, meta), }; // deferred promise return new Promise((resolve, reject) => { getNativeModule(this._firestore).transactionBegin(id); meta.resolve = r => { resolve(r); this._remove(id); }; meta.reject = e => { reject(e); this._remove(id); }; }); } /** * Destroys a local instance of a transaction meta * * @param id * @private */ _remove(id) { // todo confirm pending arg no longer needed getNativeModule(this._firestore).transactionDispose(id); // TODO may need delaying to next event loop delete this._pending[id]; } /** * ------------- * EVENTS * ------------- */ /** * Handles incoming native transaction events and distributes to correct * internal handler by event.type * * @param event * @returns {*} * @private */ _handleTransactionEvent(event: TransactionEvent) { switch (event.type) { case 'update': return this._handleUpdate(event); case 'error': return this._handleError(event); case 'complete': return this._handleComplete(event); default: getLogger(this._firestore).warn( `Unknown transaction event type: '${event.type}'`, event ); return undefined; } } /** * Handles incoming native transaction update events * * @param event * @private */ async _handleUpdate(event: TransactionEvent) { const { id } = event; // abort if no longer exists js side if (!this._pending[id]) return this._remove(id); const { meta, transaction } = this._pending[id]; const { updateFunction, reject } = meta; // clear any saved state from previous transaction runs transaction._prepare(); let finalError; let updateFailed; let pendingResult; // run the users custom update functionality try { const possiblePromise = updateFunction(transaction); // validate user has returned a promise in their update function // TODO must it actually return a promise? Can't find any usages of it without one... if (!possiblePromise || !possiblePromise.then) { finalError = new Error( 'Update function for `firestore.runTransaction(updateFunction)` must return a Promise.' ); } else { pendingResult = await possiblePromise; } } catch (exception) { // exception can still be falsey if user `Promise.reject();` 's with no args // so we track the exception with a updateFailed boolean to ensure no fall-through updateFailed = true; finalError = exception; } // reject the final promise and remove from native // update is failed when either the users updateFunction // throws an error or rejects a promise if (updateFailed) { // $FlowExpectedError: Reject will always be present return reject(finalError); } // capture the resolved result as we'll need this // to resolve the runTransaction() promise when // native emits that the transaction is final transaction._pendingResult = pendingResult; // send the buffered update/set/delete commands for native to process return getNativeModule(this._firestore).transactionApplyBuffer( id, transaction._commandBuffer ); } /** * Handles incoming native transaction error events * * @param event * @private */ _handleError(event: TransactionEvent) { const { id, error } = event; const { meta } = this._pending[id]; if (meta && error) { const { code, message } = error; // build a JS error and replace its stack // with the captured one at start of transaction // so it's actually relevant to the user const errorWithStack = new Error(message); // $FlowExpectedError: code is needed for Firebase errors errorWithStack.code = code; // $FlowExpectedError: stack should be a stack trace errorWithStack.stack = meta.stack; // $FlowExpectedError: Reject will always be present meta.reject(errorWithStack); } } /** * Handles incoming native transaction complete events * * @param event * @private */ _handleComplete(event: TransactionEvent) { const { id } = event; const { meta, transaction } = this._pending[id]; if (meta) { const pendingResult = transaction._pendingResult; // $FlowExpectedError: Resolve will always be present meta.resolve(pendingResult); } } }