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:
parent
a165eb191c
commit
efd47b72a8
@ -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,
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 [];
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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(
|
||||||
|
@ -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({
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
};
|
@ -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(
|
||||||
|
@ -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({
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
@ -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;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
164
src/test/e2e/services/state-service.e2e.test.ts
Normal file
164
src/test/e2e/services/state-service.e2e.test.ts
Normal 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
|
||||||
|
});
|
245
src/test/examples/variantsexport_v3.json
Normal file
245
src/test/examples/variantsexport_v3.json
Normal 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": []
|
||||||
|
}
|
@ -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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
40
src/test/fixtures/fake-feature-toggle-store.ts
vendored
40
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user