1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02:00

feat: Add variants per env (#2471)

## About the changes
Variants are now stored in each environment rather than in the feature
toggle. This enables RBAC, suggest changes, etc to also apply to
variants.

Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#2254

### Important files
- **src/lib/db/feature-strategy-store.ts** a complex query was moved to
a view named `features_view`
- **src/lib/services/state-service.ts** export version number increased
due to the new format

## Discussion points
We're keeping the old column as a safeguard to be able to go back

Co-authored-by: sighphyre <liquidwicked64@gmail.com>
Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
This commit is contained in:
Gastón Fournier 2022-11-21 10:37:16 +01:00 committed by GitHub
parent a165eb191c
commit efd47b72a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1399 additions and 201 deletions

View File

@ -7,7 +7,7 @@ import {
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { IFeatureEnvironment } from '../types/model'; import { IFeatureEnvironment, IVariant } from '../types/model';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -21,6 +21,7 @@ interface IFeatureEnvironmentRow {
environment: string; environment: string;
feature_name: string; feature_name: string;
enabled: boolean; enabled: boolean;
variants?: [];
} }
interface ISegmentRow { interface ISegmentRow {
@ -86,6 +87,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
enabled: md.enabled, enabled: md.enabled,
featureName, featureName,
environment, environment,
variants: md.variants,
}; };
} }
throw new NotFoundError( throw new NotFoundError(
@ -102,6 +104,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
enabled: r.enabled, enabled: r.enabled,
featureName: r.feature_name, featureName: r.feature_name,
environment: r.environment, environment: r.environment,
variants: r.variants,
})); }));
} }
@ -162,6 +165,24 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
return present; return present;
} }
async getEnvironmentsForFeature(
featureName: string,
): Promise<IFeatureEnvironment[]> {
const envs = await this.db(T.featureEnvs).where(
'feature_name',
featureName,
);
if (envs) {
return envs.map((r) => ({
featureName: r.feature_name,
environment: r.environment,
variants: r.variants || [],
enabled: r.enabled,
}));
}
return [];
}
async getEnvironmentMetaData( async getEnvironmentMetaData(
environment: string, environment: string,
featureName: string, featureName: string,
@ -261,6 +282,41 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
.del(); .del();
} }
// TODO: remove this once variants per env are GA
async clonePreviousVariants(
environment: string,
project: string,
): Promise<void> {
const rows = await this.db(`${T.features} as f`)
.select([
this.db.raw(`'${environment}' as environment`),
'fe.enabled',
'fe.feature_name',
'fe.variants',
])
.distinctOn(['environment', 'feature_name'])
.join(`${T.featureEnvs} as fe`, 'f.name', 'fe.feature_name')
.whereNot({ environment })
.andWhere({ project });
const newRows = rows.map((row) => {
const r = row as any as IFeatureEnvironmentRow;
return {
variants: JSON.stringify(r.variants),
enabled: r.enabled,
environment: r.environment,
feature_name: r.feature_name,
};
});
if (newRows.length > 0) {
await this.db(T.featureEnvs)
.insert(newRows)
.onConflict(['environment', 'feature_name'])
.merge(['variants']);
}
}
async connectFeatureToEnvironmentsForProject( async connectFeatureToEnvironmentsForProject(
featureName: string, featureName: string,
projectId: string, projectId: string,
@ -295,6 +351,40 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
); );
} }
async addVariantsToFeatureEnvironment(
featureName: string,
environment: string,
variants: IVariant[],
): Promise<void> {
let v = variants || [];
v.sort((a, b) => a.name.localeCompare(b.name));
await this.db(T.featureEnvs)
.insert({
variants: JSON.stringify(v),
enabled: false,
feature_name: featureName,
environment: environment,
})
.onConflict(['feature_name', 'environment'])
.merge(['variants']);
}
async addFeatureEnvironment(
featureEnvironment: IFeatureEnvironment,
): Promise<void> {
let v = featureEnvironment.variants || [];
v.sort((a, b) => a.name.localeCompare(b.name));
await this.db(T.featureEnvs)
.insert({
variants: JSON.stringify(v),
enabled: featureEnvironment.enabled,
feature_name: featureEnvironment.featureName,
environment: featureEnvironment.environment,
})
.onConflict(['feature_name', 'environment'])
.merge(['variants', 'enabled']);
}
async cloneStrategies( async cloneStrategies(
sourceEnvironment: string, sourceEnvironment: string,
destinationEnvironment: string, destinationEnvironment: string,

View File

@ -215,58 +215,25 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
async getFeatureToggleWithEnvs( async getFeatureToggleWithEnvs(
featureName: string, featureName: string,
archived: boolean = false, archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> {
return this.loadFeatureToggleWithEnvs(featureName, archived, false);
}
async getFeatureToggleWithVariantEnvs(
featureName: string,
archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> {
return this.loadFeatureToggleWithEnvs(featureName, archived, true);
}
async loadFeatureToggleWithEnvs(
featureName: string,
archived: boolean,
withEnvironmentVariants: boolean,
): Promise<FeatureToggleWithEnvironment> { ): Promise<FeatureToggleWithEnvironment> {
const stopTimer = this.timer('getFeatureAdmin'); const stopTimer = this.timer('getFeatureAdmin');
const rows = await this.db('features') const rows = await this.db('features_view')
.select( .where('name', featureName)
'features.name as name',
'features.description as description',
'features.type as type',
'features.project as project',
'features.stale as stale',
'features.variants as variants',
'features.impression_data as impression_data',
'features.created_at as created_at',
'features.last_seen_at as last_seen_at',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'environments.name as environment_name',
'environments.type as environment_type',
'environments.sort_order as environment_sort_order',
'feature_strategies.id as strategy_id',
'feature_strategies.strategy_name as strategy_name',
'feature_strategies.parameters as parameters',
'feature_strategies.constraints as constraints',
'feature_strategies.sort_order as sort_order',
'fss.segment_id as segments',
)
.leftJoin(
'feature_environments',
'feature_environments.feature_name',
'features.name',
)
.leftJoin('feature_strategies', function () {
this.on(
'feature_strategies.feature_name',
'=',
'feature_environments.feature_name',
).andOn(
'feature_strategies.environment',
'=',
'feature_environments.environment',
);
})
.leftJoin(
'environments',
'feature_environments.environment',
'environments.name',
)
.leftJoin(
'feature_strategy_segment as fss',
`fss.feature_strategy_id`,
`feature_strategies.id`,
)
.where('features.name', featureName)
.modify(FeatureToggleStore.filterByArchived, archived); .modify(FeatureToggleStore.filterByArchived, archived);
stopTimer(); stopTimer();
if (rows.length > 0) { if (rows.length > 0) {
@ -280,7 +247,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
acc.description = r.description; acc.description = r.description;
acc.project = r.project; acc.project = r.project;
acc.stale = r.stale; acc.stale = r.stale;
acc.variants = r.variants;
acc.createdAt = r.created_at; acc.createdAt = r.created_at;
acc.lastSeenAt = r.last_seen_at; acc.lastSeenAt = r.last_seen_at;
acc.type = r.type; acc.type = r.type;
@ -289,8 +256,15 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
name: r.environment, name: r.environment,
}; };
} }
const env = acc.environments[r.environment]; const env = acc.environments[r.environment];
const variants = r.variants || [];
variants.sort((a, b) => a.name.localeCompare(b.name));
if (withEnvironmentVariants) {
env.variants = variants;
}
acc.variants = variants;
env.enabled = r.enabled; env.enabled = r.enabled;
env.type = r.environment_type; env.type = r.environment_type;
env.sortOrder = r.environment_sort_order; env.sortOrder = r.environment_sort_order;
@ -325,8 +299,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
); );
return e; return e;
}); });
featureToggle.variants = featureToggle.variants || [];
featureToggle.variants.sort((a, b) => a.name.localeCompare(b.name));
featureToggle.archived = archived; featureToggle.archived = archived;
return featureToggle; return featureToggle;
} }

View File

