// tslint:disable only-arrow-functions import { values } from 'lodash'; import { EventEmitter } from './emitter'; import { CollectionFactory, CollectionSearcher, DatabaseFactory, JSONPatch, LocalForage, LocalModel, LocalResource as ILocalResource, Model, ModelArray, QueryFactory, Resource, ServerModel, ServerResourceFactory } from './types'; import * as utils from './utils'; interface ResourceMap { [id: string]: LocalModel; } type LastRequest = (Date | number); const MAX_STORAGE_SIZE = 3 * 1024 * 1024; // 3MB - should fit without problems in any browser export function createLocalResourceFactory(ServerResourceFactory: ServerResourceFactory, QueryFactory: QueryFactory, CollectionFactory: CollectionFactory, ResourceDBFactory: DatabaseFactory, localforage: LocalForage) { let totalStorageSize = 0; let totalDesiredSize = 0; let persistMode = 'FULL'; let persistModeEmitter = new EventEmitter(); function LocalResourceFactory(url: string, rootKey: string, rootKeyPlural: string) { let db = ResourceDBFactory(); // Create the server resource and query let ServerResource = ServerResourceFactory(url, rootKey, rootKeyPlural); let QueryList = QueryFactory(url, rootKey, rootKeyPlural, db); let Collection = CollectionFactory(); // In memory resource store let _resources: ResourceMap = {}; let _internalRes: ResourceMap = {}; let ourStorageSize = 0; let ourDesiredSize = 0; let persistModeWatching = false; let _lastreqs: {[id: string]: LastRequest} = {}; let _reqs = {}; let pStorageKey = utils.persistentStorageKey(url); let persistProm: Promise = null; function toObject() { return utils.toObject(this); } function idToModel(id: string) { let loc = _internalRes[id]; return loc.$mod; } function serverTransform(serv: ServerModel, id: string, transformer: Function) { let res: LocalModel = _internalRes[serv.$id]; // If we don't have a resource then create it if (!res) { res = new ( LocalResource)(serv, true); } if (id) { _resources[id] = res; } return transformer(res, id); } function get(ids: (string | string[]), force?: boolean, transform?: Function) { // We've requested a bunch of ids // We should persist the change (to offline storage) if this is the first request // for this (these) objects let now = new Date().getTime(); let isSomeFirst = false; if (Array.isArray(ids)) { ids.forEach(function(id) { if (!_reqs[id]) { isSomeFirst = true; } _lastreqs[id] = now; _reqs[id] = true; }); } else { if (!_reqs[ids]) { isSomeFirst = true; } _lastreqs[ids] = now; _reqs[ids] = true; } // If our persistMode is MIN we'll want to persist to storage here if (persistMode === 'MIN' && isSomeFirst) { persistChange(); } return ServerResource.get(ids, force, function(serv: ServerModel, id: string) { return serverTransform(serv, id, transform); }); } function collect(Resource: Resource, seeds: Model | ModelArray, relation: CollectionSearcher) { return new Collection(Resource, seeds, relation); } function query(qry: any, limit: number, Resource: Resource) { return QueryList(qry, limit, Resource, idToModel); } function remove(this: LocalModel, skipServer?: boolean) { if (this._id) { delete _resources[this._id]; } this.$deleted = true; // Kick the database syncToStorage(this); // If we have been created then notify the server if (this.$created && !skipServer) { return this.$serv.$remove(); } return Promise.resolve(true); } // Once we are synced with the server resource we will resolve the promise function save(this: LocalModel, vals: any) { let res = this; res.$created = true; // Only trigger the server sync once per 'tick' (so calling save() multiple times // has no effect) if (!res.$saveprom) { let oldData = this.$toObject(); res.$saveprom = new Promise(resolve => { setTimeout(function() { resolve(); }); }).then(function () { res.$saveprom = null; // Save us to the db syncToStorage(res); let patch = utils.diff(oldData, res.$toObject()); return syncToServer(res, patch).then(function() { return res; }); }).then(function() { if (!res.$resolved) { // Wait for the db to sync before resolving (if the sync is outstanding) let dbprom = res.$dbsync ? res.$dbsync : Promise.resolve(); dbprom.then(function() { res.$deferred.resolve(res); res.$resolved = true; }); } return res; }); } utils.removeResValues(res); utils.setResValues(res, vals); return res.$saveprom; } function performServerSync(res: any, patch: JSONPatch) { // We are about to sync. Unset the resync flag res.$resync = []; // Wait for the server to finish saving. Then check if we need to resync. This promise // won't resolve until there are no more resyncs to do return res.$serv.$save(patch).then(function() { if (res.$resync.length > 0) { return performServerSync(res, res.$resync); } return res; }); } function syncToServer(res: any, patch: JSONPatch) { if (res.$sync) { res.$resync.push.apply(res.$resync, patch); } else { let prom = performServerSync(res, patch)['finally'](function() { // Once we've synced remove the $sync promise delete res.$sync; }); res.$sync = prom; } return res.$sync; } function updatedServer(res: LocalModel, newVal: any, oldVal: any) { // We could have been deleted (existing oldVal, null newval) if (oldVal && !newVal) { // We have been deleted. Cleanup ourselves and pass it up the chain res.$emitter.emit('update', null, res.$toObject()); res.$remove(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 preexist = res.$toObject(); 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 _resources[res._id] = res; // If we've only just been given an id then store of the created time as the // time we were last requested (this is because the object must have just been // created on the server) if (res._id && !oldVal._id) { _lastreqs[res._id] = res.$createdAt; _reqs[res._id] = true; } // Notify that we have changed res.$emitter.emit('update', res.$toObject(), preexist); // Kick the db let syncprom = syncToStorage(res) || Promise.resolve(); syncprom.then(function() { // We might have synced for the first time return res.$serv.$promise.then(function() { if (!res.$resolved) { res.$deferred.resolve(res); res.$resolved = true; } }); }); } } function performDbSync(res: LocalModel): Promise { res.$dbresync = false; return db.update(res).then(function() { if (res.$dbresync) { return performDbSync(res); } return; }); } function watchForPersistModeChanges() { if (persistModeWatching) { return; } persistModeEmitter.on('change', function(_url) { // If we have called this then ignore if (_url === url) { return; } // Redo the persist doPersist(); }); } function doPersist(): Promise { // If we're already doing a persist or we dont have advanced storage options then // just return if (persistMode === 'NONE' || !utils.advancedStorage(localforage)) { return Promise.resolve(); } // If this is the first time through then stick a listener on for changes if (!persistModeWatching) { watchForPersistModeChanges(); } let data: Array<{lastreq: LastRequest, obj: any}> = []; switch (persistMode) { case 'FULL': data = values(_resources).map(function(res) { return { lastreq: _lastreqs[res._id], obj: res.$toObject(), }; }); break; case 'MIN': values(_resources).forEach(function(res) { if (_reqs[res._id]) { data.push({ lastreq: _lastreqs[res._id], obj: res.$toObject(), }); } }); break; default: return Promise.resolve(); } // We need to manually manage storage let dataStr = JSON.stringify(data); let newStorageSize = dataStr.length; let expectedSize = totalDesiredSize - ourDesiredSize + newStorageSize; // Do we expect to bust the max size? If so we need to change persist mode // and emit if (expectedSize > MAX_STORAGE_SIZE) { if (persistMode === 'FULL') { persistMode = 'MIN'; } else if (persistMode === 'MIN') { persistMode = 'NONE'; } else { // Don't know how we could get here but return just in case return Promise.resolve(); } persistModeEmitter.emit('change', url); // Schedule this later return new Promise(resolve => setTimeout(resolve)).then(doPersist); } // MODE HAS NOT CHANGED // Store our expected size totalDesiredSize = expectedSize; ourDesiredSize = newStorageSize; return localforage.setItem(pStorageKey, data).then(function() { totalStorageSize = totalStorageSize - ourStorageSize + newStorageSize; ourStorageSize = newStorageSize; }); } function persistChange() { // If we're already doing a persist or we dont have advanced storage options then // just return if (persistProm || persistMode === 'NONE' || !utils.advancedStorage(localforage)) { return; } persistProm = new Promise(r => setTimeout(r, 500)).then(function() { return doPersist().then(function() { // Finished the persist persistProm = null; }); }); } function syncToStorage(res: LocalModel): Promise { persistChange(); if (res.$dbsync) { res.$dbresync = true; } else { let prom = performDbSync(res); prom.then(finishedSync, finishedSync); res.$dbsync = prom; } return res.$dbsync; function finishedSync() { // Whatever happens remove the $sync promise and refresh all the queries delete res.$dbsync; res.$dbresync = false; QueryList.refresh(); } } function updateServer(this: LocalModel, val: any) { this.$serv.$updateVal(val); } function refresh(this: LocalModel) { this.$serv.$refresh(); } function LocalResourceConstructor(val: any, fromServ?: boolean, mod?: any, lastRequested?: (Date | number)) { let res = this; this.$emitter = new EventEmitter(); 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; // Have we pushed any values down to the server yet? this.$createdAt = new Date().getTime(); this.$resync = []; this.$saveprom = null; this.$deleted = false; // Store off the model so we can reference it later this.$mod = mod; // Used to correlate to the db objects when we don't have an _id (creating) this.$id = fromServ ? val.$id : utils.uuid(); _internalRes[this.$id] = this; // If we've been given values put them on if (val) { let props = fromServ ? val.$toObject() : val; for (let key of Object.keys(props)) { res[key] = props[key]; } } if (val && fromServ) { this.$serv = val; } else if (this._id) { this.$serv = new ( ServerResource)(val, this.$id); } else { // Don't add in the values until we save this.$serv = new ( ServerResource)(null, this.$id); } // If we have an id then add us to the store if (this._id) { _resources[this._id] = this; } // If we have an id and we've been passed in a last requested time then store off // the last requested time if (this._id && lastRequested) { // If the last requested already exists use the max let existing = _lastreqs[this._id] || 0; _lastreqs[this._id] = Math.max(lastRequested as number, existing as number); } // Listen for changes on the server this.$serv.$emitter.on('update', function(newVal: any, oldVal: any) { updatedServer(res, newVal, oldVal); }); // Update us in the db this.$dbresync = false; // If it's from the server don't create it yet. Wait for the update to come (along // with hopefully all the data) if (!fromServ && val) { syncToStorage(this); } } const LocalResource: ILocalResource = LocalResourceConstructor; LocalResource.get = get; LocalResource.query = query; LocalResource.collect = collect; LocalResource.prototype.$save = save; LocalResource.prototype.$remove = remove; LocalResource.prototype.$delete = remove; LocalResource.prototype.$toObject = toObject; LocalResource.prototype.$updateServer = updateServer; LocalResource.prototype.$refresh = refresh; return LocalResource; } return LocalResourceFactory; }