unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
1,012 lines • 61.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const types_1 = require("../../types");
const error_1 = require("../../error");
const bad_data_error_1 = __importDefault(require("../../error/bad-data-error"));
const name_exists_error_1 = __importDefault(require("../../error/name-exists-error"));
const invalid_operation_error_1 = __importDefault(require("../../error/invalid-operation-error"));
const feature_schema_1 = require("../../schema/feature-schema");
const notfound_error_1 = __importDefault(require("../../error/notfound-error"));
const util_1 = require("../../util");
const fast_json_patch_1 = require("fast-json-patch");
const constraint_types_1 = require("../../util/validators/constraint-types");
const helpers_1 = require("../playground/feature-evaluator/helpers");
const unique_1 = require("../../util/unique");
const feature_naming_validation_1 = require("../feature-naming-pattern/feature-naming-validation");
const archivedfeature_error_1 = __importDefault(require("../../error/archivedfeature-error"));
const metric_events_1 = require("../../metric-events");
const allSettledWithRejection_1 = require("../../util/allSettledWithRejection");
const exceeds_limit_error_1 = require("../../error/exceeds-limit-error");
const sortStrategies_1 = require("../../util/sortStrategies");
const oneOf = (values, match) => {
return values.some((value) => value === match);
};
class FeatureToggleService {
constructor({ featureStrategiesStore, featureToggleStore, clientFeatureToggleStore, projectStore, featureTagStore, featureEnvironmentStore, contextFieldStore, strategyStore, }, { getLogger, flagResolver, eventBus, resourceLimits, }, segmentService, accessService, eventService, changeRequestAccessReadModel, privateProjectChecker, dependentFeaturesReadModel, dependentFeaturesService, featureLifecycleReadModel, featureCollaboratorsReadModel) {
this.logger = getLogger('services/feature-toggle-service.ts');
this.featureStrategiesStore = featureStrategiesStore;
this.strategyStore = strategyStore;
this.featureToggleStore = featureToggleStore;
this.clientFeatureToggleStore = clientFeatureToggleStore;
this.tagStore = featureTagStore;
this.projectStore = projectStore;
this.featureEnvironmentStore = featureEnvironmentStore;
this.contextFieldStore = contextFieldStore;
this.segmentService = segmentService;
this.accessService = accessService;
this.eventService = eventService;
this.flagResolver = flagResolver;
this.changeRequestAccessReadModel = changeRequestAccessReadModel;
this.privateProjectChecker = privateProjectChecker;
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
this.dependentFeaturesService = dependentFeaturesService;
this.featureLifecycleReadModel = featureLifecycleReadModel;
this.featureCollaboratorsReadModel = featureCollaboratorsReadModel;
this.eventBus = eventBus;
this.resourceLimits = resourceLimits;
}
async validateFeaturesContext(featureNames, projectId) {
const features = await this.featureToggleStore.getAllByNames(featureNames);
const invalidProjects = (0, unique_1.unique)(features
.map((feature) => feature.project)
.filter((project) => project !== projectId));
if (invalidProjects.length > 0) {
throw new invalid_operation_error_1.default(`The operation could not be completed. The features exist, but the provided project ids ("${invalidProjects.join(',')}") does not match the project provided in request URL ("${projectId}").`);
}
}
async validateFeatureBelongsToProject({ featureName, projectId, }) {
const id = await this.featureToggleStore.getProjectId(featureName);
if (id !== projectId) {
throw new notfound_error_1.default(`There's no feature named "${featureName}" in project "${projectId}"${id === undefined
? '.'
: `, but there's a feature with that name in project "${id}"`}`);
}
}
async validateFeatureIsNotArchived(featureName, project) {
const toggle = await this.featureToggleStore.get(featureName);
if (toggle === undefined) {
throw new notfound_error_1.default(`Could not find feature ${featureName}`);
}
if (toggle.archived || Boolean(toggle.archivedAt)) {
throw new archivedfeature_error_1.default();
}
}
async validateNoChildren(featureName) {
const children = await this.dependentFeaturesReadModel.getChildren([
featureName,
]);
if (children.length > 0) {
throw new invalid_operation_error_1.default('You can not archive/delete this feature since other features depend on it.');
}
}
async validateNoOrphanParents(featureNames) {
if (featureNames.length === 0)
return;
const parents = await this.dependentFeaturesReadModel.getOrphanParents(featureNames);
if (parents.length > 0) {
throw new invalid_operation_error_1.default(featureNames.length > 1
? `You can not archive/delete those features since other features depend on them.`
: `You can not archive/delete this feature since other features depend on it.`);
}
}
validateUpdatedProperties({ featureName, projectId }, existingStrategy) {
if (existingStrategy.projectId !== projectId) {
throw new invalid_operation_error_1.default('You can not change the projectId for an activation strategy.');
}
if (existingStrategy.featureName !== featureName) {
throw new invalid_operation_error_1.default('You can not change the featureName for an activation strategy.');
}
if (existingStrategy.parameters &&
'stickiness' in existingStrategy.parameters &&
existingStrategy.parameters.stickiness === '') {
throw new invalid_operation_error_1.default('You can not have an empty string for stickiness.');
}
}
async validateProjectCanAccessSegments(projectId, segmentIds) {
if (segmentIds && segmentIds.length > 0) {
await Promise.all(segmentIds.map((segmentId) => this.segmentService.get(segmentId))).then((segments) => segments.map((segment) => {
if (segment.project && segment.project !== projectId) {
throw new bad_data_error_1.default(`The segment "${segment.name}" with id ${segment.id} does not belong to project "${projectId}".`);
}
}));
}
}
async validateStrategyLimit(featureEnv) {
const limit = this.resourceLimits.featureEnvironmentStrategies;
const existingCount = (await this.featureStrategiesStore.getStrategiesForFeatureEnv(featureEnv.projectId, featureEnv.featureName, featureEnv.environment)).length;
if (existingCount >= limit) {
(0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, {
resource: 'strategy',
limit,
});
}
}
validateConstraintsLimit(constraints) {
const { constraints: constraintsLimit, constraintValues: constraintValuesLimit, } = this.resourceLimits;
if (constraints.updated.length > constraintsLimit &&
constraints.updated.length > constraints.existing.length) {
(0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, {
resource: 'constraints',
limit: constraintsLimit,
});
}
const isSameLength = constraints.existing.length === constraints.updated.length;
const constraintOverLimit = constraints.updated.find((constraint, i) => {
const updatedCount = constraint.values?.length ?? 0;
const existingCount = constraints.existing[i]?.values?.length ?? 0;
const isOverLimit = Array.isArray(constraint.values) &&
updatedCount > constraintValuesLimit;
const allowAnyway = isSameLength && existingCount >= updatedCount;
return isOverLimit && !allowAnyway;
});
if (constraintOverLimit) {
(0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, {
resource: `constraint values for ${constraintOverLimit.contextName}`,
limit: constraintValuesLimit,
resourceNameOverride: 'constraint values',
});
}
}
async validateStrategyType(strategyName) {
if (strategyName !== undefined) {
const exists = await this.strategyStore.exists(strategyName);
if (!exists) {
throw new bad_data_error_1.default(`Could not find strategy type with name ${strategyName}`);
}
}
}
async validateConstraints(constraints) {
const validations = constraints.map((constraint) => {
return this.validateConstraint(constraint);
});
return Promise.all(validations);
}
async validateConstraint(input) {
const constraint = await feature_schema_1.constraintSchema.validateAsync(input);
const { operator } = constraint;
const contextDefinition = await this.contextFieldStore.get(constraint.contextName);
if (oneOf(util_1.NUM_OPERATORS, operator)) {
await (0, constraint_types_1.validateNumber)(constraint.value);
}
if (oneOf(util_1.STRING_OPERATORS, operator)) {
await (0, constraint_types_1.validateString)(constraint.values);
}
if (oneOf(util_1.SEMVER_OPERATORS, operator)) {
// Semver library is not asynchronous, so we do not
// need to await here.
(0, constraint_types_1.validateSemver)(constraint.value);
}
if (oneOf(util_1.DATE_OPERATORS, operator)) {
await (0, constraint_types_1.validateDate)(constraint.value);
}
if (contextDefinition?.legalValues &&
contextDefinition.legalValues.length > 0) {
const valuesToValidate = oneOf([...util_1.DATE_OPERATORS, ...util_1.SEMVER_OPERATORS, ...util_1.NUM_OPERATORS], operator)
? constraint.value
: constraint.values;
(0, constraint_types_1.validateLegalValues)(contextDefinition.legalValues, valuesToValidate);
}
return constraint;
}
async patchFeature(project, featureName, operations, auditUser) {
const featureToggle = await this.getFeatureMetadata(featureName);
if (operations.some((op) => op.path.indexOf('/variants') >= 0)) {
throw new error_1.OperationDeniedError(`Changing variants is done via PATCH operation to /api/admin/projects/:project/features/:feature/variants`);
}
const { newDocument } = (0, fast_json_patch_1.applyPatch)((0, fast_json_patch_1.deepClone)(featureToggle), operations);
const updated = await this.updateFeatureToggle(project, newDocument, featureName, auditUser);
if (featureToggle.stale !== newDocument.stale) {
await this.eventService.storeEvent(new types_1.FeatureStaleEvent({
stale: newDocument.stale,
project,
featureName,
auditUser,
}));
}
return updated;
}
featureStrategyToPublic(featureStrategy, segments = []) {
const result = {
id: featureStrategy.id,
name: featureStrategy.strategyName,
title: featureStrategy.title,
disabled: featureStrategy.disabled,
constraints: featureStrategy.constraints || [],
parameters: featureStrategy.parameters,
variants: featureStrategy.variants || [],
sortOrder: featureStrategy.sortOrder,
segments: segments.map((segment) => segment.id) ?? [],
};
return result;
}
async updateStrategiesSortOrder(context, sortOrders, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser);
}
async unprotectedUpdateStrategiesSortOrder(context, sortOrders, auditUser) {
const { featureName, environment, projectId: project } = context;
const existingOrder = (await this.getStrategiesForEnvironment(project, featureName, environment))
.sort(sortStrategies_1.sortStrategies)
.map((strategy) => strategy.id);
const eventPreData = { strategyIds: existingOrder };
await Promise.all(sortOrders.map(({ id, sortOrder }) => this.featureStrategiesStore.updateSortOrder(id, sortOrder)));
const newOrder = (await this.getStrategiesForEnvironment(project, featureName, environment))
.sort(sortStrategies_1.sortStrategies)
.map((strategy) => strategy.id);
const eventData = { strategyIds: newOrder };
const event = new types_1.StrategiesOrderChangedEvent({
featureName,
environment,
project,
preData: eventPreData,
data: eventData,
auditUser,
});
await this.eventService.storeEvent(event);
}
async createStrategy(strategyConfig, context, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedCreateStrategy(strategyConfig, context, auditUser);
}
async unprotectedCreateStrategy(strategyConfig, context, auditUser) {
const { featureName, projectId, environment } = context;
await this.validateFeatureBelongsToProject(context);
await this.validateStrategyType(strategyConfig.name);
await this.validateProjectCanAccessSegments(projectId, strategyConfig.segments);
if (strategyConfig.constraints &&
strategyConfig.constraints.length > 0) {
this.validateConstraintsLimit({
updated: strategyConfig.constraints,
existing: [],
});
strategyConfig.constraints = await this.validateConstraints(strategyConfig.constraints);
}
if (strategyConfig.parameters &&
'stickiness' in strategyConfig.parameters &&
strategyConfig.parameters.stickiness === '') {
strategyConfig.parameters.stickiness = 'default';
}
if (strategyConfig.variants && strategyConfig.variants.length > 0) {
await feature_schema_1.variantsArraySchema.validateAsync(strategyConfig.variants);
const fixedVariants = this.fixVariantWeights(strategyConfig.variants);
strategyConfig.variants = fixedVariants;
}
await this.validateStrategyLimit({
featureName,
projectId,
environment,
});
try {
const newFeatureStrategy = await this.featureStrategiesStore.createStrategyFeatureEnv({
strategyName: strategyConfig.name,
title: strategyConfig.title,
disabled: strategyConfig.disabled,
constraints: strategyConfig.constraints || [],
variants: strategyConfig.variants || [],
parameters: strategyConfig.parameters || {},
sortOrder: strategyConfig.sortOrder,
projectId,
featureName,
environment,
});
if (strategyConfig.segments &&
Array.isArray(strategyConfig.segments)) {
await this.segmentService.updateStrategySegments(newFeatureStrategy.id, strategyConfig.segments);
}
const segments = await this.segmentService.getByStrategy(newFeatureStrategy.id);
const strategy = this.featureStrategyToPublic(newFeatureStrategy, segments);
await this.eventService.storeEvent(new types_1.FeatureStrategyAddEvent({
project: projectId,
featureName,
environment,
data: strategy,
auditUser,
}));
return strategy;
}
catch (e) {
if (e.code === error_1.FOREIGN_KEY_VIOLATION) {
throw new bad_data_error_1.default('You have not added the current environment to the project');
}
throw e;
}
}
/**
* PUT /api/admin/projects/:projectId/features/:featureName/strategies/:strategyId ?
* {
*
* }
* @param id
* @param updates
* @param context - Which context does this strategy live in (projectId, featureName, environment)
* @param auditUser - Audit info about the user performing the update
* @param user - Optional User object performing the action
*/
async updateStrategy(id, updates, context, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedUpdateStrategy(id, updates, context, auditUser, user);
}
async optionallyDisableFeature(featureName, environment, projectId, auditUser, user) {
if (this.flagResolver.isEnabled('simplifyDisableFeature')) {
const strategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(projectId, featureName, environment);
const hasOnlyDisabledStrategies = strategies.every((strategy) => strategy.disabled);
if (hasOnlyDisabledStrategies) {
await this.unprotectedUpdateEnabled(projectId, featureName, environment, false, auditUser, user);
}
return;
}
const feature = await this.getFeature({ featureName });
const env = feature.environments.find((e) => e.name === environment);
const hasOnlyDisabledStrategies = env?.strategies.every((strategy) => strategy.disabled);
if (hasOnlyDisabledStrategies) {
await this.unprotectedUpdateEnabled(projectId, featureName, environment, false, auditUser, user);
}
}
async unprotectedUpdateStrategy(id, updates, context, auditUser, user) {
const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id);
if (existingStrategy === undefined) {
throw new notfound_error_1.default(`Could not find strategy with id ${id}`);
}
this.validateUpdatedProperties(context, existingStrategy);
await this.validateStrategyType(updates.name);
await this.validateProjectCanAccessSegments(projectId, updates.segments);
const existingSegments = await this.segmentService.getByStrategy(id);
if (existingStrategy.id === id) {
if (updates.constraints && updates.constraints.length > 0) {
this.validateConstraintsLimit({
updated: updates.constraints,
existing: existingStrategy.constraints,
});
updates.constraints = await this.validateConstraints(updates.constraints);
}
if (updates.variants && updates.variants.length > 0) {
await feature_schema_1.variantsArraySchema.validateAsync(updates.variants);
const fixedVariants = this.fixVariantWeights(updates.variants);
updates.variants = fixedVariants;
}
const strategy = await this.featureStrategiesStore.updateStrategy(id, updates);
if (updates.segments && Array.isArray(updates.segments)) {
await this.segmentService.updateStrategySegments(strategy.id, updates.segments);
}
const segments = await this.segmentService.getByStrategy(strategy.id);
// Store event!
const data = this.featureStrategyToPublic(strategy, segments);
const preData = this.featureStrategyToPublic(existingStrategy, existingSegments);
await this.eventService.storeEvent(new types_1.FeatureStrategyUpdateEvent({
project: projectId,
featureName,
environment,
data,
preData,
auditUser,
}));
await this.optionallyDisableFeature(featureName, environment, projectId, auditUser, user);
return data;
}
throw new notfound_error_1.default(`Could not find strategy with id ${id}`);
}
async updateStrategyParameter(id, name, value, context, auditUser) {
const { projectId, environment, featureName } = context;
const existingStrategy = await this.featureStrategiesStore.get(id);
if (existingStrategy === undefined) {
throw new notfound_error_1.default(`Could not find strategy with id ${id}`);
}
this.validateUpdatedProperties(context, existingStrategy);
if (existingStrategy.id === id) {
existingStrategy.parameters[name] = String(value);
const existingSegments = await this.segmentService.getByStrategy(id);
const strategy = await this.featureStrategiesStore.updateStrategy(id, existingStrategy);
const segments = await this.segmentService.getByStrategy(strategy.id);
const data = this.featureStrategyToPublic(strategy, segments);
const preData = this.featureStrategyToPublic(existingStrategy, existingSegments);
await this.eventService.storeEvent(new types_1.FeatureStrategyUpdateEvent({
featureName,
project: projectId,
environment,
data,
preData,
auditUser,
}));
return data;
}
throw new notfound_error_1.default(`Could not find strategy with id ${id}`);
}
/**
* DELETE /api/admin/projects/:projectId/features/:featureName/environments/:environmentName/strategies/:strategyId
* {
*
* }
* @param id - strategy id
* @param context - Which context does this strategy live in (projectId, featureName, environment)
* @param auditUser - Audit information about user performing the action (userid, username, ip)
* @param user
*/
async deleteStrategy(id, context, auditUser, user) {
await this.stopWhenChangeRequestsEnabled(context.projectId, context.environment, user);
return this.unprotectedDeleteStrategy(id, context, auditUser);
}
async unprotectedDeleteStrategy(id, context, auditUser) {
const existingStrategy = await this.featureStrategiesStore.get(id);
if (!existingStrategy) {
// If the strategy doesn't exist, do nothing.
return;
}
const { featureName, projectId, environment } = context;
this.validateUpdatedProperties(context, existingStrategy);
await this.featureStrategiesStore.delete(id);
// Disable the feature in the environment if it only has disabled strategies
await this.optionallyDisableFeature(featureName, environment, projectId, auditUser);
const preData = this.featureStrategyToPublic(existingStrategy);
await this.eventService.storeEvent(new types_1.FeatureStrategyRemoveEvent({
featureName,
project: projectId,
environment,
auditUser,
preData,
}));
// If there are no strategies left for environment disable it
await this.featureEnvironmentStore.disableEnvironmentIfNoStrategies(featureName, environment);
}
async getStrategiesForEnvironment(project, featureName, environment = util_1.DEFAULT_ENV) {
this.logger.debug('getStrategiesForEnvironment');
const hasEnv = await this.featureEnvironmentStore.featureHasEnvironment(environment, featureName);
if (hasEnv) {
const featureStrategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(project, featureName, environment);
const result = [];
for (const strat of featureStrategies) {
const segments = (await this.segmentService.getByStrategy(strat.id)).map((segment) => segment.id) ?? [];
result.push({
id: strat.id,
name: strat.strategyName,
constraints: strat.constraints,
parameters: strat.parameters,
variants: strat.variants,
title: strat.title,
disabled: strat.disabled,
sortOrder: strat.sortOrder,
milestoneId: strat.milestoneId,
segments,
});
}
return result;
}
throw new notfound_error_1.default(`Feature ${featureName} does not have environment ${environment}`);
}
/**
* GET /api/admin/projects/:project/features/:featureName
* @param featureName
* @param archived - return archived or non archived toggles
* @param projectId - provide if you're requesting the feature in the context of a specific project.
* @param userId
*/
async getFeature({ featureName, archived, projectId, environmentVariants, userId, }) {
if (projectId) {
await this.validateFeatureBelongsToProject({
featureName,
projectId,
});
}
let dependencies = [];
let children = [];
let lifecycle = undefined;
let collaborators = [];
[dependencies, children, lifecycle, collaborators] = await Promise.all([
this.dependentFeaturesReadModel.getParents(featureName),
this.dependentFeaturesReadModel.getChildren([featureName]),
this.featureLifecycleReadModel.findCurrentStage(featureName),
this.featureCollaboratorsReadModel.getFeatureCollaborators(featureName),
]);
if (environmentVariants) {
const result = await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(featureName, userId, archived);
return {
...result,
dependencies,
children,
lifecycle,
collaborators: { users: collaborators },
};
}
else {
const result = await this.featureStrategiesStore.getFeatureToggleWithEnvs(featureName, userId, archived);
return {
...result,
dependencies,
children,
lifecycle,
collaborators: { users: collaborators },
};
}
}
/**
* GET /api/admin/projects/:project/features/:featureName/variants
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
* @param featureName
* @return The list of variants
*/
async getVariants(featureName) {
return this.featureToggleStore.getVariants(featureName);
}
async getVariantsForEnv(featureName, environment) {
const featureEnvironment = await this.featureEnvironmentStore.get({
featureName,
environment,
});
return featureEnvironment?.variants || [];
}
async getFeatureMetadata(featureName) {
const metaData = await this.featureToggleStore.get(featureName);
if (metaData === undefined) {
throw new notfound_error_1.default(`Could find metadata for feature with name ${featureName}`);
}
return metaData;
}
async getClientFeatures(query) {
const result = await this.clientFeatureToggleStore.getFrontendApiClient(query || {});
return result.map(({ name, type, enabled, project, stale, strategies, variants, description, impressionData, dependencies, }) => ({
name,
type,
enabled,
project,
stale: stale || false,
strategies,
variants,
description,
impressionData,
dependencies,
}));
}
async getPlaygroundFeatures(query) {
const features = await this.featureToggleStore.getPlaygroundFeatures(query);
return features;
}
async getFeatureOverview(params) {
return this.featureStrategiesStore.getFeatureOverview(params);
}
async getFeatureTypeCounts(params) {
return this.featureToggleStore.getFeatureTypeCounts(params);
}
async getFeatureToggle(featureName) {
return this.featureStrategiesStore.getFeatureToggleWithEnvs(featureName);
}
async validateFeatureFlagLimit() {
const currentFlagCount = await this.featureToggleStore.count();
const limit = this.resourceLimits.featureFlags;
if (currentFlagCount >= limit) {
(0, exceeds_limit_error_1.throwExceedsLimitError)(this.eventBus, {
resource: 'feature flag',
limit,
});
}
}
async validateActiveProject(projectId) {
const hasActiveProject = await this.projectStore.hasActiveProject(projectId);
if (!hasActiveProject) {
throw new notfound_error_1.default(`Active project with id ${projectId} does not exist`);
}
}
async createFeatureToggle(projectId, value, auditUser, isValidated = false) {
this.logger.info(`${auditUser.username} creates feature flag ${value.name}`);
await this.validateName(value.name);
await this.validateFeatureFlagNameAgainstPattern(value.name, projectId);
const projectExists = await this.projectStore.hasActiveProject(projectId);
if (await this.projectStore.isFeatureLimitReached(projectId)) {
throw new invalid_operation_error_1.default('You have reached the maximum number of feature flags for this project.');
}
await this.validateFeatureFlagLimit();
if (projectExists) {
let featureData;
if (isValidated) {
featureData = { createdByUserId: auditUser.id, ...value };
}
else {
const validated = await feature_schema_1.featureMetadataSchema.validateAsync(value);
featureData = {
createdByUserId: auditUser.id,
...validated,
};
}
const featureName = featureData.name;
const createdToggle = await this.featureToggleStore.create(projectId, featureData);
await this.featureEnvironmentStore.connectFeatureToEnvironmentsForProject(featureName, projectId);
if (value.variants && value.variants.length > 0) {
const environments = await this.featureEnvironmentStore.getEnvironmentsForFeature(featureName);
await this.featureEnvironmentStore.setVariantsToFeatureEnvironments(featureName, environments.map((env) => env.environment), value.variants);
}
if (value.tags && value.tags.length > 0) {
const mapTagsToFeatureTagInserts = value.tags.map((tag) => ({
tagValue: tag.value,
tagType: tag.type,
createdByUserId: auditUser.id,
featureName: featureName,
}));
await this.tagStore.tagFeatures(mapTagsToFeatureTagInserts);
}
await this.eventService.storeEvent(new types_1.FeatureCreatedEvent({
featureName,
project: projectId,
data: createdToggle,
auditUser,
}));
return createdToggle;
}
throw new notfound_error_1.default(`Active project with id ${projectId} does not exist`);
}
async checkFeatureFlagNamesAgainstProjectPattern(projectId, featureNames) {
try {
const project = await this.projectStore.get(projectId);
if (project === undefined) {
throw new notfound_error_1.default(`Could not find project with id: ${projectId}`);
}
const patternData = project.featureNaming;
const namingPattern = patternData?.pattern;
if (namingPattern) {
const result = (0, feature_naming_validation_1.checkFeatureFlagNamesAgainstPattern)(featureNames, namingPattern);
if (result.state === 'invalid') {
return {
...result,
featureNaming: patternData,
};
}
}
}
catch (error) {
// the project doesn't exist, so there's nothing to
// validate against
this.logger.info("Got an error when trying to validate flag naming patterns. It is probably because the target project doesn't exist. Here's the error:", error.message);
return { state: 'valid' };
}
return { state: 'valid' };
}
async validateFeatureFlagNameAgainstPattern(featureName, projectId) {
if (projectId) {
const result = await this.checkFeatureFlagNamesAgainstProjectPattern(projectId, [featureName]);
if (result.state === 'invalid') {
const namingPattern = result.featureNaming.pattern;
const namingExample = result.featureNaming.example;
const namingDescription = result.featureNaming.description;
const error = `The feature flag name "${featureName}" does not match the project's naming pattern: "${namingPattern}".`;
const example = namingExample
? ` Here's an example of a name that does match the pattern: "${namingExample}"."`
: '';
const description = namingDescription
? ` The pattern's description is: "${namingDescription}"`
: '';
throw new error_1.PatternError(`${error}${example}${description}`, [
`The flag name does not match the pattern.`,
]);
}
}
}
async cloneFeatureToggle(featureName, projectId, newFeatureName, auditUser, replaceGroupId = true) {
const changeRequestEnabled = await this.changeRequestAccessReadModel.isChangeRequestsEnabledForProject(projectId);
if (changeRequestEnabled) {
throw new error_1.ForbiddenError(`Cloning not allowed. Project ${projectId} has change requests enabled.`);
}
this.logger.info(`${auditUser.username} clones feature flag ${featureName} to ${newFeatureName}`);
await this.validateName(newFeatureName);
const cToggle = await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(featureName);
const newToggle = {
...cToggle,
name: newFeatureName,
variants: undefined,
createdAt: undefined,
};
const created = await this.createFeatureToggle(projectId, newToggle, auditUser);
const variantTasks = newToggle.environments.map((e) => {
return this.featureEnvironmentStore.addVariantsToFeatureEnvironment(newToggle.name, e.name, e.variants);
});
const strategyTasks = newToggle.environments.flatMap((e) => e.strategies.map((s) => {
if (replaceGroupId &&
s.parameters &&
s.parameters.hasOwnProperty('groupId')) {
s.parameters.groupId = newFeatureName;
}
const context = {
projectId,
featureName: newFeatureName,
environment: e.name,
};
return this.createStrategy(s, context, auditUser);
}));
const cloneDependencies = this.dependentFeaturesService.cloneDependencies({
featureName,
newFeatureName,
projectId,
}, auditUser);
await Promise.all([
...strategyTasks,
...variantTasks,
cloneDependencies,
]);
return created;
}
async updateFeatureToggle(projectId, updatedFeature, featureName, auditUser) {
await this.validateFeatureBelongsToProject({
featureName,
projectId,
});
this.logger.info(`${auditUser.username} updates feature flag ${featureName}`);
const featureData = await feature_schema_1.featureMetadataSchema.validateAsync(updatedFeature);
const preData = await this.featureToggleStore.get(featureName);
const featureToggle = await this.featureToggleStore.update(projectId, {
...featureData,
name: featureName,
});
if (preData === undefined) {
throw new notfound_error_1.default(`Could find feature toggle with name ${featureName}`);
}
await this.eventService.storeEvent(new types_1.FeatureMetadataUpdateEvent({
auditUser,
data: featureToggle,
preData,
featureName,
project: projectId,
}));
return featureToggle;
}
async getFeatureCountForProject(projectId) {
return this.featureToggleStore.count({
archived: false,
project: projectId,
});
}
async removeAllStrategiesForEnv(toggleName, environment = util_1.DEFAULT_ENV) {
await this.featureStrategiesStore.removeAllStrategiesForFeatureEnv(toggleName, environment);
}
async getStrategy(strategyId) {
const strategy = await this.featureStrategiesStore.getStrategyById(strategyId);
const segments = await this.segmentService.getByStrategy(strategyId);
let result = {
id: strategy.id,
name: strategy.strategyName,
constraints: strategy.constraints || [],
parameters: strategy.parameters,
variants: strategy.variants || [],
segments: [],
title: strategy.title,
disabled: strategy.disabled,
sortOrder: strategy.sortOrder,
};
if (segments && segments.length > 0) {
result = {
...result,
segments: segments.map((segment) => segment.id),
};
}
return result;
}
async getEnvironmentInfo(project, environment, featureName) {
const envMetadata = await this.featureEnvironmentStore.getEnvironmentMetaData(environment, featureName);
const strategies = await this.featureStrategiesStore.getStrategiesForFeatureEnv(project, featureName, environment);
const defaultStrategy = await this.projectStore.getDefaultStrategy(project, environment);
return {
name: featureName,
environment,
enabled: envMetadata.enabled,
strategies,
defaultStrategy,
};
}
// todo: store events for this change.
async deleteEnvironment(projectId, environment) {
await this.featureStrategiesStore.deleteConfigurationsForProjectAndEnvironment(projectId, environment);
await this.projectStore.deleteEnvironmentForProject(projectId, environment);
}
/** Validations */
async validateName(name) {
await feature_schema_1.nameSchema.validateAsync({ name });
await this.validateUniqueFeatureName(name);
return name;
}
async validateUniqueFeatureName(name) {
let msg;
try {
const feature = await this.featureToggleStore.get(name);
if (feature === undefined) {
return;
}
msg = feature.archived
? 'An archived flag with that name already exists'
: 'A flag with that name already exists';
}
catch (error) {
return;
}
throw new name_exists_error_1.default(msg);
}
async hasFeature(name) {
return this.featureToggleStore.exists(name);
}
async updateStale(featureName, isStale, auditUser) {
const feature = await this.featureToggleStore.get(featureName);
if (feature === undefined) {
throw new notfound_error_1.default(`Could not find feature with name: ${featureName}`);
}
const { project } = feature;
feature.stale = isStale;
await this.featureToggleStore.update(project, feature);
await this.eventService.storeEvent(new types_1.FeatureStaleEvent({
stale: isStale,
project,
featureName,
auditUser,
}));
return feature;
}
async archiveToggle(featureName, user, auditUser, projectId) {
if (projectId) {
await this.stopWhenChangeRequestsEnabled(projectId, undefined, user);
}
await this.unprotectedArchiveToggle(featureName, auditUser, projectId);
}
async unprotectedArchiveToggle(featureName, auditUser, projectId) {
const feature = await this.featureToggleStore.get(featureName);
if (feature === undefined) {
throw new notfound_error_1.default(`Could not find feature with name ${featureName}`);
}
if (projectId) {
await this.validateFeatureBelongsToProject({
featureName,
projectId,
});
await this.validateNoOrphanParents([featureName]);
}
await this.validateNoChildren(featureName);
await this.featureToggleStore.archive(featureName);
if (projectId) {
await this.dependentFeaturesService.unprotectedDeleteFeaturesDependencies([featureName], projectId, auditUser);
}
await this.eventService.storeEvent(new types_1.FeatureArchivedEvent({
featureName,
auditUser,
project: feature.project,
}));
}
async archiveToggles(featureNames, user, auditUser, projectId) {
await this.stopWhenChangeRequestsEnabled(projectId, undefined, user);
await this.unprotectedArchiveToggles(featureNames, projectId, auditUser);
}
async validateArchiveToggles(featureNames) {
const hasDeletedDependencies = await this.dependentFeaturesReadModel.haveDependencies(featureNames);
const parentsWithChildFeatures = await this.dependentFeaturesReadModel.getOrphanParents(featureNames);
return {
hasDeletedDependencies,
parentsWithChildFeatures,
};
}
async unprotectedArchiveToggles(featureNames, projectId, auditUser) {
await Promise.all([
this.validateFeaturesContext(featureNames, projectId),
this.validateNoOrphanParents(featureNames),
]);
const features = await this.featureToggleStore.getAllByNames(featureNames);
await this.featureToggleStore.batchArchive(featureNames);
await this.dependentFeaturesService.unprotectedDeleteFeaturesDependencies(featureNames, projectId, auditUser);
await this.eventService.storeEvents(features.map((feature) => new types_1.FeatureArchivedEvent({
featureName: feature.name,
project: feature.project,
auditUser,
})));
}
async setToggleStaleness(featureNames, stale, projectId, auditUser) {
await this.validateFeaturesContext(featureNames, projectId);
const features = await this.featureToggleStore.getAllByNames(featureNames);
const relevantFeatures = features.filter((feature) => feature.stale !== stale);
const relevantFeatureNames = relevantFeatures.map((feature) => feature.name);
await this.featureToggleStore.batchStale(relevantFeatureNames, stale);
await this.eventService.storeEvents(relevantFeatures.map((feature) => new types_1.FeatureStaleEvent({
stale: stale,
project: projectId,
featureName: feature.name,
auditUser,
})));
}
async bulkUpdateEnabled(project, featureNames, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) {
await (0, allSettledWithRejection_1.allSettledWithRejection)(featureNames.map((featureName) => this.updateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies)));
}
async updateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) {
await this.stopWhenChangeRequestsEnabled(project, environment, user);
if (enabled) {
await this.stopWhenCannotCreateStrategies(project, environment, featureName, user);
}
return this.unprotectedUpdateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies);
}
async unprotectedUpdateEnabled(project, featureName, environment, enabled, auditUser, user, shouldActivateDisabledStrategies = false) {
await this.validateFeatureBelongsToProject({
featureName,
projectId: project,
});
const hasEnvironment = await this.featureEnvironmentStore.featureHasEnvironment(environment, featureName);
if (!hasEnvironment) {
throw new notfound_error_1.default(`Could not find environment ${environment} for feature: ${featureName}`);
}
await this.validateFeatureIsNotArchived(featureName, project);
if (enabled) {
const strategies = await this.getStrategiesForEnvironment(project, featureName, environment);
const hasDisabledStrategies = strategies.some((strategy) => strategy.disabled);
if (hasDisabledStrategies && shouldActivateDisabledStrategies) {
await Promise.all(strategies.map((strategy) => this.updateStrategy(strategy.id, { ...strategy, disabled: false }, {
environment,
projectId: project,
featureName,
}, auditUser, user)));
}
const hasOnlyDisabledStrategies = strategies.every((strategy) => strategy.disabled);
const shouldCreate = hasOnlyDisabledStrategies && !shouldActivateDisabledStrategies;
if (strategies.length === 0 || shouldCreate) {
const projectEnvironmentDefaultStrategy = await this.projectStore.getDefaultStrategy(project, environment);
const strategy = projectEnvironmentDefaultStrategy != null
? (0, helpers_1.getProjectDefaultStrategy)(projectEnvironmentDefaultStrategy, featureName)
: (0, helpers_1.getDefaultStrategy)(featureName);
await this.unprotectedCreateStrategy(strategy, {
environment,
projectId: project,
featureName,
}, auditUser);
}
}
const updatedEnvironmentStatus = await this.featureEnvironmentStore.setEnvironmentEnabledStatus(environment, featureName, enabled);
const feature = await this.featureToggleStore.get(featureName);
if (updatedEnvironmentStatus > 0) {
await this.eventService.storeEvent(new types_1.FeatureEnvironmentEvent({
enabled,
project,
featureName,
environment,
auditUser,
}));
}
return feature; // If we get here we know the toggle exists
}
async changeProject(featureName, newProject, auditUser) {
const changeRequestEnabled = await this.changeRequestAccessReadModel.isChangeRequestsEnabledForProject(newProject);
if (changeRequestEnabled) {
throw new error_1.ForbiddenError(`Changing project not allowed. Project ${newProject} has change requests enabled.`);
}
if (await this.dependentFeaturesReadModel.haveDependencies([
featureName,
])) {
throw new error_1.ForbiddenError('Changing project not allowed. Feature has dependencies.');
}
const feature = await this.featureToggleStore.get(featureName);
if (feature === undefined) {
throw new notfound_error_1.default(`Could not find feature with name ${featureName}`);
}
const oldProject = feature.project;
feature.project = newProject;
await this.featureToggleStore.update(newProject, feature);
await this.eventService.storeEvent(new types_1.FeatureChangeProjectEvent({
auditUser,
oldProject,
newProject,
featureName,
}));
}
// TODO: add project id.
async deleteFeature(featureName, auditUser) {
await this.validateNoChildren(featureName);
const toggle = await this.featureToggleStore.get(featureName);
if (toggle === undefined) {
return; /// Do nothing, toggle is already deleted
}
const tags = await this.tagStore.getAllTagsForFeature(featureName);
await this.featureToggleStore.delete(featureName);
await this.eventService.storeEvent(new types_1.FeatureDeletedEvent({
featureName,
project: toggle.project,
auditUser,
preData: toggle,
tags,
}));
}
async deleteFeatures(featureNames, projectId, auditUser) {
await this.validateFeaturesContext(featureNames, projectId);
await this.validateNoOrphanParents(featureNames);
const features = await this.featureToggleStore.getAllByNames(featureNames);
const eligibleFeatures = features.filter((toggle) => toggle.archivedAt !== null);
const eligibleFeatureNames = eligibleFeatures.map((toggle) => toggle.name);
if (eligibleFeatures.length === 0