@ -75,7 +75,7 @@ export default class FeatureToggleClientStore
'features.project as project', 'features.project as project',
'features.stale as stale', 'features.stale as stale',
'features.impression_data as impression_data', 'features.impression_data as impression_data',
'features.variants as variants', 'fe.variants as variants',
'features.created_at as created_at', 'features.created_at as created_at',
'features.last_seen_at as last_seen_at', 'features.last_seen_at as last_seen_at',
'fe.enabled as enabled', 'fe.enabled as enabled',
@ -109,7 +109,12 @@ export default class FeatureToggleClientStore
) )
.leftJoin( .leftJoin(
this.db('feature_environments') this.db('feature_environments')
.select('feature_name', 'enabled', 'environment') .select(
'feature_name',
'enabled',
'environment',
'variants',
)
.where({ environment }) .where({ environment })
.as('fe'), .as('fe'),
'fe.feature_name', 'fe.feature_name',
@ -180,7 +185,7 @@ export default class FeatureToggleClientStore
feature.project = r.project; feature.project = r.project;
feature.stale = r.stale; feature.stale = r.stale;
feature.type = r.type; feature.type = r.type;
feature.variants = r.variants; feature.variants = r.variants || [];
feature.project = r.project; feature.project = r.project;
if (isAdmin) { if (isAdmin) {
feature.lastSeenAt = r.last_seen_at; feature.lastSeenAt = r.last_seen_at;

View File

@ -13,7 +13,6 @@ const FEATURE_COLUMNS = [
'type', 'type',
'project', 'project',
'stale', 'stale',
'variants',
'created_at', 'created_at',
'impression_data', 'impression_data',
'last_seen_at', 'last_seen_at',
@ -25,7 +24,6 @@ export interface FeaturesTable {
description: string; description: string;
type: string; type: string;
stale: boolean; stale: boolean;
variants?: string;
project: string; project: string;
last_seen_at?: Date; last_seen_at?: Date;
created_at?: Date; created_at?: Date;
@ -34,7 +32,12 @@ export interface FeaturesTable {
archived_at?: Date; archived_at?: Date;
} }
interface VariantDTO {
variants: IVariant[];
}
const TABLE = 'features'; const TABLE = 'features';
const FEATURE_ENVIRONMENTS_TABLE = 'feature_environments';
export default class FeatureToggleStore implements IFeatureToggleStore { export default class FeatureToggleStore implements IFeatureToggleStore {
private db: Knex; private db: Knex;
@ -156,15 +159,12 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
if (!row) { if (!row) {
throw new NotFoundError('No feature toggle found'); throw new NotFoundError('No feature toggle found');
} }
const sortedVariants = (row.variants as unknown as IVariant[]) || [];
sortedVariants.sort((a, b) => a.name.localeCompare(b.name));
return { return {
name: row.name, name: row.name,
description: row.description, description: row.description,
type: row.type, type: row.type,
project: row.project, project: row.project,
stale: row.stale, stale: row.stale,
variants: sortedVariants,
createdAt: row.created_at, createdAt: row.created_at,
lastSeenAt: row.last_seen_at, lastSeenAt: row.last_seen_at,
impressionData: row.impression_data, impressionData: row.impression_data,
@ -173,13 +173,14 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
}; };
} }
rowToVariants(row: FeaturesTable): IVariant[] { rowToEnvVariants(variantRows: VariantDTO[]): IVariant[] {
if (!row) { if (!variantRows.length) {
throw new NotFoundError('No feature toggle found'); return [];
} }
const sortedVariants = (row.variants as unknown as IVariant[]) || [];
sortedVariants.sort((a, b) => a.name.localeCompare(b.name));
const sortedVariants =
(variantRows[0].variants as unknown as IVariant[]) || [];
sortedVariants.sort((a, b) => a.name.localeCompare(b.name));
return sortedVariants; return sortedVariants;
} }
@ -193,7 +194,6 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
stale: data.stale, stale: data.stale,
created_at: data.createdAt, created_at: data.createdAt,
impression_data: data.impressionData, impression_data: data.impressionData,
variants: JSON.stringify(data.variants),
}; };
if (!row.created_at) { if (!row.created_at) {
delete row.created_at; delete row.created_at;
@ -253,10 +253,37 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
} }
async getVariants(featureName: string): Promise<IVariant[]> { async getVariants(featureName: string): Promise<IVariant[]> {
const row = await this.db(TABLE) if (!(await this.exists(featureName))) {
.select('variants') throw new NotFoundError('No feature toggle found');
.where({ name: featureName }); }
return this.rowToVariants(row[0]); const row = await this.db(`${TABLE} as f`)
.select('fe.variants')
.join(
`${FEATURE_ENVIRONMENTS_TABLE} as fe`,
'fe.feature_name',
'f.name',
)
.where({ name: featureName })
.limit(1);
return this.rowToEnvVariants(row);
}
async getVariantsForEnv(
featureName: string,
environment: string,
): Promise<IVariant[]> {
const row = await this.db(`${TABLE} as f`)
.select('fev.variants')
.join(
`${FEATURE_ENVIRONMENTS_TABLE} as fev`,
'fev.feature_name',
'f.name',
)
.where({ name: featureName })
.andWhere({ environment });
return this.rowToEnvVariants(row);
} }
async saveVariants( async saveVariants(
@ -264,11 +291,19 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
featureName: string, featureName: string,
newVariants: IVariant[], newVariants: IVariant[],
): Promise<FeatureToggle> { ): Promise<FeatureToggle> {
const variantsString = JSON.stringify(newVariants);
await this.db('feature_environments')
.update('variants', variantsString)
.where('feature_name', featureName);
const row = await this.db(TABLE) const row = await this.db(TABLE)
.update({ variants: JSON.stringify(newVariants) }) .select(FEATURE_COLUMNS)
.where({ project: project, name: featureName }) .where({ project: project, name: featureName });
.returning(FEATURE_COLUMNS);
return this.rowToFeature(row[0]); const toggle = this.rowToFeature(row[0]);
toggle.variants = newVariants;
return toggle;
} }
} }

View File

@ -178,15 +178,12 @@ class ProjectStore implements IProjectStore {
.returning(COLUMNS) .returning(COLUMNS)
.onConflict('id') .onConflict('id')
.ignore(); .ignore();
if (rows.length > 0) { if (environments && rows.length > 0) {
await this.addDefaultEnvironment(rows); environments.forEach((env) => {
environments projects.forEach(async (project) => {
?.filter((env) => env.name !== DEFAULT_ENV) await this.addEnvironmentToProject(project.id, env.name);
.forEach((env) => {
projects.forEach((project) => {
this.addEnvironmentToProject(project.id, env.name);
});
}); });
});
return rows.map(this.mapRow); return rows.map(this.mapRow);
} }
return []; return [];

View File

@ -462,10 +462,12 @@ export default class ProjectFeaturesController extends Controller {
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
const { featureName, projectId } = req.params; const { featureName, projectId } = req.params;
const { variantEnvironments } = req.query;
const feature = await this.featureService.getFeature( const feature = await this.featureService.getFeature(
featureName, featureName,
false, false,
projectId, projectId,
variantEnvironments === 'true',
); );
res.status(200).json(feature); res.status(200).json(feature);
} }

View File

@ -14,6 +14,12 @@ import { createRequestSchema } from '../../../openapi/util/create-request-schema
import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import { createResponseSchema } from '../../../openapi/util/create-response-schema';
const PREFIX = '/:projectId/features/:featureName/variants'; const PREFIX = '/:projectId/features/:featureName/variants';
const ENV_PREFIX =
'/:projectId/features/:featureName/environments/:environment/variants';
interface FeatureEnvironmentParams extends FeatureParams {
environment: string;
}
interface FeatureParams extends ProjectParam { interface FeatureParams extends ProjectParam {
featureName: string; featureName: string;
@ -84,6 +90,53 @@ export default class VariantsController extends Controller {
}), }),
], ],
}); });
this.route({
method: 'get',
path: ENV_PREFIX,
permission: NONE,
handler: this.getVariantsOnEnv,
middleware: [
openApiService.validPath({
tags: ['Features'],
operationId: 'getEnvironmentFeatureVariants',
responses: {
200: createResponseSchema('featureVariantsSchema'),
},
}),
],
});
this.route({
method: 'patch',
path: ENV_PREFIX,
permission: UPDATE_FEATURE_VARIANTS,
handler: this.patchVariantsOnEnv,
middleware: [
openApiService.validPath({
tags: ['Features'],
operationId: 'patchEnvironmentsFeatureVariants',
requestBody: createRequestSchema('patchesSchema'),
responses: {
200: createResponseSchema('featureVariantsSchema'),
},
}),
],
});
this.route({
method: 'put',
path: ENV_PREFIX,
permission: UPDATE_FEATURE_VARIANTS,
handler: this.overwriteVariantsOnEnv,
middleware: [
openApiService.validPath({
tags: ['Features'],
operationId: 'overwriteEnvironmentFeatureVariants',
requestBody: createRequestSchema('variantsSchema'),
responses: {
200: createResponseSchema('featureVariantsSchema'),
},
}),
],
});
} }
async getVariants( async getVariants(
@ -131,4 +184,54 @@ export default class VariantsController extends Controller {
variants: updatedFeature.variants, variants: updatedFeature.variants,
}); });
} }
async getVariantsOnEnv(
req: Request<FeatureEnvironmentParams, any, any, any>,
res: Response<FeatureVariantsSchema>,
): Promise<void> {
const { featureName, environment } = req.params;
const variants = await this.featureService.getVariantsForEnv(
featureName,
environment,
);
res.status(200).json({ version: 1, variants: variants || [] });
}
async patchVariantsOnEnv(
req: IAuthRequest<FeatureEnvironmentParams, any, Operation[]>,
res: Response<FeatureVariantsSchema>,
): Promise<void> {
const { projectId, featureName, environment } = req.params;
const userName = extractUsername(req);
const variants = await this.featureService.updateVariantsOnEnv(
featureName,
projectId,
environment,
req.body,
userName,
);
res.status(200).json({
version: 1,
variants,
});
}
async overwriteVariantsOnEnv(
req: IAuthRequest<FeatureEnvironmentParams, any, IVariant[], any>,
res: Response<FeatureVariantsSchema>,
): Promise<void> {
const { featureName, environment } = req.params;
const userName = extractUsername(req);
const variants = await this.featureService.saveVariantsOnEnv(
featureName,
environment,
req.body,
userName,
);
res.status(200).json({
version: 1,
variants: variants,
});
}
} }

