betajs/betajs-data

View on GitHub
src/data/stores/partial/cached_store.js

Summary

Maintainability
F
3 days
Test Coverage
/*
 * Very important to know:
 *  - If both itemCache + remoteStore use the same id_key, the keys actually coincide.
 *  - If they use different keys, the cache stores the remoteStore keys as a foreign key and assigns its own keys to the cached items
 *
 */

Scoped.define("module:Stores.CachedStore", [
    "module:Stores.BaseStore",
    "module:Stores.MemoryStore",
    "module:Queries",
    "module:Queries.Constrained",
    "module:Stores.CacheStrategies.ExpiryCacheStrategy",
    "base:Promise",
    "base:Objs",
    "base:Types",
    "base:Iterators.ArrayIterator",
    "base:Iterators.MappedIterator",
    "base:Timers.Timer"
], function (Store, MemoryStore, Queries, Constrained, ExpiryCacheStrategy, Promise, Objs, Types, ArrayIterator, MappedIterator, Timer, scoped) {
    return Store.extend({scoped: scoped}, function (inherited) {
        return {

            constructor: function (remoteStore, options) {
                inherited.constructor.call(this);
                this.remoteStore = remoteStore;
                this.__remoteQueryAggregate = Promise.aggregateExecution(this.remoteStore.query, this.remoteStore, null, function (data) {
                    return data ? data.asArray() : data;
                });
                this._options = Objs.extend({
                    itemMetaKey: "meta",
                    queryMetaKey: "meta",
                    queryKey: "query",
                    cacheKey: null,
                    suppAttrs: {},
                    optimisticRead: false,
                    hideMetaData: true
                }, options);
                this._online = true;
                this.itemCache = this._options.itemCache || this.auto_destroy(new MemoryStore({
                    id_key: this._options.cacheKey || this.remoteStore.id_key()
                }));
                this._options.cacheKey = this.itemCache.id_key();
                this._id_key = this.itemCache.id_key();
                this._foreignKey = this.itemCache.id_key() !== this.remoteStore.id_key();
                this.queryCache = this._options.queryCache || this.auto_destroy(new MemoryStore());
                this.cacheStrategy = this._options.cacheStrategy || this.auto_destroy(new ExpiryCacheStrategy());
                if (this._options.auto_cleanup) {
                    this.auto_destroy(new Timer({
                        fire: this.cleanup,
                        context: this,
                        start: true,
                        delay: this._options.auto_cleanup
                    }));
                }
            },

            _query_capabilities: function () {
                return Constrained.fullConstrainedQueryCapabilities();
            },

            _insert: function (data, ctx) {
                return this.cacheInsert(data, {
                    lockItem: true,
                    silent: true,
                    refreshMeta: false,
                    accessMeta: true
                }, ctx);
            },

            _update: function (id, data, ctx, transaction_id) {
                return this.cacheUpdate(id, data, {
                    ignoreLock: false,
                    silent: true,
                    lockAttrs: true,
                    refreshMeta: false,
                    accessMeta: true
                }, ctx, transaction_id);
            },

            _remove: function (id, ctx) {
                return this.cacheRemove(id, {
                    ignoreLock: true,
                    silent: true
                }, ctx);
            },

            _get: function (id, ctx) {
                return this.cacheGet(id, {
                    silentInsert: true,
                    silentUpdate: true,
                    silentRemove: true,
                    refreshMeta: true,
                    accessMeta: true
                }, ctx);
            },

            _query: function (query, options, ctx) {
                return this.cacheQuery(query, options, {
                    silent: true,
                    queryRefreshMeta: true,
                    queryAccessMeta: true,
                    refreshMeta: true,
                    accessMeta: true
                }, ctx);
            },

            /*
             * options:
             *   - lockItem: boolean
             *   - silent: boolean
             *   - refreshMeta: boolean
             *   - accessMeta: boolean
             */

            cacheInsert: function (data, options, ctx) {
                var meta = {
                    lockedItem: options.lockItem,
                    lockedAttrs: {},
                    refreshMeta: options.refreshMeta ? this.cacheStrategy.itemRefreshMeta() : null,
                    accessMeta: options.accessMeta ? this.cacheStrategy.itemAccessMeta() : null
                };
                if (options.meta)
                    meta = Objs.extend(meta, options.meta);
                return this.itemCache.insert(this.addItemSupp(this.addItemMeta(data, meta)), ctx).mapSuccess(function (result) {
                    data = this._options.hideMetaData ? this.removeItemMeta(result) : result;
                    if (!options.silent)
                        this._inserted(data, ctx);
                    return data;
                }, this);
            },

            /*
             * options:
             *   - ignoreLock: boolean
             *   - lockAttrs: boolean
             *   - silent: boolean
             *   - accessMeta: boolean
             *   - refreshMeta: boolean
             *   - foreignKey: boolean (default false)
             *   - unlockItem: boolean (default false)
             */

            cacheUpdate: function (id, data, options, ctx, transaction_id) {
                var foreignKey = options.foreignKey && this._foreignKey;
                var idKey = foreignKey ? this.remoteStore.id_key() : this.id_key();
                var itemPromise = foreignKey ?
                                  this.itemCache.getBy(this.remoteStore.id_key(), id, ctx)
                                : this.itemCache.get(id, ctx);
                return itemPromise.mapSuccess(function (item) {
                    if (!item)
                        return null;
                    var meta = this.readItemMeta(item);
                    if (options.unlockItem) {
                        meta.lockedItem = false;
                        meta.lockedAttrs = {};
                    }
                    if (options.meta)
                        meta = Objs.extend(Objs.clone(meta, 1), options.meta);
                    data = Objs.filter(data, function (value, key) {
                        return (options.ignoreLock || (!meta.lockedItem && !meta.lockedAttrs[key])) && (!(key in item) || item[key] != value);
                    }, this);
                    /*
                    if (Types.is_empty(data))
                        return this.removeItemMeta(item);
                    */
                    if (options.lockAttrs) {
                        Objs.iter(data, function (value, key) {
                            meta.lockedAttrs[key] = true;
                        }, this);
                    }
                    if (options.refreshMeta)
                        meta.refreshMeta = this.cacheStrategy.itemRefreshMeta(/*meta.refreshMeta*/);
                    if (options.accessMeta)
                        meta.accessMeta = this.cacheStrategy.itemAccessMeta(meta.accessMeta);
                    return this.itemCache.update(this.itemCache.id_of(item), this.addItemMeta(data, meta), ctx, transaction_id).mapSuccess(function (result) {
                        result = this._options.hideMetaData ? this.removeItemMeta(result) : result;
                        if (!result[idKey])
                            result[idKey] = id;
                        if (!options.silent)
                            this._updated(result, data, ctx, undefined, transaction_id);
                        else if (options.meta)
                            this._updated(result, this.addItemMeta({}, meta), ctx, undefined, transaction_id);
                        return result;
                    }, this);
                }, this);
            },

            cacheInsertUpdate: function (data, options, ctx, transaction_id) {
                var foreignKey = options.foreignKey && this._foreignKey;
                var itemPromise = foreignKey ?
                                  this.itemCache.getBy(this.remoteStore.id_key(), this.remoteStore.id_of(data), ctx)
                                : this.itemCache.get(this.itemCache.id_of(data), ctx);
                return itemPromise.mapSuccess(function (item) {
                    options.foreignKey = false;
                    if (!item)
                        return this.cacheInsert(data, options, ctx);
                    if (options.keepCache)
                        return item;
                    var backup = Objs.clone(data, 1);
                    var itemId = this.itemCache.id_of(item);
                    backup[this.itemCache.id_key()] = itemId;
                    return this.cacheUpdate(itemId, data, options, ctx, transaction_id).mapSuccess(function (result) {
                        return Objs.extend(backup, result);
                    });
                }, this);
            },

            /*
             * options:
             *   - ignoreLock: boolean
             *   - silent: boolean
             *   - foreignKey: boolean
             */
            cacheRemove: function (id, options, ctx) {
                var foreignKey = options.foreignKey && this._foreignKey;
                var itemPromise = foreignKey ?
                      this.itemCache.getBy(this.remoteStore.id_key(), id, ctx)
                    : this.itemCache.get(id, ctx);
                return itemPromise.mapSuccess(function (data) {
                    if (!data)
                        return data;
                    var meta = this.readItemMeta(data);
                    if (!options.ignoreLock && (meta.lockedItem || !Types.is_empty(meta.lockedAttrs)))
                        return Promise.error("locked item");
                    var cached_id = this.itemCache.id_of(data);
                    return this.itemCache.remove(cached_id, ctx).mapSuccess(function () {
                        if (!options.silent)
                            this._removed(cached_id, ctx, data);
                        return data;
                    }, this);
                }, this);
            },
            
            cacheOnlyGet: function (id, options, ctx) {
                options = options || {};
                var foreignKey = options.foreignKey && this._foreignKey;
                var itemPromise = foreignKey ?
                      this.itemCache.getBy(this.remoteStore.id_key(), id, ctx)
                    : this.itemCache.get(id, ctx);
                return itemPromise;
            },

            /*
             * options:
             *   - silentInsert: boolean
             *   - silentUpdate: boolean
             *   - silentRemove: boolean
             *   - refreshMeta: boolean
             *   - accessMeta: boolean
             *   - foreignKey: boolean
             */
            cacheGet: function (id, options, ctx) {
                var foreignKey = options.foreignKey && this._foreignKey;
                return this.cacheOnlyGet(id, options, ctx).mapSuccess(function (data) {
                    if (!data) {
                        if (!foreignKey && this._foreignKey)
                            return data;
                        return this.remoteStore.get(id, ctx).mapSuccess(function (data) {
                            this.online();
                            if (data) {
                                return this.cacheInsert(data, {
                                    lockItem: false,
                                    silent: options.silentInsert,
                                    accessMeta: true,
                                    refreshMeta: true
                                }, ctx);
                            } else
                                return data;
                        }, this);
                    }
                    var meta = this.readItemMeta(data);
                    var cached_id = this.itemCache.id_of(data);
                    var remote_id = this.remoteStore.id_of(data);
                    if (this.cacheStrategy.validItemRefreshMeta(meta.refreshMeta) || meta.lockedItem) {
                        if (options.accessMeta) {
                            meta.accessMeta = this.cacheStrategy.itemAccessMeta(meta.accessMeta);
                            this.itemCache.update(cached_id, this.addItemMeta({}, meta), ctx);
                        }
                        return this._options.hideMetaData ? this.removeItemMeta(data) : data;
                    }
                    return this.remoteStore.get(remote_id, ctx).mapSuccess(function (data) {
                        this.online();
                        if (data) {
                            return this.cacheUpdate(cached_id, data, {
                                ignoreLock: false,
                                lockAttrs: false,
                                silent: options.silentUpdate,
                                accessMeta: true,
                                refreshMeta: true
                            }, ctx).mapSuccess(function (cacheResult) {
                                data[this.itemCache.id_key()] = cached_id;
                                return Objs.extend(data, cacheResult);
                            }, this);
                        } else {
                            return this.cacheRemove(cached_id, {
                                ignoreLock: false,
                                silent: options.silentRemove
                            }, ctx);
                        }
                    }, this).mapError(function () {
                        this.offline();
                        return Promise.value(data);
                    }, this);
                }, this);
            },
            
            __itemCacheQuery: function (query, options, ctx) {
                return this.itemCache.query(query, options, ctx).mapSuccess(function (items) {
                    items = items.asArray();
                    Objs.iter(items, function (item) {
                        this.cacheUpdate(this.itemCache.id_of(item), {}, {
                            lockItem: false,
                            lockAttrs: false,
                            silent: true,
                            accessMeta: options.accessMeta,
                            refreshMeta: false
                        }, ctx);
                    }, this);
                    var arrIter = new ArrayIterator(items);
                    return this._options.hideMetaData ? (new MappedIterator(arrIter, this.removeItemMeta, this)).auto_destroy(arrIter, true) : arrIter;
                }, this);
            },

            /*
             * options:
             *   - silent: boolean
             *   - queryRefreshMeta: boolean
             *   - queryAccessMeta: boolean
             *   - refreshMeta: boolean
             *   - accessMeta: boolean
             */
            cacheQuery: function (query, queryOptions, options, ctx) {
                var queryString = Constrained.serialize({
                    query: query,
                    options: queryOptions
                });
                var localQuery = Objs.objectBy(
                    this._options.queryKey,
                    queryString
                );
                return this.queryCache.query(localQuery, {limit : 1}, ctx).mapSuccess(function (resultIter) {
                    var result = resultIter.hasNext() ? resultIter.next() : null;
                    resultIter.destroy();
                    if (result) {
                        var meta = this.readQueryMeta(result);
                        var query_id = this.queryCache.id_of(result);
                        if (this.cacheStrategy.validQueryRefreshMeta(meta.refreshMeta)) {
                            if (options.queryAccessMeta) {
                                meta.accessMeta = this.cacheStrategy.queryAccessMeta(meta.accessMeta);
                                this.queryCache.update(query_id, this.addQueryMeta({}, meta), ctx);
                            }
                            return this.__itemCacheQuery(query, queryOptions, ctx);
                        }
                        this.queryCache.remove(query_id, ctx);
                    }
                    // Note: This is probably not good enough in the most general cases.
                    if (Queries.queryDeterminedByAttrs(query, this._options.suppAttrs, true))
                        return this.itemCache.query(query, queryOptions, ctx);
                    var remotePromise = this.__remoteQueryAggregate(this.removeItemSupp(query), queryOptions, ctx).mapSuccess(function (items) {
                        this.online();
                        items = items.asArray ? items.asArray() : items;
                        var meta = {
                            refreshMeta: options.queryRefreshMeta ? this.cacheStrategy.queryRefreshMeta() : null,
                            accessMeta: options.queryAccessMeta ? this.cacheStrategy.queryAccessMeta() : null
                        };
                        this.queryCache.insert(Objs.objectBy(
                            this._options.queryKey, queryString,
                            this._options.queryMetaKey, meta
                        ), ctx);
                        var promises = [];
                        Objs.iter(items, function (item) {
                            promises.push(this.cacheInsertUpdate(item, {
                                lockItem: false,
                                lockAttrs: false,
                                silent: options.silent && !this._options.optimisticRead,
                                accessMeta: options.accessMeta,
                                refreshMeta: options.refreshMeta,
                                foreignKey: true
                            }, ctx).mapSuccess(function (result) {
                                return this.cacheOnlyGet(this.id_of(result), null, ctx);
                            }, this));
                        }, this);
                        return Promise.and(promises).mapSuccess(function (items) {
                            var arrIter = new ArrayIterator(items);
                            return (new MappedIterator(arrIter, this.addItemSupp, this)).auto_destroy(arrIter, true);
                        }, this);
                    }, this).mapError(function () {
                        this.offline();
                        if (!this._options.optimisticRead) {
                            return this.__itemCacheQuery(query, queryOptions, ctx);
                        }
                    }, this);
                    return this._options.optimisticRead ? this.__itemCacheQuery(query, queryOptions, ctx) : remotePromise;
                }, this);
            },

            online: function () {
                this.trigger("online");
                this._online = true;
            },

            offline: function () {
                this.trigger("offline");
                this._online = false;
            },

            addItemMeta: function (data, meta) {
                data = Objs.clone(data, 1);
                data[this._options.itemMetaKey] = meta;
                return data;
            },

            addItemSupp: function (data) {
                return Objs.extend(Objs.clone(this._options.suppAttrs, 1), data);
            },
            
            removeItemSupp: function (data) {
                if (!this._options.suppAttrs)
                    return data;
                return Objs.filter(data, function (value, key) {
                    return !(key in this._options.suppAttrs);
                }, this);
            },

            addQueryMeta: function (data, meta) {
                data = Objs.clone(data, 1);
                data[this._options.queryMetaKey] = meta;
                return data;
            },

            removeItemMeta: function (data) {
                data = data || {};
                data = Objs.clone(data, 1);
                delete data[this._options.itemMetaKey];
                return data;
            },

            removeQueryMeta: function (data) {
                data = Objs.clone(data, 1);
                delete data[this._options.queryMetaKey];
                return data;
            },

            readItemMeta: function (data) {
                return data[this._options.itemMetaKey];
            },

            readQueryMeta: function (data) {
                return data[this._options.queryMetaKey];
            },

            unlockItem: function (id, ctx, opts) {
                return this.itemCache.get(id, ctx).mapSuccess(function (data) {
                    if (!data)
                        return data;
                    var meta = this.readItemMeta(data);
                    meta.lockedItem = false;
                    meta.lockedAttrs = {};
                    opts = opts || {};
                    if (opts.meta)
                        meta = Objs.extend(Objs.clone(meta, 1), opts.meta);
                    return this.itemCache.update(id, this.addItemMeta({}, meta), ctx).success(function () {
                        if (opts.meta)
                            this._updated(this.id_row(id), this.addItemMeta({}, meta), ctx);
                    }, this);
                }, this);
            },

            cleanup: function () {
                if (!this._online)
                    return;
                this.queryCache.query().success(function (queries) {
                    while (queries.hasNext()) {
                        var query = queries.next();
                        var meta = this.readQueryMeta(query);
                        if (!this.cacheStrategy.validQueryRefreshMeta(meta.refreshMeta) || !this.cacheStrategy.validQueryAccessMeta(meta.accessMeta))
                            this.queryCache.remove(this.queryCache.id_of(query));
                    }
                    queries.destroy();
                }, this);
                this.itemCache.query().success(function (items) {
                    while (items.hasNext()) {
                        var item = items.next();
                        var meta = this.readItemMeta(item);
                        if (!meta.lockedItem && Types.is_empty(meta.lockedAttrs) &&
                            (!this.cacheStrategy.validItemRefreshMeta(meta.refreshMeta) || !this.cacheStrategy.validItemAccessMeta(meta.accessMeta)))
                            this.itemCache.remove(this.itemCache.id_of(item));
                    }
                    items.destroy();
                }, this);
            },

            cachedIdToRemoteId: function (cachedId) {
                if (!this._foreignKey)
                    return Promise.value(cachedId);
                return this.itemCache.get(cachedId).mapSuccess(function (item) {
                    return item ? this.remoteStore.id_of(item) : null;
                }, this);
            },
            
            serialize: function () {
                return this.itemCache.serialize().mapSuccess(function (itemCacheSerialized) {
                    return this.queryCache.serialize().mapSuccess(function (queryCacheSerialized) {
                        return {
                            items: itemCacheSerialized,
                            queries: queryCacheSerialized
                        };
                    }, this);
                }, this);
            },
            
            unserialize: function (data) {
                return this.itemCache.unserialize(data.items).mapSuccess(function (items) {
                    this.queryCache.unserialize(data.queries);
                    return this._options.hideMetaData ? items.map(function (item) {
                        return this.removeItemMeta(item);
                    }, this) : items;
                }, this);
            }

        };
    });
});



