1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: return lifecycle state in feature overview (#6920)

This commit is contained in:
Mateusz Kwasniewski 2024-04-24 14:27:26 +02:00 committed by GitHub
parent 143327844d
commit f5061bc3ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 202 additions and 20 deletions

View File

@ -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);
}
}

View File

@ -0,0 +1,7 @@
import type { IFeatureLifecycleStage } from '../../types';
export interface IFeatureLifecycleReadModel {
findCurrentStage(
feature: string,
): Promise<IFeatureLifecycleStage | undefined>;
}

View File

@ -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);
}
}

View File

@ -5,10 +5,10 @@ import {
FEATURE_CREATED, FEATURE_CREATED,
type IEnvironment, type IEnvironment,
type IUnleashConfig, type IUnleashConfig,
type StageName,
} from '../../types'; } from '../../types';
import { createFakeFeatureLifecycleService } from './createFeatureLifecycle'; import { createFakeFeatureLifecycleService } from './createFeatureLifecycle';
import EventEmitter from 'events'; import EventEmitter from 'events';
import type { StageName } from './feature-lifecycle-store-type';
import { STAGE_ENTERED } from './feature-lifecycle-service'; import { STAGE_ENTERED } from './feature-lifecycle-service';
import noLoggerProvider from '../../../test/fixtures/no-logger'; import noLoggerProvider from '../../../test/fixtures/no-logger';

View File

