mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-12 01:17:04 +02:00
refactor: move release plan stores to OSS (#9747)
This commit is contained in:
parent
e9ec1db3b7
commit
b2471633b4
@ -168,6 +168,7 @@
|
||||
"stoppable": "^1.1.0",
|
||||
"ts-toolbelt": "^9.6.0",
|
||||
"type-is": "^1.6.18",
|
||||
"ulidx": "^2.4.1",
|
||||
"unleash-client": "^6.6.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
|
@ -1,4 +1,11 @@
|
||||
import type { IUnleashConfig, IUnleashStores } from '../types';
|
||||
import {
|
||||
type IUnleashConfig,
|
||||
type IUnleashStores,
|
||||
ReleasePlanMilestoneStore,
|
||||
ReleasePlanMilestoneStrategyStore,
|
||||
ReleasePlanStore,
|
||||
ReleasePlanTemplateStore,
|
||||
} from '../types';
|
||||
|
||||
import EventStore from '../features/events/event-store';
|
||||
import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store';
|
||||
@ -191,6 +198,11 @@ export const createStores = (
|
||||
uniqueConnectionReadModel: new UniqueConnectionReadModel(
|
||||
new UniqueConnectionStore(db),
|
||||
),
|
||||
releasePlanStore: new ReleasePlanStore(db, config),
|
||||
releasePlanTemplateStore: new ReleasePlanTemplateStore(db, config),
|
||||
releasePlanMilestoneStore: new ReleasePlanMilestoneStore(db, config),
|
||||
releasePlanMilestoneStrategyStore:
|
||||
new ReleasePlanMilestoneStrategyStore(db, config),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1426,32 +1426,50 @@ test('should return change request ids per environment', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
const createReleasePlan = async ({
|
||||
feature,
|
||||
environment,
|
||||
planId,
|
||||
}: { feature: string; environment: string; planId: string }) => {
|
||||
await db.rawDatabase('release_plan_definitions').insert({
|
||||
id: planId,
|
||||
discriminator: 'plan',
|
||||
const createReleasePlan = async (
|
||||
{
|
||||
feature,
|
||||
environment,
|
||||
planId,
|
||||
}: { feature: string; environment: string; planId: string },
|
||||
milestones: {
|
||||
name: string;
|
||||
order: number;
|
||||
}[],
|
||||
) => {
|
||||
const result = await db.stores.releasePlanTemplateStore.insert({
|
||||
name: 'plan',
|
||||
feature_name: feature,
|
||||
environment: environment,
|
||||
created_by_user_id: 1,
|
||||
createdByUserId: 1,
|
||||
discriminator: 'template',
|
||||
});
|
||||
const releasePlan = await db.stores.releasePlanStore.insert({
|
||||
id: planId,
|
||||
name: 'plan',
|
||||
featureName: feature,
|
||||
environment: environment,
|
||||
createdByUserId: 1,
|
||||
releasePlanTemplateId: result.id,
|
||||
});
|
||||
const milestoneResults = await Promise.all(
|
||||
milestones.map((milestone) =>
|
||||
createMilestone({
|
||||
...milestone,
|
||||
planId: releasePlan.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { releasePlan, milestones: milestoneResults };
|
||||
};
|
||||
|
||||
const createMilestone = async ({
|
||||
id,
|
||||
name,
|
||||
order,
|
||||
planId,
|
||||
}: { id: string; name: string; order: number; planId: string }) => {
|
||||
await db.rawDatabase('milestones').insert({
|
||||
id,
|
||||
}: { name: string; order: number; planId: string }) => {
|
||||
return db.stores.releasePlanMilestoneStore.insert({
|
||||
name,
|
||||
sort_order: order,
|
||||
release_plan_definition_id: planId,
|
||||
sortOrder: order,
|
||||
releasePlanDefinitionId: planId,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1459,39 +1477,39 @@ const activateMilestone = async ({
|
||||
planId,
|
||||
milestoneId,
|
||||
}: { planId: string; milestoneId: string }) => {
|
||||
await db
|
||||
.rawDatabase('release_plan_definitions')
|
||||
.update({ active_milestone_id: milestoneId })
|
||||
.where('id', planId);
|
||||
await db.stores.releasePlanStore.update(planId, {
|
||||
activeMilestoneId: milestoneId,
|
||||
});
|
||||
};
|
||||
|
||||
test('should return release plan milestones', async () => {
|
||||
await app.createFeature('my_feature_a');
|
||||
|
||||
await createReleasePlan({
|
||||
feature: 'my_feature_a',
|
||||
environment: 'development',
|
||||
planId: 'plan0',
|
||||
const { releasePlan, milestones } = await createReleasePlan(
|
||||
{
|
||||
feature: 'my_feature_a',
|
||||
environment: 'development',
|
||||
planId: 'plan0',
|
||||
},
|
||||
[
|
||||
{
|
||||
name: 'Milestone 1',
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
name: 'Milestone 2',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
name: 'Milestone 3',
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
);
|
||||
await activateMilestone({
|
||||
planId: releasePlan.id,
|
||||
milestoneId: milestones[1].id,
|
||||
});
|
||||
await createMilestone({
|
||||
id: 'milestone0',
|
||||
name: 'Milestone 1',
|
||||
order: 0,
|
||||
planId: 'plan0',
|
||||
});
|
||||
await createMilestone({
|
||||
id: 'milestone1',
|
||||
name: 'Milestone 2',
|
||||
order: 1,
|
||||
planId: 'plan0',
|
||||
});
|
||||
await createMilestone({
|
||||
id: 'milestone3',
|
||||
name: 'Milestone 3',
|
||||
order: 2,
|
||||
planId: 'plan0',
|
||||
});
|
||||
await activateMilestone({ planId: 'plan0', milestoneId: 'milestone1' });
|
||||
|
||||
const { body } = await searchFeatures({});
|
||||
|
||||
|
@ -0,0 +1,48 @@
|
||||
import { ulid } from 'ulidx';
|
||||
import type { ReleasePlanMilestone } from './release-plan-milestone';
|
||||
import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store';
|
||||
import type { Row } from '../../db/crud/row-type';
|
||||
import type { Db } from '../../db/db';
|
||||
|
||||
const TABLE = 'milestones';
|
||||
|
||||
const fromRow = (row: any): ReleasePlanMilestone => {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
sortOrder: row.sort_order,
|
||||
releasePlanDefinitionId: row.release_plan_definition_id,
|
||||
strategies: [],
|
||||
};
|
||||
};
|
||||
|
||||
export type ReleasePlanMilestoneWriteModel = Omit<ReleasePlanMilestone, 'id'>;
|
||||
|
||||
export class ReleasePlanMilestoneStore extends CRUDStore<
|
||||
ReleasePlanMilestone,
|
||||
ReleasePlanMilestoneWriteModel,
|
||||
Row<ReleasePlanMilestone>,
|
||||
ReleasePlanMilestone,
|
||||
string
|
||||
> {
|
||||
constructor(db: Db, config: CrudStoreConfig) {
|
||||
super(TABLE, db, config);
|
||||
}
|
||||
|
||||
override async insert(
|
||||
item: ReleasePlanMilestoneWriteModel,
|
||||
): Promise<ReleasePlanMilestone> {
|
||||
const row = this.toRow(item);
|
||||
row.id = ulid();
|
||||
await this.db(TABLE).insert(row);
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
async deleteAllConnectedToReleasePlanTemplate(
|
||||
templateId: string,
|
||||
): Promise<void> {
|
||||
await this.db(TABLE)
|
||||
.where('release_plan_definition_id', templateId)
|
||||
.delete();
|
||||
}
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
import { ulid } from 'ulidx';
|
||||
import type { ReleasePlanMilestoneStrategy } from './release-plan-milestone-strategy';
|
||||
import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store';
|
||||
import type { Row } from '../../db/crud/row-type';
|
||||
import type { Db } from '../../db/db';
|
||||
|
||||
const TABLE = 'milestone_strategies';
|
||||
|
||||
export type ReleasePlanMilestoneStrategyWriteModel = Omit<
|
||||
ReleasePlanMilestoneStrategy,
|
||||
'id'
|
||||
>;
|
||||
|
||||
const fromRow = (row: any): ReleasePlanMilestoneStrategy => {
|
||||
return {
|
||||
id: row.id,
|
||||
milestoneId: row.milestone_id,
|
||||
sortOrder: row.sort_order,
|
||||
title: row.title,
|
||||
strategyName: row.strategy_name,
|
||||
parameters: row.parameters,
|
||||
constraints: JSON.parse(row.constraints),
|
||||
variants: JSON.parse(row.variants),
|
||||
segments: [],
|
||||
};
|
||||
};
|
||||
|
||||
const toRow = (item: ReleasePlanMilestoneStrategyWriteModel) => {
|
||||
return {
|
||||
id: ulid(),
|
||||
milestone_id: item.milestoneId,
|
||||
sort_order: item.sortOrder,
|
||||
title: item.title,
|
||||
strategy_name: item.strategyName,
|
||||
parameters: item.parameters ?? {},
|
||||
constraints: JSON.stringify(item.constraints ?? []),
|
||||
variants: JSON.stringify(item.variants ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
const toUpdateRow = (item: ReleasePlanMilestoneStrategyWriteModel) => {
|
||||
return {
|
||||
milestone_id: item.milestoneId,
|
||||
sort_order: item.sortOrder,
|
||||
title: item.title,
|
||||
strategy_name: item.strategyName,
|
||||
parameters: item.parameters ?? {},
|
||||
constraints: JSON.stringify(item.constraints ?? []),
|
||||
variants: JSON.stringify(item.variants ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
export class ReleasePlanMilestoneStrategyStore extends CRUDStore<
|
||||
ReleasePlanMilestoneStrategy,
|
||||
ReleasePlanMilestoneStrategyWriteModel,
|
||||
Row<ReleasePlanMilestoneStrategy>,
|
||||
ReleasePlanMilestoneStrategy,
|
||||
string
|
||||
> {
|
||||
constructor(db: Db, config: CrudStoreConfig) {
|
||||
super(TABLE, db, config);
|
||||
}
|
||||
|
||||
override async insert({
|
||||
segments,
|
||||
...strategy
|
||||
}: ReleasePlanMilestoneStrategyWriteModel): Promise<ReleasePlanMilestoneStrategy> {
|
||||
const row = toRow(strategy);
|
||||
await this.db(TABLE).insert(row);
|
||||
segments?.forEach(async (segmentId) => {
|
||||
const segmentRow = {
|
||||
milestone_strategy_id: row.id,
|
||||
segment_id: segmentId,
|
||||
};
|
||||
await this.db('milestone_strategy_segments').insert(segmentRow);
|
||||
});
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
private async updateStrategy(
|
||||
strategyId: string,
|
||||
{ segments, ...strategy }: ReleasePlanMilestoneStrategyWriteModel,
|
||||
): Promise<ReleasePlanMilestoneStrategy> {
|
||||
const rows = await this.db(this.tableName)
|
||||
.where({ id: strategyId })
|
||||
.update(toUpdateRow(strategy))
|
||||
.returning('*');
|
||||
return this.fromRow(rows[0]) as ReleasePlanMilestoneStrategy;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
strategyId: string,
|
||||
{ segments, ...strategy }: ReleasePlanMilestoneStrategyWriteModel,
|
||||
): Promise<ReleasePlanMilestoneStrategy> {
|
||||
const releasePlanMilestoneStrategy = await this.updateStrategy(
|
||||
strategyId,
|
||||
strategy,
|
||||
);
|
||||
// now delete
|
||||
await this.db('milestone_strategy_segments')
|
||||
.where('milestone_strategy_id', strategyId)
|
||||
.delete();
|
||||
for (const segmentId of segments ?? []) {
|
||||
const segmentRow = {
|
||||
milestone_strategy_id: strategyId,
|
||||
segment_id: segmentId,
|
||||
};
|
||||
await this.db('milestone_strategy_segments').insert(segmentRow);
|
||||
}
|
||||
return releasePlanMilestoneStrategy;
|
||||
}
|
||||
|
||||
async deleteStrategiesForMilestone(milestoneId: string): Promise<void> {
|
||||
await this.db('milestone_strategies')
|
||||
.where('milestone_id', milestoneId)
|
||||
.delete();
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import type { IFeatureStrategy } from '../../types';
|
||||
|
||||
export interface ReleasePlanMilestoneStrategy
|
||||
extends Partial<
|
||||
Pick<
|
||||
IFeatureStrategy,
|
||||
'title' | 'parameters' | 'constraints' | 'variants' | 'segments'
|
||||
>
|
||||
> {
|
||||
id: string;
|
||||
milestoneId: string;
|
||||
sortOrder: number;
|
||||
strategyName: string;
|
||||
}
|
9
src/lib/features/release-plans/release-plan-milestone.ts
Normal file
9
src/lib/features/release-plans/release-plan-milestone.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { ReleasePlanMilestoneStrategy } from './release-plan-milestone-strategy';
|
||||
|
||||
export interface ReleasePlanMilestone {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
releasePlanDefinitionId: string;
|
||||
strategies?: ReleasePlanMilestoneStrategy[];
|
||||
}
|
369
src/lib/features/release-plans/release-plan-store.ts
Normal file
369
src/lib/features/release-plans/release-plan-store.ts
Normal file
@ -0,0 +1,369 @@
|
||||
import type { ReleasePlan } from './release-plan';
|
||||
import type { ReleasePlanMilestoneStrategy } from './release-plan-milestone-strategy';
|
||||
import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store';
|
||||
import type { Row } from '../../db/crud/row-type';
|
||||
import type { Db } from '../../db/db';
|
||||
import { defaultToRow } from '../../db/crud/default-mappings';
|
||||
import type { IAuditUser } from '../../types';
|
||||
|
||||
const TABLE = 'release_plan_definitions';
|
||||
|
||||
type ReleasePlanWriteModel = Omit<
|
||||
ReleasePlan,
|
||||
'discriminator' | 'createdAt' | 'milestones'
|
||||
>;
|
||||
|
||||
const selectColumns = [
|
||||
'rpd.id AS planId',
|
||||
'rpd.discriminator AS planDiscriminator',
|
||||
'rpd.name AS planName',
|
||||
'rpd.description as planDescription',
|
||||
'rpd.feature_name as planFeatureName',
|
||||
'rpd.environment as planEnvironment',
|
||||
'rpd.created_by_user_id as planCreatedByUserId',
|
||||
'rpd.created_at as planCreatedAt',
|
||||
'rpd.active_milestone_id as planActiveMilestoneId',
|
||||
'rpd.release_plan_template_id as planTemplateId',
|
||||
'mi.id AS milestoneId',
|
||||
'mi.name AS milestoneName',
|
||||
'mi.sort_order AS milestoneSortOrder',
|
||||
'ms.id AS strategyId',
|
||||
'ms.sort_order AS strategySortOrder',
|
||||
'ms.title AS strategyTitle',
|
||||
'ms.strategy_name AS strategyName',
|
||||
'ms.parameters AS strategyParameters',
|
||||
'ms.constraints AS strategyConstraints',
|
||||
'ms.variants AS strategyVariants',
|
||||
'mss.segment_id AS segmentId',
|
||||
];
|
||||
const processReleasePlanRows = (templateRows): ReleasePlan[] =>
|
||||
templateRows.reduce(
|
||||
(
|
||||
acc: ReleasePlan[],
|
||||
{
|
||||
planId,
|
||||
planDiscriminator,
|
||||
planName,
|
||||
planDescription,
|
||||
planFeatureName,
|
||||
planEnvironment,
|
||||
planCreatedByUserId,
|
||||
planCreatedAt,
|
||||
planActiveMilestoneId,
|
||||
planTemplateId,
|
||||
milestoneId,
|
||||
milestoneName,
|
||||
milestoneSortOrder,
|
||||
strategyId,
|
||||
strategySortOrder,
|
||||
strategyTitle,
|
||||
strategyName,
|
||||
strategyParameters,
|
||||
strategyConstraints,
|
||||
strategyVariants,
|
||||
segmentId,
|
||||
},
|
||||
) => {
|
||||
let plan = acc.find(({ id }) => id === planId);
|
||||
|
||||
if (!plan) {
|
||||
plan = {
|
||||
id: planId,
|
||||
discriminator: planDiscriminator,
|
||||
name: planName,
|
||||
description: planDescription,
|
||||
featureName: planFeatureName,
|
||||
environment: planEnvironment,
|
||||
createdByUserId: planCreatedByUserId,
|
||||
createdAt: planCreatedAt,
|
||||
activeMilestoneId: planActiveMilestoneId,
|
||||
releasePlanTemplateId: planTemplateId,
|
||||
milestones: [],
|
||||
};
|
||||
acc.push(plan);
|
||||
}
|
||||
|
||||
if (!milestoneId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
let milestone = plan.milestones.find(
|
||||
({ id }) => id === milestoneId,
|
||||
);
|
||||
if (!milestone) {
|
||||
milestone = {
|
||||
id: milestoneId,
|
||||
name: milestoneName,
|
||||
sortOrder: milestoneSortOrder,
|
||||
strategies: [],
|
||||
releasePlanDefinitionId: planId,
|
||||
};
|
||||
plan.milestones.push(milestone);
|
||||
}
|
||||
|
||||
if (!strategyId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
let strategy = milestone.strategies?.find(
|
||||
({ id }) => id === strategyId,
|
||||
);
|
||||
|
||||
if (!strategy) {
|
||||
strategy = {
|
||||
id: strategyId,
|
||||
milestoneId: milestoneId,
|
||||
sortOrder: strategySortOrder,
|
||||
title: strategyTitle,
|
||||
strategyName: strategyName,
|
||||
parameters: strategyParameters ?? {},
|
||||
constraints: strategyConstraints,
|
||||
variants: strategyVariants ?? [],
|
||||
segments: [],
|
||||
};
|
||||
milestone.strategies = [
|
||||
...(milestone.strategies || []),
|
||||
strategy,
|
||||
];
|
||||
}
|
||||
|
||||
if (segmentId) {
|
||||
strategy.segments = [...(strategy.segments || []), segmentId];
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const processMilestoneStrategyRows = (
|
||||
rows: any,
|
||||
): ReleasePlanMilestoneStrategy[] => {
|
||||
return rows.map((row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
sortOrder: row.sort_order,
|
||||
title: row.title,
|
||||
strategyName: row.strategy_name,
|
||||
parameters: row.parameters,
|
||||
constraints: row.constraints,
|
||||
variants: row.variants,
|
||||
segments: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export class ReleasePlanStore extends CRUDStore<
|
||||
ReleasePlan,
|
||||
ReleasePlanWriteModel,
|
||||
Row<ReleasePlan>,
|
||||
ReleasePlan,
|
||||
string
|
||||
> {
|
||||
constructor(db: Db, config: CrudStoreConfig) {
|
||||
super(TABLE, db, config, {
|
||||
fromRow: (row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
discriminator: row.discriminator,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
featureName: row.feature_name,
|
||||
environment: row.environment,
|
||||
createdByUserId: row.created_by_user_id,
|
||||
createdAt: row.created_at,
|
||||
activeMilestoneId: row.active_milestone_id,
|
||||
releasePlanTemplateId: row.release_plan_template_id,
|
||||
milestones: [],
|
||||
};
|
||||
},
|
||||
toRow: (item) => ({
|
||||
...defaultToRow(item),
|
||||
discriminator: 'plan',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
override async count(
|
||||
query?: Partial<ReleasePlanWriteModel>,
|
||||
): Promise<number> {
|
||||
let countQuery = this.db(this.tableName)
|
||||
.where('discriminator', 'plan')
|
||||
.count('*');
|
||||
if (query) {
|
||||
countQuery = countQuery.where(this.toRow(query));
|
||||
}
|
||||
const { count } = (await countQuery.first()) ?? { count: 0 };
|
||||
return Number(count);
|
||||
}
|
||||
|
||||
async getByFeatureFlagEnvironmentAndPlanId(
|
||||
featureName: string,
|
||||
environment: string,
|
||||
planId: string,
|
||||
): Promise<ReleasePlan> {
|
||||
const endTimer = this.timer('getByFeatureFlagEnvironmentAndPlanId');
|
||||
const rows = await this.db(`${this.tableName} AS rpd`)
|
||||
.where('rpd.discriminator', 'plan')
|
||||
.andWhere('rpd.feature_name', featureName)
|
||||
.andWhere('rpd.environment', environment)
|
||||
.andWhere('rpd.id', planId)
|
||||
.leftJoin(
|
||||
'milestones AS mi',
|
||||
'mi.release_plan_definition_id',
|
||||
'rpd.id',
|
||||
)
|
||||
.leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id')
|
||||
.leftJoin(
|
||||
'milestone_strategy_segments AS mss',
|
||||
'mss.milestone_strategy_id',
|
||||
'ms.id',
|
||||
)
|
||||
.orderBy('mi.sort_order', 'asc')
|
||||
.orderBy('ms.sort_order', 'asc')
|
||||
.select(selectColumns);
|
||||
endTimer();
|
||||
return processReleasePlanRows(rows)[0];
|
||||
}
|
||||
|
||||
async getByPlanId(planId: string): Promise<ReleasePlan | undefined> {
|
||||
const endTimer = this.timer('getByPlanId');
|
||||
const rows = await this.db(`${this.tableName} AS rpd`)
|
||||
.where('rpd.discriminator', 'plan')
|
||||
.andWhere('rpd.id', planId)
|
||||
.leftJoin(
|
||||
'milestones AS mi',
|
||||
'mi.release_plan_definition_id',
|
||||
'rpd.id',
|
||||
)
|
||||
.leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id')
|
||||
.leftJoin(
|
||||
'milestone_strategy_segments AS mss',
|
||||
'mss.milestone_strategy_id',
|
||||
'ms.id',
|
||||
)
|
||||
.orderBy('mi.sort_order', 'asc')
|
||||
.orderBy('ms.sort_order', 'asc')
|
||||
.select(selectColumns);
|
||||
endTimer();
|
||||
const releasePlans = processReleasePlanRows(rows);
|
||||
if (releasePlans.length === 0) {
|
||||
return;
|
||||
}
|
||||
return releasePlans[0];
|
||||
}
|
||||
async getByFeatureFlagAndEnvironment(
|
||||
featureName: string,
|
||||
environment: string,
|
||||
): Promise<ReleasePlan[]> {
|
||||
const endTimer = this.timer('getByFeatureFlagAndEnvironment');
|
||||
const planRows = await this.db(`${this.tableName} AS rpd`)
|
||||
.where('rpd.discriminator', 'plan')
|
||||
.andWhere('rpd.feature_name', featureName)
|
||||
.andWhere('rpd.environment', environment)
|
||||
.leftJoin(
|
||||
'milestones AS mi',
|
||||
'mi.release_plan_definition_id',
|
||||
'rpd.id',
|
||||
)
|
||||
.leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id')
|
||||
.leftJoin(
|
||||
'milestone_strategy_segments AS mss',
|
||||
'mss.milestone_strategy_id',
|
||||
'ms.id',
|
||||
)
|
||||
.orderBy('mi.sort_order', 'asc')
|
||||
.orderBy('ms.sort_order', 'asc')
|
||||
.select(selectColumns);
|
||||
endTimer();
|
||||
return processReleasePlanRows(planRows);
|
||||
}
|
||||
|
||||
async activateStrategiesForMilestone(
|
||||
planId: string,
|
||||
auditUser: IAuditUser,
|
||||
): Promise<ReleasePlanMilestoneStrategy[]> {
|
||||
const endTimer = this.timer('activateStrategiesForMilestone');
|
||||
const rows = await this.db.raw(
|
||||
`
|
||||
INSERT INTO feature_strategies(id, feature_name, project_name, environment, strategy_name, parameters, constraints, sort_order, title, variants, created_by_user_id, milestone_id)
|
||||
SELECT ms.id, rpd.feature_name, feature.project, rpd.environment, ms.strategy_name, ms.parameters, ms.constraints, ms.sort_order, ms.title, ms.variants, :userId, ms.milestone_id
|
||||
FROM milestone_strategies AS ms
|
||||
LEFT JOIN milestones AS m ON m.id = ms.milestone_id
|
||||
LEFT JOIN release_plan_definitions AS rpd ON rpd.active_milestone_id = m.id AND rpd.discriminator = 'plan'
|
||||
LEFT JOIN features AS feature ON rpd.feature_name = feature.name
|
||||
WHERE rpd.id = :planId AND rpd.discriminator = 'plan'
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING *
|
||||
`,
|
||||
{ userId: auditUser.id, planId },
|
||||
);
|
||||
endTimer();
|
||||
return processMilestoneStrategyRows(rows.rows);
|
||||
}
|
||||
|
||||
async deactivateStrategiesForMilestone(
|
||||
templateId: string,
|
||||
): Promise<ReleasePlanMilestoneStrategy[]> {
|
||||
const endTimer = this.timer('deactivateStrategiesForMilestone');
|
||||
const deletedRows = await this.db.raw(
|
||||
`DELETE FROM feature_strategies
|
||||
WHERE milestone_id = (SELECT active_milestone_id FROM release_plan_definitions WHERE id = :templateId)
|
||||
RETURNING *`,
|
||||
{ templateId },
|
||||
);
|
||||
endTimer();
|
||||
return processMilestoneStrategyRows(deletedRows.rows);
|
||||
}
|
||||
|
||||
async getActiveStrategiesForPlan(
|
||||
planId: string,
|
||||
): Promise<ReleasePlanMilestoneStrategy[]> {
|
||||
const endTimer = this.timer('getActiveStrategiesForPlan');
|
||||
const rows = await this.db
|
||||
.select(
|
||||
'id',
|
||||
'strategy_name',
|
||||
'sort_order',
|
||||
'title',
|
||||
'parameters',
|
||||
'variants',
|
||||
'constraints',
|
||||
)
|
||||
.from('feature_strategies')
|
||||
.whereRaw(
|
||||
`milestone_id IN (SELECT active_milestone_id FROM release_plan_definitions WHERE id = :id)`,
|
||||
{ id: planId },
|
||||
);
|
||||
endTimer();
|
||||
return processMilestoneStrategyRows(rows);
|
||||
}
|
||||
|
||||
async activateStrategySegmentsForMilestone(
|
||||
milestone_id: string,
|
||||
): Promise<number[]> {
|
||||
const endTimer = this.timer('activateStrategySegmentsForMilestone');
|
||||
const rows = await this.db.raw(
|
||||
`
|
||||
INSERT INTO feature_strategy_segment(feature_strategy_id, segment_id) SELECT milestone_strategy_id, segment_id FROM milestone_strategy_segments WHERE milestone_strategy_id IN (SELECT id FROM milestone_strategies WHERE milestone_id = :milestone_id) RETURNING segment_id
|
||||
`,
|
||||
{ milestone_id },
|
||||
);
|
||||
endTimer();
|
||||
return rows.rows.map((row) => row.segment_id);
|
||||
}
|
||||
|
||||
async featureAndEnvironmentHasPlan(
|
||||
featureName: string,
|
||||
environment: string,
|
||||
): Promise<boolean> {
|
||||
const endTimer = this.timer('featureAndEnvironmentHasPlan');
|
||||
const result = await this.db.raw(
|
||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE discriminator = 'plan' AND feature_name = :featureName AND environment = :environment) AS present`,
|
||||
{ featureName, environment },
|
||||
);
|
||||
const { present } = result.rows[0];
|
||||
endTimer();
|
||||
return present;
|
||||
}
|
||||
}
|
205
src/lib/features/release-plans/release-plan-template-store.ts
Normal file
205
src/lib/features/release-plans/release-plan-template-store.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { ulid } from 'ulidx';
|
||||
import type { ReleasePlanTemplate } from './release-plan-template';
|
||||
import type { ReleasePlanMilestone } from './release-plan-milestone';
|
||||
import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store';
|
||||
import type { Row } from '../../db/crud/row-type';
|
||||
import type { Db } from '../../db/db';
|
||||
import { NotFoundError } from '../../error';
|
||||
|
||||
const TABLE = 'release_plan_definitions';
|
||||
|
||||
export type ReleasePlanTemplateWriteModel = Omit<
|
||||
ReleasePlanTemplate,
|
||||
'id' | 'createdAt' | 'milestones'
|
||||
>;
|
||||
|
||||
const fromRow = (row: any): ReleasePlanTemplate => {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdAt: row.created_at,
|
||||
description: row.description,
|
||||
discriminator: row.discriminator,
|
||||
createdByUserId: row.created_by_user_id,
|
||||
};
|
||||
};
|
||||
|
||||
export class ReleasePlanTemplateStore extends CRUDStore<
|
||||
ReleasePlanTemplate,
|
||||
ReleasePlanTemplateWriteModel,
|
||||
Row<ReleasePlanTemplate>,
|
||||
ReleasePlanTemplate,
|
||||
string
|
||||
> {
|
||||
constructor(db: Db, config: CrudStoreConfig) {
|
||||
super(TABLE, db, config);
|
||||
}
|
||||
|
||||
override async getAll(): Promise<ReleasePlanTemplate[]> {
|
||||
const endTimer = this.timer('getAll');
|
||||
const templates = await this.db<ReleasePlanTemplate>(TABLE)
|
||||
.where('discriminator', 'template')
|
||||
.where('archived_at', null)
|
||||
.orderBy('created_at');
|
||||
endTimer();
|
||||
return templates.map(({ milestones, ...template }) =>
|
||||
fromRow(template),
|
||||
);
|
||||
}
|
||||
|
||||
override async count(
|
||||
query?: Partial<ReleasePlanTemplateWriteModel>,
|
||||
): Promise<number> {
|
||||
let countQuery = this.db(this.tableName)
|
||||
.where('discriminator', 'template')
|
||||
.whereNull('archived_at')
|
||||
.count('*');
|
||||
if (query) {
|
||||
countQuery = countQuery.where(this.toRow(query));
|
||||
}
|
||||
const { count } = (await countQuery.first()) ?? { count: 0 };
|
||||
return Number(count);
|
||||
}
|
||||
|
||||
async checkNameAlreadyExists(name: string, id?: string): Promise<boolean> {
|
||||
const exists = await this.db(TABLE)
|
||||
.where('discriminator', 'template')
|
||||
.where({ name })
|
||||
.modify((qb) => {
|
||||
if (id) {
|
||||
qb.whereNot('id', id);
|
||||
}
|
||||
})
|
||||
.first()
|
||||
.select('id');
|
||||
|
||||
return Boolean(exists);
|
||||
}
|
||||
|
||||
processReleasePlanTemplateRows(templateRows): ReleasePlanTemplate {
|
||||
return {
|
||||
id: templateRows[0].templateId,
|
||||
discriminator: templateRows[0].templateDiscriminator,
|
||||
name: templateRows[0].templateName,
|
||||
description: templateRows[0].templateDescription,
|
||||
createdByUserId: templateRows[0].templateCreatedByUserId,
|
||||
createdAt: templateRows[0].templateCreatedAt,
|
||||
milestones: templateRows.reduce(
|
||||
(acc: ReleasePlanMilestone[], row) => {
|
||||
if (!row.milestoneId) {
|
||||
return acc;
|
||||
}
|
||||
let milestone = acc.find((m) => m.id === row.milestoneId);
|
||||
if (!milestone) {
|
||||
milestone = {
|
||||
id: row.milestoneId,
|
||||
name: row.milestoneName,
|
||||
sortOrder: row.milestoneSortOrder,
|
||||
strategies: [],
|
||||
releasePlanDefinitionId: row.templateId,
|
||||
};
|
||||
acc.push(milestone);
|
||||
}
|
||||
if (!row.strategyId) {
|
||||
return acc;
|
||||
}
|
||||
let strategy = milestone.strategies?.find(
|
||||
(s) => s.id === row.strategyId,
|
||||
);
|
||||
if (!strategy) {
|
||||
strategy = {
|
||||
id: row.strategyId,
|
||||
milestoneId: row.milestoneId,
|
||||
sortOrder: row.strategySortOrder,
|
||||
title: row.strategyTitle,
|
||||
strategyName: row.strategyName,
|
||||
parameters: row.strategyParameters ?? {},
|
||||
constraints: row.strategyConstraints,
|
||||
variants: row.strategyVariants ?? [],
|
||||
segments: [],
|
||||
};
|
||||
milestone.strategies = [
|
||||
...(milestone.strategies || []),
|
||||
strategy,
|
||||
];
|
||||
}
|
||||
|
||||
if (row.segmentId) {
|
||||
strategy.segments = [
|
||||
...(strategy.segments || []),
|
||||
row.segmentId,
|
||||
];
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
),
|
||||
archivedAt: templateRows[0].templateArchivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<ReleasePlanTemplate> {
|
||||
const endTimer = this.timer('getById');
|
||||
const templateRows = await this.db(`${TABLE} AS rpd`)
|
||||
.where('rpd.id', id)
|
||||
.leftJoin(
|
||||
'milestones AS mi',
|
||||
'mi.release_plan_definition_id',
|
||||
'rpd.id',
|
||||
)
|
||||
.leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id')
|
||||
.leftJoin(
|
||||
'milestone_strategy_segments AS mss',
|
||||
'mss.milestone_strategy_id',
|
||||
'ms.id',
|
||||
)
|
||||
.orderBy('mi.sort_order', 'asc')
|
||||
.orderBy('ms.sort_order', 'asc')
|
||||
.select(
|
||||
'rpd.id AS templateId',
|
||||
'rpd.discriminator AS templateDiscriminator',
|
||||
'rpd.name AS templateName',
|
||||
'rpd.description as templateDescription',
|
||||
'rpd.created_by_user_id as templateCreatedByUserId',
|
||||
'rpd.created_at as templateCreatedAt',
|
||||
'rpd.archived_at AS templateArchivedAt',
|
||||
'mi.id AS milestoneId',
|
||||
'mi.name AS milestoneName',
|
||||
'mi.sort_order AS milestoneSortOrder',
|
||||
'ms.id AS strategyId',
|
||||
'ms.sort_order AS strategySortOrder',
|
||||
'ms.title AS strategyTitle',
|
||||
'ms.strategy_name AS strategyName',
|
||||
'ms.parameters AS strategyParameters',
|
||||
'ms.constraints AS strategyConstraints',
|
||||
'ms.variants AS strategyVariants',
|
||||
'mss.segment_id AS segmentId',
|
||||
);
|
||||
endTimer();
|
||||
|
||||
if (!templateRows.length) {
|
||||
throw new NotFoundError(`Could not find template with id ${id}`);
|
||||
}
|
||||
|
||||
return this.processReleasePlanTemplateRows(templateRows);
|
||||
}
|
||||
|
||||
override async insert(
|
||||
item: ReleasePlanTemplateWriteModel,
|
||||
): Promise<ReleasePlanTemplate> {
|
||||
const endTimer = this.timer('insert');
|
||||
const row = this.toRow(item);
|
||||
row.id = ulid();
|
||||
await this.db(TABLE).insert(row);
|
||||
endTimer();
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
async archive(id: string): Promise<void> {
|
||||
const endTimer = this.timer('archive');
|
||||
const now = new Date();
|
||||
await this.db(TABLE).where('id', id).update({ archived_at: now });
|
||||
endTimer();
|
||||
}
|
||||
}
|
12
src/lib/features/release-plans/release-plan-template.ts
Normal file
12
src/lib/features/release-plans/release-plan-template.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { ReleasePlanMilestone } from './release-plan-milestone';
|
||||
|
||||
export interface ReleasePlanTemplate {
|
||||
id: string;
|
||||
discriminator: 'template';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
createdByUserId: number;
|
||||
createdAt: string;
|
||||
milestones?: ReleasePlanMilestone[];
|
||||
archivedAt?: string;
|
||||
}
|
15
src/lib/features/release-plans/release-plan.ts
Normal file
15
src/lib/features/release-plans/release-plan.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { ReleasePlanMilestone } from './release-plan-milestone';
|
||||
|
||||
export interface ReleasePlan {
|
||||
id: string;
|
||||
discriminator: 'plan';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
featureName: string;
|
||||
environment: string;
|
||||
createdByUserId: number;
|
||||
createdAt: string;
|
||||
activeMilestoneId?: string;
|
||||
milestones: ReleasePlanMilestone[];
|
||||
releasePlanTemplateId: string;
|
||||
}
|
@ -55,6 +55,10 @@ import type { IUserUnsubscribeStore } from '../features/user-subscriptions/user-
|
||||
import type { IUserSubscriptionsReadModel } from '../features/user-subscriptions/user-subscriptions-read-model-type';
|
||||
import { IUniqueConnectionStore } from '../features/unique-connection/unique-connection-store-type';
|
||||
import { IUniqueConnectionReadModel } from '../features/unique-connection/unique-connection-read-model-type';
|
||||
import { ReleasePlanStore } from '../features/release-plans/release-plan-store';
|
||||
import { ReleasePlanTemplateStore } from '../features/release-plans/release-plan-template-store';
|
||||
import { ReleasePlanMilestoneStore } from '../features/release-plans/release-plan-milestone-store';
|
||||
import { ReleasePlanMilestoneStrategyStore } from '../features/release-plans/release-plan-milestone-strategy-store';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -114,6 +118,10 @@ export interface IUnleashStores {
|
||||
userSubscriptionsReadModel: IUserSubscriptionsReadModel;
|
||||
uniqueConnectionStore: IUniqueConnectionStore;
|
||||
uniqueConnectionReadModel: IUniqueConnectionReadModel;
|
||||
releasePlanStore: ReleasePlanStore;
|
||||
releasePlanTemplateStore: ReleasePlanTemplateStore;
|
||||
releasePlanMilestoneStore: ReleasePlanMilestoneStore;
|
||||
releasePlanMilestoneStrategyStore: ReleasePlanMilestoneStrategyStore;
|
||||
}
|
||||
|
||||
export {
|
||||
@ -171,4 +179,8 @@ export {
|
||||
type IUserSubscriptionsReadModel,
|
||||
IUniqueConnectionStore,
|
||||
IUniqueConnectionReadModel,
|
||||
ReleasePlanStore,
|
||||
ReleasePlanTemplateStore,
|
||||
ReleasePlanMilestoneStore,
|
||||
ReleasePlanMilestoneStrategyStore,
|
||||
};
|
||||
|
9
src/test/fixtures/store.ts
vendored
9
src/test/fixtures/store.ts
vendored
@ -20,6 +20,10 @@ import type {
|
||||
IntegrationEventsStore,
|
||||
IPrivateProjectStore,
|
||||
IUnleashStores,
|
||||
ReleasePlanMilestoneStore,
|
||||
ReleasePlanMilestoneStrategyStore,
|
||||
ReleasePlanStore,
|
||||
ReleasePlanTemplateStore,
|
||||
} from '../../lib/types';
|
||||
import FakeSessionStore from './fake-session-store';
|
||||
import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
|
||||
@ -129,6 +133,11 @@ const createStores: () => IUnleashStores = () => {
|
||||
uniqueConnectionReadModel: new UniqueConnectionReadModel(
|
||||
uniqueConnectionStore,
|
||||
),
|
||||
releasePlanStore: {} as ReleasePlanStore,
|
||||
releasePlanMilestoneStore: {} as ReleasePlanMilestoneStore,
|
||||
releasePlanTemplateStore: {} as ReleasePlanTemplateStore,
|
||||
releasePlanMilestoneStrategyStore:
|
||||
{} as ReleasePlanMilestoneStrategyStore,
|
||||
};
|
||||
};
|
||||
|
||||
|
17
yarn.lock
17
yarn.lock
@ -6246,6 +6246,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"layerr@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "layerr@npm:3.0.0"
|
||||
checksum: 10c0/320c9b9cf1392c73c9ff8f8d1bb7a782093ac7341fe5d7fea6ebfeea6d785af8e0fc573541431b6ffcb268577f8aea66168757e73a15035568a00ab9e4370705
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lazy-cache@npm:^0.2.3":
|
||||
version: 0.2.7
|
||||
resolution: "lazy-cache@npm:0.2.7"
|
||||
@ -9290,6 +9297,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ulidx@npm:^2.4.1":
|
||||
version: 2.4.1
|
||||
resolution: "ulidx@npm:2.4.1"
|
||||
dependencies:
|
||||
layerr: "npm:^3.0.0"
|
||||
checksum: 10c0/3cbe05123b4b49d262e26cd0edd3ac38a4f9e97f043d47ae7daa88502748881d1615d832e3bcb29698ca2429947c24494929ba35b40d6bf38df891cb15642d2e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~5.26.4":
|
||||
version: 5.26.5
|
||||
resolution: "undici-types@npm:5.26.5"
|
||||
@ -9464,6 +9480,7 @@ __metadata:
|
||||
tsc-watch: "npm:6.2.1"
|
||||
type-is: "npm:^1.6.18"
|
||||
typescript: "npm:5.8.2"
|
||||
ulidx: "npm:^2.4.1"
|
||||
unleash-client: "npm:^6.6.0"
|
||||
uuid: "npm:^9.0.0"
|
||||
wait-on: "npm:^8.0.0"
|
||||
|
Loading…
Reference in New Issue
Block a user