unleash-server
Version:
Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.
509 lines • 23 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const state_schema_1 = require("./state-schema");
const events_1 = require("../types/events");
const state_util_1 = require("./state-util");
const constants_1 = require("../util/constants");
const environment_1 = require("../types/environment");
class StateService {
constructor(stores, { getLogger, flagResolver, }) {
this.compareFeatureTags = (old, tag) => old.featureName === tag.featureName &&
old.tagValue === tag.tagValue &&
old.tagType === tag.tagType;
this.compareTags = (old, tag) => old.type === tag.type && old.value === tag.value;
this.eventStore = stores.eventStore;
this.toggleStore = stores.featureToggleStore;
this.strategyStore = stores.strategyStore;
this.tagStore = stores.tagStore;
this.featureStrategiesStore = stores.featureStrategiesStore;
this.featureEnvironmentStore = stores.featureEnvironmentStore;
this.tagTypeStore = stores.tagTypeStore;
this.projectStore = stores.projectStore;
this.featureTagStore = stores.featureTagStore;
this.environmentStore = stores.environmentStore;
this.segmentStore = stores.segmentStore;
this.apiTokenStore = stores.apiTokenStore;
this.flagResolver = flagResolver;
this.logger = getLogger('services/state-service.js');
}
async importFile({ file, dropBeforeImport = false, userName = 'import-user', keepExisting = true, }) {
return (0, state_util_1.readFile)(file)
.then((data) => (0, state_util_1.parseFile)(file, data))
.then((data) => this.import({
data,
userName,
dropBeforeImport,
keepExisting,
}));
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
replaceGlobalEnvWithDefaultEnv(data) {
data.environments?.forEach((e) => {
if (e.name === environment_1.GLOBAL_ENV) {
e.name = constants_1.DEFAULT_ENV;
}
});
data.featureEnvironments?.forEach((fe) => {
if (fe.environment === environment_1.GLOBAL_ENV) {
// eslint-disable-next-line no-param-reassign
fe.environment = constants_1.DEFAULT_ENV;
}
});
data.featureStrategies?.forEach((fs) => {
if (fs.environment === environment_1.GLOBAL_ENV) {
// eslint-disable-next-line no-param-reassign
fs.environment = constants_1.DEFAULT_ENV;
}
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
moveVariantsToFeatureEnvironments(data) {
data.featureEnvironments?.forEach((featureEnvironment) => {
let feature = data.features?.find((f) => f.name === featureEnvironment.featureName);
if (feature) {
featureEnvironment.variants = feature.variants || [];
}
});
}
async import({ data, userName = 'importUser', dropBeforeImport = false, keepExisting = true, }) {
if (data.version === 2) {
this.replaceGlobalEnvWithDefaultEnv(data);
}
if (!data.version || data.version < 4) {
this.moveVariantsToFeatureEnvironments(data);
}
const importData = await state_schema_1.stateSchema.validateAsync(data);
let importedEnvironments = [];
if (importData.environments) {
importedEnvironments = await this.importEnvironments({
environments: data.environments,
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.projects) {
await this.importProjects({
projects: data.projects,
importedEnvironments,
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.features) {
let projectData;
if (!importData.version || importData.version === 1) {
projectData = await this.convertLegacyFeatures(importData);
}
else {
projectData = importData;
}
const { features, featureStrategies, featureEnvironments } = projectData;
await this.importFeatures({
features,
userName,
dropBeforeImport,
keepExisting,
featureEnvironments,
});
if (featureEnvironments) {
await this.importFeatureEnvironments({
featureEnvironments,
});
}
await this.importFeatureStrategies({
featureStrategies,
dropBeforeImport,
keepExisting,
});
}
if (importData.strategies) {
await this.importStrategies({
strategies: data.strategies,
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.tagTypes && importData.tags) {
await this.importTagData({
tagTypes: data.tagTypes,
tags: data.tags,
featureTags: (data.featureTags || [])
.filter((t) => (data.features || []).some((f) => f.name === t.featureName))
.map((t) => ({
featureName: t.featureName,
tagValue: t.tagValue || t.value,
tagType: t.tagType || t.type,
})) || [],
userName,
dropBeforeImport,
keepExisting,
});
}
if (importData.segments) {
await this.importSegments(data.segments, userName, dropBeforeImport);
}
if (importData.featureStrategySegments) {
await this.importFeatureStrategySegments(data.featureStrategySegments);
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
enabledIn(feature, env) {
const config = {};
env.filter((e) => e.featureName === feature).forEach((e) => {
config[e.environment] = e.enabled || false;
});
return config;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatureEnvironments({ featureEnvironments }) {
await Promise.all(featureEnvironments
.filter(async (env) => {
await this.environmentStore.exists(env.environment);
})
.map(async (featureEnvironment) => this.featureEnvironmentStore.addFeatureEnvironment(featureEnvironment)));
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatureStrategies({ featureStrategies, dropBeforeImport, keepExisting, }) {
const oldFeatureStrategies = dropBeforeImport
? []
: await this.featureStrategiesStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing strategies for feature toggles');
await this.featureStrategiesStore.deleteAll();
}
const strategiesToImport = keepExisting
? featureStrategies.filter((s) => !oldFeatureStrategies.some((o) => o.id === s.id))
: featureStrategies;
await Promise.all(strategiesToImport.map((featureStrategy) => this.featureStrategiesStore.createStrategyFeatureEnv(featureStrategy)));
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async convertLegacyFeatures({ features, }) {
const strategies = features.flatMap((f) => f.strategies.map((strategy) => ({
featureName: f.name,
projectId: f.project,
constraints: strategy.constraints || [],
parameters: strategy.parameters || {},
environment: constants_1.DEFAULT_ENV,
strategyName: strategy.name,
})));
const newFeatures = features;
const featureEnvironments = features.map((feature) => ({
featureName: feature.name,
environment: constants_1.DEFAULT_ENV,
enabled: feature.enabled,
variants: feature.variants || [],
}));
return {
features: newFeatures,
featureStrategies: strategies,
featureEnvironments,
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importFeatures({ features, userName, dropBeforeImport, keepExisting, featureEnvironments, }) {
this.logger.info(`Importing ${features.length} feature toggles`);
const oldToggles = dropBeforeImport
? []
: await this.toggleStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing feature toggles');
await this.toggleStore.deleteAll();
await this.eventStore.store({
type: events_1.DROP_FEATURES,
createdBy: userName,
data: { name: 'all-features' },
});
}
await Promise.all(features
.filter((0, state_util_1.filterExisting)(keepExisting, oldToggles))
.filter((0, state_util_1.filterEqual)(oldToggles))
.map(async (feature) => {
await this.toggleStore.create(feature.project, feature);
await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(feature.name, feature.project, this.enabledIn(feature.name, featureEnvironments));
await this.eventStore.store({
type: events_1.FEATURE_IMPORT,
createdBy: userName,
data: feature,
});
}));
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importStrategies({ strategies, userName, dropBeforeImport, keepExisting, }) {
this.logger.info(`Importing ${strategies.length} strategies`);
const oldStrategies = dropBeforeImport
? []
: await this.strategyStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing strategies');
await this.strategyStore.dropCustomStrategies();
await this.eventStore.store({
type: events_1.DROP_STRATEGIES,
createdBy: userName,
data: { name: 'all-strategies' },
});
}
await Promise.all(strategies
.filter((0, state_util_1.filterExisting)(keepExisting, oldStrategies))
.filter((0, state_util_1.filterEqual)(oldStrategies))
.map((strategy) => this.strategyStore.importStrategy(strategy).then(() => {
this.eventStore.store({
type: events_1.STRATEGY_IMPORT,
createdBy: userName,
data: strategy,
});
})));
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importEnvironments({ environments, userName, dropBeforeImport, keepExisting, }) {
this.logger.info(`Import ${environments.length} projects`);
const oldEnvs = dropBeforeImport
? []
: await this.environmentStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing environments');
await this.environmentStore.deleteAll();
await this.eventStore.store({
type: events_1.DROP_ENVIRONMENTS,
createdBy: userName,
data: { name: 'all-environments' },
});
}
const envsImport = environments.filter((env) => keepExisting ? !oldEnvs.some((old) => old.name === env.name) : true);
let importedEnvs = [];
if (envsImport.length > 0) {
importedEnvs = await this.environmentStore.importEnvironments(envsImport);
const importedEnvironmentEvents = importedEnvs.map((env) => ({
type: events_1.ENVIRONMENT_IMPORT,
createdBy: userName,
data: env,
}));
await this.eventStore.batchStore(importedEnvironmentEvents);
const apiTokens = await this.apiTokenStore.getAll();
const envNames = importedEnvs.map((env) => env.name);
apiTokens
.filter((apiToken) => !(apiToken.environment === constants_1.ALL_ENVS))
.filter((apiToken) => !envNames.includes(apiToken.environment))
.forEach((apiToken) => this.apiTokenStore.delete(apiToken.secret));
}
return importedEnvs;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importProjects({ projects, importedEnvironments, userName, dropBeforeImport, keepExisting, }) {
this.logger.info(`Import ${projects.length} projects`);
const oldProjects = dropBeforeImport
? []
: await this.projectStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping existing projects');
await this.projectStore.deleteAll();
await this.eventStore.store({
type: events_1.DROP_PROJECTS,
createdBy: userName,
data: { name: 'all-projects' },
});
}
const projectsToImport = projects.filter((project) => keepExisting
? !oldProjects.some((old) => old.id === project.id)
: true);
if (projectsToImport.length > 0) {
const importedProjects = await this.projectStore.importProjects(projectsToImport, importedEnvironments);
const importedProjectEvents = importedProjects.map((project) => ({
type: events_1.PROJECT_IMPORT,
createdBy: userName,
data: project,
}));
await this.eventStore.batchStore(importedProjectEvents);
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importTagData({ tagTypes, tags, featureTags, userName, dropBeforeImport, keepExisting, }) {
this.logger.info(`Importing ${tagTypes.length} tagtypes, ${tags.length} tags and ${featureTags.length} feature tags`);
const oldTagTypes = dropBeforeImport
? []
: await this.tagTypeStore.getAll();
const oldTags = dropBeforeImport ? [] : await this.tagStore.getAll();
const oldFeatureTags = dropBeforeImport
? []
: await this.featureTagStore.getAll();
if (dropBeforeImport) {
this.logger.info('Dropping all existing featuretags, tags and tagtypes');
await this.featureTagStore.deleteAll();
await this.tagStore.deleteAll();
await this.tagTypeStore.deleteAll();
await this.eventStore.batchStore([
{
type: events_1.DROP_FEATURE_TAGS,
createdBy: userName,
data: { name: 'all-feature-tags' },
},
{
type: events_1.DROP_TAGS,
createdBy: userName,
data: { name: 'all-tags' },
},
{
type: events_1.DROP_TAG_TYPES,
createdBy: userName,
data: { name: 'all-tag-types' },
},
]);
}
await this.importTagTypes(tagTypes, keepExisting, oldTagTypes, userName);
await this.importTags(tags, keepExisting, oldTags, userName);
await this.importFeatureTags(featureTags, keepExisting, oldFeatureTags, userName);
}
async importFeatureTags(featureTags, keepExisting, oldFeatureTags, userName) {
const featureTagsToInsert = featureTags.filter((tag) => keepExisting
? !oldFeatureTags.some((old) => this.compareFeatureTags(old, tag))
: true);
if (featureTagsToInsert.length > 0) {
const importedFeatureTags = await this.featureTagStore.importFeatureTags(featureTagsToInsert);
const importedFeatureTagEvents = importedFeatureTags.map((tag) => ({
type: events_1.FEATURE_TAG_IMPORT,
createdBy: userName,
data: tag,
}));
await this.eventStore.batchStore(importedFeatureTagEvents);
}
}
async importTags(tags, keepExisting, oldTags, userName) {
const tagsToInsert = tags.filter((tag) => keepExisting
? !oldTags.some((old) => this.compareTags(old, tag))
: true);
if (tagsToInsert.length > 0) {
const importedTags = await this.tagStore.bulkImport(tagsToInsert);
const importedTagEvents = importedTags.map((tag) => ({
type: events_1.TAG_IMPORT,
createdBy: userName,
data: tag,
}));
await this.eventStore.batchStore(importedTagEvents);
}
}
async importTagTypes(tagTypes, keepExisting, oldTagTypes = [], // eslint-disable-line
userName) {
const tagTypesToInsert = tagTypes.filter((tagType) => keepExisting
? !oldTagTypes.some((t) => t.name === tagType.name)
: true);
if (tagTypesToInsert.length > 0) {
const importedTagTypes = await this.tagTypeStore.bulkImport(tagTypesToInsert);
const importedTagTypeEvents = importedTagTypes.map((tagType) => ({
type: events_1.TAG_TYPE_IMPORT,
createdBy: userName,
data: tagType,
}));
await this.eventStore.batchStore(importedTagTypeEvents);
}
}
async importSegments(segments, userName, dropBeforeImport) {
if (dropBeforeImport) {
await this.segmentStore.deleteAll();
}
await Promise.all(segments.map((segment) => this.segmentStore.create(segment, { username: userName })));
}
async importFeatureStrategySegments(featureStrategySegments) {
await Promise.all(featureStrategySegments.map(({ featureStrategyId, segmentId }) => this.segmentStore.addToStrategy(segmentId, featureStrategyId)));
}
async export(opts) {
if (this.flagResolver.isEnabled('variantsPerEnvironment')) {
return this.exportV4(opts);
}
// adapt v4 to v3. We need includeEnvironments set to true to filter the
// best environment from where we'll pick variants (cause now they are stored
// per environment despite being displayed as if they belong to the feature)
const v4 = await this.exportV4({ ...opts, includeEnvironments: true });
// undefined defaults to true
if (opts.includeFeatureToggles !== false) {
const enabledEnvironments = v4.environments.filter((env) => env.enabled !== false);
const featureAndEnvs = v4.featureEnvironments.map((fE) => {
const envDetails = enabledEnvironments.find((env) => fE.environment === env.name);
return { ...fE, ...envDetails, active: fE.enabled };
});
v4.features = v4.features.map((f) => {
const variants = featureAndEnvs
.sort((e1, e2) => {
if (e1.active !== e2.active) {
return e1.active ? -1 : 1;
}
if (e1.type !== 'production' ||
e2.type !== 'production') {
if (e1.type === 'production') {
return -1;
}
else if (e2.type === 'production') {
return 1;
}
}
return e1.sortOrder - e2.sortOrder;
})
.find((fe) => fe.featureName === f.name)?.variants;
return { ...f, variants };
});
v4.featureEnvironments = v4.featureEnvironments.map((fe) => {
delete fe.variants;
return fe;
});
}
// only if explicitly set to false (i.e. undefined defaults to true)
if (opts.includeEnvironments === false) {
delete v4.environments;
}
else {
if (v4.environments.length === 0) {
throw Error('Unable to export without enabled environments');
}
}
v4.version = 3;
return v4;
}
async exportV4({ includeFeatureToggles = true, includeStrategies = true, includeProjects = true, includeTags = true, includeEnvironments = true, includeSegments = true, }) {
return Promise.all([
includeFeatureToggles
? this.toggleStore.getAll({ archived: false })
: Promise.resolve([]),
includeStrategies
? this.strategyStore.getEditableStrategies()
: Promise.resolve([]),
this.projectStore && includeProjects
? this.projectStore.getAll()
: Promise.resolve([]),
includeTags ? this.tagTypeStore.getAll() : Promise.resolve([]),
includeTags ? this.tagStore.getAll() : Promise.resolve([]),
includeTags && includeFeatureToggles
? this.featureTagStore.getAll()
: Promise.resolve([]),
includeFeatureToggles
? this.featureStrategiesStore.getAll()
: Promise.resolve([]),
includeEnvironments
? this.environmentStore.getAll()
: Promise.resolve([]),
includeFeatureToggles
? this.featureEnvironmentStore.getAll()
: Promise.resolve([]),
includeSegments ? this.segmentStore.getAll() : Promise.resolve([]),
includeSegments
? this.segmentStore.getAllFeatureStrategySegments()
: Promise.resolve([]),
]).then(([features, strategies, projects, tagTypes, tags, featureTags, featureStrategies, environments, featureEnvironments, segments, featureStrategySegments,]) => ({
version: 4,
features,
strategies,
projects,
tagTypes,
tags,
featureTags,
featureStrategies: featureStrategies.filter((fS) => features.some((f) => fS.featureName === f.name)),
environments,
featureEnvironments: featureEnvironments.filter((fE) => features.some((f) => fE.featureName === f.name)),
segments,
featureStrategySegments,
}));
}
}
exports.default = StateService;
module.exports = StateService;
//# sourceMappingURL=state-service.js.map