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';
|
2021-07-07 10:46:50 +02:00
|
|
|
|
|
|
|
const FEATURE_COLUMNS = [
|
|
|
|
'name',
|
|
|
|
'description',
|
|
|
|
'type',
|
|
|
|
'project',
|
|
|
|
'stale',
|
|
|
|
'variants',
|
|
|
|
'created_at',
|
2022-02-03 11:06:51 +01:00
|
|
|
'impression_data',
|
2021-07-07 10:46:50 +02:00
|
|
|
'last_seen_at',
|
|
|
|
];
|
|
|
|
|
|
|
|
export interface FeaturesTable {
|
|
|
|
name: string;
|
|
|
|
description: string;
|
|
|
|
type: string;
|
|
|
|
stale: boolean;
|
2022-01-06 10:23:52 +01:00
|
|
|
variants?: string;
|
2021-07-07 10:46:50 +02:00
|
|
|
project: string;
|
|
|
|
last_seen_at?: Date;
|
|
|
|
created_at?: Date;
|
2022-02-03 11:06:51 +01:00
|
|
|
impression_data: boolean;
|
2021-07-07 10:46:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
const TABLE = 'features';
|
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
export default class FeatureToggleStore implements IFeatureToggleStore {
|
2021-07-07 10:46:50 +02:00
|
|
|
private db: Knex;
|
|
|
|
|
|
|
|
private logger: Logger;
|
|
|
|
|
|
|
|
private timer: Function;
|
|
|
|
|
|
|
|
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
|
|
|
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> {
|
|
|
|
return this.db
|
|
|
|
.from(TABLE)
|
2022-05-09 15:20:12 +02:00
|
|
|
.count('*')
|
2021-07-07 10:46:50 +02:00
|
|
|
.where(query)
|
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[]> {
|
2021-08-12 15:04:37 +02:00
|
|
|
const rows = await this.db
|
|
|
|
.select(FEATURE_COLUMNS)
|
|
|
|
.from(TABLE)
|
2021-09-13 10:23:57 +02:00
|
|
|
.where(query);
|
2021-08-12 15:04:37 +02:00
|
|
|
return rows.map(this.rowToFeature);
|
|
|
|
}
|
|
|
|
|
|
|
|
async getFeatures(archived: boolean): Promise<FeatureToggle[]> {
|
2021-07-07 10:46:50 +02:00
|
|
|
const rows = await this.db
|
|
|
|
.select(FEATURE_COLUMNS)
|
|
|
|
.from(TABLE)
|
|
|
|
.where({ archived });
|
|
|
|
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 })
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
async getArchivedFeatures(): Promise<FeatureToggle[]> {
|
|
|
|
const rows = await this.db
|
|
|
|
.select(FEATURE_COLUMNS)
|
|
|
|
.from(TABLE)
|
2021-09-13 10:23:57 +02:00
|
|
|
.where({ archived: true })
|
2021-07-07 10:46:50 +02:00
|
|
|
.orderBy('name', 'asc');
|
|
|
|
return rows.map(this.rowToFeature);
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
rowToFeature(row: FeaturesTable): FeatureToggle {
|
|
|
|
if (!row) {
|
|
|
|
throw new NotFoundError('No feature toggle found');
|
|
|
|
}
|
2021-11-26 13:06:36 +01:00
|
|
|
const sortedVariants = (row.variants as unknown as IVariant[]) || [];
|
|
|
|
sortedVariants.sort((a, b) => a.name.localeCompare(b.name));
|
2021-07-07 10:46:50 +02:00
|
|
|
return {
|
|
|
|
name: row.name,
|
|
|
|
description: row.description,
|
|
|
|
type: row.type,
|
|
|
|
project: row.project,
|
|
|
|
stale: row.stale,
|
2021-11-26 13:06:36 +01:00
|
|
|
variants: sortedVariants,
|
2021-07-07 10:46:50 +02:00
|
|
|
createdAt: row.created_at,
|
|
|
|
lastSeenAt: row.last_seen_at,
|
2022-02-03 11:06:51 +01:00
|
|
|
impressionData: row.impression_data,
|
2021-07-07 10:46:50 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-11-24 13:08:04 +01:00
|
|
|
rowToVariants(row: FeaturesTable): IVariant[] {
|
|
|
|
if (!row) {
|
|
|
|
throw new NotFoundError('No feature toggle found');
|
|
|
|
}
|
2021-11-26 13:06:36 +01:00
|
|
|
const sortedVariants = (row.variants as unknown as IVariant[]) || [];
|
|
|
|
sortedVariants.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
|
|
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,
|
|
|
|
archived: data.archived || false,
|
|
|
|
stale: data.stale,
|
|
|
|
created_at: data.createdAt,
|
2022-02-03 11:06:51 +01:00
|
|
|
impression_data: data.impressionData,
|
2022-06-28 08:54:09 +02:00
|
|
|
variants: JSON.stringify(data.variants),
|
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> {
|
2021-07-07 10:46:50 +02:00
|
|
|
const row = await this.db(TABLE)
|
|
|
|
.where({ name })
|
|
|
|
.update({ archived: true })
|
|
|
|
.returning(FEATURE_COLUMNS);
|
|
|
|
return this.rowToFeature(row[0]);
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
.where({ name, archived: true }) // Feature toggle must be archived to allow deletion
|
|
|
|
.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 })
|
|
|
|
.update({ archived: false })
|
|
|
|
.returning(FEATURE_COLUMNS);
|
|
|
|
return this.rowToFeature(row[0]);
|
|
|
|
}
|
2021-11-24 13:08:04 +01:00
|
|
|
|
|
|
|
async getVariants(featureName: string): Promise<IVariant[]> {
|
|
|
|
const row = await this.db(TABLE)
|
|
|
|
.select('variants')
|
|
|
|
.where({ name: featureName });
|
|
|
|
return this.rowToVariants(row[0]);
|
|
|
|
}
|
|
|
|
|
|
|
|
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> {
|
|
|
|
const row = await this.db(TABLE)
|
|
|
|
.update({ variants: JSON.stringify(newVariants) })
|
2021-12-16 11:07:19 +01:00
|
|
|
.where({ project: project, name: featureName })
|
2021-11-24 13:08:04 +01:00
|
|
|
.returning(FEATURE_COLUMNS);
|
|
|
|
return this.rowToFeature(row[0]);
|
|
|
|
}
|
2021-07-07 10:46:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = FeatureToggleStore;
|