1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-31 01:16:01 +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 metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import { IFeatureEnvironment } from '../types/model';
import { IFeatureEnvironment, IVariant } from '../types/model';
import NotFoundError from '../error/notfound-error';
import { v4 as uuidv4 } from 'uuid';
@ -21,6 +21,7 @@ interface IFeatureEnvironmentRow {
environment: string;
feature_name: string;
enabled: boolean;
variants?: [];
}
interface ISegmentRow {
@ -86,6 +87,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
enabled: md.enabled,
featureName,
environment,
variants: md.variants,
};
}
throw new NotFoundError(
@ -102,6 +104,7 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
enabled: r.enabled,
featureName: r.feature_name,
environment: r.environment,
variants: r.variants,
}));
}
@ -162,6 +165,24 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
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(
environment: string,
featureName: string,
@ -261,6 +282,41 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
.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(
featureName: 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(
sourceEnvironment: string,
destinationEnvironment: string,

View File

@ -215,58 +215,25 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
async getFeatureToggleWithEnvs(
featureName: string,
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> {
const stopTimer = this.timer('getFeatureAdmin');
const rows = await this.db('features')
.select(
'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)
const rows = await this.db('features_view')
.where('name', featureName)
.modify(FeatureToggleStore.filterByArchived, archived);
stopTimer();
if (rows.length > 0) {
@ -280,7 +247,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
acc.description = r.description;
acc.project = r.project;
acc.stale = r.stale;
acc.variants = r.variants;
acc.createdAt = r.created_at;
acc.lastSeenAt = r.last_seen_at;
acc.type = r.type;
@ -289,8 +256,15 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
name: 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.type = r.environment_type;
env.sortOrder = r.environment_sort_order;
@ -325,8 +299,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
);
return e;
});
featureToggle.variants = featureToggle.variants || [];
featureToggle.variants.sort((a, b) => a.name.localeCompare(b.name));
featureToggle.archived = archived;
return featureToggle;
}

View File

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

View File

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

View File

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

View File

@ -462,10 +462,12 @@ export default class ProjectFeaturesController extends Controller {
res: Response,
): Promise<void> {
const { featureName, projectId } = req.params;
const { variantEnvironments } = req.query;
const feature = await this.featureService.getFeature(
featureName,
false,
projectId,
variantEnvironments === 'true',
);
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';
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 {
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(
@ -131,4 +184,54 @@ export default class VariantsController extends Controller {
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(),
});
const variantValueSchema = joi
export const variantValueSchema = joi
.string()
.required()
// 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 { IProjectStore } from 'lib/types/stores/project-store';
import MinimumOneEnvironmentError from '../error/minimum-one-environment-error';
import { IFlagResolver } from 'lib/types/experimental';
export default class EnvironmentService {
private logger: Logger;
@ -23,6 +24,8 @@ export default class EnvironmentService {
private featureEnvironmentStore: IFeatureEnvironmentStore;
private flagResolver: IFlagResolver;
constructor(
{
environmentStore,
@ -36,13 +39,17 @@ export default class EnvironmentService {
| 'featureEnvironmentStore'
| 'projectStore'
>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
{
getLogger,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
) {
this.logger = getLogger('services/environment-service.ts');
this.environmentStore = environmentStore;
this.featureStrategiesStore = featureStrategiesStore;
this.featureEnvironmentStore = featureEnvironmentStore;
this.projectStore = projectStore;
this.flagResolver = flagResolver;
}
async getAll(): Promise<IEnvironment[]> {
@ -90,6 +97,13 @@ export default class EnvironmentService {
environment,
projectId,
);
if (!this.flagResolver.isEnabled('variantsPerEnvironment')) {
await this.featureEnvironmentStore.clonePreviousVariants(
environment,
projectId,
);
}
} catch (e) {
if (e.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new NameExistsError(

View File

@ -25,6 +25,7 @@ import {
FeatureStrategyRemoveEvent,
FeatureStrategyUpdateEvent,
FeatureVariantEvent,
EnvironmentVariantEvent,
} from '../types/events';
import NotFoundError from '../error/notfound-error';
import {
@ -611,16 +612,23 @@ class FeatureToggleService {
featureName: string,
archived: boolean = false,
projectId?: string,
environmentVariants: boolean = false,
): Promise<FeatureToggleWithEnvironment> {
const feature =
await this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName,
archived,
);
if (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);
}
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> {
return this.featureToggleStore.get(featureName);
}
@ -705,6 +724,20 @@ class FeatureToggleService {
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);
await this.eventStore.store(
@ -762,7 +795,7 @@ class FeatureToggleService {
}),
);
await Promise.allSettled(tasks);
await Promise.all(tasks);
return created;
}
@ -1195,10 +1228,30 @@ class FeatureToggleService {
createdBy: string,
): Promise<FeatureToggle> {
const oldVariants = await this.getVariants(featureName);
const { newDocument } = await applyPatch(oldVariants, newVariants);
const { newDocument } = applyPatch(oldVariants, newVariants);
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(
featureName: string,
project: string,
@ -1229,6 +1282,38 @@ class FeatureToggleService {
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[] {
let variableVariants = variants.filter((x) => {
return x.weightType === WeightType.VARIABLE;
@ -1265,7 +1350,9 @@ class FeatureToggleService {
}
return x;
});
return variableVariants.concat(fixedVariants);
return variableVariants
.concat(fixedVariants)
.sort((a, b) => a.name.localeCompare(b.name));
}
private async stopWhenChangeRequestsEnabled(

View File

@ -1,5 +1,9 @@
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 { tagSchema } from './tag-schema';
import { tagTypeSchema } from './tag-type-schema';
@ -24,6 +28,7 @@ export const featureEnvironmentsSchema = joi.object().keys({
environment: joi.string(),
featureName: joi.string(),
enabled: joi.boolean(),
variants: joi.array().items(variantsSchema).optional(),
});
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 { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
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 { ISegmentStore } from '../types/stores/segment-store';
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({
data,
userName = 'importUser',
@ -162,6 +174,9 @@ export default class StateService {
if (data.version === 2) {
this.replaceGlobalEnvWithDefaultEnv(data);
}
if (!data.version || data.version < 4) {
this.moveVariantsToFeatureEnvironments(data);
}
const importData = await stateSchema.validateAsync(data);
let importedEnvironments: IEnvironment[] = [];
@ -199,15 +214,10 @@ export default class StateService {
userName,
dropBeforeImport,
keepExisting,
featureEnvironments,
});
if (featureEnvironments) {
// make sure the project and environment are connected
// before importing featureEnvironments
await this.linkFeatureEnvironments({
features,
featureEnvironments,
});
await this.importFeatureEnvironments({
featureEnvironments,
});
@ -267,27 +277,7 @@ export default class StateService {
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async linkFeatureEnvironments({
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) {
enabledIn(feature: string, env) {
const config = {};
env.filter((e) => e.featureName === feature).forEach((e) => {
config[e.environment] = e.enabled || false;
@ -298,20 +288,15 @@ export default class StateService {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatureEnvironments({ featureEnvironments }): Promise<void> {
await Promise.all(
featureEnvironments.map((env) =>
this.toggleStore
.getProjectId(env.featureName)
.then((id) =>
this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(
env.featureName,
id,
this.enabledInConfiguration(
env.featureName,
featureEnvironments,
),
),
featureEnvironments
.filter(async (env) => {
await this.environmentStore.exists(env.environment);
})
.map(async (featureEnvironment) =>
this.featureEnvironmentStore.addFeatureEnvironment(
featureEnvironment,
),
),
),
);
}
@ -363,6 +348,7 @@ export default class StateService {
featureName: feature.name,
environment: DEFAULT_ENV,
enabled: feature.enabled,
variants: feature.variants || [],
}));
return {
features: newFeatures,
@ -377,6 +363,7 @@ export default class StateService {
userName,
dropBeforeImport,
keepExisting,
featureEnvironments,
}): Promise<void> {
this.logger.info(`Importing ${features.length} feature toggles`);
const oldToggles = dropBeforeImport
@ -398,12 +385,11 @@ export default class StateService {
.filter(filterExisting(keepExisting, oldToggles))
.filter(filterEqual(oldToggles))
.map(async (feature) => {
const { name, project, variants = [] } = feature;
await this.toggleStore.create(feature.project, feature);
await this.toggleStore.saveVariants(
project,
name,
variants,
await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(
feature.name,
feature.project,
this.enabledIn(feature.name, featureEnvironments),
);
await this.eventStore.store({
type: FEATURE_IMPORT,
@ -772,7 +758,7 @@ export default class StateService {
segments,
featureStrategySegments,
]) => ({
version: 3,
version: 4,
features,
strategies,
projects,

View File

@ -8,6 +8,8 @@ export const FEATURE_DELETED = 'feature-deleted';
export const FEATURE_UPDATED = 'feature-updated';
export const FEATURE_METADATA_UPDATED = 'feature-metadata-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_ARCHIVED = 'feature-archived';
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 {
readonly project: string;

View File

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

View File

@ -1,4 +1,4 @@
import { IFeatureEnvironment } from '../model';
import { IFeatureEnvironment, IVariant } from '../model';
import { Store } from './store';
export interface FeatureEnvironmentKey {
@ -12,6 +12,9 @@ export interface IFeatureEnvironmentStore
environment: string,
featureName: string,
): Promise<boolean>;
getEnvironmentsForFeature(
featureName: string,
): Promise<IFeatureEnvironment[]>;
isEnvironmentEnabled(
featureName: string,
environment: string,
@ -62,4 +65,15 @@ export interface IFeatureEnvironmentStore
sourceEnvironment: string,
destinationEnvironment: string,
): 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,
archived?: boolean,
): Promise<FeatureToggleWithEnvironment>;
getFeatureToggleWithVariantEnvs(
featureName: string,
archived?,
): Promise<FeatureToggleWithEnvironment>;
getFeatureOverview(
projectId: string,
archived: boolean,

View File

@ -16,7 +16,19 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
archive(featureName: string): Promise<FeatureToggle>;
revive(featureName: string): 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[]>;
/**
* 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(
project: 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', () => {
// utility function for seeding the database before runs
const seedDatabase = (
const seedDatabase = async (
database: ITestDb,
features: ClientFeatureSchema[],
environment: string,
): Promise<FeatureToggle[]> =>
Promise.all(
): Promise<FeatureToggle[]> => {
// 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) => {
// create feature
const toggle = await database.stores.featureToggleStore.create(
@ -82,28 +95,28 @@ describe('Playground API E2E', () => {
{
...feature,
createdAt: undefined,
variants: [
...(feature.variants ?? []).map((variant) => ({
...variant,
weightType: WeightType.VARIABLE,
stickiness: 'default',
})),
],
variants: null,
},
);
// 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.
});
// 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
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;
}),
);
};
test('Returned features should be a subset of the provided toggles', async () => {
await fc.assert(

View File

@ -21,6 +21,11 @@ test('Can get variants for a feature', async () => {
const featureName = 'feature-variants';
const variantName = 'fancy-variant';
await db.stores.featureToggleStore.create('default', { name: featureName });
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants('default', featureName, [
{
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', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants(
'default',
featureName,
@ -126,6 +136,13 @@ test('Can add variant for a feature', async () => {
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants(
'default',
featureName,
@ -174,6 +191,13 @@ test('Can remove variant for a feature', async () => {
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants(
'default',
featureName,
@ -211,6 +235,11 @@ test('PUT overwrites current variant on feature', async () => {
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants(
'default',
featureName,
@ -317,6 +346,12 @@ test('PATCHING with all variable weightTypes forces weights to sum to no less th
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants);
@ -434,6 +469,12 @@ test('Patching with a fixed variant and variable variants splits remaining weigh
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants);
newVariants.push({
@ -525,6 +566,12 @@ test('Multiple fixed variants gets added together to decide how much weight vari
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants);
@ -570,6 +617,12 @@ test('If sum of fixed variant weight exceed 1000 fails with 400', async () => {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = [];
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,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const newVariants: IVariant[] = [];
const observer = jsonpatch.observe(newVariants);
@ -673,6 +732,12 @@ test('PATCH endpoint validates uniqueness of variant names', async () => {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await db.stores.featureToggleStore.saveVariants(
'default',
featureName,
@ -711,6 +776,13 @@ test('PUT endpoint validates uniqueness of variant names', async () => {
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await app.request
.put(`/api/admin/projects/default/features/${featureName}/variants`)
.send([
@ -741,6 +813,12 @@ test('Variants should be sorted by their name when PUT', async () => {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
await app.request
.put(`/api/admin/projects/default/features/${featureName}/variants`)
.send([
@ -784,6 +862,12 @@ test('Variants should be sorted by name when PATCHed as well', async () => {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);
const variants: IVariant[] = [];
const observer = jsonpatch.observe(variants);
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 () => {
await app.request
.post('/api/admin/state/import')
.post('/api/admin/state/import?drop=true')
.attach('file', 'src/test/examples/exported412-version2.json')
.expect(202);
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(
'this-is-fun',
);
expect(feature.environments).toHaveLength(4);
expect(feature.environments).toHaveLength(1);
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": {
"get": {
"operationId": "getFeatureVariants",

View File

@ -8,11 +8,14 @@ import User from '../../../lib/types/user';
import { IConstraint } from '../../../lib/types/model';
import { AccessService } from '../../../lib/services/access-service';
import { GroupService } from '../../../lib/services/group-service';
import EnvironmentService from '../../../lib/services/environment-service';
let stores;
let db;
let service: FeatureToggleService;
let segmentService: SegmentService;
let environmentService: EnvironmentService;
let unleashConfig;
const mockConstraints = (): IConstraint[] => {
return Array.from({ length: 5 }).map(() => ({
@ -28,6 +31,7 @@ beforeAll(async () => {
'feature_toggle_service_v2_service_serial',
config.getLogger,
);
unleashConfig = config;
stores = db.stores;
segmentService = new SegmentService(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(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(
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
await database.stores.environmentStore
.create({
@ -122,6 +106,35 @@ export const seedDatabaseForPlaygroundTest = async (
// 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
await Promise.all(
(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;
}),
);

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,
IFeatureEnvironmentStore,
} 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';
export default class FakeFeatureEnvironmentStore
@ -18,6 +18,20 @@ export default class FakeFeatureEnvironmentStore
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> {
this.featureEnvironments.splice(
this.featureEnvironments.findIndex(
@ -181,4 +195,27 @@ export default class FakeFeatureEnvironmentStore
): Promise<void> {
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(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,

View File

@ -3,7 +3,12 @@ import {
IFeatureToggleStore,
} from '../../lib/types/stores/feature-toggle-store';
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 {
features: FeatureToggle[] = [];
@ -126,6 +131,25 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
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(
project: string,
featureName: string,
@ -135,4 +159,18 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
feature.variants = newVariants;
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();
}
}