Source: src/shared_store.js

/**
 * @file Share a data store between users
 * @author Andrew Sayers
 * @description We use a third party to synchronise storage between users
 */

/**
 * @summary Share a data store between users
 * @param {Object} args store arguments
 * @constructor
 * @abstract
 *
 * @example
 * var shared_store = new SharedStore({
 *     lock_url: 'http://.../...', // third-party URL to lock the store during writing
 *     store   : function(data) {...}, // called when we want to store a new string
 *     retrieve: function() {...}, // called when we want to download a string
 *     error_callback: function(message, resolutions),
 *     v: v
 * });
 *
 * @description Stores data in a location shared between several users.
 * Most bulletin board software doesn't provide a mechanism to lock a resource
 * when writing.  To prevent race conditions where two users overwrite
 * each other's updates, we call out to a third-party locking service.
 * See /lock.rb in this repository for an example implementation.
 *
 * Note: if this string is empty, the object will work but will not protect against
 * race conditions
 *
 */
function SharedStore(args) {

    this.store    = args.store;
    this.retrieve = args.retrieve;

    this.change_cbs   = [];
    this.interval_cbs = [];
    this.data         = '{}';
    this.error_callback = args.error_callback;
    this.v = args.v;

    var ss = this;
    this.promise = this.retrieve().then(function(data) { ss.data = data });

    if ( args.lock_url ) {
        this.   lock = function(       ) { return $.ajax({ url: args.lock_url +    '/lock'           , xhr: function() { return new BabelExt.XMLHttpRequest() } }) };
        this. unlock = function(lock_id) { return $.ajax({ url: args.lock_url +  '/unlock/' + lock_id, xhr: function() { return new BabelExt.XMLHttpRequest() } }) };
        this.refresh = function(lock_id) { return $.ajax({ url: args.lock_url + '/refresh/' + lock_id, xhr: function() { return new BabelExt.XMLHttpRequest() } }) };
    } else {
        console.log( "Shared store lock URL not specified - writes to the shared store may overwrite other people's information!" );
        this.lock = this.unlock = this.refresh = function() {
            return $.Deferred().resolve('1').promise();
        }
    }

}

SharedStore.prototype.constructor = SharedStore;
SharedStore.prototype = Object.create(Object, {

       lock: { writable: true, configurable: false },
     unlock: { writable: true, configurable: false },
    refresh: { writable: true, configurable: false },

    promise: { writable: true, configurable: false },

    store    : { writable: true, configurable: false },
    retrieve : { writable: true, configurable: false },

      change_cbs: { writable: true, configurable: false },
    interval_cbs: { writable: true, configurable: false },

    error_callback: { writable: true, configurable: false },
    v: { writable: true, configurable: false }

});

/**
 * @summary get the stored value
 * @return {Object} stored value
 * @description because this value is stored remotely, this just
 * returns the cached value.
 * See transaction() and change()
 */
SharedStore.prototype.val = function() {
    return JSON.parse(this.data);
}

// Regularly update the data
SharedStore.prototype._interval_transaction = function(callback) {
    var ss = this;
    this._timeout = setTimeout(function() { ss.transaction(function(){}) }, 60*1000 );
}

/**
 * @summary Add a callback for when the value changes, or fire a change event
 * @param {function=} callback
 * @param {jQuery.Promise=} retrieval callback
 *
 * @example
 * ss.change(function(data) { console.log(data) }); // no return value
 * ss.change().then(function(data) {...}); // also calls previous change_cbs
 *
 * The store is checked at regular intervals, and will be called whenever
 * the value changes.
 */
SharedStore.prototype.change = function(callback) {

    if ( callback ) {

        this.change_cbs.push(callback);
        if ( !this._timeout ) this._interval_transaction();

    } else {

        var ss = this;
        return this.retrieve().then(function(data) {
            if ( !data || data == ss.data ) {
                data = JSON.parse(data || '{}' );
            } else {
                data = JSON.parse(data);
                ss.change_cbs.forEach(function(f) { f(data) });
            }
            return data;
        });

    }

}

