diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 5f8ede8ecf..e962b8b334 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -44,6 +44,7 @@ import { InactiveUsersStore } from '../users/inactive/inactive-users-store'; import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store'; import { SegmentReadModel } from '../features/segment/segment-read-model'; import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; +import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store'; export const createStores = ( config: IUnleashConfig, @@ -145,11 +146,17 @@ export const createStores = ( privateProjectStore: new PrivateProjectStore(db, getLogger), dependentFeaturesStore: new DependentFeaturesStore(db), 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), trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), segmentReadModel: new SegmentReadModel(db), projectOwnersReadModel: new ProjectOwnersReadModel(db), + featureLifecycleStore: new FeatureLifecycleStore(db), }; }; diff --git a/src/lib/features/feature-search/createFeatureSearchService.ts b/src/lib/features/feature-search/createFeatureSearchService.ts index 066314534c..d0be150b46 100644 --- a/src/lib/features/feature-search/createFeatureSearchService.ts +++ b/src/lib/features/feature-search/createFeatureSearchService.ts @@ -13,6 +13,7 @@ export const createFeatureSearchService = db, eventBus, getLogger, + flagResolver, ); return new FeatureSearchService( diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index de1c07556b..40cffd7a3f 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -7,6 +7,7 @@ import type { IFeatureOverview, IFeatureSearchOverview, IFeatureSearchStore, + IFlagResolver, ITag, } from '../../types'; import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; @@ -40,9 +41,17 @@ class FeatureSearchStore implements IFeatureSearchStore { 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.logger = getLogger('feature-search-store.ts'); + this.flagResolver = flagResolver; this.timer = (action) => metricsHelper.wrapTimer(eventBus, DB_TIME, { 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( { userId, @@ -86,6 +109,9 @@ class FeatureSearchStore implements IFeatureSearchStore { const validatedSortOrder = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; + const featureLifecycleEnabled = + this.flagResolver.isEnabled('featureLifecycle'); + const finalQuery = this.db .with('ranked_features', (query) => { query.from('features'); @@ -235,6 +261,7 @@ class FeatureSearchStore implements IFeatureSearchStore { .select(selectColumns) .denseRank('rank', this.db.raw(rankingSql)); }) + .with('lifecycle', this.getLatestLifecycleStageQuery()) .with( 'final_ranks', this.db.raw( @@ -290,10 +317,20 @@ class FeatureSearchStore implements IFeatureSearchStore { .joinRaw('CROSS JOIN total_features') .whereBetween('final_rank', [offset + 1, offset + limit]) .orderBy('final_rank'); + if (featureLifecycleEnabled) { + finalQuery.leftJoin( + 'lifecycle', + 'ranked_features.feature_name', + 'lifecycle.stage_feature', + ); + } const rows = await finalQuery; stopTimer(); if (rows.length > 0) { - const overview = this.getAggregatedSearchData(rows); + const overview = this.getAggregatedSearchData( + rows, + featureLifecycleEnabled, + ); const features = sortEnvironments(overview); return { features, @@ -349,7 +386,10 @@ class FeatureSearchStore implements IFeatureSearchStore { return rankingSql; } - getAggregatedSearchData(rows): IFeatureSearchOverview[] { + getAggregatedSearchData( + rows, + featureLifecycleEnabled: boolean, + ): IFeatureSearchOverview[] { const entriesMap: Map = new Map(); const orderedEntries: IFeatureSearchOverview[] = []; @@ -372,6 +412,14 @@ class FeatureSearchStore implements IFeatureSearchStore { environments: [], 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); orderedEntries.push(entry); } diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 4baf38288b..c303ce0609 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -21,6 +21,7 @@ beforeAll(async () => { experimental: { flags: { 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({ name: 'my_feature_b', 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({ query: 'my_feature_b', }); @@ -964,6 +972,7 @@ test('should return environment usage metrics', async () => { features: [ { name: 'my_feature_b', + lifecycle: { stage: 'pre-live' }, environments: [ { name: 'default', diff --git a/src/lib/openapi/spec/feature-search-response-schema.ts b/src/lib/openapi/spec/feature-search-response-schema.ts index dbaac99c4e..b517e331b9 100644 --- a/src/lib/openapi/spec/feature-search-response-schema.ts +++ b/src/lib/openapi/spec/feature-search-response-schema.ts @@ -143,6 +143,32 @@ export const featureSearchResponseSchema = { nullable: true, 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: { schemas: { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index a3304edbd9..84992a8b60 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -237,6 +237,7 @@ export interface IFeatureOverview { createdAt: Date; lastSeenAt: Date; environments: IEnvironmentOverview[]; + lifecycle?: IFeatureLifecycleStage; } export type IFeatureSearchOverview = Exclude< diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 775ee7c75d..8cde17e878 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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 { ISegmentReadModel } from '../features/segment/segment-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 { accessStore: IAccessStore; @@ -86,6 +87,7 @@ export interface IUnleashStores { trafficDataUsageStore: ITrafficDataUsageStore; segmentReadModel: ISegmentReadModel; projectOwnersReadModel: IProjectOwnersReadModel; + featureLifecycleStore: IFeatureLifecycleStore; } export { @@ -130,4 +132,5 @@ export { ITrafficDataUsageStore, ISegmentReadModel, IProjectOwnersReadModel, + IFeatureLifecycleStore, }; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 0fd487d1e3..ce51a043e6 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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 { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-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 = { select: () => ({ @@ -97,6 +98,7 @@ const createStores: () => IUnleashStores = () => { trafficDataUsageStore: new FakeTrafficDataUsageStore(), segmentReadModel: new FakeSegmentReadModel(), projectOwnersReadModel: new FakeProjectOwnersReadModel(), + featureLifecycleStore: new FakeFeatureLifecycleStore(), }; };