Source: common/storage/storageclasses/watchers.js

/*globals define*/
/*eslint-env node, browser*/
/**
 * Provides watching-functionality of the database and specific projects.
 * Keeps a state of the registered watchers.
 *
 * @author pmeijer / https://github.com/pmeijer
 */

define([
    'q',
    'webgme-ot',
    'common/storage/constants',
    'common/util/guid',
    'common/EventDispatcher'
], function (Q, ot, CONSTANTS, GUID, EventDispatcher) {
    'use strict';

    function StorageWatcher(webSocket, logger, gmeConfig) {
        EventDispatcher.call(this);
        // watcher counters determining when to join/leave a room on the sever
        this.watchers = {
            sessionId: GUID(), // Need at reconnect since socket.id changes.
            database: 0,
            projects: {},
            documents: {}
        };
        this.webSocket = webSocket;
        this.logger = this.logger || logger.fork('storage');
        this.gmeConfig = gmeConfig;
        this.logger.debug('StorageWatcher ctor');
        this.connected = false;
    }

    // Inherit from the EventDispatcher
    StorageWatcher.prototype = Object.create(EventDispatcher.prototype);
    StorageWatcher.prototype.constructor = StorageWatcher;

    function _splitDocId(docId) {
        var pieces = docId.split(CONSTANTS.ROOM_DIVIDER);

        return {
            projectId: pieces[0],
            branchName: pieces[1],
            nodeId: pieces[2],
            attrName: pieces[3]
        };
    }

    StorageWatcher.prototype.watchDatabase = function (eventHandler, callback) {
        this.logger.debug('watchDatabase - handler added');
        this.webSocket.addEventListener(CONSTANTS.PROJECT_DELETED, eventHandler);
        this.webSocket.addEventListener(CONSTANTS.PROJECT_CREATED, eventHandler);
        this.watchers.database += 1;
        this.logger.debug('Nbr of database watchers:', this.watchers.database);

        if (this.watchers.database === 1) {
            this.logger.debug('First watcher will enter database room.');
            return this.webSocket.watchDatabase({join: true}).nodeify(callback);
        } else {
            return Q().nodeify(callback);
        }
    };

    StorageWatcher.prototype.unwatchDatabase = function (eventHandler, callback) {
        var deferred = Q.defer();

        this.logger.debug('unwatchDatabase - handler will be removed');
        this.logger.debug('Nbr of database watchers (before removal):', this.watchers.database);
        this.webSocket.removeEventListener(CONSTANTS.PROJECT_DELETED, eventHandler);
        this.webSocket.removeEventListener(CONSTANTS.PROJECT_CREATED, eventHandler);
        this.watchers.database -= 1;

        if (this.watchers.database === 0) {
            this.logger.debug('No more watchers will exit database room.');
            if (this.connected) {
                this.webSocket.watchDatabase({join: false})
                    .then(deferred.resolve)
                    .catch(deferred.reject);
            } else {
                deferred.resolve();
            }
        } else if (this.watchers.database < 0) {
            this.logger.error('Number of database watchers became negative!');
            deferred.reject(new Error('Number of database watchers became negative!'));
        } else {
            deferred.resolve();
        }

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

    StorageWatcher.prototype.watchProject = function (projectId, eventHandler, callback) {
        this.logger.debug('watchProject - handler added for project', projectId);
        this.webSocket.addEventListener(CONSTANTS.BRANCH_DELETED + projectId, eventHandler);
        this.webSocket.addEventListener(CONSTANTS.BRANCH_CREATED + projectId, eventHandler);
        this.webSocket.addEventListener(CONSTANTS.BRANCH_HASH_UPDATED + projectId, eventHandler);

        this.watchers.projects[projectId] = Object.hasOwn(this.watchers.projects, projectId) ?
            this.watchers.projects[projectId] + 1 : 1;
        this.logger.debug('Nbr of watchers for project:', projectId, this.watchers.projects[projectId]);
        if (this.watchers.projects[projectId] === 1) {
            this.logger.debug('First watcher will enter project room:', projectId);
            this.webSocket.watchProject({projectId: projectId, join: true})
                .nodeify(callback);
        } else {
            return Q().nodeify(callback);
        }
    };

    StorageWatcher.prototype.unwatchProject = function (projectId, eventHandler, callback) {
        var deferred = Q.defer();

        this.logger.debug('unwatchProject - handler will be removed', projectId);
        this.logger.debug('Nbr of database watchers (before removal):', projectId,
            this.watchers.projects[projectId]);
        this.webSocket.removeEventListener(CONSTANTS.BRANCH_DELETED + projectId, eventHandler);
        this.webSocket.removeEventListener(CONSTANTS.BRANCH_CREATED + projectId, eventHandler);
        this.webSocket.removeEventListener(CONSTANTS.BRANCH_HASH_UPDATED + projectId, eventHandler);

        this.watchers.projects[projectId] = Object.hasOwn(this.watchers.projects, projectId) ?
            this.watchers.projects[projectId] - 1 : -1;
        if (this.watchers.projects[projectId] === 0) {
            this.logger.debug('No more watchers will exit project room:', projectId);
            delete this.watchers.projects[projectId];
            if (this.connected) {
                this.webSocket.watchProject({projectId: projectId, join: false})
                    .then(deferred.resolve)
                    .catch(deferred.reject);
            } else {
                deferred.resolve();
            }
        } else if (this.watchers.projects[projectId] < 0) {
            this.logger.error('Number of project watchers became negative!:', projectId);
            deferred.reject(new Error('Number of project watchers became negative!'));
        } else {
            deferred.resolve();
        }

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

    /**
     * Start watching the document at the provided context.
     * @param {object} data
     * @param {string} data.projectId
     * @param {string} data.branchName
     * @param {string} data.nodeId
     * @param {string} data.attrName
     * @param {string} data.attrValue - If the first client entering the document the value will be used
     * @param {function} atOperation - Triggered when other clients made changes
     * @param {ot.Operation} atOperation.operation - Triggered when other clients' operations were applied
     * @param {function} atSelection - Triggered when other clients send their selection info
     * @param {object} atSelection.data
     * @param {ot.Selection | null} atSelection.data.selection - null is passed when other client leaves
     * @param {string} atSelection.data.userId - name/id of other user
     * @param {string} atSelection.data.socketId - unique id of other user
     * @param {function} [callback]
     * @param {Error | null} callback.err - If failed to watch the document
     * @param {object} callback.data
     * @param {string} callback.data.docId - Id of document
     * @param {string} callback.data.document - Current document on server
     * @param {number} callback.data.revision - Revision at server when connecting
     * @param {object} callback.data.users - Users that were connected when connecting
     * @returns {Promise}
     */
    StorageWatcher.prototype.watchDocument = function (data, atOperation, atSelection, callback) {
        var self = this,
            docUpdateEventName = this.webSocket.getDocumentUpdatedEventName(data),
            docSelectionEventName = this.webSocket.getDocumentSelectionEventName(data),
            docId = docUpdateEventName.substring(CONSTANTS.DOCUMENT_OPERATION.length),
            watcherId = GUID();

        data = JSON.parse(JSON.stringify(data));
        this.logger.debug('watchDocument - handler added for project', data);
        this.watchers.documents[docId] = this.watchers.documents[docId] || {};
        this.watchers.documents[docId][watcherId] = {
            eventHandler: function (_ws, eData) {
                var otClient = self.watchers.documents[eData.docId][watcherId].otClient;
                self.logger.debug('eventHandler for document', {metadata: eData});

                if (eData.watcherId === watcherId) {
                    self.logger.info('event from same watcher, skipping...');
                    return;
                }

                if (eData.operation) {
                    if (self.reconnecting) {
                        // We are reconnecting.. Put these on the queue.
                        self.watchers.documents[docId][watcherId].applyBuffer.push(eData);
                    } else {
                        otClient.applyServer(ot.TextOperation.fromJSON(eData.operation));
                    }
                }

                if (Object.hasOwn(eData, 'selection') && !self.reconnecting) {
                    atSelection({
                        selection: eData.selection ?
                            otClient.transformSelection(ot.Selection.fromJSON(eData.selection)) : null,
                        socketId: eData.socketId,
                        userId: eData.userId
                    });
                }
            },
            applyBuffer: [],
            awaitingAck: null
        };

        this.webSocket.addEventListener(docUpdateEventName, this.watchers.documents[docId][watcherId].eventHandler);
        this.webSocket.addEventListener(docSelectionEventName, this.watchers.documents[docId][watcherId].eventHandler);

        data.join = true;
        data.sessionId = this.watchers.sessionId;
        data.watcherId = watcherId;

        return this.webSocket.watchDocument(data)
            .then(function (initData) {
                self.watchers.documents[initData.docId][watcherId].otClient = new ot.Client(initData.revision);

                self.watchers.documents[initData.docId][watcherId].otClient.sendOperation =
                    function (revision, operation) {
                        var sendData = {
                            docId: initData.docId,
                            projectId: initData.projectId,
                            branchName: initData.branchName,
                            revision: revision,
                            operation: operation,
                            selection: self.watchers.documents[initData.docId][watcherId].selection,
                            sessionId: self.watchers.sessionId,
                            watcherId: watcherId
                        };

                        self.watchers.documents[initData.docId][watcherId].awaitingAck = {
                            revision: revision,
                            operation: operation
                        };

                        self.webSocket.sendDocumentOperation(sendData, function (err) {
                            if (err) {
                                self.logger.error('Failed to sendDocument', err);
                                return;
                            }

                            if (Object.hasOwn(self.watchers.documents, initData.docId) &&
                                Object.hasOwn(self.watchers.documents[initData.docId], watcherId)) {
                                self.watchers.documents[initData.docId][watcherId].awaitingAck = null;
                                self.watchers.documents[initData.docId][watcherId].otClient.serverAck(revision);
                            } else {
                                self.logger.error(new Error('Received document acknowledgement ' +
                                    'after watcher left document ' + initData.docId));
                            }
                        });
                    };

                self.watchers.documents[initData.docId][watcherId].otClient.applyOperation = atOperation;

                return initData;
            })
            .nodeify(callback);
    };

    /**
     * Stop watching the document.
     * @param {object} data
     * @param {string} data.docId - document id, if not provided projectId, branchName, nodeId, attrName must be.
     * @param {string} data.watcherId
     * @param {string} [data.projectId]
     * @param {string} [data.branchName]
     * @param {string} [data.nodeId]
     * @param {string} [data.attrName]
     * @param {function} [callback]
     * @param {Error | null} callback.err - If failed to unwatch the document
     * @returns {Promise}
     */
    StorageWatcher.prototype.unwatchDocument = function (data, callback) {
        var deferred = Q.defer(),
            docUpdateEventName = this.webSocket.getDocumentUpdatedEventName(data),
            docSelectionEventName = this.webSocket.getDocumentSelectionEventName(data),
            pieces;

        if (typeof data.docId === 'string') {
            pieces = _splitDocId(data.docId);
            Object.keys(pieces)
                .forEach(function (key) {
                    data[key] = pieces[key];
                });
        } else {
            data.docId = docUpdateEventName.substring(CONSTANTS.DOCUMENT_OPERATION.length);
        }

        if (typeof data.watcherId !== 'string') {
            deferred.reject(new Error('data.watcherId not provided - use the one given at watchDocument.'));
        } else if (Object.hasOwn(this.watchers.documents, data.docId) === false ||
            Object.hasOwn(this.watchers.documents[data.docId], data.watcherId) === false) {
            deferred.reject(new Error('Document is not being watched ' + data.docId +
                ' by watcherId [' + data.watcherId + ']'));
        } else {
            // Remove handler from web-socket module.
            this.webSocket.removeEventListener(docUpdateEventName,
                this.watchers.documents[data.docId][data.watcherId].eventHandler);
            this.webSocket.removeEventListener(docSelectionEventName,
                this.watchers.documents[data.docId][data.watcherId].eventHandler);

            // "Remove" handlers attached to the otClient.
            this.watchers.documents[data.docId][data.watcherId].otClient.sendOperation =
                this.watchers.documents[data.docId][data.watcherId].otClient.applyOperation = function () {
                };

            delete this.watchers.documents[data.docId][data.watcherId];

            if (Object.keys(this.watchers.documents[data.docId]).length === 0) {
                delete this.watchers.documents[data.docId];
            }

            // Finally exit socket.io room on server if connected.
            if (this.connected) {
                data.join = false;
                this.webSocket.watchDocument(data)
                    .then(deferred.resolve)
                    .catch(deferred.reject);
            } else {
                deferred.resolve();
            }
        }

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

    /**
     * Send operation made, and optionally selection, on document at docId.
     * @param {object} data
     * @param {string} data.docId
     * @param {string} data.watcherId
     * @param {ot.TextOperation} data.operation
     * @param {ot.Selection} [data.selection]
     */
    StorageWatcher.prototype.sendDocumentOperation = function (data) {
        // TODO: Do we need to add a callback for confirmation here?
        if (typeof data.watcherId !== 'string') {
            throw new Error('data.watcherId not provided - use the one given at watchDocument.');
        } else if (Object.hasOwn(this.watchers.documents, data.docId) &&
            Object.hasOwn(this.watchers.documents[data.docId], data.watcherId) &&
            this.watchers.documents[data.docId][data.watcherId].otClient instanceof ot.Client) {

            this.watchers.documents[data.docId][data.watcherId].selection = data.selection;
            this.watchers.documents[data.docId][data.watcherId].otClient.applyClient(data.operation);
        } else {
            throw new Error('Document not being watched ' + data.docId +
                '. (If "watchDocument" was initiated make sure to wait for the callback.)');
        }
    };

    /**
     * Send selection on document at docId. (Will only be transmitted if client is Synchronized.)
     * @param {object} data
     * @param {string} data.docId
     * @param {string} data.watcherId
     * @param {ot.Selection} data.selection
     */
    StorageWatcher.prototype.sendDocumentSelection = function (data) {
        var self = this,
            otClient;

        if (typeof data.watcherId !== 'string') {
            throw new Error('data.watcherId not provided - use the one given at watchDocument.');
        } else if (Object.hasOwn(this.watchers.documents, data.docId) &&
            Object.hasOwn(this.watchers.documents[data.docId], data.watcherId) &&
            this.watchers.documents[data.docId][data.watcherId].otClient instanceof ot.Client) {

            otClient = this.watchers.documents[data.docId][data.watcherId].otClient;

            if (otClient.state instanceof ot.Client.Synchronized) {
                // Only broadcast the selection when synchronized
                this.webSocket.sendDocumentSelection({
                    docId: data.docId,
                    watcherId: data.watcherId,
                    revision: otClient.revision,
                    selection: data.selection
                }, function (err) {
                    if (err) {
                        self.logger.error(err);
                    }
                });
            }

        } else {
            throw new Error('Document not being watched ' + data.docId +
                '. (If "watchDocument" was initiated make sure to wait for the callback.)');
        }
    };

    StorageWatcher.prototype._rejoinWatcherRooms = function (callback) {
        var self = this,
            promises = [],
            projectId;

        // When this is called were are in the self.reconnecting === true state until callback resolved.

        this.logger.debug('rejoinWatcherRooms');
        if (this.watchers.database > 0) {
            this.logger.debug('Rejoining database room.');
            promises.push(Q.ninvoke(this.webSocket, 'watchDatabase', {join: true}));
        }

        for (projectId in this.watchers.projects) {
            if (Object.hasOwn(this.watchers.projects, projectId) && this.watchers.projects[projectId] > 0) {
                this.logger.debug('Rejoining project room', projectId, this.watchers.projects[projectId]);
                promises.push(this.webSocket.watchProject({projectId: projectId, join: true}));
            }
        }

        function rejoinWatchers(docId, watcherIds) {
            var rejoinData = _splitDocId(docId),
                watcherId = watcherIds.pop();

            rejoinData.docId = docId;
            rejoinData.rejoin = true;
            rejoinData.revision = self.watchers.documents[docId][watcherId].otClient.revision;
            rejoinData.sessionId = self.watchers.sessionId;
            rejoinData.watcherId = watcherId;

            return self.webSocket.watchDocument(rejoinData)
                .then(function (joinData) {
                    var deferred = Q.defer(),
                        awaiting = self.watchers.documents[docId][watcherId].awaitingAck,
                        sendData;

                    function applyFromServer() {
                        joinData.operations.forEach(function (op) {
                            self.watchers.documents[docId][watcherId].otClient.applyServer(op.wrapped);
                        });

                        self.watchers.documents[docId][watcherId].applyBuffer.forEach(function (op) {
                            self.watchers.documents[docId][watcherId].otClient.applyServer(op);
                        });

                        self.watchers.documents[docId][watcherId].applyBuffer = [];
                    }

                    if (awaiting === null) {
                        // We had no outstanding operations - apply all from the server.
                        applyFromServer();
                        deferred.resolve();
                    } else {
                        // We were awaiting an acknowledgement, did it make it to the server?
                        if (joinData.operations.length > 0 &&
                            joinData.operations[0].metadata.sessionId === self.watchers.sessionId &&
                            joinData.operations[0].metadata.watcherId === watcherId) {
                            // It made it to the server - so send the acknowledgement to the otClient.
                            self.watchers.documents[docId][watcherId].awaitingAck = null;
                            self.watchers.documents[docId][watcherId].otClient.serverAck(awaiting.revision);

                            // Remove it from the operations and apply the other
                            joinData.operations.shift();
                            applyFromServer();
                            deferred.resolve();
                        } else {
                            applyFromServer();
                            sendData = {
                                docId: docId,
                                projectId: rejoinData.projectId,
                                branchName: rejoinData.branchName,
                                revision: awaiting.revision,
                                operation: awaiting.operation,
                                sessionId: self.watchers.sessionId,
                                watcherId: watcherId
                            };

                            self.webSocket.sendDocumentOperation(sendData, function (err) {
                                if (err) {
                                    deferred.reject(err);
                                    return;
                                }

                                if (Object.hasOwn(self.watchers.documents, docId) &&
                                    Object.hasOwn(self.watchers.documents[docId], watcherId)) {
                                    self.watchers.documents[docId][watcherId].awaitingAck = null;
                                    self.watchers.documents[docId][watcherId].otClient.serverAck(sendData.revision);
                                } else {
                                    self.logger.error(new Error('Received document acknowledgement ' +
                                        'after leaving document ' + docId));
                                }

                                deferred.resolve();
                            });
                        }
                    }

                    return deferred.promise;
                })
                .then(function () {
                    if (watcherIds.length > 0) {
                        rejoinWatchers(docId, watcherIds);
                    }
                });
        }

        Object.keys(this.watchers.documents).forEach(function (docId) {
            promises.push(rejoinWatchers(docId, Object.keys(self.watchers.documents[docId])));
        });

        return Q.all(promises).nodeify(callback);
    };

    return StorageWatcher;
});