/**
 * @summary transaction handlers called regularly
 * @param {function} updater callback
 *
 * @example
 * ss.interval_transaction(function(data) {
 *     data.last_active_time = new Date().getTime(); // see the 'offset' value in BulletinBoard.ping()
 *     return true; // data has changed, should do a write
 * });
 *
 * @description Sometimes we need to write to the store regularly,
 * and it's more efficient to do one big write instead of several
 * small ones.
 */
SharedStore.prototype.interval_transaction = function(updater) {
    this.interval_cbs.push(updater);
    if ( !this._timeout ) this._interval_transaction();
}

/**
 * @summary update the value
 * @param {function} updater called to update the value
 * @return {jQuery.Promise} promise that succeeds when the change goes through
 *
 * @example
 * ss.transaction(function(data) {
 *     ++data.value; // update the value
 *     return true; // signal that an update is needed
 * }).then(function(new_data) {
 *     console.log( "new data" );
 * });
 *
 * @description To ensure users can't overwrite each other's changes,
 * we lock the store, retrieve and update the value, then store it again.
 * Change callbacks might be called after the retrieve(), but will not
 * be called when the update goes through.
 *
 */
SharedStore.prototype.transaction = function(updater) {

    var ss = this;

    var dfd = $.Deferred();

    function babel_xhr() { return new BabelExt.XMLHttpRequest() };

    var delay_count = 0;

    function transact() {
        var lock_timeout = new Date().getTime() + 5000;
        ss.lock().then(function(lock_id) {
            if ( lock_id == '0' ) { // could not lock
                setTimeout( transact, ++delay_count*1000 );
            } else {

                var refresh_timeout, locked = true;

                function unlock() {
                    locked = false;
                    ss.unlock(lock_id);
                    if ( refresh_timeout ) clearTimeout(refresh_timeout);
                    $(window).off( 'unload', unlock );
                }
                $(window).on( 'unload', unlock );

                function refresh() { // the lock lasts for five seconds by default, and can be refreshed for a few more seconds
                    refresh_timeout = null;
                    if ( locked ) {
                        lock_timeout = new Date().getTime() + 5000;
                        ss.refresh(lock_id).then(function(result) {
                            if ( locked && result == 1 )
                                refresh_timeout = setTimeout( refresh, 1000 );
                            else
                                locked = false;
                        }, function() {
                            locked = false;
                        });
                    }
                }
                refresh();

                ss.retrieve().then(function(data) {
                    if ( !locked || lock_timeout < new Date().getTime() ) {
                        unlock();
                        setTimeout( transact, ++delay_count*1000 );
                        return;
                    }
                    if ( !data || data == ss.data ) {
                        data = JSON.parse(data || '{}' );
                    } else {
                        ss.data = data;
                        data = JSON.parse(data);
                        ss.change_cbs.forEach(function(f) { f(data) });
                    }
                    var need_update = ss.interval_cbs.reduce(function (prev,update) { return update(data) ? true : prev }, updater(data) );
                    if ( ss._timeout ) {
                        clearTimeout( ss._timeout );
                        ss._interval_transaction();
                    }
                    if ( need_update ) {
                        ss.store( ss.data = JSON.stringify( data ) ).then(function() {
                            dfd.resolve(data);
                            unlock();
                        }, function() {
                            unlock();
                            dfd.reject();
                        });
                    } else {
                        unlock();
                        dfd.resolve(data);
                    }
                }, function() {
                    unlock();
                    dfd.reject();
                });

            }
        }, function() {
            ss.error_callback(
                'Could not lock shared store',
                ss.v.resolve( 'policy', 'shared store resolutions', {}, 'array of items' ).map(function(item) {
                    return { message: item.value, href: item.url };
                })
            );
            dfd.reject();
        });
    }
    transact();

    return dfd.promise();

}