unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
440 lines • 15.6 kB
JavaScript
import { FEATURE_CREATED, FEATURE_IMPORT, FEATURE_TAGGED, FEATURES_IMPORTED, SEGMENT_CREATED, SEGMENT_DELETED, SEGMENT_UPDATED, } from '../../events/index.js';
import { sharedEventEmitter } from '../../util/index.js';
import { ADMIN_TOKEN_USER, SYSTEM_USER, SYSTEM_USER_ID, } from '../../types/index.js';
import { applyGenericQueryParams } from '../feature-search/search-utils.js';
import metricsHelper from '../../util/metrics-helper.js';
import { DB_TIME } from '../../metric-events.js';
const EVENT_COLUMNS = [
'id',
'type',
'created_by',
'created_at',
'created_by_user_id',
'data',
'pre_data',
'tags',
'feature_name',
'project',
'environment',
'group_type',
'group_id',
];
const TABLE = 'events';
export class EventStore {
// a new DB has to be injected per transaction
constructor(db, getLogger) {
// only one shared event emitter should exist across all event store instances
this.eventEmitter = sharedEventEmitter;
this.db = db;
this.logger = getLogger('event-store');
this.metricTimer = (action) => metricsHelper.wrapTimer(this.eventEmitter, DB_TIME, {
store: 'event',
action,
});
}
async store(event) {
const stopTimer = this.metricTimer('store');
try {
await this.db(TABLE)
.insert(this.eventToDbRow(event))
.returning(EVENT_COLUMNS);
}
catch (error) {
this.logger.warn(`Failed to store "${event.type}" event: ${error}`);
}
finally {
stopTimer();
}
}
async count() {
const stopTimer = this.metricTimer('count');
const count = await this.db(TABLE)
.count()
.first();
stopTimer();
if (!count) {
return 0;
}
if (typeof count.count === 'string') {
return Number.parseInt(count.count, 10);
}
else {
return count.count;
}
}
async searchEventsCount(queryParams, query) {
const stopTimer = this.metricTimer('searchEventsCount');
const searchQuery = this.buildSearchQuery(queryParams, query);
const count = await searchQuery.count().first();
stopTimer();
if (!count) {
return 0;
}
if (typeof count.count === 'string') {
return Number.parseInt(count.count, 10);
}
else {
return count.count;
}
}
async batchStore(events) {
const stopTimer = this.metricTimer('batchStore');
try {
await this.db(TABLE).insert(events.map((event) => this.eventToDbRow(event)));
}
catch (error) {
this.logger.warn(`Failed to store events: ${JSON.stringify(events)}`, error);
}
finally {
stopTimer();
}
}
async getMaxRevisionId(largerThan = 0) {
const stopTimer = this.metricTimer('getMaxRevisionId');
const row = await this.db(TABLE)
.max('id')
.where((builder) => builder
.andWhere((inner) => inner
.whereNotNull('feature_name')
.whereNotIn('type', [
FEATURE_CREATED,
FEATURE_TAGGED,
]))
.orWhereIn('type', [
SEGMENT_UPDATED,
FEATURE_IMPORT,
FEATURES_IMPORTED,
]))
.andWhere('id', '>=', largerThan)
.first();
stopTimer();
return row?.max ?? 0;
}
async getRevisionRange(start, end) {
const stopTimer = this.metricTimer('getRevisionRange');
const query = this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.where('id', '>', start)
.andWhere('id', '<=', end)
.andWhere((builder) => builder
.andWhere((inner) => inner
.whereNotNull('feature_name')
.whereNotIn('type', [
FEATURE_CREATED,
FEATURE_TAGGED,
]))
.orWhereIn('type', [
SEGMENT_UPDATED,
FEATURE_IMPORT,
FEATURES_IMPORTED,
SEGMENT_CREATED,
SEGMENT_DELETED,
]))
.orderBy('id', 'asc');
const rows = await query;
return rows.map(this.rowToEvent);
}
async delete(key) {
await this.db(TABLE).where({ id: key }).del();
}
async deleteAll() {
await this.db(TABLE).del();
}
destroy() { }
async exists(key) {
const result = await this.db.raw(`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`, [key]);
const { present } = result.rows[0];
return present;
}
async query(operations) {
const stopTimer = this.metricTimer('query');
try {
let query = this.select();
operations.forEach((operation) => {
if (operation.op === 'where') {
query = this.where(query, operation.parameters);
}
if (operation.op === 'forFeatures') {
query = this.forFeatures(query, operation.parameters);
}
if (operation.op === 'beforeDate') {
query = this.beforeDate(query, operation.parameters);
}
if (operation.op === 'betweenDate') {
query = this.betweenDate(query, operation.parameters);
}
});
const rows = await query;
return rows.map(this.rowToEvent);
}
catch (e) {
return [];
}
finally {
stopTimer();
}
}
async queryCount(operations) {
const stopTimer = this.metricTimer('queryCount');
try {
let query = this.db.count().from(TABLE);
operations.forEach((operation) => {
if (operation.op === 'where') {
query = this.where(query, operation.parameters);
}
if (operation.op === 'forFeatures') {
query = this.forFeatures(query, operation.parameters);
}
if (operation.op === 'beforeDate') {
query = this.beforeDate(query, operation.parameters);
}
if (operation.op === 'betweenDate') {
query = this.betweenDate(query, operation.parameters);
}
});
const queryResult = await query.first();
return Number.parseInt(queryResult.count || 0);
}
catch (e) {
return 0;
}
finally {
stopTimer();
}
}
where(query, parameters) {
return query.where(parameters);
}
beforeDate(query, parameters) {
return query.andWhere(parameters.dateAccessor, '>=', parameters.date);
}
betweenDate(query, parameters) {
if (parameters.range && parameters.range.length === 2) {
return query.andWhereBetween(parameters.dateAccessor, [
parameters.range[0],
parameters.range[1],
]);
}
return query;
}
select() {
return this.db.select(EVENT_COLUMNS).from(TABLE);
}
forFeatures(query, parameters) {
return query
.where({ type: parameters.type, project: parameters.projectId })
.whereIn('feature_name', parameters.features)
.whereIn('environment', parameters.environments);
}
async get(key) {
const row = await this.db(TABLE).where({ id: key }).first();
return this.rowToEvent(row);
}
async getAll(query) {
return this.getEvents(query);
}
async getEvents(query) {
const stopTimer = this.metricTimer('getEvents');
try {
let qB = this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.limit(100)
.orderBy([
{ column: 'created_at', order: 'desc' },
{ column: 'id', order: 'desc' },
]);
if (query) {
qB = qB.where(query);
}
const rows = await qB;
return rows.map(this.rowToEvent);
}
catch (err) {
return [];
}
finally {
stopTimer();
}
}
async searchEvents(params, queryParams, options) {
const stopTimer = this.metricTimer('searchEvents');
const query = this.buildSearchQuery(queryParams, params.query)
.select(options?.withIp ? [...EVENT_COLUMNS, 'ip'] : EVENT_COLUMNS)
.orderBy([
{ column: 'created_at', order: params.order || 'desc' },
{ column: 'id', order: params.order || 'desc' },
])
.limit(Number(params.limit) ?? 100)
.offset(Number(params.offset) ?? 0);
try {
return (await query).map((row) => options?.withIp
? { ...this.rowToEvent(row), ip: row.ip }
: this.rowToEvent(row));
}
catch (err) {
return [];
}
finally {
stopTimer();
}
}
buildSearchQuery(queryParams, query) {
let searchQuery = this.db.from(TABLE);
applyGenericQueryParams(searchQuery, queryParams);
if (query) {
searchQuery = searchQuery.where((where) => where
.orWhereRaw('data::text ILIKE ?', `%${query}%`)
.orWhereRaw('tags::text ILIKE ?', `%${query}%`)
.orWhereRaw('pre_data::text ILIKE ?', `%${query}%`));
}
return searchQuery;
}
async getEventCreators() {
const stopTimer = this.metricTimer('getEventCreators');
const query = this.db('events')
.distinctOn('events.created_by_user_id')
.leftJoin('users', 'users.id', '=', 'events.created_by_user_id')
.select([
'events.created_by_user_id as id',
this.db.raw(`
CASE
WHEN events.created_by_user_id = -1337 THEN '${SYSTEM_USER.name}'
WHEN events.created_by_user_id = -42 THEN '${ADMIN_TOKEN_USER.name}'
ELSE COALESCE(users.name, events.created_by)
END as name
`),
'users.username',
'users.email',
]);
const result = await query;
stopTimer();
return result
.filter((row) => row.name || row.username || row.email)
.map((row) => ({
id: Number(row.id),
name: String(row.name || row.username || row.email),
}));
}
async getProjectRecentEventActivity(project) {
const stopTimer = this.metricTimer('getProjectRecentEventActivity');
const result = await this.db('events')
.select(this.db.raw("TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date"))
.count('* AS count')
.where('project', project)
.andWhere('created_at', '>=', this.db.raw("NOW() - INTERVAL '1 year'"))
.groupBy(this.db.raw("TO_CHAR(created_at::date, 'YYYY-MM-DD')"))
.orderBy('date', 'asc');
stopTimer();
return result.map((row) => ({
date: row.date,
count: Number(row.count),
}));
}
rowToEvent(row) {
return {
id: row.id,
type: row.type,
createdBy: row.created_by,
createdAt: row.created_at,
createdByUserId: row.created_by_user_id,
data: row.data,
preData: row.pre_data,
tags: row.tags || [],
featureName: row.feature_name,
project: row.project,
environment: row.environment,
groupType: row.group_type || undefined,
groupId: row.group_id || undefined,
};
}
eventToDbRow(e) {
const transactionContext = this.db.userParams;
return {
type: e.type,
created_by: e.createdBy ?? 'admin',
created_by_user_id: e.createdByUserId,
data: Array.isArray(e.data) ? JSON.stringify(e.data) : e.data,
pre_data: Array.isArray(e.preData)
? JSON.stringify(e.preData)
: e.preData,
// @ts-expect-error workaround for json-array
tags: JSON.stringify(e.tags),
feature_name: e.featureName,
project: e.project,
environment: e.environment,
ip: e.ip,
group_type: transactionContext?.type || null,
group_id: transactionContext?.id || null,
};
}
setMaxListeners(number) {
return this.eventEmitter.setMaxListeners(number);
}
on(eventName, listener) {
return this.eventEmitter.on(eventName, listener);
}
emit(eventName, ...args) {
return this.eventEmitter.emit(eventName, ...args);
}
off(eventName, listener) {
return this.eventEmitter.off(eventName, listener);
}
async setUnannouncedToAnnounced() {
const stopTimer = this.metricTimer('setUnannouncedToAnnounced');
const rows = await this.db(TABLE)
.update({ announced: true })
.where('announced', false)
.returning(EVENT_COLUMNS);
stopTimer();
return rows.map(this.rowToEvent);
}
async publishUnannouncedEvents() {
const events = await this.setUnannouncedToAnnounced();
events.forEach((e) => this.eventEmitter.emit(e.type, e));
}
async setCreatedByUserId(batchSize) {
const stopTimer = this.metricTimer('setCreatedByUserId');
const API_TOKEN_TABLE = 'api_tokens';
const toUpdate = await this.db(`${TABLE} as e`)
.joinRaw('LEFT OUTER JOIN users AS u ON e.created_by = u.username OR e.created_by = u.email')
.joinRaw(`LEFT OUTER JOIN ${API_TOKEN_TABLE} AS t on e.created_by = t.username`)
.whereRaw(`e.created_by_user_id IS null AND
e.created_by IS NOT null AND
(u.id IS NOT null OR
t.username IS NOT null OR
e.created_by in ('unknown', 'migration', 'init-api-tokens')
)`)
.orderBy('e.created_at', 'desc')
.limit(batchSize)
.select(['e.*', 'u.id AS userid', 't.username']);
const updatePromises = toUpdate.map(async (row) => {
if (row.created_by === 'unknown' ||
row.created_by === 'migration' ||
(row.created_by === 'init-api-tokens' &&
row.type === 'api-token-created')) {
return this.db(TABLE)
.update({ created_by_user_id: SYSTEM_USER_ID })
.where({ id: row.id });
}
if (row.userid) {
return this.db(TABLE)
.update({ created_by_user_id: row.userid })
.where({ id: row.id });
}
if (row.username) {
return this.db(TABLE)
.update({ created_by_user_id: ADMIN_TOKEN_USER.id })
.where({ id: row.id });
}
this.logger.warn(`Could not find user for event ${row.id}`);
return Promise.resolve();
});
await Promise.all(updatePromises);
stopTimer();
return toUpdate.length;
}
}
export default EventStore;
//# sourceMappingURL=event-store.js.map