diff --git a/lib/modules/firestore/Transaction.js b/lib/modules/firestore/Transaction.js new file mode 100644 index 00000000..21f7699d --- /dev/null +++ b/lib/modules/firestore/Transaction.js @@ -0,0 +1,179 @@ +/** + * @flow + * Firestore Transaction representation wrapper + */ +import { mergeFieldPathData } from './utils'; +import { buildNativeMap } from './utils/serialize'; + +import type Firestore from './'; +import type { TransactionMeta } from './TransactionHandler'; +import type DocumentReference from './DocumentReference'; +import type DocumentSnapshot from './DocumentSnapshot'; +import { isObject, isString } from '../../utils'; +import FieldPath from './FieldPath'; +import { getNativeModule } from '../../utils/native'; + +type Command = { + type: 'set' | 'update' | 'delete', + path: string, + data: ?{ [string]: any }, + options: ?{ merge: boolean }, +}; + +type SetOptions = { + merge: boolean, +}; + +/** + * @class Transaction + */ +export default class Transaction { + _pendingResult: ?any; + _firestore: Firestore; + _meta: TransactionMeta; + _commandBuffer: Array; + + constructor(firestore: Firestore, meta: TransactionMeta) { + this._meta = meta; + this._commandBuffer = []; + this._firestore = firestore; + this._pendingResult = undefined; + } + + /** + * ------------- + * INTERNAL API + * ------------- + */ + + /** + * Clears the command buffer and any pending result in prep for + * the next transaction iteration attempt. + * + * @private + */ + _prepare() { + this._commandBuffer = []; + this._pendingResult = undefined; + } + + /** + * ------------- + * PUBLIC API + * ------------- + */ + + /** + * Reads the document referenced by the provided DocumentReference. + * + * @param documentRef DocumentReference A reference to the document to be retrieved. Value must not be null. + * + * @returns Promise + */ + get(documentRef: DocumentReference): Promise { + // todo validate doc ref + return getNativeModule(this._firestore).transactionGetDocument( + this._meta.id, + documentRef.path + ); + } + + /** + * Writes to the document referred to by the provided DocumentReference. + * If the document does not exist yet, it will be created. If you pass options, + * the provided data can be merged into the existing document. + * + * @param documentRef DocumentReference A reference to the document to be created. Value must not be null. + * @param data Object An object of the fields and values for the document. + * @param options SetOptions An object to configure the set behavior. + * Pass {merge: true} to only replace the values specified in the data argument. + * Fields omitted will remain untouched. + * + * @returns {Transaction} + */ + set( + documentRef: DocumentReference, + data: Object, + options?: SetOptions + ): Transaction { + // todo validate doc ref + // todo validate data is object + this._commandBuffer.push({ + type: 'set', + path: documentRef.path, + data: buildNativeMap(data), + options, + }); + + return this; + } + + /** + * Updates fields in the document referred to by this DocumentReference. + * The update will fail if applied to a document that does not exist. Nested + * fields can be updated by providing dot-separated field path strings or by providing FieldPath objects. + * + * @param documentRef DocumentReference A reference to the document to be updated. Value must not be null. + * @param args any Either an object containing all of the fields and values to update, + * or a series of arguments alternating between fields (as string or FieldPath + * objects) and values. + * + * @returns {Transaction} + */ + update(documentRef: DocumentReference, ...args: Array): Transaction { + // todo validate doc ref + let data = {}; + if (args.length === 1) { + if (!isObject(args[0])) { + throw new Error( + 'Transaction.update failed: If using a single data argument, it must be an object.' + ); + } + + [data] = args; + } else if (args.length % 2 === 1) { + throw new Error( + 'Transaction.update failed: Must have either a single object data argument, or equal numbers of data key/value pairs.' + ); + } else { + for (let i = 0; i < args.length; i += 2) { + const key = args[i]; + const value = args[i + 1]; + if (isString(key)) { + data[key] = value; + } else if (key instanceof FieldPath) { + data = mergeFieldPathData(data, key._segments, value); + } else { + throw new Error( + `Transaction.update failed: Argument at index ${i} must be a string or FieldPath` + ); + } + } + } + + this._commandBuffer.push({ + type: 'update', + path: documentRef.path, + data: buildNativeMap(data), + }); + + return this; + } + + /** + * Deletes the document referred to by the provided DocumentReference. + * + * @param documentRef DocumentReference A reference to the document to be deleted. Value must not be null. + * + * @returns {Transaction} + */ + delete(documentRef: DocumentReference): Transaction { + // todo validate doc ref + this._commandBuffer.push({ + type: 'delete', + path: documentRef.path, + }); + + return this; + } +} diff --git a/lib/modules/firestore/TransactionHandler.js b/lib/modules/firestore/TransactionHandler.js new file mode 100644 index 00000000..e51c1fcb --- /dev/null +++ b/lib/modules/firestore/TransactionHandler.js @@ -0,0 +1,231 @@ +/** + * @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: Array, + reject: null | Function, + resolve: null | 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; + _transactionListener: Function; + _pending: { [number]: TransactionMeta }; + + constructor(firestore: Firestore) { + this._pending = {}; + this._firestore = firestore; + this._transactionListener = 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(); + const meta = { + id, + reject: null, + resolve: null, + updateFunction, + stack: new Error().stack.slice(1), + }; + + meta.transaction = new Transaction(this._firestore, meta); + this._pending[id] = 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 + * @param pendingAbort Notify native that there's still an transaction in + * progress that needs aborting - this is to handle a JS side + * exception + * @private + */ + _remove(id, pendingAbort = false) { + // todo confirm pending arg no longer needed + getNativeModule(this._firestore).transactionDispose(id, pendingAbort); + // 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 { updateFunction, transaction, reject } = this._pending[id]; + + // 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) { + updateFailed = true; // in case the user rejects with nothing + finalError = exception; + } + + // reject the final promise and remove from native + if (updateFailed) { + 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).transactionProcessUpdateResponse( + 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) { + 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); + errorWithStack.code = code; + errorWithStack.stack = meta.stack; + + meta.reject(errorWithStack); + } + } + + /** + * Handles incoming native transaction complete events + * + * @param event + * @private + */ + _handleComplete(event: TransactionEvent) { + const { id } = event; + const meta = this._pending[id]; + + if (meta) { + const pendingResult = meta.transaction._pendingResult; + meta.resolve(pendingResult); + } + } +} diff --git a/lib/modules/firestore/index.js b/lib/modules/firestore/index.js index 7b5a185e..3e8d7ecd 100644 --- a/lib/modules/firestore/index.js +++ b/lib/modules/firestore/index.js @@ -13,6 +13,8 @@ import FieldValue from './FieldValue'; import GeoPoint from './GeoPoint'; import Path from './Path'; import WriteBatch from './WriteBatch'; +import TransactionHandler from './TransactionHandler'; +import Transaction from './Transaction'; import INTERNALS from '../../utils/internals'; import type DocumentSnapshot from './DocumentSnapshot'; @@ -36,8 +38,9 @@ type DocumentSyncEvent = { }; const NATIVE_EVENTS = [ - 'firestore_collection_sync_event', + 'firestore_transaction_event', 'firestore_document_sync_event', + 'firestore_collection_sync_event', ]; export const MODULE_NAME = 'RNFirebaseFirestore'; @@ -48,6 +51,7 @@ export const NAMESPACE = 'firestore'; */ export default class Firestore extends ModuleBase { _referencePath: Path; + _transactionHandler: TransactionHandler; constructor(app: App) { super(app, { @@ -56,7 +60,9 @@ export default class Firestore extends ModuleBase { multiApp: true, namespace: NAMESPACE, }); + this._referencePath = new Path([]); + this._transactionHandler = new TransactionHandler(this); SharedEventEmitter.addListener( // sub to internal native event - this fans out to @@ -73,11 +79,23 @@ export default class Firestore extends ModuleBase { ); } + /** + * ------------- + * PUBLIC API + * ------------- + */ + + /** + * Creates a write batch, used for performing multiple writes as a single atomic operation. + * + * @returns {WriteBatch} + */ batch(): WriteBatch { return new WriteBatch(this); } /** + * Gets a CollectionReference instance that refers to the collection at the specified path. * * @param collectionPath * @returns {CollectionReference} @@ -92,6 +110,7 @@ export default class Firestore extends ModuleBase { } /** + * Gets a DocumentReference instance that refers to the document at the specified path. * * @param documentPath * @returns {DocumentReference} @@ -105,13 +124,27 @@ export default class Firestore extends ModuleBase { return new DocumentReference(this, path); } - enablePersistence(): Promise { - throw new Error('Persistence is enabled by default on the Firestore SDKs'); + /** + * Executes the given updateFunction and then attempts to commit the + * changes applied within the transaction. If any document read within + * the transaction has changed, Cloud Firestore retries the updateFunction. + * + * If it fails to commit after 5 attempts, the transaction fails. + * + * @param updateFunction + * @returns {void|Promise} + */ + runTransaction( + updateFunction: (transaction: Transaction) => Promise + ): Promise { + return this._transactionHandler._add(updateFunction); } - runTransaction(): Promise { - throw new Error('firebase.firestore().runTransaction() coming soon'); - } + /** + * ------------- + * UNSUPPORTED + * ------------- + */ setLogLevel(): void { throw new Error( @@ -121,13 +154,45 @@ export default class Firestore extends ModuleBase { ) ); } + enableNetwork(): void { + throw new Error( + INTERNALS.STRINGS.ERROR_UNSUPPORTED_MODULE_METHOD( + 'firestore', + 'enableNetwork' + ) + ); + } + disableNetwork(): void { + throw new Error( + INTERNALS.STRINGS.ERROR_UNSUPPORTED_MODULE_METHOD( + 'firestore', + 'disableNetwork' + ) + ); + } + /** + * ------------- + * MISC + * ------------- + */ + + enablePersistence(): Promise { + throw new Error('Persistence is enabled by default on the Firestore SDKs'); + } settings(): void { throw new Error('firebase.firestore().settings() coming soon'); } + /** + * ------------- + * INTERNALS + * ------------- + */ + /** * Internal collection sync listener + * * @param event * @private */ @@ -147,6 +212,7 @@ export default class Firestore extends ModuleBase { /** * Internal document sync listener + * * @param event * @private */