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,
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';

View File

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

View File

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

View File

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

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

View File

@ -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<FeatureToggleWithDependencies> {
}: IGetFeatureParams): Promise<FeatureToggleView> {
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,
};
}
}

View File

@ -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: {

View File

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

View File

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

View File

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