Scoped.define("module:Stores.CacheStrategies.CacheStrategy", [
                                                              "base:Class"    
                                                              ], function (Class, scoped) {
    return Class.extend({scoped: scoped}, {

        itemRefreshMeta: function (refreshMeta) {},

        queryRefreshMeta: function (refreshMeta) {},

        itemAccessMeta: function (accessMeta) {},

        queryAccessMeta: function (accessMeta) {},

        validItemRefreshMeta: function (refreshMeta) {},

        validQueryRefreshMeta: function (refreshMeta) {},

        validItemAccessMeta: function (accessMeta) {},

        validQueryAccessMeta: function (accessMeta) {}


    });    
});


Scoped.define("module:Stores.CacheStrategies.ExpiryCacheStrategy", [
                                                                    "module:Stores.CacheStrategies.CacheStrategy",
                                                                    "base:Time",
                                                                    "base:Objs"
                                                                    ], function (CacheStrategy, Time, Objs, scoped) {
    return CacheStrategy.extend({scoped: scoped}, function (inherited) {
        return {

            constructor: function (options) {
                inherited.constructor.call(this);
                this._options = Objs.extend({
                    itemRefreshTime: 24 * 60 * 1000,
                    itemAccessTime: 10 * 60 * 60 * 1000,
                    queryRefreshTime: 24 * 60 * 1000,
                    queryAccessTime: 10 * 60 * 60 * 1000,
                    now: function () {
                        return Time.now();
                    }
                }, options);
            },

            itemRefreshMeta: function (refreshMeta) {
                if (refreshMeta)
                    return refreshMeta;
                if (this._options.itemRefreshTime === null)
                    return null;
                return this._options.now() + this._options.itemRefreshTime;
            },

            queryRefreshMeta: function (refreshMeta) {
                if (refreshMeta)
                    return refreshMeta;
                if (this._options.queryRefreshTime === null)
                    return null;
                return this._options.now() + this._options.queryRefreshTime;
            },

            itemAccessMeta: function (accessMeta) {
                if (this._options.itemAccessTime === null)
                    return null;
                return this._options.now() + this._options.itemAccessTime;
            },

            queryAccessMeta: function (accessMeta) {
                if (this._options.queryAccessTime === null)
                    return null;
                return this._options.now() + this._options.queryAccessTime;
            },

            validItemRefreshMeta: function (refreshMeta) {
                return this._options.itemRefreshTime === null || refreshMeta >= this._options.now();
            },

            validQueryRefreshMeta: function (refreshMeta) {
                return this._options.queryRefreshTime === null || refreshMeta >= this._options.now();
            },    

            validItemAccessMeta: function (accessMeta) {
                return this._options.itemAccessTime === null || accessMeta >= this._options.now();
            },

            validQueryAccessMeta: function (accessMeta) {
                return this._options.queryAccessTime === null || accessMeta >= this._options.now();
            }

        };
    });    
});