From f2115cc3db1cb4df6368c2a771d26d235bc0d522 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 8 Oct 2025 09:39:37 +0200 Subject: [PATCH] feat: move release plans to feature environments (#10746) --- .../createFeatureToggleService.ts | 7 + .../feature-toggle/feature-toggle-service.ts | 44 +++++ .../release-plan-read-model-type.ts | 8 + .../release-plans/release-plan-read-model.ts | 184 ++++++++++++++++++ .../release-plans/release-plan-store.ts | 4 + src/lib/openapi/spec/export-result-schema.ts | 10 + .../spec/feature-environment-schema.ts | 18 ++ src/lib/openapi/spec/feature-schema.ts | 10 + .../spec/feature-search-environment-schema.ts | 10 + .../openapi/spec/health-overview-schema.ts | 6 + src/lib/openapi/spec/import-toggles-schema.ts | 10 + .../openapi/spec/project-overview-schema.ts | 6 + .../fake/fake-release-plan-read-model.ts | 17 ++ 13 files changed, 334 insertions(+) create mode 100644 src/lib/features/release-plans/release-plan-read-model-type.ts create mode 100644 src/lib/features/release-plans/release-plan-read-model.ts create mode 100644 src/test/fixtures/fake/fake-release-plan-read-model.ts diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 03a8ff06e3..2ab7f9de5b 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -66,6 +66,8 @@ import { createFeatureLinkService, } from '../feature-links/createFeatureLinkService.js'; import { ResourceLimitsService } from '../resource-limits/resource-limits-service.js'; +import { ReleasePlanReadModel } from '../release-plans/release-plan-read-model.js'; +import { FakeReleasePlanReadModel } from '../../../test/fixtures/fake/fake-release-plan-read-model.js'; export const createFeatureToggleService = ( db: Db, @@ -141,6 +143,8 @@ export const createFeatureToggleService = ( const resourceLimitsService = new ResourceLimitsService(config); + const releasePlanReadModel = new ReleasePlanReadModel(db); + const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -166,6 +170,7 @@ export const createFeatureToggleService = ( featureLinksReadModel, featureLinkService, resourceLimitsService, + releasePlanReadModel, }, ); return featureToggleService; @@ -211,6 +216,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { const { featureLinkService } = createFakeFeatureLinkService(config); const resourceLimitsService = new ResourceLimitsService(config); + const releasePlanReadModel = new FakeReleasePlanReadModel(); const featureToggleService = new FeatureToggleService( { @@ -241,6 +247,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { featureLinksReadModel, featureLinkService, resourceLimitsService, + releasePlanReadModel, }, ); return { diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 90cd4c11b3..1af6a4777e 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -19,6 +19,7 @@ import { type IAuditUser, type IConstraint, type IDependency, + type IEnvironmentDetail, type IFeatureCollaboratorsReadModel, type IFeatureEnvironmentInfo, type IFeatureEnvironmentStore, @@ -117,6 +118,8 @@ import { sortStrategies } from '../../util/sortStrategies.js'; import type FeatureLinkService from '../feature-links/feature-link-service.js'; import type { IFeatureLink } from '../feature-links/feature-links-read-model-type.js'; import type { ResourceLimitsService } from '../resource-limits/resource-limits-service.js'; +import type { IReleasePlanReadModel } from '../release-plans/release-plan-read-model-type.js'; +import type { ReleasePlan } from '../release-plans/release-plan.js'; interface IFeatureContext { featureName: string; projectId: string; @@ -177,6 +180,7 @@ export type ServicesAndReadModels = { featureLinkService: FeatureLinkService; featureLinksReadModel: IFeatureLinksReadModel; resourceLimitsService: ResourceLimitsService; + releasePlanReadModel: IReleasePlanReadModel; }; export class FeatureToggleService { @@ -226,6 +230,8 @@ export class FeatureToggleService { private resourceLimitsService: ResourceLimitsService; + private releasePlanReadModel: IReleasePlanReadModel; + constructor( { featureStrategiesStore, @@ -251,6 +257,7 @@ export class FeatureToggleService { featureLinksReadModel, featureLinkService, resourceLimitsService, + releasePlanReadModel, }: ServicesAndReadModels, ) { this.logger = getLogger('services/feature-toggle-service.ts'); @@ -276,6 +283,7 @@ export class FeatureToggleService { this.featureLinkService = featureLinkService; this.eventBus = eventBus; this.resourceLimitsService = resourceLimitsService; + this.releasePlanReadModel = releasePlanReadModel; } async validateFeaturesContext( @@ -1151,8 +1159,16 @@ export class FeatureToggleService { userId, archived, ); + + const environmentsWithReleasePlans = + await this.addReleasePlansToEnvironments( + featureName, + result.environments, + ); + return { ...result, + environments: environmentsWithReleasePlans, dependencies, children, lifecycle, @@ -1171,8 +1187,15 @@ export class FeatureToggleService { archived, ); + const environmentsWithReleasePlans = + await this.addReleasePlansToEnvironments( + featureName, + result.environments, + ); + return { ...result, + environments: environmentsWithReleasePlans, dependencies, children, lifecycle, @@ -1182,6 +1205,27 @@ export class FeatureToggleService { } } + private async addReleasePlansToEnvironments( + featureName: string, + environments: IEnvironmentDetail[], + ): Promise<(IEnvironmentDetail & { releasePlans?: ReleasePlan[] })[]> { + if (!this.flagResolver.isEnabled('milestoneProgression')) { + return environments; + } + + const environmentNames = environments.map((env) => env.name); + const releasePlansByEnvironment = + await this.releasePlanReadModel.getReleasePlans( + featureName, + environmentNames, + ); + + return environments.map((env) => ({ + ...env, + releasePlans: releasePlansByEnvironment[env.name] || [], + })); + } + async getVariantsForEnv( featureName: string, environment: string, diff --git a/src/lib/features/release-plans/release-plan-read-model-type.ts b/src/lib/features/release-plans/release-plan-read-model-type.ts new file mode 100644 index 0000000000..ba4190c290 --- /dev/null +++ b/src/lib/features/release-plans/release-plan-read-model-type.ts @@ -0,0 +1,8 @@ +import type { ReleasePlan } from './release-plan.js'; + +export interface IReleasePlanReadModel { + getReleasePlans( + featureName: string, + environments: string[], + ): Promise>; +} diff --git a/src/lib/features/release-plans/release-plan-read-model.ts b/src/lib/features/release-plans/release-plan-read-model.ts new file mode 100644 index 0000000000..322926efe6 --- /dev/null +++ b/src/lib/features/release-plans/release-plan-read-model.ts @@ -0,0 +1,184 @@ +import type { Db } from '../../db/db.js'; +import type { IReleasePlanReadModel } from './release-plan-read-model-type.js'; +import type { ReleasePlan } from './release-plan.js'; + +const TABLE = 'release_plan_definitions'; + +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; + }, + [], + ); + +export class ReleasePlanReadModel implements IReleasePlanReadModel { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getReleasePlans( + featureName: string, + environments: string[], + ): Promise> { + if (environments.length === 0) { + return {}; + } + + const planRows = await this.db(`${TABLE} AS rpd`) + .where('rpd.discriminator', 'plan') + .andWhere('rpd.feature_name', featureName) + .whereIn('rpd.environment', environments) + .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('rpd.environment', 'asc') + .orderBy('mi.sort_order', 'asc') + .orderBy('ms.sort_order', 'asc') + .select(selectColumns); + + const allPlans = processReleasePlanRows(planRows); + + const plansByEnvironment: Record = {}; + for (const plan of allPlans) { + if (!plansByEnvironment[plan.environment]) { + plansByEnvironment[plan.environment] = []; + } + plansByEnvironment[plan.environment].push(plan); + } + + for (const env of environments) { + if (!plansByEnvironment[env]) { + plansByEnvironment[env] = []; + } + } + + return plansByEnvironment; + } +} diff --git a/src/lib/features/release-plans/release-plan-store.ts b/src/lib/features/release-plans/release-plan-store.ts index c95aebaa3d..a4977e547d 100644 --- a/src/lib/features/release-plans/release-plan-store.ts +++ b/src/lib/features/release-plans/release-plan-store.ts @@ -252,6 +252,10 @@ export class ReleasePlanStore extends CRUDStore< } return releasePlans[0]; } + + // should be deprecated soon in favor of IReleasePlanReadModel.getReleasePlans + // this will remove read model responsibility from the store + // also we're moving release plans to feature environments (DB join) instead of separate API calls (browser join) async getByFeatureFlagAndEnvironment( featureName: string, environment: string, diff --git a/src/lib/openapi/spec/export-result-schema.ts b/src/lib/openapi/spec/export-result-schema.ts index c8029ca033..9b0c744539 100644 --- a/src/lib/openapi/spec/export-result-schema.ts +++ b/src/lib/openapi/spec/export-result-schema.ts @@ -13,6 +13,11 @@ import { constraintSchema } from './constraint-schema.js'; import { tagTypeSchema } from './tag-type-schema.js'; import { strategyVariantSchema } from './strategy-variant-schema.js'; import { featureDependenciesSchema } from './feature-dependencies-schema.js'; +import { releasePlanSchema } from './release-plan-schema.js'; +import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js'; +import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js'; +import { createFeatureStrategySchema } from './create-feature-strategy-schema.js'; +import { createStrategyVariantSchema } from './create-strategy-variant-schema.js'; import { dependentFeatureSchema } from './dependent-feature-schema.js'; import { tagSchema } from './tag-schema.js'; import { featureLinksSchema } from './feature-links-schema.js'; @@ -207,6 +212,11 @@ export const exportResultSchema = { tagSchema, featureLinksSchema, featureLinkSchema, + releasePlanSchema, + releasePlanMilestoneSchema, + releasePlanMilestoneStrategySchema, + createFeatureStrategySchema, + createStrategyVariantSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/feature-environment-schema.ts b/src/lib/openapi/spec/feature-environment-schema.ts index 123c3d36fa..eefc2ea666 100644 --- a/src/lib/openapi/spec/feature-environment-schema.ts +++ b/src/lib/openapi/spec/feature-environment-schema.ts @@ -4,6 +4,11 @@ import { parametersSchema } from './parameters-schema.js'; import { featureStrategySchema } from './feature-strategy-schema.js'; import { variantSchema } from './variant-schema.js'; import { strategyVariantSchema } from './strategy-variant-schema.js'; +import { releasePlanSchema } from './release-plan-schema.js'; +import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js'; +import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js'; +import { createFeatureStrategySchema } from './create-feature-strategy-schema.js'; +import { createStrategyVariantSchema } from './create-strategy-variant-schema.js'; export const featureEnvironmentSchema = { $id: '#/components/schemas/featureEnvironmentSchema', @@ -106,6 +111,14 @@ export const featureEnvironmentSchema = { description: 'Whether the feature has any enabled strategies defined.', }, + releasePlans: { + type: 'array', + description: + 'Release plans for this feature environment (only available when milestoneProgression feature flag is enabled)', + items: { + $ref: '#/components/schemas/releasePlanSchema', + }, + }, }, components: { schemas: { @@ -114,6 +127,11 @@ export const featureEnvironmentSchema = { featureStrategySchema, strategyVariantSchema, variantSchema, + releasePlanSchema, + releasePlanMilestoneSchema, + releasePlanMilestoneStrategySchema, + createFeatureStrategySchema, + createStrategyVariantSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/feature-schema.ts b/src/lib/openapi/spec/feature-schema.ts index eca11353d9..5c8f2939fa 100644 --- a/src/lib/openapi/spec/feature-schema.ts +++ b/src/lib/openapi/spec/feature-schema.ts @@ -7,6 +7,11 @@ import { featureStrategySchema } from './feature-strategy-schema.js'; import { tagSchema } from './tag-schema.js'; import { featureEnvironmentSchema } from './feature-environment-schema.js'; import { strategyVariantSchema } from './strategy-variant-schema.js'; +import { releasePlanSchema } from './release-plan-schema.js'; +import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js'; +import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js'; +import { createFeatureStrategySchema } from './create-feature-strategy-schema.js'; +import { createStrategyVariantSchema } from './create-strategy-variant-schema.js'; export const featureSchema = { $id: '#/components/schemas/featureSchema', @@ -288,6 +293,11 @@ export const featureSchema = { parametersSchema, variantSchema, tagSchema, + releasePlanSchema, + releasePlanMilestoneSchema, + releasePlanMilestoneStrategySchema, + createFeatureStrategySchema, + createStrategyVariantSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/feature-search-environment-schema.ts b/src/lib/openapi/spec/feature-search-environment-schema.ts index 34fa730334..431f9dcb4e 100644 --- a/src/lib/openapi/spec/feature-search-environment-schema.ts +++ b/src/lib/openapi/spec/feature-search-environment-schema.ts @@ -5,6 +5,11 @@ import { featureStrategySchema } from './feature-strategy-schema.js'; import { variantSchema } from './variant-schema.js'; import { strategyVariantSchema } from './strategy-variant-schema.js'; import { featureEnvironmentSchema } from './feature-environment-schema.js'; +import { releasePlanSchema } from './release-plan-schema.js'; +import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js'; +import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js'; +import { createFeatureStrategySchema } from './create-feature-strategy-schema.js'; +import { createStrategyVariantSchema } from './create-strategy-variant-schema.js'; export const featureSearchEnvironmentSchema = { $id: '#/components/schemas/featureSearchEnvironmentSchema', @@ -37,6 +42,11 @@ export const featureSearchEnvironmentSchema = { strategyVariantSchema, featureEnvironmentSchema, variantSchema, + releasePlanSchema, + releasePlanMilestoneSchema, + releasePlanMilestoneStrategySchema, + createFeatureStrategySchema, + createStrategyVariantSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index 6b700861a3..d88232648f 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -10,6 +10,9 @@ import { featureEnvironmentSchema } from './feature-environment-schema.js'; import { projectStatsSchema } from './project-stats-schema.js'; import { createFeatureStrategySchema } from './create-feature-strategy-schema.js'; import { projectEnvironmentSchema } from './project-environment-schema.js'; +import { releasePlanSchema } from './release-plan-schema.js'; +import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js'; +import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js'; import { createStrategyVariantSchema } from './create-strategy-variant-schema.js'; import { strategyVariantSchema } from './strategy-variant-schema.js'; import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema.js'; @@ -144,6 +147,9 @@ export const healthOverviewSchema = { overrideSchema, parametersSchema, featureStrategySchema, + releasePlanSchema, + releasePlanMilestoneSchema, + releasePlanMilestoneStrategySchema, strategyVariantSchema, variantSchema, projectStatsSchema, diff --git a/src/lib/openapi/spec/import-toggles-schema.ts b/src/lib/openapi/spec/import-toggles-schema.ts index d7a89b66e0..f545a0174f 100644 --- a/src/lib/openapi/spec/import-toggles-schema.ts +++ b/src/lib/openapi/spec/import-toggles-schema.ts @@ -13,6 +13,11 @@ import { parametersSchema } from './parameters-schema.js'; import { legalValueSchema } from './legal-value-schema.js'; import { tagTypeSchema } from './tag-type-schema.js'; import { featureEnvironmentSchema } from './feature-environment-schema.js'; +import { releasePlanSchema } from './release-plan-schema.js'; +import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js'; +import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js'; +import { createFeatureStrategySchema } from './create-feature-strategy-schema.js'; +import { createStrategyVariantSchema } from './create-strategy-variant-schema.js'; import { strategyVariantSchema } from './strategy-variant-schema.js'; import { featureDependenciesSchema } from './feature-dependencies-schema.js'; import { dependentFeatureSchema } from './dependent-feature-schema.js'; @@ -53,6 +58,11 @@ export const importTogglesSchema = { contextFieldSchema, featureTagSchema, segmentSchema, + releasePlanSchema, + releasePlanMilestoneSchema, + releasePlanMilestoneStrategySchema, + createFeatureStrategySchema, + createStrategyVariantSchema, variantsSchema, variantSchema, overrideSchema, diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index 559caab5bf..b38504fff3 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -15,6 +15,9 @@ import { strategyVariantSchema } from './strategy-variant-schema.js'; import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema.js'; import { featureTypeCountSchema } from './feature-type-count-schema.js'; import { projectLinkTemplateSchema } from './project-link-template-schema.js'; +import { releasePlanSchema } from './release-plan-schema.js'; +import { releasePlanMilestoneSchema } from './release-plan-milestone-schema.js'; +import { releasePlanMilestoneStrategySchema } from './release-plan-milestone-strategy-schema.js'; export const projectOverviewSchema = { $id: '#/components/schemas/projectOverviewSchema', @@ -202,6 +205,9 @@ export const projectOverviewSchema = { featureStrategySchema, strategyVariantSchema, variantSchema, + releasePlanSchema, + releasePlanMilestoneSchema, + releasePlanMilestoneStrategySchema, projectStatsSchema, createFeatureNamingPatternSchema, featureTypeCountSchema, diff --git a/src/test/fixtures/fake/fake-release-plan-read-model.ts b/src/test/fixtures/fake/fake-release-plan-read-model.ts new file mode 100644 index 0000000000..fcf5584671 --- /dev/null +++ b/src/test/fixtures/fake/fake-release-plan-read-model.ts @@ -0,0 +1,17 @@ +import type { IReleasePlanReadModel } from '../../../lib/features/release-plans/release-plan-read-model-type.js'; +import type { ReleasePlan } from '../../../lib/features/release-plans/release-plan.js'; + +export class FakeReleasePlanReadModel implements IReleasePlanReadModel { + private releasePlans: Record; + + constructor(releasePlans: Record = {}) { + this.releasePlans = releasePlans; + } + + async getReleasePlans( + _featureName: string, + _environments: string[], + ): Promise> { + return this.releasePlans; + } +}