Skip to content

Commit

Permalink
feat: initial design for feature lifecycle service (#6777)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Apr 5, 2024
1 parent 0a247ab commit e2fabca
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 0 deletions.
22 changes: 22 additions & 0 deletions src/lib/features/feature-lifecycle/createFeatureLifecycle.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
29 changes: 29 additions & 0 deletions src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type {
FeatureLifecycleStage,
IFeatureLifecycleStore,
FeatureLifecycleView,
} from './feature-lifecycle-store-type';

export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
private lifecycles: Record<string, FeatureLifecycleView> = {};

async insert(featureLifecycleStage: FeatureLifecycleStage): Promise<void> {
const existing = await this.get(featureLifecycleStage.feature);
this.lifecycles[featureLifecycleStage.feature] = [
...existing,
{
stage: featureLifecycleStage.stage,
enteredStageAt: new Date(),
},
];
}

async get(feature: string): Promise<FeatureLifecycleView> {
return this.lifecycles[feature] || [];
}

async stageExists(stage: FeatureLifecycleStage): Promise<boolean> {
const lifecycle = await this.get(stage.feature);
return Boolean(lifecycle.find((s) => s.stage === stage.stage));
}
}
Original file line number Diff line number Diff line change
@@ -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) },
]);
});
93 changes: 93 additions & 0 deletions src/lib/features/feature-lifecycle/feature-lifecycle-service.ts
Original file line number Diff line number Diff line change
@@ -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<FeatureLifecycleView> {
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' });
}
}
24 changes: 24 additions & 0 deletions src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
get(feature: string): Promise<FeatureLifecycleView>;
stageExists(stage: FeatureLifecycleStage): Promise<boolean>;
}
1 change: 1 addition & 0 deletions src/lib/types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down

0 comments on commit e2fabca

Please sign in to comment.