Skip to content

Commit

Permalink
feat: feature lifecycle complete and uncomplete (#6927)
Browse files Browse the repository at this point in the history
Creating a way to complete and uncomplete feature.
  • Loading branch information
sjaanus authored Apr 26, 2024
1 parent 31ab38e commit 78b9299
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 26 deletions.
23 changes: 23 additions & 0 deletions src/lib/features/feature-lifecycle/createFeatureLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import EventStore from '../../db/event-store';
import type { Db } from '../../db/db';
import { FeatureLifecycleStore } from './feature-lifecycle-store';
import EnvironmentStore from '../project-environments/environment-store';
import EventService from '../events/event-service';
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
import { EventEmitter } from 'stream';
import FeatureTagStore from '../../db/feature-tag-store';

export const createFeatureLifecycleService = (
db: Db,
Expand All @@ -16,12 +20,24 @@ export const createFeatureLifecycleService = (
const eventStore = new EventStore(db, getLogger);
const featureLifecycleStore = new FeatureLifecycleStore(db);
const environmentStore = new EnvironmentStore(db, eventBus, getLogger);
const featureTagStore = new FeatureTagStore(
db,
config.eventBus,
config.getLogger,
);
const eventService = new EventService(
{ eventStore, featureTagStore },
{ getLogger, eventBus: new EventEmitter() },
);
const featureLifecycleService = new FeatureLifecycleService(
{
eventStore,
featureLifecycleStore,
environmentStore,
},
{
eventService,
},
config,
);

Expand All @@ -37,12 +53,19 @@ export const createFakeFeatureLifecycleService = (config: IUnleashConfig) => {
const eventStore = new FakeEventStore();
const featureLifecycleStore = new FakeFeatureLifecycleStore();
const environmentStore = new FakeEnvironmentStore();
const eventService = new EventService(
{ eventStore, featureTagStore: new FakeFeatureTagStore() },
config,
);
const featureLifecycleService = new FeatureLifecycleService(
{
eventStore,
featureLifecycleStore,
environmentStore,
},
{
eventService,
},
config,
);

Expand Down
10 changes: 10 additions & 0 deletions src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,14 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
const lifecycle = await this.get(stage.feature);
return Boolean(lifecycle.find((s) => s.stage === stage.stage));
}

async deleteStage(stage: FeatureLifecycleStage): Promise<void> {
if (!this.lifecycles[stage.feature]) {
return;
}
const updatedStages = this.lifecycles[stage.feature].filter(
(s) => s.stage !== stage.stage,
);
this.lifecycles[stage.feature] = updatedStages;
}
}
77 changes: 77 additions & 0 deletions src/lib/features/feature-lifecycle/feature-lifecycle-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import {
type IUnleashServices,
NONE,
serializeDates,
UPDATE_FEATURE,
} from '../../types';
import type { OpenApiService } from '../../services';
import {
createResponseSchema,
emptyResponse,
featureLifecycleSchema,
type FeatureLifecycleSchema,
getStandardResponses,
} from '../../openapi';
import Controller from '../../routes/controller';
import type { Request, Response } from 'express';
import { NotFoundError } from '../../error';
import type { IAuthRequest } from '../../routes/unleash-types';

interface FeatureLifecycleParams {
projectId: string;
Expand Down Expand Up @@ -62,6 +65,46 @@ export default class FeatureLifecycleController extends Controller {
}),
],
});

this.route({
method: 'post',
path: `${PATH}/complete`,
handler: this.complete,
permission: UPDATE_FEATURE,
acceptAnyContentType: true,
middleware: [
openApiService.validPath({
tags: ['Unstable'],
summary: 'Set feature completed',
description: 'This will set the feature as completed.',
operationId: 'complete',
responses: {
200: emptyResponse,
...getStandardResponses(401, 403, 404),
},
}),
],
});

this.route({
method: 'post',
path: `${PATH}/uncomplete`,
handler: this.uncomplete,
permission: UPDATE_FEATURE,
acceptAnyContentType: true,
middleware: [
openApiService.validPath({
tags: ['Unstable'],
summary: 'Set feature uncompleted',
description: 'This will set the feature as uncompleted.',
operationId: 'uncomplete',
responses: {
200: emptyResponse,
...getStandardResponses(401, 403, 404),
},
}),
],
});
}

async getFeatureLifecycle(
Expand All @@ -83,4 +126,38 @@ export default class FeatureLifecycleController extends Controller {
serializeDates(result),
);
}

async complete(
req: IAuthRequest<FeatureLifecycleParams>,
res: Response,
): Promise<void> {
if (!this.flagResolver.isEnabled('featureLifecycle')) {
throw new NotFoundError('Feature lifecycle is disabled.');
}
const { featureName } = req.params;

await this.featureLifecycleService.featureCompleted(
featureName,
req.audit,
);

res.status(200).end();
}

