Browse Source

feat(elasticsearch-plugin): Add ability to customize index options and mappings

* #995 feat(elastic): add abilty to customize index settings on index creation

* #995 feat(elastic): warn user about difference between actual index and config index

* #995 feat(elastic): warn user about difference between actual index and config index - fix import order

* #995 feat(elastic): implement background index recreation while reindexing with aliases

* #995 feat(elastic): implement index name salt and fix bugs

* #995 feat(elastic): make re-indexing robust and clean up code
Artem Danilov 4 years ago
parent
commit
92587e5a35

+ 2 - 1
packages/elasticsearch-plugin/package.json

@@ -19,7 +19,8 @@
   },
   "dependencies": {
     "@elastic/elasticsearch": "^7.9.1",
-    "deepmerge": "^4.2.2"
+    "deepmerge": "^4.2.2",
+    "fast-deep-equal": "^3.1.3"
   },
   "devDependencies": {
     "@vendure/common": "^1.1.2",

+ 2 - 2
packages/elasticsearch-plugin/src/elasticsearch-index.service.ts

@@ -64,8 +64,8 @@ export class ElasticsearchIndexService implements OnApplicationBootstrap {
         });
     }
 
-    reindex(ctx: RequestContext, dropIndices: boolean) {
-        return this.updateIndexQueue.add({ type: 'reindex', ctx: ctx.serialize(), dropIndices });
+    reindex(ctx: RequestContext) {
+        return this.updateIndexQueue.add({ type: 'reindex', ctx: ctx.serialize()});
     }
 
     updateProduct(ctx: RequestContext, product: Product) {

+ 1 - 1
packages/elasticsearch-plugin/src/elasticsearch-resolver.ts

@@ -55,7 +55,7 @@ export class AdminElasticSearchResolver implements Omit<SearchResolver, 'facetVa
     @Mutation()
     @Allow(Permission.UpdateCatalog, Permission.UpdateProduct)
     async reindex(@Ctx() ctx: RequestContext): Promise<GraphQLJob> {
-        return (this.elasticsearchService.reindex(ctx, false) as unknown) as GraphQLJob;
+        return (this.elasticsearchService.reindex(ctx) as unknown) as GraphQLJob;
     }
 }
 

+ 54 - 20
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -14,6 +14,7 @@ import {
     RequestContext,
     SearchService,
 } from '@vendure/core';
+import equal from 'fast-deep-equal/es6';
 
 import { buildElasticBody } from './build-elastic-body';
 import { ELASTIC_SEARCH_OPTIONS, loggerCtx, PRODUCT_INDEX_NAME, VARIANT_INDEX_NAME } from './constants';
@@ -94,15 +95,57 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
             const index = indexPrefix + indexName;
             const result = await this.client.indices.exists({ index });
 
-            if (result.body === false) {
+            if (!result.body) {
                 Logger.verbose(`Index "${index}" does not exist. Creating...`, loggerCtx);
                 await createIndices(
                     this.client,
                     indexPrefix,
+                    this.options.indexSettings,
+                    this.options.indexMappingProperties,
                     this.configService.entityIdStrategy.primaryKeyType,
                 );
             } else {
                 Logger.verbose(`Index "${index}" exists`, loggerCtx);
+
+                const existingIndexSettingsResult = await this.client.indices.getSettings({ index });
+                const existingIndexSettings = existingIndexSettingsResult.body[Object.keys(existingIndexSettingsResult.body)[0]].settings.index;
+
+                const tempName = new Date().getTime();
+                const nameSalt = Math.random().toString(36).substring(7);
+                const tempPrefix = `temp-` + `${tempName}-${nameSalt}-`;
+                const tempIndex = tempPrefix + indexName;
+
+                await createIndices(
+                    this.client,
+                    tempPrefix,
+                    this.options.indexSettings,
+                    this.options.indexMappingProperties,
+                    this.configService.entityIdStrategy.primaryKeyType,
+                    false,
+                );
+                const tempIndexSettingsResult = await this.client.indices.getSettings({ index: tempIndex });
+                const tempIndexSettings = tempIndexSettingsResult.body[tempIndex].settings.index;
+
+                const indexParamsToExclude = [`routing`, `number_of_shards`, `provided_name`,
+                    `creation_date`, `number_of_replicas`, `uuid`, `version`];
+                for (const param of indexParamsToExclude) {
+                    delete tempIndexSettings[param];
+                    delete existingIndexSettings[param];
+                }
+                if (!equal(tempIndexSettings, existingIndexSettings))
+                    Logger.warn(`Index "${index}" settings differs from index setting in vendure config! Consider re-indexing the data.`, loggerCtx);
+                else {
+                    const existingIndexMappingsResult = await this.client.indices.getMapping({ index });
+                    const existingIndexMappings = existingIndexMappingsResult.body[Object.keys(existingIndexMappingsResult.body)[0]].mappings;
+
+                    const tempIndexMappingsResult = await this.client.indices.getMapping({ index: tempIndex });
+                    const tempIndexMappings = tempIndexMappingsResult.body[tempIndex].mappings;
+                    if (!equal(tempIndexMappings, existingIndexMappings))
+                        // tslint:disable-next-line:max-line-length
+                        Logger.warn(`Index "${index}" mapping differs from index mapping in vendure config! Consider re-indexing the data.`, loggerCtx);
+                }
+
+                await this.client.indices.delete({ index: [tempPrefix+`products`, tempPrefix+`variants`] });
             }
         };
 
@@ -343,18 +386,9 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
     /**
      * Rebuilds the full search index.
      */
-    async reindex(ctx: RequestContext, dropIndices = true): Promise<Job> {
+    async reindex(ctx: RequestContext): Promise<Job> {
         const { indexPrefix } = this.options;
-        const job = await this.elasticsearchIndexService.reindex(ctx, dropIndices);
-        // tslint:disable-next-line:no-non-null-assertion
-        return job!;
-    }
-
-    /**
-     * Reindexes all in current Channel without dropping indices.
-     */
-    async updateAll(ctx: RequestContext): Promise<Job> {
-        const job = await this.elasticsearchIndexService.reindex(ctx, false);
+        const job = await this.elasticsearchIndexService.reindex(ctx);
         // tslint:disable-next-line:no-non-null-assertion
         return job!;
     }
@@ -415,17 +449,17 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
     ): { productAsset: SearchResultAsset | undefined; productVariantAsset: SearchResultAsset | undefined } {
         const productAsset: SearchResultAsset | undefined = source.productAssetId
             ? {
-                  id: source.productAssetId.toString(),
-                  preview: source.productPreview,
-                  focalPoint: source.productPreviewFocalPoint,
-              }
+                id: source.productAssetId.toString(),
+                preview: source.productPreview,
+                focalPoint: source.productPreviewFocalPoint,
+            }
             : undefined;
         const productVariantAsset: SearchResultAsset | undefined = source.productVariantAssetId
             ? {
-                  id: source.productVariantAssetId.toString(),
-                  preview: source.productVariantPreview,
-                  focalPoint: source.productVariantPreviewFocalPoint,
-              }
+                id: source.productVariantAssetId.toString(),
+                preview: source.productVariantPreview,
+                focalPoint: source.productVariantPreviewFocalPoint,
+            }
             : undefined;
         return { productAsset, productVariantAsset };
     }

