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
815 lines (697 loc) • 23.8 kB
JavaScript
module.exports = DataService;
const commons = require('happn-commons');
const traverse = require('traverse'),
async = commons.async,
CONSTANTS = commons.constants,
util = commons.utils,
EventEmitter = require('events').EventEmitter,
hyperid = commons.hyperid.create({
urlSafe: true,
});
const META_FIELDS = [
'_meta',
'_id',
'path',
'created',
'modified',
'timestamp',
'createdBy',
'modifiedBy',
];
const PREFIXED_META_FIELDS = META_FIELDS.filter(function (metaField) {
return metaField !== '_meta';
}).map(function (metaField) {
if (metaField !== '_meta') return '_meta.' + metaField;
});
require('util').inherits(DataService, EventEmitter);
DataService.prototype.UPSERT_TYPE = CONSTANTS.UPSERT_TYPE;
DataService.prototype.initialize = initialize;
DataService.prototype.upsert = util.maybePromisify(upsert);
DataService.prototype.remove = util.maybePromisify(remove);
DataService.prototype.get = util.maybePromisify(get);
DataService.prototype.count = util.maybePromisify(count);
DataService.prototype.archive = util.maybePromisify(archive);
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.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.stop = stop;
DataService.prototype.compact = compact;
DataService.prototype.__compacting = {};
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.__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 loki data store as the default
var defaultDatastoreConfig = {
name: 'default',
provider: 'happn-db-provider-loki',
isDefault: true,
settings: {
fsync: this.config.fsync === true,
},
};
if (this.config.dbfile) this.config.filename = this.config.dbfile;
if (this.config.filename) defaultDatastoreConfig.settings.filename = this.config.filename;
this.config.datastores.push(defaultDatastoreConfig);
}
if (this.config.secure) this.processStore = this.processSecureStore;
this.__initializeProviders()
.then(() => {
callback();
})
.catch(callback);
}
function __providerHasFeature(provider, feature) {
if (typeof provider[feature] === 'function') return true;
if (provider.featureset) return provider.featureset[feature];
return false;
}
function upsert(path, data, options, callback) {
if (typeof options === 'function') {
callback = options;
options = null;
}
if (!options) options = {};
if (data) delete data._meta;
var setData = this.formatSetData(path, data);
if (options.merge) {
const provider = this.db(path);
if (this.__providerHasFeature(provider, 'merge')) {
provider.merge(path, setData, callback);
return;
}
//upserting, due to this being non-atomic (concurrency may cause unique index issues)
options.upsertType = CONSTANTS.UPSERT_TYPE.UPSERT;
this.getOneByPath(path, (e, previous) => {
if (e) return callback(e);
if (!previous) {
return this.__upsertInternal(path, setData, options, callback);
}
previous.data = { ...previous.data, ...setData.data }; //shallow merge
previous.modified = Date.now();
this.__upsertInternal(path, previous, options, callback);
});
return;
}
this.__upsertInternal(path, setData, options, callback);
}
function archive(path, callback) {
const provider = this.db(path);
if (!this.__providerHasFeature(provider, 'archive')) {
return callback(new Error(`archive feature not available for provider on path: ${path}`));
}
return provider.archive(callback);
}
function get(path, parameters, callback) {
if (typeof parameters === 'function') {
callback = parameters;
parameters = null;
}
let provider, parsedParameters;
try {
provider = this.db(path);
parsedParameters = this.__getPullOptions(parameters);
} catch (e) {
callback(e);
return;
}
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));
});
}
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(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);
if (message.request.options.upsertType === commons.constants.UPSERT_TYPE.BULK) {
message.request.options.noPublish = true;
}
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) {
if (typeof fields === 'function') {
callback = fields;
fields = {};
}
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 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');
const dataStore = this.datastores[datastoreKey];
if (!dataStore) throw new Error(`missing datastore with the key [${datastoreKey}]`);
this.addDataStoreFilterSorted(pattern, dataStore);
}
function removeDataStoreFilter(pattern) {
this.removeDataStoreFilterSorted(pattern);
}
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, commons.mongoFilter(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;
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,
},
};
}
function __upsertInternal(path, setData, options, callback) {
var provider = this.db(path);
if (options.increment != null) {
if (!this.__providerHasFeature(provider, 'increment'))
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;
let transformed = provider.transform(setData);
callback(null, transformed);
}
);
}
provider.upsert(path, setData, options, callback);
}
function remove(path, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
if (options == null) {
options = {};
}
if (options.criteria) {
// ensure we add the data. prefix
options.criteria = this.parseFields(options.criteria);
}
this.db(path).remove(path, options, function (e, removed) {
if (e) return callback(new Error('error removing item on path ' + path, e));
callback(null, removed);
});
}
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
) {
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) {
if (!this.__providerHasFeature(dataStore.provider, 'startCompacting')) {
return callback();
}
return this.__compacting[dataStoreKey].provider.startCompacting(
interval,
callback,
compactionHandler
);
}
if (!this.__providerHasFeature(dataStore.provider, 'compact')) {
return callback();
}
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 || {};
dataStoreInstance.patterns = datastoreConfig.patterns || [];
datastoreConfig.provider = datastoreConfig.provider || 'happn-db-provider-loki';
if (datastoreConfig.provider === 'memory' || datastoreConfig.provider === 'mem') {
datastoreConfig.provider = 'happn-db-provider-loki';
dataStoreInstance.settings.filename = null; //no filename
}
var DataProvider = require(datastoreConfig.provider);
dataStoreInstance.provider = new DataProvider(dataStoreInstance.settings, this.log);
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;
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,
});
};
_this.__providerEventHandlers[providerKey] = providerEventsHandler;
}
function __getPullOptions(parameters) {
var returnParams = {
criteria: null,
options: {},
};
if (!parameters || typeof parameters !== 'object') {
return returnParams;
}
if (!parameters.options || typeof parameters.options !== 'object') {
parameters.options = {};
}
returnParams.options = parameters.options;
if (parameters.path_only || parameters.options.path_only) {
returnParams.options.fields = {
path: 1,
_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 __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;
});
}