From e2fabcafd4bbc9b8ec27559f09c34537e965f97a Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Fri, 5 Apr 2024 11:47:30 +0200 Subject: [PATCH] feat: initial design for feature lifecycle service (#6777) --- .../createFeatureLifecycle.ts | 22 +++++ .../fake-feature-lifecycle-store.ts | 29 ++++++ .../feature-lifecycle-service.test.ts | 63 +++++++++++++ .../feature-lifecycle-service.ts | 93 +++++++++++++++++++ .../feature-lifecycle-store-type.ts | 24 +++++ src/lib/types/events.ts | 1 + 6 files changed, 232 insertions(+) create mode 100644 src/lib/features/feature-lifecycle/createFeatureLifecycle.ts create mode 100644 src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-service.ts create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts diff --git a/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts new file mode 100644 index 0000000000..8f9d171eab --- /dev/null +++ b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts @@ -0,0 +1,22 @@ +import FakeEventStore from '../../../test/fixtures/fake-event-store'; +import { FakeFeatureLifecycleStore } from './fake-feature-lifecycle-store'; +import { FeatureLifecycleService } from './feature-lifecycle-service'; +import FakeEnvironmentStore from '../project-environments/fake-environment-store'; + +export const createFakeFeatureLifecycleService = () => { + const eventStore = new FakeEventStore(); + const featureLifecycleStore = new FakeFeatureLifecycleStore(); + const environmentStore = new FakeEnvironmentStore(); + const featureLifecycleService = new FeatureLifecycleService({ + eventStore, + featureLifecycleStore, + environmentStore, + }); + + return { + featureLifecycleService, + featureLifecycleStore, + eventStore, + environmentStore, + }; +}; diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts new file mode 100644 index 0000000000..dc1a74165e --- /dev/null +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts @@ -0,0 +1,29 @@ +import type { + FeatureLifecycleStage, + IFeatureLifecycleStore, + FeatureLifecycleView, +} from './feature-lifecycle-store-type'; + +export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { + private lifecycles: Record = {}; + + async insert(featureLifecycleStage: FeatureLifecycleStage): Promise { + const existing = await this.get(featureLifecycleStage.feature); + this.lifecycles[featureLifecycleStage.feature] = [ + ...existing, + { + stage: featureLifecycleStage.stage, + enteredStageAt: new Date(), + }, + ]; + } + + async get(feature: string): Promise { + return this.lifecycles[feature] || []; + } + + async stageExists(stage: FeatureLifecycleStage): Promise { + const lifecycle = await this.get(stage.feature); + return Boolean(lifecycle.find((s) => s.stage === stage.stage)); + } +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts new file mode 100644 index 0000000000..c52e06afd3 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.test.ts @@ -0,0 +1,63 @@ +import { + CLIENT_METRICS, + FEATURE_ARCHIVED, + FEATURE_COMPLETED, + FEATURE_CREATED, + type IEnvironment, +} from '../../types'; +import { createFakeFeatureLifecycleService } from './createFeatureLifecycle'; + +function ms(timeMs) { + return new Promise((resolve) => setTimeout(resolve, timeMs)); +} + +test('can insert and read lifecycle stages', async () => { + const { featureLifecycleService, eventStore, environmentStore } = + createFakeFeatureLifecycleService(); + const featureName = 'testFeature'; + async function emitMetricsEvent(environment: string) { + await eventStore.emit(CLIENT_METRICS, { featureName, environment }); + await ms(1); + } + await environmentStore.create({ + name: 'my-dev-environment', + type: 'development', + } as IEnvironment); + await environmentStore.create({ + name: 'my-prod-environment', + type: 'production', + } as IEnvironment); + await environmentStore.create({ + name: 'my-another-dev-environment', + type: 'development', + } as IEnvironment); + await environmentStore.create({ + name: 'my-another-prod-environment', + type: 'production', + } as IEnvironment); + featureLifecycleService.listen(); + + await eventStore.emit(FEATURE_CREATED, { featureName }); + + await emitMetricsEvent('unknown-environment'); + await emitMetricsEvent('my-dev-environment'); + await emitMetricsEvent('my-dev-environment'); + await emitMetricsEvent('my-another-dev-environment'); + await emitMetricsEvent('my-prod-environment'); + await emitMetricsEvent('my-prod-environment'); + await emitMetricsEvent('my-another-prod-environment'); + + await eventStore.emit(FEATURE_COMPLETED, { featureName }); + await eventStore.emit(FEATURE_ARCHIVED, { featureName }); + + const lifecycle = + await featureLifecycleService.getFeatureLifecycle(featureName); + + expect(lifecycle).toEqual([ + { stage: 'initial', enteredStageAt: expect.any(Date) }, + { stage: 'pre-live', enteredStageAt: expect.any(Date) }, + { stage: 'live', enteredStageAt: expect.any(Date) }, + { stage: 'completed', enteredStageAt: expect.any(Date) }, + { stage: 'archived', enteredStageAt: expect.any(Date) }, + ]); +}); diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts new file mode 100644 index 0000000000..f4b8b75c12 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-service.ts @@ -0,0 +1,93 @@ +import { + CLIENT_METRICS, + FEATURE_ARCHIVED, + FEATURE_COMPLETED, + FEATURE_CREATED, + type IEnvironmentStore, + type IEventStore, +} from '../../types'; +import type { + FeatureLifecycleView, + IFeatureLifecycleStore, +} from './feature-lifecycle-store-type'; + +export class FeatureLifecycleService { + private eventStore: IEventStore; + + private featureLifecycleStore: IFeatureLifecycleStore; + + private environmentStore: IEnvironmentStore; + + constructor({ + eventStore, + featureLifecycleStore, + environmentStore, + }: { + eventStore: IEventStore; + environmentStore: IEnvironmentStore; + featureLifecycleStore: IFeatureLifecycleStore; + }) { + this.eventStore = eventStore; + this.featureLifecycleStore = featureLifecycleStore; + this.environmentStore = environmentStore; + } + + listen() { + this.eventStore.on(FEATURE_CREATED, async (event) => { + await this.featureInitialized(event.featureName); + }); + this.eventStore.on(CLIENT_METRICS, async (event) => { + await this.featureReceivedMetrics( + event.featureName, + event.environment, + ); + }); + this.eventStore.on(FEATURE_COMPLETED, async (event) => { + await this.featureCompleted(event.featureName); + }); + this.eventStore.on(FEATURE_ARCHIVED, async (event) => { + await this.featureArchived(event.featureName); + }); + } + + async getFeatureLifecycle(feature: string): Promise { + return this.featureLifecycleStore.get(feature); + } + + async featureInitialized(feature: string) { + await this.featureLifecycleStore.insert({ feature, stage: 'initial' }); + } + + async stageReceivedMetrics(feature: string, stage: 'live' | 'pre-live') { + const stageExists = await this.featureLifecycleStore.stageExists({ + stage, + feature, + }); + if (!stageExists) { + await this.featureLifecycleStore.insert({ feature, stage }); + } + } + + async featureReceivedMetrics(feature: string, environment: string) { + const env = await this.environmentStore.get(environment); + if (!env) { + return; + } + if (env.type === 'production') { + await this.stageReceivedMetrics(feature, 'live'); + } else if (env.type === 'development') { + await this.stageReceivedMetrics(feature, 'pre-live'); + } + } + + async featureCompleted(feature: string) { + await this.featureLifecycleStore.insert({ + feature, + stage: 'completed', + }); + } + + async featureArchived(feature: string) { + await this.featureLifecycleStore.insert({ feature, stage: 'archived' }); + } +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts new file mode 100644 index 0000000000..cfdccd4794 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts @@ -0,0 +1,24 @@ +export type StageName = + | 'initial' + | 'pre-live' + | 'live' + | 'completed' + | 'archived'; + +export type FeatureLifecycleStage = { + feature: string; + stage: StageName; +}; + +export type FeatureLifecycleStageView = { + stage: StageName; + enteredStageAt: Date; +}; + +export type FeatureLifecycleView = FeatureLifecycleStageView[]; + +export interface IFeatureLifecycleStore { + insert(featureLifecycleStage: FeatureLifecycleStage): Promise; + get(feature: string): Promise; + stageExists(stage: FeatureLifecycleStage): Promise; +} diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 791ba79df1..2cd9706011 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -30,6 +30,7 @@ export const FEATURE_STRATEGY_REMOVE = 'feature-strategy-remove' as const; export const DROP_FEATURE_TAGS = 'drop-feature-tags' as const; export const FEATURE_UNTAGGED = 'feature-untagged' as const; export const FEATURE_STALE_ON = 'feature-stale-on' as const; +export const FEATURE_COMPLETED = 'feature-completed' as const; export const FEATURE_STALE_OFF = 'feature-stale-off' as const; export const DROP_FEATURES = 'drop-features' as const; export const FEATURE_ENVIRONMENT_ENABLED =