@ -1,21 +1,11 @@
export type StageName = import type { IFeatureLifecycleStage, StageName } from '../../types';
| 'initial'
| 'pre-live'
| 'live'
| 'completed'
| 'archived';
export type FeatureLifecycleStage = { export type FeatureLifecycleStage = {
feature: string; feature: string;
stage: StageName; stage: StageName;
}; };
export type FeatureLifecycleStageView = { export type FeatureLifecycleView = IFeatureLifecycleStage[];
stage: StageName;
enteredStageAt: Date;
};
export type FeatureLifecycleView = FeatureLifecycleStageView[];
export interface IFeatureLifecycleStore { export interface IFeatureLifecycleStore {
insert(featureLifecycleStage: FeatureLifecycleStage): Promise<void>; insert(featureLifecycleStage: FeatureLifecycleStage): Promise<void>;

View File

@ -2,9 +2,9 @@ import type {
FeatureLifecycleStage, FeatureLifecycleStage,
IFeatureLifecycleStore, IFeatureLifecycleStore,
FeatureLifecycleView, FeatureLifecycleView,
StageName,
} from './feature-lifecycle-store-type'; } from './feature-lifecycle-store-type';
import type { Db } from '../../db/db'; import type { Db } from '../../db/db';
import type { StageName } from '../../types';
type DBType = { type DBType = {
feature: string; feature: string;

View File

@ -9,13 +9,13 @@ import {
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_CREATED, FEATURE_CREATED,
type IEventStore, type IEventStore,
type StageName,
} from '../../types'; } from '../../types';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import { import {
type FeatureLifecycleService, type FeatureLifecycleService,
STAGE_ENTERED, STAGE_ENTERED,
} from './feature-lifecycle-service'; } from './feature-lifecycle-service';
import type { StageName } from './feature-lifecycle-store-type';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; 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 () => { test('should return lifecycle stages', async () => {
await app.createFeature('my_feature_a'); await app.createFeature('my_feature_a');
eventStore.emit(FEATURE_CREATED, { featureName: 'my_feature_a' }); eventStore.emit(FEATURE_CREATED, { featureName: 'my_feature_a' });
await reachedStage('initial'); await reachedStage('initial');
await expectFeatureStage('initial');
eventBus.emit(CLIENT_METRICS, { eventBus.emit(CLIENT_METRICS, {
featureName: 'my_feature_a', featureName: 'my_feature_a',
environment: 'default', environment: 'default',
@ -87,6 +99,7 @@ test('should return lifecycle stages', async () => {
environment: 'non-existent', environment: 'non-existent',
}); });
await reachedStage('live'); await reachedStage('live');
await expectFeatureStage('live');
eventStore.emit(FEATURE_ARCHIVED, { featureName: 'my_feature_a' }); eventStore.emit(FEATURE_ARCHIVED, { featureName: 'my_feature_a' });
await reachedStage('archived'); await reachedStage('archived');
@ -97,4 +110,6 @@ test('should return lifecycle stages', async () => {
{ stage: 'live', enteredStageAt: expect.any(String) }, { stage: 'live', enteredStageAt: expect.any(String) },
{ stage: 'archived', enteredStageAt: expect.any(String) }, { stage: 'archived', enteredStageAt: expect.any(String) },
]); ]);
await expectFeatureStage('archived');
}); });

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -53,6 +53,8 @@ import {
} from '../dependent-features/createDependentFeaturesService'; } from '../dependent-features/createDependentFeaturesService';
import { createEventsService } from '../events/createEventsService'; import { createEventsService } from '../events/createEventsService';
import { EventEmitter } from 'stream'; 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 = ( export const createFeatureToggleService = (
db: Db, db: Db,
@ -122,6 +124,8 @@ export const createFeatureToggleService = (
const dependentFeaturesReadModel = new DependentFeaturesReadModel(db); const dependentFeaturesReadModel = new DependentFeaturesReadModel(db);
const featureLifecycleReadModel = new FeatureLifecycleReadModel(db);
const dependentFeaturesService = createDependentFeaturesService(config)(db); const dependentFeaturesService = createDependentFeaturesService(config)(db);
const featureToggleService = new FeatureToggleService( const featureToggleService = new FeatureToggleService(
@ -143,6 +147,7 @@ export const createFeatureToggleService = (
privateProjectChecker, privateProjectChecker,
dependentFeaturesReadModel, dependentFeaturesReadModel,
dependentFeaturesService, dependentFeaturesService,
featureLifecycleReadModel,
); );
return featureToggleService; return featureToggleService;
}; };
@ -185,6 +190,8 @@ export const createFakeFeatureToggleService = (
const fakePrivateProjectChecker = createFakePrivateProjectChecker(); const fakePrivateProjectChecker = createFakePrivateProjectChecker();
const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel(); const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel();
const dependentFeaturesService = createFakeDependentFeaturesService(config); const dependentFeaturesService = createFakeDependentFeaturesService(config);
const featureLifecycleReadModel = new FakeFeatureLifecycleReadModel();
const featureToggleService = new FeatureToggleService( const featureToggleService = new FeatureToggleService(
{ {
featureStrategiesStore, featureStrategiesStore,
@ -204,6 +211,7 @@ export const createFakeFeatureToggleService = (
fakePrivateProjectChecker, fakePrivateProjectChecker,
dependentFeaturesReadModel, dependentFeaturesReadModel,
dependentFeaturesService, dependentFeaturesService,
featureLifecycleReadModel,
); );
return featureToggleService; return featureToggleService;
}; };

View File

@ -15,7 +15,7 @@ import {
type FeatureToggle, type FeatureToggle,
type FeatureToggleDTO, type FeatureToggleDTO,
type FeatureToggleLegacy, type FeatureToggleLegacy,
type FeatureToggleWithDependencies, type FeatureToggleView,
type FeatureToggleWithEnvironment, type FeatureToggleWithEnvironment,
FeatureUpdatedEvent, FeatureUpdatedEvent,
FeatureVariantEvent, FeatureVariantEvent,
@ -24,6 +24,7 @@ import {
type IDependency, type IDependency,
type IFeatureEnvironmentInfo, type IFeatureEnvironmentInfo,
type IFeatureEnvironmentStore, type IFeatureEnvironmentStore,
type IFeatureLifecycleStage,
type IFeatureNaming, type IFeatureNaming,
type IFeatureOverview, type IFeatureOverview,
type IFeatureStrategy, type IFeatureStrategy,
@ -108,6 +109,7 @@ import ArchivedFeatureError from '../../error/archivedfeature-error';
import { FEATURES_CREATED_BY_PROCESSED } from '../../metric-events'; import { FEATURES_CREATED_BY_PROCESSED } from '../../metric-events';
import { allSettledWithRejection } from '../../util/allSettledWithRejection'; import { allSettledWithRejection } from '../../util/allSettledWithRejection';
import type EventEmitter from 'node:events'; import type EventEmitter from 'node:events';
import type { IFeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycle-read-model-type';
interface IFeatureContext { interface IFeatureContext {
featureName: string; featureName: string;
@ -173,6 +175,8 @@ class FeatureToggleService {
private dependentFeaturesReadModel: IDependentFeaturesReadModel; private dependentFeaturesReadModel: IDependentFeaturesReadModel;
private featureLifecycleReadModel: IFeatureLifecycleReadModel;
private dependentFeaturesService: DependentFeaturesService; private dependentFeaturesService: DependentFeaturesService;
private eventBus: EventEmitter; private eventBus: EventEmitter;
@ -210,6 +214,7 @@ class FeatureToggleService {
privateProjectChecker: IPrivateProjectChecker, privateProjectChecker: IPrivateProjectChecker,
dependentFeaturesReadModel: IDependentFeaturesReadModel, dependentFeaturesReadModel: IDependentFeaturesReadModel,
dependentFeaturesService: DependentFeaturesService, dependentFeaturesService: DependentFeaturesService,
featureLifecycleReadModel: IFeatureLifecycleReadModel,
) { ) {
this.logger = getLogger('services/feature-toggle-service.ts'); this.logger = getLogger('services/feature-toggle-service.ts');
this.featureStrategiesStore = featureStrategiesStore; this.featureStrategiesStore = featureStrategiesStore;
@ -228,6 +233,7 @@ class FeatureToggleService {
this.privateProjectChecker = privateProjectChecker; this.privateProjectChecker = privateProjectChecker;
this.dependentFeaturesReadModel = dependentFeaturesReadModel; this.dependentFeaturesReadModel = dependentFeaturesReadModel;
this.dependentFeaturesService = dependentFeaturesService; this.dependentFeaturesService = dependentFeaturesService;
this.featureLifecycleReadModel = featureLifecycleReadModel;
this.eventBus = eventBus; this.eventBus = eventBus;
} }
@ -980,7 +986,7 @@ class FeatureToggleService {
projectId, projectId,
environmentVariants, environmentVariants,
userId, userId,
}: IGetFeatureParams): Promise<FeatureToggleWithDependencies> { }: IGetFeatureParams): Promise<FeatureToggleView> {
if (projectId) { if (projectId) {
await this.validateFeatureBelongsToProject({ await this.validateFeatureBelongsToProject({
featureName, featureName,
@ -990,9 +996,11 @@ class FeatureToggleService {
let dependencies: IDependency[] = []; let dependencies: IDependency[] = [];
let children: string[] = []; 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.getParents(featureName),
this.dependentFeaturesReadModel.getChildren([featureName]), this.dependentFeaturesReadModel.getChildren([featureName]),
this.featureLifecycleReadModel.findCurrentStage(featureName),
]); ]);
if (environmentVariants) { if (environmentVariants) {
@ -1006,6 +1014,7 @@ class FeatureToggleService {
...result, ...result,
dependencies, dependencies,
children, children,
lifecycle,
}; };
} else { } else {
const result = const result =
@ -1018,6 +1027,7 @@ class FeatureToggleService {
...result, ...result,
dependencies, dependencies,
children, children,
lifecycle,
}; };
} }
} }

View File

@ -130,6 +130,32 @@ export const featureSchema = {
example: 'some-feature', 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: { dependencies: {
type: 'array', type: 'array',
items: { items: {

View File

@ -14,6 +14,7 @@ import type { IDependentFeaturesReadModel } from '../features/dependent-features
import EventService from '../features/events/event-service'; import EventService from '../features/events/event-service';
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store'; import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
import type { DependentFeaturesService } from '../features/dependent-features/dependent-features-service'; 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 () => { test('Should only store events for potentially stale on', async () => {
expect.assertions(2); expect.assertions(2);
@ -66,6 +67,7 @@ test('Should only store events for potentially stale on', async () => {
{} as IPrivateProjectChecker, {} as IPrivateProjectChecker,
{} as IDependentFeaturesReadModel, {} as IDependentFeaturesReadModel,
{} as DependentFeaturesService, {} as DependentFeaturesService,
{} as IFeatureLifecycleReadModel,
); );
await featureToggleService.updatePotentiallyStaleFeatures(); await featureToggleService.updatePotentiallyStaleFeatures();

View File

@ -129,6 +129,8 @@ import { JobService } from '../features/scheduler/job-service';
import { JobStore } from '../features/scheduler/job-store'; import { JobStore } from '../features/scheduler/job-store';
import { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service'; import { FeatureLifecycleService } from '../features/feature-lifecycle/feature-lifecycle-service';
import { createFakeFeatureLifecycleService } from '../features/feature-lifecycle/createFeatureLifecycle'; 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 = ( export const createServices = (
stores: IUnleashStores, stores: IUnleashStores,
@ -158,6 +160,9 @@ export const createServices = (
const dependentFeaturesReadModel = db const dependentFeaturesReadModel = db
? new DependentFeaturesReadModel(db) ? new DependentFeaturesReadModel(db)
: new FakeDependentFeaturesReadModel(); : new FakeDependentFeaturesReadModel();
const featureLifecycleReadModel = db
? new FeatureLifecycleReadModel(db)
: new FakeFeatureLifecycleReadModel();
const segmentReadModel = db const segmentReadModel = db
? new SegmentReadModel(db) ? new SegmentReadModel(db)
: new FakeSegmentReadModel(); : new FakeSegmentReadModel();
@ -258,6 +263,7 @@ export const createServices = (
privateProjectChecker, privateProjectChecker,
dependentFeaturesReadModel, dependentFeaturesReadModel,
dependentFeaturesService, dependentFeaturesService,
featureLifecycleReadModel,
); );
const transactionalEnvironmentService = db const transactionalEnvironmentService = db
? withTransactional(createEnvironmentService(config), db) ? withTransactional(createEnvironmentService(config), db)

View File

@ -103,10 +103,10 @@ export interface FeatureToggleWithEnvironment extends FeatureToggle {
environments: IEnvironmentDetail[]; environments: IEnvironmentDetail[];
} }
export interface FeatureToggleWithDependencies export interface FeatureToggleView extends FeatureToggleWithEnvironment {
extends FeatureToggleWithEnvironment {
dependencies: IDependency[]; dependencies: IDependency[];
children: string[]; children: string[];
lifecycle: IFeatureLifecycleStage | undefined;
} }
// @deprecated // @deprecated
@ -153,6 +153,17 @@ export interface IDependency {
enabled?: boolean; enabled?: boolean;
} }
export type StageName =
| 'initial'
| 'pre-live'
| 'live'
| 'completed'
| 'archived';
export interface IFeatureLifecycleStage {
stage: StageName;
enteredStageAt: Date;
}
export interface IFeatureDependency { export interface IFeatureDependency {
feature: string; feature: string;
dependency: IDependency; dependency: IDependency;