Browse Source

Merge branch 'worker'

Michael Bromley 6 years ago
parent
commit
759c79d5be
42 changed files with 1652 additions and 1084 deletions
  1. 11 0
      packages/core/e2e/config/test-config.ts
  2. 33 4
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  3. 164 76
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  4. 76 2
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  5. 40 8
      packages/core/e2e/test-server.ts
  6. 5 5
      packages/core/mock-data/populate-for-testing.ts
  7. 1 0
      packages/core/package.json
  8. 3 3
      packages/core/src/api/api-internal-modules.ts
  9. 1 1
      packages/core/src/api/api.module.ts
  10. 113 0
      packages/core/src/api/common/request-context.spec.ts
  11. 29 0
      packages/core/src/api/common/request-context.ts
  12. 46 5
      packages/core/src/bootstrap.ts
  13. 1 0
      packages/core/src/config/config.service.mock.ts
  14. 5 0
      packages/core/src/config/config.service.ts
  15. 8 1
      packages/core/src/config/default-config.ts
  16. 6 1
      packages/core/src/config/logger/default-logger.ts
  17. 40 0
      packages/core/src/config/vendure-config.ts
  18. 6 0
      packages/core/src/config/vendure-plugin/vendure-plugin.ts
  19. 1 1
      packages/core/src/data-import/data-import.module.ts
  20. 2 1
      packages/core/src/index.ts
  21. 6 1
      packages/core/src/plugin/default-search-plugin/constants.ts
  22. 10 37
      packages/core/src/plugin/default-search-plugin/default-search-plugin.ts
  23. 0 165
      packages/core/src/plugin/default-search-plugin/indexer/index-builder.ts
  24. 248 0
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  25. 0 154
      packages/core/src/plugin/default-search-plugin/indexer/ipc.ts
  26. 0 16
      packages/core/src/plugin/default-search-plugin/indexer/search-index-worker.ts
  27. 92 276
      packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts
  28. 55 8
      packages/core/src/plugin/plugin.module.ts
  29. 57 3
      packages/core/src/service/service.module.ts
  30. 1 1
      packages/core/src/service/services/tax-rate.service.ts
  31. 1 0
      packages/core/src/worker/constants.ts
  32. 14 0
      packages/core/src/worker/worker.module.ts
  33. 15 8
      packages/create/src/create-vendure-app.ts
  34. 7 4
      packages/create/src/gather-user-responses.ts
  35. 4 1
      packages/create/src/helpers.ts
  36. 1 0
      packages/create/src/types.ts
  37. 7 0
      packages/create/templates/index-worker.hbs
  38. 5 1
      packages/elasticsearch-plugin/src/constants.ts
  39. 94 298
      packages/elasticsearch-plugin/src/elasticsearch-index.service.ts
  40. 358 0
      packages/elasticsearch-plugin/src/indexer.controller.ts
  41. 72 3
      packages/elasticsearch-plugin/src/plugin.ts
  42. 14 0
      yarn.lock

+ 11 - 0
packages/core/e2e/config/test-config.ts

@@ -1,7 +1,10 @@
+import { Transport } from '@nestjs/microservices';
 import { ADMIN_API_PATH, SHOP_API_PATH } from '@vendure/common/lib/shared-constants';
 import path from 'path';
 
 import { DefaultAssetNamingStrategy } from '../../src/config/asset-naming-strategy/default-asset-naming-strategy';
+import { DefaultLogger } from '../../src/config/logger/default-logger';
+import { LogLevel } from '../../src/config/logger/vendure-logger';
 import { VendureConfig } from '../../src/config/vendure-config';
 
 import { TestingAssetPreviewStrategy } from './testing-asset-preview-strategy';
@@ -52,6 +55,7 @@ export const testConfig: VendureConfig = {
     paymentOptions: {
         paymentMethodHandlers: [],
     },
+    logger: new DefaultLogger({ level: LogLevel.Error }),
     importExportOptions: {
         importAssetsDir: path.join(__dirname, '..', 'fixtures/assets'),
     },
@@ -60,4 +64,11 @@ export const testConfig: VendureConfig = {
         assetStorageStrategy: new TestingAssetStorageStrategy(),
         assetPreviewStrategy: new TestingAssetPreviewStrategy(),
     },
+    workerOptions: {
+        runInMainProcess: true,
+        transport: Transport.TCP,
+        options: {
+            port: 3051,
+        },
+    },
 };

+ 33 - 4
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -11,6 +11,8 @@ import {
     ConfigArgType,
     CreateCollection,
     CreateFacet,
+    GetRunningJobs,
+    JobState,
     LanguageCode,
     SearchFacetValues,
     SearchGetPrices,
@@ -45,7 +47,7 @@ describe('Default search plugin', () => {
                 customerCount: 1,
             },
             {
-                plugins: [new DefaultSearchPlugin({ runInForkedProcess: false })],
+                plugins: [new DefaultSearchPlugin()],
             },
         );
         await adminClient.init();
@@ -269,6 +271,7 @@ describe('Default search plugin', () => {
                     { id: 'T_3', enabled: false },
                 ],
             });
+            await awaitRunningJobs();
             const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS_SHOP, {
                 input: {
                     groupByProduct: false,
@@ -313,7 +316,7 @@ describe('Default search plugin', () => {
                     facetValueIds: [],
                 },
             });
-
+            await awaitRunningJobs();
             const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
                 SEARCH_PRODUCTS,
                 {
@@ -358,7 +361,7 @@ describe('Default search plugin', () => {
                     },
                 },
             );
-
+            await awaitRunningJobs();
             const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
                 SEARCH_PRODUCTS,
                 {
@@ -411,7 +414,7 @@ describe('Default search plugin', () => {
                     ],
                 },
             });
-
+            await awaitRunningJobs();
             const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
                 SEARCH_PRODUCTS,
                 {
@@ -438,6 +441,7 @@ describe('Default search plugin', () => {
                     value: 50,
                 },
             });
+            await awaitRunningJobs();
             const result = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(SEARCH_GET_PRICES, {
                 input: {
                     groupByProduct: true,
@@ -473,6 +477,7 @@ describe('Default search plugin', () => {
                     { id: 'T_2', enabled: false },
                 ],
             });
+            await awaitRunningJobs();
             const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
                 input: {
                     groupByProduct: true,
@@ -492,6 +497,7 @@ describe('Default search plugin', () => {
                     { id: 'T_4', enabled: false },
                 ],
             });
+            await awaitRunningJobs();
             const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
                 input: {
                     groupByProduct: true,
@@ -512,6 +518,7 @@ describe('Default search plugin', () => {
                     enabled: false,
                 },
             });
+            await awaitRunningJobs();
             const result = await adminClient.query<SearchProductsShop.Query, SearchProductsShop.Variables>(SEARCH_PRODUCTS, {
                 input: {
                     groupByProduct: true,
@@ -525,8 +532,30 @@ describe('Default search plugin', () => {
             ]);
         });
     });
+
+    /**
+     * Since the updates to the search index are performed in the background, we need
+     * to ensure that any running background jobs are completed before continuing certain
+     * tests.
+     */
+    async function awaitRunningJobs() {
+        let runningJobs = 0;
+        do {
+            const { jobs } = await adminClient.query<GetRunningJobs.Query>(GET_RUNNING_JOBS);
+            runningJobs = jobs.filter(job => job.state !== JobState.COMPLETED);
+        } while (runningJobs > 0);
+    }
 });
 