async uncomplete(
req: IAuthRequest<FeatureLifecycleParams>,
res: Response,
): Promise<void> {
if (!this.flagResolver.isEnabled('featureLifecycle')) {
throw new NotFoundError('Feature lifecycle is disabled.');
}
const { featureName } = req.params;

await this.featureLifecycleService.featureUnCompleted(
featureName,
req.audit,
);

res.status(200).end();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ test('can insert and read lifecycle stages', async () => {
emitMetricsEvent('my-prod-environment');
emitMetricsEvent('my-another-prod-environment');

eventStore.emit(FEATURE_COMPLETED, { featureName });
await reachedStage('completed');
eventStore.emit(FEATURE_ARCHIVED, { featureName });
await reachedStage('archived');

Expand All @@ -80,7 +78,6 @@ test('can insert and read lifecycle stages', async () => {
{ 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) },
]);

Expand Down
40 changes: 32 additions & 8 deletions src/lib/features/feature-lifecycle/feature-lifecycle-service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
CLIENT_METRICS,
FEATURE_ARCHIVED,
FEATURE_COMPLETED,
FEATURE_CREATED,
FEATURE_REVIVED,
FeatureCompletedEvent,
FeatureUncompletedEvent,
type IAuditUser,
type IEnvironmentStore,
type IEventStore,
type IFlagResolver,
Expand All @@ -15,6 +17,7 @@ import type {
} from './feature-lifecycle-store-type';
import EventEmitter from 'events';
import type { Logger } from '../../logger';
import type EventService from '../events/event-service';
import type { ValidatedClientMetrics } from '../metrics/shared/schema';

export const STAGE_ENTERED = 'STAGE_ENTERED';
Expand All @@ -30,6 +33,8 @@ export class FeatureLifecycleService extends EventEmitter {

private eventBus: EventEmitter;

private eventService: EventService;

private logger: Logger;

constructor(
Expand All @@ -42,6 +47,11 @@ export class FeatureLifecycleService extends EventEmitter {
environmentStore: IEnvironmentStore;
featureLifecycleStore: IFeatureLifecycleStore;
},
{
eventService,
}: {
eventService: EventService;
},
{
flagResolver,
eventBus,
Expand All @@ -54,6 +64,7 @@ export class FeatureLifecycleService extends EventEmitter {
this.environmentStore = environmentStore;
this.flagResolver = flagResolver;
this.eventBus = eventBus;
this.eventService = eventService;
this.logger = getLogger(
'feature-lifecycle/feature-lifecycle-service.ts',
);
Expand Down Expand Up @@ -84,11 +95,6 @@ export class FeatureLifecycleService extends EventEmitter {
}
},
);
this.eventStore.on(FEATURE_COMPLETED, async (event) => {
await this.checkEnabled(() =>
this.featureCompleted(event.featureName),
);
});
this.eventStore.on(FEATURE_ARCHIVED, async (event) => {
await this.checkEnabled(() =>
this.featureArchived(event.featureName),
Expand Down Expand Up @@ -145,14 +151,32 @@ export class FeatureLifecycleService extends EventEmitter {
}
}

private async featureCompleted(feature: string) {
public async featureCompleted(feature: string, auditUser: IAuditUser) {
await this.featureLifecycleStore.insert([
{
feature,
stage: 'completed',
},
]);
this.emit(STAGE_ENTERED, { stage: 'completed' });
await this.eventService.storeEvent(
new FeatureCompletedEvent({
featureName: feature,
auditUser,
}),
);
}

public async featureUnCompleted(feature: string, auditUser: IAuditUser) {
await this.featureLifecycleStore.deleteStage({
feature,
stage: 'completed',
});
await this.eventService.storeEvent(
new FeatureUncompletedEvent({
featureName: feature,
auditUser,
}),
);
}

private async featureArchived(feature: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export interface IFeatureLifecycleStore {
get(feature: string): Promise<FeatureLifecycleView>;
stageExists(stage: FeatureLifecycleStage): Promise<boolean>;
delete(feature: string): Promise<void>;
deleteStage(stage: FeatureLifecycleStage): Promise<void>;
}
9 changes: 9 additions & 0 deletions src/lib/features/feature-lifecycle/feature-lifecycle-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
await this.db('feature_lifecycles').where({ feature }).del();
}

async deleteStage(stage: FeatureLifecycleStage): Promise<void> {
await this.db('feature_lifecycles')
.where({
stage: stage.stage,
feature: stage.feature,
})
.del();
}

async stageExists(stage: FeatureLifecycleStage): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS(SELECT 1 FROM feature_lifecycles WHERE stage = ? and feature = ?) AS present`,
Expand Down
Loading

0 comments on commit 78b9299

Please sign in to comment.