2021-07-07 10:46:50 +02:00
|
|
|
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';
|
2021-08-12 15:04:37 +02:00
|
|
|
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
|
2023-01-30 09:02:44 +01:00
|
|
|
import { Db } from './db';
|
2021-07-07 10:46:50 +02:00
|
|
|
|
|
|
|
const FEATURE_COLUMNS = [
|
|
|
|
'name',
|
|
|
|
'description',
|
|
|
|
'type',
|
|
|
|
'project',
|
|
|
|
'stale',
|
|
|
|
'created_at',
|
2022-02-03 11:06:51 +01:00
|
|
|
'impression_data',
|
2021-07-07 10:46:50 +02:00
|
|
|
'last_seen_at',
|
2022-07-01 13:51:26 +02:00
|
|
|
'archived_at',
|
2021-07-07 10:46:50 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
export interface FeaturesTable {
|
|
|
|
name: string;
|
|
|
|
description: string;
|
|
|
|
type: string;
|
|
|
|
stale: boolean;
|
|
|
|
project: string;
|
|
|
|
last_seen_at?: Date;
|
|
|
|
created_at?: Date;
|
2022-02-03 11:06:51 +01:00
|
|
|
impression_data: boolean;
|
2022-07-01 13:51:26 +02:00
|
|
|
archived?: boolean;
|
|
|
|
archived_at?: Date;
|
2021-07-07 10:46:50 +02:00
|
|
|
}
|
|
|
|
|
2022-11-21 10:37:16 +01:00
|
|
|
interface VariantDTO {
|
|
|
|
variants: IVariant[];
|
|
|
|
}
|
|
|
|
|
2021-07-07 10:46:50 +02:00
|
|
|
const TABLE = 'features';
|
2022-11-21 10:37:16 +01:00
|
|
|
const FEATURE_ENVIRONMENTS_TABLE = 'feature_environments';
|
2021-07-07 10:46:50 +02:00
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
export default class FeatureToggleStore implements IFeatureToggleStore {
|
2023-01-30 09:02:44 +01:00
|
|
|
private db: Db;
|
2021-07-07 10:46:50 +02:00
|
|
|
|
|
|
|
private logger: Logger;
|
|
|
|
|
|
|
|
private timer: Function;
|
|
|
|
|
2023-01-30 09:02:44 +01:00
|
|
|
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
|
2021-07-07 10:46:50 +02:00
|
|
|
this.db = db;
|
|
|
|
this.logger = getLogger('feature-toggle-store.ts');
|
2021-08-12 15:04:37 +02:00
|
|
|
this.timer = (action) =>
|
2021-07-07 10:46:50 +02:00
|
|
|
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
|
|
|
store: 'feature-toggle',
|
|
|
|
action,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async count(
|
|
|
|
query: {
|
|
|
|
archived?: boolean;
|
|
|
|
project?: string;
|
|
|
|
stale?: boolean;
|
|
|
|
} = { archived: false },
|
|
|
|
): Promise<number> {
|
2022-07-01 13:51:26 +02:00
|
|
|
const { archived, ...rest } = query;
|
2021-07-07 10:46:50 +02:00
|
|
|
return this.db
|
|
|
|
.from(TABLE)
|
2022-05-09 15:20:12 +02:00
|
|
|
.count('*')
|
2022-07-01 13:51:26 +02:00
|
|
|
.where(rest)
|
|
|
|
.modify(FeatureToggleStore.filterByArchived, archived)
|
2021-08-12 15:04:37 +02:00
|
|
|
.then((res) => Number(res[0].count));
|
2021-07-07 10:46:50 +02:00
|
|
|
}
|
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
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)
|
2021-09-13 10:23:57 +02:00
|
|
|
.where({ name })
|
2021-08-12 15:04:37 +02:00
|
|
|
.then(this.rowToFeature);
|
|
|
|
}
|
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
async getAll(
|
|
|
|
query: {
|
|
|
|
archived?: boolean;
|
|
|
|
project?: string;
|
|
|
|
stale?: boolean;
|
|
|
|
} = { archived: false },
|
|
|
|
): Promise<FeatureToggle[]> {
|
2022-07-01 13:51:26 +02:00
|
|
|
const { archived, ...rest } = query;
|
2021-08-12 15:04:37 +02:00
|
|
|
const rows = await this.db
|
|
|
|
.select(FEATURE_COLUMNS)
|
|
|
|
.from(TABLE)
|
2022-07-01 13:51:26 +02:00
|
|
|
.where(rest)
|
|
|
|
.modify(FeatureToggleStore.filterByArchived, archived);
|
2021-07-07 10:46:50 +02:00
|
|
|
return rows.map(this.rowToFeature);
|
|
|
|
}
|
|
|
|
|
2023-01-11 16:00:20 +01:00
|
|
|
async getAllByNames(names: string[]): Promise<FeatureToggle[]> {
|
2023-02-06 15:46:25 +01:00
|
|
|
const query = this.db<FeaturesTable>(TABLE).orderBy('name', 'asc');
|
2023-02-14 13:13:58 +01:00
|
|
|
query.whereIn('name', names);
|
2023-01-11 16:00:20 +01:00
|
|
|
const rows = await query;
|
|
|
|
return rows.map(this.rowToFeature);
|
|
|
|
}
|
|
|
|
|
2023-01-26 16:13:15 +01:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2021-07-07 10:46:50 +02:00
|
|
|
/**
|
|
|
|
* 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 })
|
2021-08-12 15:04:37 +02:00
|
|
|
.then((r) => (r ? r.project : undefined))
|
|
|
|
.catch((e) => {
|
2021-07-07 10:46:50 +02:00
|
|
|
this.logger.error(e);
|
|
|
|
return undefined;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async exists(name: string): Promise<boolean> {
|
|
|
|
const result = await this.db.raw(
|
2021-08-12 15:04:37 +02:00
|
|
|
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
|
2021-07-07 10:46:50 +02:00
|
|
|
[name],
|
|
|
|
);
|
|
|
|
const { present } = result.rows[0];
|
|
|
|
return present;
|
|
|
|
}
|
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
async setLastSeen(toggleNames: string[]): Promise<void> {
|
2021-07-07 10:46:50 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-01 13:51:26 +02:00
|
|
|
static filterByArchived: Knex.QueryCallbackWithArgs = (
|
|
|
|
queryBuilder: Knex.QueryBuilder,
|
|
|
|
archived: boolean,
|
|
|
|
) => {
|
|
|
|
return archived
|
|
|
|
? queryBuilder.whereNotNull('archived_at')
|
|
|
|
: queryBuilder.whereNull('archived_at');
|
|
|
|
};
|
|
|
|
|
2021-07-07 10:46:50 +02:00
|
|
|
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,
|
2022-02-03 11:06:51 +01:00
|
|
|
impressionData: row.impression_data,
|
2022-07-01 13:51:26 +02:00
|
|
|
archivedAt: row.archived_at,
|
|
|
|
archived: row.archived_at != null,
|
2021-07-07 10:46:50 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2022-11-21 10:37:16 +01:00
|
|
|
rowToEnvVariants(variantRows: VariantDTO[]): IVariant[] {
|
|
|
|
if (!variantRows.length) {
|
|
|
|
return [];
|
2021-11-24 13:08:04 +01:00
|
|
|
}
|
2021-11-26 13:06:36 +01:00
|
|
|
|
2022-11-21 10:37:16 +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;
|
2021-11-24 13:08:04 +01:00
|
|
|
}
|
|
|
|
|
2021-07-07 10:46:50 +02:00
|
|
|
dtoToRow(project: string, data: FeatureToggleDTO): FeaturesTable {
|
|
|
|
const row = {
|
|
|
|
name: data.name,
|
|
|
|
description: data.description,
|
|
|
|
type: data.type,
|
|
|
|
project,
|
2022-07-01 13:51:26 +02:00
|
|
|
archived_at: data.archived ? new Date() : null,
|
2021-07-07 10:46:50 +02:00
|
|
|
stale: data.stale,
|
|
|
|
created_at: data.createdAt,
|
2022-02-03 11:06:51 +01:00
|
|
|
impression_data: data.impressionData,
|
2021-07-07 10:46:50 +02:00
|
|
|
};
|
|
|
|
if (!row.created_at) {
|
|
|
|
delete row.created_at;
|
|
|
|
}
|
|
|
|
return row;
|
|
|
|
}
|
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
async create(
|
2021-07-07 10:46:50 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
async update(
|
2021-07-07 10:46:50 +02:00
|
|
|
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]);
|
|
|
|
}
|
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
async archive(name: string): Promise<FeatureToggle> {
|
2022-07-01 13:51:26 +02:00
|
|
|
const now = new Date();
|
2021-07-07 10:46:50 +02:00
|
|
|
const row = await this.db(TABLE)
|
|
|
|
.where({ name })
|
2022-07-01 13:51:26 +02:00
|
|
|
.update({ archived_at: now })
|
2021-07-07 10:46:50 +02:00
|
|
|
.returning(FEATURE_COLUMNS);
|
|
|
|
return this.rowToFeature(row[0]);
|
|
|
|
}
|
|
|
|
|
2023-03-14 09:48:29 +01:00
|
|
|
async batchArchive(names: string[]): Promise<FeatureToggle[]> {
|
|
|
|
const now = new Date();
|
|
|
|
const rows = await this.db(TABLE)
|
|
|
|
.whereIn('name', names)
|
|
|
|
.update({ archived_at: now })
|
|
|
|
.returning(FEATURE_COLUMNS);
|
|
|
|
return rows.map((row) => this.rowToFeature(row));
|
|
|
|
}
|
|
|
|
|
2023-03-15 07:37:06 +01:00
|
|
|
async batchStale(
|
|
|
|
names: string[],
|
|
|
|
stale: boolean,
|
|
|
|
): Promise<FeatureToggle[]> {
|
|
|
|
const rows = await this.db(TABLE)
|
|
|
|
.whereIn('name', names)
|
|
|
|
.update({ stale })
|
|
|
|
.returning(FEATURE_COLUMNS);
|
|
|
|
return rows.map((row) => this.rowToFeature(row));
|
|
|
|
}
|
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
async delete(name: string): Promise<void> {
|
2021-07-07 10:46:50 +02:00
|
|
|
await this.db(TABLE)
|
2022-07-01 13:51:26 +02:00
|
|
|
.where({ name }) // Feature toggle must be archived to allow deletion
|
|
|
|
.whereNotNull('archived_at')
|
2021-07-07 10:46:50 +02:00
|
|
|
.del();
|
|
|
|
}
|
|
|
|
|
2023-03-15 14:08:08 +01:00
|
|
|
async batchDelete(names: string[]): Promise<void> {
|
|
|
|
await this.db(TABLE)
|
|
|
|
.whereIn('name', names)
|
|
|
|
.whereNotNull('archived_at')
|
|
|
|
.del();
|
|
|
|
}
|
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
async revive(name: string): Promise<FeatureToggle> {
|
2021-07-07 10:46:50 +02:00
|
|
|
const row = await this.db(TABLE)
|
|
|
|
.where({ name })
|
2022-07-01 13:51:26 +02:00
|
|
|
.update({ archived_at: null })
|
2021-07-07 10:46:50 +02:00
|
|
|
.returning(FEATURE_COLUMNS);
|
|
|
|
return this.rowToFeature(row[0]);
|
|
|
|
}
|
2021-11-24 13:08:04 +01:00
|
|
|
|
|
|
|
async getVariants(featureName: string): Promise<IVariant[]> {
|
2022-11-21 10:37:16 +01:00
|
|
|
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);
|
2021-11-24 13:08:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
async saveVariants(
|
2021-12-16 11:07:19 +01:00
|
|
|
project: string,
|
2021-11-24 13:08:04 +01:00
|
|
|
featureName: string,
|
|
|
|
newVariants: IVariant[],
|
|
|
|
): Promise<FeatureToggle> {
|
2022-11-21 10:37:16 +01:00
|
|
|
const variantsString = JSON.stringify(newVariants);
|
|
|
|
await this.db('feature_environments')
|
|
|
|
.update('variants', variantsString)
|
|
|
|
.where('feature_name', featureName);
|
|
|
|
|
2021-11-24 13:08:04 +01:00
|
|
|
const row = await this.db(TABLE)
|
2022-11-21 10:37:16 +01:00
|
|
|
.select(FEATURE_COLUMNS)
|
|
|
|
.where({ project: project, name: featureName });
|
|
|
|
|
|
|
|
const toggle = this.rowToFeature(row[0]);
|
|
|
|
toggle.variants = newVariants;
|
|
|
|
|
|
|
|
return toggle;
|
2021-11-24 13:08:04 +01:00
|
|
|
}
|
2021-07-07 10:46:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = FeatureToggleStore;
|