@ortoo/angular-resource
Version:
Magical CRUD resource management for angular
508 lines (407 loc) • 15.2 kB
text/typescript
// 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<Model>,
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<any> = 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 (<any> 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<Model>, seeds: Model | ModelArray<Model>, relation: CollectionSearcher) {
return new Collection(Resource, seeds, relation);
}
function query(qry: any, limit: number, Resource: Resource<Model>) {
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<void> {
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<void> {
// 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<void>(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<void> {
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 (<any> ServerResource)(val, this.$id);
} else {
// Don't add in the values until we save
this.$serv = new (<any> 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;
}