mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: expose lifecycle stage in project overview search (#7017)
This commit is contained in:
		
							parent
							
								
									28a7797aea
								
							
						
					
					
						commit
						97d702afeb
					
				| @ -44,6 +44,7 @@ import { InactiveUsersStore } from '../users/inactive/inactive-users-store'; | |||||||
| import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store'; | import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store'; | ||||||
| import { SegmentReadModel } from '../features/segment/segment-read-model'; | import { SegmentReadModel } from '../features/segment/segment-read-model'; | ||||||
| import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; | import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; | ||||||
|  | import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store'; | ||||||
| 
 | 
 | ||||||
| export const createStores = ( | export const createStores = ( | ||||||
|     config: IUnleashConfig, |     config: IUnleashConfig, | ||||||
| @ -145,11 +146,17 @@ export const createStores = ( | |||||||
|         privateProjectStore: new PrivateProjectStore(db, getLogger), |         privateProjectStore: new PrivateProjectStore(db, getLogger), | ||||||
|         dependentFeaturesStore: new DependentFeaturesStore(db), |         dependentFeaturesStore: new DependentFeaturesStore(db), | ||||||
|         lastSeenStore: new LastSeenStore(db, eventBus, getLogger), |         lastSeenStore: new LastSeenStore(db, eventBus, getLogger), | ||||||
|         featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger), |         featureSearchStore: new FeatureSearchStore( | ||||||
|  |             db, | ||||||
|  |             eventBus, | ||||||
|  |             getLogger, | ||||||
|  |             config.flagResolver, | ||||||
|  |         ), | ||||||
|         inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger), |         inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger), | ||||||
|         trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), |         trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), | ||||||
|         segmentReadModel: new SegmentReadModel(db), |         segmentReadModel: new SegmentReadModel(db), | ||||||
|         projectOwnersReadModel: new ProjectOwnersReadModel(db), |         projectOwnersReadModel: new ProjectOwnersReadModel(db), | ||||||
|  |         featureLifecycleStore: new FeatureLifecycleStore(db), | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -13,6 +13,7 @@ export const createFeatureSearchService = | |||||||
|             db, |             db, | ||||||
|             eventBus, |             eventBus, | ||||||
|             getLogger, |             getLogger, | ||||||
|  |             flagResolver, | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         return new FeatureSearchService( |         return new FeatureSearchService( | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ import type { | |||||||
|     IFeatureOverview, |     IFeatureOverview, | ||||||
|     IFeatureSearchOverview, |     IFeatureSearchOverview, | ||||||
|     IFeatureSearchStore, |     IFeatureSearchStore, | ||||||
|  |     IFlagResolver, | ||||||
|     ITag, |     ITag, | ||||||
| } from '../../types'; | } from '../../types'; | ||||||
| import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; | import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; | ||||||
| @ -40,9 +41,17 @@ class FeatureSearchStore implements IFeatureSearchStore { | |||||||
| 
 | 
 | ||||||
|     private readonly timer: Function; |     private readonly timer: Function; | ||||||
| 
 | 
 | ||||||
|     constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) { |     private flagResolver: IFlagResolver; | ||||||
|  | 
 | ||||||
|  |     constructor( | ||||||
|  |         db: Db, | ||||||
|  |         eventBus: EventEmitter, | ||||||
|  |         getLogger: LogProvider, | ||||||
|  |         flagResolver: IFlagResolver, | ||||||
|  |     ) { | ||||||
|         this.db = db; |         this.db = db; | ||||||
|         this.logger = getLogger('feature-search-store.ts'); |         this.logger = getLogger('feature-search-store.ts'); | ||||||
|  |         this.flagResolver = flagResolver; | ||||||
|         this.timer = (action) => |         this.timer = (action) => | ||||||
|             metricsHelper.wrapTimer(eventBus, DB_TIME, { |             metricsHelper.wrapTimer(eventBus, DB_TIME, { | ||||||
|                 store: 'feature-search', |                 store: 'feature-search', | ||||||
| @ -65,6 +74,20 @@ class FeatureSearchStore implements IFeatureSearchStore { | |||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     private getLatestLifecycleStageQuery() { | ||||||
|  |         return this.db('feature_lifecycles') | ||||||
|  |             .select( | ||||||
|  |                 'feature as stage_feature', | ||||||
|  |                 'stage as latest_stage', | ||||||
|  |                 'created_at as entered_stage_at', | ||||||
|  |             ) | ||||||
|  |             .distinctOn('stage_feature') | ||||||
|  |             .orderBy([ | ||||||
|  |                 'stage_feature', | ||||||
|  |                 { column: 'entered_stage_at', order: 'desc' }, | ||||||
|  |             ]); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     async searchFeatures( |     async searchFeatures( | ||||||
|         { |         { | ||||||
|             userId, |             userId, | ||||||
| @ -86,6 +109,9 @@ class FeatureSearchStore implements IFeatureSearchStore { | |||||||
|         const validatedSortOrder = |         const validatedSortOrder = | ||||||
|             sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; |             sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; | ||||||
| 
 | 
 | ||||||
|  |         const featureLifecycleEnabled = | ||||||
|  |             this.flagResolver.isEnabled('featureLifecycle'); | ||||||
|  | 
 | ||||||
|         const finalQuery = this.db |         const finalQuery = this.db | ||||||
|             .with('ranked_features', (query) => { |             .with('ranked_features', (query) => { | ||||||
|                 query.from('features'); |                 query.from('features'); | ||||||
| @ -235,6 +261,7 @@ class FeatureSearchStore implements IFeatureSearchStore { | |||||||
|                     .select(selectColumns) |                     .select(selectColumns) | ||||||
|                     .denseRank('rank', this.db.raw(rankingSql)); |                     .denseRank('rank', this.db.raw(rankingSql)); | ||||||
|             }) |             }) | ||||||
|  |             .with('lifecycle', this.getLatestLifecycleStageQuery()) | ||||||
|             .with( |             .with( | ||||||
|                 'final_ranks', |                 'final_ranks', | ||||||
|                 this.db.raw( |                 this.db.raw( | ||||||
| @ -290,10 +317,20 @@ class FeatureSearchStore implements IFeatureSearchStore { | |||||||
|             .joinRaw('CROSS JOIN total_features') |             .joinRaw('CROSS JOIN total_features') | ||||||
|             .whereBetween('final_rank', [offset + 1, offset + limit]) |             .whereBetween('final_rank', [offset + 1, offset + limit]) | ||||||
|             .orderBy('final_rank'); |             .orderBy('final_rank'); | ||||||
|  |         if (featureLifecycleEnabled) { | ||||||
|  |             finalQuery.leftJoin( | ||||||
|  |                 'lifecycle', | ||||||
|  |                 'ranked_features.feature_name', | ||||||
|  |                 'lifecycle.stage_feature', | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|         const rows = await finalQuery; |         const rows = await finalQuery; | ||||||
|         stopTimer(); |         stopTimer(); | ||||||
|         if (rows.length > 0) { |         if (rows.length > 0) { | ||||||
|             const overview = this.getAggregatedSearchData(rows); |             const overview = this.getAggregatedSearchData( | ||||||
|  |                 rows, | ||||||
|  |                 featureLifecycleEnabled, | ||||||
|  |             ); | ||||||
|             const features = sortEnvironments(overview); |             const features = sortEnvironments(overview); | ||||||
|             return { |             return { | ||||||
|                 features, |                 features, | ||||||
| @ -349,7 +386,10 @@ class FeatureSearchStore implements IFeatureSearchStore { | |||||||
|         return rankingSql; |         return rankingSql; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     getAggregatedSearchData(rows): IFeatureSearchOverview[] { |     getAggregatedSearchData( | ||||||
|  |         rows, | ||||||
|  |         featureLifecycleEnabled: boolean, | ||||||
|  |     ): IFeatureSearchOverview[] { | ||||||
|         const entriesMap: Map<string, IFeatureSearchOverview> = new Map(); |         const entriesMap: Map<string, IFeatureSearchOverview> = new Map(); | ||||||
|         const orderedEntries: IFeatureSearchOverview[] = []; |         const orderedEntries: IFeatureSearchOverview[] = []; | ||||||
| 
 | 
 | ||||||
| @ -372,6 +412,14 @@ class FeatureSearchStore implements IFeatureSearchStore { | |||||||
|                     environments: [], |                     environments: [], | ||||||
|                     segments: row.segment_name ? [row.segment_name] : [], |                     segments: row.segment_name ? [row.segment_name] : [], | ||||||
|                 }; |                 }; | ||||||
|  |                 if (featureLifecycleEnabled) { | ||||||
|  |                     entry.lifecycle = row.latest_stage | ||||||
|  |                         ? { | ||||||
|  |                               stage: row.latest_stage, | ||||||
|  |                               enteredStageAt: row.entered_stage_at, | ||||||
|  |                           } | ||||||
|  |                         : undefined; | ||||||
|  |                 } | ||||||
|                 entriesMap.set(row.feature_name, entry); |                 entriesMap.set(row.feature_name, entry); | ||||||
|                 orderedEntries.push(entry); |                 orderedEntries.push(entry); | ||||||
|             } |             } | ||||||
|  | |||||||
| @ -21,6 +21,7 @@ beforeAll(async () => { | |||||||
|             experimental: { |             experimental: { | ||||||
|                 flags: { |                 flags: { | ||||||
|                     strictSchemaValidation: true, |                     strictSchemaValidation: true, | ||||||
|  |                     featureLifecycle: true, | ||||||
|                 }, |                 }, | ||||||
|             }, |             }, | ||||||
|         }, |         }, | ||||||
| @ -924,7 +925,7 @@ test('should filter features by combined operators', async () => { | |||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| test('should return environment usage metrics', async () => { | test('should return environment usage metrics and lifecycle', async () => { | ||||||
|     await app.createFeature({ |     await app.createFeature({ | ||||||
|         name: 'my_feature_b', |         name: 'my_feature_b', | ||||||
|         createdAt: '2023-01-29T15:21:39.975Z', |         createdAt: '2023-01-29T15:21:39.975Z', | ||||||
| @ -957,6 +958,13 @@ test('should return environment usage metrics', async () => { | |||||||
|         }, |         }, | ||||||
|     ]); |     ]); | ||||||
| 
 | 
 | ||||||
|  |     await stores.featureLifecycleStore.insert([ | ||||||
|  |         { feature: 'my_feature_b', stage: 'initial' }, | ||||||
|  |     ]); | ||||||
|  |     await stores.featureLifecycleStore.insert([ | ||||||
|  |         { feature: 'my_feature_b', stage: 'pre-live' }, | ||||||
|  |     ]); | ||||||
|  | 
 | ||||||
|     const { body } = await searchFeatures({ |     const { body } = await searchFeatures({ | ||||||
|         query: 'my_feature_b', |         query: 'my_feature_b', | ||||||
|     }); |     }); | ||||||
| @ -964,6 +972,7 @@ test('should return environment usage metrics', async () => { | |||||||
|         features: [ |         features: [ | ||||||
|             { |             { | ||||||
|                 name: 'my_feature_b', |                 name: 'my_feature_b', | ||||||
|  |                 lifecycle: { stage: 'pre-live' }, | ||||||
|                 environments: [ |                 environments: [ | ||||||
|                     { |                     { | ||||||
|                         name: 'default', |                         name: 'default', | ||||||
|  | |||||||
| @ -143,6 +143,32 @@ export const featureSearchResponseSchema = { | |||||||
|             nullable: true, |             nullable: true, | ||||||
|             description: 'The list of feature tags', |             description: 'The list of feature tags', | ||||||
|         }, |         }, | ||||||
|  |         lifecycle: { | ||||||
|  |             type: 'object', | ||||||
|  |             description: 'Current lifecycle stage of the feature', | ||||||
|  |             additionalProperties: false, | ||||||
|  |             required: ['stage', 'enteredStageAt'], | ||||||
|  |             properties: { | ||||||
|  |                 stage: { | ||||||
|  |                     description: 'The name of the current lifecycle stage', | ||||||
|  |                     type: 'string', | ||||||
|  |                     enum: [ | ||||||
|  |                         'initial', | ||||||
|  |                         'pre-live', | ||||||
|  |                         'live', | ||||||
|  |                         'completed', | ||||||
|  |                         'archived', | ||||||
|  |                     ], | ||||||
|  |                     example: 'initial', | ||||||
|  |                 }, | ||||||
|  |                 enteredStageAt: { | ||||||
|  |                     description: 'When the feature entered this stage', | ||||||
|  |                     type: 'string', | ||||||
|  |                     format: 'date-time', | ||||||
|  |                     example: '2023-01-28T15:21:39.975Z', | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|     }, |     }, | ||||||
|     components: { |     components: { | ||||||
|         schemas: { |         schemas: { | ||||||
|  | |||||||
| @ -237,6 +237,7 @@ export interface IFeatureOverview { | |||||||
|     createdAt: Date; |     createdAt: Date; | ||||||
|     lastSeenAt: Date; |     lastSeenAt: Date; | ||||||
|     environments: IEnvironmentOverview[]; |     environments: IEnvironmentOverview[]; | ||||||
|  |     lifecycle?: IFeatureLifecycleStage; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export type IFeatureSearchOverview = Exclude< | export type IFeatureSearchOverview = Exclude< | ||||||
|  | |||||||
| @ -41,6 +41,7 @@ import type { IInactiveUsersStore } from '../users/inactive/types/inactive-users | |||||||
| import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type'; | import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type'; | ||||||
| import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; | import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; | ||||||
| import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type'; | import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type'; | ||||||
|  | import { IFeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store-type'; | ||||||
| 
 | 
 | ||||||
| export interface IUnleashStores { | export interface IUnleashStores { | ||||||
|     accessStore: IAccessStore; |     accessStore: IAccessStore; | ||||||
| @ -86,6 +87,7 @@ export interface IUnleashStores { | |||||||
|     trafficDataUsageStore: ITrafficDataUsageStore; |     trafficDataUsageStore: ITrafficDataUsageStore; | ||||||
|     segmentReadModel: ISegmentReadModel; |     segmentReadModel: ISegmentReadModel; | ||||||
|     projectOwnersReadModel: IProjectOwnersReadModel; |     projectOwnersReadModel: IProjectOwnersReadModel; | ||||||
|  |     featureLifecycleStore: IFeatureLifecycleStore; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export { | export { | ||||||
| @ -130,4 +132,5 @@ export { | |||||||
|     ITrafficDataUsageStore, |     ITrafficDataUsageStore, | ||||||
|     ISegmentReadModel, |     ISegmentReadModel, | ||||||
|     IProjectOwnersReadModel, |     IProjectOwnersReadModel, | ||||||
|  |     IFeatureLifecycleStore, | ||||||
| }; | }; | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							| @ -44,6 +44,7 @@ import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inac | |||||||
| import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store'; | import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store'; | ||||||
| import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model'; | import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model'; | ||||||
| import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model'; | import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model'; | ||||||
|  | import { FakeFeatureLifecycleStore } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-store'; | ||||||
| 
 | 
 | ||||||
| const db = { | const db = { | ||||||
|     select: () => ({ |     select: () => ({ | ||||||
| @ -97,6 +98,7 @@ const createStores: () => IUnleashStores = () => { | |||||||
|         trafficDataUsageStore: new FakeTrafficDataUsageStore(), |         trafficDataUsageStore: new FakeTrafficDataUsageStore(), | ||||||
|         segmentReadModel: new FakeSegmentReadModel(), |         segmentReadModel: new FakeSegmentReadModel(), | ||||||
|         projectOwnersReadModel: new FakeProjectOwnersReadModel(), |         projectOwnersReadModel: new FakeProjectOwnersReadModel(), | ||||||
|  |         featureLifecycleStore: new FakeFeatureLifecycleStore(), | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user