import { Knex } from 'knex'; import EventEmitter from 'events'; import { v4 as uuidv4 } from 'uuid'; import metricsHelper from '../../util/metrics-helper'; import { DB_TIME } from '../../metric-events'; import { Logger, LogProvider } from '../../logger'; import NotFoundError from '../../error/notfound-error'; import { FeatureToggleWithEnvironment, IConstraint, IEnvironmentOverview, IFeatureOverview, IFeatureStrategiesStore, IFeatureStrategy, IFeatureToggleClient, IFlagResolver, IStrategyConfig, IStrategyVariant, ITag, PartialDeep, PartialSome, } from '../../types'; import FeatureToggleStore from './feature-toggle-store'; import { ensureStringValue, mapValues } from '../../util'; import { IFeatureProjectUserParams } from './feature-toggle-controller'; import { Db } from '../../db/db'; import Raw = Knex.Raw; import { IFeatureSearchParams } from './types/feature-toggle-strategies-store-type'; import { addMilliseconds, format, formatISO, parseISO } from 'date-fns'; const COLUMNS = [ 'id', 'feature_name', 'project_name', 'environment', 'strategy_name', 'title', 'parameters', 'constraints', 'variants', 'created_at', 'disabled', ]; const T = { features: 'features', featureStrategies: 'feature_strategies', featureStrategySegment: 'feature_strategy_segment', featureEnvs: 'feature_environments', strategies: 'strategies', }; interface IFeatureStrategiesTable { id: string; feature_name: string; project_name: string; environment: string; title?: string | null; strategy_name: string; parameters: object; constraints: string; variants: string; sort_order: number; created_at?: Date; disabled?: boolean | null; } export interface ILoadFeatureToggleWithEnvsParams { featureName: string; archived: boolean; withEnvironmentVariants: boolean; userId?: number; } function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy { return { id: row.id, featureName: row.feature_name, projectId: row.project_name, environment: row.environment, strategyName: row.strategy_name, title: row.title, parameters: mapValues(row.parameters || {}, ensureStringValue), constraints: (row.constraints as unknown as IConstraint[]) || [], variants: (row.variants as unknown as IStrategyVariant[]) || [], createdAt: row.created_at, sortOrder: row.sort_order, disabled: row.disabled, }; } function mapInput(input: IFeatureStrategy): IFeatureStrategiesTable { return { id: input.id, feature_name: input.featureName, project_name: input.projectId, environment: input.environment, strategy_name: input.strategyName, title: input.title, parameters: input.parameters, constraints: JSON.stringify(input.constraints || []), variants: JSON.stringify(input.variants || []), created_at: input.createdAt, sort_order: input.sortOrder ?? 9999, disabled: input.disabled, }; } const getUniqueRows = (rows: any[]) => { const seen = {}; return rows.filter((row) => { const key = `${row.environment}-${row.feature_name}`; if (seen[key]) { return false; } seen[key] = true; return true; }); }; const sortEnvironments = (overview: IFeatureOverview) => { return Object.values(overview).map((data: IFeatureOverview) => ({ ...data, environments: data.environments .filter((f) => f.name) .sort((a, b) => { if (a.sortOrder === b.sortOrder) { return a.name.localeCompare(b.name); } return a.sortOrder - b.sortOrder; }), })); }; interface StrategyUpdate { strategy_name: string; parameters: object; constraints: string; variants: string; title?: string; disabled?: boolean; } function mapStrategyUpdate( input: Partial, ): Partial { const update: Partial = {}; if (input.name !== null) { update.strategy_name = input.name; } if (input.parameters !== null) { update.parameters = input.parameters; } if (input.title !== null) { update.title = input.title; } if (input.disabled !== null) { update.disabled = input.disabled; } update.constraints = JSON.stringify(input.constraints || []); update.variants = JSON.stringify(input.variants || []); return update; } class FeatureStrategiesStore implements IFeatureStrategiesStore { private db: Db; private logger: Logger; private readonly timer: Function; private flagResolver: IFlagResolver; constructor( db: Db, eventBus: EventEmitter, getLogger: LogProvider, flagResolver: IFlagResolver, ) { this.db = db; this.logger = getLogger('feature-toggle-strategies-store.ts'); this.timer = (action) => metricsHelper.wrapTimer(eventBus, DB_TIME, { store: 'feature-toggle-strategies', action, }); this.flagResolver = flagResolver; } async delete(key: string): Promise { await this.db(T.featureStrategies).where({ id: key }).del(); } async deleteAll(): Promise { await this.db(T.featureStrategies).delete(); } destroy(): void {} async exists(key: string): Promise { const result = await this.db.raw( `SELECT EXISTS(SELECT 1 FROM ${T.featureStrategies} WHERE id = ?) AS present`, [key], ); const { present } = result.rows[0]; return present; } async get(key: string): Promise { const row = await this.db(T.featureStrategies) .where({ id: key }) .first(); if (!row) { throw new NotFoundError(`Could not find strategy with id=${key}`); } return mapRow(row); } private async nextSortOrder(featureName: string, environment: string) { const [{ max }] = await this.db(T.featureStrategies) .max('sort_order as max') .where({ feature_name: featureName, environment, }); return Number.isInteger(max) ? max + 1 : 0; } async createStrategyFeatureEnv( strategyConfig: PartialSome, ): Promise { const sortOrder = strategyConfig.sortOrder ?? (await this.nextSortOrder( strategyConfig.featureName, strategyConfig.environment, )); const strategyRow = mapInput({ id: uuidv4(), ...strategyConfig, sortOrder, }); const rows = await this.db(T.featureStrategies) .insert(strategyRow) .returning('*'); return mapRow(rows[0]); } async removeAllStrategiesForFeatureEnv( featureName: string, environment: string, ): Promise { await this.db('feature_strategies') .where({ feature_name: featureName, environment, }) .del(); } async getAll(): Promise { const stopTimer = this.timer('getAll'); const rows = await this.db .select(COLUMNS) .from(T.featureStrategies); stopTimer(); return rows.map(mapRow); } async getAllByFeatures( features: string[], environment?: string, ): Promise { const query = this.db .select(COLUMNS) .from(T.featureStrategies) .whereIn('feature_name', features) .orderBy('feature_name', 'asc'); if (environment) { query.where('environment', environment); } const rows = await query; return rows.map(mapRow); } async getStrategiesForFeatureEnv( projectId: string, featureName: string, environment: string, ): Promise { const stopTimer = this.timer('getForFeature'); const rows = await this.db(T.featureStrategies) .where({ project_name: projectId, feature_name: featureName, environment, }) .orderBy([ { column: 'sort_order', order: 'asc', }, { column: 'created_at', order: 'asc', }, ]); stopTimer(); return rows.map(mapRow); } async getFeatureToggleWithEnvs( featureName: string, userId?: number, archived: boolean = false, ): Promise { return this.loadFeatureToggleWithEnvs({ featureName, archived, withEnvironmentVariants: false, userId, }); } async getFeatureToggleWithVariantEnvs( featureName: string, userId?: number, archived: boolean = false, ): Promise { return this.loadFeatureToggleWithEnvs({ featureName, archived, withEnvironmentVariants: true, userId, }); } async loadFeatureToggleWithEnvs({ featureName, archived, withEnvironmentVariants, userId, }: ILoadFeatureToggleWithEnvsParams): Promise { const stopTimer = this.timer('getFeatureAdmin'); let query = this.db('features_view') .where('name', featureName) .modify(FeatureToggleStore.filterByArchived, archived); let selectColumns = ['features_view.*'] as (string | Raw)[]; if (this.flagResolver.isEnabled('useLastSeenRefactor')) { query.leftJoin('last_seen_at_metrics', function () { this.on( 'last_seen_at_metrics.environment', '=', 'features_view.environment_name', ).andOn( 'last_seen_at_metrics.feature_name', '=', 'features_view.name', ); }); // Override feature view for now selectColumns.push( 'last_seen_at_metrics.last_seen_at as env_last_seen_at', ); } if (userId) { query = query.leftJoin(`favorite_features`, function () { this.on( 'favorite_features.feature', 'features_view.name', ).andOnVal('favorite_features.user_id', '=', userId); }); selectColumns = [ ...selectColumns, this.db.raw( 'favorite_features.feature is not null as favorite', ), ]; } const rows = await query.select(selectColumns); stopTimer(); if (rows.length > 0) { const featureToggle = rows.reduce((acc, r) => { if (acc.environments === undefined) { acc.environments = {}; } acc.name = r.name; acc.favorite = r.favorite; acc.impressionData = r.impression_data; acc.description = r.description; acc.project = r.project; acc.stale = r.stale; acc.lastSeenAt = r.last_seen_at; acc.createdAt = r.created_at; acc.type = r.type; if (!acc.environments[r.environment]) { acc.environments[r.environment] = { name: r.environment, lastSeenAt: r.env_last_seen_at, }; } const env = acc.environments[r.environment]; const variants = r.variants || []; variants.sort((a, b) => a.name.localeCompare(b.name)); if (withEnvironmentVariants) { env.variants = variants; } // this code sets variants at the feature level (should be deprecated with variants per environment) const currentVariants = new Map( acc.variants?.map((v) => [v.name, v]), ); variants.forEach((variant) => { currentVariants.set(variant.name, variant); }); acc.variants = Array.from(currentVariants.values()); env.enabled = r.enabled; env.type = r.environment_type; env.sortOrder = r.environment_sort_order; if (!env.strategies) { env.strategies = []; } if (r.strategy_id) { const found = env.strategies.find( (strategy) => strategy.id === r.strategy_id, ); if (!found) { env.strategies.push( FeatureStrategiesStore.getAdminStrategy(r), ); } } if (r.segments) { this.addSegmentIdsToStrategy(env, r); } acc.environments[r.environment] = env; return acc; }, {}); featureToggle.environments = Object.values( featureToggle.environments, ).sort((a, b) => { // @ts-expect-error return a.sortOrder - b.sortOrder; }); featureToggle.environments = featureToggle.environments.map((e) => { e.strategies = e.strategies.sort( (a, b) => a.sortOrder - b.sortOrder, ); if (e.strategies && e.strategies.length === 0) { e.enabled = false; } return e; }); featureToggle.archived = archived; return featureToggle; } throw new NotFoundError( `Could not find feature toggle with name ${featureName}`, ); } private addSegmentIdsToStrategy( featureToggle: PartialDeep, row: Record, ) { const strategy = featureToggle.strategies?.find( (s) => s?.id === row.strategy_id, ); if (!strategy) { return; } if (!strategy.segments) { strategy.segments = []; } strategy.segments.push(row.segments); } private static getEnvironment(r: any): IEnvironmentOverview { return { name: r.environment, enabled: r.enabled, type: r.environment_type, sortOrder: r.environment_sort_order, variantCount: r.variants?.length || 0, lastSeenAt: r.env_last_seen_at, hasStrategies: r.has_strategies, hasEnabledStrategies: r.has_enabled_strategies, }; } private addTag( featureToggle: Record, row: Record, ): void { const tags = featureToggle.tags || []; const newTag = FeatureStrategiesStore.rowToTag(row); featureToggle.tags = [...tags, newTag]; } private isNewTag( featureToggle: Record, row: Record, ): boolean { return ( row.tag_type && row.tag_value && !featureToggle.tags?.some( (tag) => tag.type === row.tag_type && tag.value === row.tag_value, ) ); } private static rowToTag(r: any): ITag { return { value: r.tag_value, type: r.tag_type, }; } // WIP copy of getFeatureOverview to get the search PoC working async searchFeatures({ projectId, userId, query: queryString, type, tag, status, offset, limit, sortOrder, sortBy, }: IFeatureSearchParams): Promise<{ features: IFeatureOverview[]; total: number; }> { let environmentCount = 1; if (projectId) { const rows = await this.db('project_environments') .count('* as environmentCount') .where('project_id', projectId); environmentCount = Number(rows[0].environmentCount); } let query = this.db('features'); if (projectId) { query = query.where({ project: projectId }); } if (queryString?.trim()) { // todo: we can run a cheaper query when no colon is detected const tagQuery = this.db .from('feature_tag') .select('feature_name') .whereRaw("(?? || ':' || ??) LIKE ?", [ 'tag_type', 'tag_value', `%${queryString}%`, ]); query = query.where((builder) => { builder .whereILike('features.name', `%${queryString}%`) .orWhereIn('features.name', tagQuery); }); } if (tag && tag.length > 0) { const tagQuery = this.db .from('feature_tag') .select('feature_name') .whereIn(['tag_type', 'tag_value'], tag); query = query.whereIn('features.name', tagQuery); } if (type) { query = query.whereIn('features.type', type); } if (status && status.length > 0) { query = query.where((builder) => { for (const [envName, envStatus] of status) { builder.orWhere(function () { this.where( 'feature_environments.environment', envName, ).andWhere( 'feature_environments.enabled', envStatus === 'enabled' ? true : false, ); }); } }); } query = query .modify(FeatureToggleStore.filterByArchived, false) .leftJoin( 'feature_environments', 'feature_environments.feature_name', 'features.name', ) .leftJoin( 'environments', 'feature_environments.environment', 'environments.name', ) .leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name'); const countQuery = query.clone(); if (this.flagResolver.isEnabled('useLastSeenRefactor')) { query.leftJoin('last_seen_at_metrics', function () { this.on( 'last_seen_at_metrics.environment', '=', 'environments.name', ).andOn( 'last_seen_at_metrics.feature_name', '=', 'features.name', ); }); } let selectColumns = [ 'features.name as feature_name', 'features.description as description', 'features.type as type', 'features.created_at as created_at', 'features.last_seen_at as last_seen_at', 'features.stale as stale', 'features.impression_data as impression_data', 'feature_environments.enabled as enabled', 'feature_environments.environment as environment', 'feature_environments.variants as variants', 'environments.type as environment_type', 'environments.sort_order as environment_sort_order', 'ft.tag_value as tag_value', 'ft.tag_type as tag_type', ] as (string | Raw | Knex.QueryBuilder)[]; if (this.flagResolver.isEnabled('useLastSeenRefactor')) { selectColumns.push( 'last_seen_at_metrics.last_seen_at as env_last_seen_at', ); } else { selectColumns.push( 'feature_environments.last_seen_at as env_last_seen_at', ); } if (userId) { query = query.leftJoin(`favorite_features`, function () { this.on('favorite_features.feature', 'features.name').andOnVal( 'favorite_features.user_id', '=', userId, ); }); selectColumns = [ ...selectColumns, this.db.raw( 'favorite_features.feature is not null as favorite', ), ]; } if (this.flagResolver.isEnabled('featureSwitchRefactor')) { selectColumns = [ ...selectColumns, this.db.raw( 'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies', ), this.db.raw( 'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies', ), ]; } const sortByMapping = { name: 'feature_name', type: 'type', lastSeenAt: 'env_last_seen_at', }; if (sortBy.startsWith('environment:')) { const [, envName] = sortBy.split(':'); query = query .orderByRaw( `CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${sortOrder}`, [envName], ) .orderBy('created_at', 'asc'); } else if (sortByMapping[sortBy]) { query = query .orderBy(sortByMapping[sortBy], sortOrder) .orderBy('created_at', 'asc'); } else { query = query.orderBy('created_at', sortOrder); } const total = await countQuery .countDistinct({ total: 'features.name' }) .first(); query = query .select(selectColumns) .limit(limit * environmentCount) .offset(offset * environmentCount); const rows = await query; if (rows.length > 0) { const overview = this.getFeatureOverviewData(getUniqueRows(rows)); const features = sortEnvironments(overview); return { features, total: Number(total?.total) || 0, }; } return { features: [], total: 0, }; } async getFeatureOverview({ projectId, archived, userId, tag, namePrefix, }: IFeatureProjectUserParams): Promise { let query = this.db('features').where({ project: projectId }); if (tag) { const tagQuery = this.db .from('feature_tag') .select('feature_name') .whereIn(['tag_type', 'tag_value'], tag); query = query.whereIn('features.name', tagQuery); } if (namePrefix?.trim()) { let namePrefixQuery = namePrefix; if (!namePrefix.endsWith('%')) { namePrefixQuery = `${namePrefixQuery}%`; } query = query.whereILike('features.name', namePrefixQuery); } query = query .modify(FeatureToggleStore.filterByArchived, archived) .leftJoin( 'feature_environments', 'feature_environments.feature_name', 'features.name', ) .leftJoin( 'environments', 'feature_environments.environment', 'environments.name', ) .leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name'); if (this.flagResolver.isEnabled('useLastSeenRefactor')) { query.leftJoin('last_seen_at_metrics', function () { this.on( 'last_seen_at_metrics.environment', '=', 'environments.name', ).andOn( 'last_seen_at_metrics.feature_name', '=', 'features.name', ); }); } let selectColumns = [ 'features.name as feature_name', 'features.description as description', 'features.type as type', 'features.created_at as created_at', 'features.last_seen_at as last_seen_at', 'features.stale as stale', 'features.impression_data as impression_data', 'feature_environments.enabled as enabled', 'feature_environments.environment as environment', 'feature_environments.variants as variants', 'environments.type as environment_type', 'environments.sort_order as environment_sort_order', 'ft.tag_value as tag_value', 'ft.tag_type as tag_type', ] as (string | Raw | Knex.QueryBuilder)[]; if (this.flagResolver.isEnabled('useLastSeenRefactor')) { selectColumns.push( 'last_seen_at_metrics.last_seen_at as env_last_seen_at', ); } else { selectColumns.push( 'feature_environments.last_seen_at as env_last_seen_at', ); } if (userId) { query = query.leftJoin(`favorite_features`, function () { this.on('favorite_features.feature', 'features.name').andOnVal( 'favorite_features.user_id', '=', userId, ); }); selectColumns = [ ...selectColumns, this.db.raw( 'favorite_features.feature is not null as favorite', ), ]; } if (this.flagResolver.isEnabled('featureSwitchRefactor')) { selectColumns = [ ...selectColumns, this.db.raw( 'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies', ), this.db.raw( 'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies', ), ]; } query = query.select(selectColumns); const rows = await query; if (rows.length > 0) { const overview = this.getFeatureOverviewData(getUniqueRows(rows)); return sortEnvironments(overview); } return []; } getFeatureOverviewData(rows): IFeatureOverview { return rows.reduce((acc, row) => { if (acc[row.feature_name] !== undefined) { acc[row.feature_name].environments.push( FeatureStrategiesStore.getEnvironment(row), ); if (this.isNewTag(acc[row.feature_name], row)) { this.addTag(acc[row.feature_name], row); } } else { acc[row.feature_name] = { type: row.type, description: row.description, favorite: row.favorite, name: row.feature_name, createdAt: row.created_at, lastSeenAt: row.last_seen_at, stale: row.stale, impressionData: row.impression_data, environments: [FeatureStrategiesStore.getEnvironment(row)], }; if (this.isNewTag(acc[row.feature_name], row)) { this.addTag(acc[row.feature_name], row); } } return acc; }, {}); } async getStrategyById(id: string): Promise { const strat = await this.db(T.featureStrategies).where({ id }).first(); if (strat) { return mapRow(strat); } throw new NotFoundError(`Could not find strategy with id: ${id}`); } async updateSortOrder(id: string, sortOrder: number): Promise { await this.db(T.featureStrategies) .where({ id }) .update({ sort_order: sortOrder }); } async updateStrategy( id: string, updates: Partial, ): Promise { const update = mapStrategyUpdate(updates); const row = await this.db(T.featureStrategies) .where({ id }) .update(update) .returning('*'); return mapRow(row[0]); } private static getAdminStrategy( r: any, includeId: boolean = true, ): IStrategyConfig { const strategy = { name: r.strategy_name, constraints: r.constraints || [], variants: r.strategy_variants || [], parameters: r.parameters, sortOrder: r.sort_order, id: r.strategy_id, title: r.strategy_title || '', disabled: r.strategy_disabled || false, }; if (!includeId) { delete strategy.id; } return strategy; } async deleteConfigurationsForProjectAndEnvironment( projectId: String, environment: String, ): Promise { await this.db(T.featureStrategies) .where({ project_name: projectId, environment, }) .del(); } async setProjectForStrategiesBelongingToFeature( featureName: string, newProjectId: string, ): Promise { await this.db(T.featureStrategies) .where({ feature_name: featureName }) .update({ project_name: newProjectId }); } async getStrategiesBySegment( segmentId: number, ): Promise { const stopTimer = this.timer('getStrategiesBySegment'); const rows = await this.db .select(this.prefixColumns()) .from(T.featureStrategies) .join( T.featureStrategySegment, `${T.featureStrategySegment}.feature_strategy_id`, `${T.featureStrategies}.id`, ) .where(`${T.featureStrategySegment}.segment_id`, '=', segmentId); stopTimer(); return rows.map(mapRow); } async getStrategiesByContextField( contextFieldName: string, ): Promise { const stopTimer = this.timer('getStrategiesByContextField'); const rows = await this.db .select(this.prefixColumns()) .from(T.featureStrategies) .where( this.db.raw( "EXISTS (SELECT 1 FROM jsonb_array_elements(constraints) AS elem WHERE elem ->> 'contextName' = ?)", contextFieldName, ), ); stopTimer(); return rows.map(mapRow); } prefixColumns(): string[] { return COLUMNS.map((c) => `${T.featureStrategies}.${c}`); } async getCustomStrategiesInUseCount(): Promise { const stopTimer = this.timer('getCustomStrategiesInUseCount'); const notBuiltIn = '0'; const columns = [ this.db.raw('count(fes.strategy_name) as times_used'), 'fes.strategy_name', ]; const rows = await this.db(`${T.strategies} as str`) .select(columns) .join( `${T.featureStrategies} as fes`, 'fes.strategy_name', 'str.name', ) .where(`str.built_in`, '=', notBuiltIn) .groupBy('strategy_name'); stopTimer(); return rows.length; } } module.exports = FeatureStrategiesStore; export default FeatureStrategiesStore;