From f5061bc3ff4de07fcd198f1bfd06cd12a0ea4413 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 24 Apr 2024 14:27:26 +0200 Subject: [PATCH] feat: return lifecycle state in feature overview (#6920) --- .../fake-feature-lifecycle-read-model.ts | 12 ++++++ .../feature-lifecycle-read-model-type.ts | 7 ++++ .../feature-lifecycle-read-model.ts | 33 ++++++++++++++++ .../feature-lifecycle-service.test.ts | 2 +- .../feature-lifecycle-store-type.ts | 14 +------ .../feature-lifecycle-store.ts | 2 +- .../feature-lifecycle.e2e.test.ts | 17 +++++++- .../get-current-stage.test.ts | 39 +++++++++++++++++++ .../feature-lifecycle/get-current-stage.ts | 23 +++++++++++ .../createFeatureToggleService.ts | 8 ++++ .../feature-toggle/feature-toggle-service.ts | 16 ++++++-- src/lib/openapi/spec/feature-schema.ts | 26 +++++++++++++ .../feature-service-potentially-stale.test.ts | 2 + src/lib/services/index.ts | 6 +++ src/lib/types/model.ts | 15 ++++++- 15 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts create mode 100644 src/lib/features/feature-lifecycle/get-current-stage.test.ts create mode 100644 src/lib/features/feature-lifecycle/get-current-stage.ts diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts new file mode 100644 index 0000000000..c01d0009b3 --- /dev/null +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts @@ -0,0 +1,12 @@ +import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; +import type { IFeatureLifecycleStage } from '../../types'; + +export class FakeFeatureLifecycleReadModel + implements IFeatureLifecycleReadModel +{ + findCurrentStage( + feature: string, + ): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts new file mode 100644 index 0000000000..ea827f053c --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts @@ -0,0 +1,7 @@ +import type { IFeatureLifecycleStage } from '../../types'; + +export interface IFeatureLifecycleReadModel { + findCurrentStage( + feature: string, + ): Promise; +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts new file mode 100644 index 0000000000..1f6e5deee8 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts @@ -0,0 +1,33 @@ +import type { Db } from '../../db/db'; +import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; +import { getCurrentStage } from './get-current-stage'; +import type { IFeatureLifecycleStage, StageName } from '../../types'; + +type DBType = { + feature: string; + stage: StageName; + created_at: Date; +}; + +export class FeatureLifecycleReadModel implements IFeatureLifecycleReadModel { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async findCurrentStage( + feature: string, + ): Promise { + const results = await this.db('feature_lifecycles') + .where({ feature }) + .orderBy('created_at', 'asc'); + + const stages = results.map(({ stage, created_at }: DBType) => ({ + stage, + enteredStageAt: created_at, + })); + + return getCurrentStage(stages); + } +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts index c4380820a7..849432f3cb 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts @@ -5,10 +5,10 @@ import { FEATURE_CREATED, type IEnvironment, type IUnleashConfig, + type StageName, } from '../../types'; import { createFakeFeatureLifecycleService } from './createFeatureLifecycle'; import EventEmitter from 'events'; -import type { StageName } from './feature-lifecycle-store-type'; import { STAGE_ENTERED } from './feature-lifecycle-service'; import noLoggerProvider from '../../../test/fixtures/no-logger'; diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts index cfdccd4794..ac73b0b3d8 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts @@ -1,21 +1,11 @@ -export type StageName = - | 'initial' - | 'pre-live' - | 'live' - | 'completed' - | 'archived'; +import type { IFeatureLifecycleStage, StageName } from '../../types'; export type FeatureLifecycleStage = { feature: string; stage: StageName; }; -export type FeatureLifecycleStageView = { - stage: StageName; - enteredStageAt: Date; -}; - -export type FeatureLifecycleView = FeatureLifecycleStageView[]; +export type FeatureLifecycleView = IFeatureLifecycleStage[]; export interface IFeatureLifecycleStore { insert(featureLifecycleStage: FeatureLifecycleStage): Promise; diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts index f07c8f480d..21ba4d6b02 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts @@ -2,9 +2,9 @@ import type { FeatureLifecycleStage, IFeatureLifecycleStore, FeatureLifecycleView, - StageName, } from './feature-lifecycle-store-type'; import type { Db } from '../../db/db'; +import type { StageName } from '../../types'; type DBType = { feature: string; diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts index e724b2941e..72f74fe994 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts @@ -9,13 +9,13 @@ import { FEATURE_ARCHIVED, FEATURE_CREATED, type IEventStore, + type StageName, } from '../../types'; import type EventEmitter from 'events'; import { type FeatureLifecycleService, STAGE_ENTERED, } from './feature-lifecycle-service'; -import type { StageName } from './feature-lifecycle-store-type'; let app: IUnleashTest; let db: ITestDb; @@ -69,10 +69,22 @@ function reachedStage(name: StageName) { ); } +const expectFeatureStage = async (stage: StageName) => { + const { body: feature } = await app.getProjectFeatures( + 'default', + 'my_feature_a', + ); + expect(feature.lifecycle).toMatchObject({ + stage, + enteredStageAt: expect.any(String), + }); +}; + test('should return lifecycle stages', async () => { await app.createFeature('my_feature_a'); eventStore.emit(FEATURE_CREATED, { featureName: 'my_feature_a' }); await reachedStage('initial'); + await expectFeatureStage('initial'); eventBus.emit(CLIENT_METRICS, { featureName: 'my_feature_a', environment: 'default', @@ -87,6 +99,7 @@ test('should return lifecycle stages', async () => { environment: 'non-existent', }); await reachedStage('live'); + await expectFeatureStage('live'); eventStore.emit(FEATURE_ARCHIVED, { featureName: 'my_feature_a' }); await reachedStage('archived'); @@ -97,4 +110,6 @@ test('should return lifecycle stages', async () => { { stage: 'live', enteredStageAt: expect.any(String) }, { stage: 'archived', enteredStageAt: expect.any(String) }, ]); + + await expectFeatureStage('archived'); }); diff --git a/src/lib/features/feature-lifecycle/get-current-stage.test.ts b/src/lib/features/feature-lifecycle/get-current-stage.test.ts new file mode 100644 index 0000000000..d4e9646448 --- /dev/null +++ b/src/lib/features/feature-lifecycle/get-current-stage.test.ts @@ -0,0 +1,39 @@ +import { getCurrentStage } from './get-current-stage'; +import type { IFeatureLifecycleStage } from '../../types'; + +const irrelevantDate = new Date('2024-04-22T10:00:00Z'); + +describe('getCurrentStage', () => { + it('should return the first matching stage based on the preferred order', () => { + const stages: IFeatureLifecycleStage[] = [ + { + stage: 'initial', + enteredStageAt: irrelevantDate, + }, + { + stage: 'completed', + enteredStageAt: irrelevantDate, + }, + { + stage: 'archived', + enteredStageAt: irrelevantDate, + }, + { stage: 'live', enteredStageAt: irrelevantDate }, + ]; + + const result = getCurrentStage(stages); + + expect(result).toEqual({ + stage: 'archived', + enteredStageAt: irrelevantDate, + }); + }); + + it('should handle an empty stages array', () => { + const stages: IFeatureLifecycleStage[] = []; + + const result = getCurrentStage(stages); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/lib/features/feature-lifecycle/get-current-stage.ts b/src/lib/features/feature-lifecycle/get-current-stage.ts new file mode 100644 index 0000000000..f53a5c7e46 --- /dev/null +++ b/src/lib/features/feature-lifecycle/get-current-stage.ts @@ -0,0 +1,23 @@ +import type { IFeatureLifecycleStage, StageName } from '../../types'; + +const preferredOrder: StageName[] = [ + 'archived', + 'completed', + 'live', + 'pre-live', + 'initial', +]; + +export function getCurrentStage( + stages: IFeatureLifecycleStage[], +): IFeatureLifecycleStage | undefined { + for (const preferredStage of preferredOrder) { + const foundStage = stages.find( + (stage) => stage.stage === preferredStage, + ); + if (foundStage) { + return foundStage; + } + } + return undefined; +} diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index d5837df423..7f755f59b8 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -53,6 +53,8 @@ import { } from '../dependent-features/createDependentFeaturesService'; import { createEventsService } from '../events/createEventsService'; import { EventEmitter } from 'stream'; +import { FeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycle-read-model'; +import { FakeFeatureLifecycleReadModel } from '../feature-lifecycle/fake-feature-lifecycle-read-model'; export const createFeatureToggleService = ( db: Db, @@ -122,6 +124,8 @@ export const createFeatureToggleService = ( const dependentFeaturesReadModel = new DependentFeaturesReadModel(db); + const featureLifecycleReadModel = new FeatureLifecycleReadModel(db); + const dependentFeaturesService = createDependentFeaturesService(config)(db); const featureToggleService = new FeatureToggleService( @@ -143,6 +147,7 @@ export const createFeatureToggleService = ( privateProjectChecker, dependentFeaturesReadModel, dependentFeaturesService, + featureLifecycleReadModel, ); return featureToggleService; }; @@ -185,6 +190,8 @@ export const createFakeFeatureToggleService = ( const fakePrivateProjectChecker = createFakePrivateProjectChecker(); const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel(); const dependentFeaturesService = createFakeDependentFeaturesService(config); + const featureLifecycleReadModel = new FakeFeatureLifecycleReadModel(); + const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -204,6 +211,7 @@ export const createFakeFeatureToggleService = ( fakePrivateProjectChecker, dependentFeaturesReadModel, dependentFeaturesService, + featureLifecycleReadModel, ); return featureToggleService; }; diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 7abd421bb3..a4aca46d09 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -15,7 +15,7 @@ import { type FeatureToggle, type FeatureToggleDTO, type FeatureToggleLegacy, - type FeatureToggleWithDependencies, + type FeatureToggleView, type FeatureToggleWithEnvironment, FeatureUpdatedEvent, FeatureVariantEvent, @@ -24,6 +24,7 @@ import { type IDependency, type IFeatureEnvironmentInfo, type IFeatureEnvironmentStore, + type IFeatureLifecycleStage, type IFeatureNaming, type IFeatureOverview, type IFeatureStrategy, @@ -108,6 +109,7 @@ import ArchivedFeatureError from '../../error/archivedfeature-error'; import { FEATURES_CREATED_BY_PROCESSED } from '../../metric-events'; import { allSettledWithRejection } from '../../util/allSettledWithRejection'; import type EventEmitter from 'node:events'; +import type { IFeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycle-read-model-type'; interface IFeatureContext { featureName: string; @@ -173,6 +175,8 @@ class FeatureToggleService { private dependentFeaturesReadModel: IDependentFeaturesReadModel; + private featureLifecycleReadModel: IFeatureLifecycleReadModel; + private dependentFeaturesService: DependentFeaturesService; private eventBus: EventEmitter; @@ -210,6 +214,7 @@ class FeatureToggleService { privateProjectChecker: IPrivateProjectChecker, dependentFeaturesReadModel: IDependentFeaturesReadModel, dependentFeaturesService: DependentFeaturesService, + featureLifecycleReadModel: IFeatureLifecycleReadModel, ) { this.logger = getLogger('services/feature-toggle-service.ts'); this.featureStrategiesStore = featureStrategiesStore; @@ -228,6 +233,7 @@ class FeatureToggleService { this.privateProjectChecker = privateProjectChecker; this.dependentFeaturesReadModel = dependentFeaturesReadModel; this.dependentFeaturesService = dependentFeaturesService; + this.featureLifecycleReadModel = featureLifecycleReadModel; this.eventBus = eventBus; } @@ -980,7 +986,7 @@ class FeatureToggleService { projectId, environmentVariants, userId, - }: IGetFeatureParams): Promise { + }: IGetFeatureParams): Promise { if (projectId) { await this.validateFeatureBelongsToProject({ featureName, @@ -990,9 +996,11 @@ class FeatureToggleService { let dependencies: IDependency[] = []; let children: string[] = []; - [dependencies, children] = await Promise.all([ + let lifecycle: IFeatureLifecycleStage | undefined = undefined; + [dependencies, children, lifecycle] = await Promise.all([ this.dependentFeaturesReadModel.getParents(featureName), this.dependentFeaturesReadModel.getChildren([featureName]), + this.featureLifecycleReadModel.findCurrentStage(featureName), ]); if (environmentVariants) { @@ -1006,6 +1014,7 @@ class FeatureToggleService { ...result, dependencies, children, + lifecycle, }; } else { const result = @@ -1018,6 +1027,7 @@ class FeatureToggleService { ...result, dependencies, children, + lifecycle, }; } } diff --git a/src/lib/openapi/spec/feature-schema.ts b/src/lib/openapi/spec/feature-schema.ts index bba2c025f7..b936776203 100644 --- a/src/lib/openapi/spec/feature-schema.ts +++ b/src/lib/openapi/spec/feature-schema.ts @@ -130,6 +130,32 @@ export const featureSchema = { example: 'some-feature', }, }, + 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', + }, + }, + }, dependencies: { type: 'array', items: { diff --git a/src/lib/services/feature-service-potentially-stale.test.ts b/src/lib/services/feature-service-potentially-stale.test.ts index 0db88645a1..960adb5588 100644 --- a/src/lib/services/feature-service-potentially-stale.test.ts +++ b/src/lib/services/feature-service-potentially-stale.test.ts @@ -14,6 +14,7 @@ import type { IDependentFeaturesReadModel } from '../features/dependent-features import EventService from '../features/events/event-service'; import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store'; import type { DependentFeaturesService } from '../features/dependent-features/dependent-features-service'; +import type { IFeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model-type'; test('Should only store events for potentially stale on', async () => { expect.assertions(2); @@ -66,6 +67,7 @@ test('Should only store events for potentially stale on', async () => { {} as IPrivateProjectChecker, {} as IDependentFeaturesReadModel, {} as DependentFeaturesService, + {} as IFeatureLifecycleReadModel, ); await featureToggleService.updatePotentiallyStaleFeatures(); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 2eafd2f7fa..95060a8780 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -129,6 +129,8 @@ import { JobService } from '../features/scheduler/job-service'; import { JobStore } from '../features/scheduler/job-store'; import { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service'; import { createFakeFeatureLifecycleService } from '../features/feature-lifecycle/createFeatureLifecycle'; +import { FeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model'; +import { FakeFeatureLifecycleReadModel } from '../features/feature-lifecycle/fake-feature-lifecycle-read-model'; export const createServices = ( stores: IUnleashStores, @@ -158,6 +160,9 @@ export const createServices = ( const dependentFeaturesReadModel = db ? new DependentFeaturesReadModel(db) : new FakeDependentFeaturesReadModel(); + const featureLifecycleReadModel = db + ? new FeatureLifecycleReadModel(db) + : new FakeFeatureLifecycleReadModel(); const segmentReadModel = db ? new SegmentReadModel(db) : new FakeSegmentReadModel(); @@ -258,6 +263,7 @@ export const createServices = ( privateProjectChecker, dependentFeaturesReadModel, dependentFeaturesService, + featureLifecycleReadModel, ); const transactionalEnvironmentService = db ? withTransactional(createEnvironmentService(config), db) diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 6cc536148a..e5ba9fdd92 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -103,10 +103,10 @@ export interface FeatureToggleWithEnvironment extends FeatureToggle { environments: IEnvironmentDetail[]; } -export interface FeatureToggleWithDependencies - extends FeatureToggleWithEnvironment { +export interface FeatureToggleView extends FeatureToggleWithEnvironment { dependencies: IDependency[]; children: string[]; + lifecycle: IFeatureLifecycleStage | undefined; } // @deprecated @@ -153,6 +153,17 @@ export interface IDependency { enabled?: boolean; } +export type StageName = + | 'initial' + | 'pre-live' + | 'live' + | 'completed' + | 'archived'; +export interface IFeatureLifecycleStage { + stage: StageName; + enteredStageAt: Date; +} + export interface IFeatureDependency { feature: string; dependency: IDependency;