// tslint:disable only-arrow-functions import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { Subject } from 'rxjs/Subject'; import { EventEmitter, Handler, Subscription } from './emitter'; import * as utils from './utils'; import { Chain } from './chain'; import { ChainableQuery, Collection, CollectionSearcher, LocalForage, LocalModel, LocalResourceFactory, Model, ModelArray, Plugin, Query, QueryFunction, ResourceFactory as IResourceFactory} from './types'; type LastRequest = (Date | number); // The length of time since an object has been requested that we keep in persistent storage. // In milliseconds - default to 7 days export const defaultPStorageMaxLen = 7 * 24 * 60 * 60 * 1000; export function createResourceFactory(LocalResourceFactory: LocalResourceFactory, localforage: LocalForage, plugins: Array>, pStorageMaxLen: number = defaultPStorageMaxLen): IResourceFactory { const ResourceFactory: IResourceFactory = (url: string, rootKey: string, rootKeyPlural?: string) => { rootKeyPlural = rootKeyPlural || (rootKey + 's'); // Create the local resource let LocalResource = LocalResourceFactory(url, rootKey, rootKeyPlural); // In memory resource store let _resources: {[id: string]: Model} = {}; let _resFromLocal: {[$id: string]: Model} = {}; // Create an event emitter let emitter = new EventEmitter(); // Load from persistent storage (if we have something more exciting than localstorage) if (utils.advancedStorage(localforage)) { let pStorageKey = utils.persistentStorageKey(url); localforage.getItem(pStorageKey).then(function(data) { if (!(data && Array.isArray(data))) { return; } let now = new Date().getTime(); data.forEach(function(datum) { let obj = datum.obj; let lastRequested = datum.lastreq; // Only create if the time between now and the last request is less than // pStorageMaxLen if ((now - lastRequested) <= pStorageMaxLen) { createFromStorage(obj, lastRequested); } }); }); } function localToRes(loc: LocalModel, id: string) { let res = _resFromLocal[loc.$id]; // If we don't have a resource then create it if (!res) { res = new ( Resource)(loc, true); } if (id) { addedResource(id, res); } return res; } function toObject(this: Model) { return utils.toObject(this); } function reset(this: Model) { // If we aren't saved at all then just remove if (!this.$created) { this.$remove(true); return; } let newValues = this.$loc.$toObject(); utils.removeResValues(this); utils.setResValues(this, newValues); } // The promise resolves once the save has been committed function save(this: Model) { let res = this; res.$created = true; return this.$loc.$save(this.$toObject()).then(function() { emitter.emit('updated', res); res.$subject.next(res); if (!res.$resolved) { res.$deferred.resolve(res); res.$resolved = true; } return res; }); } function remove(this: Model, noPrompt?: boolean, skipLocal?: boolean) { if (!noPrompt) { // Add confirmation alert if (!window.confirm('Do you really want to delete this item?')) { return; } } this.$deleted = true; if (this._id) { removedResource(this._id, this); } let prom; if (skipLocal) { prom = Promise.resolve(true); } else { prom = this.$loc.$remove(); } emitter.emit('remove', this); return prom; } function type() { return rootKey[0].toUpperCase() + rootKey.slice(1); } function collection(seeds: Model | ModelArray, relation: CollectionSearcher): Collection { return LocalResource.collect(Resource, seeds, relation); } function chain(origQry: ChainableQuery, modelOrQryFn: (typeof Resource | QueryFunction), qryFn?: QueryFunction): Query { return Chain(origQry, modelOrQryFn, qryFn); } function query(qry: any, limit?: number) { let result = LocalResource.query(qry, limit, Resource); emitter.emit('query', result, qry, limit); return result; } function get(id: string, force?: boolean): Model; function get(ids: string[], force?: boolean): ModelArray; function get(ids: (string | string[]), force?: boolean) { let results = LocalResource.get(ids, force, localToRes); emitter.emit('get', ids, results, force); return results; } function addedResource(id: string, res: Model) { let existing = _resources[id]; _resources[id] = res; if (!existing) { emitter.emit('created', id, res); } } function removedResource(id: string, res: Model) { let existing = _resources[id]; delete _resources[id]; if (existing) { emitter.emit('deleted', id, res); res.$subject.next(null); } } function updatedLocal(res: Model, newVal: any, oldVal: any) { // We could have been deleted (existing oldVal, null newval) if (oldVal && !newVal) { // We have been deleted. Cleanup ourselves res.$remove(true, true); } else { res.$created = true; if (oldVal._id && (newVal._id !== oldVal._id)) { throw new Error('Not allowed to change id'); } // Merge the objects together using the oldVal as a base, and using *our* version // to resolve any conflicts. We may need to put in some conflict resolution logic // somewhere on a case by case basis let merge = utils.mergeObjects(res.$toObject(), oldVal, newVal); // Now put back in the merge values utils.removeResValues(res); utils.setResValues(res, merge); // Make sure we are stored addedResource(res._id, res); } // Notify on the subject that we've updated res.$subject.next(res); } function updateServer(this: Model, val: any) { this.$loc.$updateServer(val); } // updates or creates a model depending on whether we know about it already. This // is usually used when we recieve a response with model data from the server using // a non-standard method function updateOrCreate(val: any) { let res = _resources[val._id]; if (!res) { res = new ( Resource)(val, false, new Date().getTime()); } else { res.$updateServer(val); } return res; } function createFromStorage(val: any, lastRequested: LastRequest) { let res = _resources[val._id]; // If we do have already know about a resource then lets assume it is more up to date // than the storage version. If we haven't resolved yet though (or we don't yet have // an ID on the object) lets update our values if (!res) { res = new ( Resource)(val, false, lastRequested); } else if (!res._id || !res.$resolved) { res.$updateServer(val); } return res; } function refresh() { this.$loc.$refresh(); } class Resource implements Model { public static get = get; public static query = query; public static chain = chain; public static type = type; public static updateOrCreate = updateOrCreate; public static collect = collection; public static dispose: {(): void}; public static addListener: {(name: string, handler: Handler): void}; public static emit: {(fnName: string, ...data: any[]): void}; public static listen: {(name: string, handler: Handler): Subscription}; public static off: {(name: string, handler: Handler): void}; public static on: {(name: string, handler: Handler): void}; public static once: {(name: string, handler: Handler): void}; public static removeListener: {(name: string, handler: Handler): void}; public _id?: string; public $created: boolean; public $deleted: boolean; public $promise: Promise; public $subject: BehaviorSubject; public $resolvedSubject: Subject; public $deferred: utils.Deferred; public $resolved: boolean; public $loc: LocalModel; public $id: string; public $save = save; public $remove = remove; public $delete = remove; public $type = type; public $reset = reset; public $refresh = refresh; public $toObject = toObject; public $updateServer = updateServer; constructor(val?: any, fromLoc?: boolean, lastRequested?: LastRequest) { let res = this; this.$deleted = false; this.$deferred = new utils.Deferred(); this.$promise = this.$deferred.promise; // An initial promise for our initial // fetch or create of data this.$resolved = false; // Have we had an initial resolution of the promise this.$created = false; // Goes true on an initial save this.$subject = new BehaviorSubject(this); this.$resolvedSubject = utils.convertToResolvedSubject(this.$subject, this.$promise); // If we've been given values put them on if (val) { let props = fromLoc ? val.$toObject() : val; for (let key of Object.keys(props)) { res[key] = props[key]; } } // Create the local resource if (fromLoc) { this.$loc = val; this.$loc.$mod = this; // Resolve the promise once the local copy has resolved this.$loc.$promise.then(function() { if (!res.$resolved) { res.$deferred.resolve(res); res.$resolved = true; } }); } else if (this._id) { // We have an id - so we persist down this.$loc = new ( LocalResource)(val, false, this, lastRequested); // We immediately resolve our promise since we have data this.$deferred.resolve(this); this.$resolved = true; } else { // Don't add in the values until we save this.$loc = new ( LocalResource)(null, false, this); } this.$id = this.$loc.$id; _resFromLocal[this.$id] = this; // If we have an id then add us to the store if (this._id) { addedResource(this._id, this); } // Listen for changes on the local resource this.$loc.$emitter.on('update', function(newVal, oldVal) { updatedLocal(res, newVal, oldVal); }); } } // Trigger any plugins for (let plugin of plugins) { plugin(Resource, rootKey); } inheritEventEmitter(Resource, emitter); return Resource; }; ResourceFactory.Chain = Chain; return ResourceFactory; } function inheritEventEmitter(obj: any, emitter: EventEmitter) { let props = [ 'dispose', 'addListener', 'emit', 'listen', 'off', 'on', 'once', 'removeListener', ]; // Event emitter 'inheritance' for (let prop of props) { obj[prop] = function() { return emitter[prop].apply(emitter, arguments); }; } }