UNPKG

happn-3

Version:

pub/sub api as a service using primus and mongo & redis or nedb, can work as cluster, single process or embedded using nedb

978 lines (817 loc) 29.2 kB
module.exports = DataService; var traverse = require('traverse'), sift = require('sift').default, async = require('async'), CONSTANTS = require('../..').constants, util = require('util'), EventEmitter = require('events').EventEmitter, hyperid = require('happner-hyperid').create({ urlSafe: true }), VersionUpdater = require('./versions/updater'), Promise = require('bluebird'); var META_FIELDS = [ '_meta', '_id', 'path', 'created', 'modified', 'timestamp', 'createdBy', 'modifiedBy' ]; var PREFIXED_META_FIELDS = META_FIELDS.filter(function(metaField) { return metaField !== '_meta'; }).map(function(metaField) { if (metaField !== '_meta') return '_meta.' + metaField; }); var UPSERT_TYPE = { upsert: 0, update: 1, insert: 2 }; util.inherits(DataService, EventEmitter); DataService.prototype.UPSERT_TYPE = UPSERT_TYPE; DataService.prototype.initialize = initialize; DataService.prototype.upsert = Promise.promisify(upsert); DataService.prototype.remove = Promise.promisify(remove); DataService.prototype.get = Promise.promisify(get); DataService.prototype.count = Promise.promisify(count); DataService.prototype.processGet = processGet; DataService.prototype.processCount = processCount; DataService.prototype.processRemove = processRemove; DataService.prototype.processStore = processStore; DataService.prototype.processSecureStore = processSecureStore; DataService.prototype.processNoStore = processNoStore; DataService.prototype.getOneByPath = getOneByPath; DataService.prototype.insertTag = insertTag; DataService.prototype.saveTag = saveTag; DataService.prototype.addDataStoreFilter = addDataStoreFilter; DataService.prototype.removeDataStoreFilter = removeDataStoreFilter; DataService.prototype.addDataStoreFilterSorted = addDataStoreFilterSorted; DataService.prototype.removeDataStoreFilterSorted = removeDataStoreFilterSorted; DataService.prototype.parseFields = parseFields; DataService.prototype.filter = filter; DataService.prototype.transform = transform; DataService.prototype.transformAll = transformAll; DataService.prototype.formatSetData = formatSetData; DataService.prototype.randomId = randomId; DataService.prototype.startCompacting = startCompacting; DataService.prototype.compact = compact; DataService.prototype.stop = stop; DataService.prototype.__initializeProviders = __initializeProviders; DataService.prototype._insertDataProvider = _insertDataProvider; DataService.prototype.__attachProviderEvents = __attachProviderEvents; DataService.prototype.__getPullOptions = __getPullOptions; DataService.prototype.__upsertInternal = __upsertInternal; DataService.prototype.__iterateDataStores = __iterateDataStores; DataService.prototype.__updateDatabaseVersions = __updateDatabaseVersions; DataService.prototype.__compacting = {}; DataService.prototype.__providerHasFeature = __providerHasFeature; DataService.prototype.__findDataProviderConfig = __findDataProviderConfig; DataService.prototype.addDataProviderPatterns = addDataProviderPatterns; //gets the item.data as an array (from data, _meta container) DataService.prototype.extractData = extractData; function DataService(opts) { var Logger; if (opts && opts.logger) { Logger = opts.logger.createLogger('Data'); } else { Logger = require('happn-logger'); Logger.configure({ logLevel: 'info' }); } this.log = Logger.createLogger('Data'); this.log.$$TRACE('construct(%j)', opts); this.datastores = {}; this.dataroutes = {}; this.dataroutessorted = []; } function initialize(config, callback) { this.config = config; this.errorService = this.happn.services.error; if (!this.config.datastores) this.config.datastores = []; if (this.config.datastores.length === 0) { //insert the default nedb data store var defaultDatastoreConfig = { name: 'default', provider: 'nedb', isDefault: true, settings: {} }; if (this.config.dbfile) this.config.filename = this.config.dbfile; if (this.config.filename) defaultDatastoreConfig.settings.filename = this.config.filename; if (this.config.compactInterval) defaultDatastoreConfig.settings.compactInterval = this.config.compactInterval; this.config.datastores.push(defaultDatastoreConfig); } if (this.config.secure) this.processStore = this.processSecureStore; this.__initializeProviders() .then(() => { return this.__updateDatabaseVersions(); }) .then(() => { callback(); }) .catch(callback); } function __updateDatabaseVersions() { return new Promise((resolve, reject) => { var versionUpdater = new VersionUpdater(this, this.happn.services.system); versionUpdater.analyzeDB((e, analysis) => { if (e) return reject(e); //make available so we can inspect in our integration tests this.dbVersionAnalysis = analysis; if (analysis.isNew) return versionUpdater.writeVersionToDB(analysis.moduleDBVersion, resolve, reject); if (analysis.matchingVersion) return resolve(analysis); if (!this.config.autoUpdateDBVersion) return reject( new Error( 'current database version ' + analysis.currentDBVersion + ' does not match ' + analysis.moduleDBVersion ) ); versionUpdater.updateDB( analysis, logs => { this.log.warn( 'database upgrade from ' + analysis.currentDBVersion + ' to ' + analysis.moduleDBVersion ); logs.forEach(log => { this.log.info(log.message); }); versionUpdater.writeVersionToDB(analysis.moduleDBVersion, resolve, reject); }, reject ); }); }); } function __providerHasFeature(provider, feature) { if (!provider.featureset) return false; return provider.featureset[feature] === true; } function upsert(path, data, options, callback) { if (typeof options === 'function') { callback = options; options = null; } if (!options) options = {}; if (data) delete data._meta; if (options.set_type === 'sibling') { //appends an item with a path that matches the message path - but made unique by a shortid at the end of the path if (!path.endsWith('/')) path += '/'; path += this.randomId(); } var setData = this.formatSetData(path, data); if (options.tag) { if (data != null) return callback(new Error('Cannot set tag with new data.')); setData.data = {}; options.merge = true; } if (options.merge) { return this.getOneByPath(path, null, (e, previous) => { if (e) return callback(e); if (!previous) { options.upsertType = 2; //just inserting return this.__upsertInternal(path, setData, options, true, callback); } for (var propertyName in previous.data) if (setData.data[propertyName] == null) setData.data[propertyName] = previous.data[propertyName]; setData.created = previous.created; setData.modified = Date.now(); setData._id = previous._id; options.updateType = 1; //updating this.__upsertInternal(path, setData, options, true, callback); }); } this.__upsertInternal(path, setData, options, false, callback); } function get(path, parameters, callback) { try { if (typeof parameters === 'function') { callback = parameters; parameters = null; } var parsedParameters = this.__getPullOptions(path, parameters); var provider = this.db(path); if (parsedParameters.options.aggregate && !this.__providerHasFeature(provider, 'aggregate')) return callback(new Error(`aggregate feature not available for provider on path: ${path}`)); if (parsedParameters.options.collation && !this.__providerHasFeature(provider, 'collation')) return callback(new Error(`collation feature not available for provider on path: ${path}`)); provider.find(path, parsedParameters, function(e, items) { if (e) return callback(e); if (parsedParameters.options.aggregate) { return callback(null, items); } if (path.indexOf('*') === -1) { //this is a single item if (items.length === 0) return callback(null, null); return callback(null, provider.transform(items[0], null, parsedParameters.options.fields)); } if (parsedParameters.options.path_only) { return callback(e, { paths: provider.transformAll(items) }); } callback(null, provider.transformAll(items, parsedParameters.options.fields)); }); } catch (e) { callback(e); } } function processGet(message, callback) { return this.get(message.request.path, message.request.options, (e, response) => { if (e) return this.errorService.handleSystem(e, 'DataService', CONSTANTS.ERROR_SEVERITY.HIGH, e => { callback(e, message); }); message.response = response; return callback(null, message); }); } function count(path, parameters, callback) { try { if (typeof parameters === 'function') { callback = parameters; parameters = null; } var provider = this.db(path); if (!provider.count) return callback(new Error('Database provider does not support count')); var options = this.__getPullOptions(path, parameters); provider.count(path, options, function(e, count) { if (e) return callback(e); callback(null, count); }); } catch (e) { callback(e); } } function processCount(message, callback) { return this.count(message.request.path, message.request.options, (e, response) => { if (e) return this.errorService.handleSystem(e, 'DataService', CONSTANTS.ERROR_SEVERITY.HIGH, e => { callback(e, message); }); message.response = response; return callback(null, message); }); } function processRemove(message, callback) { return this.remove(message.request.path, message.request.options, (e, response) => { if (e) return this.errorService.handleSystem( e, 'DataService', CONSTANTS.ERROR_SEVERITY.HIGH, function(e) { callback(e, message); } ); message.response = response; return callback(null, message); }); } function processStore(message, callback) { if (!message.request.options) message.request.options = {}; if (message.request.options.noStore) return this.processNoStore(message, callback); this.upsert( message.request.path, message.request.data, message.request.options, (e, response) => { if (e) return this.errorService.handleSystem( e, 'DataService', CONSTANTS.ERROR_SEVERITY.HIGH, e => { callback(e); } ); message.response = response; return callback(null, message); } ); } function processSecureStore(message, callback) { if (!message.request.options) message.request.options = {}; if (message.request.options.noStore) return this.processNoStore(message, callback); message.request.options.modifiedBy = message.session.user.username; this.upsert( message.request.path, message.request.data, message.request.options, (e, response) => { if (e) return this.errorService.handleSystem( e, 'DataService', CONSTANTS.ERROR_SEVERITY.HIGH, e => { callback(e); } ); message.response = response; return callback(null, message); } ); } function processNoStore(message, callback) { message.response = this.formatSetData(message.request.path, message.request.data); return callback(null, message); } function getOneByPath(path, fields, callback) { this.db(path).findOne( { path: path }, fields || {}, (e, findresult) => { if (e) return this.errorService.handleSystem( e, 'DataService', CONSTANTS.ERROR_SEVERITY.MEDIUM, callback ); return callback(null, findresult); } ); } function insertTag(snapshotData, tag, path, callback) { var baseTagPath = '/_TAGS'; if (path.substring(0, 1) !== '/') baseTagPath += '/'; var tagPath = baseTagPath + path + '/' + this.randomId(); var tagData = { data: snapshotData, _tag: tag, path: tagPath }; tagData._meta = { path: tagPath, tag: tag }; if (snapshotData._meta) { if (snapshotData._meta.modifiedBy) tagData._meta.modifiedBy = snapshotData._meta.modifiedBy; if (snapshotData._meta.createdBy) tagData._meta.createdBy = snapshotData._meta.createdBy; } this.__upsertInternal( tagPath, tagData, { upsertType: this.UPSERT_TYPE.insert, noCache: true }, false, callback ); } function saveTag(path, tag, data, callback) { if (!data) { return this.getOneByPath(path, null, (e, found) => { if (e) return callback(e); //handleSystem was already called here if (found) return this.insertTag(found, tag, path, callback); callback(new Error("Attempt to tag something that doesn't exist in the first place")); }); } this.insertTag(data, tag, path, callback); } function addDataStoreFilterSorted(pattern, dataStore) { this.dataroutes[pattern] = dataStore; this.dataroutessorted.push(pattern); this.dataroutessorted = this.dataroutessorted.sort((a, b) => { return b.length - a.length; }); } function removeDataStoreFilterSorted(pattern) { delete this.dataroutes[pattern]; this.dataroutessorted = this.dataroutessorted.filter(patternFilter => { // eslint-disable-next-line eqeqeq return patternFilter != pattern; }); } function addDataStoreFilter(pattern, datastoreKey) { if (!datastoreKey) throw new Error('missing datastoreKey parameter'); var dataStore = this.datastores[datastoreKey]; if (!dataStore) throw new Error('no datastore with the key ' + datastoreKey + ', exists'); var tagPattern = pattern.toString(); if (tagPattern.indexOf('/') === 0) tagPattern = tagPattern.substring(1, tagPattern.length); this.addDataStoreFilterSorted(pattern, dataStore); this.addDataStoreFilterSorted('/_TAGS/' + tagPattern, dataStore); } function removeDataStoreFilter(pattern) { var tagPattern = pattern.toString(); if (tagPattern.indexOf('/') === 0) tagPattern = tagPattern.substring(1, tagPattern.length); this.removeDataStoreFilterSorted(pattern); this.removeDataStoreFilterSorted('/_TAGS/' + tagPattern); } function parseFields(fields) { var lastError; traverse(fields).forEach(function(value) { if (value != null) { if (value.bsonid) this.update(value.bsonid); //ignore elements in arrays if (this.parent && Array.isArray(this.parent.node)) return; if (typeof this.key === 'string') { if (this.key === '$regex') { let expression = value; if (typeof expression !== 'string' && !Array.isArray(expression)) { lastError = new Error('$regex parameter value must be an Array or a string'); return; } if (typeof expression === 'string') expression = [expression]; //allow for just a string return this.update(RegExp.apply(null, expression)); } //ignore directives if (this.key.indexOf('$') === 0) return; if (META_FIELDS.indexOf(this.key) > -1) return; //look in the right place for meta fields if they have been prefixed with meta. if (PREFIXED_META_FIELDS.indexOf(this.key) > -1) { if (!this.parent) fields[this.key.replace('_meta.', '')] = value; else this.parent.node[this.key.replace('_meta.', '')] = value; return this.remove(); } if (this.key.indexOf('_data.') === 0) { if (!this.parent) fields[this.key.substring(1)] = value; //remove _ else this.parent.node[this.key.substring(1)] = value; return this.remove(); } //prepend with data. if (this.key.indexOf('data.') !== 0) { if (!this.parent) fields['data.' + this.key] = value; else this.parent.node['data.' + this.key] = value; return this.remove(); } } } }); if (lastError) throw lastError; return fields; } function filter(criteria, data, callback) { if (!criteria) return callback(null, data); try { var filterCriteria = this.parseFields(criteria); callback(null, sift(filterCriteria, data)); } catch (e) { callback(new Error('Filter of resultset failed', e)); } } function transform(dataObj, meta) { var transformed = { data: dataObj.data }; if (!meta) { meta = {}; if (dataObj.created) meta.created = dataObj.created; if (dataObj.modified) meta.modified = dataObj.modified; if (dataObj.modifiedBy) meta.modifiedBy = dataObj.modifiedBy; } transformed._meta = meta; transformed._meta.path = dataObj.path; transformed._meta._id = dataObj.path; if (dataObj._tag) transformed._meta.tag = dataObj._tag; return transformed; } function transformAll(items, fields) { return items.map(item => { return this.transform(item, null, fields); }); } function formatSetData(path, data, options) { if ( typeof data !== 'object' || data instanceof Array === true || data instanceof Date === true || data == null ) data = { value: data }; if (options && options.modifiedBy) return { data: data, _meta: { path: path, modifiedBy: options.modifiedBy } }; return { data: data, _meta: { path: path } }; } DataService.prototype.__upsertInternal = function(path, setData, options, dataWasMerged, callback) { var provider = this.db(path); if (options.increment != null) { if (typeof provider.increment !== 'function') return callback(new Error('db provider does not have an increment function')); if (!setData.data || typeof setData.data.value !== 'string') return callback(new Error('invalid increment counter field name, must be a string')); if (isNaN(options.increment)) return callback(new Error('increment option value must be a number')); return provider.increment(path, setData.data.value, options.increment, function(e, gaugeValue) { if (e) return callback(e); setData.data.gauge = setData.data.value; setData.data.value = gaugeValue; callback(null, provider.transform(setData)); }); } provider.upsert(path, setData, options, dataWasMerged, (e, response, created, upsert, meta) => { if (e) return callback(e); if (dataWasMerged) { //always merged if being tagged if (options.tag) return this.insertTag(setData, options.tag, path, callback); if (created) return callback(null, provider.transform(created)); setData.path = meta.path; return callback(null, provider.transform(setData, meta)); } if (created) return callback(null, provider.transform(created)); setData.path = path; callback(null, provider.transform(setData, meta)); }); }; function remove(path, options, callback) { if (typeof options === 'function') callback = options; this.db(path).remove(path, function(e, removed) { if (e) return callback(new Error('error removing item on path ' + path, e)); callback(null, removed); }); } function startCompacting(dataStoreKey, interval, callback, compactionHandler) { try { if (typeof dataStoreKey === 'function') { compactionHandler = callback; callback = dataStoreKey; interval = 300000; //defaults the compaction to once every 5 minutes dataStoreKey = null; } if (typeof dataStoreKey === 'number') { compactionHandler = callback; callback = interval; interval = dataStoreKey; dataStoreKey = null; } if (typeof interval === 'function') { compactionHandler = callback; interval = dataStoreKey; callback = interval; } interval = parseInt(interval.toString()); if (interval < 5000) throw new Error('interval must be at least 5000 milliseconds'); this.__iterateDataStores( dataStoreKey, (key, dataStore, next) => { this.compact(key, interval, next, compactionHandler); }, callback ); } catch (e) { this.errorService.handleSystem(e, 'DataService', CONSTANTS.ERROR_SEVERITY.HIGH, callback); } } function compact( dataStoreKey, //what dataStore - if undefined we will compact all data stores interval, //compaction interval callback, //callback on compaction cycle started or single compaction ended compactionHandler ) { //every time a compaction happens this is called if (typeof interval === 'function') { compactionHandler = callback; callback = interval; interval = null; } if (typeof dataStoreKey === 'function') { callback = dataStoreKey; compactionHandler = null; interval = null; dataStoreKey = null; } if (dataStoreKey == null) { this.__iterateDataStores((key, dataStore, next) => { this.compact(key, null, next, null); }, callback); } else { var dataStore = this.datastores[dataStoreKey]; this.__compacting[dataStoreKey] = dataStore; if (interval) this.__compacting[dataStoreKey].provider.startCompacting( interval, callback, compactionHandler ); else { this.__compacting[dataStoreKey].provider.compact(e => { if (e) this.errorService.handleSystem(e, 'DataService', CONSTANTS.ERROR_SEVERITY.MEDIUM); delete this.__compacting[dataStoreKey]; callback(); }); } } } function stop(options, callback) { if (typeof options === 'function') callback = options; this.__iterateDataStores(function(key, dataStore, next) { if (dataStore.provider.stop) return dataStore.provider.stop(next); next(); }, callback); } function __initializeProviders() { return new Promise((resolve, reject) => { var dataStorePos = 0; async.eachSeries( this.config.datastores, (datastoreConfig, datastoreCallback) => { this._insertDataProvider(dataStorePos, datastoreConfig, e => { if (e) return datastoreCallback(e); dataStorePos++; datastoreCallback(); }); }, e => { if (e) return reject(e); this.defaultProviderConfig = this.datastores[this.defaultDatastore]; this.defaultProvider = this.defaultProviderConfig.provider; this.db = function(path) { return this.__findDataProviderConfig(path).provider; }; resolve(); } ); }); } function __findDataProviderConfig(path) { for (var dataStoreRoute of this.dataroutessorted) if (this.happn.services.utils.wildcardMatch(dataStoreRoute, path, 'DATASTORE_ROUTES', 0, true)) return this.dataroutes[dataStoreRoute]; return this.defaultProviderConfig; } function addDataProviderPatterns(route, patterns) { const providerConfig = this.__findDataProviderConfig(route); patterns.forEach(pattern => { this.addDataStoreFilter(pattern, providerConfig.name); }); } function _insertDataProvider(dataStorePos, datastoreConfig, callback) { try { //eslint-disable-next-line if (dataStorePos === 0 && this.defaultDatastore == null) this.defaultDatastore = datastoreConfig.name; //just in case we haven't set a default var dataStoreInstance = { name: datastoreConfig.name }; dataStoreInstance.settings = datastoreConfig.settings || {}; //eslint-disable-next-line if (dataStoreInstance.settings.compactInterval == null && this.config.compactInterval != null) dataStoreInstance.settings.compactInterval = this.config.compactInterval; dataStoreInstance.patterns = datastoreConfig.patterns || []; datastoreConfig.provider = datastoreConfig.provider || './providers/nedb'; if (datastoreConfig.provider === 'nedb') datastoreConfig.provider = './providers/nedb'; if (datastoreConfig.provider === 'memory' || datastoreConfig.provider === 'mem') { datastoreConfig.provider = './providers/nedb'; dataStoreInstance.settings.filename = null; //no filename } var DataProvider = require(datastoreConfig.provider); dataStoreInstance.provider = new DataProvider(dataStoreInstance.settings); Object.defineProperty(dataStoreInstance, '__service', { value: this }); this.__attachProviderEvents(datastoreConfig.name, dataStoreInstance.provider); dataStoreInstance.provider.initialize(e => { if (e) return callback(e); if (dataStoreInstance.provider.transform == null) dataStoreInstance.provider.transform = this.transform; if (dataStoreInstance.provider.transformAll == null) dataStoreInstance.provider.transformAll = this.transformAll; this.datastores[datastoreConfig.name] = dataStoreInstance; //make sure we match the special /_TAGS patterns to find the right db for a tag dataStoreInstance.patterns.forEach(pattern => { this.addDataStoreFilter(pattern, datastoreConfig.name); }); //forces the default datastore if (datastoreConfig.isDefault) this.defaultDatastore = datastoreConfig.name; callback(); }); } catch (e) { callback(e); } } //bind to all possible events coming out of a provider - funnel into single 'provider-event' function __attachProviderEvents(providerKey, provider) { const _this = this; if (typeof provider.on !== 'function') return; if (!_this.__providerEventHandlers) _this.__providerEventHandlers = {}; var providerEventsHandler = function(data) { this.service.emit('provider-event', { eventName: this.eventName, eventData: data, provider: this.providerKey }); }; //only one event so far, but there could be more, use bind to ensure we emit the correct eventName etc. provider.on( 'compaction-successful', providerEventsHandler.bind({ service: _this, providerKey: providerKey, eventName: 'compaction-successful' }) ); _this.__providerEventHandlers[providerKey] = providerEventsHandler; } function __getPullOptions(path, parameters) { var returnParams = { criteria: null, options: {} }; if (!parameters) return returnParams; if (!parameters.options) parameters.options = {}; returnParams.options = parameters.options; if (parameters.path_only || parameters.options.path_only) { returnParams.options.fields = { _meta: 1 }; returnParams.options.path_only = true; } if (parameters.fields || parameters.options.fields) { returnParams.options.fields = this.parseFields(parameters.options.fields || parameters.fields); returnParams.options.fields._meta = 1; } if (parameters.aggregate || parameters.options.aggregate) returnParams.options.aggregate = parameters.aggregate || parameters.options.aggregate; if (parameters.sort || parameters.options.sort) returnParams.options.sort = this.parseFields(parameters.sort || parameters.options.sort); if (parameters.collation || parameters.options.collation) returnParams.options.collation = parameters.collation || parameters.options.collation; if (parameters.criteria) returnParams.criteria = this.parseFields(parameters.criteria); return returnParams; } function __upsertInternal(path, setData, options, dataWasMerged, callback) { var provider = this.db(path); provider.upsert(path, setData, options, dataWasMerged, (e, response, created, upsert, meta) => { if (dataWasMerged) { //always merged if being tagged if (options.tag) return this.insertTag(setData, options.tag, path, callback); if (created) return callback(null, provider.transform(created)); setData.path = meta.path; return callback(null, provider.transform(setData, meta)); } if (created) return callback(null, provider.transform(created)); setData.path = path; callback(null, provider.transform(setData, meta)); }); } function __iterateDataStores(dataStoreKey, operator, callback) { if (typeof dataStoreKey === 'function') { callback = operator; operator = dataStoreKey; dataStoreKey = null; } if (dataStoreKey) { if (!this.datastores) return callback( new Error( 'datastore with key ' + dataStoreKey + ', specified, but multiple datastores not configured' ) ); if (!this.datastores[dataStoreKey]) return callback(new Error('datastore with key ' + dataStoreKey + ', does not exist')); return operator(dataStoreKey, this.datastores[dataStoreKey], callback); } if (this.datastores) { async.eachSeries( Object.keys(this.datastores), (key, next) => { operator(key, this.datastores[key], next); }, callback ); } else { return operator( 'default', { db: this.dbInstance, config: this.config }, callback ); } } function randomId() { return hyperid(); } function extractData(data) { return data.map(function(item) { return item.data; }); }