unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
540 lines • 20.3 kB
JavaScript
import NotFoundError from '../../error/notfound-error.js';
import { DEFAULT_ENV } from '../../util/index.js';
import metricsHelper from '../../util/metrics-helper.js';
import { DB_TIME } from '../../metric-events.js';
import { applySearchFilters } from '../feature-search/search-utils.js';
const COLUMNS = [
'id',
'name',
'description',
'created_at',
'health',
'updated_at',
];
const TABLE = 'projects';
const SETTINGS_COLUMNS = [
'project_mode',
'default_stickiness',
'feature_limit',
'feature_naming_pattern',
'feature_naming_example',
'feature_naming_description',
'link_templates',
];
const SETTINGS_TABLE = 'project_settings';
const PROJECT_ENVIRONMENTS = 'project_environments';
class ProjectStore {
constructor(db, eventBus, { getLogger, isOss }) {
this.db = db;
this.logger = getLogger('project-store.ts');
this.timer = (action) => metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'project',
action,
});
this.isOss = isOss;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
fieldToRow(data) {
return {
id: data.id,
name: data.name,
description: data.description,
};
}
destroy() { }
async isFeatureLimitReached(id) {
const stop = this.timer('isFeatureLimitReached');
const result = await this.db.raw(`SELECT EXISTS(SELECT 1
FROM project_settings
LEFT JOIN features ON project_settings.project = features.project
WHERE project_settings.project = ?
AND features.archived_at IS NULL
GROUP BY project_settings.project
HAVING project_settings.feature_limit <= COUNT(features.project)) AS present`, [id]);
stop();
const { present } = result.rows[0];
return present;
}
async getProjectLinkTemplates(id) {
const stop = this.timer('getProjectLinkTemplates');
const result = await this.db
.select('link_templates')
.from(SETTINGS_TABLE)
.where({ project: id })
.first();
stop();
return result?.link_templates || [];
}
async getAll(query = {}) {
const stop = this.timer('getAll');
let projects = this.db
.select(COLUMNS)
.from(TABLE)
.where(query)
.orderBy('name', 'asc');
projects = projects.where(`${TABLE}.archived_at`, null);
if (this.isOss) {
projects = projects.where('id', 'default');
}
const rows = await projects;
stop();
return rows.map(this.mapRow.bind(this));
}
async get(id) {
const stop = this.timer('getProject');
const extraColumns = ['archived_at'];
const project = await this.db
.first([...COLUMNS, ...SETTINGS_COLUMNS, ...extraColumns])
.from(TABLE)
.leftJoin(SETTINGS_TABLE, `${SETTINGS_TABLE}.project`, `${TABLE}.id`)
.where({ id })
.then(this.mapRow.bind(this));
stop();
return project;
}
async exists(id) {
const stop = this.timer('exists');
const result = await this.db.raw(`SELECT EXISTS(SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`, [id]);
const { present } = result.rows[0];
stop();
return present;
}
async hasProject(id) {
const stop = this.timer('hasProject');
const result = await this.db.raw(`SELECT EXISTS(SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`, [id]);
const { present } = result.rows[0];
stop();
return present;
}
async hasActiveProject(id) {
const stop = this.timer('hasActiveProject');
const result = await this.db.raw(`SELECT EXISTS(SELECT 1 FROM ${TABLE} WHERE id = ? and archived_at IS NULL) AS present`, [id]);
const { present } = result.rows[0];
stop();
return present;
}
async updateHealth(healthUpdate) {
const stop = this.timer('updateHealth');
await this.db(TABLE).where({ id: healthUpdate.id }).update({
health: healthUpdate.health,
updated_at: new Date(),
});
stop();
}
async create(project) {
const stop = this.timer('create');
const row = await this.db(TABLE)
.insert({ ...this.fieldToRow(project), created_at: new Date() })
.returning('*');
const settingsRow = await this.db(SETTINGS_TABLE)
.insert({
project: project.id,
default_stickiness: project.defaultStickiness,
feature_limit: project.featureLimit,
project_mode: project.mode,
})
.returning('*');
stop();
return this.mapRow({ ...row[0], ...settingsRow[0] });
}
async hasProjectSettings(projectId) {
const stop = this.timer('hasProjectSettings');
const result = await this.db.raw(`SELECT EXISTS(SELECT 1 FROM ${SETTINGS_TABLE} WHERE project = ?) AS present`, [projectId]);
const { present } = result.rows[0];
stop();
return present;
}
async update(data) {
const stop = this.timer('update');
try {
await this.db(TABLE)
.where({ id: data.id })
.update(this.fieldToRow(data));
if (data.defaultStickiness !== undefined ||
data.featureLimit !== undefined) {
if (await this.hasProjectSettings(data.id)) {
await this.db(SETTINGS_TABLE)
.where({ project: data.id })
.update({
default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit,
});
}
else {
await this.db(SETTINGS_TABLE).insert({
project: data.id,
default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit,
project_mode: 'open',
});
}
}
}
catch (err) {
this.logger.error('Could not update project, error: ', err);
}
finally {
stop();
}
}
async updateProjectEnterpriseSettings(data) {
const stop = this.timer('updateProjectEnterpriseSettings');
try {
const link_templates = JSON.stringify(data.linkTemplates ? data.linkTemplates : []);
if (await this.hasProjectSettings(data.id)) {
await this.db(SETTINGS_TABLE)
.where({ project: data.id })
.update({
project_mode: data.mode,
feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example,
feature_naming_description: data.featureNaming?.description,
link_templates,
});
}
else {
await this.db(SETTINGS_TABLE).insert({
project: data.id,
project_mode: data.mode,
feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example,
feature_naming_description: data.featureNaming?.description,
link_templates,
});
}
}
catch (err) {
this.logger.error('Could not update project settings, error: ', err);
}
finally {
stop();
}
}
async importProjects(projects, environments) {
const stop = this.timer('importProjects');
const rows = await this.db(TABLE)
.insert(projects.map(this.fieldToRow))
.returning(COLUMNS)
.onConflict('id')
.ignore();
stop();
if (environments && rows.length > 0) {
environments.forEach((env) => {
projects.forEach(async (project) => {
await this.addEnvironmentToProject(project.id, env.name);
});
});
return rows.map(this.mapRow, this);
}
return [];
}
async addDefaultEnvironment(projects) {
const environments = projects.map((project) => ({
project_id: project.id,
environment_name: DEFAULT_ENV,
}));
await this.db('project_environments')
.insert(environments)
.onConflict(['project_id', 'environment_name'])
.ignore();
}
async deleteAll() {
const stop = this.timer('deleteAll');
await this.db(TABLE).del();
stop();
}
async delete(id) {
const stop = this.timer('delete');
try {
await this.db(TABLE).where({ id }).del();
}
catch (err) {
this.logger.error('Could not delete project, error: ', err);
}
finally {
stop();
}
}
async archive(id) {
const stop = this.timer('archive');
const now = new Date();
await this.db(TABLE).where({ id }).update({ archived_at: now });
stop();
}
async revive(id) {
const stop = this.timer('revive');
await this.db(TABLE).where({ id }).update({ archived_at: null });
stop();
}
async getProjectLinksForEnvironments(environments) {
const rows = await this.db('project_environments')
.select(['project_id', 'environment_name'])
.whereIn('environment_name', environments);
return rows.map(this.mapLinkRow, this);
}
async deleteEnvironmentForProject(id, environment) {
await this.db('project_environments')
.where({
project_id: id,
environment_name: environment,
})
.del();
}
async addEnvironmentToProject(id, environment) {
await this.db('project_environments')
.insert({
project_id: id,
environment_name: environment,
})
.onConflict(['project_id', 'environment_name'])
.ignore();
}
async addEnvironmentToProjects(environment, projects) {
const rows = await Promise.all(projects.map(async (projectId) => {
return {
project_id: projectId,
environment_name: environment,
};
}));
await this.db('project_environments')
.insert(rows)
.onConflict(['project_id', 'environment_name'])
.ignore();
}
async getEnvironmentsForProject(id) {
const rows = await this.db(PROJECT_ENVIRONMENTS)
.where({
project_id: id,
})
.innerJoin('environments', 'project_environments.environment_name', 'environments.name')
.orderBy('environments.sort_order', 'asc')
.orderBy('project_environments.environment_name', 'asc')
.returning([
'project_environments.environment_name',
'project_environments.default_strategy',
]);
return rows.map(this.mapProjectEnvironmentRow, this);
}
async getMembersCountByProject(projectId) {
const members = await this.db
.from((db) => {
db.select('user_id')
.from('role_user')
.leftJoin('roles', 'role_user.role_id', 'roles.id')
.where((builder) => builder
.where('project', projectId)
.whereNot('type', 'root'))
.union((queryBuilder) => {
queryBuilder
.select('user_id')
.from('group_role')
.leftJoin('group_user', 'group_user.group_id', 'group_role.group_id')
.where('project', projectId);
})
.as('query');
})
.count()
.first();
return Number(members.count);
}
async getMembersCountByProjectAfterDate(projectId, date) {
const members = await this.db
.from((db) => {
db.select('user_id')
.from('role_user')
.leftJoin('roles', 'role_user.role_id', 'roles.id')
.where((builder) => builder
.where('project', projectId)
.whereNot('type', 'root')
.andWhere('role_user.created_at', '>=', date))
.union((queryBuilder) => {
queryBuilder
.select('user_id')
.from('group_role')
.leftJoin('group_user', 'group_user.group_id', 'group_role.group_id')
.where('project', projectId)
.andWhere('group_role.created_at', '>=', date);
})
.as('query');
})
.count()
.first();
return Number(members.count);
}
async getApplicationsByProject(params) {
const { project, limit, sortOrder, searchParams, offset } = params;
const validatedSortOrder = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const query = this.db
.with('applications', (qb) => {
qb.select('project', 'app_name', 'environment')
.distinct()
.from('client_metrics_env as cme')
.leftJoin('features as f', 'cme.feature_name', 'f.name')
.where('project', project);
})
.with('ranked', (qb) => {
applySearchFilters(qb, searchParams, [
'a.app_name',
'a.environment',
'ci.instance_id',
'ci.sdk_version',
]);
qb.select('a.app_name', 'a.environment', 'ci.instance_id', 'ci.sdk_version', this.db.raw(`DENSE_RANK() OVER (ORDER BY a.app_name ${validatedSortOrder}) AS rank`))
.from('applications as a')
.innerJoin('client_applications as ca', 'a.app_name', 'ca.app_name')
.leftJoin('client_instances as ci', function () {
this.on('ci.app_name', '=', 'a.app_name').andOn('ci.environment', '=', 'a.environment');
});
})
.with('final_ranks', this.db.raw('select row_number() over (order by min(rank)) as final_rank from ranked group by app_name'))
.with('total', this.db.raw('select count(*) as total from final_ranks'))
.select('*')
.from('ranked')
.joinRaw('CROSS JOIN total')
.whereBetween('rank', [offset + 1, offset + limit]);
const rows = await query;
if (rows.length !== 0) {
const applications = this.getAggregatedApplicationsData(rows);
return {
applications,
total: Number(rows[0].total) || 0,
};
}
return {
applications: [],
total: 0,
};
}
async getDefaultStrategy(projectId, environment) {
const rows = await this.db(PROJECT_ENVIRONMENTS)
.select('default_strategy')
.where({
project_id: projectId,
environment_name: environment,
});
return rows.length > 0 ? rows[0].default_strategy : undefined;
}
async updateDefaultStrategy(projectId, environment, strategy) {
const rows = await this.db(PROJECT_ENVIRONMENTS)
.update({
default_strategy: strategy,
})
.where({
project_id: projectId,
environment_name: environment,
})
.returning('default_strategy');
return rows[0].default_strategy;
}
async count() {
let count = this.db.from(TABLE).count('*');
count = count.where(`${TABLE}.archived_at`, null);
return count.then((res) => Number(res[0].count));
}
async getProjectModeCounts() {
let query = this.db
.select(this.db.raw(`COALESCE(${SETTINGS_TABLE}.project_mode, 'open') as mode`))
.count(`${TABLE}.id as count`)
.from(`${TABLE}`)
.leftJoin(`${SETTINGS_TABLE}`, `${TABLE}.id`, `${SETTINGS_TABLE}.project`)
.groupBy(this.db.raw(`COALESCE(${SETTINGS_TABLE}.project_mode, 'open')`));
query = query.where(`${TABLE}.archived_at`, null);
const result = await query;
return result.map(this.mapProjectModeCount);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
mapProjectModeCount(row) {
return {
mode: row.mode,
count: Number(row.count),
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
mapLinkRow(row) {
return {
environmentName: row.environment_name,
projectId: row.project_id,
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
mapRow(row) {
if (!row) {
throw new NotFoundError('No project found');
}
return {
id: row.id,
name: row.name,
description: row.description,
createdAt: row.created_at,
health: row.health ?? 100,
updatedAt: row.updated_at || new Date(),
...(row.archived_at ? { archivedAt: row.archived_at } : {}),
mode: row.project_mode || 'open',
defaultStickiness: row.default_stickiness || 'default',
featureLimit: row.feature_limit,
featureNaming: {
pattern: row.feature_naming_pattern,
example: row.feature_naming_example,
description: row.feature_naming_description,
},
linkTemplates: row.link_templates || [],
};
}
mapProjectEnvironmentRow(row) {
return {
environment: row.environment_name,
defaultStrategy: row.default_strategy === null
? undefined
: row.default_strategy,
};
}
getAggregatedApplicationsData(rows) {
const entriesMap = new Map();
rows.forEach((row) => {
const { app_name, environment, instance_id, sdk_version } = row;
let entry = entriesMap.get(app_name);
if (!entry) {
entry = {
name: app_name,
environments: [],
instances: [],
sdks: [],
};
entriesMap.set(app_name, entry);
}
if (!entry.environments.includes(environment)) {
entry.environments.push(environment);
}
if (!entry.instances.includes(instance_id)) {
entry.instances.push(instance_id);
}
if (sdk_version) {
const sdkParts = sdk_version.split(':');
const sdkName = sdkParts[0];
const sdkVersion = sdkParts[1] || '';
let sdk = entry.sdks.find((sdk) => sdk.name === sdkName);
if (!sdk) {
sdk = {
name: sdkName,
versions: [],
};
entry.sdks.push(sdk);
}
if (sdkVersion && !sdk.versions.includes(sdkVersion)) {
sdk.versions.push(sdkVersion);
}
}
});
entriesMap.forEach((entry) => {
entry.environments.sort();
entry.instances.sort();
entry.sdks.forEach((sdk) => {
sdk.versions.sort();
});
entry.sdks.sort((a, b) => a.name.localeCompare(b.name));
});
return Array.from(entriesMap.values());
}
}
export default ProjectStore;
//# sourceMappingURL=project-store.js.map