@onehat/data
Version:
JS data modeling package with adapters for many storage mediums.
771 lines (666 loc) • 21.6 kB
JavaScript
/** @module Repository */
import EventEmitter from '@onehat/events';
import Repository from '../Repository.js';
import Command from './Command.js';
import moment from 'relative-time-parser'; // Notice this version of moment is imported from 'relative-time-parser', and may be out of sync with our general 'moment' package
import {
v4 as uuid,
} from 'uuid';
import _ from 'lodash';
export const MODE_LOCAL_MIRROR = 'MODE_LOCAL_MIRROR';
export const MODE_COMMAND_QUEUE = 'MODE_COMMAND_QUEUE';
export const MODE_REMOTE_WITH_OFFLINE = 'MODE_REMOTE_WITH_OFFLINE';
/**
* Class representing a (pseudo) Repository that has two sides: local, and remote.
* Local can be any subclass of Repository where isLocal === true.
* Remote can be any subclass of Repository where isRemote === true.
* The most common use is for local to be an OfflineRepository and remote to be
* an AjaxRepository.
*
* Note: This is not a true subclass of Repository. Instead, its properties
* and methods are Proxied from the "ActiveRepository", either the local or remote,
* depending upon operating mode.
*
* Multiple operating modes:
* - MODE_LOCAL_MIRROR (default mode)
* - This mode is for keeping local copies of data that don't change very often.
* - It's often used in apps for offline functionality--the app reads from this repository,
* and saves its commands to a CommandQueue repository.
* - *Add/Edit/Delete operations are disabled.*
* - First time in use, it loads its data from remote.
* - From then on, it primarily depends upon local.
* - Keeps track of the last time it pulled down from remote.
* - Can be set to reload its local data periodically, to make sure its data doesn't get too stale.
*
* - MODE_COMMAND_QUEUE
* - This mode provides the ability to send remote commands when isConnected,
* and to queue them up in an offline manner when !isConnected.
* - Items added locally are automatically transmitted to back-end when isConnected.
* - Data only goes from local --> remote; not the other way around.
* However, we do get a response from the server on the returned object.
* - Remote only uses C, not RUD
* - Sort by date, transmit by date
*
* NOTE: This mode is able to send commands of many different types, and have
* specialized event handlers for each separate type. In order to do that,
* this Repository becomes nothing more than a generic data transport pipeline between
* client and server. The Entities it returns are representative of what was returned
* from the server (raw payload, response info). In order to use these entities in any
* kind of meaningful way, we process them into Command objects. Each Command type
* can have its own set of processing handlers.
*
* As a result, when operating in this mode, OneHatData::_createRepository forces this
* Repository's remote repository type to be a CommandRepository.
*
*
* - MODE_REMOTE_WITH_OFFLINE (not currently implemented)
* - This mode provides an offline backup to the normal operation of remote.
* - Normally uses remote, but automatically switches to local if necessary (i.e. if offline).
* - Any changes made while offline will be saved to a queue, and then replayed to remote
* when the remote source becomes available again
*
* @extends EventEmitter
*/
class LocalFromRemoteRepository extends EventEmitter {
constructor(config = {}) {
super(...arguments);
// OneHatData._createRepository has already created the local and remote repositories
if (!config.local || !(config.local instanceof Repository)) {
this.throwError('No local repository defined.');
return;
}
if (!config.local.isLocal) {
this.throwError('Local repository is not configured to be a local type.');
return;
}
if (!config.remote || !(config.remote instanceof Repository)) {
this.throwError('No remote repository defined.');
return;
}
if (!config.remote.isRemote) {
this.throwError('Remote repository is not configured to be a remote type.');
return;
}
const defaults = {
/**
* @member {string} id - Must be unique, if supplied. Defaults to UUID
*/
id: uuid(),
/**
* @member {string} mode - The mode this Repository will operate in.
* Options: MODE_LOCAL_MIRROR || MODE_REMOTE_WITH_OFFLINE || MODE_COMMAND_QUEUE
* Defaults to MODE_LOCAL_MIRROR
*/
mode: MODE_LOCAL_MIRROR,
/**
* @member {boolean} isAutoSync - Whether to auto sync this repository on initialization
*/
isAutoSync: false,
/**
* @member {string} syncRate - Interval with which to sync local with remote.
* Format must be a relative time frame parseable with relative-time-parser's relativeTime() function.
* Examples: '+10 minutes', '+6 hours', '+1 day', '+1 week'
*/
syncRate: '+1 day',
/**
* @member {string} retryRate - Interval with which to re-try syncing local with remote after a failure.
* Format must be a relative time frame parseable with relative-time-parser's relativeTime() function.
* Examples: '+10 minutes', '+6 hours', '+1 day', '+1 week'
*/
retryRate: '+1 minute',
/**
* @member {boolean} useLongTimers - Whether to set "long" timers in JS. Sometimes React Native-Android has issues with this.
*/
useLongTimers: true,
/**
* @member {boolean} isOnline - Whether the remote storage medium is available.
* This must be managed by outside software, calling setIsOnline at appropriate times.
* @private
*/
isOnline: true,
/**
* Config var to be used in MODE_COMMAND_QUEUE mode
* This tells the LFR repository which commands will be initialized
* @member {array} commands - Names of commands
* @private
*/
commands: [],
};
_.merge(this, defaults, config);
if (this.mode !== MODE_LOCAL_MIRROR &&
this.mode !== MODE_REMOTE_WITH_OFFLINE &&
this.mode !== MODE_COMMAND_QUEUE) {
this.throwError('Mode not recognized.');
return;
}
this.registerEvents([
'beginSync',
'endSync',
'destroy',
'error',
]);
/**
* @member {boolean} isSyncing - Whether this Repository is currently syncing
* @private
*/
this.isSyncing = false;
/**
* @member {date} lastSync - Date of last sync operation between local and remote
* @private
*/
this.lastSync = null;
// This ES6 Proxy allows us to create magic getters and setters for all properties and methods
// of the active Repository (local or remote). If they exist on the LocalFromRemote, those are used.
// Otherwise, the Proxy will pass the getter or setter to the active Repository.
const oThis = this;
this._proxy = new Proxy(this, {
get (target, name, receiver) {
if (name === 'then') { // special case, otherwise Promises break
return Reflect.get(target, name, receiver);
}
if (!Reflect.has(target, name)) {
const activeRepo = oThis._getActiveRepository();
return activeRepo[name];
}
return Reflect.get(target, name, receiver);
},
set(target, name, value, receiver) {
if (Reflect.has(target, name)) {
return Reflect.set(target, name, value, receiver);
} else {
const activeRepo = oThis._getActiveRepository();
return Reflect.set(activeRepo, name, value);
}
},
});
return this._proxy; // Return the Proxy, not 'this'
}
/**
* Initializes the Repository.
* - Relays all events from sub-repositories
*/
async initialize() {
// // This is going to relay the events, but add a prefix to each
// this.relayEventsFrom(this.remote, this.remote.getRegisteredEvents(), 'remote_');
// this.relayEventsFrom(this.local, this.local.getRegisteredEvents(), 'local_');
// // Relay events from activeRepository directly, without prefix
// const activeRepository = this._getActiveRepository();
// this.relayEventsFrom(activeRepository, activeRepository.getRegisteredEvents());
// Set up and initialize commands
if (this.mode === MODE_COMMAND_QUEUE) {
this.setCheckReturnValues();
const commands = this.commands; // copy config array into local var
this.commands = {}; // reset the local var as an object, so we can index commands by name
this.registerCommands(commands);
}
if (this.isAutoSync) {
this._doAutoSync();
}
}
// ____ __ __
// / __ \_ _____ _____/ /___ ____ _____/ /____
// / / / / | / / _ \/ ___/ / __ \/ __ `/ __ / ___/
// / /_/ /| |/ / __/ / / / /_/ / /_/ / /_/ (__ )
// \____/ |___/\___/_/ /_/\____/\__,_/\__,_/____/
// The following methods are all overloads of EventEmitter methods.
// They determine whether the events they pertain to are registered on this
// LocalFromRemote class, or if they should be relayed to the active repository.
emit(name) { // NOTE: Purposefully do not use an arrow-function, so we have access to arguments
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository().emit(...arguments);
}
return super.emit(...arguments);
}
_emitAlt(name) {
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository()._emitAlt(...arguments);
}
return super._emitAlt(...arguments);
}
addListeners(names) {
const
registeredEvents = this._registeredEvents,
activeRepository = this._getActiveRepository();
_.each(names, (name) => {
if (_.indexOf(registeredEvents, name) === -1) {
activeRepository.on(...arguments);
} else {
super.on(...arguments);
}
});
}
addListener(name) {
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository().addListener(...arguments);
}
return super.addListener(...arguments);
}
on(name) {
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository().on(...arguments);
}
return super.on(...arguments);
}
once(name) {
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository().once(...arguments);
}
return super.once(...arguments);
}
removeListener(name) {
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository().removeListener(...arguments);
}
return super.removeListener(...arguments);
}
off(name) {
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository().off(...arguments);
}
return super.off(...arguments);
}
removeListeners(names) {
const
registeredEvents = this._registeredEvents,
activeRepository = this._getActiveRepository();
_.each(names, (name) => {
if (_.indexOf(registeredEvents, name) === -1) {
activeRepository.off(...arguments);
} else {
super.off(...arguments);
}
});
}
isRegisteredEvent(name) {
if (_.indexOf(this._registeredEvents, name) === -1) {
return this._getActiveRepository().isRegisteredEvent(...arguments);
}
return super.isRegisteredEvent(...arguments);
}
// ________ __ ___ __ __ __
// / ____/ /___ ___________ / |/ /__ / /_/ /_ ____ ____/ /____
// / / / / __ `/ ___/ ___/ / /|_/ / _ \/ __/ __ \/ __ \/ __ / ___/
// / /___/ / /_/ (__ |__ ) / / / / __/ /_/ / / / /_/ / /_/ (__ )
// \____/_/\__,_/____/____/ /_/ /_/\___/\__/_/ /_/\____/\__,_/____/
/**
* Registers multiple commands for when syncing in MODE_COMMAND_QUEUE mode.
*/
registerCommands(commands, useDefaultHandler = true) {
const oThis = this;
_.each(commands, (name) => {
if (!oThis.isRegisteredCommand(name)) {
const command = new Command(name);
if (useDefaultHandler) {
command.useDefaultHandler();
}
oThis.commands[name] = command;
}
});
}
/**
* Adds a handler to a registered command.
* @param {string} name - The command name
* @return {function} handler - The handler function
*/
registerCommandHandler(name, handler) {
const command = this.getCommand(name);
if (!command) {
return false;
}
command.registerHandler(handler);
}
/**
* Removes a handler from a registered command.
* @param {string} name - The command name
* @return {function} handler - The handler function
*/
unregisterCommandHandler(name, handler) {
const command = this.getCommand(name);
if (!command) {
return false;
}
command.unregisterHandler(handler);
}
/**
* Checks to see if command has been registered.
* @param {string} name - The command name
* @return {boolean} isRegisteredCommand
*/
isRegisteredCommand(name) {
return _.indexOf(this.commands, name) !== -1;
}
/**
* Gets a registered command.
* @param {string} name - The command name
* @return {boolean} isRegisteredCommand
*/
getCommand(name) {
return this.commands[name] || null;
}
/**
* Adds a hook into the normal Repository.add() method,
* so we can sync immediately after add for MODE_COMMAND_QUEUE mode.
*/
async add(data) {
// NORMAL PROCESS
// This adds to the local repository, so we can sync later, if needed.
const normalAdd = await this._getActiveRepository().add(data);
if (this.mode !== MODE_COMMAND_QUEUE || !this.isOnline) {
return normalAdd;
}
// MODE_COMMAND_QUEUE -- try to sync now!
return await this.sync(normalAdd);
}
/**
* Syncs local and remote repositories, based on operation mode.
*/
async sync(entity, callback = null) {
if (this.debugMode) {
console.log('sync');
}
try {
if (!this.isOnline) {
this._doAutoSync(true);
return;
}
this.isSyncing = true;
this.emit('beginSync', this);
let remoteData;
switch (this.mode) {
case MODE_LOCAL_MIRROR:
// Load remote data into local
// Local <-- Remote
if (!this.remote.isAutoLoad) {
await this.remote.load();
}
remoteData = this.remote.getOriginalData();
await this.local.load(remoteData);
if (!this.local.isAutoSave) {
await this.local.save();
}
await this._setLastSync();
break;
case MODE_COMMAND_QUEUE:
const localItems = entity ? [entity] : this.local.getBy(entity => !entity.response);
let i, localItem;
for (i = 0; i < localItems.length; i++) {
localItem = localItems[i];
let command = this.commands[localItem.command];
if (!command) {
if (localItem.command) {
this.throwError('Command ' + localItem.command + ' not registered');
}
return;
}
if (!command.hasHandlers()) {
this.throwError('No command handler registered for ' + localItem.command);
return;
}
// local --> remote
const remoteItem = await this.remote.add(localItem.getOriginalData());
if (!this.remote.isAutoSave) {
await this.remote.save();
}
// local <-- remote
localItem.response = remoteItem.response;
this.remote.clear();
// Handle the server's response
await command.processResponse(localItem);
// let shouldDelete = true;
// try {
// shouldDelete = await handler.call(this, localItem);
// } catch(error) {
// this.emit('error', error.message);
// }
// if (shouldDelete) {
// await this.local.delete(localItem);
// }
}
await this._setLastSync();
if (entity) {
return entity;
}
break;
case MODE_REMOTE_WITH_OFFLINE:
this.throwError('Not implemented');
return;
// Load remote data into local
// Local <-- Remote
if (!this.remote.isAutoLoad) {
await this.remote.load();
}
remoteData = this.remote.getOriginalData();
await this.local.load(remoteData);
if (!this.local.isAutoSave) {
await this.local.save();
}
await this._setLastSync();
break;
// Load remote
// Compare it to local
// Find all local new, edited, and deleted records.
// Should we push these changes to server immediately?
// How do we know which to use as master (local or remote)?
// i.e. What do we do if they get out of sync?
// If no changes to push, just reload remote and then save
}
} catch(error) {
if (this.debugMode) {
const msg = error && error.message;
debugger;
}
} finally {
this.isSyncing = false;
this.emit('endSync', this);
if (callback) {
callback();
}
}
}
/**
* Sync on a regular schedule.
* Two operating modes: isRetry or !isRetry.
* If !isRetry, then we're just doing a regular autoSync.
* This will schedule the next sync based on nextDue.
*
* if isRetry, we are retrying to sync, due to being offline.
* This will schedule the next sync based on nextRetryDate.
*/
async _doAutoSync(isRetry = false) {
const now = moment(),
nowMs = now.valueOf();
let lastSync = await this.getLastSync(),
nextSync = this.getNextSync(),
nextSyncMs = nextSync.valueOf(),
nextRetry = this.getNextRetry(),
nextRetryMs = nextRetry.valueOf(),
ms = (isRetry ? nextRetryMs : nextSyncMs) - nowMs,
hours = ms / 1000 / 60 / 60;
if (ms < 0) {
// Sync now
await this.sync();
// Now figure out the NEXT sync time
lastSync = await this.getLastSync();
nextSync = this.getNextSync();
nextSyncMs = nextSync.valueOf();
nextRetry = this.getNextRetry();
nextRetryMs = nextRetry.valueOf();
ms = (isRetry ? nextRetryMs : nextSyncMs) - nowMs;
hours = ms / 1000 / 60 / 60;
}
// console.log({
// ms,
// hours,
// now: now.format('lll'),
// nowMs,
// lastSync: lastSync && lastSync.format('lll'),
// nextSync: nextSync.format('lll'),
// nextSyncMs,
// nextRetry: nextRetry.format('lll'),
// nextRetryMs,
// });
if (this._timeout) {
clearTimeout(this._timeout);
}
if (ms < 0) {
// It just synced. Should not need to sync again right now!
// This is basically an error condition, but we suppress this error
// and simply don't sync.
return;
}
if (ms < 60000 || this.useLongTimers) {
this._timeout = setTimeout(async () => {
// await this.sync();
if (!isRetry) {
this._doAutoSync(); // Set up next autosync timer
}
}, ms);
}
}
/**
* Gets lastSync from private variable,
* or from local storage medium, if possible.
*/
async getLastSync() {
if (!this.lastSync && this.local.getLastSync) {
const lastSync = await this.local.getLastSync();
// const lastSync = null;
if (lastSync) {
this.lastSync = lastSync;
}
}
return this.lastSync;
}
getLastModifiedDate() {
return this.remote.getLastModifiedDate();
}
/**
* Sets lastSync to now and saves to local storage medium, if possible.
* @private
*/
async _setLastSync() {
if (!this.local.entities.length) {
return; // don't set sync date if nothing was synced
}
const now = moment();
this.lastSync = now;
if (this.local.setLastSync) {
await this.local.setLastSync(now.format());
}
};
getNextRetry() {
const date = moment().relativeTime(this.retryRate);
if (!isNaN(date) && date.isValid()) {
return date;
}
return null;
}
getNextSync() {
const oneMinuteAgo = moment().relativeTime('-1 minute');
if (!this.lastSync) {
return oneMinuteAgo;
}
if (this.isOnline && this.needsSync) {
return oneMinuteAgo;
}
const date = this.lastSync.relativeTime(this.syncRate);
if (!isNaN(date) && date.isValid()) {
return date;
}
return null;
}
get needsSync() {
if (this.mode === MODE_LOCAL_MIRROR) {
if (!_.isEmpty(this.local.getNonPersisted()) ||
!_.isEmpty(this.local.getDirty()) ||
!_.isEmpty(this.local.getDeleted())
) {
return true;
}
}
if (this.mode === MODE_COMMAND_QUEUE) {
const unsynced = this.local.getBy(entity => !entity.response);
if (unsynced.length) {
return true;
}
}
return this.nextSyncDate < moment();
}
/**
* Gets the active Repository, based on this.mode
* @return {object} repository
* @private
*/
_getActiveRepository() {
switch(this.mode) {
case MODE_LOCAL_MIRROR:
case MODE_COMMAND_QUEUE:
return this.local;
case MODE_REMOTE_WITH_OFFLINE:
if (this.isOnline) {
return this.remote;
}
return this.local;
}
}
/**
* Sets autoSync. If autoSync is enabled, it immediately starts autosync process.
*/
async setAutoSync(isAutoSync) {
let isChanged = false
if (this.isAutoSync !== isAutoSync) {
isChanged = true;
this.isAutoSync = isAutoSync;
if (isAutoSync) {
await this._doAutoSync();
} else {
clearTimeout(this._timeout);
}
}
return isChanged;
}
/**
* Sets options on the repositories.
*/
setOptions(options) {
this.local.setOptions(options);
this.remote.setOptions(options);
}
/**
* Sets isOnline. If isOnline and autoSync is enabled, it immediately starts isAutosync process.
*/
setIsOnline(isOnline) {
this.isOnline = !!isOnline; // force convert type to boolean
if (isOnline && this.isAutoSync) {
this._doAutoSync();
}
}
get className() {
return this.__proto__.constructor.className;
}
get type() {
return this.__proto__.constructor.type;
}
/**
* Destroy this object.
* - Removes child objects
* - Removes event listeners
* @fires destroy
*/
destroy() {
this.local.destroy();
this.remote.destroy();
if (this._timeout) {
clearTimeout(this._timeout);
}
_.each(this.commands, (command) => {
command.destroy();
});
this.emit('destroy');
this.isDestroyed = true;
this.removeAllListeners();
}
};
LocalFromRemoteRepository.className = 'LocalFromRemote';
LocalFromRemoteRepository.type = 'lfr';
export default LocalFromRemoteRepository;