Source: common/storage/storageclasses/editorstorage.js

/*globals define*/
/*eslint-env node*/
/**
 * This class implements the functionality needed to edit a model in a specific project and branch in a
 * collaborative fashion.
 *
 * It keeps a state of the open projects which in turn keeps track of the open branches.
 *
 * Each project is associated with a project-cache which is shared amongst the branches. So switching
 * between branches is (potentially) an operation that does not require lots of server round-trips.
 *
 * It is possible to have multiple projects open and multiple branches within each project. However
 * one instance of a storage can only hold a single instance of a project (or branch within a project).
 *
 * @author pmeijer / https://github.com/pmeijer
 */

define([
    'common/storage/storageclasses/objectloaders',
    'common/storage/constants',
    'common/storage/project/project',
    'common/storage/project/branch',
    'common/util/assert',
    'common/util/key',
    'common/storage/util',
    'q'
], function (StorageObjectLoaders, CONSTANTS, Project, Branch, assert, generateKey, UTIL, Q) {
    'use strict';

    /**
     *
     * @param webSocket
     * @param mainLogger
     * @param gmeConfig
     * @constructor
     */
    function EditorStorage(webSocket, mainLogger, gmeConfig) {
        var self = this,
            logger = mainLogger.fork('storage'),
            projects = {};

        self.logger = logger;
        self.userId = null;
        self.serverVersion = null;

        StorageObjectLoaders.call(this, webSocket, mainLogger, gmeConfig);

        function triggerNetworkChange(connectionState, networkHandler) {
            networkHandler(connectionState);
            self.dispatchEvent(CONSTANTS.NETWORK_STATUS_CHANGED, connectionState);
        }

        /**
         * Dig out the context for the server-worker request. Needed to determine if
         * the request needs be queued on the current commit-queue.
         * @param {object} swmParams
         * @returns {object} If the request contains a projectId and (branchName and/or commitHash). It
         * will return an object with projectId and (branchName and/or commitHash).
         */
        function extractSWMContext(swmParams) {
            var result = {};

            if (swmParams.projectId) {
                result.projectId = swmParams.projectId;
                if (swmParams.branchName || swmParams.branch || swmParams.commitHash || swmParams.commit) {
                    // Add any of these.
                    result.branchName = swmParams.branchName || swmParams.branch;
                    result.commitHash = swmParams.commitHash || swmParams.commit;
                }
            } else if (swmParams.context &&
                swmParams.context.managerConfig &&
                swmParams.context.managerConfig.project) {
                // This is a plugin request..
                result.projectId = swmParams.context.managerConfig.project;
                result.commitHash = swmParams.context.managerConfig.commitHash;
                result.branchName = swmParams.context.managerConfig.branchName;
            }

            return result;
        }

        this.open = function (networkHandler) {
            webSocket.connect(function (err, connectionState) {
                if (err) {
                    logger.error(err);
                    triggerNetworkChange(CONSTANTS.CONNECTION_ERROR, networkHandler);
                } else if (connectionState === CONSTANTS.CONNECTED) {
                    self.connected = true;
                    self.userId = webSocket.userId;
                    self.serverVersion = webSocket.serverVersion;
                    triggerNetworkChange(CONSTANTS.CONNECTED, networkHandler);
                } else if (connectionState === CONSTANTS.RECONNECTING) {
                    // This is an internal state only to handle rejoining of rooms.
                    // Technically the websocket is connected at this point.
                    self.reconnecting = true;
                } else if (connectionState === CONSTANTS.RECONNECTED) {
                    self.connected = true;
                    self._rejoinWatcherRooms()
                        .then(function () {
                            return self._rejoinBranchRooms();
                        })
                        .then(function () {
                            self.reconnecting = false;
                            triggerNetworkChange(CONSTANTS.RECONNECTED, networkHandler);
                        })
                        .catch(function (err) {
                            logger.error('failing during reconnect', err);
                            triggerNetworkChange(CONSTANTS.CONNECTION_ERROR, networkHandler);
                        });

                } else if (connectionState === CONSTANTS.DISCONNECTED) {
                    self.connected = false;
                    triggerNetworkChange(CONSTANTS.DISCONNECTED, networkHandler);
                } else if (connectionState === CONSTANTS.INCOMPATIBLE_CONNECTION) {
                    triggerNetworkChange(connectionState, networkHandler);
                } else if (connectionState === CONSTANTS.JWT_ABOUT_TO_EXPIRE) {
                    triggerNetworkChange(connectionState, networkHandler);
                } else if (connectionState === CONSTANTS.JWT_EXPIRED) {
                    triggerNetworkChange(connectionState, networkHandler);
                } else {
                    logger.error('unexpected connection state');
                    triggerNetworkChange(CONSTANTS.CONNECTION_ERROR, networkHandler);
                }
            });
        };

        this.close = function (callback) {
            logger.debug('Closing storage, openProjects', Object.keys(projects));

            return Q.allSettled(Object.keys(projects)
                .map(function (projectId) {
                    return self.closeProject(projectId);
                }))
                .then(function () {
                    // Remove the handler for the socket.io events 'connect' and 'disconnect'.
                    logger.debug('Removing connect and disconnect events');
                    webSocket.socket.removeAllListeners('connect');
                    webSocket.socket.removeAllListeners('disconnect');
                    // Disconnect from the server.
                    logger.debug('Disconnecting web-socket');
                    webSocket.disconnect();
                    self.connected = false;
                    // Remove all local event-listeners.
                    webSocket.clearAllEvents();
                    self.clearAllEvents();
                })
                .nodeify(callback);
        };

        this.getToken = function () {
            return webSocket.ioClient.getToken();
        };

        this.setToken = function (newToken) {
            return webSocket.ioClient.setToken(newToken);
        };

        /**
         * Callback for openProject.
         *
         * @callback EditorStorage~openProjectCallback
         * @param {string} err - error string.
         * @param {Project} project - the newly opened project.
         * @param {object} branches - the newly opened project.
         * @example
         * // branches is of the form
         * // { master: '#somevalidhash', b1: '#someothervalidhash' }
         */

        /**
         *
         * @param {string} projectId - name of project to open.
         * @param {EditorStorage~openProjectCallback} - callback
         */
        this.openProject = function (projectId, callback) {
            var deferred,
                data = {
                    projectId: projectId
                };

            if (projects[projectId]) {
                return Q.reject(new Error('project is already open ' + projectId)).nodeify(callback);
            }

            webSocket.openProject(data)
                .spread(function (branches, access) {
                    var project = new Project(projectId, self, logger, gmeConfig);
                    projects[projectId] = project;

                    if (callback) {
                        callback(null, project, branches, access);
                    } else {
                        deferred.resolve([project, branches, access]);
                    }
                })
                .catch(function (err) {
                    if (callback) {
                        callback(err);
                    } else {
                        deferred.reject(err);
                    }
                });

            if (!callback) {
                deferred = Q.defer();
                return deferred.promise;
            }
        };

        this.closeProject = function (projectId, callback) {
            logger.debug('closeProject', projectId);

            if (projects[projectId]) {
                return Q.allSettled(Object.keys(projects[projectId].branches)
                    .map(function (branchName) {
                        return self.closeBranch(projectId, branchName);
                    }))
                    .then(function () {
                        if (self.connected) {
                            return webSocket.closeProject({projectId: projectId});
                        } else {
                            logger.debug('Disconnected while closing project.. skipping webSocket request to server.');
                        }
                    })
                    .then(function () {
                        delete projects[projectId];
                    })
                    .nodeify(callback);
            } else {
                logger.warn('Project is not open ', projectId);
                return Q().nodeify(callback);
            }
        };

        this.openBranch = function (projectId, branchName, hashUpdateHandler, branchStatusHandler, callback) {
            var project = projects[projectId],
                data = {
                    projectId: projectId,
                    branchName: branchName
                },
                deferred,
                branch;

            if (!project) {
                return Q.reject(
                    new Error('Cannot open branch, ' + branchName + ', project ' + projectId + ' is not opened.'))
                    .nodeify(callback);
            }

            if (project.branches[branchName]) {
                return Q.reject(new Error('Branch is already open ' + branchName + ', project: ' + projectId))
                    .nodeify(callback);
            }

            logger.debug('openBranch, calling webSocket openBranch', projectId, branchName);

            deferred = Q.defer();

            webSocket.openBranch(data)
                .then(function (latestCommit) {
                    var branchHash;

                    branch = new Branch(branchName, project.logger);
                    project.branches[branchName] = branch;

                    // Update state of branch
                    branch.latestCommitData = latestCommit;
                    branchHash = latestCommit.commitObject[CONSTANTS.MONGO_ID];
                    branch.updateHashes(branchHash, branchHash);

                    // Add handlers to branch and set the remote update handler for the web-socket.
                    branch.addHashUpdateHandler(hashUpdateHandler);
                    branch.addBranchStatusHandler(branchStatusHandler);

                    branch._remoteUpdateHandler = function (_ws, updateData, initCallback) {
                        var j,
                            originHash = updateData.commitObject[CONSTANTS.MONGO_ID];
                        logger.debug('_remoteUpdateHandler invoked for project, branch', projectId, branchName);
                        for (j = 0; j < updateData.coreObjects.length; j += 1) {
                            if (updateData.coreObjects[j] && updateData.coreObjects[j].type === 'patch') {
                                project.insertPatchObject(updateData.coreObjects[j]);
                            } else {
                                project.insertObject(updateData.coreObjects[j]);
                            }
                        }

                        branch.queueUpdate(updateData);
                        branch.updateHashes(null, originHash);

                        if (branch.getCommitQueue().length === 0) {
                            if (branch.getUpdateQueue().length === 1) {
                                self._pullNextQueuedCommit(projectId, branchName, initCallback); // hashUpdateHandlers
                            }
                        } else {
                            logger.debug('commitQueue is not empty, only updating originHash.');
                        }
                    };

                    branch._remoteUpdateHandler(null, latestCommit, function (err) {
                        webSocket.addEventListener(webSocket.getBranchUpdateEventName(projectId, branchName),
                            branch._remoteUpdateHandler);

                        if (err) {
                            deferred.reject(err);
                        } else {
                            deferred.resolve(latestCommit);
                        }
                    });
                })
                .catch(deferred.reject);

            return deferred.promise.nodeify(callback);
        };

        this.closeBranch = function (projectId, branchName, callback) {
            var project = projects[projectId],
                branch;

            logger.debug('closeBranch', projectId, branchName);

            if (!project) {
                logger.warn('closeBranch: project is not open', projectId, branchName);
                return Q(null).nodeify(callback);
            }

            branch = project.branches[branchName];

            if (!branch) {
                logger.warn('closeBranch: project does not have given branch.', projectId, branchName);
                return Q(null).nodeify(callback);
            }

            // This will prevent memory leaks and expose if a commit is being
            // processed at the server this time (see last error in _pushNextQueuedCommit).
            branch.dispatchBranchStatus(null);

            // Stop listening to events from the server
            webSocket.removeEventListener(webSocket.getBranchUpdateEventName(projectId, branchName),
                branch._remoteUpdateHandler);

            branch.cleanUp();
            if (self.connected) {
                return webSocket.closeBranch({projectId: projectId, branchName: branchName})
                    .then(function () {
                        delete project.branches[branchName];
                    })
                    .nodeify(callback);
            } else {
                logger.debug('Disconnected while closing branch.. skipping webSocket request to server.');
                delete project.branches[branchName];
                return Q(null).nodeify(callback);
            }
        };

        this.forkBranch = function (projectId, branchName, forkName, commitHash, callback) {
            var project = projects[projectId],
                branch,
                forkData;

            this.logger.debug('forkBranch', projectId, branchName, forkName, commitHash);

            if (!project) {
                return Q.reject(new Error('Cannot fork branch, ' + branchName + ', project '
                    + projectId + ' is not opened.'))
                    .nodeify(callback);
            }

            branch = project.branches[branchName];

            if (!branch) {
                return Q.reject(new Error('Cannot fork branch, branch is not open ' + branchName +
                    ', project: ' + projectId))
                    .nodeify(callback);
            }

            forkData = branch.getCommitsForNewFork(commitHash, forkName); // commitHash = null defaults to latest commit
            self.logger.debug('forkBranch - forkData', forkData);

            if (forkData === false) {
                return Q.reject(new Error('Could not find specified commitHash: ' + commitHash)).nodeify(callback);
            }

            return self.persistCommits(forkData.queue)
                .then(function () {
                    return self.createBranch(projectId, forkName, forkData.commitHash);
                })
                .then(function () {
                    return forkData.commitHash;
                })
                .nodeify(callback);
        };

        this.persistCommits = function (commitQueue, callback) {
            var deferred = Q.defer(),
                commitHash;

            function commitNext(i) {
                var currentCommitData = commitQueue[i];

                if (i < commitQueue.length) {
                    currentCommitData = commitQueue[i];
                    logger.debug('persistCommits - commitNext, currentCommitData', currentCommitData);
                    delete currentCommitData.branchName;
                    commitHash = currentCommitData.commitObject[CONSTANTS.MONGO_ID];

                    webSocket.makeCommit(currentCommitData)
                        .then(function (result) {
                            logger.debug('persistCommits - commit successful, hash', result);
                            commitNext(i += 1);
                        })
                        .catch(deferred.reject);
                } else {
                    deferred.resolve(commitHash);
                }
            }

            commitNext(0);

            return deferred.promise.nodeify(callback);
        };

        this.makeCommit = function (projectId, branchName, parents, rootHash, coreObjects, msg, callback) {
            var project = projects[projectId],
                branch,
                commitId,
                commitCallback,
                persistQueueElement = {},
                commitData = {
                    rootHash: rootHash,
                    projectId: projectId,
                    commitObject: null,
                    coreObjects: {},
                    changedNodes: null
                },
                keys = Object.keys(coreObjects),
                i;

            //handling patch object creation
            // console.time('patch-computation');
            for (i = 0; i < keys.length; i += 1) {
                if (UTIL.coreObjectHasOldAndNewData(coreObjects[keys[i]])) {
                    // Patch type object.
                    persistQueueElement[keys[i]] = coreObjects[keys[i]].newData;
                    if (keys[i] === rootHash) {
                        // console.time('root-patch-computation');
                    }
                    commitData.coreObjects[keys[i]] = UTIL.getPatchObject(coreObjects[keys[i]].oldData,
                        coreObjects[keys[i]].newData);
                    //if (keys[i] === rootHash) {
                    // console.timeEnd('root-patch-computation');
                    //}
                } else if (coreObjects[keys[i]].newData && coreObjects[keys[i]].newHash) {
                    // A new object with no previous data (send the entire data).
                    commitData.coreObjects[keys[i]] = coreObjects[keys[i]].newData;
                    persistQueueElement[keys[i]] = coreObjects[keys[i]].newData;
                } else {
                    // A regular object.
                    commitData.coreObjects[keys[i]] = coreObjects[keys[i]];
                    persistQueueElement[keys[i]] = coreObjects[keys[i]];
                }
            }
            // console.timeEnd('patch-computation');
            // console.time('getChangedNodes');

            commitData.changedNodes = UTIL.getChangedNodes(commitData.coreObjects, rootHash);

            // console.timeEnd('getChangedNodes');

            commitData.commitObject = self._getCommitObject(projectId, parents, commitData.rootHash, msg);

            if (project) {
                project.insertObject(commitData.commitObject);
                commitId = commitData.commitObject[CONSTANTS.MONGO_ID];

                commitCallback = function commitCallback() {
                    delete project.projectCache.queuedPersists[commitId];
                    self.logger.debug('Removed now persisted core-objects from cache: ',
                        Object.keys(project.projectCache.queuedPersists).length);
                    callback.apply(null, arguments);
                };

                project.projectCache.queuedPersists[commitId] = persistQueueElement;
                logger.debug('Queued non-persisted core-objects in cache: ',
                    Object.keys(project.projectCache.queuedPersists).length);
            } else {
                commitCallback = callback;
            }

            if (typeof branchName === 'string') {
                commitData.branchName = branchName;
                branch = project ? project.branches[branchName] : null;
            }

            logger.debug('makeCommit', commitData);
            if (branch) {
                logger.debug('makeCommit, branch is open will commit using commitQueue. branchName:', branchName);
                self._commitToBranch(projectId, branchName, commitData, parents[0], commitCallback);
            } else {
                webSocket.makeCommit(commitData, commitCallback);
            }

            return commitData.commitObject;
        };

        this.setBranchHash = function (projectId, branchName, newHash, oldHash, callback) {
            var project = projects[projectId];

            logger.debug('setBranchHash', projectId, branchName, newHash, oldHash);
            if (project && project.branches[branchName]) {
                logger.debug('setBranchHash, branch is open, will notify other local users about change');
                project.loadObject(newHash, function (err, commitObject) {
                    var commitData;
                    if (err) {
                        logger.error('setBranchHash, failed to load in commitObject');
                        //branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.ERROR, err);
                        callback(err);
                        return;
                    }
                    logger.debug('setBranchHash, loaded commitObject');
                    commitData = {
                        projectId: projectId,
                        branchName: branchName,
                        coreObjects: {},
                        changedNodes: null,
                        commitObject: commitObject,
                        oldHash: oldHash
                    };
                    self._commitToBranch(projectId, branchName, commitData, oldHash, callback);
                });
            } else {
                StorageObjectLoaders.prototype.setBranchHash.call(self,
                    projectId, branchName, newHash, oldHash)
                    .nodeify(callback);
            }
        };

        this.simpleRequest = function (parameters, callback) {
            // This method is overridden here in order to avoid worker-requests
            // to be sent out referencing commits that haven't made it to the server yet.
            var context = extractSWMContext(parameters),
                commitHash,
                deferred,
                queuedInBranch;

            if (context.projectId && projects[context.projectId]) {
                // The request deals with a currently opened project - let's see if there is
                // a commitHash and/or branch associated with the request..
                if (context.commitHash) {
                    commitHash = context.commitHash;

                    if (context.branchName &&
                        projects[context.projectId].branches[context.branchName] &&
                        projects[context.projectId].branches[context.branchName].getQueuedHashes()
                            .indexOf(context.commitHash) > -1) {
                        // Since both commitHash and branchName was specified and the commitHash was queued
                        // in that branch - this is the branch to pick.
                        queuedInBranch = context.branchName;
                    } else {
                        // No branch was specified - let's see if the commit is queued in any opened branch.
                        // (Typically there's really only one open.)
                        Object.keys(projects[context.projectId].branches)
                            .forEach(function (branchName) {
                                if (projects[context.projectId].branches[branchName].getQueuedHashes()
                                    .indexOf(context.commitHash) > -1) {
                                    queuedInBranch = branchName;
                                }
                            });
                    }
                } else if (context.branchName && projects[context.projectId].branches[context.branchName]) {
                    // There is no specific commit-associated with request. However since branchName was passed
                    // we can only assume that it should run on the last commit in that branch.

                    commitHash = projects[context.projectId].branches[context.branchName].getQueuedHashes()[0];

                    if (commitHash) {
                        queuedInBranch = context.branchName;
                    }
                }

                if (queuedInBranch) {
                    deferred = Q.defer();

                    projects[context.projectId].branches[queuedInBranch].queueWorkerRequest(commitHash, {
                        release: function () {
                            StorageObjectLoaders.prototype.simpleRequest.call(self, parameters)
                                .then(deferred.resolve)
                                .catch(deferred.reject);
                        },
                        abort: function () {
                            deferred.reject(new Error('Queued worker request was aborted. Commit ' + commitHash +
                                ' in branch [' + queuedInBranch + '] never made it to the server.'));
                        }
                    });

                    return deferred.promise.nodeify(callback);
                }
            }

            return StorageObjectLoaders.prototype.simpleRequest.call(self, parameters).nodeify(callback);
        };

        this._commitToBranch = function (projectId, branchName, commitData, oldCommitHash, callback) {
            var project = projects[projectId],
                newCommitHash = commitData.commitObject._id,
                branch = project.branches[branchName],
                wasFirstInQueue,
                eventData = {
                    commitData: commitData,
                    local: true
                };

            logger.debug('_commitToBranch, [oldCommitHash, localHash]', oldCommitHash, branch.getLocalHash());

            if (oldCommitHash === branch.getLocalHash()) {
                branch.updateHashes(newCommitHash, null);
                branch.queueCommit(commitData, callback);

                if (branch.inSync === false) {
                    branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.AHEAD_NOT_SYNC);
                } else {
                    branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.AHEAD_SYNC);
                }

                // Get the queue length before dispatching because within the asynchrony,
                // the queue may get longer and we end up never pushing any commit.
                wasFirstInQueue = branch.getCommitQueue().length === 1;

                branch.dispatchHashUpdate(eventData, function (err, proceed) {
                    logger.debug('_commitToBranch, dispatchHashUpdate done. [err, proceed]', err, proceed);

                    if (err) {
                        branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.ERROR, err);
                        callback(new Error('Commit failed being loaded in users: ' + err));
                    } else if (proceed === true) {
                        if (wasFirstInQueue) {
                            logger.debug('_commitToBranch, commit was first in queue - will start pushing commit');
                            self._pushNextQueuedCommit(projectId, branchName);
                        } else {
                            logger.debug('_commitToBranch, commit was NOT first in queue');
                        }
                    } else {
                        callback(new Error('Commit halted when loaded in users (proceed was not true).'));
                    }
                });
            } else {
                // The current user is behind the local branch, e.g. plugin trying to save after client changes.
                logger.warn('_commitToBranch, incoming commit parent was not the same as the localHash ' +
                    'for the branch, commit will be canceled!');
                callback(null, {status: CONSTANTS.CANCELED, hash: newCommitHash});
            }
        };

        this._pushNextQueuedCommit = function (projectId, branchName) {
            var project = projects[projectId],
                branch = project.branches[branchName],
                commitData;

            logger.debug('_pushNextQueuedCommit, length=', branch.getCommitQueue().length);

            commitData = branch.getFirstCommit();

            logger.debug('_pushNextQueuedCommit, makeCommit [from# -> to#]',
                commitData.commitObject.parents[0], commitData.commitObject._id);

            webSocket.makeCommit(commitData, function (err, result) {
                var mergeCommitData;
                if (err) {
                    logger.error('makeCommit failed', err);
                }

                if (branch.isOpen) {
                    branch.callbackQueue[0](err, result);
                    if (!err && result) {
                        branch.commitInserted(result.hash);
                        if (result.status === CONSTANTS.SYNCED) {
                            branch.inSync = true;
                            branch.updateHashes(null, result.hash);
                            branch.getFirstCommit(true);
                            if (branch.getCommitQueue().length === 0) {
                                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.SYNC);
                            } else {
                                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.AHEAD_SYNC);
                                self._pushNextQueuedCommit(projectId, branchName);
                            }
                        } else if (result.status === CONSTANTS.MERGED) {
                            branch.inSync = true;
                            branch.updateHashes(null, result.mergeHash);

                            if (branch.getCommitQueue().length === 1) {
                                // Finds the MERGED commit-data and clears the update-queue.
                                mergeCommitData = branch.getMergedCommit(result.mergeHash);
                                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.MERGING);
                                branch.dispatchHashUpdate({commitData: mergeCommitData, local: false},
                                    function (err, proceed) {
                                        branch.getFirstCommit(true);
                                        if (err) {
                                            logger.error('Loading of merged commit failed with error', err,
                                                {metadata: mergeCommitData});
                                            branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.ERROR, err);
                                        } else if (proceed === true) {
                                            logger.debug('Merged commit was successfully loaded, updating localHash.');
                                            branch.updateHashes(result.mergeHash, null);
                                            // TODO: What if a commit is made during the hashUpdate?
                                            branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.SYNC);
                                            return;
                                        } else {
                                            logger.warn('Loading of update commit was aborted',
                                                {metadata: result.mergeHash});
                                        }
                                    }
                                );
                            } else {
                                branch.getFirstCommit(true);
                                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.MERGING);
                                self._pushNextQueuedCommit(projectId, branchName);
                            }
                        } else if (result.status === CONSTANTS.FORKED) {
                            branch.inSync = false;
                            branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.AHEAD_NOT_SYNC);
                        } else {
                            err = new Error('Unsupported commit status ' + result.status);
                            logger.error(err);
                            branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.ERROR, err);
                        }
                    } else {
                        branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.ERROR,
                            err || new Error('No result at commit.'));
                    }
                } else {
                    logger.error('_pushNextQueuedCommit returned from server but the branch was closed, ' +
                        'the branch has probably been closed while waiting for the response.', projectId, branchName);
                }
            });
        };

        this._pullNextQueuedCommit = function (projectId, branchName, callback) {
            assert(Object.hasOwn(projects, projectId), 'Project not opened: ' + projectId);
            var project = projects[projectId],
                branch = project.branches[branchName],
                error,
                updateData;

            if (!branch) {
                error = new Error('Branch, ' + branchName + ', not in project ' + projectId + '.');
                if (callback) {
                    callback(error);
                    return;
                } else {
                    throw error;
                }
            }

            logger.debug('About to update, updateQueue', {metadata: branch.getUpdateQueue()});
            if (branch.getUpdateQueue().length === 0) {
                logger.debug('No queued updates, returns');
                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.SYNC);
                if (callback) {
                    callback(null);
                }
                return;
            }

            updateData = branch.getFirstUpdate();

            if (branch.isOpen) {
                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.PULLING);
                branch.dispatchHashUpdate({commitData: updateData, local: false}, function (err, proceed) {
                    var originHash = updateData.commitObject[CONSTANTS.MONGO_ID];
                    if (err) {
                        logger.error('Loading of update commit failed with error', err, {metadata: updateData});
                        branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.ERROR, err);
                    } else if (proceed === true) {
                        logger.debug('New commit was successfully loaded, updating localHash.');
                        branch.updateHashes(originHash, null);
                        branch.getFirstUpdate(true);
                        if (branch.getCommitQueue().length === 0) {
                            self._pullNextQueuedCommit(projectId, branchName, callback);
                        }
                        return;
                    } else {
                        logger.warn('Loading of update commit was aborted', {metadata: updateData});
                    }
                    if (callback) {
                        callback(new Error('Loading the first commit was aborted'));
                    }
                });
            } else {
                logger.error('_pullNextQueuedCommit returned from server but the branch was closed.',
                    projectId, branchName);
            }
        };

        this._getCommitObject = function (projectId, parents, rootHash, msg) {
            msg = msg || 'n/a';
            var commitObj = {
                    root: rootHash,
                    parents: parents,
                    updater: [self.userId],
                    time: Date.now(),
                    message: msg,
                    type: CONSTANTS.COMMIT_TYPE,
                    __v: CONSTANTS.VERSION
                },
                commitHash = '#' + generateKey(commitObj, gmeConfig);

            commitObj[CONSTANTS.MONGO_ID] = commitHash;

            return commitObj;
        };

        this._rejoinBranchRooms = function (callback) {
            var projectId,
                project,
                branchName,
                branchRooms = [],
                promises = [];

            logger.debug('_rejoinBranchRooms');

            function afterRejoin(projectId, branchName) {
                var project = projects[projectId],
                    deferred = Q.defer();

                logger.debug('_rejoinBranchRooms, rejoined branch room', projectId, branchName);

                if (project) {
                    project.getBranchHash(branchName)
                        .then(function (branchHash) {
                            var branch = project.branches[branchName],
                                queuedCommitHash;
                            logger.debug('_rejoinBranchRooms received branchHash', projectId, branchName, branchHash);

                            if (!branch) {
                                throw new Error('_rejoinBranchRooms branch was closed ' + projectId + ':' + branchName);
                            }

                            if (branch.getCommitQueue().length > 0) {
                                queuedCommitHash = branch.getFirstCommit().commitObject._id;
                                logger.debug('_rejoinBranchRooms, commits were queued length=, firstQueuedCommitHash',
                                    branch.getCommitQueue().length, queuedCommitHash);

                                project.getCommonAncestorCommit(branchHash, queuedCommitHash)
                                    .then(function (commonCommitHash) {
                                        var result;
                                        // The commit made it to the server but the acknowledgement was
                                        // interrupted by the disconnect.

                                        logger.debug('_rejoinBranchRooms getCommonAncestorCommit',
                                            projectId, branchName, commonCommitHash);

                                        if (branch.isOpen === false) {
                                            throw new Error('_rejoinBranchRooms branch was closed ' +
                                                projectId + ':' + branchName);
                                        }

                                        function dispatchSynced() {
                                            result = {status: CONSTANTS.SYNCED, hash: branchHash};

                                            branch.callbackQueue[0](null, result);
                                            branch.inSync = true;
                                            branch.updateHashes(null, branchHash);
                                            branch.getFirstCommit(true);
                                            if (branch.getCommitQueue().length === 0) {
                                                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.SYNC);
                                            } else {
                                                branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.AHEAD_SYNC);
                                                self._pushNextQueuedCommit(projectId, branchName);
                                            }
                                        }

                                        function dispatchForked() {
                                            result = {status: CONSTANTS.FORKED, hash: branchHash};

                                            branch.callbackQueue[0](null, result);
                                            branch.inSync = false;
                                            branch.dispatchBranchStatus(CONSTANTS.BRANCH_STATUS.AHEAD_NOT_SYNC);
                                        }

                                        // c - the commit made by this storage
                                        // H - the head of the branch
                                        if (commonCommitHash === queuedCommitHash) {
                                            // The commit is (or was) in sync with the branch.
                                            //  Hc  or  H
                                            //  |       c
                                            //  |       |
                                            // In case two the next commit made will be forked.
                                            dispatchSynced();
                                        } else if (commonCommitHash === branchHash) {
                                            // The branch has moved back since the commit was made.
                                            // Treat it like the commit was forked.
                                            //  c
                                            //  H
                                            dispatchForked();
                                        } else {
                                            // The branch has moved forward in a different direction.
                                            //  c   H
                                            //   \ /
                                            dispatchForked();
                                        }

                                        deferred.resolve();
                                    })
                                    .catch(function (err) {
                                        try {
                                            if (err.message.indexOf('Commit object does not exist [' +
                                                queuedCommitHash) > -1) {
                                                // Commit never made it to the server - push it.
                                                logger.debug('First queued commit never made it to the server - push!');
                                                self._pushNextQueuedCommit(projectId, branchName);
                                                deferred.resolve();
                                            } else {
                                                deferred.reject(err);
                                            }
                                        } catch (err) {
                                            deferred.reject(err);
                                        }
                                    });
                            } else {
                                logger.debug('_rejoinBranchRooms, no commits were queued during disconnect.');
                                deferred.resolve();
                            }
                        })
                        .catch(function (err) {
                            deferred.reject(err);
                        });
                } else {
                    deferred.reject(new Error('_rejoinBranchRooms project was closed ' + projectId + ':' + branchName));
                }

                return deferred.promise;
            }

            for (projectId in projects) {
                if (Object.hasOwn(projects, projectId)) {
                    project = projects[projectId];
                    logger.debug('_rejoinBranchRooms found project', projectId);
                    for (branchName in project.branches) {
                        if (Object.hasOwn(project.branches, branchName)) {
                            logger.debug('_rejoinBranchRooms joining branch', projectId, branchName);

                            branchRooms.push({
                                projectId: projectId,
                                branchName: branchName
                            });

                            promises.push(webSocket.watchBranch({
                                projectId: projectId,
                                branchName: branchName,
                                join: true
                            }));
                        }
                    }
                }
            }

            return Q.all(promises)
                .then(function () {
                    return Q.all(branchRooms.map(function (data) {
                        // Deal with commit queue for each room after rejoining.
                        return afterRejoin(data.projectId, data.branchName);
                    }));
                })
                .nodeify(callback);
        };

        this.sendWsRouterMessage = webSocket.sendWsRouterMessage;
        this.onWebsocketRouterMessage = webSocket.onWebsocketRouterMessage;
         
        this.CONSTANTS = CONSTANTS;
    }

    EditorStorage.prototype = Object.create(StorageObjectLoaders.prototype);
    EditorStorage.prototype.constructor = EditorStorage;

    EditorStorage.CONSTANTS = CONSTANTS;

    return EditorStorage;
});