Source: ARCore/db.js

define(["lib/underscore",                 "common/util/log",            "common/util/id_generator",
        "lib/jquery",                     "common/network/dispatcher",  "common/constants",
        "common/util/check",              "require",                    "lib/meteor",
        "ARCore/machines",
        ],
function(_,                                Log,                          IDGenerator,
         $,                                Dispatcher,                   Constants,
         check,                            require,                      meteor) {

    "use strict";

    var LocalCollection = meteor.minimongo.LocalCollection;
    var DDP = meteor.livedata.DDP;
    var Collection = meteor.meteor.Meteor.Collection;

    var logger = Log("DB");

    var localConnectionFactory = function() {
        return DDP.connect("http://localhost:9000/");
    };

    var Machines = function() {
        return require("ARCore/machines");
    };

    // name -> DB for all DBs that have been instantiated
    var existingDBs = {};

    // array of published DB names, so we can unpublish them on unload
    var publishedDBs = [];

    // returns the existing DB from existingDBs, or creates a new one that
    // uses localConnectionFactory
    var findOrCreateLocalDB = function(name) {
        if(!existingDBs[name]) {
            existingDBs[name] = DB(name, Machines().localhost, localConnectionFactory);
        }

        return existingDBs[name];
    };

    // joins parts using the DB NAME_SEPARATOR
    var makeDBName = function(parts) {
        return parts.join(Constants.db.NAME_SEPARATOR);
    };

    // pass this to Array#sort to sort machines by hostname
    var machineSorter = function(machineA, machineB) {
        if(machineA.hostname < machineB.hostname)      { return -1; }
        else if(machineA.hostname > machineB.hostname) { return  1; }
        else                                           { return  0; }
    };

    // on unload, we need to unpublish our DBs
    $(window).unload(function() {
        _.each(publishedDBs, function(name) {
            Dispatcher._bridge.fireEvent("db_unpublish", { name: name });
        });
    });

    /**
     * Creates a new Lens.DB, but does not connect to it. Connection is done
     * lazily when needed, so there are no side-effects of creating
     * a DB.
     *
     * @param {String} name
     *     Name of the DB server. Must be unqiue for the connection.
     * @param {Machine} machine
     *     Which machine the DB belongs to.
     * @param {Function} connectionFactory
     *     A function that returns a Meteor connection (as returned by
     *     DDP.connect).
     * @param {Boolean} [isLensDBServer]
     *     Whether the server supports the initCollection RPC for client-side
     *     collection creation, and manages the peers collection. Defaults to
     *     true.
     *
     * @privconstructor
     * @class
     *
     * Lens.DB provides MongoDB-like database that can easily be synchronized
     * between different Lens applications or LuXoR devices.
     *
     * Databases are reactive, and can be used in conjunction with
     * {@link Lens.autorun}. See {@tutorial reactivity} for more on reactive
     * programming in Lens.
     *
     * Lens.DB is taken from [Meteor](http://www.meteor.com/). Once you've
     * created a collection using {@link Lens.DB#Collection}, you can
     * use the [Meteor Collection API](http://docs.meteor.com/#collections)
     * to query the collection and insert new records.
     *
     * See {@tutorial dbs} for more information on the types of databases
     * available.
     *
     * @property {String} name    The name of this database. Read-only.
     * @property {String} machine The machine who's backend is managing this DB.
     *                            Null for server and in-memory databases. Read-only.
     * @memberOf Lens
     */
    var DB = function(name, machine, connectionFactory, isLensDBServer) {
        /** @alias Lens.DB.prototype */
        var self = {};

        self.name = name;
        self.machine = machine;

        var collections = {};
        var connection  = null;
        var open        = false;

        // isLensDBServer defaults to true
        if(isLensDBServer === undefined) {
            isLensDBServer = true;
        }

        // when we close a connection, we override the retry timeout so Meteor
        // doens't autoatically re-connect. We save the old value here so
        // we can undo this to re-open the conneciton.
        var prevRetryTimeout;

        // the setInterval ID for the interval that's sending hearbeats to the
        // backend
        var heartbeat;

        // send a heartbeat to the backend. assumes the connection is open.
        var sendHeartbeat = function() {
            connection.call("heartbeat", name, Machines().localhost.hostname);
        };

        // opens the connection to the backend.
        var openConnection = function() {
            if(open || (!connectionFactory)) {
                // we're either already open, or this is an in-memory database
                return;
            }

            // connect
            if(!connection) {
                // make a new connection
                connection = connectionFactory();
            }
            else {
                // revive the old connection
                connection._stream._retryTimeout = prevRetryTimeout;
                connection.reconnect();
            }

            // set up the heartbeat
            if(isLensDBServer) {
                heartbeat = setInterval(sendHeartbeat, Constants.db.HEARTBEAT_INTERVAL);
                sendHeartbeat();
            }

            // mark the DB as open
            open = true;

            // open the peers collection
            if(isLensDBServer) {
                getCollection(Constants.db.PEERS_COLLECTION);
            }
        };

        // creates a new collection and returns it.
        var getCollection = function(collectionName, onReady) {
            var collectionFullName = collectionName;

            if(name) {
                // if the DB has a name, use it to prefix the collection name
                collectionFullName = makeDBName([name, collectionName]);
            }
            openConnection();

            if(collections[collectionFullName] === undefined) {
                if(!connection) {
                    // no connection -> this is an in-memory DB
                    collections[collectionFullName] = new LocalCollection();
                }
                else {
                    collections[collectionFullName] = new Collection(collectionFullName, {connection: connection});

                    if(isLensDBServer) {
                        connection.call("initCollection", collectionFullName, function(error, result) {
                            if(result) {
                                connection.subscribe(collectionFullName, onReady);
                            }
                            else {
                                logger.error("initCollection returned an error: " + error);
                            }
                        });
                    }
                }
            }
            return collections[collectionFullName];
        };

        /**
         * Returns a new database collection. See the
         * [Meteor Collection API](http://docs.meteor.com/#collections)
         * for documentation on how to make queries against the collection.
         *
         * Automatically calls {@link open} if this database isn't open.
         *
         * @param {String} collectionName
         *     Name of the collection. If this name already exists, returns the
         *     existing collection.
         * @param {Function} [onReady]
         *     You can use the returned collection immediately, but it won't
         *     have synced with the server immediately. If you pass this
         *     function, it will be called when the collection finished its
         *     initial sync with the server.
         *
         * @return {Collection}
         */
        self.Collection = function(collectionName, onReady) {
            check(collectionName, String);
            check(onReady, check.Match.Optional(Function));

            return getCollection(collectionName, onReady);
        };

        /**
         * Returns the Meteor connection to this DB's server. This object
         * is the same as the one returned by
         * [DDP.connect](http://docs.meteor.com/#ddp_connect).
         *
         * Returns null for in-memory databases.
         *
         * Automatically calls {@link open} if the database isn't open.
         */
        self.connection = function() {
            openConnection();
            return connection;
        };

        /**
         * Opens the connection to the database backend. This is automatically
         * called by {@link Collection} and {@link connection}; you only need to
         * call this if you have called {@link close} and want to re-open the
         * connection
         */
        self.open = function() {
            openConnection();
        };

        /**
         * Closes the connection to the database backend. You can still use
         * collections, but they won't be synced with the server, and other
         * clients of published or shared databases won't see you as a peer.
         */
        self.close = function() {
            if(!open) {
                return;
            }

            // stop the heartbeat
            clearInterval(heartbeat);

            // notify the backend
            connection.call("disconnect", name, Machines().localhost.hostname);

            // disconnect
            var stream = connection._stream;
            prevRetryTimeout = stream._retryTimeout;
            stream._retryTimeout = function() { return 20 * 365 * 24 * 60 * 60 * 1000; }; // far future
            stream._lostConnection();

            // mark the DB as closed
            open = false;
        };

        /**
         * Returns an array of {@link Machine}s that are also connected to this
         * database. A reactive data source.
         */
        self.peers = function() {
            return getCollection(Constants.db.PEERS_COLLECTION).find().fetch().map(function(peer) {
                return Machines().machine(peer.hostname);
            }).filter(function(machine) {
                return machine !== null;
            });
        };

        Object.freeze(self);
        return self;
    };

    _.extend(DB, /** @lends Lens.DB */ {
        /**
         * Returns a new in-memory database.
         * @return {Lens.DB}
         */
        inMemory: function() {
            return DB(IDGenerator.uuid(), null);
        },

        /**
         * Returns a new persistent database accessible only to this app.
         * @param  {String} name
         * @return {Lens.DB}
         */
        app: function(name) {
            check(name, String);

            return findOrCreateLocalDB(makeDBName([Constants.db.APP_PREFIX, Lens.appName(), name]));
        },

        /**
         * Returns a new persistent database accessible only to this app.
         * @param  {String} name
         * @return {Lens.DB}
         */
        machine: function(name) {
            check(name, String);

            return findOrCreateLocalDB(makeDBName([Constants.db.MACHINE_PREFIX, name]));
        },

        /**
         * Returns a new database that is published to nearby LuXoR devices.
         * You can find nearby published databases using {@link Lens.Machines}
         * @param  {String} name
         * @return {Lens.DB}
         */
        published: function(name) {
            check(name, String);

            name = makeDBName([Constants.db.PUBLISHED_PREFIX, name]);

            Dispatcher._bridge.fireEvent("db_publish", { name: name });
            publishedDBs.push(name);

            return findOrCreateLocalDB(name);
        },

        /**
         * Returns a new database that is connection to an external Meteor
         * backend.
         * @param {String}  url              The URL of the Meteor app.
         * @param {Boolean} [isLensDBServer] Set this to true if the Meteor app
         *                                   is a LuXoR DB server (which allows
         *                                   LuXoR to create Collection client-
         *                                   side). Defaults to false.
         * @return {Lens.DB}
         */
        server: function(url, isLensDBServer) {
            check(url, String);
            check(isLensDBServer, check.Match.Optional(Boolean));

            return DB("", null, function() {
                return DDP.connect(url);
            }, !!isLensDBServer);
        },

        /**
         * Returns a new ad-hoc shared database. A shared database is only
         * persisted for as long as the machine that created it is connected.
         * Moreover, unlike a published database, you need not have one machine
         * publish the database and other connect to it. Rather, have all
         * machines call DB.shared and Lens with automatically select a "master"
         * and have the other machines connect to it.
         *
         * @param {String}   name
         * @param {Function} callback Callback to invoke with the shared
         *                            {@link Lens.DB} once one has been created.
         */
        shared: function(name, callback) {
            check(name, String);
            check(callback, Function);

            var dbName = makeDBName([Constants.db.SHARED_PREFIX, name]);

            // if we've already initialized this shared database, return the
            // existing one
            if(existingDBs[dbName]) {
                callback(existingDBs[dbName]);
                return;
            }

            // if another machine is advertising this shared database, connect
            // to that one. Pick the one with the lowest hostname if there
            // are more than one.
            var machines = Lens.Machines.all().filter(function(machine) {
                return machine._sharedDB(name) !== null;
            }).sort(machineSorter);

            if(machines.length > 0) {
                callback(machines[0]._sharedDB([name]));
                return;
            }


            // If we can't find an existing database to connect to, we might
            // be the master for this shared database. Let nearby devices know
            // that we can master and wait for this to propagate. If we're the
            // lowest-named machine who wants to master for this database, then
            // we become the master. Else, keep waiting for someone to become
            // the master.
            var rpcName = "_master_for_" + name;
            var rpcBinding = Lens.Machines.localhost.registerVoidRPC(rpcName, function(){});

            var machineSubscriptionBinding;

            var intervalId = setInterval(function() {
                var machines = Lens.Machines.findByRPC(rpcName).sort(machineSorter);

                if(machines[0].isLocalhost()) {
                    // we're the master!
                    logger.info("This machine is the master for", dbName);

                    rpcBinding.clear();
                    clearInterval(intervalId);
                    machineSubscriptionBinding.clear();

                    Dispatcher._bridge.fireEvent("db_publish", { name: dbName });
                    publishedDBs.push(dbName);

                    callback(findOrCreateLocalDB(dbName));
                }
            }, Constants.capability.PERIOD_CLEANUP_SECONDS*1000);

            machineSubscriptionBinding = Lens.Machines.subscribe(function(db) {
                // someone else is the master
                logger.info("Found external master for", dbName);

                rpcBinding.clear();
                clearInterval(intervalId);
                machineSubscriptionBinding.clear();

                callback(db);
            }, {
                type: "db",
                name: dbName
            });
        },

        /**
         * Connects to a database on another machine.
         * @private
         */
        _connect: function(machine, name) {
            return DB(name, machine, function() {
                if(machine.isLocalhost()) {
                    return localConnectionFactory();
                }
                else {
                    return DDP.connect("http://" + machine.name() + ".local:9000");
                }
            });
        },

        CAP_NAME_PREFIX: "lens.db."
    });

    Lens._addMember(DB, "DB");
    return DB;
});