Skip to content

Commit

Permalink
feat: return lifecycle state in feature overview (#6920)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Apr 24, 2024
1 parent 1433278 commit f5061bc
Show file tree
Hide file tree
Showing 15 changed files with 202 additions and 20 deletions.
Original file line number Diff line number Diff line change
@@ -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<IFeatureLifecycleStage | undefined> {
return Promise.resolve(undefined);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { IFeatureLifecycleStage } from '../../types';

export interface IFeatureLifecycleReadModel {
findCurrentStage(
feature: string,
): Promise<IFeatureLifecycleStage | undefined>;
}
33 changes: 33 additions & 0 deletions src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts
Original file line number Diff line number Diff line change
@@ -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<IFeatureLifecycleStage | undefined> {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
14 changes: 2 additions & 12 deletions src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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');

Expand All @@ -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');
});
39 changes: 39 additions & 0 deletions src/lib/features/feature-lifecycle/get-current-stage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 23 additions & 0 deletions src/lib/features/feature-lifecycle/get-current-stage.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions src/lib/features/feature-toggle/createFeatureToggleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -143,6 +147,7 @@ export const createFeatureToggleService = (
privateProjectChecker,
dependentFeaturesReadModel,
dependentFeaturesService,
featureLifecycleReadModel,
);
return featureToggleService;
};
Expand Down Expand Up @@ -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,
Expand All @@ -204,6 +211,7 @@ export const createFakeFeatureToggleService = (
fakePrivateProjectChecker,
dependentFeaturesReadModel,
dependentFeaturesService,
featureLifecycleReadModel,
);
return featureToggleService;
};
16 changes: 13 additions & 3 deletions src/lib/features/feature-toggle/feature-toggle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
type FeatureToggle,
type FeatureToggleDTO,
type FeatureToggleLegacy,
type FeatureToggleWithDependencies,
type FeatureToggleView,
type FeatureToggleWithEnvironment,
FeatureUpdatedEvent,
FeatureVariantEvent,
Expand All @@ -24,6 +24,7 @@ import {
type IDependency,
type IFeatureEnvironmentInfo,
type IFeatureEnvironmentStore,
type IFeatureLifecycleStage,
type IFeatureNaming,
type IFeatureOverview,
type IFeatureStrategy,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -173,6 +175,8 @@ class FeatureToggleService {

private dependentFeaturesReadModel: IDependentFeaturesReadModel;

private featureLifecycleReadModel: IFeatureLifecycleReadModel;

private dependentFeaturesService: DependentFeaturesService;

private eventBus: EventEmitter;
Expand Down Expand Up @@ -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;
Expand All @@ -228,6 +233,7 @@ class FeatureToggleService {
this.privateProjectChecker = privateProjectChecker;
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
this.dependentFeaturesService = dependentFeaturesService;
this.featureLifecycleReadModel = featureLifecycleReadModel;
this.eventBus = eventBus;
}

Expand Down Expand Up @@ -980,7 +986,7 @@ class FeatureToggleService {
projectId,
environmentVariants,
userId,
}: IGetFeatureParams): Promise<FeatureToggleWithDependencies> {
}: IGetFeatureParams): Promise<FeatureToggleView> {
if (projectId) {
await this.validateFeatureBelongsToProject({
featureName,
Expand All @@ -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) {
Expand All @@ -1006,6 +1014,7 @@ class FeatureToggleService {
...result,
dependencies,
children,
lifecycle,
};
} else {
const result =
Expand All @@ -1018,6 +1027,7 @@ class FeatureToggleService {
...result,
dependencies,
children,
lifecycle,
};
}
}
Expand Down
26 changes: 26 additions & 0 deletions src/lib/openapi/spec/feature-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/services/feature-service-potentially-stale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading

0 comments on commit f5061bc

Please sign in to comment.