1
0
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:
Mateusz Kwasniewski 2025-04-11 11:37:06 +02:00 committed by GitHub
parent e9ec1db3b7
commit b2471633b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 904 additions and 45 deletions

View File

@ -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"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View 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[];
}

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

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

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

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

View File

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

View File

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

View File

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