+export const GET_RUNNING_JOBS = gql`
+    query GetRunningJobs {
+        jobs {
+            name
+            state
+        }
+    }
+`;
+
 export const SEARCH_PRODUCTS = gql`
     query SearchProductsAdmin($input: SearchInput!) {
         search(input: $input) {

+ 164 - 76
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1,5 +1,4 @@
 // tslint:disable
-
 export type Maybe<T> = T | null;
 /** All built-in and custom scalars, mapped to their actual values */
 export type Scalars = {
@@ -20,6 +19,7 @@ export type Scalars = {
 };
 
 export type Address = Node & {
+    __typename?: 'Address';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -38,6 +38,7 @@ export type Address = Node & {
 };
 
 export type Adjustment = {
+    __typename?: 'Adjustment';
     adjustmentSource: Scalars['String'];
     type: AdjustmentType;
     description: Scalars['String'];
@@ -45,6 +46,7 @@ export type Adjustment = {
 };
 
 export type AdjustmentOperations = {
+    __typename?: 'AdjustmentOperations';
     conditions: Array<ConfigurableOperation>;
     actions: Array<ConfigurableOperation>;
 };
@@ -60,6 +62,7 @@ export enum AdjustmentType {
 }
 
 export type Administrator = Node & {
+    __typename?: 'Administrator';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -78,6 +81,7 @@ export type AdministratorFilterParameter = {
 };
 
 export type AdministratorList = PaginatedList & {
+    __typename?: 'AdministratorList';
     items: Array<Administrator>;
     totalItems: Scalars['Int'];
 };
@@ -99,6 +103,7 @@ export type AdministratorSortParameter = {
 };
 
 export type Asset = Node & {
+    __typename?: 'Asset';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -122,6 +127,7 @@ export type AssetFilterParameter = {
 };
 
 export type AssetList = PaginatedList & {
+    __typename?: 'AssetList';
     items: Array<Asset>;
     totalItems: Scalars['Int'];
 };
@@ -156,6 +162,7 @@ export type BooleanOperators = {
 
 export type Cancellation = Node &
     StockMovement & {
+        __typename?: 'Cancellation';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -166,6 +173,7 @@ export type Cancellation = Node &
     };
 
 export type Channel = Node & {
+    __typename?: 'Channel';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -179,6 +187,7 @@ export type Channel = Node & {
 };
 
 export type Collection = Node & {
+    __typename?: 'Collection';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -203,6 +212,7 @@ export type CollectionProductVariantsArgs = {
 };
 
 export type CollectionBreadcrumb = {
+    __typename?: 'CollectionBreadcrumb';
     id: Scalars['ID'];
     name: Scalars['String'];
 };
@@ -218,6 +228,7 @@ export type CollectionFilterParameter = {
 };
 
 export type CollectionList = PaginatedList & {
+    __typename?: 'CollectionList';
     items: Array<Collection>;
     totalItems: Scalars['Int'];
 };
@@ -239,6 +250,7 @@ export type CollectionSortParameter = {
 };
 
 export type CollectionTranslation = {
+    __typename?: 'CollectionTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -256,6 +268,7 @@ export type CollectionTranslationInput = {
 };
 
 export type ConfigArg = {
+    __typename?: 'ConfigArg';
     name: Scalars['String'];
     type: ConfigArgType;
     value?: Maybe<Scalars['String']>;
@@ -286,6 +299,7 @@ export enum ConfigArgType {
 }
 
 export type ConfigurableOperation = {
+    __typename?: 'ConfigurableOperation';
     code: Scalars['String'];
     args: Array<ConfigArg>;
     description: Scalars['String'];
@@ -297,6 +311,7 @@ export type ConfigurableOperationInput = {
 };
 
 export type Country = Node & {
+    __typename?: 'Country';
     id: Scalars['ID'];
     languageCode: LanguageCode;
     code: Scalars['String'];
@@ -313,6 +328,7 @@ export type CountryFilterParameter = {
 };
 
 export type CountryList = PaginatedList & {
+    __typename?: 'CountryList';
     items: Array<Country>;
     totalItems: Scalars['Int'];
 };
@@ -331,6 +347,7 @@ export type CountrySortParameter = {
 };
 
 export type CountryTranslation = {
+    __typename?: 'CountryTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -823,12 +840,14 @@ export enum CurrencyCode {
 }
 
 export type CurrentUser = {
+    __typename?: 'CurrentUser';
     id: Scalars['ID'];
     identifier: Scalars['String'];
     channelTokens: Array<Scalars['String']>;
 };
 
 export type Customer = Node & {
+    __typename?: 'Customer';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -858,6 +877,7 @@ export type CustomerFilterParameter = {
 };
 
 export type CustomerGroup = Node & {
+    __typename?: 'CustomerGroup';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -865,6 +885,7 @@ export type CustomerGroup = Node & {
 };
 
 export type CustomerList = PaginatedList & {
+    __typename?: 'CustomerList';
     items: Array<Customer>;
     totalItems: Scalars['Int'];
 };
@@ -900,6 +921,7 @@ export type DateRange = {
 };
 
 export type DeletionResponse = {
+    __typename?: 'DeletionResponse';
     result: DeletionResult;
     message?: Maybe<Scalars['String']>;
 };
@@ -912,6 +934,7 @@ export enum DeletionResult {
 }
 
 export type Facet = Node & {
+    __typename?: 'Facet';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -934,6 +957,7 @@ export type FacetFilterParameter = {
 };
 
 export type FacetList = PaginatedList & {
+    __typename?: 'FacetList';
     items: Array<Facet>;
     totalItems: Scalars['Int'];
 };
@@ -954,6 +978,7 @@ export type FacetSortParameter = {
 };
 
 export type FacetTranslation = {
+    __typename?: 'FacetTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -969,6 +994,7 @@ export type FacetTranslationInput = {
 };
 
 export type FacetValue = Node & {
+    __typename?: 'FacetValue';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -984,11 +1010,13 @@ export type FacetValue = Node & {
  * by the search, and in what quantity.
  */
 export type FacetValueResult = {
+    __typename?: 'FacetValueResult';
     facetValue: FacetValue;
     count: Scalars['Int'];
 };
 
 export type FacetValueTranslation = {
+    __typename?: 'FacetValueTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1004,6 +1032,7 @@ export type FacetValueTranslationInput = {
 };
 
 export type GlobalSettings = {
+    __typename?: 'GlobalSettings';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1014,12 +1043,14 @@ export type GlobalSettings = {
 };
 
 export type ImportInfo = {
+    __typename?: 'ImportInfo';
     errors?: Maybe<Array<Scalars['String']>>;
     processed: Scalars['Int'];
     imported: Scalars['Int'];
 };
 
 export type JobInfo = {
+    __typename?: 'JobInfo';
     id: Scalars['String'];
     name: Scalars['String'];
     state: JobState;
@@ -1415,6 +1446,7 @@ export enum LanguageCode {
 }
 
 export type LoginResult = {
+    __typename?: 'LoginResult';
     user: CurrentUser;
 };
 
@@ -1425,6 +1457,7 @@ export type MoveCollectionInput = {
 };
 
 export type Mutation = {
+    __typename?: 'Mutation';
     /** Create a new Administrator */
     createAdministrator: Administrator;
     /** Update an existing Administrator */
@@ -1439,6 +1472,12 @@ export type Mutation = {
     createChannel: Channel;
     /** Update an existing Channel */
     updateChannel: Channel;
+    /** Create a new Collection */
+    createCollection: Collection;
+    /** Update an existing Collection */
+    updateCollection: Collection;
+    /** Move a Collection to a different parent or index */
+    moveCollection: Collection;
     /** Create a new Country */
     createCountry: Country;
     /** Update an existing Country */
@@ -1453,12 +1492,18 @@ export type Mutation = {
     addCustomersToGroup: CustomerGroup;
     /** Remove Customers from a CustomerGroup */
     removeCustomersFromGroup: CustomerGroup;
-    /** Create a new Collection */
-    createCollection: Collection;
-    /** Update an existing Collection */
-    updateCollection: Collection;
-    /** Move a Collection to a different parent or index */
-    moveCollection: Collection;
+    /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
+    createCustomer: Customer;
+    /** Update an existing Customer */
+    updateCustomer: Customer;
+    /** Delete a Customer */
+    deleteCustomer: DeletionResponse;
+    /** Create a new Address and associate it with the Customer specified by customerId */
+    createCustomerAddress: Address;
+    /** Update an existing Address */
+    updateCustomerAddress: Address;
+    /** Update an existing Address */
+    deleteCustomerAddress: Scalars['Boolean'];
     /** Create a new Facet */
     createFacet: Facet;
     /** Update an existing Facet */
@@ -1472,18 +1517,6 @@ export type Mutation = {
     /** Delete one or more FacetValues */
     deleteFacetValues: Array<DeletionResponse>;
     updateGlobalSettings: GlobalSettings;
-    /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
-    createCustomer: Customer;
-    /** Update an existing Customer */
-    updateCustomer: Customer;
-    /** Delete a Customer */
-    deleteCustomer: DeletionResponse;
-    /** Create a new Address and associate it with the Customer specified by customerId */
-    createCustomerAddress: Address;
-    /** Update an existing Address */
-    updateCustomerAddress: Address;
-    /** Update an existing Address */
-    deleteCustomerAddress: Scalars['Boolean'];
     importProducts?: Maybe<ImportInfo>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -1568,6 +1601,18 @@ export type MutationUpdateChannelArgs = {
     input: UpdateChannelInput;
 };
 
+export type MutationCreateCollectionArgs = {
+    input: CreateCollectionInput;
+};
+
+export type MutationUpdateCollectionArgs = {
+    input: UpdateCollectionInput;
+};
+
+export type MutationMoveCollectionArgs = {
+    input: MoveCollectionInput;
+};
+
 export type MutationCreateCountryArgs = {
     input: CreateCountryInput;
 };
@@ -1598,16 +1643,30 @@ export type MutationRemoveCustomersFromGroupArgs = {
     customerIds: Array<Scalars['ID']>;
 };
 
-export type MutationCreateCollectionArgs = {
-    input: CreateCollectionInput;
+export type MutationCreateCustomerArgs = {
+    input: CreateCustomerInput;
+    password?: Maybe<Scalars['String']>;
 };
 
-export type MutationUpdateCollectionArgs = {
-    input: UpdateCollectionInput;
+export type MutationUpdateCustomerArgs = {
+    input: UpdateCustomerInput;
 };
 
-export type MutationMoveCollectionArgs = {
-    input: MoveCollectionInput;
+export type MutationDeleteCustomerArgs = {
+    id: Scalars['ID'];
+};
+
+export type MutationCreateCustomerAddressArgs = {
+    customerId: Scalars['ID'];
+    input: CreateAddressInput;
+};
+
+export type MutationUpdateCustomerAddressArgs = {
+    input: UpdateAddressInput;
+};
+
+export type MutationDeleteCustomerAddressArgs = {
+    id: Scalars['ID'];
 };
 
 export type MutationCreateFacetArgs = {
@@ -1640,32 +1699,6 @@ export type MutationUpdateGlobalSettingsArgs = {
     input: UpdateGlobalSettingsInput;
 };
 
-export type MutationCreateCustomerArgs = {
-    input: CreateCustomerInput;
-    password?: Maybe<Scalars['String']>;
-};
-
-export type MutationUpdateCustomerArgs = {
-    input: UpdateCustomerInput;
-};
-
-export type MutationDeleteCustomerArgs = {
-    id: Scalars['ID'];
-};
-
-export type MutationCreateCustomerAddressArgs = {
-    customerId: Scalars['ID'];
-    input: CreateAddressInput;
-};
-
-export type MutationUpdateCustomerAddressArgs = {
-    input: UpdateAddressInput;
-};
-
-export type MutationDeleteCustomerAddressArgs = {
-    id: Scalars['ID'];
-};
-
 export type MutationImportProductsArgs = {
     csvFile: Scalars['Upload'];
 };
@@ -1782,6 +1815,7 @@ export type MutationRemoveMembersFromZoneArgs = {
 };
 
 export type Node = {
+    __typename?: 'Node';
     id: Scalars['ID'];
 };
 
@@ -1800,6 +1834,7 @@ export type NumberRange = {
 };
 
 export type Order = Node & {
+    __typename?: 'Order';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1823,6 +1858,7 @@ export type Order = Node & {
 };
 
 export type OrderAddress = {
+    __typename?: 'OrderAddress';
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
     streetLine1?: Maybe<Scalars['String']>;
@@ -1851,6 +1887,7 @@ export type OrderFilterParameter = {
 };
 
 export type OrderItem = Node & {
+    __typename?: 'OrderItem';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1862,6 +1899,7 @@ export type OrderItem = Node & {
 };
 
 export type OrderLine = Node & {
+    __typename?: 'OrderLine';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1878,6 +1916,7 @@ export type OrderLine = Node & {
 };
 
 export type OrderList = PaginatedList & {
+    __typename?: 'OrderList';
     items: Array<Order>;
     totalItems: Scalars['Int'];
 };
@@ -1904,11 +1943,13 @@ export type OrderSortParameter = {
 };
 
 export type PaginatedList = {
+    __typename?: 'PaginatedList';
     items: Array<Node>;
     totalItems: Scalars['Int'];
 };
 
 export type Payment = Node & {
+    __typename?: 'Payment';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1920,6 +1961,7 @@ export type Payment = Node & {
 };
 
 export type PaymentMethod = Node & {
+    __typename?: 'PaymentMethod';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1936,6 +1978,7 @@ export type PaymentMethodFilterParameter = {
 };
 
 export type PaymentMethodList = PaginatedList & {
+    __typename?: 'PaymentMethodList';
     items: Array<PaymentMethod>;
     totalItems: Scalars['Int'];
 };
@@ -1988,11 +2031,13 @@ export enum Permission {
 
 /** The price range where the result has more than one price */
 export type PriceRange = {
+    __typename?: 'PriceRange';
     min: Scalars['Int'];
     max: Scalars['Int'];
 };
 
 export type Product = Node & {
+    __typename?: 'Product';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2022,6 +2067,7 @@ export type ProductFilterParameter = {
 };
 
 export type ProductList = PaginatedList & {
+    __typename?: 'ProductList';
     items: Array<Product>;
     totalItems: Scalars['Int'];
 };
@@ -2034,6 +2080,7 @@ export type ProductListOptions = {
 };
 
 export type ProductOption = Node & {
+    __typename?: 'ProductOption';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2045,6 +2092,7 @@ export type ProductOption = Node & {
 };
 
 export type ProductOptionGroup = Node & {
+    __typename?: 'ProductOptionGroup';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2057,6 +2105,7 @@ export type ProductOptionGroup = Node & {
 };
 
 export type ProductOptionGroupTranslation = {
+    __typename?: 'ProductOptionGroupTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2072,6 +2121,7 @@ export type ProductOptionGroupTranslationInput = {
 };
 
 export type ProductOptionTranslation = {
+    __typename?: 'ProductOptionTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2096,6 +2146,7 @@ export type ProductSortParameter = {
 };
 
 export type ProductTranslation = {
+    __typename?: 'ProductTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2115,6 +2166,7 @@ export type ProductTranslationInput = {
 };
 
 export type ProductVariant = Node & {
+    __typename?: 'ProductVariant';
     id: Scalars['ID'];
     productId: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -2160,6 +2212,7 @@ export type ProductVariantFilterParameter = {
 };
 
 export type ProductVariantList = PaginatedList & {
+    __typename?: 'ProductVariantList';
     items: Array<ProductVariant>;
     totalItems: Scalars['Int'];
 };
@@ -2184,6 +2237,7 @@ export type ProductVariantSortParameter = {
 };
 
 export type ProductVariantTranslation = {
+    __typename?: 'ProductVariantTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2199,6 +2253,7 @@ export type ProductVariantTranslationInput = {
 };
 
 export type Promotion = Node & {
+    __typename?: 'Promotion';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2216,6 +2271,7 @@ export type PromotionFilterParameter = {
 };
 
 export type PromotionList = PaginatedList & {
+    __typename?: 'PromotionList';
     items: Array<Promotion>;
     totalItems: Scalars['Int'];
 };
@@ -2235,6 +2291,7 @@ export type PromotionSortParameter = {
 };
 
 export type Query = {
+    __typename?: 'Query';
     administrators: AdministratorList;
     administrator?: Maybe<Administrator>;
     assets: AssetList;
@@ -2243,22 +2300,22 @@ export type Query = {
     channels: Array<Channel>;
     channel?: Maybe<Channel>;
     activeChannel: Channel;
+    collections: CollectionList;
+    collection?: Maybe<Collection>;
+    collectionFilters: Array<ConfigurableOperation>;
     countries: CountryList;
     country?: Maybe<Country>;
     customerGroups: Array<CustomerGroup>;
     customerGroup?: Maybe<CustomerGroup>;
-    collections: CollectionList;
-    collection?: Maybe<Collection>;
-    collectionFilters: Array<ConfigurableOperation>;
+    customers: CustomerList;
+    customer?: Maybe<Customer>;
     facets: FacetList;
     facet?: Maybe<Facet>;
     globalSettings: GlobalSettings;
-    customers: CustomerList;
-    customer?: Maybe<Customer>;
-    order?: Maybe<Order>;
-    orders: OrderList;
     job?: Maybe<JobInfo>;
     jobs: Array<JobInfo>;
+    order?: Maybe<Order>;
+    orders: OrderList;
     paymentMethods: PaymentMethodList;
     paymentMethod?: Maybe<PaymentMethod>;
     productOptionGroups: Array<ProductOptionGroup>;
@@ -2282,7 +2339,6 @@ export type Query = {
     taxRate?: Maybe<TaxRate>;
     zones: Array<Zone>;
     zone?: Maybe<Zone>;
-    temp__?: Maybe<Scalars['Boolean']>;
 };
 
 export type QueryAdministratorsArgs = {
@@ -2305,6 +2361,16 @@ export type QueryChannelArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryCollectionsArgs = {
+    languageCode?: Maybe<LanguageCode>;
+    options?: Maybe<CollectionListOptions>;
+};
+
+export type QueryCollectionArgs = {
+    id: Scalars['ID'];
+    languageCode?: Maybe<LanguageCode>;
+};
+
 export type QueryCountriesArgs = {
     options?: Maybe<CountryListOptions>;
 };
@@ -2317,14 +2383,12 @@ export type QueryCustomerGroupArgs = {
     id: Scalars['ID'];
 };
 
-export type QueryCollectionsArgs = {
-    languageCode?: Maybe<LanguageCode>;
-    options?: Maybe<CollectionListOptions>;
+export type QueryCustomersArgs = {
+    options?: Maybe<CustomerListOptions>;
 };
 
-export type QueryCollectionArgs = {
+export type QueryCustomerArgs = {
     id: Scalars['ID'];
-    languageCode?: Maybe<LanguageCode>;
 };
 
 export type QueryFacetsArgs = {
@@ -2337,12 +2401,12 @@ export type QueryFacetArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryCustomersArgs = {
-    options?: Maybe<CustomerListOptions>;
+export type QueryJobArgs = {
+    jobId: Scalars['String'];
 };
 
-export type QueryCustomerArgs = {
-    id: Scalars['ID'];
+export type QueryJobsArgs = {
+    input?: Maybe<JobListInput>;
 };
 
 export type QueryOrderArgs = {
@@ -2353,14 +2417,6 @@ export type QueryOrdersArgs = {
     options?: Maybe<OrderListOptions>;
 };
 
-export type QueryJobArgs = {
-    jobId: Scalars['String'];
-};
-
-export type QueryJobsArgs = {
-    input?: Maybe<JobListInput>;
-};
-
 export type QueryPaymentMethodsArgs = {
     options?: Maybe<PaymentMethodListOptions>;
 };
@@ -2436,6 +2492,7 @@ export type QueryZoneArgs = {
 
 export type Return = Node &
     StockMovement & {
+        __typename?: 'Return';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -2446,6 +2503,7 @@ export type Return = Node &
     };
 
 export type Role = Node & {
+    __typename?: 'Role';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2463,6 +2521,7 @@ export type RoleFilterParameter = {
 };
 
 export type RoleList = PaginatedList & {
+    __typename?: 'RoleList';
     items: Array<Role>;
     totalItems: Scalars['Int'];
 };
@@ -2484,6 +2543,7 @@ export type RoleSortParameter = {
 
 export type Sale = Node &
     StockMovement & {
+        __typename?: 'Sale';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -2504,18 +2564,21 @@ export type SearchInput = {
 };
 
 export type SearchReindexResponse = {
+    __typename?: 'SearchReindexResponse';
     success: Scalars['Boolean'];
     timeTaken: Scalars['Int'];
     indexedItemCount: Scalars['Int'];
 };
 
 export type SearchResponse = {
+    __typename?: 'SearchResponse';
     items: Array<SearchResult>;
     totalItems: Scalars['Int'];
     facetValues: Array<FacetValueResult>;
 };
 
 export type SearchResult = {
+    __typename?: 'SearchResult';
     sku: Scalars['String'];
     slug: Scalars['String'];
     productId: Scalars['ID'];
@@ -2546,10 +2609,12 @@ export type SearchResultSortParameter = {
 };
 
 export type ServerConfig = {
+    __typename?: 'ServerConfig';
     customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type ShippingMethod = Node & {
+    __typename?: 'ShippingMethod';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2567,6 +2632,7 @@ export type ShippingMethodFilterParameter = {
 };
 
 export type ShippingMethodList = PaginatedList & {
+    __typename?: 'ShippingMethodList';
     items: Array<ShippingMethod>;
     totalItems: Scalars['Int'];
 };
@@ -2579,6 +2645,7 @@ export type ShippingMethodListOptions = {
 };
 
 export type ShippingMethodQuote = {
+    __typename?: 'ShippingMethodQuote';
     id: Scalars['ID'];
     price: Scalars['Int'];
     priceWithTax: Scalars['Int'];
@@ -2595,6 +2662,7 @@ export type ShippingMethodSortParameter = {
 
 /** The price value where the result has a single price */
 export type SinglePrice = {
+    __typename?: 'SinglePrice';
     value: Scalars['Int'];
 };
 
@@ -2605,6 +2673,7 @@ export enum SortOrder {
 
 export type StockAdjustment = Node &
     StockMovement & {
+        __typename?: 'StockAdjustment';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -2614,6 +2683,7 @@ export type StockAdjustment = Node &
     };
 
 export type StockMovement = {
+    __typename?: 'StockMovement';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2625,6 +2695,7 @@ export type StockMovement = {
 export type StockMovementItem = StockAdjustment | Sale | Cancellation | Return;
 
 export type StockMovementList = {
+    __typename?: 'StockMovementList';
     items: Array<StockMovementItem>;
     totalItems: Scalars['Int'];
 };
@@ -2648,6 +2719,7 @@ export type StringOperators = {
 };
 
 export type TaxCategory = Node & {
+    __typename?: 'TaxCategory';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2655,6 +2727,7 @@ export type TaxCategory = Node & {
 };
 
 export type TaxRate = Node & {
+    __typename?: 'TaxRate';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2675,6 +2748,7 @@ export type TaxRateFilterParameter = {
 };
 
 export type TaxRateList = PaginatedList & {
+    __typename?: 'TaxRateList';
     items: Array<TaxRate>;
     totalItems: Scalars['Int'];
 };
@@ -2867,6 +2941,7 @@ export type UpdateZoneInput = {
 };
 
 export type User = Node & {
+    __typename?: 'User';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -2878,6 +2953,7 @@ export type User = Node & {
 };
 
 export type Zone = Node & {
+    __typename?: 'Zone';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -3129,6 +3205,12 @@ export type DeleteCustomerMutation = { __typename?: 'Mutation' } & {
     deleteCustomer: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result'>;
 };
 
+export type GetRunningJobsQueryVariables = {};
+
+export type GetRunningJobsQuery = { __typename?: 'Query' } & {
+    jobs: Array<{ __typename?: 'JobInfo' } & Pick<JobInfo, 'name' | 'state'>>;
+};
+
 export type SearchProductsAdminQueryVariables = {
     input: SearchInput;
 };
@@ -4285,6 +4367,12 @@ export namespace DeleteCustomer {
     export type DeleteCustomer = DeleteCustomerMutation['deleteCustomer'];
 }
 
+export namespace GetRunningJobs {
+    export type Variables = GetRunningJobsQueryVariables;
+    export type Query = GetRunningJobsQuery;
+    export type Jobs = NonNullable<GetRunningJobsQuery['jobs'][0]>;
+}
+
 export namespace SearchProductsAdmin {
     export type Variables = SearchProductsAdminQueryVariables;
     export type Query = SearchProductsAdminQuery;

+ 76 - 2
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1,5 +1,4 @@
 // tslint:disable
-
 export type Maybe<T> = T | null;
 /** All built-in and custom scalars, mapped to their actual values */
 export type Scalars = {
@@ -20,6 +19,7 @@ export type Scalars = {
 };
 
 export type Address = Node & {
+    __typename?: 'Address';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -38,6 +38,7 @@ export type Address = Node & {
 };
 
 export type Adjustment = {
+    __typename?: 'Adjustment';
     adjustmentSource: Scalars['String'];
     type: AdjustmentType;
     description: Scalars['String'];
@@ -45,6 +46,7 @@ export type Adjustment = {
 };
 
 export type AdjustmentOperations = {
+    __typename?: 'AdjustmentOperations';
     conditions: Array<ConfigurableOperation>;
     actions: Array<ConfigurableOperation>;
 };
@@ -60,6 +62,7 @@ export enum AdjustmentType {
 }
 
 export type Administrator = Node & {
+    __typename?: 'Administrator';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -70,11 +73,13 @@ export type Administrator = Node & {
 };
 
 export type AdministratorList = PaginatedList & {
+    __typename?: 'AdministratorList';
     items: Array<Administrator>;
     totalItems: Scalars['Int'];
 };
 
 export type Asset = Node & {
+    __typename?: 'Asset';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -87,6 +92,7 @@ export type Asset = Node & {
 };
 
 export type AssetList = PaginatedList & {
+    __typename?: 'AssetList';
     items: Array<Asset>;
     totalItems: Scalars['Int'];
 };
@@ -103,6 +109,7 @@ export type BooleanOperators = {
 
 export type Cancellation = Node &
     StockMovement & {
+        __typename?: 'Cancellation';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -113,6 +120,7 @@ export type Cancellation = Node &
     };
 
 export type Channel = Node & {
+    __typename?: 'Channel';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -126,6 +134,7 @@ export type Channel = Node & {
 };
 
 export type Collection = Node & {
+    __typename?: 'Collection';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -149,6 +158,7 @@ export type CollectionProductVariantsArgs = {
 };
 
 export type CollectionBreadcrumb = {
+    __typename?: 'CollectionBreadcrumb';
     id: Scalars['ID'];
     name: Scalars['String'];
 };
@@ -163,6 +173,7 @@ export type CollectionFilterParameter = {
 };
 
 export type CollectionList = PaginatedList & {
+    __typename?: 'CollectionList';
     items: Array<Collection>;
     totalItems: Scalars['Int'];
 };
@@ -184,6 +195,7 @@ export type CollectionSortParameter = {
 };
 
 export type CollectionTranslation = {
+    __typename?: 'CollectionTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -193,6 +205,7 @@ export type CollectionTranslation = {
 };
 
 export type ConfigArg = {
+    __typename?: 'ConfigArg';
     name: Scalars['String'];
     type: ConfigArgType;
     value?: Maybe<Scalars['String']>;
@@ -223,6 +236,7 @@ export enum ConfigArgType {
 }
 
 export type ConfigurableOperation = {
+    __typename?: 'ConfigurableOperation';
     code: Scalars['String'];
     args: Array<ConfigArg>;
     description: Scalars['String'];
@@ -234,6 +248,7 @@ export type ConfigurableOperationInput = {
 };
 
 export type Country = Node & {
+    __typename?: 'Country';
     id: Scalars['ID'];
     languageCode: LanguageCode;
     code: Scalars['String'];
@@ -243,11 +258,13 @@ export type Country = Node & {
 };
 
 export type CountryList = PaginatedList & {
+    __typename?: 'CountryList';
     items: Array<Country>;
     totalItems: Scalars['Int'];
 };
 
 export type CountryTranslation = {
+    __typename?: 'CountryTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -598,12 +615,14 @@ export enum CurrencyCode {
 }
 
 export type CurrentUser = {
+    __typename?: 'CurrentUser';
     id: Scalars['ID'];
     identifier: Scalars['String'];
     channelTokens: Array<Scalars['String']>;
 };
 
 export type Customer = Node & {
+    __typename?: 'Customer';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -623,6 +642,7 @@ export type CustomerOrdersArgs = {
 };
 
 export type CustomerGroup = Node & {
+    __typename?: 'CustomerGroup';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -630,6 +650,7 @@ export type CustomerGroup = Node & {
 };
 
 export type CustomerList = PaginatedList & {
+    __typename?: 'CustomerList';
     items: Array<Customer>;
     totalItems: Scalars['Int'];
 };
@@ -647,6 +668,7 @@ export type DateRange = {
 };
 
 export type DeletionResponse = {
+    __typename?: 'DeletionResponse';
     result: DeletionResult;
     message?: Maybe<Scalars['String']>;
 };
@@ -659,6 +681,7 @@ export enum DeletionResult {
 }
 
 export type Facet = Node & {
+    __typename?: 'Facet';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -671,11 +694,13 @@ export type Facet = Node & {
 };
 
 export type FacetList = PaginatedList & {
+    __typename?: 'FacetList';
     items: Array<Facet>;
     totalItems: Scalars['Int'];
 };
 
 export type FacetTranslation = {
+    __typename?: 'FacetTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -684,6 +709,7 @@ export type FacetTranslation = {
 };
 
 export type FacetValue = Node & {
+    __typename?: 'FacetValue';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -699,11 +725,13 @@ export type FacetValue = Node & {
  * by the search, and in what quantity.
  */
 export type FacetValueResult = {
+    __typename?: 'FacetValueResult';
     facetValue: FacetValue;
     count: Scalars['Int'];
 };
 
 export type FacetValueTranslation = {
+    __typename?: 'FacetValueTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -712,6 +740,7 @@ export type FacetValueTranslation = {
 };
 
 export type GlobalSettings = {
+    __typename?: 'GlobalSettings';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -722,6 +751,7 @@ export type GlobalSettings = {
 };
 
 export type ImportInfo = {
+    __typename?: 'ImportInfo';
     errors?: Maybe<Array<Scalars['String']>>;
     processed: Scalars['Int'];
     imported: Scalars['Int'];
@@ -1100,10 +1130,12 @@ export enum LanguageCode {
 }
 
 export type LoginResult = {
+    __typename?: 'LoginResult';
     user: CurrentUser;
 };
 
 export type Mutation = {
+    __typename?: 'Mutation';
     /** Adds an item to the order. If custom fields are defined on the OrderLine
      * entity, a third argument 'customFields' will be available.
      */
@@ -1249,6 +1281,7 @@ export type MutationResetPasswordArgs = {
 };
 
 export type Node = {
+    __typename?: 'Node';
     id: Scalars['ID'];
 };
 
@@ -1267,6 +1300,7 @@ export type NumberRange = {
 };
 
 export type Order = Node & {
+    __typename?: 'Order';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1290,6 +1324,7 @@ export type Order = Node & {
 };
 
 export type OrderAddress = {
+    __typename?: 'OrderAddress';
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
     streetLine1?: Maybe<Scalars['String']>;
@@ -1318,6 +1353,7 @@ export type OrderFilterParameter = {
 };
 
 export type OrderItem = Node & {
+    __typename?: 'OrderItem';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1329,6 +1365,7 @@ export type OrderItem = Node & {
 };
 
 export type OrderLine = Node & {
+    __typename?: 'OrderLine';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1345,6 +1382,7 @@ export type OrderLine = Node & {
 };
 
 export type OrderList = PaginatedList & {
+    __typename?: 'OrderList';
     items: Array<Order>;
     totalItems: Scalars['Int'];
 };
@@ -1371,11 +1409,13 @@ export type OrderSortParameter = {
 };
 
 export type PaginatedList = {
+    __typename?: 'PaginatedList';
     items: Array<Node>;
     totalItems: Scalars['Int'];
 };
 
 export type Payment = Node & {
+    __typename?: 'Payment';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1392,6 +1432,7 @@ export type PaymentInput = {
 };
 
 export type PaymentMethod = Node & {
+    __typename?: 'PaymentMethod';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1434,11 +1475,13 @@ export enum Permission {
 
 /** The price range where the result has more than one price */
 export type PriceRange = {
+    __typename?: 'PriceRange';
     min: Scalars['Int'];
     max: Scalars['Int'];
 };
 
 export type Product = Node & {
+    __typename?: 'Product';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1466,6 +1509,7 @@ export type ProductFilterParameter = {
 };
 
 export type ProductList = PaginatedList & {
+    __typename?: 'ProductList';
     items: Array<Product>;
     totalItems: Scalars['Int'];
 };
@@ -1478,6 +1522,7 @@ export type ProductListOptions = {
 };
 
 export type ProductOption = Node & {
+    __typename?: 'ProductOption';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1489,6 +1534,7 @@ export type ProductOption = Node & {
 };
 
 export type ProductOptionGroup = Node & {
+    __typename?: 'ProductOptionGroup';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1501,6 +1547,7 @@ export type ProductOptionGroup = Node & {
 };
 
 export type ProductOptionGroupTranslation = {
+    __typename?: 'ProductOptionGroupTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1509,6 +1556,7 @@ export type ProductOptionGroupTranslation = {
 };
 
 export type ProductOptionTranslation = {
+    __typename?: 'ProductOptionTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1526,6 +1574,7 @@ export type ProductSortParameter = {
 };
 
 export type ProductTranslation = {
+    __typename?: 'ProductTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1536,6 +1585,7 @@ export type ProductTranslation = {
 };
 
 export type ProductVariant = Node & {
+    __typename?: 'ProductVariant';
     id: Scalars['ID'];
     productId: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -1570,6 +1620,7 @@ export type ProductVariantFilterParameter = {
 };
 
 export type ProductVariantList = PaginatedList & {
+    __typename?: 'ProductVariantList';
     items: Array<ProductVariant>;
     totalItems: Scalars['Int'];
 };
@@ -1593,6 +1644,7 @@ export type ProductVariantSortParameter = {
 };
 
 export type ProductVariantTranslation = {
+    __typename?: 'ProductVariantTranslation';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1601,6 +1653,7 @@ export type ProductVariantTranslation = {
 };
 
 export type Promotion = Node & {
+    __typename?: 'Promotion';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1611,11 +1664,13 @@ export type Promotion = Node & {
 };
 
 export type PromotionList = PaginatedList & {
+    __typename?: 'PromotionList';
     items: Array<Promotion>;
     totalItems: Scalars['Int'];
 };
 
 export type Query = {
+    __typename?: 'Query';
     activeChannel: Channel;
     activeCustomer?: Maybe<Customer>;
     activeOrder?: Maybe<Order>;
@@ -1631,7 +1686,6 @@ export type Query = {
     product?: Maybe<Product>;
     products: ProductList;
     search: SearchResponse;
-    temp__?: Maybe<Scalars['Boolean']>;
 };
 
 export type QueryCollectionsArgs = {
@@ -1677,6 +1731,7 @@ export type RegisterCustomerInput = {
 
 export type Return = Node &
     StockMovement & {
+        __typename?: 'Return';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -1687,6 +1742,7 @@ export type Return = Node &
     };
 
 export type Role = Node & {
+    __typename?: 'Role';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1697,12 +1753,14 @@ export type Role = Node & {
 };
 
 export type RoleList = PaginatedList & {
+    __typename?: 'RoleList';
     items: Array<Role>;
     totalItems: Scalars['Int'];
 };
 
 export type Sale = Node &
     StockMovement & {
+        __typename?: 'Sale';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -1723,18 +1781,21 @@ export type SearchInput = {
 };
 
 export type SearchReindexResponse = {
+    __typename?: 'SearchReindexResponse';
     success: Scalars['Boolean'];
     timeTaken: Scalars['Int'];
     indexedItemCount: Scalars['Int'];
 };
 
 export type SearchResponse = {
+    __typename?: 'SearchResponse';
     items: Array<SearchResult>;
     totalItems: Scalars['Int'];
     facetValues: Array<FacetValueResult>;
 };
 
 export type SearchResult = {
+    __typename?: 'SearchResult';
     sku: Scalars['String'];
     slug: Scalars['String'];
     productId: Scalars['ID'];
@@ -1764,10 +1825,12 @@ export type SearchResultSortParameter = {
 };
 
 export type ServerConfig = {
+    __typename?: 'ServerConfig';
     customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type ShippingMethod = Node & {
+    __typename?: 'ShippingMethod';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1778,11 +1841,13 @@ export type ShippingMethod = Node & {
 };
 
 export type ShippingMethodList = PaginatedList & {
+    __typename?: 'ShippingMethodList';
     items: Array<ShippingMethod>;
     totalItems: Scalars['Int'];
 };
 
 export type ShippingMethodQuote = {
+    __typename?: 'ShippingMethodQuote';
     id: Scalars['ID'];
     price: Scalars['Int'];
     priceWithTax: Scalars['Int'];
@@ -1791,6 +1856,7 @@ export type ShippingMethodQuote = {
 
 /** The price value where the result has a single price */
 export type SinglePrice = {
+    __typename?: 'SinglePrice';
     value: Scalars['Int'];
 };
 
@@ -1801,6 +1867,7 @@ export enum SortOrder {
 
 export type StockAdjustment = Node &
     StockMovement & {
+        __typename?: 'StockAdjustment';
         id: Scalars['ID'];
         createdAt: Scalars['DateTime'];
         updatedAt: Scalars['DateTime'];
@@ -1810,6 +1877,7 @@ export type StockAdjustment = Node &
     };
 
 export type StockMovement = {
+    __typename?: 'StockMovement';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1821,6 +1889,7 @@ export type StockMovement = {
 export type StockMovementItem = StockAdjustment | Sale | Cancellation | Return;
 
 export type StockMovementList = {
+    __typename?: 'StockMovementList';
     items: Array<StockMovementItem>;
     totalItems: Scalars['Int'];
 };
@@ -1838,6 +1907,7 @@ export type StringOperators = {
 };
 
 export type TaxCategory = Node & {
+    __typename?: 'TaxCategory';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1845,6 +1915,7 @@ export type TaxCategory = Node & {
 };
 
 export type TaxRate = Node & {
+    __typename?: 'TaxRate';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1857,6 +1928,7 @@ export type TaxRate = Node & {
 };
 
 export type TaxRateList = PaginatedList & {
+    __typename?: 'TaxRateList';
     items: Array<TaxRate>;
     totalItems: Scalars['Int'];
 };
@@ -1886,6 +1958,7 @@ export type UpdateCustomerInput = {
 };
 
 export type User = Node & {
+    __typename?: 'User';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1897,6 +1970,7 @@ export type User = Node & {
 };
 
 export type Zone = Node & {
+    __typename?: 'Zone';
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];

+ 40 - 8
packages/core/e2e/test-server.ts

@@ -1,4 +1,4 @@
-import { INestApplication } from '@nestjs/common';
+import { INestApplication, INestMicroservice } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { Omit } from '@vendure/common/lib/omit';
 import fs from 'fs';
@@ -9,6 +9,7 @@ import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOpti
 import { populateForTesting, PopulateOptions } from '../mock-data/populate-for-testing';
 import { preBootstrapConfig, runPluginOnBootstrapMethods } from '../src/bootstrap';
 import { Mutable } from '../src/common/types/common-types';
+import { Logger } from '../src/config/logger/vendure-logger';
 import { VendureConfig } from '../src/config/vendure-config';
 
 import { testConfig } from './config/test-config';
@@ -20,6 +21,7 @@ import { setTestEnvironment } from './utils/test-environment';
  */
 export class TestServer {
     app: INestApplication;
+    worker?: INestMicroservice;
 
     /**
      * Bootstraps an instance of Vendure server and populates the database according to the options
@@ -48,7 +50,16 @@ export class TestServer {
         if (options.logging) {
             console.log(`Loading test data from "${dbFilePath}"`);
         }
-        this.app = await this.bootstrapForTesting(testingConfig);
+        const [app, worker] = await this.bootstrapForTesting(testingConfig);
+        if (app) {
+            this.app = app;
+        } else {
+            console.error(`Could not bootstrap app`);
+            process.exit(1);
+        }
+        if (worker) {
+            this.worker = worker;
+        }
     }
 
     /**
@@ -56,6 +67,9 @@ export class TestServer {
      */
     async destroy() {
         await this.app.close();
+        if (this.worker) {
+            await this.worker.close();
+        }
     }
 
     private getDbFilePath() {
@@ -76,7 +90,7 @@ export class TestServer {
     ): Promise<void> {
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = true;
 
-        const app = await populateForTesting(testingConfig, this.bootstrapForTesting, {
+        const [app, worker] = await populateForTesting(testingConfig, this.bootstrapForTesting, {
             logging: false,
             ...{
                 ...options,
@@ -84,6 +98,9 @@ export class TestServer {
             },
         });
         await app.close();
+        if (worker) {
+            await worker.close();
+        }
 
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = false;
     }
@@ -91,12 +108,27 @@ export class TestServer {
     /**
      * Bootstraps an instance of the Vendure server for testing against.
      */
-    private async bootstrapForTesting(userConfig: Partial<VendureConfig>): Promise<INestApplication> {
+    private async bootstrapForTesting(userConfig: Partial<VendureConfig>): Promise<[INestApplication, INestMicroservice | undefined]> {
         const config = await preBootstrapConfig(userConfig);
         const appModule = await import('../src/app.module');
-        const app = await NestFactory.create(appModule.AppModule, { cors: config.cors, logger: false });
-        await runPluginOnBootstrapMethods(config, app);
-        await app.listen(config.port);
-        return app;
+        try {
+            const app = await NestFactory.create(appModule.AppModule, {cors: config.cors, logger: false});
+            let worker: INestMicroservice | undefined;
+            await runPluginOnBootstrapMethods(config, app);
+            await app.listen(config.port);
+            if (config.workerOptions.runInMainProcess) {
+                const workerModule = await import('../src/worker/worker.module');
+                worker = await NestFactory.createMicroservice(workerModule.WorkerModule, {
+                    transport: config.workerOptions.transport,
+                    logger: new Logger(),
+                    options: config.workerOptions.options,
+                });
+                await worker.listenAsync();
+            }
+            return [app, worker];
+        } catch (e) {
+            console.log(e);
+            throw e;
+        }
     }
 }

+ 5 - 5
packages/core/mock-data/populate-for-testing.ts

@@ -1,5 +1,5 @@
 /* tslint:disable:no-console */
-import { INestApplication } from '@nestjs/common';
+import { INestApplication, INestMicroservice } from '@nestjs/common';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import fs from 'fs-extra';
 
@@ -23,9 +23,9 @@ export interface PopulateOptions {
  */
 export async function populateForTesting(
     config: VendureConfig,
-    bootstrapFn: VendureBootstrapFunction,
+    bootstrapFn: (config: VendureConfig) => Promise<[INestApplication, INestMicroservice | undefined]>,
     options: PopulateOptions,
-): Promise<INestApplication> {
+): Promise<[INestApplication, INestMicroservice | undefined]> {
     (config.dbConnectionOptions as any).logging = false;
     const logging = options.logging === undefined ? true : options.logging;
     const originalRequireVerification = config.authOptions.requireVerification;
@@ -33,7 +33,7 @@ export async function populateForTesting(
 
     setConfig(config);
     await clearAllTables(config.dbConnectionOptions, logging);
-    const app = await bootstrapFn(config);
+    const [app, worker] = await bootstrapFn(config);
 
     await populateInitialData(app, options.initialDataPath, logging);
     await populateProducts(app, options.productsCsvPath, logging);
@@ -41,7 +41,7 @@ export async function populateForTesting(
     await populateCustomers(options.customerCount, config, logging);
 
     config.authOptions.requireVerification = originalRequireVerification;
-    return app;
+    return [app, worker];
 }
 
 async function populateInitialData(app: INestApplication, initialDataPath: string, logging: boolean) {

+ 1 - 0
packages/core/package.json

@@ -37,6 +37,7 @@
     "@nestjs/common": "^6.3.1",
     "@nestjs/core": "^6.3.1",
     "@nestjs/graphql": "^6.2.4",
+    "@nestjs/microservices": "^6.3.1",
     "@nestjs/platform-express": "^6.3.1",
     "@nestjs/testing": "^6.3.1",
     "@nestjs/typeorm": "^6.0.0",

+ 3 - 3
packages/core/src/api/api-internal-modules.ts

@@ -105,9 +105,9 @@ export class ApiSharedModule {}
  * The internal module containing the Admin GraphQL API resolvers
  */
 @Module({
-    imports: [ApiSharedModule, PluginModule, ServiceModule, DataImportModule],
+    imports: [ApiSharedModule, PluginModule.forRoot(), ServiceModule.forRoot(), DataImportModule],
     providers: [...adminResolvers, ...entityResolvers, ...adminEntityResolvers, ...PluginModule.adminApiResolvers()],
-    exports: adminResolvers,
+    exports: [...adminResolvers],
 })
 export class AdminApiModule {}
 
@@ -115,7 +115,7 @@ export class AdminApiModule {}
  * The internal module containing the Shop GraphQL API resolvers
  */
 @Module({
-    imports: [ApiSharedModule, PluginModule, ServiceModule],
+    imports: [ApiSharedModule, PluginModule.forRoot(), ServiceModule.forRoot()],
     providers: [...shopResolvers, ...entityResolvers, ...PluginModule.shopApiResolvers()],
     exports: shopResolvers,
 })

+ 1 - 1
packages/core/src/api/api.module.ts

@@ -19,7 +19,7 @@ import { IdInterceptor } from './middleware/id-interceptor';
  */
 @Module({
     imports: [
-        ServiceModule,
+        ServiceModule.forRoot(),
         DataImportModule,
         ApiSharedModule,
         AdminApiModule,

+ 113 - 0
packages/core/src/api/common/request-context.spec.ts

@@ -0,0 +1,113 @@
+import { CurrencyCode, LanguageCode } from '@vendure/common/lib/generated-types';
+
+import { Channel } from '../../entity/channel/channel.entity';
+import { Order } from '../../entity/order/order.entity';
+import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
+import { Session } from '../../entity/session/session.entity';
+import { User } from '../../entity/user/user.entity';
+import { Zone } from '../../entity/zone/zone.entity';
+
+import { RequestContext } from './request-context';
+
+describe('RequestContext', () => {
+
+    describe('fromObject()', () => {
+
+        let original: RequestContext;
+        let ctxObject: object;
+        let session: Session;
+        let user: User;
+        let channel: Channel;
+        let activeOrder: Order;
+        let zone: Zone;
+
+        beforeAll(() => {
+            activeOrder = new Order({
+                id: '55555',
+                active: true,
+                code: 'ADAWDJAWD',
+            });
+            user = new User({
+                id: '8833774',
+                verified: true,
+            });
+            session = new AuthenticatedSession({
+                id: '1234',
+                token: '2d37187e9e8fc47807fe4f58ca',
+                activeOrder,
+                user,
+            });
+            zone = new Zone({
+                id: '62626',
+                name: 'Europe',
+            });
+            channel = new Channel({
+                token: 'oiajwodij09au3r',
+                id: '995859',
+                code: '__default_channel__',
+                currencyCode: CurrencyCode.EUR,
+                pricesIncludeTax: true,
+                defaultLanguageCode: LanguageCode.en,
+                defaultShippingZone: zone,
+                defaultTaxZone: zone,
+            });
+            original = new RequestContext({
+                apiType: 'admin',
+                languageCode: LanguageCode.en,
+                channel,
+                session,
+                isAuthorized: true,
+                authorizedAsOwnerOnly: false,
+            });
+
+            ctxObject = JSON.parse(JSON.stringify(original));
+        });
+
+        it('apiType', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.apiType).toBe(original.apiType);
+        });
+
+        it('channelId', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.channelId).toBe(original.channelId);
+        });
+
+        it('languageCode', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.languageCode).toBe(original.languageCode);
+        });
+
+        it('activeUserId', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.activeUserId).toBe(original.activeUserId);
+        });
+
+        it('isAuthorized', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.isAuthorized).toBe(original.isAuthorized);
+        });
+
+        it('authorizedAsOwnerOnly', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.authorizedAsOwnerOnly).toBe(original.authorizedAsOwnerOnly);
+        });
+
+        it('channel', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.channel).toEqual(original.channel);
+        });
+
+        it('session', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.session).toEqual(original.session);
+        });
+
+        it('activeUser', () => {
+            const result = RequestContext.fromObject(ctxObject);
+            expect(result.activeUser).toEqual(original.activeUser);
+        });
+
+    });
+
+});

+ 29 - 0
packages/core/src/api/common/request-context.ts

@@ -4,6 +4,7 @@ import i18next from 'i18next';
 
 import { DEFAULT_LANGUAGE_CODE } from '../../common/constants';
 import { Channel } from '../../entity/channel/channel.entity';
+import { AnonymousSession } from '../../entity/session/anonymous-session.entity';
 import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
 import { Session } from '../../entity/session/session.entity';
 import { User } from '../../entity/user/user.entity';
@@ -49,6 +50,34 @@ export class RequestContext {
         this._translationFn = translationFn || (((key: string) => key) as any);
     }
 
+    /**
+     * @description
+     * Creates a new RequestContext object from a plain object which is the result of
+     * a JSON serialization - deserialization operation.
+     */
+    static fromObject(ctxObject: any): RequestContext {
+        let session: Session | undefined;
+        if (ctxObject._session) {
+            if (ctxObject._session.user) {
+                const user = new User(ctxObject._session.user);
+                session = new AuthenticatedSession({
+                    ...ctxObject._session,
+                    user,
+                });
+            } else {
+                session = new AnonymousSession(ctxObject._session);
+            }
+        }
+        return new RequestContext({
+            apiType: ctxObject._apiType,
+            channel: new Channel(ctxObject._channel),
+            session,
+            languageCode: ctxObject._languageCode,
+            isAuthorized: ctxObject._isAuthorized,
+            authorizedAsOwnerOnly: ctxObject._authorizedAsOwnerOnly,
+        });
+    }
+
     get apiType(): ApiType {
         return this._apiType;
     }

+ 46 - 5
packages/core/src/bootstrap.ts

@@ -1,6 +1,8 @@
-import { INestApplication } from '@nestjs/common';
+import { INestApplication, INestMicroservice } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
+import { Transport } from '@nestjs/microservices';
 import { Type } from '@vendure/common/lib/shared-types';
+import { worker } from 'cluster';
 import { EntitySubscriberInterface } from 'typeorm';
 
 import { InternalServerError } from './common/error/errors';
@@ -19,15 +21,15 @@ export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestA
  */
 export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INestApplication> {
     const config = await preBootstrapConfig(userConfig);
-    Logger.info(`Bootstrapping Vendure Server...`);
+    Logger.useLogger(config.logger);
+    Logger.info(`Bootstrapping Vendure Server (pid: ${process.pid})...`);
 
     // The AppModule *must* be loaded only after the entities have been set in the
     // config, so that they are available when the AppModule decorator is evaluated.
     // tslint:disable-next-line:whitespace
     const appModule = await import('./app.module');
     DefaultLogger.hideNestBoostrapLogs();
-    let app: INestApplication;
-    app = await NestFactory.create(appModule.AppModule, {
+    const app = await NestFactory.create(appModule.AppModule, {
         cors: config.cors,
         logger: new Logger(),
     });
@@ -35,10 +37,50 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
     app.useLogger(new Logger());
     await runPluginOnBootstrapMethods(config, app);
     await app.listen(config.port, config.hostname);
+    if (config.workerOptions.runInMainProcess) {
+        await bootstrapWorkerInternal(config);
+        Logger.warn(`Worker is running in main process. This is not recommended for production.`);
+        Logger.warn(`[VendureConfig.workerOptions.runInMainProcess = true]`);
+    }
     logWelcomeMessage(config);
     return app;
 }
 
+/**
+ * Bootstrap the Vendure worker.
+ */
+export async function bootstrapWorker(userConfig: Partial<VendureConfig>): Promise<INestMicroservice> {
+    if (userConfig.workerOptions && userConfig.workerOptions.runInMainProcess === true) {
+        Logger.useLogger(userConfig.logger || new DefaultLogger());
+        const errorMessage = `Cannot bootstrap worker when "runInMainProcess" is set to true`
+        Logger.error(errorMessage, 'Vendure Worker');
+        throw new Error(errorMessage);
+    } else {
+        return bootstrapWorkerInternal(userConfig);
+    }
+}
+
+async function bootstrapWorkerInternal(userConfig: Partial<VendureConfig>): Promise<INestMicroservice> {
+    const config = await preBootstrapConfig(userConfig);
+    if (!config.workerOptions.runInMainProcess && (config.logger as any).setDefaultContext) {
+        (config.logger as any).setDefaultContext('Vendure Worker');
+    }
+    Logger.useLogger(config.logger);
+    Logger.info(`Bootstrapping Vendure Worker (pid: ${process.pid})...`);
+
+    const workerModule = await import('./worker/worker.module');
+    DefaultLogger.hideNestBoostrapLogs();
+    const workerApp = await NestFactory.createMicroservice(workerModule.WorkerModule, {
+        transport: config.workerOptions.transport,
+        logger: new Logger(),
+        options: config.workerOptions.options,
+    });
+    DefaultLogger.restoreOriginalLogLevel();
+    workerApp.useLogger(new Logger());
+    await workerApp.listenAsync();
+    return workerApp;
+}
+
 /**
  * Setting the global config must be done prior to loading the AppModule.
  */
@@ -64,7 +106,6 @@ export async function preBootstrapConfig(
     });
 
     let config = getConfig();
-    Logger.useLogger(config.logger);
     config = await runPluginConfigurations(config);
     registerCustomEntityFields(config);
     return config;

+ 1 - 0
packages/core/src/config/config.service.mock.ts

@@ -32,6 +32,7 @@ export class MockConfigService implements MockClass<ConfigService> {
     emailOptions: {};
     importExportOptions: {};
     orderOptions = {};
+    workerOptions = {};
     customFields = {};
     middleware = [];
     logger = {} as any;

+ 5 - 0
packages/core/src/config/config.service.ts

@@ -20,6 +20,7 @@ import {
     ShippingOptions,
     TaxOptions,
     VendureConfig,
+    WorkerOptions,
 } from './vendure-config';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
@@ -120,4 +121,8 @@ export class ConfigService implements VendureConfig {
     get logger(): VendureLogger {
         return this.activeConfig.logger;
     }
+
+    get workerOptions(): WorkerOptions {
+        return this.activeConfig.workerOptions as Required<WorkerOptions>;
+    }
 }

+ 8 - 1
packages/core/src/config/default-config.ts

@@ -1,5 +1,5 @@
+import { Transport } from '@nestjs/microservices';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
-import { ADMIN_API_PATH, API_PORT } from '@vendure/common/lib/shared-constants';
 import { CustomFields } from '@vendure/common/lib/shared-types';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
@@ -80,6 +80,13 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     importExportOptions: {
         importAssetsDir: __dirname,
     },
+    workerOptions: {
+        runInMainProcess: true,
+        transport: Transport.TCP,
+        options: {
+            port: 3001,
+        },
+    },
     customFields: {
         Address: [],
         Collection: [],

+ 6 - 1
packages/core/src/config/logger/default-logger.ts

@@ -26,6 +26,7 @@ export class DefaultLogger implements VendureLogger {
     /** @internal */
     level: LogLevel = LogLevel.Info;
     private readonly timestamp: boolean;
+    private defaultContext = DEFAULT_CONTEXT;
     private readonly localeStringOptions = {
         year: '2-digit',
         hour: 'numeric',
@@ -74,6 +75,10 @@ export class DefaultLogger implements VendureLogger {
         }
     }
 
+    setDefaultContext(defaultContext: string) {
+        this.defaultContext = defaultContext;
+    }
+
     error(message: string, context?: string, trace?: string | undefined): void {
         if (this.level >= LogLevel.Error) {
             this.logMessage(
@@ -131,7 +136,7 @@ export class DefaultLogger implements VendureLogger {
     }
 
     private logContext(context?: string) {
-        return chalk.cyan(`[${context || DEFAULT_CONTEXT}]`);
+        return chalk.cyan(`[${context || this.defaultContext}]`);
     }
 
     private logTimestamp() {

+ 40 - 0
packages/core/src/config/vendure-config.ts

@@ -1,4 +1,5 @@
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
+import { ClientOptions, Transport } from '@nestjs/microservices';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { CustomFields } from '@vendure/common/lib/shared-types';
 import { RequestHandler } from 'express';
@@ -313,6 +314,40 @@ export interface ImportExportOptions {
     importAssetsDir?: string;
 }
 
+/**
+ * @description
+ * Options related to the Vendure Worker.
+ *
+ * @docsCategory worker
+ */
+export interface WorkerOptions {
+    /**
+     * @description
+     * If set to `true`, the Worker will run be bootstrapped as part of the main Vendure server (when invoking the
+     * `bootstrap()` function) and will run in the same process. This mode is intended only for development and
+     * testing purposes, not for production, since running the Worker in the main process negates the benefits
+     * of having long-running or expensive tasks run in the background.
+     *
+     * @default true
+     */
+    runInMainProcess?: boolean;
+    /**
+     * @description
+     * Sets the transport protocol used to communicate with the Worker. Options include TCP, Redis, gPRC and more. See the
+     * [NestJS microservices documentation](https://docs.nestjs.com/microservices/basics) for a full list.
+     *
+     * @default Transport.TCP
+     */
+    transport?: Transport;
+    /**
+     * @description
+     * Additional options related to the chosen transport method. See See the
+     * [NestJS microservices documentation](https://docs.nestjs.com/microservices/basics) for details on the options relating to each of the
+     * transport methods.
+     */
+    options?: ClientOptions['options'];
+}
+
 /**
  * @description
  * All possible configuration options are defined by the
@@ -464,4 +499,9 @@ export interface VendureConfig {
      * Configures how taxes are calculated on products.
      */
     taxOptions?: TaxOptions;
+    /**
+     * @description
+     * Configures the Vendure Worker, which is used for long-running background tasks.
+     */
+    workerOptions?: WorkerOptions;
 }

+ 6 - 0
packages/core/src/config/vendure-plugin/vendure-plugin.ts

@@ -93,6 +93,12 @@ export interface VendurePlugin {
      */
     defineProviders?(): Provider[];
 
+    /**
+     * @description
+     * The plugin may define providers which are run in the Worker context, i.e. Nest microservice controllers.
+     */
+    defineWorkers?(): Array<Type<any>>;
+
     /**
      * @description
      * The plugin may define custom database entities, which should be defined as classes annotated as per the

+ 1 - 1
packages/core/src/data-import/data-import.module.ts

@@ -12,7 +12,7 @@ import { Populator } from './providers/populator/populator';
     // Important! PluginModule must be defined before ServiceModule
     // in order that overrides of Services (e.g. SearchService) are correctly
     // registered with the injector.
-    imports: [PluginModule, ServiceModule, ConfigModule],
+    imports: [PluginModule.forRoot(), ServiceModule.forRoot(), ConfigModule],
     exports: [ImportParser, Importer, Populator],
     providers: [ImportParser, Importer, Populator],
 })

+ 2 - 1
packages/core/src/index.ts

@@ -1,4 +1,4 @@
-export { bootstrap } from './bootstrap';
+export { bootstrap, bootstrapWorker } from './bootstrap';
 export * from './api/index';
 export * from './common/index';
 export * from './config/index';
@@ -7,6 +7,7 @@ export * from './plugin/index';
 export * from './entity/index';
 export * from './data-import/index';
 export * from './service/index';
+export * from './worker/constants';
 export * from '@vendure/common/lib/shared-types';
 export {
     Permission,

+ 6 - 1
packages/core/src/plugin/default-search-plugin/constants.ts

@@ -1 +1,6 @@
-export const SEARCH_PLUGIN_OPTIONS = Symbol('SEARCH_PLUGIN_OPTIONS');
+export const loggerCtx = 'DefaultSearchPlugin';
+export enum Message {
+    Reindex = 'Reindex',
+    UpdateVariantsById = 'UpdateVariantsById',
+    UpdateProductOrVariant = 'UpdateProductOrVariant',
+}

+ 10 - 37
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -14,9 +14,9 @@ import { CollectionModificationEvent } from '../../event-bus/events/collection-m
 import { TaxRateModificationEvent } from '../../event-bus/events/tax-rate-modification-event';
 import { SearchService } from '../../service/services/search.service';
 
-import { SEARCH_PLUGIN_OPTIONS } from './constants';
 import { AdminFulltextSearchResolver, ShopFulltextSearchResolver } from './fulltext-search.resolver';
 import { FulltextSearchService } from './fulltext-search.service';
+import { IndexerController } from './indexer/indexer.controller';
 import { SearchIndexService } from './indexer/search-index.service';
 import { SearchIndexItem } from './search-index-item.entity';
 
@@ -25,24 +25,6 @@ export interface DefaultSearchReindexResponse extends SearchReindexResponse {
     indexedItemCount: number;
 }
 
-/**
- * @description
- * Options for configuring the DefaultSearchPlugin.
- *
- * @docsCategory DefaultSearchPlugin
- */
-export interface DefaultSearchPluginOptions {
-    /**
-     * @description
-     * By default, the DefaultSearchPlugin will spawn a background process which is responsible
-     * for updating the search index. By setting this option to `false`, indexing will be
-     * performed on the main server process instead. Usually this is undesirable as performance will
-     * be degraded during indexing, but the option is useful for certain debugging and testing scenarios.
-     * @default true
-     */
-    runInForkedProcess: boolean;
-}
-
 /**
  * @description
  * The DefaultSearchPlugin provides a full-text Product search based on the full-text searching capabilities of the
@@ -73,20 +55,6 @@ export interface DefaultSearchPluginOptions {
  * @docsCategory DefaultSearchPlugin
  */
 export class DefaultSearchPlugin implements VendurePlugin {
-    private readonly options: DefaultSearchPluginOptions;
-
-    constructor(options?: DefaultSearchPluginOptions) {
-        const defaultOptions: DefaultSearchPluginOptions = {
-            runInForkedProcess: true,
-        };
-        this.options = { ...defaultOptions, ...options };
-
-        if (process.env[CREATING_VENDURE_APP]) {
-            // For the "create" step we will not run the indexer in a forked process as this
-            // can cause issues with sqlite locking.
-            this.options.runInForkedProcess = false;
-        }
-    }
 
     /** @internal */
     async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
@@ -94,11 +62,11 @@ export class DefaultSearchPlugin implements VendurePlugin {
         const searchIndexService = inject(SearchIndexService);
         eventBus.subscribe(CatalogModificationEvent, event => {
             if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
-                return searchIndexService.updateProductOrVariant(event.ctx, event.entity);
+                return searchIndexService.updateProductOrVariant(event.ctx, event.entity).start();
             }
         });
         eventBus.subscribe(CollectionModificationEvent, event => {
-            return searchIndexService.updateVariantsById(event.ctx, event.productVariantIds);
+            return searchIndexService.updateVariantsById(event.ctx, event.productVariantIds).start();
         });
         eventBus.subscribe(TaxRateModificationEvent, event => {
             const defaultTaxZone = event.ctx.channel.defaultTaxZone;
@@ -106,7 +74,6 @@ export class DefaultSearchPlugin implements VendurePlugin {
                 return searchIndexService.reindex(event.ctx).start();
             }
         });
-        await searchIndexService.connect();
     }
 
     /** @internal */
@@ -146,7 +113,13 @@ export class DefaultSearchPlugin implements VendurePlugin {
             FulltextSearchService,
             SearchIndexService,
             { provide: SearchService, useClass: FulltextSearchService },
-            { provide: SEARCH_PLUGIN_OPTIONS, useFactory: () => this.options },
+        ];
+    }
+
+    /** @internal */
+    defineWorkers(): Array<Type<any>> {
+        return [
+            IndexerController,
         ];
     }
 }

+ 0 - 165
packages/core/src/plugin/default-search-plugin/indexer/index-builder.ts

@@ -1,165 +0,0 @@
-import { ID, Type } from '@vendure/common/lib/shared-types';
-import { unique } from '@vendure/common/lib/unique';
-import { Connection, ConnectionOptions, createConnection, SelectQueryBuilder } from 'typeorm';
-import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
-
-import { RequestContext } from '../../../api/common/request-context';
-import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
-import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
-import { AsyncQueue } from '../async-queue';
-import { SearchIndexItem } from '../search-index-item.entity';
-
-import { CompletedMessage, ConnectedMessage, Message, MessageType, ReturnRawBatchMessage, SaveVariantsPayload, VariantsSavedMessage } from './ipc';
-
-export const BATCH_SIZE = 500;
-export const variantRelations = [
-    'product',
-    'product.featuredAsset',
-    'product.facetValues',
-    'product.facetValues.facet',
-    'featuredAsset',
-    'facetValues',
-    'facetValues.facet',
-    'collections',
-    'taxCategory',
-];
-
-export function getSearchIndexQueryBuilder(connection: Connection) {
-    const qb = connection.getRepository(ProductVariant).createQueryBuilder('variants');
-    FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
-        relations: variantRelations,
-    });
-    FindOptionsUtils.joinEagerRelations(qb, qb.alias, connection.getMetadata(ProductVariant));
-    return qb;
-}
-
-/**
- * This class is responsible for all updates to the search index.
- */
-export class IndexBuilder {
-    private connection: Connection;
-    private indexQueryBuilder: SelectQueryBuilder<ProductVariant>;
-    private onMessageHandlers = new Set<(message: string) => void>();
-    private queue = new AsyncQueue('search-index');
-
-    /**
-     * When running in the main process, it should be constructed with the existing connection.
-     * Otherwise, the connection will be created in the .connect() method in response to an
-     * IPC message.
-     */
-    constructor(connection?: Connection) {
-        if (connection) {
-            this.connection = connection;
-            this.indexQueryBuilder = getSearchIndexQueryBuilder(this.connection);
-        }
-    }
-
-    processMessage(message: Message): Promise<Message | undefined> {
-        switch (message.type) {
-            case MessageType.CONNECTION_OPTIONS: {
-                return this.connect(message.value);
-            }
-            case MessageType.GET_RAW_BATCH: {
-                return this.getRawBatch(message.value.batchNumber);
-            }
-            case MessageType.GET_RAW_BATCH_BY_IDS: {
-                return this.getRawBatchByIds(message.value.ids);
-            }
-            case MessageType.SAVE_VARIANTS: {
-                return this.saveVariants(message.value);
-            }
-            default:
-                return Promise.resolve(undefined);
-        }
-    }
-
-    async processMessageAndEmitResult(message: Message) {
-        const result = await this.processMessage(message);
-        if (result) {
-            result.channelId = message.channelId;
-            this.onMessageHandlers.forEach(handler => {
-                handler(JSON.stringify(result));
-            });
-        }
-    }
-
-    addMessageListener<T extends Message>(handler: (message: string) => void) {
-        this.onMessageHandlers.add(handler);
-    }
-
-    removeMessageListener<T extends Message>(handler: (message: string) => void) {
-        this.onMessageHandlers.delete(handler);
-    }
-
-    private async connect(dbConnectionOptions: ConnectionOptions): Promise<ConnectedMessage> {
-        const {coreEntitiesMap} = await import('../../../entity/entities');
-        const coreEntities = Object.values(coreEntitiesMap) as Array<Type<any>>;
-        this.connection = await createConnection({...dbConnectionOptions, entities: [SearchIndexItem, ...coreEntities], name: 'index-builder' });
-        this.indexQueryBuilder = getSearchIndexQueryBuilder(this.connection);
-        return new ConnectedMessage(this.connection.isConnected);
-    }
-
-    private async getRawBatchByIds(ids: ID[]): Promise<ReturnRawBatchMessage> {
-        const variants = await this.connection.getRepository(ProductVariant).findByIds(ids, {
-            relations: variantRelations,
-        });
-        return new ReturnRawBatchMessage({variants});
-    }
-
-    private async getRawBatch(batchNumber: string | number): Promise<ReturnRawBatchMessage> {
-        const i = Number.parseInt(batchNumber.toString(), 10);
-        const variants = await this.indexQueryBuilder
-            .where('variants__product.deletedAt IS NULL')
-            .take(BATCH_SIZE)
-            .skip(i * BATCH_SIZE)
-            .getMany();
-
-        return new ReturnRawBatchMessage({variants});
-    }
-
-    private async saveVariants(payload: SaveVariantsPayload): Promise<VariantsSavedMessage | CompletedMessage> {
-        const {variants, ctx, batch, total} = payload;
-        const requestContext = new RequestContext(ctx);
-
-        const items = variants.map((v: ProductVariant) =>
-            new SearchIndexItem({
-                sku: v.sku,
-                enabled: v.enabled,
-                slug: v.product.slug,
-                price: v.price,
-                priceWithTax: v.priceWithTax,
-                languageCode: requestContext.languageCode,
-                productVariantId: v.id,
-                productId: v.product.id,
-                productName: v.product.name,
-                description: v.product.description,
-                productVariantName: v.name,
-                productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
-                productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
-                facetIds: this.getFacetIds(v),
-                facetValueIds: this.getFacetValueIds(v),
-                collectionIds: v.collections.map(c => c.id.toString()),
-            }),
-        );
-        await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(items));
-        if (batch === total - 1) {
-            return new CompletedMessage(true);
-        } else {
-            return new VariantsSavedMessage({batchNumber: batch});
-        }
-    }
-
-    private getFacetIds(variant: ProductVariant): string[] {
-        const facetIds = (fv: FacetValue) => fv.facet.id.toString();
-        const variantFacetIds = variant.facetValues.map(facetIds);
-        const productFacetIds = variant.product.facetValues.map(facetIds);
-        return unique([...variantFacetIds, ...productFacetIds]);
-    }
-
-    private getFacetValueIds(variant: ProductVariant): string[] {
-        const facetValueIds = (fv: FacetValue) => fv.id.toString();
-        const variantFacetValueIds = variant.facetValues.map(facetValueIds);
-        const productFacetValueIds = variant.product.facetValues.map(facetValueIds);
-        return unique([...variantFacetValueIds, ...productFacetValueIds]);
-    }
-}

+ 248 - 0
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -0,0 +1,248 @@
+import { Controller } from '@nestjs/common';
+import { MessagePattern } from '@nestjs/microservices';
+import { InjectConnection } from '@nestjs/typeorm';
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
+import { defer, Observable } from 'rxjs';
+import { Connection } from 'typeorm';
+import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { Logger } from '../../../config/logger/vendure-logger';
+import { FacetValue } from '../../../entity/facet-value/facet-value.entity';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { Product } from '../../../entity/product/product.entity';
+import { translateDeep } from '../../../service/helpers/utils/translate-entity';
+import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { TaxRateService } from '../../../service/services/tax-rate.service';
+import { AsyncQueue } from '../async-queue';
+import { loggerCtx, Message } from '../constants';
+import { SearchIndexItem } from '../search-index-item.entity';
+
+export const BATCH_SIZE = 1000;
+export const variantRelations = [
+    'product',
+    'product.featuredAsset',
+    'product.facetValues',
+    'product.facetValues.facet',
+    'featuredAsset',
+    'facetValues',
+    'facetValues.facet',
+    'collections',
+    'taxCategory',
+];
+
+export interface ReindexMessageResponse {
+    total: number;
+    completed: number;
+    duration: number;
+}
+
+@Controller()
+export class IndexerController {
+    private queue = new AsyncQueue('search-index');
+
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private productVariantService: ProductVariantService,
+        private taxRateService: TaxRateService,
+    ) {}
+
+    @MessagePattern(Message.Reindex)
+    reindex({ ctx: rawContext }: { ctx: any }): Observable<ReindexMessageResponse> {
+        const ctx = RequestContext.fromObject(rawContext);
+        return new Observable(observer => {
+            (async () => {
+                const timeStart = Date.now();
+                const qb = this.getSearchIndexQueryBuilder();
+                const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
+                Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
+                const batches = Math.ceil(count / BATCH_SIZE);
+
+                // Ensure tax rates are up-to-date.
+                await this.taxRateService.updateActiveTaxRates();
+
+                await this.connection.getRepository(SearchIndexItem).delete({ languageCode: ctx.languageCode });
+                Logger.verbose('Deleted existing index items', loggerCtx);
+
+                for (let i = 0; i < batches; i++) {
+                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
+
+                    const variants = await qb
+                        .where('variants__product.deletedAt IS NULL')
+                        .take(BATCH_SIZE)
+                        .skip(i * BATCH_SIZE)
+                        .getMany();
+                    const hydratedVariants = this.hydrateVariants(ctx, variants);
+                    await this.saveVariants(ctx, hydratedVariants);
+                    observer.next({
+                        total: count,
+                        completed: Math.min((i + 1) * BATCH_SIZE, count),
+                        duration: +new Date() - timeStart,
+                    });
+                }
+                Logger.verbose(`Completed reindexing!`);
+                observer.next({
+                    total: count,
+                    completed: count,
+                    duration: +new Date() - timeStart,
+                });
+                observer.complete();
+            })();
+        });
+    }
+
+    @MessagePattern(Message.UpdateVariantsById)
+    updateVariantsById({ ctx: rawContext, ids }: { ctx: any, ids: ID[] }): Observable<ReindexMessageResponse> {
+        const ctx = RequestContext.fromObject(rawContext);
+
+        return new Observable(observer => {
+            (async () => {
+                const timeStart = Date.now();
+                if (ids.length) {
+                    const batches = Math.ceil(ids.length / BATCH_SIZE);
+                    Logger.verbose(`Updating ${ids.length} variants...`);
+
+                    for (let i = 0; i < batches; i++) {
+                        const begin = i * BATCH_SIZE;
+                        const end = begin + BATCH_SIZE;
+                        Logger.verbose(`Updating ids from index ${begin} to ${end}`);
+                        const batchIds = ids.slice(begin, end);
+                        const batch = await this.connection.getRepository(ProductVariant).findByIds(batchIds, {
+                            relations: variantRelations,
+                        });
+                        const variants = this.hydrateVariants(ctx, batch);
+                        await this.saveVariants(ctx, variants);
+                        observer.next({
+                            total: ids.length,
+                            completed: Math.min((i + 1) * BATCH_SIZE, ids.length),
+                            duration: +new Date() - timeStart,
+                        });
+                    }
+                }
+                Logger.verbose(`Completed reindexing!`);
+                observer.next({
+                    total: ids.length,
+                    completed: ids.length,
+                    duration: +new Date() - timeStart,
+                });
+                observer.complete();
+            })();
+        });
+    }
+
+    /**
+     * Updates the search index only for the affected entities.
+     */
+    @MessagePattern(Message.UpdateProductOrVariant)
+    updateProductOrVariant({ ctx: rawContext, productId, variantId }: { ctx: any, productId?: ID, variantId?: ID }): Observable<boolean> {
+        const ctx = RequestContext.fromObject(rawContext);
+        let updatedVariants: ProductVariant[] = [];
+        let removedVariantIds: ID[] = [];
+        return defer(async () => {
+            if (productId) {
+                const product = await this.connection.getRepository(Product).findOne(productId, {
+                    relations: ['variants'],
+                });
+                if (product) {
+                    if (product.deletedAt) {
+                        removedVariantIds = product.variants.map(v => v.id);
+                    } else {
+                        updatedVariants = await this.connection
+                            .getRepository(ProductVariant)
+                            .findByIds(product.variants.map(v => v.id), {
+                                relations: variantRelations,
+                            });
+                        if (product.enabled === false) {
+                            updatedVariants.forEach(v => v.enabled = false);
+                        }
+                    }
+                }
+            } else {
+                const variant = await this.connection.getRepository(ProductVariant).findOne(variantId, {
+                    relations: variantRelations,
+                });
+                if (variant) {
+                    updatedVariants = [variant];
+                }
+            }
+            Logger.verbose(`Updating ${updatedVariants.length} variants`, loggerCtx);
+            updatedVariants = this.hydrateVariants(ctx, updatedVariants);
+            if (updatedVariants.length) {
+                await this.saveVariants(ctx, updatedVariants);
+            }
+            if (removedVariantIds.length) {
+                await this.removeSearchIndexItems(ctx.languageCode, removedVariantIds);
+            }
+            return true;
+        });
+    }
+
+    private getSearchIndexQueryBuilder() {
+        const qb = this.connection.getRepository(ProductVariant).createQueryBuilder('variants');
+        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
+            relations: variantRelations,
+        });
+        FindOptionsUtils.joinEagerRelations(qb, qb.alias, this.connection.getMetadata(ProductVariant));
+        return qb;
+    }
+
+    /**
+     * Given an array of ProductVariants, this method applies the correct taxes and translations.
+     */
+    private hydrateVariants(ctx: RequestContext, variants: ProductVariant[]): ProductVariant[] {
+        return variants
+            .map(v => this.productVariantService.applyChannelPriceAndTax(v, ctx))
+            .map(v => translateDeep(v, ctx.languageCode, ['product']));
+    }
+
+    private async saveVariants(ctx: RequestContext, variants: ProductVariant[]) {
+        const items = variants.map((v: ProductVariant) =>
+            new SearchIndexItem({
+                sku: v.sku,
+                enabled: v.enabled,
+                slug: v.product.slug,
+                price: v.price,
+                priceWithTax: v.priceWithTax,
+                languageCode: ctx.languageCode,
+                productVariantId: v.id,
+                productId: v.product.id,
+                productName: v.product.name,
+                description: v.product.description,
+                productVariantName: v.name,
+                productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
+                productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
+                facetIds: this.getFacetIds(v),
+                facetValueIds: this.getFacetValueIds(v),
+                collectionIds: v.collections.map(c => c.id.toString()),
+            }),
+        );
+        await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(items));
+    }
+
+    private getFacetIds(variant: ProductVariant): string[] {
+        const facetIds = (fv: FacetValue) => fv.facet.id.toString();
+        const variantFacetIds = variant.facetValues.map(facetIds);
+        const productFacetIds = variant.product.facetValues.map(facetIds);
+        return unique([...variantFacetIds, ...productFacetIds]);
+    }
+
+    private getFacetValueIds(variant: ProductVariant): string[] {
+        const facetValueIds = (fv: FacetValue) => fv.id.toString();
+        const variantFacetValueIds = variant.facetValues.map(facetValueIds);
+        const productFacetValueIds = variant.product.facetValues.map(facetValueIds);
+        return unique([...variantFacetValueIds, ...productFacetValueIds]);
+    }
+
+    /**
+     * Remove items from the search index
+     */
+    private async removeSearchIndexItems(languageCode: LanguageCode, variantIds: ID[]) {
+        const compositeKeys = variantIds.map(id => ({
+            productVariantId: id,
+            languageCode,
+        })) as any[];
+        await this.queue.push(() => this.connection.getRepository(SearchIndexItem).delete(compositeKeys));
+    }
+}

+ 0 - 154
packages/core/src/plugin/default-search-plugin/indexer/ipc.ts

@@ -1,154 +0,0 @@
-import { ID } from '@vendure/common/lib/shared-types';
-import { ChildProcess } from 'child_process';
-import { ConnectionOptions } from 'typeorm';
-
-import { RequestContext } from '../../../api/common/request-context';
-import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
-
-import { IndexBuilder } from './index-builder';
-
-export enum MessageType {
-    CONNECTION_OPTIONS,
-    CONNECTED,
-    GET_RAW_BATCH,
-    GET_RAW_BATCH_BY_IDS,
-    RETURN_RAW_BATCH,
-    SAVE_VARIANTS,
-    VARIANTS_SAVED,
-    COMPLETED,
-}
-
-export interface SaveVariantsPayload {
-    variants: ProductVariant[];
-    ctx: RequestContext;
-    batch: number;
-    total: number;
-}
-
-export interface IPCMessage {
-    type: MessageType;
-    value: any;
-    channelId: string;
-}
-
-export class ConnectionOptionsMessage implements IPCMessage {
-    readonly type = MessageType.CONNECTION_OPTIONS;
-    channelId: string;
-    constructor(public value: ConnectionOptions) {}
-}
-
-export class ConnectedMessage implements IPCMessage {
-    readonly type = MessageType.CONNECTED;
-    channelId: string;
-    constructor(public value: boolean) {}
-}
-
-export class GetRawBatchMessage implements IPCMessage {
-    readonly type = MessageType.GET_RAW_BATCH;
-    channelId: string;
-    constructor(public value: { batchNumber: number; }) {}
-}
-
-export class GetRawBatchByIdsMessage implements IPCMessage {
-    readonly type = MessageType.GET_RAW_BATCH_BY_IDS;
-    channelId: string;
-    constructor(public value: { ids: ID[]; }) {}
-}
-
-export class ReturnRawBatchMessage implements IPCMessage {
-    readonly type = MessageType.RETURN_RAW_BATCH;
-    channelId: string;
-    constructor(public value: { variants: ProductVariant[]; }) {}
-}
-
-export class SaveVariantsMessage implements IPCMessage {
-    readonly type = MessageType.SAVE_VARIANTS;
-    channelId: string;
-    constructor(public value: SaveVariantsPayload) {}
-}
-
-export class VariantsSavedMessage implements IPCMessage {
-    readonly type = MessageType.VARIANTS_SAVED;
-    channelId: string;
-    constructor(public value: { batchNumber: number; }) {}
-}
-
-export class CompletedMessage implements IPCMessage {
-    readonly type = MessageType.COMPLETED;
-    channelId: string;
-    constructor(public value: boolean) {}
-}
-
-export type Message = ConnectionOptionsMessage |
-    ConnectedMessage |
-    GetRawBatchMessage |
-    GetRawBatchByIdsMessage |
-    ReturnRawBatchMessage |
-    SaveVariantsMessage |
-    VariantsSavedMessage |
-    CompletedMessage;
-
-export type MessageOfType<T extends MessageType> = Extract<Message, { type: T }>;
-
-export function sendIPCMessage(target: NodeJS.Process | ChildProcess, message: Message) {
-    // tslint:disable-next-line:no-non-null-assertion
-    target.send!(JSON.stringify(message));
-}
-
-/**
- * An IpcChannel allows safe communication between main thread and worker. It achieves
- * this by adding a unique ID to each outgoing message, which the worker then adds
- * to any responses.
- *
- * If the `target` is an instance of IndexBuilder running on the main process (not in
- * a worker thread), then the channel interacts directly with it, whilst keeping the
- * differences abstracted away from the consuming code.
- */
-export class IpcChannel {
-    private readonly channelId = Math.random().toString(32);
-    private handlers: Array<(m: string) => void> = [];
-    constructor(private readonly target: NodeJS.Process | ChildProcess | IndexBuilder) {}
-
-    /**
-     * Send a message to the worker process.
-     */
-    send(message: Message) {
-        message.channelId = this.channelId;
-        if (this.target instanceof IndexBuilder) {
-            this.target.processMessageAndEmitResult(message);
-        } else {
-            sendIPCMessage(this.target, message);
-        }
-    }
-
-    /**
-     * Subscribes to the given IPC message which is sent from the worker in response to a message
-     * send with the `send()` method.
-     */
-    subscribe<T extends MessageType>(messageType: T, callback: (message: MessageOfType<T>) => void): void {
-        const handler = (messageString: string) => {
-            const message = JSON.parse(messageString) as Message;
-            if (message.type === messageType && message.channelId === this.channelId) {
-                callback(message as MessageOfType<T>);
-            }
-        };
-        if (this.target instanceof IndexBuilder) {
-            this.target.addMessageListener(handler);
-        } else {
-            this.target.on('message', handler);
-        }
-        this.handlers.push(handler);
-    }
-
-    /**
-     * Clean up all event listeners created by subscriptions.
-     */
-    close() {
-        const target = this.target;
-        if (target instanceof IndexBuilder) {
-            this.handlers.forEach(handler => target.removeMessageListener(handler));
-        } else {
-            this.handlers.forEach(handler => target.off('message', handler));
-        }
-    }
-}

+ 0 - 16
packages/core/src/plugin/default-search-plugin/indexer/search-index-worker.ts

@@ -1,16 +0,0 @@
-/* tslint:disable:no-non-null-assertion no-console */
-import { IndexBuilder } from './index-builder';
-import { ConnectionOptionsMessage, GetRawBatchByIdsMessage, GetRawBatchMessage, SaveVariantsMessage, sendIPCMessage } from './ipc';
-
-export type IncomingMessage = ConnectionOptionsMessage | GetRawBatchMessage | GetRawBatchByIdsMessage | SaveVariantsMessage;
-
-const indexBuilder = new IndexBuilder();
-
-process.on('message', async (messageString) => {
-    const message: IncomingMessage = JSON.parse(messageString);
-    const result = await indexBuilder.processMessage(message);
-    if (result) {
-        result.channelId = message.channelId;
-        sendIPCMessage(process, result);
-    }
-});

+ 92 - 276
packages/core/src/plugin/default-search-plugin/indexer/search-index.service.ts

@@ -1,139 +1,64 @@
-import { Inject, Injectable } from '@nestjs/common';
-import { InjectConnection } from '@nestjs/typeorm';
-import { LanguageCode } from '@vendure/common/lib/generated-types';
-import { pick } from '@vendure/common/lib/pick';
+import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
+import { ClientProxy } from '@nestjs/microservices';
 import { ID } from '@vendure/common/lib/shared-types';
-import { ChildProcess, fork } from 'child_process';
-import fs from 'fs-extra';
-import path from 'path';
-import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
-import { ConfigService } from '../../../config/config.service';
 import { Logger } from '../../../config/logger/vendure-logger';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { Product } from '../../../entity/product/product.entity';
 import { Job } from '../../../service/helpers/job-manager/job';
-import { translateDeep } from '../../../service/helpers/utils/translate-entity';
 import { JobService } from '../../../service/services/job.service';
-import { ProductVariantService } from '../../../service/services/product-variant.service';
-import { SEARCH_PLUGIN_OPTIONS } from '../constants';
-import { DefaultSearchPluginOptions } from '../default-search-plugin';
-import { SearchIndexItem } from '../search-index-item.entity';
+import { VENDURE_WORKER_CLIENT } from '../../../worker/constants';
+import { Message } from '../constants';
 
-import { BATCH_SIZE, getSearchIndexQueryBuilder, IndexBuilder, variantRelations } from './index-builder';
-import {
-    CompletedMessage,
-    ConnectedMessage,
-    ConnectionOptionsMessage,
-    GetRawBatchByIdsMessage,
-    GetRawBatchMessage,
-    IpcChannel,
-    MessageType,
-    ReturnRawBatchMessage,
-    SaveVariantsMessage,
-    VariantsSavedMessage,
-} from './ipc';
-// This import is needed to ensure that the worker script gets compiled
-// and emitted during build.
-import './search-index-worker';
-
-export type IncomingMessage = ConnectedMessage | ReturnRawBatchMessage | VariantsSavedMessage | CompletedMessage;
-const loggerCtx = 'DefaultSearchPlugin';
+import { ReindexMessageResponse } from './indexer.controller';
 
 /**
- * This service is responsible for all writes to the search index. It works together with the SearchIndexWorker
- * process to perform these often resource-intensive tasks in another thread, which keeps the main
- * server thread responsive.
+ * This service is responsible for messaging the {@link IndexerController} with search index updates.
  */
 @Injectable()
-export class SearchIndexService {
-    private workerProcess: ChildProcess | IndexBuilder;
-    private restartAttempts = 0;
+export class SearchIndexService implements OnModuleDestroy {
 
-    constructor(@InjectConnection() private connection: Connection,
-                @Inject(SEARCH_PLUGIN_OPTIONS) private options: DefaultSearchPluginOptions,
-                private productVariantService: ProductVariantService,
-                private jobService: JobService,
-                private configService: ConfigService) {}
-
-    /**
-     * Creates the search index worker process and has it connect to the database.
-     */
-    async connect() {
-        if (this.options.runInForkedProcess && this.configService.dbConnectionOptions.type !== 'sqljs') {
-            try {
-                const workerProcess = this.getChildProcess(path.join(__dirname, 'search-index-worker.ts'));
-                Logger.verbose(`IndexBuilder running as forked process`, loggerCtx);
-                workerProcess.on('error', err => {
-                    Logger.error(`IndexBuilder worker error: ` + err.message, loggerCtx);
-                });
-                workerProcess.on('close', () => {
-                    this.restartAttempts++;
-                    Logger.error(`IndexBuilder worker process died!`, loggerCtx);
-                    if (this.restartAttempts <= 10) {
-                        Logger.error(`Attempting to restart (${this.restartAttempts})...`, loggerCtx);
-                        this.connect();
-                    } else {
-                        Logger.error(`Too many failed restart attempts. Sorry!`);
-                    }
-                });
-                await this.establishConnection(workerProcess);
-                this.workerProcess = workerProcess;
-            } catch (e) {
-                Logger.error(e);
-            }
-
-        } else {
-            this.workerProcess = new IndexBuilder(this.connection);
-            Logger.verbose(`IndexBuilder running in main process`, loggerCtx);
-        }
-    }
+    constructor(@Inject(VENDURE_WORKER_CLIENT) private readonly client: ClientProxy,
+                private jobService: JobService) {}
 
     reindex(ctx: RequestContext): Job {
         return this.jobService.createJob({
             name: 'reindex',
             singleInstance: true,
             work: async reporter => {
-                const timeStart = Date.now();
-                const qb = getSearchIndexQueryBuilder(this.connection);
-                const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
-                Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
-                const batches = Math.ceil(count / BATCH_SIZE);
-
-                await this.connection.getRepository(SearchIndexItem).delete({ languageCode: ctx.languageCode });
-                Logger.verbose('Deleted existing index items', loggerCtx);
-
-                return new Promise(async (resolve, reject) => {
-                    const ipcChannel = new IpcChannel(this.workerProcess);
-                    ipcChannel.subscribe(MessageType.COMPLETED, message => {
-                        Logger.verbose(`Reindexing completed in ${Date.now() - timeStart}ms`, loggerCtx);
-                        ipcChannel.close();
-                        resolve({
-                            success: true,
-                            indexedItemCount: count,
-                            timeTaken: Date.now() - timeStart,
+                return new Promise((resolve, reject) => {
+                    Logger.verbose(`sending reindex message`);
+                    let total: number | undefined;
+                    let duration = 0;
+                    let completed = 0;
+                    this.client.send<ReindexMessageResponse>(Message.Reindex, { ctx })
+                        .subscribe({
+                            next: response => {
+                                if (!total) {
+                                    total = response.total;
+                                }
+                                duration = response.duration;
+                                completed = response.completed;
+                                const progress = Math.ceil((completed / total) * 100);
+                                reporter.setProgress(progress);
+                            },
+                            complete: () => {
+                                resolve({
+                                    success: true,
+                                    indexedItemCount: total,
+                                    timeTaken: duration,
+                                });
+                            },
+                            error: (err) => {
+                                Logger.error(JSON.stringify(err));
+                                resolve({
+                                    success: false,
+                                    indexedItemCount: 0,
+                                    timeTaken: 0,
+                                });
+                            },
                         });
-                    });
-                    ipcChannel.subscribe(MessageType.VARIANTS_SAVED, message => {
-                        reporter.setProgress(Math.ceil(((message.value.batchNumber + 1) / batches) * 100));
-                        Logger.verbose(`Completed batch ${message.value.batchNumber + 1} of ${batches}`, loggerCtx);
-                    });
-
-                    for (let i = 0; i < batches; i++) {
-                        Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
-
-                        const variants = await this.getBatch(this.workerProcess, i);
-                        const hydratedVariants = this.hydrateVariants(ctx, variants);
-                        Logger.verbose(`variants count: ${variants.length}`);
-
-                        ipcChannel.send(new SaveVariantsMessage({
-                            variants: hydratedVariants,
-                            ctx,
-                            batch: i,
-                            total: batches,
-                        }));
-                    }
                 });
             },
         });
@@ -142,175 +67,66 @@ export class SearchIndexService {
     /**
      * Updates the search index only for the affected entities.
      */
-    async updateProductOrVariant(ctx: RequestContext, updatedEntity: Product | ProductVariant) {
-        let updatedVariants: ProductVariant[] = [];
-        let removedVariantIds: ID[] = [];
-        if (updatedEntity instanceof Product) {
-            const product = await this.connection.getRepository(Product).findOne(updatedEntity.id, {
-                relations: ['variants'],
-            });
-            if (product) {
-                if (product.deletedAt) {
-                    removedVariantIds = product.variants.map(v => v.id);
+    updateProductOrVariant(ctx: RequestContext, updatedEntity: Product | ProductVariant) {
+        return this.jobService.createJob({
+            name: 'update-index',
+            work: async () => {
+                if (updatedEntity instanceof Product) {
+                    return this.client.send(Message.UpdateProductOrVariant, { ctx, productId: updatedEntity.id })
+                        .toPromise()
+                        .catch(err => Logger.error(err));
                 } else {
-                    updatedVariants = await this.connection
-                        .getRepository(ProductVariant)
-                        .findByIds(product.variants.map(v => v.id), {
-                            relations: variantRelations,
-                        });
-                    if (product.enabled === false) {
-                        updatedVariants.forEach(v => v.enabled = false);
-                    }
-                }
-            }
-        } else {
-            const variant = await this.connection.getRepository(ProductVariant).findOne(updatedEntity.id, {
-                relations: variantRelations,
-            });
-            if (variant) {
-                updatedVariants = [variant];
-            }
-        }
-
-        if (updatedVariants.length) {
-            await this.saveSearchIndexItems(ctx, updatedVariants);
-        }
-        if (removedVariantIds.length) {
-            await this.removeSearchIndexItems(ctx.languageCode, removedVariantIds);
-        }
-    }
-
-    async updateVariantsById(ctx: RequestContext, ids: ID[]) {
-        return new Promise(async resolve => {
-            if (ids.length) {
-                const ipcChannel = new IpcChannel(this.workerProcess);
-                const batches = Math.ceil(ids.length / BATCH_SIZE);
-                Logger.verbose(`Updating ${ids.length} variants...`);
-
-                ipcChannel.subscribe(MessageType.COMPLETED, message => {
-                    Logger.verbose(`Completed updating variants`);
-                    ipcChannel.close();
-                    resolve();
-                });
-
-                for (let i = 0; i < batches; i++) {
-                    const begin = i * BATCH_SIZE;
-                    const end = begin + BATCH_SIZE;
-                    Logger.verbose(`Updating ids from index ${begin} to ${end}`);
-                    const batchIds = ids.slice(begin, end);
-                    const batch = await this.getBatchByIds(this.workerProcess, batchIds);
-                    const variants = this.hydrateVariants(ctx, batch);
-
-                    ipcChannel.send(new SaveVariantsMessage({ variants, ctx, batch: i, total: batches }));
+                    return this.client.send(Message.UpdateProductOrVariant, { ctx, variantId: updatedEntity.id })
+                        .toPromise()
+                        .catch(err => Logger.error(err));
                 }
-            } else {
-                resolve();
-            }
-        });
-    }
-
-    /**
-     * Add or update items in the search index
-     */
-    private async saveSearchIndexItems(ctx: RequestContext, variants: ProductVariant[]) {
-        const items = this.hydrateVariants(ctx, variants);
-        Logger.verbose(`Updating search index for ${variants.length} variants`, loggerCtx);
-        return new Promise(resolve => {
-            const ipcChannel = new IpcChannel(this.workerProcess);
-            ipcChannel.subscribe(MessageType.COMPLETED, message => {
-                Logger.verbose(`Done!`, loggerCtx);
-                ipcChannel.close();
-                resolve();
-            });
-            ipcChannel.send(new SaveVariantsMessage({ variants: items, ctx, batch: 0, total: 1 }));
+            },
         });
-    }
 
-    /**
-     * Remove items from the search index
-     */
-    private async removeSearchIndexItems(languageCode: LanguageCode, variantIds: ID[]) {
-        const compositeKeys = variantIds.map(id => ({
-            productVariantId: id,
-            languageCode,
-        })) as any[];
-        await this.connection.getRepository(SearchIndexItem).delete(compositeKeys);
     }
 
-    /**
-     * Given an array of ProductVariants, this method applies the correct taxes and translations.
-     */
-    private hydrateVariants(ctx: RequestContext, variants: ProductVariant[]): ProductVariant[] {
-        return variants
-            .map(v => this.productVariantService.applyChannelPriceAndTax(v, ctx))
-            .map(v => translateDeep(v, ctx.languageCode, ['product']));
-    }
-
-    /**
-     * Forks a child process based on the given filename. The filename can be a JS or TS file, as this method will attempt to
-     * use either (attempts JS first).
-     */
-    private getChildProcess(filename: string): ChildProcess {
-        const ext = path.extname(filename);
-        const fileWithoutExt = filename.replace(new RegExp(`${ext}$`), '');
-        let error: any;
-        try {
-            const jsFile = fileWithoutExt + '.js';
-            if (fs.existsSync(jsFile)) {
-                return fork(jsFile, [], { execArgv: [] });
-            }
-        } catch (e) {
-            // ignore and try ts
-            error = e;
-        }
-        try {
-            const tsFile = fileWithoutExt + '.ts';
-            if (fs.existsSync(tsFile)) {
-                // Fork the TS file using ts-node. This is useful when running in dev mode or
-                // for e2e tests.
-                return fork(tsFile, [], { execArgv: ['-r', 'ts-node/register'] });
-            }
-        } catch (e) {
-            // ignore and thow at the end.
-            error = e;
-        }
-        throw error;
-    }
-
-    private establishConnection(child: ChildProcess): Promise<boolean> {
-        const connectionOptions = pick(this.configService.dbConnectionOptions as any,
-            ['type', 'name', 'database', 'host', 'port', 'username', 'password', 'location', 'autoSave']);
-        return new Promise(resolve => {
-            const ipcChannel = new IpcChannel(child);
-            ipcChannel.subscribe(MessageType.CONNECTED, message => {
-                Logger.verbose(`IndexBuilder connection result: ${message.value}`, loggerCtx);
-                ipcChannel.close();
-                resolve(message.value);
-            });
-            ipcChannel.send(new ConnectionOptionsMessage(connectionOptions));
-        });
-    }
-
-    private getBatch(child: ChildProcess | IndexBuilder, batch: number): Promise<ProductVariant[]> {
-        return new Promise(resolve => {
-            const ipcChannel = new IpcChannel(child);
-            ipcChannel.subscribe(MessageType.RETURN_RAW_BATCH, message => {
-                ipcChannel.close();
-                resolve(message.value.variants);
-            });
-            ipcChannel.send(new GetRawBatchMessage({ batchNumber: batch }));
+    updateVariantsById(ctx: RequestContext, ids: ID[]) {
+        return this.jobService.createJob({
+            name: 'update-index',
+            work: async reporter => {
+                return new Promise((resolve, reject) => {
+                    Logger.verbose(`sending reindex message`);
+                    let total: number | undefined;
+                    let duration = 0;
+                    let completed = 0;
+                    this.client.send<ReindexMessageResponse>(Message.UpdateVariantsById, { ctx, ids })
+                        .subscribe({
+                            next: response => {
+                                if (!total) {
+                                    total = response.total;
+                                }
+                                duration = response.duration;
+                                completed = response.completed;
+                                const progress = Math.ceil((completed / total) * 100);
+                                reporter.setProgress(progress);
+                            },
+                            complete: () => {
+                                resolve({
+                                    success: true,
+                                    indexedItemCount: total,
+                                    timeTaken: duration,
+                                });
+                            },
+                            error: (err) => {
+                                Logger.error(JSON.stringify(err));
+                                resolve({
+                                    success: false,
+                                    indexedItemCount: 0,
+                                    timeTaken: 0,
+                                });
+                            },
+                        });
+                });
+            },
         });
     }
 
-    private getBatchByIds(child: ChildProcess | IndexBuilder, ids: ID[]): Promise<ProductVariant[]> {
-        return new Promise(resolve => {
-            const ipcChannel = new IpcChannel(child);
-            ipcChannel.subscribe(MessageType.RETURN_RAW_BATCH, message => {
-                ipcChannel.close();
-                resolve(message.value.variants);
-            });
-            ipcChannel.send(new GetRawBatchByIdsMessage({ ids }));
-        });
+    onModuleDestroy(): any {
+        this.client.close();
     }
-
 }

+ 55 - 8
packages/core/src/plugin/plugin.module.ts

@@ -1,27 +1,41 @@
-import { Module } from '@nestjs/common';
+import { DynamicModule, Module } from '@nestjs/common';
+import { ClientProxyFactory, ClientsModule, Transport } from '@nestjs/microservices';
 import { Type } from '@vendure/common/lib/shared-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
 import { getConfig } from '../config/config-helpers';
 import { ConfigModule } from '../config/config.module';
+import { ConfigService } from '../config/config.service';
 import { EventBusModule } from '../event-bus/event-bus.module';
 import { ServiceModule } from '../service/service.module';
+import { VENDURE_WORKER_CLIENT } from '../worker/constants';
 
 import { getPluginAPIExtensions } from './plugin-utils';
 
-const plugins = getConfig().plugins;
-const pluginProviders = plugins
-    .map(p => (p.defineProviders ? p.defineProviders() : undefined))
-    .filter(notNullOrUndefined)
-    .reduce((flattened, providers) => flattened.concat(providers), []);
+const pluginProviders = getPluginProviders();
 
 /**
  * This module collects and re-exports all providers defined in plugins so that they can be used in other
  * modules.
  */
 @Module({
-    imports: [ServiceModule, EventBusModule, ConfigModule],
-    providers: pluginProviders,
+    imports: [
+        EventBusModule,
+        ConfigModule,
+    ],
+    providers: [
+        {
+            provide: VENDURE_WORKER_CLIENT,
+            useFactory: (configService: ConfigService) => {
+                return ClientProxyFactory.create({
+                    transport: configService.workerOptions.transport as any,
+                    options: configService.workerOptions.options as any,
+                });
+            },
+            inject: [ConfigService],
+        },
+        ...pluginProviders,
+    ],
     exports: pluginProviders,
 })
 export class PluginModule {
@@ -32,9 +46,42 @@ export class PluginModule {
     static adminApiResolvers(): Array<Type<any>> {
         return graphQLResolversFor('admin');
     }
+
+    static forRoot(): DynamicModule {
+        return {
+            module: PluginModule,
+            imports: [ServiceModule.forRoot()],
+        };
+    }
+
+    static forWorker(): DynamicModule {
+        const controllers = getWorkerControllers();
+        return {
+            module: PluginModule,
+            imports: [ServiceModule.forWorker()],
+            controllers,
+        };
+    }
+}
+
+function getPluginProviders() {
+    const plugins = getConfig().plugins;
+    return plugins
+        .map(p => (p.defineProviders ? p.defineProviders() : undefined))
+        .filter(notNullOrUndefined)
+        .reduce((flattened, providers) => flattened.concat(providers), []);
+}
+
+function getWorkerControllers() {
+    const plugins = getConfig().plugins;
+    return plugins
+        .map(p => (p.defineWorkers ? p.defineWorkers() : undefined))
+        .filter(notNullOrUndefined)
+        .reduce((flattened, providers) => flattened.concat(providers), []);
 }
 
 function graphQLResolversFor(apiType: 'shop' | 'admin'): Array<Type<any>> {
+    const plugins = getConfig().plugins;
     return getPluginAPIExtensions(plugins, apiType)
         .map(extension => extension.resolvers)
         .reduce((flattened, r) => [...flattened, ...r], []);

+ 57 - 3
packages/core/src/service/service.module.ts

@@ -1,8 +1,8 @@
-import { Module, OnModuleInit } from '@nestjs/common';
+import { DynamicModule, Module, OnModuleInit } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 
-import { getConfig } from '../config/config-helpers';
 import { ConfigModule } from '../config/config.module';
+import { ConfigService } from '../config/config.service';
 import { EventBusModule } from '../event-bus/event-bus.module';
 
 import { AssetUpdater } from './helpers/asset-updater/asset-updater';
@@ -73,6 +73,9 @@ const exportedProviders = [
     ZoneService,
 ];
 
+let defaultTypeOrmModule: DynamicModule;
+let workerTypeOrmModule: DynamicModule;
+
 /**
  * The ServiceModule is responsible for the service layer, i.e. accessing the database
  * and implementing the main business logic of the application.
@@ -81,7 +84,10 @@ const exportedProviders = [
  * into a format suitable for the service layer logic.
  */
 @Module({
-    imports: [ConfigModule, EventBusModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
+    imports: [
+        ConfigModule,
+        EventBusModule,
+    ],
     providers: [
         ...exportedProviders,
         PasswordCiper,
@@ -123,4 +129,52 @@ export class ServiceModule implements OnModuleInit {
         await this.shippingMethodService.initShippingMethods();
         await this.paymentMethodService.initPaymentMethods();
     }
+
+    static forRoot(): DynamicModule {
+        if (!defaultTypeOrmModule) {
+            defaultTypeOrmModule = TypeOrmModule.forRootAsync({
+                imports: [ConfigModule],
+                useFactory: (configService: ConfigService) => {
+                    return configService.dbConnectionOptions;
+                },
+                inject: [ConfigService],
+            });
+        }
+        return {
+            module: ServiceModule,
+            imports: [
+                defaultTypeOrmModule,
+            ],
+        };
+    }
+
+    static forWorker(): DynamicModule {
+        if (!workerTypeOrmModule) {
+            workerTypeOrmModule = TypeOrmModule.forRootAsync({
+                imports: [ConfigModule],
+                useFactory: (configService: ConfigService) => {
+                    const { dbConnectionOptions, workerOptions } = configService;
+                    if (workerOptions.runInMainProcess) {
+                        // When running in the main process, we can re-use the existing
+                        // default connection.
+                        return {
+                            ...dbConnectionOptions,
+                            name: 'default',
+                            keepConnectionAlive: true,
+                        };
+                    } else {
+                        return {
+                            ...dbConnectionOptions,
+                            name: 'worker',
+                        };
+                    }
+                },
+                inject: [ConfigService],
+            });
+        }
+        return {
+            module: ServiceModule,
+            imports: [workerTypeOrmModule],
+        };
+    }
 }

+ 1 - 1
packages/core/src/service/services/tax-rate.service.ts

@@ -107,7 +107,7 @@ export class TaxRateService {
         return rate || this.defaultTaxRate;
     }
 
-    private async updateActiveTaxRates() {
+    async updateActiveTaxRates() {
         this.activeTaxRates = await this.connection.getRepository(TaxRate).find({
             relations: ['category', 'zone', 'customerGroup'],
             where: {

+ 1 - 0
packages/core/src/worker/constants.ts

@@ -0,0 +1 @@
+export const VENDURE_WORKER_CLIENT = Symbol('VENDURE_WORKER_CLIENT');

+ 14 - 0
packages/core/src/worker/worker.module.ts

@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+
+import { ConfigModule } from '../config/config.module';
+import { PluginModule } from '../plugin/plugin.module';
+import { ServiceModule } from '../service/service.module';
+
+@Module({
+    imports: [
+        ConfigModule,
+        ServiceModule.forWorker(),
+        PluginModule.forWorker(),
+    ],
+})
+export class WorkerModule {}

+ 15 - 8
packages/create/src/create-vendure-app.ts

@@ -62,22 +62,25 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
 
     const root = path.resolve(name);
     const appName = path.basename(root);
-    const { dbType, usingTs, configSource, indexSource, populateProducts } = await gatherUserResponses(root);
+    const { dbType, usingTs, configSource, indexSource, indexWorkerSource, populateProducts } = await gatherUserResponses(root);
+
+    const useYarn = useNpm ? false : shouldUseYarn();
+    const originalDirectory = process.cwd();
+    process.chdir(root);
+    if (!useYarn && !checkThatNpmCanReadCwd()) {
+        process.exit(1);
+    }
 
     const packageJsonContents = {
         name: appName,
         version: '0.1.0',
         private: true,
         scripts: {
-            start: usingTs ? 'ts-node index.ts' : 'node index.js',
+            'run:server': usingTs ? 'ts-node index.ts' : 'node index.js',
+            'run:worker': usingTs ? 'ts-node index-worker.ts' : 'node index-worker.js',
+            'start': useYarn ? 'concurrently yarn:run:*' : 'concurrently npm:run:*',
         },
     };
-    const useYarn = useNpm ? false : shouldUseYarn();
-    const originalDirectory = process.cwd();
-    process.chdir(root);
-    if (!useYarn && !checkThatNpmCanReadCwd()) {
-        process.exit(1);
-    }
 
     console.log();
     console.log(`Setting up your new Vendure project in ${chalk.green(root)}`);
@@ -124,6 +127,10 @@ async function createApp(name: string | undefined, useNpm: boolean, logLevel: Lo
                         })
                         .then(() => {
                             subscriber.next(`Created index`);
+                            return fs.writeFile(rootPathScript('index-worker'), indexWorkerSource);
+                        })
+                        .then(() => {
+                            subscriber.next(`Created worker`);
                             if (usingTs) {
                                 return fs.copyFile(
                                     assetPath('tsconfig.template.json'),

+ 7 - 4
packages/create/src/gather-user-responses.ts

@@ -94,9 +94,10 @@ export async function gatherUserResponses(root: string): Promise<UserResponses>
         process.exit(0);
     }
 
-    const { indexSource, configSource } = await generateSources(root, answers);
+    const { indexSource, indexWorkerSource, configSource } = await generateSources(root, answers);
     return {
         indexSource,
+        indexWorkerSource,
         configSource,
         usingTs: answers.language === 'ts',
         dbType: answers.dbType,
@@ -105,9 +106,9 @@ export async function gatherUserResponses(root: string): Promise<UserResponses>
 }
 
 /**
- * Create the server index and config source code based on the options specified by the CLI prompts.
+ * Create the server index, worker and config source code based on the options specified by the CLI prompts.
  */
-async function generateSources(root: string, answers: any): Promise<{ indexSource: string; configSource: string; }> {
+async function generateSources(root: string, answers: any): Promise<{ indexSource: string; indexWorkerSource: string; configSource: string; }> {
     const assetPath = (fileName: string) => path.join(__dirname, '../assets', fileName);
 
     const templateContext = {
@@ -124,7 +125,9 @@ async function generateSources(root: string, answers: any): Promise<{ indexSourc
     const configSource = Handlebars.compile(configTemplate)(templateContext);
     const indexTemplate = await fs.readFile(assetPath('index.hbs'), 'utf-8');
     const indexSource = Handlebars.compile(indexTemplate)(templateContext);
-    return { indexSource, configSource };
+    const indexWorkerTemplate = await fs.readFile(assetPath('index-worker.hbs'), 'utf-8');
+    const indexWorkerSource = Handlebars.compile(indexWorkerTemplate)(templateContext);
+    return { indexSource, indexWorkerSource, configSource };
 }
 
 function defaultDBPort(dbType: DbType): number {

+ 4 - 1
packages/create/src/helpers.ts

@@ -229,7 +229,10 @@ export function getDependencies(usingTs: boolean, dbType: DbType): { dependencie
         '@vendure/admin-ui-plugin',
         dbDriverPackage(dbType),
     ];
-    const devDependencies = usingTs ? ['ts-node'] : [];
+    const devDependencies = ['concurrently'];
+    if (usingTs) {
+        devDependencies.push('ts-node');
+    }
 
     return {dependencies, devDependencies};
 }

+ 1 - 0
packages/create/src/types.ts

@@ -5,6 +5,7 @@ export interface UserResponses {
     dbType: DbType;
     populateProducts: boolean;
     indexSource: string;
+    indexWorkerSource: string;
     configSource: string;
 }
 

+ 7 - 0
packages/create/templates/index-worker.hbs

@@ -0,0 +1,7 @@
+{{#if isTs }}import { bootstrapWorker } from '@vendure/core';{{else}}const { bootstrapWorker } = require('@vendure/core');{{/if}}
+{{#if isTs }}import { config } from './vendure-config';{{else}}const { config } = require('./vendure-config');{{/if}}
+
+bootstrapWorker(config).catch(err => {
+    // tslint:disable-next-line:no-console
+    console.log(err);
+});

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

@@ -1,8 +1,12 @@
 export const ELASTIC_SEARCH_OPTIONS = Symbol('ELASTIC_SEARCH_OPTIONS');
 export const ELASTIC_SEARCH_CLIENT = Symbol('ELASTIC_SEARCH_CLIENT');
-export const BATCH_SIZE = 500;
 export const VARIANT_INDEX_NAME = 'variants';
 export const VARIANT_INDEX_TYPE = 'variant-index-item';
 export const PRODUCT_INDEX_NAME = 'products';
 export const PRODUCT_INDEX_TYPE = 'product-index-item';
 export const loggerCtx = 'ElasticsearchPlugin';
+export enum Message {
+    Reindex = 'Reindex',
+    UpdateVariantsById = 'UpdateVariantsById',
+    UpdateProductOrVariant = 'UpdateProductOrVariant',
+}

+ 94 - 298
packages/elasticsearch-plugin/src/elasticsearch-index.service.ts

@@ -1,324 +1,120 @@
-import { Client } from '@elastic/elasticsearch';
-import { Inject, Injectable } from '@nestjs/common';
-import { InjectConnection } from '@nestjs/typeorm';
-import { unique } from '@vendure/common/lib/unique';
-import {
-    FacetValue,
-    ID,
-    Job,
-    JobService,
-    Logger,
-    Product,
-    ProductVariant,
-    ProductVariantService,
-    RequestContext,
-    translateDeep,
-} from '@vendure/core';
-import { Connection, SelectQueryBuilder } from 'typeorm';
-import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
+import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
+import { ClientProxy } from '@nestjs/microservices';
+import { ID, Job, JobService, Logger, Product, ProductVariant, RequestContext, VENDURE_WORKER_CLIENT } from '@vendure/core';
 
-import {
-    BATCH_SIZE,
-    ELASTIC_SEARCH_CLIENT,
-    ELASTIC_SEARCH_OPTIONS,
-    loggerCtx,
-    PRODUCT_INDEX_NAME,
-    PRODUCT_INDEX_TYPE,
-    VARIANT_INDEX_NAME,
-    VARIANT_INDEX_TYPE,
-} from './constants';
-import { ElasticsearchOptions } from './plugin';
-import { BulkOperation, BulkOperationDoc, BulkResponseBody, ProductIndexItem, VariantIndexItem } from './types';
-
-export const variantRelations = [
-    'product',
-    'product.featuredAsset',
-    'product.facetValues',
-    'product.facetValues.facet',
-    'featuredAsset',
-    'facetValues',
-    'facetValues.facet',
-    'collections',
-    'taxCategory',
-];
+import { Message } from './constants';
+import { ReindexMessageResponse } from './indexer.controller';
 
 @Injectable()
-export class ElasticsearchIndexService {
+export class ElasticsearchIndexService implements OnModuleDestroy {
 
-    constructor(@InjectConnection() private connection: Connection,
-                @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
-                @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
-                private productVariantService: ProductVariantService,
+    constructor(@Inject(VENDURE_WORKER_CLIENT) private readonly client: ClientProxy,
                 private jobService: JobService) {}
 
     /**
      * Updates the search index only for the affected entities.
      */
-    async updateProductOrVariant(ctx: RequestContext, updatedEntity: Product | ProductVariant) {
-        let updatedProductVariants: ProductVariant[] = [];
-        let removedProducts: Product[] = [];
-        let updatedVariants: ProductVariant[] = [];
-        let removedVariantIds: ID[] = [];
-        if (updatedEntity instanceof Product) {
-            const product = await this.connection.getRepository(Product).findOne(updatedEntity.id, {
-                relations: ['variants'],
-            });
-            if (product) {
-                if (product.deletedAt) {
-                    removedProducts = [product];
-                    removedVariantIds = product.variants.map(v => v.id);
+    updateProductOrVariant(ctx: RequestContext, updatedEntity: Product | ProductVariant) {
+        return this.jobService.createJob({
+            name: 'update-index',
+            work: async () => {
+                if (updatedEntity instanceof Product) {
+                    return this.client.send(Message.UpdateProductOrVariant, { ctx, productId: updatedEntity.id })
+                        .toPromise()
+                        .catch(err => Logger.error(err));
                 } else {
-                    const variants = await this.connection
-                        .getRepository(ProductVariant)
-                        .findByIds(product.variants.map(v => v.id), {
-                            relations: variantRelations,
-                        });
-                    updatedVariants = this.hydrateVariants(ctx, variants);
-                    updatedProductVariants = updatedVariants;
+                    return this.client.send(Message.UpdateProductOrVariant, { ctx, variantId: updatedEntity.id })
+                        .toPromise()
+                        .catch(err => Logger.error(err));
                 }
-            }
-        } else {
-            const variant = await this.connection.getRepository(ProductVariant).findOne(updatedEntity.id, {
-                relations: variantRelations,
-            });
-            if (variant) {
-                updatedVariants = [variant];
-            }
-        }
-
-        if (updatedProductVariants.length) {
-            const updatedProductIndexItem = this.createProductIndexItem(updatedProductVariants);
-            const operations: [BulkOperation, BulkOperationDoc<ProductIndexItem>] = [
-                { update: { _id: updatedProductIndexItem.productId.toString() } },
-                { doc: updatedProductIndexItem },
-            ];
-            await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, operations);
-        }
-        if (updatedVariants.length) {
-            const operations = updatedVariants.reduce((ops, variant) => {
-                return [
-                    ...ops,
-                    { update: { _id: variant.id.toString() } },
-                    { doc: this.createVariantIndexItem(variant) },
-                ];
-            }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
-            await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
-        }
-        if (removedVariantIds.length) {
-            const operations = removedVariantIds.reduce((ops, id) => {
-                return [
-                    ...ops,
-                    { delete: { _id: id.toString() } },
-                ];
-            }, [] as BulkOperation[]);
-            await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
-        }
+            },
+        });
     }
 
-    async updateVariantsById(ctx: RequestContext, ids: ID[]) {
-        if (ids.length) {
-            const batches = Math.ceil(ids.length / BATCH_SIZE);
-            Logger.verbose(`Updating ${ids.length} variants...`);
-
-            for (let i = 0; i < batches; i++) {
-                const begin = i * BATCH_SIZE;
-                const end = begin + BATCH_SIZE;
-                Logger.verbose(`Updating ids from index ${begin} to ${end}`);
-                const batchIds = ids.slice(begin, end);
-                const batch = await this.getBatchByIds(ctx, batchIds);
-                const variants = this.hydrateVariants(ctx, batch);
-                const operations = this.hydrateVariants(ctx, variants).reduce((ops, variant) => {
-                    return [
-                        ...ops,
-                        { update: { _id: variant.id.toString() } },
-                        { doc: this.createVariantIndexItem(variant) },
-                    ];
-                }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
-                await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
-            }
-        }
+    updateVariantsById(ctx: RequestContext, ids: ID[]) {
+        return this.jobService.createJob({
+            name: 'update-index',
+            work: async reporter => {
+                return new Promise((resolve, reject) => {
+                    Logger.verbose(`sending reindex message`);
+                    let total: number | undefined;
+                    let duration = 0;
+                    let completed = 0;
+                    this.client.send<ReindexMessageResponse>(Message.UpdateVariantsById, { ctx, ids })
+                        .subscribe({
+                            next: response => {
+                                if (!total) {
+                                    total = response.total;
+                                }
+                                duration = response.duration;
+                                completed = response.completed;
+                                const progress = Math.ceil((completed / total) * 100);
+                                reporter.setProgress(progress);
+                            },
+                            complete: () => {
+                                resolve({
+                                    success: true,
+                                    indexedItemCount: total,
+                                    timeTaken: duration,
+                                });
+                            },
+                            error: (err) => {
+                                Logger.error(JSON.stringify(err));
+                                resolve({
+                                    success: false,
+                                    indexedItemCount: 0,
+                                    timeTaken: 0,
+                                });
+                            },
+                        });
+                });
+            },
+        });
     }
 
     reindex(ctx: RequestContext): Job {
-        const job = this.jobService.createJob({
+        return this.jobService.createJob({
             name: 'reindex',
             singleInstance: true,
             work: async reporter => {
-                const timeStart = Date.now();
-                const qb = this.getSearchIndexQueryBuilder();
-                const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
-                Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
-
-                const batches = Math.ceil(count / BATCH_SIZE);
-                let variantsInProduct: ProductVariant[] = [];
-
-                for (let i = 0; i < batches; i++) {
-                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
-
-                    const variants = await this.getBatch(ctx, qb, i);
-                    Logger.verbose(`variants count: ${variants.length}`);
-
-                    const variantsToIndex: Array<BulkOperation | VariantIndexItem> = [];
-                    const productsToIndex: Array<BulkOperation | ProductIndexItem> = [];
-
-                    // tslint:disable-next-line:prefer-for-of
-                    for (let j = 0; j < variants.length; j++) {
-                        const variant = variants[j];
-                        variantsInProduct.push(variant);
-                        variantsToIndex.push({ index: { _id: variant.id.toString() } });
-                        variantsToIndex.push(this.createVariantIndexItem(variant));
-
-                        const nextVariant = variants[j + 1];
-                        if (nextVariant && nextVariant.productId !== variant.productId) {
-                            productsToIndex.push({ index: { _id: variant.productId.toString() } });
-                            productsToIndex.push(this.createProductIndexItem(variantsInProduct) as any);
-                            variantsInProduct = [];
-                        }
-                    }
-                    await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, variantsToIndex);
-                    await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, productsToIndex);
-                    reporter.setProgress(Math.ceil(((i + 1) / batches) * 100));
-                }
-                return {
-                    success: true,
-                    indexedItemCount: count,
-                    timeTaken: Date.now() - timeStart,
-                };
-            },
-        });
-        return job;
-    }
-
-    private async executeBulkOperations(indexName: string,
-                                        indexType: string,
-                                        operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem | ProductIndexItem>>) {
-        try {
-            const {body}: { body: BulkResponseBody; } = await this.client.bulk({
-                refresh: 'true',
-                index: this.options.indexPrefix + indexName,
-                type: indexType,
-                body: operations,
-            });
-
-            if (body.errors) {
-                Logger.error(`Some errors occurred running bulk operations on ${indexType}! Set logger to "debug" to print all errors.`, loggerCtx);
-                body.items.forEach(item => {
-                    if (item.index) {
-                        Logger.debug(JSON.stringify(item.index.error, null, 2), loggerCtx);
-                    }
-                    if (item.update) {
-                        Logger.debug(JSON.stringify(item.update.error, null, 2), loggerCtx);
-                    }
-                    if (item.delete) {
-                        Logger.debug(JSON.stringify(item.delete.error, null, 2), loggerCtx);
-                    }
+                return new Promise((resolve, reject) => {
+                    Logger.verbose(`sending reindex message`);
+                    let total: number | undefined;
+                    let duration = 0;
+                    let completed = 0;
+                    this.client.send<ReindexMessageResponse>(Message.Reindex, { ctx })
+                        .subscribe({
+                            next: response => {
+                                if (!total) {
+                                    total = response.total;
+                                }
+                                duration = response.duration;
+                                completed = response.completed;
+                                const progress = Math.ceil((completed / total) * 100);
+                                reporter.setProgress(progress);
+                            },
+                            complete: () => {
+                                resolve({
+                                    success: true,
+                                    indexedItemCount: total,
+                                    timeTaken: duration,
+                                });
+                            },
+                            error: (err) => {
+                                Logger.error(JSON.stringify(err));
+                                resolve({
+                                    success: false,
+                                    indexedItemCount: 0,
+                                    timeTaken: 0,
+                                });
+                            },
+                        });
                 });
-            } else {
-                Logger.verbose(`Executed ${body.items.length} bulk operations on ${indexType}`);
-            }
-            return body;
-        } catch (e) {
-            Logger.error(`Error when attempting to run bulk operations [${e.toString()}]`, loggerCtx);
-            Logger.error('Error details: ' + JSON.stringify(e.body.error, null, 2), loggerCtx);
-        }
-    }
-
-    private getSearchIndexQueryBuilder() {
-        const qb = this.connection.getRepository(ProductVariant).createQueryBuilder('variants');
-        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
-            relations: variantRelations,
-            order: {
-                productId: 'ASC',
             },
         });
-        FindOptionsUtils.joinEagerRelations(qb, qb.alias, this.connection.getMetadata(ProductVariant));
-        return qb;
-    }
-
-    private async getBatch(ctx: RequestContext, qb: SelectQueryBuilder<ProductVariant>, batchNumber: string | number): Promise<ProductVariant[]> {
-        const i = Number.parseInt(batchNumber.toString(), 10);
-        const variants = await qb
-            .where('variants__product.deletedAt IS NULL')
-            .take(BATCH_SIZE)
-            .skip(i * BATCH_SIZE)
-            .getMany();
-
-        return this.hydrateVariants(ctx, variants);
-    }
-
-    private async getBatchByIds(ctx: RequestContext, ids: ID[]) {
-        const variants = await this.connection.getRepository(ProductVariant).findByIds(ids, {
-            relations: variantRelations,
-        });
-        return this.hydrateVariants(ctx, variants);
-    }
-    /**
-     * Given an array of ProductVariants, this method applies the correct taxes and translations.
-     */
-    private hydrateVariants(ctx: RequestContext, variants: ProductVariant[]): ProductVariant[] {
-        return variants
-            .map(v => this.productVariantService.applyChannelPriceAndTax(v, ctx))
-            .map(v => translateDeep(v, ctx.languageCode, ['product']));
-    }
-
-    private createVariantIndexItem(v: ProductVariant): VariantIndexItem {
-        return {
-            sku: v.sku,
-            slug: v.product.slug,
-            productId: v.product.id as string,
-            productName: v.product.name,
-            productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
-            productVariantId: v.id as string,
-            productVariantName: v.name,
-            productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
-            price: v.price,
-            priceWithTax: v.priceWithTax,
-            currencyCode: v.currencyCode,
-            description: v.product.description,
-            facetIds: this.getFacetIds([v]),
-            facetValueIds: this.getFacetValueIds([v]),
-            collectionIds: v.collections.map(c => c.id.toString()),
-            enabled: v.enabled && v.product.enabled,
-        };
-    }
-
-    private createProductIndexItem(variants: ProductVariant[]): ProductIndexItem {
-        const first = variants[0];
-        const prices = variants.map(v => v.price);
-        const pricesWithTax = variants.map(v => v.priceWithTax);
-        return {
-            sku: variants.map(v => v.sku),
-            slug: variants.map(v => v.product.slug),
-            productId: first.product.id,
-            productName: variants.map(v => v.product.name),
-            productPreview: first.product.featuredAsset ? first.product.featuredAsset.preview : '',
-            productVariantId: variants.map(v => v.id),
-            productVariantName: variants.map(v => v.name),
-            productVariantPreview: variants.filter(v => v.featuredAsset).map(v => v.featuredAsset.preview),
-            priceMin: Math.min(...prices),
-            priceMax: Math.max(...prices),
-            priceWithTaxMin: Math.min(...pricesWithTax),
-            priceWithTaxMax: Math.max(...pricesWithTax),
-            currencyCode: first.currencyCode,
-            description: first.product.description,
-            facetIds: this.getFacetIds(variants),
-            facetValueIds: this.getFacetValueIds(variants),
-            collectionIds: variants.reduce((ids, v) => [ ...ids, ...v.collections.map(c => c.id)], [] as ID[]),
-            enabled: first.product.enabled,
-        };
-    }
-
-    private getFacetIds(variants: ProductVariant[]): string[] {
-        const facetIds = (fv: FacetValue) => fv.facet.id.toString();
-        const variantFacetIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetIds)], [] as string[]);
-        const productFacetIds = variants[0].product.facetValues.map(facetIds);
-        return unique([...variantFacetIds, ...productFacetIds]);
     }
 
-    private getFacetValueIds(variants: ProductVariant[]): string[] {
-        const facetValueIds = (fv: FacetValue) => fv.id.toString();
-        const variantFacetValueIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetValueIds)], [] as string[]);
-        const productFacetValueIds = variants[0].product.facetValues.map(facetValueIds);
-        return unique([...variantFacetValueIds, ...productFacetValueIds]);
+    onModuleDestroy(): any {
+        this.client.close();
     }
 }

+ 358 - 0
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -0,0 +1,358 @@
+import { Client } from '@elastic/elasticsearch';
+import { Controller, Inject } from '@nestjs/common';
+import { MessagePattern } from '@nestjs/microservices';
+import { InjectConnection } from '@nestjs/typeorm';
+import { unique } from '@vendure/common/lib/unique';
+import { FacetValue, ID, JobService, Logger, Product, ProductVariant, ProductVariantService, RequestContext, translateDeep } from '@vendure/core';
+import { defer, Observable } from 'rxjs';
+import { Connection, SelectQueryBuilder } from 'typeorm';
+import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
+
+import {
+    ELASTIC_SEARCH_CLIENT,
+    ELASTIC_SEARCH_OPTIONS,
+    loggerCtx,
+    Message,
+    PRODUCT_INDEX_NAME,
+    PRODUCT_INDEX_TYPE,
+    VARIANT_INDEX_NAME,
+    VARIANT_INDEX_TYPE,
+} from './constants';
+import { ElasticsearchOptions } from './plugin';
+import { BulkOperation, BulkOperationDoc, BulkResponseBody, ProductIndexItem, VariantIndexItem } from './types';
+
+export const variantRelations = [
+    'product',
+    'product.featuredAsset',
+    'product.facetValues',
+    'product.facetValues.facet',
+    'featuredAsset',
+    'facetValues',
+    'facetValues.facet',
+    'collections',
+    'taxCategory',
+];
+
+export interface ReindexMessageResponse {
+    total: number;
+    completed: number;
+    duration: number;
+}
+
+@Controller()
+export class ElasticsearchIndexerController {
+
+    constructor(@InjectConnection() private connection: Connection,
+                @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
+                @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
+                private productVariantService: ProductVariantService,
+                private jobService: JobService) {}
+
+    /**
+     * Updates the search index only for the affected entities.
+     */
+    @MessagePattern(Message.UpdateProductOrVariant)
+    updateProductOrVariant({ ctx: rawContext, productId, variantId }: { ctx: any, productId?: ID, variantId?: ID }): Observable<boolean> {
+        const ctx = RequestContext.fromObject(rawContext);
+        let updatedProductVariants: ProductVariant[] = [];
+        let removedProducts: Product[] = [];
+        let updatedVariants: ProductVariant[] = [];
+        let removedVariantIds: ID[] = [];
+
+        return defer(async () => {
+            if (productId) {
+                const product = await this.connection.getRepository(Product).findOne(productId, {
+                    relations: ['variants'],
+                });
+                if (product) {
+                    if (product.deletedAt) {
+                        removedProducts = [product];
+                        removedVariantIds = product.variants.map(v => v.id);
+                    } else {
+                        const variants = await this.connection
+                            .getRepository(ProductVariant)
+                            .findByIds(product.variants.map(v => v.id), {
+                                relations: variantRelations,
+                            });
+                        updatedProductVariants = updatedVariants;
+                    }
+                }
+            } else {
+                const variant = await this.connection.getRepository(ProductVariant).findOne(variantId, {
+                    relations: variantRelations,
+                });
+                if (variant) {
+                    updatedVariants = [variant];
+                }
+            }
+            Logger.verbose(`Updating ${updatedVariants.length} variants`, loggerCtx);
+            updatedVariants = this.hydrateVariants(ctx, updatedVariants);
+
+            if (updatedProductVariants.length) {
+                const updatedProductIndexItem = this.createProductIndexItem(updatedProductVariants);
+                const operations: [BulkOperation, BulkOperationDoc<ProductIndexItem>] = [
+                    { update: { _id: updatedProductIndexItem.productId.toString() } },
+                    { doc: updatedProductIndexItem },
+                ];
+                await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, operations);
+            }
+            if (updatedVariants.length) {
+                const operations = updatedVariants.reduce((ops, variant) => {
+                    return [
+                        ...ops,
+                        { update: { _id: variant.id.toString() } },
+                        { doc: this.createVariantIndexItem(variant) },
+                    ];
+                }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
+                await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
+            }
+            if (removedVariantIds.length) {
+                const operations = removedVariantIds.reduce((ops, id) => {
+                    return [
+                        ...ops,
+                        { delete: { _id: id.toString() } },
+                    ];
+                }, [] as BulkOperation[]);
+                await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
+            }
+
+            return true;
+        });
+    }
+
+    @MessagePattern(Message.UpdateVariantsById)
+    updateVariantsById({ ctx: rawContext, ids }: { ctx: any, ids: ID[] }): Observable<ReindexMessageResponse> {
+        const ctx = RequestContext.fromObject(rawContext);
+        const { batchSize } = this.options;
+
+        return new Observable(observer => {
+            (async () => {
+                const timeStart = Date.now();
+                if (ids.length) {
+                    const batches = Math.ceil(ids.length / batchSize);
+                    Logger.verbose(`Updating ${ids.length} variants...`);
+
+                    for (let i = 0; i < batches; i++) {
+                        const begin = i * batchSize;
+                        const end = begin + batchSize;
+                        Logger.verbose(`Updating ids from index ${begin} to ${end}`);
+                        const batchIds = ids.slice(begin, end);
+                        const batch = await this.getBatchByIds(ctx, batchIds);
+                        const operations = batch.reduce((ops, variant) => {
+                            return [
+                                ...ops,
+                                { update: { _id: variant.id.toString() } },
+                                { doc: this.createVariantIndexItem(variant) },
+                            ];
+                        }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
+                        await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
+                        observer.next({
+                            total: ids.length,
+                            completed: Math.min((i + 1) * batchSize, ids.length),
+                            duration: +new Date() - timeStart,
+                        });
+                    }
+                }
+                Logger.verbose(`Completed reindexing!`);
+                observer.next({
+                    total: ids.length,
+                    completed: ids.length,
+                    duration: +new Date() - timeStart,
+                });
+                observer.complete();
+            })();
+        });
+    }
+
+    @MessagePattern(Message.Reindex)
+    reindex({ ctx: rawContext }: { ctx: any }): Observable<ReindexMessageResponse> {
+        const ctx = RequestContext.fromObject(rawContext);
+        const { batchSize } = this.options;
+
+        return new Observable(observer => {
+                (async () => {
+                    const timeStart = Date.now();
+                    const qb = this.getSearchIndexQueryBuilder();
+                    const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
+                    Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
+
+                    const batches = Math.ceil(count / batchSize);
+                    let variantsInProduct: ProductVariant[] = [];
+
+                    for (let i = 0; i < batches; i++) {
+                        Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
+
+                        const variants = await this.getBatch(ctx, qb, i);
+                        Logger.verbose(`variants count: ${variants.length}`);
+
+                        const variantsToIndex: Array<BulkOperation | VariantIndexItem> = [];
+                        const productsToIndex: Array<BulkOperation | ProductIndexItem> = [];
+
+                        // tslint:disable-next-line:prefer-for-of
+                        for (let j = 0; j < variants.length; j++) {
+                            const variant = variants[j];
+                            variantsInProduct.push(variant);
+                            variantsToIndex.push({index: {_id: variant.id.toString()}});
+                            variantsToIndex.push(this.createVariantIndexItem(variant));
+
+                            const nextVariant = variants[j + 1];
+                            if (nextVariant && nextVariant.productId !== variant.productId) {
+                                productsToIndex.push({index: {_id: variant.productId.toString()}});
+                                productsToIndex.push(this.createProductIndexItem(variantsInProduct) as any);
+                                variantsInProduct = [];
+                            }
+                        }
+                        await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, variantsToIndex);
+                        await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, productsToIndex);
+                        observer.next({
+                            total: count,
+                            completed: Math.min((i + 1) * batchSize, count),
+                            duration: +new Date() - timeStart,
+                        });
+                    }
+                    Logger.verbose(`Completed reindexing!`);
+                    observer.next({
+                        total: count,
+                        completed: count,
+                        duration: +new Date() - timeStart,
+                    });
+                    observer.complete();
+                })();
+            },
+        );
+    }
+
+    private async executeBulkOperations(indexName: string,
+                                        indexType: string,
+                                        operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem | ProductIndexItem>>) {
+        try {
+            const {body}: { body: BulkResponseBody; } = await this.client.bulk({
+                refresh: 'true',
+                index: this.options.indexPrefix + indexName,
+                type: indexType,
+                body: operations,
+            });
+
+            if (body.errors) {
+                Logger.error(`Some errors occurred running bulk operations on ${indexType}! Set logger to "debug" to print all errors.`, loggerCtx);
+                body.items.forEach(item => {
+                    if (item.index) {
+                        Logger.debug(JSON.stringify(item.index.error, null, 2), loggerCtx);
+                    }
+                    if (item.update) {
+                        Logger.debug(JSON.stringify(item.update.error, null, 2), loggerCtx);
+                    }
+                    if (item.delete) {
+                        Logger.debug(JSON.stringify(item.delete.error, null, 2), loggerCtx);
+                    }
+                });
+            } else {
+                Logger.verbose(`Executed ${body.items.length} bulk operations on ${indexType}`);
+            }
+            return body;
+        } catch (e) {
+            Logger.error(`Error when attempting to run bulk operations [${e.toString()}]`, loggerCtx);
+            Logger.error('Error details: ' + JSON.stringify(e.body.error, null, 2), loggerCtx);
+        }
+    }
+
+    private getSearchIndexQueryBuilder() {
+        const qb = this.connection.getRepository(ProductVariant).createQueryBuilder('variants');
+        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
+            relations: variantRelations,
+            order: {
+                productId: 'ASC',
+            },
+        });
+        FindOptionsUtils.joinEagerRelations(qb, qb.alias, this.connection.getMetadata(ProductVariant));
+        return qb;
+    }
+
+    private async getBatch(ctx: RequestContext, qb: SelectQueryBuilder<ProductVariant>, batchNumber: string | number): Promise<ProductVariant[]> {
+        const { batchSize } = this.options;
+        const i = Number.parseInt(batchNumber.toString(), 10);
+        const variants = await qb
+            .where('variants__product.deletedAt IS NULL')
+            .take(batchSize)
+            .skip(i * batchSize)
+            .getMany();
+
+        return this.hydrateVariants(ctx, variants);
+    }
+
+    private async getBatchByIds(ctx: RequestContext, ids: ID[]) {
+        const variants = await this.connection.getRepository(ProductVariant).findByIds(ids, {
+            relations: variantRelations,
+        });
+        return this.hydrateVariants(ctx, variants);
+    }
+    /**
+     * Given an array of ProductVariants, this method applies the correct taxes and translations.
+     */
+    private hydrateVariants(ctx: RequestContext, variants: ProductVariant[]): ProductVariant[] {
+        return variants
+            .map(v => this.productVariantService.applyChannelPriceAndTax(v, ctx))
+            .map(v => translateDeep(v, ctx.languageCode, ['product']));
+    }
+
+    private createVariantIndexItem(v: ProductVariant): VariantIndexItem {
+        return {
+            sku: v.sku,
+            slug: v.product.slug,
+            productId: v.product.id as string,
+            productName: v.product.name,
+            productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
+            productVariantId: v.id as string,
+            productVariantName: v.name,
+            productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
+            price: v.price,
+            priceWithTax: v.priceWithTax,
+            currencyCode: v.currencyCode,
+            description: v.product.description,
+            facetIds: this.getFacetIds([v]),
+            facetValueIds: this.getFacetValueIds([v]),
+            collectionIds: v.collections.map(c => c.id.toString()),
+            enabled: v.enabled && v.product.enabled,
+        };
+    }
+
+    private createProductIndexItem(variants: ProductVariant[]): ProductIndexItem {
+        const first = variants[0];
+        const prices = variants.map(v => v.price);
+        const pricesWithTax = variants.map(v => v.priceWithTax);
+        return {
+            sku: variants.map(v => v.sku),
+            slug: variants.map(v => v.product.slug),
+            productId: first.product.id,
+            productName: variants.map(v => v.product.name),
+            productPreview: first.product.featuredAsset ? first.product.featuredAsset.preview : '',
+            productVariantId: variants.map(v => v.id),
+            productVariantName: variants.map(v => v.name),
+            productVariantPreview: variants.filter(v => v.featuredAsset).map(v => v.featuredAsset.preview),
+            priceMin: Math.min(...prices),
+            priceMax: Math.max(...prices),
+            priceWithTaxMin: Math.min(...pricesWithTax),
+            priceWithTaxMax: Math.max(...pricesWithTax),
+            currencyCode: first.currencyCode,
+            description: first.product.description,
+            facetIds: this.getFacetIds(variants),
+            facetValueIds: this.getFacetValueIds(variants),
+            collectionIds: variants.reduce((ids, v) => [ ...ids, ...v.collections.map(c => c.id)], [] as ID[]),
+            enabled: first.product.enabled,
+        };
+    }
+
+    private getFacetIds(variants: ProductVariant[]): string[] {
+        const facetIds = (fv: FacetValue) => fv.facet.id.toString();
+        const variantFacetIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetIds)], [] as string[]);
+        const productFacetIds = variants[0].product.facetValues.map(facetIds);
+        return unique([...variantFacetIds, ...productFacetIds]);
+    }
+
+    private getFacetValueIds(variants: ProductVariant[]): string[] {
+        const facetValueIds = (fv: FacetValue) => fv.id.toString();
+        const variantFacetValueIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetValueIds)], [] as string[]);
+        const productFacetValueIds = variants[0].product.facetValues.map(facetValueIds);
+        return unique([...variantFacetValueIds, ...productFacetValueIds]);
+    }
+}

+ 72 - 3
packages/elasticsearch-plugin/src/plugin.ts

@@ -20,25 +20,86 @@ import { ELASTIC_SEARCH_CLIENT, ELASTIC_SEARCH_OPTIONS, loggerCtx } from './cons
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
 import { AdminElasticSearchResolver, ShopElasticSearchResolver } from './elasticsearch-resolver';
 import { ElasticsearchService } from './elasticsearch.service';
+import { ElasticsearchIndexerController } from './indexer.controller';
 
+/**
+ * @description
+ * Configuration options for the {@link ElasticsearchPlugin}.
+ *
+ * @docsCategory ElasticsearchPlugin
+ */
 export interface ElasticsearchOptions {
+    /**
+     * @description
+     * The host of the Elasticsearch server.
+     */
     host: string;
+    /**
+     * @description
+     * The port of the Elasticsearch server.
+     */
     port: number;
+    /**
+     * @description
+     * Prefix for the indices created by the plugin.
+     *
+     * @default
+     * 'vendure-'
+     */
     indexPrefix?: string;
+    /**
+     * @description
+     * Batch size for bulk operations (e.g. when rebuilding the indices)
+     *
+     * @default
+     * 2000
+     */
+    batchSize?: number;
 }
 
+/**
+ * @description
+ * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
+ * engine.
+ *
+ * ## Installation
+ *
+ * `yarn add \@vendure/elasticsearch-plugin`
+ *
+ * or
+ *
+ * `npm install \@vendure/elasticsearch-plugin`
+ *
+ * @example
+ * ```ts
+ * import { ElasticsearchPlugin } from '\@vendure/elasticsearch-plugin';
+ *
+ * const config: VendureConfig = {
+ *   // Add an instance of the plugin to the plugins array
+ *   plugins: [
+ *     new ElasticsearchPlugin({
+ *       host: 'http://localhost',
+ *       port: 9200,
+ *     }),
+ *   ],
+ * };
+ * ```
+ *
+ * @docsCategory ElasticsearchPlugin
+ */
 export class ElasticsearchPlugin implements VendurePlugin {
     private readonly options: Required<ElasticsearchOptions>;
     private readonly client: Client;
 
     constructor(options: ElasticsearchOptions) {
-        this.options = { indexPrefix: 'vendure-', ...options };
+        this.options = { indexPrefix: 'vendure-', batchSize: 2000, ...options };
         const { host, port } = options;
         this.client = new Client({
             node: `${host}:${port}`,
         });
     }
 
+    /** @internal */
     async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
         const elasticsearchService = inject(ElasticsearchService);
         const elasticsearchIndexService = inject(ElasticsearchIndexService);
@@ -57,11 +118,11 @@ export class ElasticsearchPlugin implements VendurePlugin {
         const eventBus = inject(EventBus);
         eventBus.subscribe(CatalogModificationEvent, event => {
             if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
-                return elasticsearchIndexService.updateProductOrVariant(event.ctx, event.entity);
+                return elasticsearchIndexService.updateProductOrVariant(event.ctx, event.entity).start();
             }
         });
         eventBus.subscribe(CollectionModificationEvent, event => {
-            return elasticsearchIndexService.updateVariantsById(event.ctx, event.productVariantIds);
+            return elasticsearchIndexService.updateVariantsById(event.ctx, event.productVariantIds).start();
         });
         eventBus.subscribe(TaxRateModificationEvent, event => {
             const defaultTaxZone = event.ctx.channel.defaultTaxZone;
@@ -97,6 +158,7 @@ export class ElasticsearchPlugin implements VendurePlugin {
         };
     }
 
+    /** @internal */
     defineProviders(): Provider[] {
         return [
             { provide: ElasticsearchIndexService, useClass: ElasticsearchIndexService },
@@ -108,4 +170,11 @@ export class ElasticsearchPlugin implements VendurePlugin {
             { provide: ELASTIC_SEARCH_CLIENT, useFactory: () => this.client },
         ];
     }
+
+    /** @internal */
+    defineWorkers(): Array<Type<any>> {
+        return [
+            ElasticsearchIndexerController,
+        ];
+    }
 }

+ 14 - 0
yarn.lock

@@ -1286,6 +1286,15 @@
   optionalDependencies:
     type-graphql "^0.17.3"
 
+"@nestjs/microservices@^6.3.1":
+  version "6.3.1"
+  resolved "https://registry.npmjs.org/@nestjs/microservices/-/microservices-6.3.1.tgz#b6d080afb61c1c8e50394eb73f5f40a5f606b09b"
+  integrity sha512-vujz9lczXBnkw8H7vY/7V6Je0zcCUvpbgwLNGa5Dm/+SzUKTdSk5qjGgt3TiczXZY2PSuOdsTY0qUFHYK7B5yA==
+  dependencies:
+    iterare "1.1.2"
+    json-socket "0.3.0"
+    optional "0.1.4"
+
 "@nestjs/platform-express@^6.3.1":
   version "6.3.1"
   resolved "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-6.3.1.tgz#8adb602a5bb9571b9d58646bd52141a26ea8ba3e"
@@ -6830,6 +6839,11 @@ json-schema@0.2.3:
   resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
   integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
 
+json-socket@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.npmjs.org/json-socket/-/json-socket-0.3.0.tgz#f4b953c685bb8e8bd0b72438f5208d9a0799ae07"
+  integrity sha512-jc8ZbUnYIWdxERFWQKVgwSLkGSe+kyzvmYxwNaRgx/c8NNyuHes4UHnPM3LUrAFXUx1BhNJ94n1h/KCRlbvV0g==
+
 json-stable-stringify-without-jsonify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"