mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	refactor: move search implementation out of strategies store (#5642)
This is first step of refactoring. Next steps follow with possibly a query builder, or atleast using some reusable methods.
This commit is contained in:
		
							parent
							
								
									fbb5dd9022
								
							
						
					
					
						commit
						fa087fb473
					
				| @ -39,6 +39,7 @@ import { ImportTogglesStore } from '../features/export-import-toggles/import-tog | ||||
| import PrivateProjectStore from '../features/private-project/privateProjectStore'; | ||||
| import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store'; | ||||
| import LastSeenStore from '../services/client-metrics/last-seen/last-seen-store'; | ||||
| import FeatureSearchStore from '../features/feature-search/feature-search-store'; | ||||
| 
 | ||||
| export const createStores = ( | ||||
|     config: IUnleashConfig, | ||||
| @ -139,6 +140,7 @@ export const createStores = ( | ||||
|         privateProjectStore: new PrivateProjectStore(db, getLogger), | ||||
|         dependentFeaturesStore: new DependentFeaturesStore(db), | ||||
|         lastSeenStore: new LastSeenStore(db, eventBus, getLogger), | ||||
|         featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -1,23 +1,22 @@ | ||||
| import { Db } from '../../db/db'; | ||||
| import { IUnleashConfig } from '../../types'; | ||||
| 
 | ||||
| import FeatureStrategiesStore from '../feature-toggle/feature-toggle-strategies-store'; | ||||
| import { FeatureSearchService } from './feature-search-service'; | ||||
| import FakeFeatureStrategiesStore from '../feature-toggle/fakes/fake-feature-strategies-store'; | ||||
| import FakeFeatureSearchStore from './fake-feature-search-store'; | ||||
| import FeatureSearchStore from './feature-search-store'; | ||||
| 
 | ||||
| export const createFeatureSearchService = | ||||
|     (config: IUnleashConfig) => | ||||
|     (db: Db): FeatureSearchService => { | ||||
|         const { getLogger, eventBus, flagResolver } = config; | ||||
|         const featureStrategiesStore = new FeatureStrategiesStore( | ||||
|         const featureSearchStore = new FeatureSearchStore( | ||||
|             db, | ||||
|             eventBus, | ||||
|             getLogger, | ||||
|             flagResolver, | ||||
|         ); | ||||
| 
 | ||||
|         return new FeatureSearchService( | ||||
|             { featureStrategiesStore: featureStrategiesStore }, | ||||
|             { featureSearchStore: featureSearchStore }, | ||||
|             config, | ||||
|         ); | ||||
|     }; | ||||
| @ -25,11 +24,11 @@ export const createFeatureSearchService = | ||||
| export const createFakeFeatureSearchService = ( | ||||
|     config: IUnleashConfig, | ||||
| ): FeatureSearchService => { | ||||
|     const fakeFeatureStrategiesStore = new FakeFeatureStrategiesStore(); | ||||
|     const fakeFeatureSearchStore = new FakeFeatureSearchStore(); | ||||
| 
 | ||||
|     return new FeatureSearchService( | ||||
|         { | ||||
|             featureStrategiesStore: fakeFeatureStrategiesStore, | ||||
|             featureSearchStore: fakeFeatureSearchStore, | ||||
|         }, | ||||
|         config, | ||||
|     ); | ||||
|  | ||||
							
								
								
									
										15
									
								
								src/lib/features/feature-search/fake-feature-search-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/lib/features/feature-search/fake-feature-search-store.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| import { IFeatureOverview } from 'lib/types'; | ||||
| import { | ||||
|     IFeatureSearchParams, | ||||
|     IQueryParam, | ||||
| } from '../feature-toggle/types/feature-toggle-strategies-store-type'; | ||||
| import { IFeatureSearchStore } from './feature-search-store-type'; | ||||
| 
 | ||||
| export default class FakeFeatureSearchStore implements IFeatureSearchStore { | ||||
|     searchFeatures( | ||||
|         params: IFeatureSearchParams, | ||||
|         queryParams: IQueryParam[], | ||||
|     ): Promise<{ features: IFeatureOverview[]; total: number }> { | ||||
|         throw new Error('Method not implemented.'); | ||||
|     } | ||||
| } | ||||
| @ -1,9 +1,8 @@ | ||||
| import { Logger } from '../../logger'; | ||||
| import { | ||||
|     IFeatureStrategiesStore, | ||||
|     IFeatureSearchStore, | ||||
|     IUnleashConfig, | ||||
|     IUnleashStores, | ||||
|     serializeDates, | ||||
| } from '../../types'; | ||||
| import { | ||||
|     IFeatureSearchParams, | ||||
| @ -12,22 +11,20 @@ import { | ||||
| } from '../feature-toggle/types/feature-toggle-strategies-store-type'; | ||||
| 
 | ||||
| export class FeatureSearchService { | ||||
|     private featureStrategiesStore: IFeatureStrategiesStore; | ||||
|     private featureSearchStore: IFeatureSearchStore; | ||||
|     private logger: Logger; | ||||
|     constructor( | ||||
|         { | ||||
|             featureStrategiesStore, | ||||
|         }: Pick<IUnleashStores, 'featureStrategiesStore'>, | ||||
|         { featureSearchStore }: Pick<IUnleashStores, 'featureSearchStore'>, | ||||
|         { getLogger }: Pick<IUnleashConfig, 'getLogger'>, | ||||
|     ) { | ||||
|         this.featureStrategiesStore = featureStrategiesStore; | ||||
|         this.featureSearchStore = featureSearchStore; | ||||
|         this.logger = getLogger('services/feature-search-service.ts'); | ||||
|     } | ||||
| 
 | ||||
|     async search(params: IFeatureSearchParams) { | ||||
|         const queryParams = this.convertToQueryParams(params); | ||||
|         const { features, total } = | ||||
|             await this.featureStrategiesStore.searchFeatures( | ||||
|             await this.featureSearchStore.searchFeatures( | ||||
|                 { | ||||
|                     ...params, | ||||
|                     limit: params.limit, | ||||
|  | ||||
							
								
								
									
										15
									
								
								src/lib/features/feature-search/feature-search-store-type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/lib/features/feature-search/feature-search-store-type.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| import { | ||||
|     IFeatureSearchParams, | ||||
|     IQueryParam, | ||||
| } from '../feature-toggle/types/feature-toggle-strategies-store-type'; | ||||
| import { IFeatureOverview } from '../../types'; | ||||
| 
 | ||||
| export interface IFeatureSearchStore { | ||||
|     searchFeatures( | ||||
|         params: IFeatureSearchParams, | ||||
|         queryParams: IQueryParam[], | ||||
|     ): Promise<{ | ||||
|         features: IFeatureOverview[]; | ||||
|         total: number; | ||||
|     }>; | ||||
| } | ||||
							
								
								
									
										520
									
								
								src/lib/features/feature-search/feature-search-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										520
									
								
								src/lib/features/feature-search/feature-search-store.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,520 @@ | ||||
| import { Knex } from 'knex'; | ||||
| import EventEmitter from 'events'; | ||||
| import metricsHelper from '../../util/metrics-helper'; | ||||
| import { DB_TIME } from '../../metric-events'; | ||||
| import { Logger, LogProvider } from '../../logger'; | ||||
| import { | ||||
|     IEnvironmentOverview, | ||||
|     IFeatureOverview, | ||||
|     IFeatureSearchStore, | ||||
|     IFlagResolver, | ||||
|     ITag, | ||||
| } from '../../types'; | ||||
| import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; | ||||
| import { Db } from '../../db/db'; | ||||
| import Raw = Knex.Raw; | ||||
| import { | ||||
|     IFeatureSearchParams, | ||||
|     IQueryParam, | ||||
| } from '../feature-toggle/types/feature-toggle-strategies-store-type'; | ||||
| import FeatureStrategiesStore from '../feature-toggle/feature-toggle-strategies-store'; | ||||
| 
 | ||||
| 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; | ||||
|             }), | ||||
|     })); | ||||
| }; | ||||
| 
 | ||||
| class FeatureSearchStore implements IFeatureSearchStore { | ||||
|     private db: Db; | ||||
| 
 | ||||
|     private logger: Logger; | ||||
| 
 | ||||
|     private readonly timer: Function; | ||||
| 
 | ||||
|     constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) { | ||||
|         this.db = db; | ||||
|         this.logger = getLogger('feature-search-store.ts'); | ||||
|         this.timer = (action) => | ||||
|             metricsHelper.wrapTimer(eventBus, DB_TIME, { | ||||
|                 store: 'feature-search', | ||||
|                 action, | ||||
|             }); | ||||
|     } | ||||
| 
 | ||||
|     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, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     async searchFeatures( | ||||
|         { | ||||
|             userId, | ||||
|             searchParams, | ||||
|             type, | ||||
|             status, | ||||
|             offset, | ||||
|             limit, | ||||
|             sortOrder, | ||||
|             sortBy, | ||||
|             favoritesFirst, | ||||
|         }: IFeatureSearchParams, | ||||
|         queryParams: IQueryParam[], | ||||
|     ): Promise<{ | ||||
|         features: IFeatureOverview[]; | ||||
|         total: number; | ||||
|     }> { | ||||
|         const stopTimer = this.timer('searchFeatures'); | ||||
|         const validatedSortOrder = | ||||
|             sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; | ||||
| 
 | ||||
|         const finalQuery = this.db | ||||
|             .with('ranked_features', (query) => { | ||||
|                 query.from('features'); | ||||
| 
 | ||||
|                 applyQueryParams(query, queryParams); | ||||
| 
 | ||||
|                 const hasSearchParams = searchParams?.length; | ||||
|                 if (hasSearchParams) { | ||||
|                     const sqlParameters = searchParams.map( | ||||
|                         (item) => `%${item}%`, | ||||
|                     ); | ||||
|                     const sqlQueryParameters = sqlParameters | ||||
|                         .map(() => '?') | ||||
|                         .join(','); | ||||
| 
 | ||||
|                     query.where((builder) => { | ||||
|                         builder | ||||
|                             .orWhereRaw( | ||||
|                                 `(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`, | ||||
|                                 ['features.name', ...sqlParameters], | ||||
|                             ) | ||||
|                             .orWhereRaw( | ||||
|                                 `(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`, | ||||
|                                 ['features.description', ...sqlParameters], | ||||
|                             ); | ||||
|                     }); | ||||
|                 } | ||||
| 
 | ||||
|                 if (type) { | ||||
|                     query.whereIn('features.type', type); | ||||
|                 } | ||||
| 
 | ||||
|                 if (status && status.length > 0) { | ||||
|                     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 | ||||
|                     .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', | ||||
|                     ) | ||||
|                     .leftJoin( | ||||
|                         'feature_strategies', | ||||
|                         'feature_strategies.feature_name', | ||||
|                         'features.name', | ||||
|                     ) | ||||
|                     .leftJoin( | ||||
|                         'feature_strategy_segment', | ||||
|                         'feature_strategy_segment.feature_strategy_id', | ||||
|                         'feature_strategies.id', | ||||
|                     ) | ||||
|                     .leftJoin( | ||||
|                         'segments', | ||||
|                         'feature_strategy_segment.segment_id', | ||||
|                         'segments.id', | ||||
|                     ); | ||||
| 
 | ||||
|                 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.project as project', | ||||
|                     'features.created_at as created_at', | ||||
|                     'features.stale as stale', | ||||
|                     'features.last_seen_at as last_seen_at', | ||||
|                     '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', | ||||
|                     'segments.name as segment_name', | ||||
|                 ] as (string | Raw<any> | Knex.QueryBuilder)[]; | ||||
| 
 | ||||
|                 const lastSeenQuery = 'last_seen_at_metrics.last_seen_at'; | ||||
|                 selectColumns.push(`${lastSeenQuery} as env_last_seen_at`); | ||||
| 
 | ||||
|                 if (userId) { | ||||
|                     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', | ||||
|                         ), | ||||
|                     ]; | ||||
|                 } | ||||
| 
 | ||||
|                 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: 'features.name', | ||||
|                     type: 'features.type', | ||||
|                     lastSeenAt: lastSeenQuery, | ||||
|                 }; | ||||
| 
 | ||||
|                 let rankingSql = 'order by '; | ||||
|                 if (favoritesFirst) { | ||||
|                     rankingSql += | ||||
|                         'favorite_features.feature is not null desc, '; | ||||
|                 } | ||||
| 
 | ||||
|                 if (sortBy.startsWith('environment:')) { | ||||
|                     const [, envName] = sortBy.split(':'); | ||||
|                     rankingSql += this.db | ||||
|                         .raw( | ||||
|                             `CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`, | ||||
|                             [envName], | ||||
|                         ) | ||||
|                         .toString(); | ||||
|                 } else if (sortByMapping[sortBy]) { | ||||
|                     rankingSql += `${this.db | ||||
|                         .raw(`?? ${validatedSortOrder}`, [ | ||||
|                             sortByMapping[sortBy], | ||||
|                         ]) | ||||
|                         .toString()}, features.created_at asc, features.name asc`;
 | ||||
|                 } else { | ||||
|                     rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`; | ||||
|                 } | ||||
| 
 | ||||
|                 query | ||||
|                     .select(selectColumns) | ||||
|                     .denseRank('rank', this.db.raw(rankingSql)); | ||||
|             }) | ||||
|             .with( | ||||
|                 'final_ranks', | ||||
|                 this.db.raw( | ||||
|                     'select feature_name, row_number() over (order by min(rank)) as final_rank from ranked_features group by feature_name', | ||||
|                 ), | ||||
|             ) | ||||
|             .with( | ||||
|                 'total_features', | ||||
|                 this.db.raw('select count(*) as total from final_ranks'), | ||||
|             ) | ||||
|             .select('*') | ||||
|             .from('ranked_features') | ||||
|             .innerJoin( | ||||
|                 'final_ranks', | ||||
|                 'ranked_features.feature_name', | ||||
|                 'final_ranks.feature_name', | ||||
|             ) | ||||
|             .joinRaw('CROSS JOIN total_features') | ||||
|             .whereBetween('final_rank', [offset + 1, offset + limit]); | ||||
| 
 | ||||
|         const rows = await finalQuery; | ||||
|         stopTimer(); | ||||
|         if (rows.length > 0) { | ||||
|             const overview = this.getAggregatedSearchData(rows); | ||||
|             const features = sortEnvironments(overview); | ||||
|             return { | ||||
|                 features, | ||||
|                 total: Number(rows[0].total) || 0, | ||||
|             }; | ||||
|         } | ||||
| 
 | ||||
|         return { | ||||
|             features: [], | ||||
|             total: 0, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     getAggregatedSearchData(rows): IFeatureOverview { | ||||
|         return rows.reduce((acc, row) => { | ||||
|             if (acc[row.feature_name] !== undefined) { | ||||
|                 const environmentExists = acc[ | ||||
|                     row.feature_name | ||||
|                 ].environments.some( | ||||
|                     (existingEnvironment) => | ||||
|                         existingEnvironment.name === row.environment, | ||||
|                 ); | ||||
|                 if (!environmentExists) { | ||||
|                     acc[row.feature_name].environments.push( | ||||
|                         FeatureSearchStore.getEnvironment(row), | ||||
|                     ); | ||||
|                 } | ||||
| 
 | ||||
|                 const segmentExists = acc[row.feature_name].segments.includes( | ||||
|                     row.segment_name, | ||||
|                 ); | ||||
| 
 | ||||
|                 if (row.segment_name && !segmentExists) { | ||||
|                     acc[row.feature_name].segments.push(row.segment_name); | ||||
|                 } | ||||
| 
 | ||||
|                 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, | ||||
|                     project: row.project, | ||||
|                     favorite: row.favorite, | ||||
|                     name: row.feature_name, | ||||
|                     createdAt: row.created_at, | ||||
|                     stale: row.stale, | ||||
|                     impressionData: row.impression_data, | ||||
|                     lastSeenAt: row.last_seen_at, | ||||
|                     environments: [FeatureSearchStore.getEnvironment(row)], | ||||
|                     segments: row.segment_name ? [row.segment_name] : [], | ||||
|                 }; | ||||
| 
 | ||||
|                 if (this.isNewTag(acc[row.feature_name], row)) { | ||||
|                     this.addTag(acc[row.feature_name], row); | ||||
|                 } | ||||
|             } | ||||
|             const featureRow = acc[row.feature_name]; | ||||
|             if ( | ||||
|                 featureRow.lastSeenAt === undefined || | ||||
|                 new Date(row.env_last_seen_at) > | ||||
|                     new Date(featureRow.last_seen_at) | ||||
|             ) { | ||||
|                 featureRow.lastSeenAt = row.env_last_seen_at; | ||||
|             } | ||||
|             return acc; | ||||
|         }, {}); | ||||
|     } | ||||
| 
 | ||||
|     private addTag( | ||||
|         featureToggle: Record<string, any>, | ||||
|         row: Record<string, any>, | ||||
|     ): void { | ||||
|         const tags = featureToggle.tags || []; | ||||
|         const newTag = this.rowToTag(row); | ||||
|         featureToggle.tags = [...tags, newTag]; | ||||
|     } | ||||
| 
 | ||||
|     private rowToTag(r: any): ITag { | ||||
|         return { | ||||
|             value: r.tag_value, | ||||
|             type: r.tag_type, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     private isNewTag( | ||||
|         featureToggle: Record<string, any>, | ||||
|         row: Record<string, any>, | ||||
|     ): boolean { | ||||
|         return ( | ||||
|             row.tag_type && | ||||
|             row.tag_value && | ||||
|             !featureToggle.tags?.some( | ||||
|                 (tag) => | ||||
|                     tag.type === row.tag_type && tag.value === row.tag_value, | ||||
|             ) | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| const applyQueryParams = ( | ||||
|     query: Knex.QueryBuilder, | ||||
|     queryParams: IQueryParam[], | ||||
| ): void => { | ||||
|     const tagConditions = queryParams.filter((param) => param.field === 'tag'); | ||||
|     const segmentConditions = queryParams.filter( | ||||
|         (param) => param.field === 'segment', | ||||
|     ); | ||||
|     const genericConditions = queryParams.filter( | ||||
|         (param) => param.field !== 'tag', | ||||
|     ); | ||||
|     applyGenericQueryParams(query, genericConditions); | ||||
| 
 | ||||
|     applyMultiQueryParams( | ||||
|         query, | ||||
|         tagConditions, | ||||
|         ['tag_type', 'tag_value'], | ||||
|         createTagBaseQuery, | ||||
|     ); | ||||
|     applyMultiQueryParams( | ||||
|         query, | ||||
|         segmentConditions, | ||||
|         'segments.name', | ||||
|         createSegmentBaseQuery, | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const applyGenericQueryParams = ( | ||||
|     query: Knex.QueryBuilder, | ||||
|     queryParams: IQueryParam[], | ||||
| ): void => { | ||||
|     queryParams.forEach((param) => { | ||||
|         switch (param.operator) { | ||||
|             case 'IS': | ||||
|             case 'IS_ANY_OF': | ||||
|                 query.whereIn(param.field, param.values); | ||||
|                 break; | ||||
|             case 'IS_NOT': | ||||
|             case 'IS_NONE_OF': | ||||
|                 query.whereNotIn(param.field, param.values); | ||||
|                 break; | ||||
|             case 'IS_BEFORE': | ||||
|                 query.where(param.field, '<', param.values[0]); | ||||
|                 break; | ||||
|             case 'IS_ON_OR_AFTER': | ||||
|                 query.where(param.field, '>=', param.values[0]); | ||||
|                 break; | ||||
|         } | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| const applyMultiQueryParams = ( | ||||
|     query: Knex.QueryBuilder, | ||||
|     queryParams: IQueryParam[], | ||||
|     fields: string | string[], | ||||
|     createBaseQuery: ( | ||||
|         values: string[] | string[][], | ||||
|     ) => (dbSubQuery: Knex.QueryBuilder) => Knex.QueryBuilder, | ||||
| ): void => { | ||||
|     queryParams.forEach((param) => { | ||||
|         const values = param.values.map((val) => | ||||
|             (Array.isArray(fields) ? val.split(':') : [val]).map((s) => | ||||
|                 s.trim(), | ||||
|             ), | ||||
|         ); | ||||
| 
 | ||||
|         const baseSubQuery = createBaseQuery(values); | ||||
| 
 | ||||
|         switch (param.operator) { | ||||
|             case 'INCLUDE': | ||||
|             case 'INCLUDE_ANY_OF': | ||||
|                 if (Array.isArray(fields)) { | ||||
|                     query.whereIn(fields, values); | ||||
|                 } else { | ||||
|                     query.whereIn( | ||||
|                         fields, | ||||
|                         values.map((v) => v[0]), | ||||
|                     ); | ||||
|                 } | ||||
|                 break; | ||||
| 
 | ||||
|             case 'DO_NOT_INCLUDE': | ||||
|             case 'EXCLUDE_IF_ANY_OF': | ||||
|                 query.whereNotIn('features.name', baseSubQuery); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'INCLUDE_ALL_OF': | ||||
|                 query.whereIn('features.name', (dbSubQuery) => { | ||||
|                     baseSubQuery(dbSubQuery) | ||||
|                         .groupBy('feature_name') | ||||
|                         .havingRaw('COUNT(*) = ?', [values.length]); | ||||
|                 }); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'EXCLUDE_ALL': | ||||
|                 query.whereNotIn('features.name', (dbSubQuery) => { | ||||
|                     baseSubQuery(dbSubQuery) | ||||
|                         .groupBy('feature_name') | ||||
|                         .havingRaw('COUNT(*) = ?', [values.length]); | ||||
|                 }); | ||||
|                 break; | ||||
|         } | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| const createTagBaseQuery = (tags: string[][]) => { | ||||
|     return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => { | ||||
|         return dbSubQuery | ||||
|             .from('feature_tag') | ||||
|             .select('feature_name') | ||||
|             .whereIn(['tag_type', 'tag_value'], tags); | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const createSegmentBaseQuery = (segments: string[]) => { | ||||
|     return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => { | ||||
|         return dbSubQuery | ||||
|             .from('feature_strategies') | ||||
|             .leftJoin( | ||||
|                 'feature_strategy_segment', | ||||
|                 'feature_strategy_segment.feature_strategy_id', | ||||
|                 'feature_strategies.id', | ||||
|             ) | ||||
|             .leftJoin( | ||||
|                 'segments', | ||||
|                 'feature_strategy_segment.segment_id', | ||||
|                 'segments.id', | ||||
|             ) | ||||
|             .select('feature_name') | ||||
|             .whereIn('name', segments); | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| module.exports = FeatureSearchStore; | ||||
| export default FeatureSearchStore; | ||||
| @ -8,10 +8,7 @@ import { | ||||
|     FeatureToggle, | ||||
| } from '../../../types/model'; | ||||
| import NotFoundError from '../../../error/notfound-error'; | ||||
| import { | ||||
|     IFeatureSearchParams, | ||||
|     IFeatureStrategiesStore, | ||||
| } from '../types/feature-toggle-strategies-store-type'; | ||||
| import { IFeatureStrategiesStore } from '../types/feature-toggle-strategies-store-type'; | ||||
| import { IFeatureProjectUserParams } from '../feature-toggle-controller'; | ||||
| 
 | ||||
| interface ProjectEnvironment { | ||||
| @ -324,13 +321,6 @@ export default class FakeFeatureStrategiesStore | ||||
|     ): Promise<IFeatureOverview[]> { | ||||
|         return Promise.resolve([]); | ||||
|     } | ||||
| 
 | ||||
|     searchFeatures( | ||||
|         params: IFeatureSearchParams, | ||||
|     ): Promise<{ features: IFeatureOverview[]; total: number }> { | ||||
|         return Promise.resolve({ features: [], total: 0 }); | ||||
|     } | ||||
| 
 | ||||
|     getAllByFeatures( | ||||
|         features: string[], | ||||
|         environment?: string, | ||||
|  | ||||
| @ -25,10 +25,6 @@ import { ensureStringValue, mapValues } from '../../util'; | ||||
| import { IFeatureProjectUserParams } from './feature-toggle-controller'; | ||||
| import { Db } from '../../db/db'; | ||||
| import Raw = Knex.Raw; | ||||
| import { | ||||
|     IFeatureSearchParams, | ||||
|     IQueryParam, | ||||
| } from './types/feature-toggle-strategies-store-type'; | ||||
| 
 | ||||
| const COLUMNS = [ | ||||
|     'id', | ||||
| @ -527,237 +523,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     async searchFeatures( | ||||
|         { | ||||
|             userId, | ||||
|             searchParams, | ||||
|             type, | ||||
|             tag, | ||||
|             status, | ||||
|             offset, | ||||
|             limit, | ||||
|             sortOrder, | ||||
|             sortBy, | ||||
|             favoritesFirst, | ||||
|         }: IFeatureSearchParams, | ||||
|         queryParams: IQueryParam[], | ||||
|     ): Promise<{ | ||||
|         features: IFeatureOverview[]; | ||||
|         total: number; | ||||
|     }> { | ||||
|         const validatedSortOrder = | ||||
|             sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; | ||||
| 
 | ||||
|         const finalQuery = this.db | ||||
|             .with('ranked_features', (query) => { | ||||
|                 query.from('features'); | ||||
| 
 | ||||
|                 applyQueryParams(query, queryParams); | ||||
| 
 | ||||
|                 const hasSearchParams = searchParams?.length; | ||||
|                 if (hasSearchParams) { | ||||
|                     const sqlParameters = searchParams.map( | ||||
|                         (item) => `%${item}%`, | ||||
|                     ); | ||||
|                     const sqlQueryParameters = sqlParameters | ||||
|                         .map(() => '?') | ||||
|                         .join(','); | ||||
| 
 | ||||
|                     query.where((builder) => { | ||||
|                         builder | ||||
|                             .orWhereRaw( | ||||
|                                 `(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`, | ||||
|                                 ['features.name', ...sqlParameters], | ||||
|                             ) | ||||
|                             .orWhereRaw( | ||||
|                                 `(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`, | ||||
|                                 ['features.description', ...sqlParameters], | ||||
|                             ); | ||||
|                     }); | ||||
|                 } | ||||
| 
 | ||||
|                 if (type) { | ||||
|                     query.whereIn('features.type', type); | ||||
|                 } | ||||
| 
 | ||||
|                 if (status && status.length > 0) { | ||||
|                     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 | ||||
|                     .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', | ||||
|                     ) | ||||
|                     .leftJoin( | ||||
|                         'feature_strategies', | ||||
|                         'feature_strategies.feature_name', | ||||
|                         'features.name', | ||||
|                     ) | ||||
|                     .leftJoin( | ||||
|                         'feature_strategy_segment', | ||||
|                         'feature_strategy_segment.feature_strategy_id', | ||||
|                         'feature_strategies.id', | ||||
|                     ) | ||||
|                     .leftJoin( | ||||
|                         'segments', | ||||
|                         'feature_strategy_segment.segment_id', | ||||
|                         'segments.id', | ||||
|                     ); | ||||
| 
 | ||||
|                 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.project as project', | ||||
|                     'features.created_at as created_at', | ||||
|                     'features.stale as stale', | ||||
|                     'features.last_seen_at as last_seen_at', | ||||
|                     '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', | ||||
|                     'segments.name as segment_name', | ||||
|                 ] as (string | Raw<any> | Knex.QueryBuilder)[]; | ||||
| 
 | ||||
|                 const lastSeenQuery = 'last_seen_at_metrics.last_seen_at'; | ||||
|                 selectColumns.push(`${lastSeenQuery} as env_last_seen_at`); | ||||
| 
 | ||||
|                 if (userId) { | ||||
|                     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', | ||||
|                         ), | ||||
|                     ]; | ||||
|                 } | ||||
| 
 | ||||
|                 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: 'features.name', | ||||
|                     type: 'features.type', | ||||
|                     lastSeenAt: lastSeenQuery, | ||||
|                 }; | ||||
| 
 | ||||
|                 let rankingSql = 'order by '; | ||||
|                 if (favoritesFirst) { | ||||
|                     rankingSql += | ||||
|                         'favorite_features.feature is not null desc, '; | ||||
|                 } | ||||
| 
 | ||||
|                 if (sortBy.startsWith('environment:')) { | ||||
|                     const [, envName] = sortBy.split(':'); | ||||
|                     rankingSql += this.db | ||||
|                         .raw( | ||||
|                             `CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`, | ||||
|                             [envName], | ||||
|                         ) | ||||
|                         .toString(); | ||||
|                 } else if (sortByMapping[sortBy]) { | ||||
|                     rankingSql += `${this.db | ||||
|                         .raw(`?? ${validatedSortOrder}`, [ | ||||
|                             sortByMapping[sortBy], | ||||
|                         ]) | ||||
|                         .toString()}, features.created_at asc, features.name asc`;
 | ||||
|                 } else { | ||||
|                     rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`; | ||||
|                 } | ||||
| 
 | ||||
|                 query | ||||
|                     .select(selectColumns) | ||||
|                     .denseRank('rank', this.db.raw(rankingSql)); | ||||
|             }) | ||||
|             .with( | ||||
|                 'final_ranks', | ||||
|                 this.db.raw( | ||||
|                     'select feature_name, row_number() over (order by min(rank)) as final_rank from ranked_features group by feature_name', | ||||
|                 ), | ||||
|             ) | ||||
|             .with( | ||||
|                 'total_features', | ||||
|                 this.db.raw('select count(*) as total from final_ranks'), | ||||
|             ) | ||||
|             .select('*') | ||||
|             .from('ranked_features') | ||||
|             .innerJoin( | ||||
|                 'final_ranks', | ||||
|                 'ranked_features.feature_name', | ||||
|                 'final_ranks.feature_name', | ||||
|             ) | ||||
|             .joinRaw('CROSS JOIN total_features') | ||||
|             .whereBetween('final_rank', [offset + 1, offset + limit]); | ||||
| 
 | ||||
|         const rows = await finalQuery; | ||||
| 
 | ||||
|         if (rows.length > 0) { | ||||
|             const overview = this.getAggregatedSearchData(rows); | ||||
|             const features = sortEnvironments(overview); | ||||
|             return { | ||||
|                 features, | ||||
|                 total: Number(rows[0].total) || 0, | ||||
|             }; | ||||
|         } | ||||
|         return { | ||||
|             features: [], | ||||
|             total: 0, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     async getFeatureOverview({ | ||||
|         projectId, | ||||
|         archived, | ||||
| @ -1090,138 +855,5 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| const applyQueryParams = ( | ||||
|     query: Knex.QueryBuilder, | ||||
|     queryParams: IQueryParam[], | ||||
| ): void => { | ||||
|     const tagConditions = queryParams.filter((param) => param.field === 'tag'); | ||||
|     const segmentConditions = queryParams.filter( | ||||
|         (param) => param.field === 'segment', | ||||
|     ); | ||||
|     const genericConditions = queryParams.filter( | ||||
|         (param) => param.field !== 'tag', | ||||
|     ); | ||||
|     applyGenericQueryParams(query, genericConditions); | ||||
| 
 | ||||
|     applyMultiQueryParams( | ||||
|         query, | ||||
|         tagConditions, | ||||
|         ['tag_type', 'tag_value'], | ||||
|         createTagBaseQuery, | ||||
|     ); | ||||
|     applyMultiQueryParams( | ||||
|         query, | ||||
|         segmentConditions, | ||||
|         'segments.name', | ||||
|         createSegmentBaseQuery, | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const applyGenericQueryParams = ( | ||||
|     query: Knex.QueryBuilder, | ||||
|     queryParams: IQueryParam[], | ||||
| ): void => { | ||||
|     queryParams.forEach((param) => { | ||||
|         switch (param.operator) { | ||||
|             case 'IS': | ||||
|             case 'IS_ANY_OF': | ||||
|                 query.whereIn(param.field, param.values); | ||||
|                 break; | ||||
|             case 'IS_NOT': | ||||
|             case 'IS_NONE_OF': | ||||
|                 query.whereNotIn(param.field, param.values); | ||||
|                 break; | ||||
|             case 'IS_BEFORE': | ||||
|                 query.where(param.field, '<', param.values[0]); | ||||
|                 break; | ||||
|             case 'IS_ON_OR_AFTER': | ||||
|                 query.where(param.field, '>=', param.values[0]); | ||||
|                 break; | ||||
|         } | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| const applyMultiQueryParams = ( | ||||
|     query: Knex.QueryBuilder, | ||||
|     queryParams: IQueryParam[], | ||||
|     fields: string | string[], | ||||
|     createBaseQuery: ( | ||||
|         values: string[] | string[][], | ||||
|     ) => (dbSubQuery: Knex.QueryBuilder) => Knex.QueryBuilder, | ||||
| ): void => { | ||||
|     queryParams.forEach((param) => { | ||||
|         const values = param.values.map((val) => | ||||
|             (Array.isArray(fields) ? val.split(':') : [val]).map((s) => | ||||
|                 s.trim(), | ||||
|             ), | ||||
|         ); | ||||
| 
 | ||||
|         const baseSubQuery = createBaseQuery(values); | ||||
| 
 | ||||
|         switch (param.operator) { | ||||
|             case 'INCLUDE': | ||||
|             case 'INCLUDE_ANY_OF': | ||||
|                 if (Array.isArray(fields)) { | ||||
|                     query.whereIn(fields, values); | ||||
|                 } else { | ||||
|                     query.whereIn( | ||||
|                         fields, | ||||
|                         values.map((v) => v[0]), | ||||
|                     ); | ||||
|                 } | ||||
|                 break; | ||||
| 
 | ||||
|             case 'DO_NOT_INCLUDE': | ||||
|             case 'EXCLUDE_IF_ANY_OF': | ||||
|                 query.whereNotIn('features.name', baseSubQuery); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'INCLUDE_ALL_OF': | ||||
|                 query.whereIn('features.name', (dbSubQuery) => { | ||||
|                     baseSubQuery(dbSubQuery) | ||||
|                         .groupBy('feature_name') | ||||
|                         .havingRaw('COUNT(*) = ?', [values.length]); | ||||
|                 }); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'EXCLUDE_ALL': | ||||
|                 query.whereNotIn('features.name', (dbSubQuery) => { | ||||
|                     baseSubQuery(dbSubQuery) | ||||
|                         .groupBy('feature_name') | ||||
|                         .havingRaw('COUNT(*) = ?', [values.length]); | ||||
|                 }); | ||||
|                 break; | ||||
|         } | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| const createTagBaseQuery = (tags: string[][]) => { | ||||
|     return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => { | ||||
|         return dbSubQuery | ||||
|             .from('feature_tag') | ||||
|             .select('feature_name') | ||||
|             .whereIn(['tag_type', 'tag_value'], tags); | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const createSegmentBaseQuery = (segments: string[]) => { | ||||
|     return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => { | ||||
|         return dbSubQuery | ||||
|             .from('feature_strategies') | ||||
|             .leftJoin( | ||||
|                 'feature_strategy_segment', | ||||
|                 'feature_strategy_segment.feature_strategy_id', | ||||
|                 'feature_strategies.id', | ||||
|             ) | ||||
|             .leftJoin( | ||||
|                 'segments', | ||||
|                 'feature_strategy_segment.segment_id', | ||||
|                 'segments.id', | ||||
|             ) | ||||
|             .select('feature_name') | ||||
|             .whereIn('name', segments); | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| module.exports = FeatureStrategiesStore; | ||||
| export default FeatureStrategiesStore; | ||||
|  | ||||
| @ -91,14 +91,6 @@ export interface IFeatureStrategiesStore | ||||
|         params: IFeatureProjectUserParams, | ||||
|     ): Promise<IFeatureOverview[]>; | ||||
| 
 | ||||
|     searchFeatures( | ||||
|         params: IFeatureSearchParams, | ||||
|         queryParams: IQueryParam[], | ||||
|     ): Promise<{ | ||||
|         features: IFeatureOverview[]; | ||||
|         total: number; | ||||
|     }>; | ||||
| 
 | ||||
|     getStrategyById(id: string): Promise<IFeatureStrategy>; | ||||
| 
 | ||||
|     updateStrategy( | ||||
|  | ||||
| @ -36,6 +36,7 @@ import { IImportTogglesStore } from '../features/export-import-toggles/import-to | ||||
| import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType'; | ||||
| import { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type'; | ||||
| import { ILastSeenStore } from '../services/client-metrics/last-seen/types/last-seen-store-type'; | ||||
| import { IFeatureSearchStore } from '../features/feature-search/feature-search-store-type'; | ||||
| 
 | ||||
| export interface IUnleashStores { | ||||
|     accessStore: IAccessStore; | ||||
| @ -76,6 +77,7 @@ export interface IUnleashStores { | ||||
|     privateProjectStore: IPrivateProjectStore; | ||||
|     dependentFeaturesStore: IDependentFeaturesStore; | ||||
|     lastSeenStore: ILastSeenStore; | ||||
|     featureSearchStore: IFeatureSearchStore; | ||||
| } | ||||
| 
 | ||||
| export { | ||||
| @ -116,4 +118,5 @@ export { | ||||
|     IPrivateProjectStore, | ||||
|     IDependentFeaturesStore, | ||||
|     ILastSeenStore, | ||||
|     IFeatureSearchStore, | ||||
| }; | ||||
|  | ||||
							
								
								
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							| @ -39,6 +39,7 @@ import { FakeAccountStore } from './fake-account-store'; | ||||
| import FakeProjectStatsStore from './fake-project-stats-store'; | ||||
| import { FakeDependentFeaturesStore } from '../../lib/features/dependent-features/fake-dependent-features-store'; | ||||
| import { FakeLastSeenStore } from '../../lib/services/client-metrics/last-seen/fake-last-seen-store'; | ||||
| import FakeFeatureSearchStore from '../../lib/features/feature-search/fake-feature-search-store'; | ||||
| 
 | ||||
| const db = { | ||||
|     select: () => ({ | ||||
| @ -87,6 +88,7 @@ const createStores: () => IUnleashStores = () => { | ||||
|         privateProjectStore: {} as IPrivateProjectStore, | ||||
|         dependentFeaturesStore: new FakeDependentFeaturesStore(), | ||||
|         lastSeenStore: new FakeLastSeenStore(), | ||||
|         featureSearchStore: new FakeFeatureSearchStore(), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user