mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-19 01:17:18 +02:00
feat: export dependent feature toggles (#5007)
This commit is contained in:
parent
7343183f2d
commit
2059706e77
@ -1,4 +1,5 @@
|
||||
import { IDependency } from '../../types';
|
||||
import { IDependency, IFeatureDependency } from '../../types';
|
||||
import { FeatureDependency } from './dependent-features';
|
||||
|
||||
export interface IDependentFeaturesReadModel {
|
||||
getChildren(parents: string[]): Promise<string[]>;
|
||||
@ -6,6 +7,7 @@ export interface IDependentFeaturesReadModel {
|
||||
// we're interested in the list of parents, not orphans
|
||||
getOrphanParents(parentsAndChildren: string[]): Promise<string[]>;
|
||||
getParents(child: string): Promise<IDependency[]>;
|
||||
getDependencies(children: string[]): Promise<IFeatureDependency[]>;
|
||||
getParentOptions(child: string): Promise<string[]>;
|
||||
hasDependencies(feature: string): Promise<boolean>;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Db } from '../../db/db';
|
||||
import { IDependentFeaturesReadModel } from './dependent-features-read-model-type';
|
||||
import { IDependency } from '../../types';
|
||||
import { IDependency, IFeatureDependency } from '../../types';
|
||||
import { FeatureDependency } from './dependent-features';
|
||||
|
||||
export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
|
||||
private db: Db;
|
||||
@ -43,6 +44,22 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
|
||||
}));
|
||||
}
|
||||
|
||||
async getDependencies(children: string[]): Promise<IFeatureDependency[]> {
|
||||
const rows = await this.db('dependent_features').whereIn(
|
||||
'child',
|
||||
children,
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
feature: row.child,
|
||||
dependency: {
|
||||
feature: row.parent,
|
||||
enabled: row.enabled,
|
||||
variants: row.variants,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async getParentOptions(child: string): Promise<string[]> {
|
||||
const result = await this.db('features')
|
||||
.where('features.name', child)
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { IDependentFeaturesReadModel } from './dependent-features-read-model-type';
|
||||
import { IDependency } from '../../types';
|
||||
import { IDependency, IFeatureDependency } from '../../types';
|
||||
import { FeatureDependency } from './dependent-features';
|
||||
|
||||
export class FakeDependentFeaturesReadModel
|
||||
implements IDependentFeaturesReadModel
|
||||
{
|
||||
getDependencies(): Promise<IFeatureDependency[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
getChildren(): Promise<string[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
@ -44,6 +44,8 @@ import {
|
||||
createPrivateProjectChecker,
|
||||
} from '../private-project/createPrivateProjectChecker';
|
||||
import { DbServiceFactory } from 'lib/db/transaction';
|
||||
import { DependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model';
|
||||
import { FakeDependentFeaturesReadModel } from '../dependent-features/fake-dependent-features-read-model';
|
||||
|
||||
export const createFakeExportImportTogglesService = (
|
||||
config: IUnleashConfig,
|
||||
@ -102,6 +104,8 @@ export const createFakeExportImportTogglesService = (
|
||||
{ getLogger },
|
||||
eventService,
|
||||
);
|
||||
const dependentFeaturesReadModel = new FakeDependentFeaturesReadModel();
|
||||
|
||||
const exportImportService = new ExportImportService(
|
||||
{
|
||||
importTogglesStore,
|
||||
@ -123,6 +127,7 @@ export const createFakeExportImportTogglesService = (
|
||||
strategyService,
|
||||
tagTypeService,
|
||||
},
|
||||
dependentFeaturesReadModel,
|
||||
);
|
||||
|
||||
return exportImportService;
|
||||
@ -213,6 +218,8 @@ export const deferredExportImportTogglesService = (
|
||||
{ getLogger },
|
||||
eventService,
|
||||
);
|
||||
const dependentFeaturesReadModel = new DependentFeaturesReadModel(db);
|
||||
|
||||
const exportImportService = new ExportImportService(
|
||||
{
|
||||
importTogglesStore,
|
||||
@ -234,6 +241,7 @@ export const deferredExportImportTogglesService = (
|
||||
strategyService,
|
||||
tagTypeService,
|
||||
},
|
||||
dependentFeaturesReadModel,
|
||||
);
|
||||
|
||||
return exportImportService;
|
||||
|
@ -1,20 +1,21 @@
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
import { Logger } from '../../logger';
|
||||
import { IStrategy } from '../../types/stores/strategy-store';
|
||||
import { IFeatureToggleStore } from '../feature-toggle/types/feature-toggle-store-type';
|
||||
import { IFeatureStrategiesStore } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||
import {
|
||||
IUnleashConfig,
|
||||
IContextFieldStore,
|
||||
IUnleashStores,
|
||||
ISegmentStore,
|
||||
IFeatureEnvironmentStore,
|
||||
ITagTypeStore,
|
||||
IFeatureTagStore,
|
||||
FeatureToggleDTO,
|
||||
IFeatureStrategy,
|
||||
IFeatureStrategySegment,
|
||||
IVariant,
|
||||
} from '../../types/model';
|
||||
import { Logger } from '../../logger';
|
||||
import { IFeatureTagStore } from '../../types/stores/feature-tag-store';
|
||||
import { ITagTypeStore } from '../../types/stores/tag-type-store';
|
||||
import { IStrategy } from '../../types/stores/strategy-store';
|
||||
import { IFeatureToggleStore } from '../feature-toggle/types/feature-toggle-store-type';
|
||||
import { IFeatureStrategiesStore } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||
import { IFeatureEnvironmentStore } from '../../types/stores/feature-environment-store';
|
||||
import { IContextFieldStore, IUnleashStores } from '../../types/stores';
|
||||
import { ISegmentStore } from '../../types/stores/segment-store';
|
||||
import { ExportQuerySchema } from '../../openapi/spec/export-query-schema';
|
||||
} from '../../types';
|
||||
import { ExportQuerySchema, ImportTogglesSchema } from '../../openapi';
|
||||
import {
|
||||
FEATURES_EXPORTED,
|
||||
FEATURES_IMPORTED,
|
||||
@ -27,7 +28,6 @@ import {
|
||||
FeatureStrategySchema,
|
||||
ImportTogglesValidateSchema,
|
||||
} from '../../openapi';
|
||||
import { ImportTogglesSchema } from '../../openapi/spec/import-toggles-schema';
|
||||
import User from '../../types/user';
|
||||
import { BadDataError } from '../../error';
|
||||
import { extractUsernameFromUser } from '../../util';
|
||||
@ -49,6 +49,8 @@ import { ImportPermissionsService, Mode } from './import-permissions-service';
|
||||
import { ImportValidationMessages } from './import-validation-messages';
|
||||
import { findDuplicates } from '../../util/findDuplicates';
|
||||
import { FeatureNameCheckResultWithFeaturePattern } from '../feature-toggle/feature-toggle-service';
|
||||
import { IDependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model-type';
|
||||
import groupBy from 'lodash.groupby';
|
||||
|
||||
export type IImportService = {
|
||||
validate(
|
||||
@ -105,6 +107,8 @@ export default class ExportImportService
|
||||
|
||||
private importPermissionsService: ImportPermissionsService;
|
||||
|
||||
private dependentFeaturesReadModel: IDependentFeaturesReadModel;
|
||||
|
||||
constructor(
|
||||
stores: Pick<
|
||||
IUnleashStores,
|
||||
@ -139,6 +143,7 @@ export default class ExportImportService
|
||||
| 'tagTypeService'
|
||||
| 'featureTagService'
|
||||
>,
|
||||
dependentFeaturesReadModel: IDependentFeaturesReadModel,
|
||||
) {
|
||||
this.toggleStore = stores.featureToggleStore;
|
||||
this.importTogglesStore = stores.importTogglesStore;
|
||||
@ -162,6 +167,7 @@ export default class ExportImportService
|
||||
this.tagTypeService,
|
||||
this.contextService,
|
||||
);
|
||||
this.dependentFeaturesReadModel = dependentFeaturesReadModel;
|
||||
this.logger = getLogger('services/state-service.js');
|
||||
}
|
||||
|
||||
@ -332,7 +338,10 @@ export default class ExportImportService
|
||||
if (tag.tagType) {
|
||||
await this.featureTagService.addTag(
|
||||
tag.featureName,
|
||||
{ type: tag.tagType, value: tag.tagValue },
|
||||
{
|
||||
type: tag.tagType,
|
||||
value: tag.tagValue,
|
||||
},
|
||||
extractUsernameFromUser(user),
|
||||
);
|
||||
}
|
||||
@ -657,6 +666,7 @@ export default class ExportImportService
|
||||
featureTags,
|
||||
segments,
|
||||
tagTypes,
|
||||
featureDependencies,
|
||||
] = await Promise.all([
|
||||
this.toggleStore.getAllByNames(featureNames),
|
||||
await this.featureEnvironmentStore.getAllByFeatures(
|
||||
@ -672,6 +682,7 @@ export default class ExportImportService
|
||||
this.featureTagStore.getAllByFeatures(featureNames),
|
||||
this.segmentStore.getAll(),
|
||||
this.tagTypeStore.getAll(),
|
||||
this.dependentFeaturesReadModel.getDependencies(featureNames),
|
||||
]);
|
||||
this.addSegmentsToStrategies(featureStrategies, strategySegments);
|
||||
const filteredContextFields = contextFields
|
||||
@ -708,6 +719,19 @@ export default class ExportImportService
|
||||
const filteredTagTypes = tagTypes.filter((tagType) =>
|
||||
featureTags.map((tag) => tag.tagType).includes(tagType.name),
|
||||
);
|
||||
|
||||
const groupedFeatureDependencies = groupBy(
|
||||
featureDependencies,
|
||||
'feature',
|
||||
);
|
||||
|
||||
const mappedFeatureDependencies = Object.entries(
|
||||
groupedFeatureDependencies,
|
||||
).map(([feature, dependencies]) => ({
|
||||
feature,
|
||||
dependencies: dependencies.map((d) => d.dependency),
|
||||
}));
|
||||
|
||||
const result = {
|
||||
features: features.map((item) => {
|
||||
const { createdAt, archivedAt, lastSeenAt, ...rest } = item;
|
||||
@ -741,9 +765,13 @@ export default class ExportImportService
|
||||
featureTags,
|
||||
segments: filteredSegments.map((item) => {
|
||||
const { id, name } = item;
|
||||
return { id, name };
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
};
|
||||
}),
|
||||
tagTypes: filteredTagTypes,
|
||||
dependencies: mappedFeatureDependencies,
|
||||
};
|
||||
await this.eventService.storeEvent({
|
||||
type: FEATURES_EXPORTED,
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
import { DEFAULT_ENV } from '../../util';
|
||||
import {
|
||||
ContextFieldSchema,
|
||||
CreateDependentFeatureSchema,
|
||||
ImportTogglesSchema,
|
||||
UpsertSegmentSchema,
|
||||
VariantsSchema,
|
||||
@ -47,7 +48,10 @@ const defaultContext: ContextFieldSchema = {
|
||||
description: 'A region',
|
||||
legalValues: [
|
||||
{ value: 'north' },
|
||||
{ value: 'south', description: 'south-desc' },
|
||||
{
|
||||
value: 'south',
|
||||
description: 'south-desc',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -68,7 +72,11 @@ const createToggle = async (
|
||||
if (strategy) {
|
||||
await app.services.featureToggleServiceV2.createStrategy(
|
||||
strategy,
|
||||
{ projectId, featureName: toggle.name, environment: DEFAULT_ENV },
|
||||
{
|
||||
projectId,
|
||||
featureName: toggle.name,
|
||||
environment: DEFAULT_ENV,
|
||||
},
|
||||
username,
|
||||
);
|
||||
}
|
||||
@ -152,6 +160,7 @@ beforeAll(async () => {
|
||||
flags: {
|
||||
featuresExportImport: true,
|
||||
featureNamingPattern: true,
|
||||
dependentFeatures: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -193,7 +202,10 @@ describe('import-export for project-specific segments', () => {
|
||||
});
|
||||
const strategy = {
|
||||
name: 'default',
|
||||
parameters: { rollout: '100', stickiness: 'default' },
|
||||
parameters: {
|
||||
rollout: '100',
|
||||
stickiness: 'default',
|
||||
},
|
||||
constraints: [
|
||||
{
|
||||
contextName: 'appName',
|
||||
@ -249,10 +261,16 @@ describe('import-export for project-specific segments', () => {
|
||||
test('exports features', async () => {
|
||||
const segmentName = 'my-segment';
|
||||
await createProjects();
|
||||
const segment = await createSegment({ name: segmentName, constraints: [] });
|
||||
const segment = await createSegment({
|
||||
name: segmentName,
|
||||
constraints: [],
|
||||
});
|
||||
const strategy = {
|
||||
name: 'default',
|
||||
parameters: { rollout: '100', stickiness: 'default' },
|
||||
parameters: {
|
||||
rollout: '100',
|
||||
stickiness: 'default',
|
||||
},
|
||||
constraints: [
|
||||
{
|
||||
contextName: 'appName',
|
||||
@ -276,6 +294,8 @@ test('exports features', async () => {
|
||||
},
|
||||
strategy,
|
||||
);
|
||||
|
||||
await app.addDependency(defaultFeatureName, 'second_feature');
|
||||
const { body } = await app.request
|
||||
.post('/api/admin/features-batch/export')
|
||||
.send({
|
||||
@ -306,6 +326,17 @@ test('exports features', async () => {
|
||||
name: segmentName,
|
||||
},
|
||||
],
|
||||
dependencies: [
|
||||
{
|
||||
feature: defaultFeatureName,
|
||||
dependencies: [
|
||||
{
|
||||
feature: 'second_feature',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@ -313,7 +344,10 @@ test('exports features by tag', async () => {
|
||||
await createProjects();
|
||||
const strategy = {
|
||||
name: 'default',
|
||||
parameters: { rollout: '100', stickiness: 'default' },
|
||||
parameters: {
|
||||
rollout: '100',
|
||||
stickiness: 'default',
|
||||
},
|
||||
constraints: [
|
||||
{
|
||||
contextName: 'appName',
|
||||
@ -386,7 +420,10 @@ test('should export custom context fields from strategies and variants', async (
|
||||
await createContext(strategyStickinessContext);
|
||||
const strategy = {
|
||||
name: 'default',
|
||||
parameters: { rollout: '100', stickiness: 'strategy-stickiness' },
|
||||
parameters: {
|
||||
rollout: '100',
|
||||
stickiness: 'strategy-stickiness',
|
||||
},
|
||||
constraints: [
|
||||
{
|
||||
contextName: strategyContext.name,
|
||||
@ -502,7 +539,12 @@ test('should export tags', async () => {
|
||||
featureName: defaultFeatureName,
|
||||
},
|
||||
],
|
||||
featureTags: [{ featureName, tagValue: 'tag1' }],
|
||||
featureTags: [
|
||||
{
|
||||
featureName,
|
||||
tagValue: 'tag1',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@ -598,15 +640,33 @@ const tags = [
|
||||
];
|
||||
|
||||
const resultTags = [
|
||||
{ value: 'tag1', type: 'simple' },
|
||||
{ value: 'tag2', type: 'simple' },
|
||||
{ value: 'feature_tagged', type: 'special_tag' },
|
||||
{
|
||||
value: 'tag1',
|
||||
type: 'simple',
|
||||
},
|
||||
{
|
||||
value: 'tag2',
|
||||
type: 'simple',
|
||||
},
|
||||
{
|
||||
value: 'feature_tagged',
|
||||
type: 'special_tag',
|
||||
},
|
||||
];
|
||||
|
||||
const tagTypes = [
|
||||
{ name: 'bestt', description: 'test' },
|
||||
{ name: 'special_tag', description: 'this is my special tag' },
|
||||
{ name: 'special_tag', description: 'this is my special tag' }, // deliberate duplicate
|
||||
{
|
||||
name: 'bestt',
|
||||
description: 'test',
|
||||
},
|
||||
{
|
||||
name: 'special_tag',
|
||||
description: 'this is my special tag',
|
||||
},
|
||||
{
|
||||
name: 'special_tag',
|
||||
description: 'this is my special tag',
|
||||
}, // deliberate duplicate
|
||||
];
|
||||
|
||||
const defaultImportPayload: ImportTogglesSchema = {
|
||||
@ -724,11 +784,21 @@ test('import multiple features with same tag', async () => {
|
||||
|
||||
expect(tags1).toMatchObject({
|
||||
version: 1,
|
||||
tags: [{ value: 'tag1', type: 'simple' }],
|
||||
tags: [
|
||||
{
|
||||
value: 'tag1',
|
||||
type: 'simple',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(tags2).toMatchObject({
|
||||
version: 1,
|
||||
tags: [{ value: 'tag1', type: 'simple' }],
|
||||
tags: [
|
||||
{
|
||||
value: 'tag1',
|
||||
type: 'simple',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@ -746,7 +816,12 @@ test('can update toggles on subsequent import', async () => {
|
||||
...defaultImportPayload,
|
||||
data: {
|
||||
...defaultImportPayload.data,
|
||||
features: [{ ...exportedFeature, type: 'operational' }],
|
||||
features: [
|
||||
{
|
||||
...exportedFeature,
|
||||
type: 'operational',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -782,7 +857,12 @@ test('reject import with unknown context fields', async () => {
|
||||
await createProjects();
|
||||
const contextField = {
|
||||
name: 'ContextField1',
|
||||
legalValues: [{ value: 'Value1', description: '' }],
|
||||
legalValues: [
|
||||
{
|
||||
value: 'Value1',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
await app.createContextField(contextField);
|
||||
const importPayloadWithContextFields: ImportTogglesSchema = {
|
||||
@ -792,7 +872,12 @@ test('reject import with unknown context fields', async () => {
|
||||
contextFields: [
|
||||
{
|
||||
...contextField,
|
||||
legalValues: [{ value: 'Value2', description: '' }],
|
||||
legalValues: [
|
||||
{
|
||||
value: 'Value2',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -813,7 +898,10 @@ test('reject import with unsupported strategies', async () => {
|
||||
data: {
|
||||
...defaultImportPayload.data,
|
||||
featureStrategies: [
|
||||
{ name: 'customStrategy', featureName: 'featureName' },
|
||||
{
|
||||
name: 'customStrategy',
|
||||
featureName: 'featureName',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@ -874,7 +962,12 @@ test('validate import data', async () => {
|
||||
anotherExportedFeature,
|
||||
],
|
||||
featureStrategies: [{ name: 'customStrategy' }],
|
||||
segments: [{ id: 1, name: 'customSegment' }],
|
||||
segments: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'customSegment',
|
||||
},
|
||||
],
|
||||
contextFields: [
|
||||
{
|
||||
...contextField,
|
||||
|
@ -182,6 +182,7 @@ import { contextFieldStrategiesSchema } from './spec/context-field-strategies-sc
|
||||
import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-schema';
|
||||
import { createFeatureNamingPatternSchema } from './spec/create-feature-naming-pattern-schema';
|
||||
import { segmentStrategiesSchema } from './spec/admin-strategies-schema';
|
||||
import { featureDependenciesSchema } from './spec/feature-dependencies-schema';
|
||||
|
||||
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
||||
export type SchemaId = typeof schemas[keyof typeof schemas]['$id'];
|
||||
@ -389,6 +390,7 @@ export const schemas: UnleashSchemas = {
|
||||
dependentFeatureSchema,
|
||||
createDependentFeatureSchema,
|
||||
parentFeatureOptionsSchema,
|
||||
featureDependenciesSchema,
|
||||
};
|
||||
|
||||
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
||||
|
@ -12,6 +12,8 @@ import { variantsSchema } from './variants-schema';
|
||||
import { constraintSchema } from './constraint-schema';
|
||||
import { tagTypeSchema } from './tag-type-schema';
|
||||
import { strategyVariantSchema } from './strategy-variant-schema';
|
||||
import { featureDependenciesSchema } from './feature-dependencies-schema';
|
||||
import { dependentFeatureSchema } from './dependent-feature-schema';
|
||||
|
||||
export const exportResultSchema = {
|
||||
$id: '#/components/schemas/exportResultSchema',
|
||||
@ -166,6 +168,14 @@ export const exportResultSchema = {
|
||||
$ref: '#/components/schemas/tagTypeSchema',
|
||||
},
|
||||
},
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
description:
|
||||
'A list of all the dependencies for features in `features` list.',
|
||||
items: {
|
||||
$ref: '#/components/schemas/featureDependenciesSchema',
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
@ -182,6 +192,8 @@ export const exportResultSchema = {
|
||||
parametersSchema,
|
||||
legalValueSchema,
|
||||
tagTypeSchema,
|
||||
featureDependenciesSchema,
|
||||
dependentFeatureSchema,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
34
src/lib/openapi/spec/feature-dependencies-schema.ts
Normal file
34
src/lib/openapi/spec/feature-dependencies-schema.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { FromSchema } from 'json-schema-to-ts';
|
||||
import { dependentFeatureSchema } from './dependent-feature-schema';
|
||||
|
||||
export const featureDependenciesSchema = {
|
||||
$id: '#/components/schemas/featureDependenciesSchema',
|
||||
type: 'object',
|
||||
description:
|
||||
'Feature dependency connection between a child feature and its dependencies',
|
||||
required: ['feature', 'dependencies'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
feature: {
|
||||
type: 'string',
|
||||
description: 'The name of the child feature.',
|
||||
example: 'child_feature',
|
||||
},
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
description: 'List of parent features for the child feature',
|
||||
items: {
|
||||
$ref: '#/components/schemas/dependentFeatureSchema',
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
dependentFeatureSchema,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type FeatureDependenciesSchema = FromSchema<
|
||||
typeof featureDependenciesSchema
|
||||
>;
|
@ -146,6 +146,11 @@ export interface IDependency {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IFeatureDependency {
|
||||
feature: string;
|
||||
dependency: IDependency;
|
||||
}
|
||||
|
||||
export type IStrategyVariant = Omit<IVariant, 'overrides'>;
|
||||
|
||||
export interface IEnvironment {
|
||||
|
Loading…
Reference in New Issue
Block a user