View File

@ -28,7 +28,7 @@ export const strategiesSchema = joi.object().keys({
parameters: joi.object(), parameters: joi.object(),
}); });
const variantValueSchema = joi export const variantValueSchema = joi
.string() .string()
.required() .required()
// perform additional validation // perform additional validation

View File

@ -11,6 +11,7 @@ import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-stor
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectStore } from 'lib/types/stores/project-store'; import { IProjectStore } from 'lib/types/stores/project-store';
import MinimumOneEnvironmentError from '../error/minimum-one-environment-error'; import MinimumOneEnvironmentError from '../error/minimum-one-environment-error';
import { IFlagResolver } from 'lib/types/experimental';
export default class EnvironmentService { export default class EnvironmentService {
private logger: Logger; private logger: Logger;
@ -23,6 +24,8 @@ export default class EnvironmentService {
private featureEnvironmentStore: IFeatureEnvironmentStore; private featureEnvironmentStore: IFeatureEnvironmentStore;
private flagResolver: IFlagResolver;
constructor( constructor(
{ {
environmentStore, environmentStore,
@ -36,13 +39,17 @@ export default class EnvironmentService {
| 'featureEnvironmentStore' | 'featureEnvironmentStore'
| 'projectStore' | 'projectStore'
>, >,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>, {
getLogger,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
) { ) {
this.logger = getLogger('services/environment-service.ts'); this.logger = getLogger('services/environment-service.ts');
this.environmentStore = environmentStore; this.environmentStore = environmentStore;
this.featureStrategiesStore = featureStrategiesStore; this.featureStrategiesStore = featureStrategiesStore;
this.featureEnvironmentStore = featureEnvironmentStore; this.featureEnvironmentStore = featureEnvironmentStore;
this.projectStore = projectStore; this.projectStore = projectStore;
this.flagResolver = flagResolver;
} }
async getAll(): Promise<IEnvironment[]> { async getAll(): Promise<IEnvironment[]> {
@ -90,6 +97,13 @@ export default class EnvironmentService {
environment, environment,
projectId, projectId,
); );
if (!this.flagResolver.isEnabled('variantsPerEnvironment')) {
await this.featureEnvironmentStore.clonePreviousVariants(
environment,
projectId,
);
}
} catch (e) { } catch (e) {
if (e.code === UNIQUE_CONSTRAINT_VIOLATION) { if (e.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new NameExistsError( throw new NameExistsError(

View File

@ -25,6 +25,7 @@ import {
FeatureStrategyRemoveEvent, FeatureStrategyRemoveEvent,
FeatureStrategyUpdateEvent, FeatureStrategyUpdateEvent,
FeatureVariantEvent, FeatureVariantEvent,
EnvironmentVariantEvent,
} from '../types/events'; } from '../types/events';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { import {
@ -611,16 +612,23 @@ class FeatureToggleService {
featureName: string, featureName: string,
archived: boolean = false, archived: boolean = false,
projectId?: string, projectId?: string,
environmentVariants: boolean = false,
): Promise<FeatureToggleWithEnvironment> { ): Promise<FeatureToggleWithEnvironment> {
const feature =
await this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
archived,
);
if (projectId) { if (projectId) {
await this.validateFeatureContext({ featureName, projectId }); await this.validateFeatureContext({ featureName, projectId });
} }
return feature;
if (environmentVariants) {
return this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(
featureName,
archived,
);
} else {
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
archived,
);
}
} }
/** /**
@ -632,6 +640,17 @@ class FeatureToggleService {
return this.featureToggleStore.getVariants(featureName); return this.featureToggleStore.getVariants(featureName);
} }
async getVariantsForEnv(
featureName: string,
environment: string,
): Promise<IVariant[]> {
const featureEnvironment = await this.featureEnvironmentStore.get({
featureName,
environment,
});
return featureEnvironment.variants || [];
}
async getFeatureMetadata(featureName: string): Promise<FeatureToggle> { async getFeatureMetadata(featureName: string): Promise<FeatureToggle> {
return this.featureToggleStore.get(featureName); return this.featureToggleStore.get(featureName);
} }
@ -705,6 +724,20 @@ class FeatureToggleService {
projectId, projectId,
); );
if (value.variants && value.variants.length > 0) {
const environments =
await this.featureEnvironmentStore.getEnvironmentsForFeature(
featureName,
);
environments.forEach(async (featureEnv) => {
await this.featureEnvironmentStore.addVariantsToFeatureEnvironment(
featureName,
featureEnv.environment,
value.variants,
);
});
}
const tags = await this.tagStore.getAllTagsForFeature(featureName); const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.eventStore.store( await this.eventStore.store(
@ -762,7 +795,7 @@ class FeatureToggleService {
}), }),
); );
await Promise.allSettled(tasks); await Promise.all(tasks);
return created; return created;
} }
@ -1195,10 +1228,30 @@ class FeatureToggleService {
createdBy: string, createdBy: string,
): Promise<FeatureToggle> { ): Promise<FeatureToggle> {
const oldVariants = await this.getVariants(featureName); const oldVariants = await this.getVariants(featureName);
const { newDocument } = await applyPatch(oldVariants, newVariants); const { newDocument } = applyPatch(oldVariants, newVariants);
return this.saveVariants(featureName, project, newDocument, createdBy); return this.saveVariants(featureName, project, newDocument, createdBy);
} }
async updateVariantsOnEnv(
featureName: string,
project: string,
environment: string,
newVariants: Operation[],
createdBy: string,
): Promise<IVariant[]> {
const oldVariants = await this.getVariantsForEnv(
featureName,
environment,
);
const { newDocument } = await applyPatch(oldVariants, newVariants);
return this.saveVariantsOnEnv(
featureName,
environment,
newDocument,
createdBy,
);
}
async saveVariants( async saveVariants(
featureName: string, featureName: string,
project: string, project: string,
@ -1229,6 +1282,38 @@ class FeatureToggleService {
return featureToggle; return featureToggle;
} }
async saveVariantsOnEnv(
featureName: string,
environment: string,
newVariants: IVariant[],
createdBy: string,
): Promise<IVariant[]> {
await variantsArraySchema.validateAsync(newVariants);
const fixedVariants = this.fixVariantWeights(newVariants);
const oldVariants = (
await this.featureEnvironmentStore.get({
featureName,
environment,
})
).variants;
await this.eventStore.store(
new EnvironmentVariantEvent({
featureName,
environment,
createdBy,
oldVariants,
newVariants: fixedVariants,
}),
);
await this.featureEnvironmentStore.addVariantsToFeatureEnvironment(
featureName,
environment,
fixedVariants,
);
return fixedVariants;
}
fixVariantWeights(variants: IVariant[]): IVariant[] { fixVariantWeights(variants: IVariant[]): IVariant[] {
let variableVariants = variants.filter((x) => { let variableVariants = variants.filter((x) => {
return x.weightType === WeightType.VARIABLE; return x.weightType === WeightType.VARIABLE;
@ -1265,7 +1350,9 @@ class FeatureToggleService {
} }
return x; return x;
}); });
return variableVariants.concat(fixedVariants); return variableVariants
.concat(fixedVariants)
.sort((a, b) => a.name.localeCompare(b.name));
} }
private async stopWhenChangeRequestsEnabled( private async stopWhenChangeRequestsEnabled(

View File

@ -1,5 +1,9 @@
import joi from 'joi'; import joi from 'joi';
import { featureSchema, featureTagSchema } from '../schema/feature-schema'; import {
featureSchema,
featureTagSchema,
variantsSchema,
} from '../schema/feature-schema';
import strategySchema from './strategy-schema'; import strategySchema from './strategy-schema';
import { tagSchema } from './tag-schema'; import { tagSchema } from './tag-schema';
import { tagTypeSchema } from './tag-type-schema'; import { tagTypeSchema } from './tag-type-schema';
@ -24,6 +28,7 @@ export const featureEnvironmentsSchema = joi.object().keys({
environment: joi.string(), environment: joi.string(),
featureName: joi.string(), featureName: joi.string(),
enabled: joi.boolean(), enabled: joi.boolean(),
variants: joi.array().items(variantsSchema).optional(),
}); });
export const environmentSchema = joi.object().keys({ export const environmentSchema = joi.object().keys({

View File

@ -46,7 +46,7 @@ import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-stor
import { IEnvironmentStore } from '../types/stores/environment-store'; import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IUnleashStores } from '../types/stores'; import { IUnleashStores } from '../types/stores';
import { DEFAULT_ENV, ALL_ENVS } from '../util/constants'; import { ALL_ENVS, DEFAULT_ENV } from '../util/constants';
import { GLOBAL_ENV } from '../types/environment'; import { GLOBAL_ENV } from '../types/environment';
import { ISegmentStore } from '../types/stores/segment-store'; import { ISegmentStore } from '../types/stores/segment-store';
import { PartialSome } from '../types/partial'; import { PartialSome } from '../types/partial';
@ -153,6 +153,18 @@ export default class StateService {
}); });
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
moveVariantsToFeatureEnvironments(data: any) {
data.featureEnvironments?.forEach((featureEnvironment) => {
let feature = data.features?.find(
(f) => f.name === featureEnvironment.featureName,
);
if (feature) {
featureEnvironment.variants = feature.variants || [];
}
});
}
async import({ async import({
data, data,
userName = 'importUser', userName = 'importUser',
@ -162,6 +174,9 @@ export default class StateService {
if (data.version === 2) { if (data.version === 2) {
this.replaceGlobalEnvWithDefaultEnv(data); this.replaceGlobalEnvWithDefaultEnv(data);
} }
if (!data.version || data.version < 4) {
this.moveVariantsToFeatureEnvironments(data);
}
const importData = await stateSchema.validateAsync(data); const importData = await stateSchema.validateAsync(data);
let importedEnvironments: IEnvironment[] = []; let importedEnvironments: IEnvironment[] = [];
@ -199,15 +214,10 @@ export default class StateService {
userName, userName,
dropBeforeImport, dropBeforeImport,
keepExisting, keepExisting,
featureEnvironments,
}); });
if (featureEnvironments) { if (featureEnvironments) {
// make sure the project and environment are connected
// before importing featureEnvironments
await this.linkFeatureEnvironments({
features,
featureEnvironments,
});
await this.importFeatureEnvironments({ await this.importFeatureEnvironments({
featureEnvironments, featureEnvironments,
}); });
@ -267,27 +277,7 @@ export default class StateService {
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async linkFeatureEnvironments({ enabledIn(feature: string, env) {
features,
featureEnvironments,
}): Promise<void> {
const linkTasks = featureEnvironments.map(async (fe) => {
const project = features.find(
(f) => f.project && f.name === fe.featureName,
).project;
if (project) {
return this.featureEnvironmentStore.connectProject(
fe.environment,
project,
true, // make it idempotent
);
}
});
await Promise.all(linkTasks);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
enabledInConfiguration(feature: string, env) {
const config = {}; const config = {};
env.filter((e) => e.featureName === feature).forEach((e) => { env.filter((e) => e.featureName === feature).forEach((e) => {
config[e.environment] = e.enabled || false; config[e.environment] = e.enabled || false;
@ -298,20 +288,15 @@ export default class StateService {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatureEnvironments({ featureEnvironments }): Promise<void> { async importFeatureEnvironments({ featureEnvironments }): Promise<void> {
await Promise.all( await Promise.all(
featureEnvironments.map((env) => featureEnvironments
this.toggleStore .filter(async (env) => {
.getProjectId(env.featureName) await this.environmentStore.exists(env.environment);
.then((id) => })
this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject( .map(async (featureEnvironment) =>
env.featureName, this.featureEnvironmentStore.addFeatureEnvironment(
id, featureEnvironment,
this.enabledInConfiguration(
env.featureName,
featureEnvironments,
),
),
), ),
), ),
); );
} }
@ -363,6 +348,7 @@ export default class StateService {
featureName: feature.name, featureName: feature.name,
environment: DEFAULT_ENV, environment: DEFAULT_ENV,
enabled: feature.enabled, enabled: feature.enabled,
variants: feature.variants || [],
})); }));
return { return {
features: newFeatures, features: newFeatures,
@ -377,6 +363,7 @@ export default class StateService {
userName, userName,
dropBeforeImport, dropBeforeImport,
keepExisting, keepExisting,
featureEnvironments,
}): Promise<void> { }): Promise<void> {
this.logger.info(`Importing ${features.length} feature toggles`); this.logger.info(`Importing ${features.length} feature toggles`);
const oldToggles = dropBeforeImport const oldToggles = dropBeforeImport
@ -398,12 +385,11 @@ export default class StateService {
.filter(filterExisting(keepExisting, oldToggles)) .filter(filterExisting(keepExisting, oldToggles))
.filter(filterEqual(oldToggles)) .filter(filterEqual(oldToggles))
.map(async (feature) => { .map(async (feature) => {
const { name, project, variants = [] } = feature;
await this.toggleStore.create(feature.project, feature); await this.toggleStore.create(feature.project, feature);
await this.toggleStore.saveVariants( await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(
project, feature.name,
name, feature.project,
variants, this.enabledIn(feature.name, featureEnvironments),
); );
await this.eventStore.store({ await this.eventStore.store({
type: FEATURE_IMPORT, type: FEATURE_IMPORT,
@ -772,7 +758,7 @@ export default class StateService {
segments, segments,
featureStrategySegments, featureStrategySegments,
]) => ({ ]) => ({
version: 3, version: 4,
features, features,
strategies, strategies,
projects, projects,

View File

@ -8,6 +8,8 @@ export const FEATURE_DELETED = 'feature-deleted';
export const FEATURE_UPDATED = 'feature-updated'; export const FEATURE_UPDATED = 'feature-updated';
export const FEATURE_METADATA_UPDATED = 'feature-metadata-updated'; export const FEATURE_METADATA_UPDATED = 'feature-metadata-updated';
export const FEATURE_VARIANTS_UPDATED = 'feature-variants-updated'; export const FEATURE_VARIANTS_UPDATED = 'feature-variants-updated';
export const FEATURE_ENVIRONMENT_VARIANTS_UPDATED =
'feature-environment-variants-updated';
export const FEATURE_PROJECT_CHANGE = 'feature-project-change'; export const FEATURE_PROJECT_CHANGE = 'feature-project-change';
export const FEATURE_ARCHIVED = 'feature-archived'; export const FEATURE_ARCHIVED = 'feature-archived';
export const FEATURE_REVIVED = 'feature-revived'; export const FEATURE_REVIVED = 'feature-revived';
@ -192,6 +194,30 @@ export class FeatureVariantEvent extends BaseEvent {
} }
} }
export class EnvironmentVariantEvent extends BaseEvent {
readonly environment: string;
readonly featureName: string;
readonly data: { variants: IVariant[] };
readonly preData: { variants: IVariant[] };
constructor(p: {
featureName: string;
environment: string;
createdBy: string;
newVariants: IVariant[];
oldVariants: IVariant[];
}) {
super(FEATURE_ENVIRONMENT_VARIANTS_UPDATED, p.createdBy);
this.featureName = p.featureName;
this.environment = p.environment;
this.data = { variants: p.newVariants };
this.preData = { variants: p.oldVariants };
}
}
export class FeatureChangeProjectEvent extends BaseEvent { export class FeatureChangeProjectEvent extends BaseEvent {
readonly project: string; readonly project: string;

View File

@ -91,6 +91,7 @@ export interface FeatureToggleLegacy extends FeatureToggle {
export interface IEnvironmentDetail extends IEnvironmentOverview { export interface IEnvironmentDetail extends IEnvironmentOverview {
strategies: IStrategyConfig[]; strategies: IStrategyConfig[];
variants: IVariant[];
} }
export interface ISortOrder { export interface ISortOrder {
@ -101,6 +102,7 @@ export interface IFeatureEnvironment {
environment: string; environment: string;
featureName: string; featureName: string;
enabled: boolean; enabled: boolean;
variants?: IVariant[];
} }
export interface IVariant { export interface IVariant {

View File

@ -1,4 +1,4 @@
import { IFeatureEnvironment } from '../model'; import { IFeatureEnvironment, IVariant } from '../model';
import { Store } from './store'; import { Store } from './store';
export interface FeatureEnvironmentKey { export interface FeatureEnvironmentKey {
@ -12,6 +12,9 @@ export interface IFeatureEnvironmentStore
environment: string, environment: string,
featureName: string, featureName: string,
): Promise<boolean>; ): Promise<boolean>;
getEnvironmentsForFeature(
featureName: string,
): Promise<IFeatureEnvironment[]>;
isEnvironmentEnabled( isEnvironmentEnabled(
featureName: string, featureName: string,
environment: string, environment: string,
@ -62,4 +65,15 @@ export interface IFeatureEnvironmentStore
sourceEnvironment: string, sourceEnvironment: string,
destinationEnvironment: string, destinationEnvironment: string,
): Promise<void>; ): Promise<void>;
addVariantsToFeatureEnvironment(
featureName: string,
environment: string,
variants: IVariant[],
): Promise<void>;
addFeatureEnvironment(
featureEnvironment: IFeatureEnvironment,
): Promise<void>;
clonePreviousVariants(environment: string, project: string): Promise<void>;
} }

View File

@ -34,6 +34,10 @@ export interface IFeatureStrategiesStore
featureName: string, featureName: string,
archived?: boolean, archived?: boolean,
): Promise<FeatureToggleWithEnvironment>; ): Promise<FeatureToggleWithEnvironment>;
getFeatureToggleWithVariantEnvs(
featureName: string,
archived?,
): Promise<FeatureToggleWithEnvironment>;
getFeatureOverview( getFeatureOverview(
projectId: string, projectId: string,
archived: boolean, archived: boolean,

View File

@ -16,7 +16,19 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
archive(featureName: string): Promise<FeatureToggle>; archive(featureName: string): Promise<FeatureToggle>;
revive(featureName: string): Promise<FeatureToggle>; revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>; getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
/**
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
* @param featureName
* TODO: Remove before release 5.0
*/
getVariants(featureName: string): Promise<IVariant[]>; getVariants(featureName: string): Promise<IVariant[]>;
/**
* TODO: Remove before release 5.0
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
* @param project
* @param featureName
* @param newVariants
*/
saveVariants( saveVariants(
project: string, project: string,
featureName: string, featureName: string,

View File

@ -0,0 +1,53 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
ALTER TABLE feature_environments ADD COLUMN variants JSONB DEFAULT '[]'::jsonb NOT NULL;
WITH feature_variants AS (SELECT variants, name FROM features)
UPDATE feature_environments SET variants = feature_variants.variants FROM feature_variants WHERE feature_name = feature_variants.name;
CREATE VIEW features_view AS
SELECT
features.name as name,
features.description as description,
features.type as type,
features.project as project,
features.stale as stale,
feature_environments.variants as variants,
features.impression_data as impression_data,
features.created_at as created_at,
features.last_seen_at as last_seen_at,
features.archived_at as archived_at,
feature_environments.enabled as enabled,
feature_environments.environment as environment,
environments.name as environment_name,
environments.type as environment_type,
environments.sort_order as environment_sort_order,
feature_strategies.id as strategy_id,
feature_strategies.strategy_name as strategy_name,
feature_strategies.parameters as parameters,
feature_strategies.constraints as constraints,
feature_strategies.sort_order as sort_order,
fss.segment_id as segments
FROM
features
LEFT JOIN feature_environments ON feature_environments.feature_name = features.name
LEFT JOIN feature_strategies ON feature_strategies.feature_name = feature_environments.feature_name
and feature_strategies.environment = feature_environments.environment
LEFT JOIN environments ON feature_environments.environment = environments.name
LEFT JOIN feature_strategy_segment as fss ON fss.feature_strategy_id = feature_strategies.id;
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
DROP VIEW features_view;
ALTER TABLE feature_environments DROP COLUMN variants;
`,
callback,
);
};

View File

@ -69,12 +69,25 @@ const playgroundRequest = async (
describe('Playground API E2E', () => { describe('Playground API E2E', () => {
// utility function for seeding the database before runs // utility function for seeding the database before runs
const seedDatabase = ( const seedDatabase = async (
database: ITestDb, database: ITestDb,
features: ClientFeatureSchema[], features: ClientFeatureSchema[],
environment: string, environment: string,
): Promise<FeatureToggle[]> => ): Promise<FeatureToggle[]> => {
Promise.all( // create environment if necessary
await database.stores.environmentStore
.create({
name: environment,
type: 'development',
enabled: true,
})
.catch(() => {
// purposefully left empty: env creation may fail if the
// env already exists, and because of the async nature
// of things, this is the easiest way to make it work.
});
return Promise.all(
features.map(async (feature) => { features.map(async (feature) => {
// create feature // create feature
const toggle = await database.stores.featureToggleStore.create( const toggle = await database.stores.featureToggleStore.create(
@ -82,28 +95,28 @@ describe('Playground API E2E', () => {
{ {
...feature, ...feature,
createdAt: undefined, createdAt: undefined,
variants: [ variants: null,
...(feature.variants ?? []).map((variant) => ({
...variant,
weightType: WeightType.VARIABLE,
stickiness: 'default',
})),
],
}, },
); );
// create environment if necessary // enable/disable the feature in environment
await database.stores.environmentStore await database.stores.featureEnvironmentStore.addEnvironmentToFeature(
.create({ feature.name,
name: environment, environment,
type: 'development', feature.enabled,
enabled: true, );
})
.catch(() => { await database.stores.featureToggleStore.saveVariants(
// purposefully left empty: env creation may fail if the feature.project,
// env already exists, and because of the async nature feature.name,
// of things, this is the easiest way to make it work. [
}); ...(feature.variants ?? []).map((variant) => ({
...variant,
weightType: WeightType.VARIABLE,
stickiness: 'default',
})),
],
);
// assign strategies // assign strategies
await Promise.all( await Promise.all(
@ -122,16 +135,10 @@ describe('Playground API E2E', () => {
), ),
); );
// enable/disable the feature in environment
await database.stores.featureEnvironmentStore.addEnvironmentToFeature(
feature.name,
environment,
feature.enabled,
);
return toggle; return toggle;
}), }),
); );
};
test('Returned features should be a subset of the provided toggles', async () => { test('Returned features should be a subset of the provided toggles', async () => {
await fc.assert( await fc.assert(

View File

@ -21,6 +21,11 @@ test('Can get variants for a feature', async () => {
const featureName = 'feature-variants'; const featureName = 'feature-variants';
const variantName = 'fancy-variant'; const variantName = 'fancy-variant';
await db.stores.featureToggleStore.create('default', { name: featureName }); await db.stores.featureToggleStore.create('default', { name: featureName });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants('default', featureName, [ await db.stores.featureToggleStore.saveVariants('default', featureName, [
{ {
name: variantName, name: variantName,
@ -89,6 +94,11 @@ test('Can patch variants for a feature and get a response of new variant', async
await db.stores.featureToggleStore.create('default', { await db.stores.featureToggleStore.create('default', {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants( await db.stores.featureToggleStore.saveVariants(
'default', 'default',
featureName, featureName,
@ -126,6 +136,13 @@ test('Can add variant for a feature', async () => {
await db.stores.featureToggleStore.create('default', { await db.stores.featureToggleStore.create('default', {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants( await db.stores.featureToggleStore.saveVariants(
'default', 'default',
featureName, featureName,
@ -174,6 +191,13 @@ test('Can remove variant for a feature', async () => {
await db.stores.featureToggleStore.create('default', { await db.stores.featureToggleStore.create('default', {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants( await db.stores.featureToggleStore.saveVariants(
'default', 'default',
featureName, featureName,
@ -211,6 +235,11 @@ test('PUT overwrites current variant on feature', async () => {
await db.stores.featureToggleStore.create('default', { await db.stores.featureToggleStore.create('default', {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants( await db.stores.featureToggleStore.saveVariants(
'default', 'default',
featureName, featureName,
@ -317,6 +346,12 @@ test('PATCHING with all variable weightTypes forces weights to sum to no less th
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = []; const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants); const observer = jsonpatch.observe(newVariants);
@ -434,6 +469,12 @@ test('Patching with a fixed variant and variable variants splits remaining weigh
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = []; const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants); const observer = jsonpatch.observe(newVariants);
newVariants.push({ newVariants.push({
@ -525,6 +566,12 @@ test('Multiple fixed variants gets added together to decide how much weight vari
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = []; const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants); const observer = jsonpatch.observe(newVariants);
@ -570,6 +617,12 @@ test('If sum of fixed variant weight exceed 1000 fails with 400', async () => {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = []; const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants); const observer = jsonpatch.observe(newVariants);
@ -611,6 +664,12 @@ test('If sum of fixed variant weight equals 1000 variable variants gets weight 0
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = []; const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants); const observer = jsonpatch.observe(newVariants);
@ -673,6 +732,12 @@ test('PATCH endpoint validates uniqueness of variant names', async () => {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants( await db.stores.featureToggleStore.saveVariants(
'default', 'default',
featureName, featureName,
@ -711,6 +776,13 @@ test('PUT endpoint validates uniqueness of variant names', async () => {
await db.stores.featureToggleStore.create('default', { await db.stores.featureToggleStore.create('default', {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await app.request await app.request
.put(`/api/admin/projects/default/features/${featureName}/variants`) .put(`/api/admin/projects/default/features/${featureName}/variants`)
.send([ .send([
@ -741,6 +813,12 @@ test('Variants should be sorted by their name when PUT', async () => {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await app.request await app.request
.put(`/api/admin/projects/default/features/${featureName}/variants`) .put(`/api/admin/projects/default/features/${featureName}/variants`)
.send([ .send([
@ -784,6 +862,12 @@ test('Variants should be sorted by name when PATCHed as well', async () => {
name: featureName, name: featureName,
}); });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const variants: IVariant[] = []; const variants: IVariant[] = [];
const observer = jsonpatch.observe(variants); const observer = jsonpatch.observe(variants);
variants.push({ variants.push({

View File

@ -320,7 +320,7 @@ test('Roundtrip with strategies in multiple environments works', async () => {
test(`Importing version 2 replaces :global: environment with 'default'`, async () => { test(`Importing version 2 replaces :global: environment with 'default'`, async () => {
await app.request await app.request
.post('/api/admin/state/import') .post('/api/admin/state/import?drop=true')
.attach('file', 'src/test/examples/exported412-version2.json') .attach('file', 'src/test/examples/exported412-version2.json')
.expect(202); .expect(202);
const env = await app.services.environmentService.get(DEFAULT_ENV); const env = await app.services.environmentService.get(DEFAULT_ENV);
@ -328,7 +328,7 @@ test(`Importing version 2 replaces :global: environment with 'default'`, async (
const feature = await app.services.featureToggleServiceV2.getFeatureToggle( const feature = await app.services.featureToggleServiceV2.getFeatureToggle(
'this-is-fun', 'this-is-fun',
); );
expect(feature.environments).toHaveLength(4); expect(feature.environments).toHaveLength(1);
expect(feature.environments[0].name).toBe(DEFAULT_ENV); expect(feature.environments[0].name).toBe(DEFAULT_ENV);
}); });

View File

@ -5840,6 +5840,162 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/variants": {
"get": {
"operationId": "getEnvironmentFeatureVariants",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "featureName",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "environment",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/featureVariantsSchema",
},
},
},
"description": "featureVariantsSchema",
},
},
"tags": [
"Features",
],
},
"patch": {
"operationId": "patchEnvironmentsFeatureVariants",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "featureName",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "environment",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/patchesSchema",
},
},
},
"description": "patchesSchema",
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/featureVariantsSchema",
},
},
},
"description": "featureVariantsSchema",
},
},
"tags": [
"Features",
],
},
"put": {
"operationId": "overwriteEnvironmentFeatureVariants",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "featureName",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "environment",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/variantsSchema",
},
},
},
"description": "variantsSchema",
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/featureVariantsSchema",
},
},
},
"description": "featureVariantsSchema",
},
},
"tags": [
"Features",
],
},
},
"/api/admin/projects/{projectId}/features/{featureName}/variants": { "/api/admin/projects/{projectId}/features/{featureName}/variants": {
"get": { "get": {
"operationId": "getFeatureVariants", "operationId": "getFeatureVariants",

View File

@ -8,11 +8,14 @@ import User from '../../../lib/types/user';
import { IConstraint } from '../../../lib/types/model'; import { IConstraint } from '../../../lib/types/model';
import { AccessService } from '../../../lib/services/access-service'; import { AccessService } from '../../../lib/services/access-service';
import { GroupService } from '../../../lib/services/group-service'; import { GroupService } from '../../../lib/services/group-service';
import EnvironmentService from '../../../lib/services/environment-service';
let stores; let stores;
let db; let db;
let service: FeatureToggleService; let service: FeatureToggleService;
let segmentService: SegmentService; let segmentService: SegmentService;
let environmentService: EnvironmentService;
let unleashConfig;
const mockConstraints = (): IConstraint[] => { const mockConstraints = (): IConstraint[] => {
return Array.from({ length: 5 }).map(() => ({ return Array.from({ length: 5 }).map(() => ({
@ -28,6 +31,7 @@ beforeAll(async () => {
'feature_toggle_service_v2_service_serial', 'feature_toggle_service_v2_service_serial',
config.getLogger, config.getLogger,
); );
unleashConfig = config;
stores = db.stores; stores = db.stores;
segmentService = new SegmentService(stores, config); segmentService = new SegmentService(stores, config);
const groupService = new GroupService(stores, config); const groupService = new GroupService(stores, config);
@ -206,3 +210,46 @@ test('should not get empty rows as features', async () => {
expect(features.length).toBe(7); expect(features.length).toBe(7);
expect(namelessFeature).toBeUndefined(); expect(namelessFeature).toBeUndefined();
}); });
test('adding and removing an environment preserves variants when variants per env is off', async () => {
const featureName = 'something-that-has-variants';
const prodEnv = 'mock-prod-env';
await stores.environmentStore.create({
name: prodEnv,
type: 'production',
});
await service.createFeatureToggle(
'default',
{
name: featureName,
description: 'Second toggle',
variants: [
{
name: 'variant1',
weight: 100,
weightType: 'fix',
stickiness: 'default',
},
],
},
'random_user',
);
//force the variantEnvironments flag off so that we can test legacy behavior
environmentService = new EnvironmentService(stores, {
...unleashConfig,
flagResolver: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isEnabled: (toggleName: string) => false,
},
});
await environmentService.addEnvironmentToProject(prodEnv, 'default');
await environmentService.removeEnvironmentFromProject(prodEnv, 'default');
await environmentService.addEnvironmentToProject(prodEnv, 'default');
const toggle = await service.getFeature(featureName, false, null, false);
expect(toggle.variants).toHaveLength(1);
});

View File

@ -93,22 +93,6 @@ export const seedDatabaseForPlaygroundTest = async (
return Promise.all( return Promise.all(
features.map(async (feature) => { features.map(async (feature) => {
// create feature
const toggle = await database.stores.featureToggleStore.create(
feature.project,
{
...feature,
createdAt: undefined,
variants: [
...(feature.variants ?? []).map((variant) => ({
...variant,
weightType: WeightType.VARIABLE,
stickiness: 'default',
})),
],
},
);
// create environment if necessary // create environment if necessary
await database.stores.environmentStore await database.stores.environmentStore
.create({ .create({
@ -122,6 +106,35 @@ export const seedDatabaseForPlaygroundTest = async (
// of things, this is the easiest way to make it work. // of things, this is the easiest way to make it work.
}); });
// create feature
const toggle = await database.stores.featureToggleStore.create(
feature.project,
{
...feature,
createdAt: undefined,
variants: null,
},
);
// enable/disable the feature in environment
await database.stores.featureEnvironmentStore.addEnvironmentToFeature(
feature.name,
environment,
feature.enabled,
);
await database.stores.featureToggleStore.saveVariants(
feature.project,
feature.name,
[
...(feature.variants ?? []).map((variant) => ({
...variant,
weightType: WeightType.VARIABLE,
stickiness: 'default',
})),
],
);
// assign strategies // assign strategies
await Promise.all( await Promise.all(
(feature.strategies || []).map( (feature.strategies || []).map(
@ -152,13 +165,6 @@ export const seedDatabaseForPlaygroundTest = async (
), ),
); );
// enable/disable the feature in environment
await database.stores.featureEnvironmentStore.addEnvironmentToFeature(
feature.name,
environment,
feature.enabled,
);
return toggle; return toggle;
}), }),
); );

View File

@ -0,0 +1,164 @@
import { createTestConfig } from '../../config/test-config';
import dbInit from '../helpers/database-init';
import StateService from '../../../lib/services/state-service';
import oldFormat from '../../examples/variantsexport_v3.json';
import { WeightType } from '../../../lib/types/model';
let stores;
let db;
let stateService: StateService;
beforeAll(async () => {
const config = createTestConfig();
db = await dbInit('state_service_serial', config.getLogger);
stores = db.stores;
stateService = new StateService(stores, config);
});
afterAll(async () => {
db.destroy();
});
test('Exporting featureEnvironmentVariants should work', async () => {
await stores.projectStore.create({
id: 'fancy',
name: 'extra',
description: 'No surprises here',
});
await stores.environmentStore.create({
name: 'dev',
type: 'development',
});
await stores.environmentStore.create({
name: 'prod',
type: 'production',
});
await stores.featureToggleStore.create('fancy', {
name: 'Some-feature',
});
await stores.featureToggleStore.create('fancy', {
name: 'another-feature',
});
await stores.featureEnvironmentStore.addEnvironmentToFeature(
'Some-feature',
'dev',
true,
);
await stores.featureEnvironmentStore.addEnvironmentToFeature(
'another-feature',
'dev',
true,
);
await stores.featureEnvironmentStore.addEnvironmentToFeature(
'another-feature',
'prod',
true,
);
await stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(
'Some-feature',
'dev',
[
{
name: 'blue',
weight: 333,
stickiness: 'default',
weightType: WeightType.VARIABLE,
},
{
name: 'green',
weight: 333,
stickiness: 'default',
weightType: WeightType.VARIABLE,
},
{
name: 'red',
weight: 333,
stickiness: 'default',
weightType: WeightType.VARIABLE,
},
],
);
await stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(
'another-feature',
'dev',
[
{
name: 'purple',
weight: 333,
stickiness: 'default',
weightType: '',
},
{
name: 'lilac',
weight: 333,
stickiness: 'default',
weightType: '',
},
{
name: 'azure',
weight: 333,
stickiness: 'default',
weightType: '',
},
],
);
await stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(
'another-feature',
'prod',
[
{
name: 'purple',
weight: 333,
stickiness: 'default',
weightType: '',
},
{
name: 'lilac',
weight: 333,
stickiness: 'default',
weightType: '',
},
{
name: 'azure',
weight: 333,
stickiness: 'default',
weightType: '',
},
],
);
const exportedData = await stateService.export({});
expect(
exportedData.featureEnvironments.find(
(fE) => fE.featureName === 'Some-feature',
).variants,
).toHaveLength(3);
});
test('Should import variants from old format and convert to new format (per environment)', async () => {
await stateService.import({
data: oldFormat,
keepExisting: false,
dropBeforeImport: true,
});
let featureEnvironments = await stores.featureEnvironmentStore.getAll();
expect(featureEnvironments).toHaveLength(6); // There are 3 environments enabled and 2 features
expect(
featureEnvironments
.filter((fE) => fE.featureName === 'variants-tester' && fE.enabled)
.every((e) => e.variants.length === 4),
).toBeTruthy();
});
test('Should import variants in new format (per environment)', async () => {
await stateService.import({
data: oldFormat,
keepExisting: false,
dropBeforeImport: true,
});
let exportedJson = await stateService.export({});
await stateService.import({
data: exportedJson,
keepExisting: false,
dropBeforeImport: true,
});
let featureEnvironments = await stores.featureEnvironmentStore.getAll();
expect(featureEnvironments).toHaveLength(6); // 3 environments, 2 features === 6 rows
});

View File

@ -0,0 +1,245 @@
{
"version": 3,
"features": [
{
"name": "UX-toggle1",
"description": "Toggle to make UX great",
"type": "release",
"project": "default",
"stale": false,
"variants": [],
"createdAt": "2022-08-19T11:12:02.559Z",
"lastSeenAt": null,
"impressionData": false,
"archivedAt": null,
"archived": false
},
{
"name": "variants-tester",
"description": "",
"type": "release",
"project": "default",
"stale": false,
"variants": [
{
"name": "azure",
"weight": 250,
"weightType": "variable",
"stickiness": "default",
"overrides": []
},
{
"name": "lilac",
"weight": 250,
"weightType": "variable",
"stickiness": "default",
"overrides": []
},
{
"name": "maroon",
"weight": 250,
"weightType": "variable",
"stickiness": "default",
"overrides": []
},
{
"name": "purple",
"weight": 250,
"weightType": "variable",
"stickiness": "default",
"overrides": []
}
],
"createdAt": "2022-11-14T12:06:52.562Z",
"lastSeenAt": null,
"impressionData": false,
"archivedAt": null,
"archived": false
}
],
"strategies": [
{
"name": "gradualRolloutRandom",
"description": "Randomly activate the feature toggle. No stickiness.",
"parameters": [
{
"name": "percentage",
"type": "percentage",
"description": "",
"required": false
}
],
"deprecated": true
},
{
"name": "gradualRolloutSessionId",
"description": "Gradually activate feature toggle. Stickiness based on session id.",
"parameters": [
{
"name": "percentage",
"type": "percentage",
"description": "",
"required": false
},
{
"name": "groupId",
"type": "string",
"description": "Used to define a activation groups, which allows you to correlate across feature toggles.",
"required": true
}
],
"deprecated": true
},
{
"name": "gradualRolloutUserId",
"description": "Gradually activate feature toggle for logged in users. Stickiness based on user id.",
"parameters": [
{
"name": "percentage",
"type": "percentage",
"description": "",
"required": false
},
{
"name": "groupId",
"type": "string",
"description": "Used to define a activation groups, which allows you to correlate across feature toggles.",
"required": true
}
],
"deprecated": true
}
],
"projects": [
{
"id": "default",
"name": "Default",
"description": "Default project",
"createdAt": "2022-06-15T09:04:10.979Z",
"health": 100,
"updatedAt": "2022-11-14T12:05:01.328Z"
}
],
"tagTypes": [
{
"name": "simple",
"description": "Used to simplify filtering of features",
"icon": "#"
}
],
"tags": [],
"featureTags": [],
"featureStrategies": [
{
"id": "6fe19ea2-c00e-41f4-a4b0-407dd87837c3",
"featureName": "UX-toggle1",
"projectId": "default",
"environment": "development",
"strategyName": "default",
"parameters": {},
"constraints": [],
"createdAt": "2022-08-19T11:12:32.398Z"
},
{
"id": "f1834062-484f-4211-a39b-41f229c12a01",
"featureName": "UX-toggle1",
"projectId": "default",
"environment": "production",
"strategyName": "flexibleRollout",
"parameters": {
"groupId": "UX-toggle1",
"rollout": "50",
"stickiness": "default"
},
"constraints": [],
"createdAt": "2022-08-19T11:12:45.779Z"
},
{
"id": "ff954a04-1155-4420-ba83-1ded384f137c",
"featureName": "UX-toggle1",
"projectId": "default",
"environment": "development",
"strategyName": "userWithId",
"parameters": {
"userIds": ""
},
"constraints": [],
"createdAt": "2022-08-19T11:12:55.382Z"
},
{
"id": "6a0f8da5-398f-4d26-9089-5a87e39dbca6",
"featureName": "variants-tester",
"projectId": "default",
"environment": "development",
"strategyName": "flexibleRollout",
"parameters": {
"groupId": "variants-tester",
"rollout": "100",
"stickiness": "default"
},
"constraints": [],
"createdAt": "2022-11-14T12:07:37.873Z"
},
{
"id": "5c35bdd1-e30b-491f-b3ed-b1c7e08c5abc",
"featureName": "variants-tester",
"projectId": "default",
"environment": "production",
"strategyName": "flexibleRollout",
"parameters": {
"groupId": "variants-tester",
"rollout": "100",
"stickiness": "default"
},
"constraints": [],
"createdAt": "2022-11-14T12:07:38.227Z"
}
],
"environments": [
{
"name": "default",
"type": "development",
"sortOrder": 100,
"enabled": true,
"protected": true
},
{
"name": "development",
"type": "development",
"sortOrder": 100,
"enabled": true,
"protected": false
},
{
"name": "production",
"type": "production",
"sortOrder": 200,
"enabled": true,
"protected": false
}
],
"featureEnvironments": [
{
"enabled": true,
"featureName": "UX-toggle1",
"environment": "development"
},
{
"enabled": true,
"featureName": "variants-tester",
"environment": "production"
},
{
"enabled": true,
"featureName": "variants-tester",
"environment": "development"
},
{
"enabled": true,
"featureName": "UX-toggle1",
"environment": "production"
}
],
"segments": [],
"featureStrategySegments": []
}

View File

@ -2,7 +2,7 @@ import {
FeatureEnvironmentKey, FeatureEnvironmentKey,
IFeatureEnvironmentStore, IFeatureEnvironmentStore,
} from '../../lib/types/stores/feature-environment-store'; } from '../../lib/types/stores/feature-environment-store';
import { IFeatureEnvironment } from '../../lib/types/model'; import { IFeatureEnvironment, IVariant } from '../../lib/types/model';
import NotFoundError from '../../lib/error/notfound-error'; import NotFoundError from '../../lib/error/notfound-error';
export default class FakeFeatureEnvironmentStore export default class FakeFeatureEnvironmentStore
@ -18,6 +18,20 @@ export default class FakeFeatureEnvironmentStore
this.featureEnvironments.push({ environment, enabled, featureName }); this.featureEnvironments.push({ environment, enabled, featureName });
} }
async addVariantsToFeatureEnvironment(
featureName: string,
environment: string,
variants: IVariant[],
): Promise<void> {
this.featureEnvironments
.filter(
(fe) =>
fe.featureName === featureName &&
fe.environment === environment,
)
.map((fe) => (fe.variants = variants));
}
async delete(key: FeatureEnvironmentKey): Promise<void> { async delete(key: FeatureEnvironmentKey): Promise<void> {
this.featureEnvironments.splice( this.featureEnvironments.splice(
this.featureEnvironments.findIndex( this.featureEnvironments.findIndex(
@ -181,4 +195,27 @@ export default class FakeFeatureEnvironmentStore
): Promise<void> { ): Promise<void> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
async addFeatureEnvironment(
featureEnvironment: IFeatureEnvironment,
): Promise<void> {
this.featureEnvironments.push(featureEnvironment);
return Promise.resolve();
}
getEnvironmentsForFeature(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureName: string,
): Promise<IFeatureEnvironment[]> {
throw new Error('Method not implemented.');
}
clonePreviousVariants(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
project: string,
): Promise<void> {
throw new Error('Method not implemented.');
}
} }

View File

@ -153,6 +153,13 @@ export default class FakeFeatureStrategiesStore
); );
} }
getFeatureToggleWithVariantEnvs(
featureName: string,
archived?: boolean,
): Promise<FeatureToggleWithEnvironment> {
return this.getFeatureToggleWithEnvs(featureName, archived);
}
async getFeatureOverview( async getFeatureOverview(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string, projectId: string,

View File

@ -3,7 +3,12 @@ import {
IFeatureToggleStore, IFeatureToggleStore,
} from '../../lib/types/stores/feature-toggle-store'; } from '../../lib/types/stores/feature-toggle-store';
import NotFoundError from '../../lib/error/notfound-error'; import NotFoundError from '../../lib/error/notfound-error';
import { FeatureToggle, FeatureToggleDTO, IVariant } from 'lib/types/model'; import {
FeatureToggle,
FeatureToggleDTO,
IFeatureEnvironment,
IVariant,
} from 'lib/types/model';
export default class FakeFeatureToggleStore implements IFeatureToggleStore { export default class FakeFeatureToggleStore implements IFeatureToggleStore {
features: FeatureToggle[] = []; features: FeatureToggle[] = [];
@ -126,6 +131,25 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return feature.variants as IVariant[]; return feature.variants as IVariant[];
} }
async getAllVariants(): Promise<IFeatureEnvironment[]> {
let features = await this.getAll();
let variants = features.flatMap((feature) => ({
featureName: feature.name,
environment: 'development',
variants: feature.variants,
enabled: true,
}));
return Promise.resolve(variants);
}
getVariantsForEnv(
featureName: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment_name: string,
): Promise<IVariant[]> {
return this.getVariants(featureName);
}
async saveVariants( async saveVariants(
project: string, project: string,
featureName: string, featureName: string,
@ -135,4 +159,18 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
feature.variants = newVariants; feature.variants = newVariants;
return feature; return feature;
} }
async saveVariantsOnEnv(
featureName: string,
environment: string,
newVariants: IVariant[],
): Promise<IVariant[]> {
await this.saveVariants('default', featureName, newVariants);
return Promise.resolve(newVariants);
}
dropAllVariants(): Promise<void> {
this.features.forEach((feature) => (feature.variants = []));
return Promise.resolve();
}
} }