+ 194 - 33
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -23,7 +23,7 @@ import {
 import { Observable } from 'rxjs';
 
 import { ELASTIC_SEARCH_OPTIONS, loggerCtx, PRODUCT_INDEX_NAME, VARIANT_INDEX_NAME } from './constants';
-import { createIndices, deleteIndices } from './indexing-utils';
+import { createIndices } from './indexing-utils';
 import { ElasticsearchOptions } from './options';
 import {
     BulkOperation,
@@ -84,7 +84,8 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
         private productVariantService: ProductVariantService,
         private configService: ConfigService,
-    ) {}
+    ) {
+    }
 
     onModuleInit(): any {
         const { host, port } = this.options;
@@ -118,10 +119,10 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
      * Updates the search index only for the affected product.
      */
     async assignProductToChannel({
-        ctx: rawContext,
-        productId,
-        channelId,
-    }: ProductChannelMessageData): Promise<boolean> {
+                                     ctx: rawContext,
+                                     productId,
+                                     channelId,
+                                 }: ProductChannelMessageData): Promise<boolean> {
         await this.updateProductsInternal([productId]);
         return true;
     }
@@ -130,29 +131,29 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
      * Updates the search index only for the affected product.
      */
     async removeProductFromChannel({
-        ctx: rawContext,
-        productId,
-        channelId,
-    }: ProductChannelMessageData): Promise<boolean> {
+                                       ctx: rawContext,
+                                       productId,
+                                       channelId,
+                                   }: ProductChannelMessageData): Promise<boolean> {
         await this.updateProductsInternal([productId]);
         return true;
     }
 
     async assignVariantToChannel({
-        ctx: rawContext,
-        productVariantId,
-        channelId,
-    }: VariantChannelMessageData): Promise<boolean> {
+                                     ctx: rawContext,
+                                     productVariantId,
+                                     channelId,
+                                 }: VariantChannelMessageData): Promise<boolean> {
         const productIds = await this.getProductIdsByVariantIds([productVariantId]);
         await this.updateProductsInternal(productIds);
         return true;
     }
 
     async removeVariantFromChannel({
-        ctx: rawContext,
-        productVariantId,
-        channelId,
-    }: VariantChannelMessageData): Promise<boolean> {
+                                       ctx: rawContext,
+                                       productVariantId,
+                                       channelId,
+                                   }: VariantChannelMessageData): Promise<boolean> {
         const productIds = await this.getProductIdsByVariantIds([productVariantId]);
         await this.updateProductsInternal(productIds);
         return true;
@@ -178,9 +179,9 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
     }
 
     updateVariantsById({
-        ctx: rawContext,
-        ids,
-    }: UpdateVariantsByIdMessageData): Observable<ReindexMessageResponse> {
+                           ctx: rawContext,
+                           ids,
+                       }: UpdateVariantsByIdMessageData): Observable<ReindexMessageResponse> {
         return asyncObservable(async observer => {
             return this.asyncQueue.push(async () => {
                 const timeStart = Date.now();
@@ -207,19 +208,179 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         });
     }
 
-    reindex({ ctx: rawContext, dropIndices }: ReindexMessageData): Observable<ReindexMessageResponse> {
+    reindex({ ctx: rawContext }: ReindexMessageData): Observable<ReindexMessageResponse> {
         return asyncObservable(async observer => {
             return this.asyncQueue.push(async () => {
                 const timeStart = Date.now();
                 const operations: Array<BulkProductOperation | BulkVariantOperation> = [];
 
-                if (dropIndices) {
-                    await deleteIndices(this.client, this.options.indexPrefix);
+                const reindexTempName = new Date().getTime();
+                try {
+
+                    const getIndexNameByAlias = async (aliasName: string) => {
+                        const aliasExist = await this.client.indices.existsAlias({ name: aliasName });
+                        if (aliasExist.body) {
+                            const alias = await this.client.indices.getAlias(
+                                {
+                                    name: aliasName,
+                                },
+                            );
+                            return Object.keys(alias.body)[0];
+                        } else {
+                            return aliasName;
+                        }
+                    };
+
                     await createIndices(
                         this.client,
                         this.options.indexPrefix,
+                        this.options.indexSettings,
+                        this.options.indexMappingProperties,
                         this.configService.entityIdStrategy.primaryKeyType,
+                        true,
+                        `-reindex-${reindexTempName}`,
                     );
+                    const reindexProductAliasName = this.options.indexPrefix + PRODUCT_INDEX_NAME + `-reindex-${reindexTempName}`;
+                    const reindexVariantAliasName = this.options.indexPrefix + VARIANT_INDEX_NAME + `-reindex-${reindexTempName}`;
+                    const reindexProductIndexName = await getIndexNameByAlias(reindexProductAliasName);
+                    const reindexVariantIndexName = await getIndexNameByAlias(reindexVariantAliasName);
+
+                    const originalProductAliasExist = await this.client.indices.existsAlias({ name: this.options.indexPrefix + PRODUCT_INDEX_NAME });
+                    const originalVariantAliasExist = await this.client.indices.existsAlias({ name: this.options.indexPrefix + VARIANT_INDEX_NAME });
+                    const originalProductIndexExist = await this.client.indices.exists({ index: this.options.indexPrefix + PRODUCT_INDEX_NAME });
+                    const originalVariantIndexExist = await this.client.indices.exists({ index: this.options.indexPrefix + PRODUCT_INDEX_NAME });
+
+                    const originalProductIndexName = await getIndexNameByAlias(this.options.indexPrefix + PRODUCT_INDEX_NAME);
+                    const originalVariantIndexName = await getIndexNameByAlias(this.options.indexPrefix + VARIANT_INDEX_NAME);
+
+                    if (originalVariantAliasExist.body || originalVariantIndexExist.body) {
+                        await this.client.reindex({
+                            refresh: true,
+                            body: {
+                                source: {
+                                    index: this.options.indexPrefix + VARIANT_INDEX_NAME,
+                                },
+                                dest: {
+                                    index: this.options.indexPrefix + VARIANT_INDEX_NAME + `-reindex-${reindexTempName}`,
+                                },
+                            },
+                        });
+                    }
+                    if (originalProductAliasExist.body || originalProductIndexExist.body) {
+                        await this.client.reindex({
+                            refresh: true,
+                            body: {
+                                source: {
+                                    index: this.options.indexPrefix + PRODUCT_INDEX_NAME,
+                                },
+                                dest: {
+                                    index: this.options.indexPrefix + PRODUCT_INDEX_NAME + `-reindex-${reindexTempName}`,
+                                },
+                            },
+                        });
+                    }
+
+                    const actions = [
+                        {
+                            remove: {
+                                index: reindexVariantIndexName,
+                                alias: this.options.indexPrefix + VARIANT_INDEX_NAME + `-reindex-${reindexTempName}`,
+                            },
+                        },
+                        {
+                            remove: {
+                                index: reindexProductIndexName,
+                                alias: this.options.indexPrefix + PRODUCT_INDEX_NAME + `-reindex-${reindexTempName}`,
+                            },
+                        },
+                        {
+                            add: {
+                                index: reindexVariantIndexName,
+                                alias: this.options.indexPrefix + VARIANT_INDEX_NAME,
+                            },
+                        },
+                        {
+                            add: {
+                                index: reindexProductIndexName,
+                                alias: this.options.indexPrefix + PRODUCT_INDEX_NAME,
+                            },
+                        },
+                    ];
+
+                    if (originalProductAliasExist.body) {
+                        actions.push({
+                                remove: {
+                                    index: originalProductIndexName,
+                                    alias: this.options.indexPrefix + PRODUCT_INDEX_NAME,
+                                },
+                            },
+                        );
+                    } else if (originalProductIndexExist.body) {
+                        await this.client.indices.delete({
+                            index: [this.options.indexPrefix + PRODUCT_INDEX_NAME],
+                        });
+                    }
+
+                    if (originalVariantAliasExist.body) {
+                        actions.push({
+                                remove: {
+                                    index: originalVariantIndexName,
+                                    alias: this.options.indexPrefix + VARIANT_INDEX_NAME,
+                                },
+                            },
+                        );
+                    } else if (originalVariantIndexExist.body) {
+                        await this.client.indices.delete({
+                            index: [this.options.indexPrefix + VARIANT_INDEX_NAME],
+                        });
+                    }
+
+                    await this.client.indices.updateAliases({
+                        body: {
+                            actions,
+                        },
+                    });
+
+                    if (originalProductAliasExist.body)
+                    {
+                        await this.client.indices.delete({
+                            index: [originalProductIndexName],
+                        });
+                    }
+                    if (originalVariantAliasExist.body)
+                    {
+                        await this.client.indices.delete({
+                            index: [originalVariantIndexName],
+                        });
+                    }
+                } catch (e) {
+                    Logger.warn(`Could not recreate indices. Reindexing continue with existing indices.`, loggerCtx);
+                    Logger.warn(JSON.stringify(e), loggerCtx);
+                } finally {
+                    const reindexVariantAliasExist = await this.client.indices.existsAlias({ name: this.options.indexPrefix + VARIANT_INDEX_NAME + `-reindex-${reindexTempName}` });
+                    if (reindexVariantAliasExist.body) {
+                        const reindexVariantAliasResult = await this.client.indices.getAlias(
+                            {
+                                name: this.options.indexPrefix + VARIANT_INDEX_NAME + `-reindex-${reindexTempName}`,
+                            },
+                        );
+                        const reindexVariantIndexName = Object.keys(reindexVariantAliasResult.body)[0];
+                        await this.client.indices.delete({
+                            index: [reindexVariantIndexName],
+                        });
+                    }
+                    const reindexProductAliasExist = await this.client.indices.existsAlias({ name: this.options.indexPrefix + PRODUCT_INDEX_NAME + `-reindex-${reindexTempName}` });
+                    if (reindexProductAliasExist.body) {
+                        const reindexProductAliasResult = await this.client.indices.getAlias(
+                            {
+                                name: this.options.indexPrefix + PRODUCT_INDEX_NAME + `-reindex-${reindexTempName}`,
+                            },
+                        );
+                        const reindexProductIndexName = Object.keys(reindexProductAliasResult.body)[0];
+                        await this.client.indices.delete({
+                            index: [reindexProductIndexName],
+                        });
+                    }
                 }
 
                 const deletedProductIds = await this.connection
@@ -384,7 +545,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                     },
                 );
                 updatedProductVariants.forEach(variant => (variant.product = product));
-                if (product.enabled === false) {
+                if (!product.enabled) {
                     updatedProductVariants.forEach(v => (v.enabled = false));
                 }
                 Logger.verbose(`Updating Product (${productId})`, loggerCtx);
@@ -424,15 +585,15 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                                 operation: {
                                     doc: variantsInChannel.length
                                         ? this.createProductIndexItem(
-                                              variantsInChannel,
-                                              channelCtx.channelId,
-                                              languageCode,
-                                          )
+                                            variantsInChannel,
+                                            channelCtx.channelId,
+                                            languageCode,
+                                        )
                                         : this.createSyntheticProductIndexItem(
-                                              channelCtx,
-                                              product,
-                                              languageCode,
-                                          ),
+                                            channelCtx,
+                                            product,
+                                            languageCode,
+                                        ),
                                     doc_as_upsert: true,
                                 },
                             },

+ 45 - 21
packages/elasticsearch-plugin/src/indexing-utils.ts

@@ -4,7 +4,8 @@ import { ID, Logger } from '@vendure/core';
 import { loggerCtx, PRODUCT_INDEX_NAME, VARIANT_INDEX_NAME } from './constants';
 import { ProductIndexItem, VariantIndexItem } from './types';
 
-export async function createIndices(client: Client, prefix: string, primaryKeyType: 'increment' | 'uuid') {
+export async function createIndices(client: Client, prefix: string, indexSettings: object, indexMappingProperties: object,
+                                    primaryKeyType: 'increment' | 'uuid', mapAlias = true, aliasPostfix = ``) {
     const textWithKeyword = {
         type: 'text',
         fields: {
@@ -46,39 +47,62 @@ export async function createIndices(client: Client, prefix: string, primaryKeyTy
         priceMax: { type: 'long' },
         priceWithTaxMin: { type: 'long' },
         priceWithTaxMax: { type: 'long' },
+        ...indexMappingProperties,
     };
 
     const variantMappings: { [prop in keyof VariantIndexItem]: any } = {
         ...commonMappings,
         price: { type: 'long' },
         priceWithTax: { type: 'long' },
+        ...indexMappingProperties,
     };
 
-    try {
-        const index = prefix + VARIANT_INDEX_NAME;
-        await client.indices.create({
-            index,
-            body: {
-                mappings: {
-                    properties: variantMappings,
+    const unixtimestampPostfix = new Date().getTime();
+
+    const createIndex = async (mappings:{ [prop in keyof any]: any }, index: string, alias: string) => {
+        if (mapAlias) {
+            await client.indices.create({
+                index,
+                body: {
+                    mappings: {
+                        properties: mappings,
+                    },
+                    settings: indexSettings,
                 },
-            },
-        });
-        Logger.verbose(`Created index "${index}"`, loggerCtx);
+            });
+            await client.indices.putAlias({
+                index,
+                name: alias,
+            });
+            Logger.verbose(`Created index "${index}"`, loggerCtx);
+        } else {
+            await client.indices.create({
+                index: alias,
+                body: {
+                    mappings: {
+                        properties: mappings,
+                    },
+                    settings: indexSettings,
+                },
+            });
+            Logger.verbose(`Created index "${alias}"`, loggerCtx);
+        }
+    }
+
+    try {
+        const index = prefix + VARIANT_INDEX_NAME + `${unixtimestampPostfix}`;
+        const alias = prefix + VARIANT_INDEX_NAME + aliasPostfix;
+
+        await createIndex(variantMappings, index,alias);
     } catch (e) {
         Logger.error(JSON.stringify(e, null, 2), loggerCtx);
     }
+
     try {
-        const index = prefix + PRODUCT_INDEX_NAME;
-        await client.indices.create({
-            index,
-            body: {
-                mappings: {
-                    properties: productMappings,
-                },
-            },
-        });
-        Logger.verbose(`Created index "${index}"`, loggerCtx);
+        const index = prefix + PRODUCT_INDEX_NAME + `${unixtimestampPostfix}`;
+        const alias = prefix + PRODUCT_INDEX_NAME + aliasPostfix;
+
+        await createIndex(productMappings, index,alias);
     } catch (e) {
         Logger.error(JSON.stringify(e, null, 2), loggerCtx);
     }

+ 21 - 0
packages/elasticsearch-plugin/src/options.ts

@@ -56,6 +56,25 @@ export interface ElasticsearchOptions {
      * 'vendure-'
      */
     indexPrefix?: string;
+    /**
+     * @description
+     * [These options](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/index-modules.html#index-modules-settings)
+     * are directly passed to index settings. To apply some settings indices will be recreated.
+     *
+     * @default
+     * {}
+     */
+    indexSettings?: object;
+    /**
+     * @description
+     * This option allow to redefine or define new properties in mapping. More about elastic
+     * [mapping](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html)
+     * After changing this option indices will be recreated.
+     *
+     * @default
+     * {}
+     */
+    indexMappingProperties?: object;
     /**
      * @description
      * Batch size for bulk operations (e.g. when rebuilding the indices).
@@ -335,6 +354,8 @@ export const defaultOptions: ElasticsearchRuntimeOptions = {
     connectionAttempts: 10,
     connectionAttemptInterval: 5000,
     indexPrefix: 'vendure-',
+    indexSettings: {},
+    indexMappingProperties:{},
     batchSize: 2000,
     searchConfig: {
         facetValueMaxSize: 50,

+ 1 - 1
packages/elasticsearch-plugin/src/plugin.ts

@@ -339,7 +339,7 @@ export class ElasticsearchPlugin implements OnApplicationBootstrap {
             .subscribe(event => {
                 const defaultTaxZone = event.ctx.channel.defaultTaxZone;
                 if (defaultTaxZone && idsAreEqual(defaultTaxZone.id, event.taxRate.zone.id)) {
-                    return this.elasticsearchService.updateAll(event.ctx);
+                    return this.elasticsearchService.reindex(event.ctx);
                 }
             });
     }

+ 0 - 1
packages/elasticsearch-plugin/src/types.ts

@@ -156,7 +156,6 @@ export interface ReindexMessageResponse {
 
 export type ReindexMessageData = {
     ctx: SerializedRequestContext;
-    dropIndices: boolean;
 };
 
 export type UpdateProductMessageData = {

+ 1 - 1
yarn.lock

@@ -8717,7 +8717,7 @@ fancy-log@^1.3.2:
     parse-node-version "^1.0.0"
     time-stamp "^1.0.0"
 
-fast-deep-equal@^3.1.1:
+fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
   version "3.1.3"
   resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==