1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00
unleash.unleash/src/lib/db/feature-toggle-store.ts

350 lines
10 KiB
TypeScript
Raw Normal View History

import { Knex } from 'knex';
import EventEmitter from 'events';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import NotFoundError from '../error/notfound-error';
import { Logger, LogProvider } from '../logger';
2021-11-26 13:06:36 +01:00
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../types/model';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { Db } from './db';
const FEATURE_COLUMNS = [
'name',
'description',
'type',
'project',
'stale',
'created_at',
'impression_data',
'last_seen_at',
'archived_at',
];
export interface FeaturesTable {
name: string;
description: string;
type: string;
stale: boolean;
project: string;
last_seen_at?: Date;
created_at?: Date;
impression_data: boolean;
archived?: boolean;
archived_at?: Date;
}
interface VariantDTO {
variants: IVariant[];
}
const TABLE = 'features';
const FEATURE_ENVIRONMENTS_TABLE = 'feature_environments';
export default class FeatureToggleStore implements IFeatureToggleStore {
private db: Db;
private logger: Logger;
private timer: Function;
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('feature-toggle-store.ts');
this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle',
action,
});
}
async count(
query: {
archived?: boolean;
project?: string;
stale?: boolean;
} = { archived: false },
): Promise<number> {
const { archived, ...rest } = query;
return this.db
.from(TABLE)
.count('*')
.where(rest)
.modify(FeatureToggleStore.filterByArchived, archived)
.then((res) => Number(res[0].count));
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async get(name: string): Promise<FeatureToggle> {
return this.db
.first(FEATURE_COLUMNS)
.from(TABLE)
.where({ name })
.then(this.rowToFeature);
}
async getAll(
query: {
archived?: boolean;
project?: string;
stale?: boolean;
} = { archived: false },
): Promise<FeatureToggle[]> {
const { archived, ...rest } = query;
const rows = await this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where(rest)
.modify(FeatureToggleStore.filterByArchived, archived);
return rows.map(this.rowToFeature);
}
2023-01-11 16:00:20 +01:00
async getAllByNames(names: string[]): Promise<FeatureToggle[]> {
const query = this.db<FeaturesTable>(TABLE).orderBy('name', 'asc');
2023-01-11 16:00:20 +01:00
if (names.length > 0) {
query.whereIn('name', names);
}
const rows = await query;
return rows.map(this.rowToFeature);
}
async getByDate(queryModifiers: {
archived?: boolean;
project?: string;
date?: string;
range?: string[];
dateAccessor: string;
}): Promise<FeatureToggle[]> {
const { project, archived, dateAccessor } = queryModifiers;
let query = this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where({ project })
.modify(FeatureToggleStore.filterByArchived, archived);
if (queryModifiers.date) {
query.andWhere(dateAccessor, '>=', queryModifiers.date);
}
if (queryModifiers.range && queryModifiers.range.length === 2) {
query.andWhereBetween(dateAccessor, [
queryModifiers.range[0],
queryModifiers.range[1],
]);
}
const rows = await query;
return rows.map(this.rowToFeature);
}
/**
* Get projectId from feature filtered by name. Used by Rbac middleware
* @deprecated
* @param name
*/
async getProjectId(name: string): Promise<string> {
return this.db
.first(['project'])
.from(TABLE)
.where({ name })
.then((r) => (r ? r.project : undefined))
.catch((e) => {
this.logger.error(e);
return undefined;
});
}
async exists(name: string): Promise<boolean> {
const result = await this.db.raw(
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
[name],
);
const { present } = result.rows[0];
return present;
}
async setLastSeen(toggleNames: string[]): Promise<void> {
const now = new Date();
try {
await this.db(TABLE)
.update({ last_seen_at: now })
.whereIn(
'name',
this.db(TABLE)
.select('name')
.whereIn('name', toggleNames)
.forUpdate()
.skipLocked(),
);
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
static filterByArchived: Knex.QueryCallbackWithArgs = (
queryBuilder: Knex.QueryBuilder,
archived: boolean,
) => {
return archived
? queryBuilder.whereNotNull('archived_at')
: queryBuilder.whereNull('archived_at');
};
rowToFeature(row: FeaturesTable): FeatureToggle {
if (!row) {
throw new NotFoundError('No feature toggle found');
}
return {
name: row.name,
description: row.description,
type: row.type,
project: row.project,
stale: row.stale,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
impressionData: row.impression_data,
archivedAt: row.archived_at,
archived: row.archived_at != null,
};
}
rowToEnvVariants(variantRows: VariantDTO[]): IVariant[] {
if (!variantRows.length) {
return [];
}
2021-11-26 13:06:36 +01:00
const sortedVariants =
(variantRows[0].variants as unknown as IVariant[]) || [];
sortedVariants.sort((a, b) => a.name.localeCompare(b.name));
2021-11-26 13:06:36 +01:00
return sortedVariants;
}
dtoToRow(project: string, data: FeatureToggleDTO): FeaturesTable {
const row = {
name: data.name,
description: data.description,
type: data.type,
project,
archived_at: data.archived ? new Date() : null,
stale: data.stale,
created_at: data.createdAt,
impression_data: data.impressionData,
};
if (!row.created_at) {
delete row.created_at;
}
return row;
}
async create(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
try {
const row = await this.db(TABLE)
.insert(this.dtoToRow(project, data))
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
} catch (err) {
this.logger.error('Could not insert feature, error: ', err);
}
return undefined;
}
async update(
project: string,
data: FeatureToggleDTO,
): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name: data.name })
.update(this.dtoToRow(project, data))
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
}
async archive(name: string): Promise<FeatureToggle> {
const now = new Date();
const row = await this.db(TABLE)
.where({ name })
.update({ archived_at: now })
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
}
async delete(name: string): Promise<void> {
await this.db(TABLE)
.where({ name }) // Feature toggle must be archived to allow deletion
.whereNotNull('archived_at')
.del();
}
async revive(name: string): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name })
.update({ archived_at: null })
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]);
}
async getVariants(featureName: string): Promise<IVariant[]> {
if (!(await this.exists(featureName))) {
throw new NotFoundError('No feature toggle found');
}
const row = await this.db(`${TABLE} as f`)
.select('fe.variants')
.join(
`${FEATURE_ENVIRONMENTS_TABLE} as fe`,
'fe.feature_name',
'f.name',
)
.where({ name: featureName })
.limit(1);
return this.rowToEnvVariants(row);
}
async getVariantsForEnv(
featureName: string,
environment: string,
): Promise<IVariant[]> {
const row = await this.db(`${TABLE} as f`)
.select('fev.variants')
.join(
`${FEATURE_ENVIRONMENTS_TABLE} as fev`,
'fev.feature_name',
'f.name',
)
.where({ name: featureName })
.andWhere({ environment });
return this.rowToEnvVariants(row);
}
async saveVariants(
project: string,
featureName: string,
newVariants: IVariant[],
): Promise<FeatureToggle> {
const variantsString = JSON.stringify(newVariants);
await this.db('feature_environments')
.update('variants', variantsString)
.where('feature_name', featureName);
const row = await this.db(TABLE)
.select(FEATURE_COLUMNS)
.where({ project: project, name: featureName });
const toggle = this.rowToFeature(row[0]);
toggle.variants = newVariants;
return toggle;
}
}
module.exports = FeatureToggleStore;