import { filter, unionWith, concat, findKey, omit, cloneDeep } from 'lodash'; import { ItemSelector, BakedEntityHandler, ConditionFunc } from 'interfaces'; import * as Actions from './Actions'; import OperationManager from './OperationManager'; import Operation from './Operation'; const isEqualItemSelector = (a?: ItemSelector, b?: ItemSelector) => (a && b) && (a.entityType === b.entityType) && (a.id === b.id); export default class TransactionManager { firestore: any; firebase: any; om: OperationManager; getBakedEntityHandlers: (operationsNotResolved: Array) => Array; errorHandler: (error: Error) => any; getState: () => any; dispatch: (action: any) => any; constructor( firestore: any, firebase: any, errorHandler: (err) => any, getState: () => any, dispatch: (action: any) => any, initialOperation: Operation, getBakedEntityHandlers: (operationsNotResolved: Array) => Array ) { this.firestore = firestore; this.firebase = firebase; this.errorHandler = errorHandler; this.getState = getState; this.dispatch = dispatch; this.om = new OperationManager(initialOperation); this.getBakedEntityHandlers = getBakedEntityHandlers; } // TODO@Taemin: require refactoring // conditionFunc는 배송정보 변경할 때 payment==='success'인 것만 적용하기 위해 추가됨 async run(conditionFunc?: ConditionFunc): Promise<'DONE'> { const batch = this.firestore.batch(); // return new Promise(resolve => resolve('DONE')); let operationsRequireRead = this.om.getOperationsRequireRead(); let operationsNotResolved = this.om.getOperationsNotResolved(); const initialOperationReadPromises = this.generateReadPromises(operationsRequireRead); await Promise.all(initialOperationReadPromises); const initOpAfterRead = this.om.getInitialOperation(); if ( conditionFunc && !conditionFunc(initOpAfterRead.before, initOpAfterRead.after) ) { const newError = new Error('NOT_PASSED_CONDITION_FUNC_ON_UPDATE'); (newError).beforeItem = initOpAfterRead.before; (newError).afterItem = initOpAfterRead.after; throw newError; } while (operationsNotResolved.length > 0) { const bakedEntityHandlers = this.getBakedEntityHandlers(operationsNotResolved); // generate updateParentOperations on bakedEntityHandlers bakedEntityHandlers.forEach((bakedEntityHandler) => { const parentItemSelectors: Array = unionWith( bakedEntityHandler.beforeParentItemSelectors, bakedEntityHandler.afterParentItemSelectors, isEqualItemSelector ); parentItemSelectors.forEach((itemSelector) => { const parentUpdateOperation = new Operation('UPDATE', itemSelector, {}); this.om.addOperation(parentUpdateOperation); }); }); // read items in updateParentOperations operationsRequireRead = this.om.getOperationsRequireRead(); const parentOperationReadPromises = this.generateReadPromises(operationsRequireRead); await Promise.all(parentOperationReadPromises); // modify updateParentOperations with bakedEntityHandler bakedEntityHandlers.forEach((bakedEntityHandler) => { const selfOperation = this.om.getOperation(bakedEntityHandler.childItemSelector); const beforeParentOperations = bakedEntityHandler.beforeParentItemSelectors.map( itemSelector => this.om.getOperation(itemSelector) ); const afterParentOperations = bakedEntityHandler.afterParentItemSelectors.map( itemSelector => this.om.getOperation(itemSelector) ); bakedEntityHandler.operationModifier(selfOperation, beforeParentOperations, afterParentOperations); }); operationsNotResolved.forEach(operation => operation.markAsResolved()); operationsNotResolved = this.om.getOperationsNotResolved(); } const allOperations = this.om.getAllOperations(); // apply to DB this.applyOperationListToBatch(batch, allOperations); await batch.commit(); // apply to Store const actions = allOperations.map(operation => this.operationToAction(operation)); actions.forEach(action => this.dispatch(action)); return 'DONE'; } convertSpecialPropValue(targetToSave: 'STORE'|'DB', item: Object) { // handle 'DELETE_FIELD' const propKeyOfDeleteField = findKey( item, function(propValue: string) { return propValue === 'DELETE_FIELD' } ); if (propKeyOfDeleteField) { if (targetToSave === 'STORE') { return omit(item, propKeyOfDeleteField); } // targetToSave === 'DB' return { ...item, [propKeyOfDeleteField]: this.firebase.firestore.FieldValue.delete() }; } return item; } operationToAction(operation: Operation): Actions.SuccessAddItem | Actions.SuccessUpdateItem | Actions.SuccessDeleteItem { const { method, itemSelector, after } = operation; switch (method) { case 'ADD': return Actions.successAddItem(itemSelector, after); case 'UPDATE': return Actions.successUpdateItem(itemSelector, this.convertSpecialPropValue('STORE', after)); case 'DELETE': return Actions.successDeleteItem(itemSelector, after); default: throw Error(`Can't create actino of ${method} method.`); } } generateReadPromises(operations: Array): Array> { const operationsRequireRead = filter(operations, operation => operation.requireRead); // for safety const readPromises = operationsRequireRead.map((operation) => { const itemSelector = operation.itemSelector; const docRef = this.firestore.collection(itemSelector.entityType).doc(itemSelector.id); return docRef.get().then((doc) => { const docData = doc.data(); if (!docData) { this.errorHandler(new Error(`Couldn't read before, ${itemSelector.entityType}[${itemSelector.id}]`)); } operation.setBefore(docData); return 'DONE'; }); }); return readPromises; } applyOperationToBatch(batch: any, operation: Operation): Promise<'DONE'>|void { const itemSelector = operation.itemSelector; const { entityType, id } = itemSelector; const docRef = this.firestore.collection(entityType).doc(id); switch (operation.method) { case 'ADD': batch.set(docRef, operation.after); break; case 'UPDATE': batch.update(docRef, this.convertSpecialPropValue('DB', operation.partialAfter)); break; case 'DELETE': batch.delete(docRef); break; default: throw new Error(`Can't resolve ${operation.method}`); } } applyOperationListToBatch(batch: any, operations: Array): void { for (let i = 0; i < operations.length; i += 1) { this.applyOperationToBatch(batch, operations[i]); } } }