mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: bulk stale features (#3311)
This commit is contained in:
		
							parent
							
								
									240bb7b027
								
							
						
					
					
						commit
						6c813ab066
					
				| @ -283,6 +283,17 @@ export default class FeatureToggleStore implements IFeatureToggleStore { | ||||
|         return rows.map((row) => this.rowToFeature(row)); | ||||
|     } | ||||
| 
 | ||||
|     async batchStale( | ||||
|         names: string[], | ||||
|         stale: boolean, | ||||
|     ): Promise<FeatureToggle[]> { | ||||
|         const rows = await this.db(TABLE) | ||||
|             .whereIn('name', names) | ||||
|             .update({ stale }) | ||||
|             .returning(FEATURE_COLUMNS); | ||||
|         return rows.map((row) => this.rowToFeature(row)); | ||||
|     } | ||||
| 
 | ||||
|     async delete(name: string): Promise<void> { | ||||
|         await this.db(TABLE) | ||||
|             .where({ name }) // Feature toggle must be archived to allow deletion
 | ||||
|  | ||||
| @ -9,7 +9,7 @@ import { | ||||
|     apiTokensSchema, | ||||
|     applicationSchema, | ||||
|     applicationsSchema, | ||||
|     archiveFeaturesSchema, | ||||
|     batchFeaturesSchema, | ||||
|     changePasswordSchema, | ||||
|     clientApplicationSchema, | ||||
|     clientFeatureSchema, | ||||
| @ -144,6 +144,7 @@ import { bulkRegistrationSchema } from './spec/bulk-registration-schema'; | ||||
| import { bulkMetricsSchema } from './spec/bulk-metrics-schema'; | ||||
| import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema'; | ||||
| import { updateTagsSchema } from './spec/update-tags-schema'; | ||||
| import { batchStaleSchema } from './spec/batch-stale-schema'; | ||||
| 
 | ||||
| // All schemas in `openapi/spec` should be listed here.
 | ||||
| export const schemas = { | ||||
| @ -156,7 +157,8 @@ export const schemas = { | ||||
|     apiTokensSchema, | ||||
|     applicationSchema, | ||||
|     applicationsSchema, | ||||
|     archiveFeaturesSchema, | ||||
|     batchFeaturesSchema, | ||||
|     batchStaleSchema, | ||||
|     bulkRegistrationSchema, | ||||
|     bulkMetricsSchema, | ||||
|     changePasswordSchema, | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { FromSchema } from 'json-schema-to-ts'; | ||||
| 
 | ||||
| export const archiveFeaturesSchema = { | ||||
|     $id: '#/components/schemas/archiveFeaturesSchema', | ||||
| export const batchFeaturesSchema = { | ||||
|     $id: '#/components/schemas/batchFeaturesSchema', | ||||
|     type: 'object', | ||||
|     required: ['features'], | ||||
|     properties: { | ||||
| @ -17,4 +17,4 @@ export const archiveFeaturesSchema = { | ||||
|     }, | ||||
| } as const; | ||||
| 
 | ||||
| export type ArchiveFeaturesSchema = FromSchema<typeof archiveFeaturesSchema>; | ||||
| export type BatchFeaturesSchema = FromSchema<typeof batchFeaturesSchema>; | ||||
							
								
								
									
										23
									
								
								src/lib/openapi/spec/batch-stale-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/lib/openapi/spec/batch-stale-schema.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { FromSchema } from 'json-schema-to-ts'; | ||||
| 
 | ||||
| export const batchStaleSchema = { | ||||
|     $id: '#/components/schemas/batchStaleSchema', | ||||
|     type: 'object', | ||||
|     required: ['features', 'stale'], | ||||
|     properties: { | ||||
|         features: { | ||||
|             type: 'array', | ||||
|             items: { | ||||
|                 type: 'string', | ||||
|             }, | ||||
|         }, | ||||
|         stale: { | ||||
|             type: 'boolean', | ||||
|         }, | ||||
|     }, | ||||
|     components: { | ||||
|         schemas: {}, | ||||
|     }, | ||||
| } as const; | ||||
| 
 | ||||
| export type BatchStaleSchema = FromSchema<typeof batchStaleSchema>; | ||||
| @ -131,4 +131,4 @@ export * from './import-toggles-validate-schema'; | ||||
| export * from './import-toggles-schema'; | ||||
| export * from './stickiness-schema'; | ||||
| export * from './tags-bulk-add-schema'; | ||||
| export * from './archive-features-schema'; | ||||
| export * from './batch-features-schema'; | ||||
|  | ||||
| @ -20,7 +20,7 @@ import { extractUsername } from '../../../util'; | ||||
| import { IAuthRequest } from '../../unleash-types'; | ||||
| import { | ||||
|     AdminFeaturesQuerySchema, | ||||
|     ArchiveFeaturesSchema, | ||||
|     BatchFeaturesSchema, | ||||
|     CreateFeatureSchema, | ||||
|     CreateFeatureStrategySchema, | ||||
|     createRequestSchema, | ||||
| @ -46,6 +46,7 @@ import { | ||||
| } from '../../../services'; | ||||
| import { querySchema } from '../../../schema/feature-schema'; | ||||
| import NotFoundError from '../../../error/notfound-error'; | ||||
| import { BatchStaleSchema } from '../../../openapi/spec/batch-stale-schema'; | ||||
| 
 | ||||
| interface FeatureStrategyParams { | ||||
|     projectId: string; | ||||
| @ -76,6 +77,7 @@ export interface IFeatureProjectUserParams extends ProjectParam { | ||||
| 
 | ||||
| const PATH = '/:projectId/features'; | ||||
| const PATH_ARCHIVE = '/:projectId/archive'; | ||||
| const PATH_STALE = '/:projectId/stale'; | ||||
| const PATH_FEATURE = `${PATH}/:featureName`; | ||||
| const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`; | ||||
| const PATH_ENV = `${PATH_FEATURE}/environments/:environment`; | ||||
| @ -418,8 +420,24 @@ export default class ProjectFeaturesController extends Controller { | ||||
|                     operationId: 'archiveFeatures', | ||||
|                     description: | ||||
|                         'This endpoint archives the specified features.', | ||||
|                     summary: 'Archive a list of features', | ||||
|                     requestBody: createRequestSchema('archiveFeaturesSchema'), | ||||
|                     summary: 'Archives a list of features', | ||||
|                     requestBody: createRequestSchema('batchFeaturesSchema'), | ||||
|                     responses: { 202: emptyResponse }, | ||||
|                 }), | ||||
|             ], | ||||
|         }); | ||||
|         this.route({ | ||||
|             method: 'post', | ||||
|             path: PATH_STALE, | ||||
|             handler: this.staleFeatures, | ||||
|             permission: UPDATE_FEATURE, | ||||
|             middleware: [ | ||||
|                 openApiService.validPath({ | ||||
|                     tags: ['Features'], | ||||
|                     operationId: 'staleFeatures', | ||||
|                     description: 'This endpoint stales the specified features.', | ||||
|                     summary: 'Stales a list of features', | ||||
|                     requestBody: createRequestSchema('batchStaleSchema'), | ||||
|                     responses: { 202: emptyResponse }, | ||||
|                 }), | ||||
|             ], | ||||
| @ -603,7 +621,7 @@ export default class ProjectFeaturesController extends Controller { | ||||
|     } | ||||
| 
 | ||||
|     async archiveFeatures( | ||||
|         req: IAuthRequest<{ projectId: string }, void, ArchiveFeaturesSchema>, | ||||
|         req: IAuthRequest<{ projectId: string }, void, BatchFeaturesSchema>, | ||||
|         res: Response, | ||||
|     ): Promise<void> { | ||||
|         if (!this.flagResolver.isEnabled('bulkOperations')) { | ||||
| @ -618,6 +636,27 @@ export default class ProjectFeaturesController extends Controller { | ||||
|         res.status(202).end(); | ||||
|     } | ||||
| 
 | ||||
|     async staleFeatures( | ||||
|         req: IAuthRequest<{ projectId: string }, void, BatchStaleSchema>, | ||||
|         res: Response, | ||||
|     ): Promise<void> { | ||||
|         if (!this.flagResolver.isEnabled('bulkOperations')) { | ||||
|             throw new NotFoundError('Bulk operations are not enabled'); | ||||
|         } | ||||
| 
 | ||||
|         const { features, stale } = req.body; | ||||
|         const { projectId } = req.params; | ||||
|         const userName = extractUsername(req); | ||||
| 
 | ||||
|         await this.featureService.setToggleStaleness( | ||||
|             features, | ||||
|             stale, | ||||
|             userName, | ||||
|             projectId, | ||||
|         ); | ||||
|         res.status(202).end(); | ||||
|     } | ||||
| 
 | ||||
|     async getFeatureEnvironment( | ||||
|         req: Request<FeatureStrategyParams, any, any, any>, | ||||
|         res: Response<FeatureEnvironmentSchema>, | ||||
|  | ||||
| @ -1101,6 +1101,37 @@ class FeatureToggleService { | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async setToggleStaleness( | ||||
|         featureNames: string[], | ||||
|         stale: boolean, | ||||
|         createdBy: string, | ||||
|         projectId: string, | ||||
|     ): Promise<void> { | ||||
|         await this.validateFeaturesContext(featureNames, projectId); | ||||
| 
 | ||||
|         const features = await this.featureToggleStore.getAllByNames( | ||||
|             featureNames, | ||||
|         ); | ||||
|         const relevantFeatures = features.filter( | ||||
|             (feature) => feature.stale !== stale, | ||||
|         ); | ||||
|         await this.featureToggleStore.batchStale( | ||||
|             relevantFeatures.map((feature) => feature.name), | ||||
|             stale, | ||||
|         ); | ||||
|         await this.eventStore.batchStore( | ||||
|             relevantFeatures.map( | ||||
|                 (feature) => | ||||
|                     new FeatureStaleEvent({ | ||||
|                         stale: stale, | ||||
|                         project: projectId, | ||||
|                         featureName: feature.name, | ||||
|                         createdBy, | ||||
|                     }), | ||||
|             ), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async updateEnabled( | ||||
|         project: string, | ||||
|         featureName: string, | ||||
|  | ||||
| @ -163,7 +163,7 @@ export class FeatureStaleEvent extends BaseEvent { | ||||
|         project: string; | ||||
|         featureName: string; | ||||
|         createdBy: string | IUser; | ||||
|         tags: ITag[]; | ||||
|         tags?: ITag[]; | ||||
|     }) { | ||||
|         super( | ||||
|             p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF, | ||||
|  | ||||
| @ -16,6 +16,10 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> { | ||||
|     update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>; | ||||
|     archive(featureName: string): Promise<FeatureToggle>; | ||||
|     batchArchive(featureNames: string[]): Promise<FeatureToggle[]>; | ||||
|     batchStale( | ||||
|         featureNames: string[], | ||||
|         stale: boolean, | ||||
|     ): Promise<FeatureToggle[]>; | ||||
|     revive(featureName: string): Promise<FeatureToggle>; | ||||
|     getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>; | ||||
|     getAllByNames(names: string[]): Promise<FeatureToggle[]>; | ||||
|  | ||||
| @ -2845,3 +2845,26 @@ test('Should be able to bulk archive features', async () => { | ||||
|         features: [{}, { name: featureName1 }, { name: featureName2 }], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| test('Should batch stale features', async () => { | ||||
|     const staledFeatureName1 = 'staledFeature1'; | ||||
|     const staledFeatureName2 = 'staledFeature2'; | ||||
| 
 | ||||
|     await createFeatureToggle(staledFeatureName1); | ||||
|     await createFeatureToggle(staledFeatureName2); | ||||
| 
 | ||||
|     await app.request | ||||
|         .post(`/api/admin/projects/${DEFAULT_PROJECT}/stale`) | ||||
|         .send({ | ||||
|             features: [staledFeatureName1, staledFeatureName2], | ||||
|             stale: true, | ||||
|         }) | ||||
|         .expect(202); | ||||
| 
 | ||||
|     const { body } = await app.request | ||||
|         .get( | ||||
|             `/api/admin/projects/${DEFAULT_PROJECT}/features/${staledFeatureName1}`, | ||||
|         ) | ||||
|         .expect(200); | ||||
|     expect(body.stale).toBeTruthy(); | ||||
| }); | ||||
|  | ||||
| @ -328,7 +328,7 @@ exports[`should serve the OpenAPI spec 1`] = ` | ||||
|         }, | ||||
|         "type": "object", | ||||
|       }, | ||||
|       "archiveFeaturesSchema": { | ||||
|       "batchFeaturesSchema": { | ||||
|         "properties": { | ||||
|           "features": { | ||||
|             "items": { | ||||
| @ -342,6 +342,24 @@ exports[`should serve the OpenAPI spec 1`] = ` | ||||
|         ], | ||||
|         "type": "object", | ||||
|       }, | ||||
|       "batchStaleSchema": { | ||||
|         "properties": { | ||||
|           "features": { | ||||
|             "items": { | ||||
|               "type": "string", | ||||
|             }, | ||||
|             "type": "array", | ||||
|           }, | ||||
|           "stale": { | ||||
|             "type": "boolean", | ||||
|           }, | ||||
|         }, | ||||
|         "required": [ | ||||
|           "features", | ||||
|           "stale", | ||||
|         ], | ||||
|         "type": "object", | ||||
|       }, | ||||
|       "bulkMetricsSchema": { | ||||
|         "properties": { | ||||
|           "applications": { | ||||
| @ -5998,11 +6016,11 @@ If the provided project does not exist, the list of events will be empty.", | ||||
|           "content": { | ||||
|             "application/json": { | ||||
|               "schema": { | ||||
|                 "$ref": "#/components/schemas/archiveFeaturesSchema", | ||||
|                 "$ref": "#/components/schemas/batchFeaturesSchema", | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|           "description": "archiveFeaturesSchema", | ||||
|           "description": "batchFeaturesSchema", | ||||
|           "required": true, | ||||
|         }, | ||||
|         "responses": { | ||||
| @ -6010,7 +6028,7 @@ If the provided project does not exist, the list of events will be empty.", | ||||
|             "description": "This response has no body.", | ||||
|           }, | ||||
|         }, | ||||
|         "summary": "Archive a list of features", | ||||
|         "summary": "Archives a list of features", | ||||
|         "tags": [ | ||||
|           "Features", | ||||
|         ], | ||||
| @ -7396,6 +7414,42 @@ If the provided project does not exist, the list of events will be empty.", | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|     "/api/admin/projects/{projectId}/stale": { | ||||
|       "post": { | ||||
|         "description": "This endpoint stales the specified features.", | ||||
|         "operationId": "staleFeatures", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "in": "path", | ||||
|             "name": "projectId", | ||||
|             "required": true, | ||||
|             "schema": { | ||||
|               "type": "string", | ||||
|             }, | ||||
|           }, | ||||
|         ], | ||||
|         "requestBody": { | ||||
|           "content": { | ||||
|             "application/json": { | ||||
|               "schema": { | ||||
|                 "$ref": "#/components/schemas/batchStaleSchema", | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|           "description": "batchStaleSchema", | ||||
|           "required": true, | ||||
|         }, | ||||
|         "responses": { | ||||
|           "202": { | ||||
|             "description": "This response has no body.", | ||||
|           }, | ||||
|         }, | ||||
|         "summary": "Stales a list of features", | ||||
|         "tags": [ | ||||
|           "Features", | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|     "/api/admin/projects/{projectId}/stickiness": { | ||||
|       "get": { | ||||
|         "operationId": "getProjectDefaultStickiness", | ||||
|  | ||||
							
								
								
									
										13
									
								
								src/test/fixtures/fake-feature-toggle-store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								src/test/fixtures/fake-feature-toggle-store.ts
									
									
									
									
										vendored
									
									
								
							| @ -34,6 +34,19 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { | ||||
|         return features; | ||||
|     } | ||||
| 
 | ||||
|     async batchStale( | ||||
|         featureNames: string[], | ||||
|         stale: boolean, | ||||
|     ): Promise<FeatureToggle[]> { | ||||
|         const features = this.features.filter((feature) => | ||||
|             featureNames.includes(feature.name), | ||||
|         ); | ||||
|         for (const feature of features) { | ||||
|             feature.stale = stale; | ||||
|         } | ||||
|         return features; | ||||
|     } | ||||
| 
 | ||||
|     async count(query: Partial<IFeatureToggleQuery>): Promise<number> { | ||||
|         return this.features.filter(this.getFilterQuery(query)).length; | ||||
|     } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user