Просмотр исходного кода

Merge branch 'master' into minor

Michael Bromley 1 год назад
Родитель
Сommit
6a11b302b1
40 измененных файлов с 387 добавлено и 130 удалено
  1. 19 0
      CHANGELOG.md
  2. 11 1
      docs/docs/guides/deployment/deploying-admin-ui.md
  3. 9 6
      docs/docs/guides/developer-guide/testing/index.md
  4. 11 14
      docs/docs/guides/how-to/publish-plugin/index.mdx
  5. 1 1
      docs/docs/guides/storefront/checkout-flow/index.mdx
  6. 1 1
      docs/docs/reference/typescript-api/orders/order-seller-strategy.md
  7. 24 0
      license/signatures/version1/cla.json
  8. 1 1
      packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.html
  9. 4 0
      packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts
  10. 26 10
      packages/core/e2e/custom-fields.e2e-spec.ts
  11. 5 11
      packages/core/e2e/draft-order.e2e-spec.ts
  12. 27 6
      packages/core/src/api/config/configure-graphql-module.ts
  13. 10 0
      packages/core/src/api/config/generate-resolvers.ts
  14. 6 2
      packages/core/src/api/resolvers/admin/order.resolver.ts
  15. 1 1
      packages/core/src/api/resolvers/entity/collection-entity.resolver.ts
  16. 1 1
      packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts
  17. 1 1
      packages/core/src/api/resolvers/entity/product-entity.resolver.ts
  18. 2 2
      packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts
  19. 13 4
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  20. 3 3
      packages/core/src/config/config.module.ts
  21. 2 1
      packages/core/src/config/default-config.ts
  22. 1 1
      packages/core/src/config/order/order-seller-strategy.ts
  23. 3 0
      packages/core/src/entity/order-line/order-line.entity.ts
  24. 6 0
      packages/core/src/entity/product-variant/product-variant.entity.ts
  25. 5 1
      packages/core/src/entity/product/product.entity.ts
  26. 16 3
      packages/core/src/entity/tax-rate/tax-rate.entity.ts
  27. 3 0
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  28. 7 2
      packages/core/src/service/helpers/active-order/active-order.service.ts
  29. 11 12
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  30. 30 26
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  31. 2 0
      packages/core/src/service/helpers/order-splitter/order-splitter.ts
  32. 2 0
      packages/core/src/service/services/asset.service.ts
  33. 1 0
      packages/core/src/service/services/order-testing.service.ts
  34. 85 11
      packages/core/src/service/services/order.service.ts
  35. 5 1
      packages/core/src/service/services/tax-rate.service.ts
  36. 17 2
      packages/core/src/testing/order-test-utils.ts
  37. 1 1
      packages/dev-server/index.ts
  38. 4 1
      packages/dev-server/load-testing/init-load-test.ts
  39. 1 1
      packages/dev-server/load-testing/run-load-test.ts
  40. 9 2
      packages/elasticsearch-plugin/src/indexing/indexer.controller.ts

+ 19 - 0
CHANGELOG.md

@@ -1,3 +1,22 @@
+## <small>3.0.2 (2024-09-10)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix removing coupon code from draft order ([04340f1](https://github.com/vendure-ecommerce/vendure/commit/04340f1)), closes [#2969](https://github.com/vendure-ecommerce/vendure/issues/2969)
+* **core** Fix search indexing issue when working with multiple channels (#3041) ([75ed6e1](https://github.com/vendure-ecommerce/vendure/commit/75ed6e1)), closes [#3041](https://github.com/vendure-ecommerce/vendure/issues/3041) [#3012](https://github.com/vendure-ecommerce/vendure/issues/3012)
+* **core** Prevent exposure of private custom fields via JSON type ([042abdb](https://github.com/vendure-ecommerce/vendure/commit/042abdb)), closes [#3049](https://github.com/vendure-ecommerce/vendure/issues/3049)
+* **elasticsearch-plugin** Fix search multichannel indexing issue ([9d6f9cf](https://github.com/vendure-ecommerce/vendure/commit/9d6f9cf)), closes [#3012](https://github.com/vendure-ecommerce/vendure/issues/3012)
+
+#### Perf
+
+* **core** Fix slow `order` query for postgres v16 ([1baa8e7](https://github.com/vendure-ecommerce/vendure/commit/1baa8e7)), closes [#3037](https://github.com/vendure-ecommerce/vendure/issues/3037)
+* **core** Omit ID encode/decode step if default EntityIdStrategy used ([ad30b55](https://github.com/vendure-ecommerce/vendure/commit/ad30b55))
+* **core** Optimizations to the addItemToOrder path ([70ad853](https://github.com/vendure-ecommerce/vendure/commit/70ad853))
+* **core** Optimize order operations ([e3d6c21](https://github.com/vendure-ecommerce/vendure/commit/e3d6c21))
+* **core** Optimize resolution of featuredAsset fields ([d7bd446](https://github.com/vendure-ecommerce/vendure/commit/d7bd446))
+* **core** Optimize setting active order on session ([c591432](https://github.com/vendure-ecommerce/vendure/commit/c591432))
+
 ## <small>3.0.1 (2024-08-21)</small>
 
 

+ 11 - 1
docs/docs/guides/deployment/deploying-admin-ui.md

@@ -3,8 +3,18 @@ title: "Deploying the Admin UI"
 showtoc: true
 ---
 
+## Compiling the Admin UI
 
-If you have customized the Admin UI with extensions, you should [compile your extensions ahead of time as part of the deployment process](/guides/extending-the-admin-ui/getting-started/#compiling-as-a-deployment-step).
+If you have customized the Admin UI with extensions, you should compile your custom Admin UI app ahead of time
+before deploying it. This will bundle the app into a set of static files which are then served by the AdminUiPlugin.
+
+- [Guide: Compiling the Admin UI as a deployment step](/guides/extending-the-admin-ui/getting-started/#compiling-as-a-deployment-step).
+
+:::warning
+
+It is not recommended to compile the Admin UI on the server at runtime, as this can be slow and resource-intensive.
+Instead, compile the Admin UI ahead of time and deploy the compiled assets, as covered in the guide linked above.
+:::
 
 ## Setting the API host & port
 

+ 9 - 6
docs/docs/guides/developer-guide/testing/index.md

@@ -195,21 +195,24 @@ All that's left is to run your tests to find out whether your code behaves as ex
 
 :::caution
 **Note:** When using **Vitest** with multiple test suites (multiple `.e2e-spec.ts` files), it will attempt to run them in parallel. If all the test servers are running
-on the same port (the default in the `testConfig` is `3050`), then this will cause a port conflict. To avoid this, you can manually set a unique port for each test suite:
+on the same port (the default in the `testConfig` is `3050`), then this will cause a port conflict. To avoid this, you can manually set a unique port for each test suite. Be aware that `mergeConfig` is used here:
 
 ```ts title="src/plugins/my-plugin/e2e/my-plugin.e2e-spec.ts"
 import { createTestEnvironment, testConfig } from '@vendure/testing';
+import { mergeConfig } from "@vendure/core";
 import { describe } from 'vitest';
 import { MyPlugin } from '../my-plugin.ts';
 
 describe('my plugin', () => {
 
-    const {server, adminClient, shopClient} = createTestEnvironment({
-        ...testConfig,
-        // highlight-next-line
-        port: 3051,
+    const {server, adminClient, shopClient} = createTestEnvironment(mergeConfig(testConfig, {
+        // highlight-start
+        apiOptions: {
+            port: 3051,
+        },
+        // highlight-end
         plugins: [MyPlugin],
-    });
+    }));
 
 });
 ```

+ 11 - 14
docs/docs/guides/how-to/publish-plugin/index.mdx

@@ -18,24 +18,21 @@ There are a couple of ways you can structure your plugin project:
 
 ### Repo structure
 
-#### Stand-alone repo
+We recommend that you use a "monorepo" structure to develop your plugins. This means that you have a single repository
+which contains all your plugins, each in its own subdirectory. This makes it easy to manage dependencies between plugins,
+and to share common code such as utility functions & dev tooling.
 
-You can have a single repository for your plugin. For this scenario you can use
-the [Vendure Plugin Template](https://github.com/vendure-ecommerce/plugin-template) as a starting point.
+Even if you only have a single plugin at the moment, it's a good idea to set up your project in this way from the start.
 
-**Pros**: simple to set up.
+To that end, we provide a [monorepo plugin starter template](https://github.com/vendure-ecommerce/plugin-template)
+which you can use as a starting point for your plugin development.
 
-**Cons**: if you have multiple plugins, you'll have multiple repositories to manage with duplicated setup and configuration.
+This starter template includes support for:
 
-#### Monorepo
-
-If you have multiple plugins, you can use a monorepo setup. Tools such as [Lerna](https://lerna.js.org/) or
-[Nx](https://nx.dev/) can help you manage multiple packages in a single repository. A good example of this approach
-can be found in the [Pinelab plugins repo](https://github.com/Pinelab-studio/pinelab-vendure-plugins).
-
-**Pros**: single repository to manage; can scale to any number of plugins; can share configuration and tooling.
-
-**Cons**: Initial setup is more complex.
+- Development & build scripts already set up
+- Admin UI extensions already configured
+- End-to-end testing infrastructure fully configured
+- Code generation for your schema extensions
 
 ### Plugin naming
 

+ 1 - 1
docs/docs/guides/storefront/checkout-flow/index.mdx

@@ -234,7 +234,7 @@ query GetShippingMethods {
 The results can then be displayed to the customer so they can choose the desired shipping method. If there is only a single
 result, then it can be automatically selected.
 
-The desired shipping method's id is the passed to the [`setOrderShippingMethod`](/reference/graphql-api/shop/mutations/#setordershippingmethod) mutation.
+The desired shipping method's id is then passed to the [`setOrderShippingMethod`](/reference/graphql-api/shop/mutations/#setordershippingmethod) mutation.
 
 ```graphql
 mutation SetShippingMethod($id: [ID!]!) {

+ 1 - 1
docs/docs/reference/typescript-api/orders/order-seller-strategy.md

@@ -50,7 +50,7 @@ interface OrderSellerStrategy extends InjectableStrategy {
 This method is called whenever a new OrderLine is added to the Order via the `addItemToOrder` mutation or the
 underlying `addItemToOrder()` method of the <a href='/reference/typescript-api/services/order-service#orderservice'>OrderService</a>.
 
-It should return the ID of the Channel to which this OrderLine will be assigned, which will be used to set the
+It should return the Channel to which this OrderLine will be assigned, which will be used to set the
 <a href='/reference/typescript-api/entities/order-line#orderline'>OrderLine</a> `sellerChannel` property.
 ### splitOrder
 

+ 24 - 0
license/signatures/version1/cla.json

@@ -111,6 +111,30 @@
       "created_at": "2024-08-13T15:47:42Z",
       "repoId": 136938012,
       "pullRequestNo": 3009
+    },
+    {
+      "name": "sphade",
+      "id": 85949974,
+      "comment_id": 2304127063,
+      "created_at": "2024-08-22T08:51:51Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3027
+    },
+    {
+      "name": "hsensh",
+      "id": 23084617,
+      "comment_id": 2324473830,
+      "created_at": "2024-09-02T11:14:15Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3041
+    },
+    {
+      "name": "neokim",
+      "id": 3601028,
+      "comment_id": 2339775064,
+      "created_at": "2024-09-10T06:26:52Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3052
     }
   ]
 }

+ 1 - 1
packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.html

@@ -11,7 +11,7 @@
     [typeahead]="couponCodeInput$"
     [formControl]="control"
     (add)="addCouponCode.emit($event.code)"
-    (remove)="removeCouponCode.emit($event.value?.code)"
+    (remove)="remove($event.code)"
 >
     <ng-template ng-option-tmp let-item="item">
         <div class="flex items-center">

+ 4 - 0
packages/admin-ui/src/lib/order/src/components/coupon-code-selector/coupon-code-selector.component.ts

@@ -65,4 +65,8 @@ export class CouponCodeSelectorComponent implements OnInit {
             this.control = new UntypedFormControl(this.couponCodes ?? []);
         }
     }
+
+    remove(code: string) {
+        this.removeCouponCode.emit(code);
+    }
 }

+ 26 - 10
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -183,6 +183,10 @@ const customConfig = mergeConfig(testConfig(), {
                 readonly: true,
             },
         ],
+        Collection: [
+            { name: 'secretKey1', type: 'string', defaultValue: '', public: false, internal: true },
+            { name: 'secretKey2', type: 'string', defaultValue: '', public: false, internal: false },
+        ],
         OrderLine: [{ name: 'validateInt', type: 'int', min: 0, max: 10 }],
     } as CustomFields,
 });
@@ -942,6 +946,20 @@ describe('Custom fields', () => {
                 `);
             }, 'Cannot query field "internalString" on type "ProductCustomFields"'),
         );
+
+        // https://github.com/vendure-ecommerce/vendure/issues/3049
+        it('does not leak private fields via JSON type', async () => {
+            const { collection } = await shopClient.query(gql`
+                query {
+                    collection(id: "T_1") {
+                        id
+                        customFields
+                    }
+                }
+            `);
+
+            expect(collection.customFields).toBe(null);
+        });
     });
 
     describe('sort & filter', () => {
@@ -1087,18 +1105,16 @@ describe('Custom fields', () => {
 
     describe('unique constraint', () => {
         it('setting unique value works', async () => {
-            const result = await adminClient.query(
-                gql`
-                    mutation {
-                        updateProduct(input: { id: "T_1", customFields: { uniqueString: "foo" } }) {
-                            id
-                            customFields {
-                                uniqueString
-                            }
+            const result = await adminClient.query(gql`
+                mutation {
+                    updateProduct(input: { id: "T_1", customFields: { uniqueString: "foo" } }) {
+                        id
+                        customFields {
+                            uniqueString
                         }
                     }
-                `,
-            );
+                }
+            `);
 
             expect(result.updateProduct.customFields.uniqueString).toBe('foo');
         });

+ 5 - 11
packages/core/e2e/draft-order.e2e-spec.ts

@@ -1,7 +1,6 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import {
-    DefaultLogger,
     DefaultOrderPlacedStrategy,
     mergeConfig,
     Order,
@@ -15,7 +14,7 @@ import path from 'path';
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { singleStageRefundablePaymentMethod } from './fixtures/test-payment-methods';
 import { ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments';
@@ -24,14 +23,12 @@ import {
     AddManualPaymentDocument,
     AdminTransitionDocument,
     CanceledOrderFragment,
-    GetOrderDocument,
     GetOrderPlacedAtDocument,
     OrderWithLinesFragment,
 } from './graphql/generated-e2e-admin-types';
 import {
     GetActiveCustomerOrdersQuery,
     TestOrderFragmentFragment,
-    TransitionToStateDocument,
     UpdatedOrderFragment,
 } from './graphql/generated-e2e-shop-types';
 import { CREATE_PROMOTION, GET_CUSTOMER_LIST } from './graphql/shared-definitions';
@@ -53,7 +50,6 @@ class TestOrderPlacedStrategy extends DefaultOrderPlacedStrategy {
 describe('Draft Orders resolver', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
-            logger: new DefaultLogger(),
             paymentOptions: {
                 paymentMethodHandlers: [singleStageRefundablePaymentMethod],
             },
@@ -125,9 +121,8 @@ describe('Draft Orders resolver', () => {
     });
 
     it('create draft order', async () => {
-        const { createDraftOrder } = await adminClient.query<Codegen.CreateDraftOrderMutation>(
-            CREATE_DRAFT_ORDER,
-        );
+        const { createDraftOrder } =
+            await adminClient.query<Codegen.CreateDraftOrderMutation>(CREATE_DRAFT_ORDER);
 
         expect(createDraftOrder.state).toBe('Draft');
         expect(createDraftOrder.active).toBe(false);
@@ -213,9 +208,8 @@ describe('Draft Orders resolver', () => {
     it('custom does not see draft orders in history', async () => {
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
 
-        const { activeCustomer } = await shopClient.query<GetActiveCustomerOrdersQuery>(
-            GET_ACTIVE_CUSTOMER_ORDERS,
-        );
+        const { activeCustomer } =
+            await shopClient.query<GetActiveCustomerOrdersQuery>(GET_ACTIVE_CUSTOMER_ORDERS);
 
         expect(activeCustomer?.orders.totalItems).toBe(0);
         expect(activeCustomer?.orders.items.length).toBe(0);

+ 27 - 6
packages/core/src/api/config/configure-graphql-module.ts

@@ -7,6 +7,7 @@ import path from 'path';
 
 import { ConfigModule } from '../../config/config.module';
 import { ConfigService } from '../../config/config.service';
+import { AutoIncrementIdStrategy, EntityIdStrategy, UuidIdStrategy } from '../../config/index';
 import { I18nModule } from '../../i18n/i18n.module';
 import { I18nService } from '../../i18n/i18n.service';
 import { getPluginAPIExtensions } from '../../plugin/plugin-metadata';
@@ -96,6 +97,24 @@ async function createGraphQLOptions(
         options.apiType,
         builtSchema,
     );
+
+    const apolloServerPlugins = [
+        new TranslateErrorsPlugin(i18nService),
+        new AssetInterceptorPlugin(configService),
+        ...configService.apiOptions.apolloServerPlugins,
+    ];
+    // We only need to add the IdCodecPlugin if the user has configured
+    // a non-default EntityIdStrategy. This is a performance optimization
+    // that prevents unnecessary traversal of each response when no
+    // actual encoding/decoding is taking place.
+    if (
+        !isUsingDefaultEntityIdStrategy(
+            configService.entityOptions.entityIdStrategy ?? configService.entityIdStrategy,
+        )
+    ) {
+        apolloServerPlugins.unshift(new IdCodecPlugin(idCodecService));
+    }
+
     return {
         path: '/' + options.apiPath,
         typeDefs: printSchema(builtSchema),
@@ -112,12 +131,7 @@ async function createGraphQLOptions(
         context: (req: any) => req,
         // This is handled by the Express cors plugin
         cors: false,
-        plugins: [
-            new IdCodecPlugin(idCodecService),
-            new TranslateErrorsPlugin(i18nService),
-            new AssetInterceptorPlugin(configService),
-            ...configService.apiOptions.apolloServerPlugins,
-        ],
+        plugins: apolloServerPlugins,
         validationRules: options.validationRules,
         introspection: configService.apiOptions.introspection ?? true,
     } as ApolloDriverConfig;
@@ -165,3 +179,10 @@ async function createGraphQLOptions(
         return schema;
     }
 }
+
+function isUsingDefaultEntityIdStrategy(entityIdStrategy: EntityIdStrategy<any>): boolean {
+    return (
+        entityIdStrategy.constructor === AutoIncrementIdStrategy ||
+        entityIdStrategy.constructor === UuidIdStrategy
+    );
+}

+ 10 - 0
packages/core/src/api/config/generate-resolvers.ts

@@ -255,6 +255,16 @@ function generateCustomFieldRelationResolvers(
                 } as any;
             }
         }
+        const allCustomFieldsAreNonPublic =
+            customFields.length && customFields.every(f => f.public === false || f.internal === true);
+        if (allCustomFieldsAreNonPublic) {
+            // When an entity has only non-public custom fields, the GraphQL type used for the
+            // customFields field is `JSON`. This type will simply return the full object, which
+            // will cause a leak of private data unless we force a `null` return value in the case
+            // that there are no public fields.
+            // See https://github.com/vendure-ecommerce/vendure/issues/3049
+            shopResolvers[entityName] = { customFields: () => null };
+        }
     }
     return { adminResolvers, shopResolvers };
 }

+ 6 - 2
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -46,7 +46,10 @@ import { Transaction } from '../../decorators/transaction.decorator';
 
 @Resolver()
 export class OrderResolver {
-    constructor(private orderService: OrderService, private connection: TransactionalConnection) {}
+    constructor(
+        private orderService: OrderService,
+        private connection: TransactionalConnection,
+    ) {}
 
     @Query()
     @Allow(Permission.ReadOrder)
@@ -63,7 +66,8 @@ export class OrderResolver {
     async order(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryOrderArgs,
-        @Relations(Order) relations: RelationPaths<Order>,
+        @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] })
+        relations: RelationPaths<Order>,
     ): Promise<Order | undefined> {
         return this.orderService.findOne(ctx, args.id, relations);
     }

+ 1 - 1
packages/core/src/api/resolvers/entity/collection-entity.resolver.ts

@@ -116,7 +116,7 @@ export class CollectionEntityResolver {
         @Ctx() ctx: RequestContext,
         @Parent() collection: Collection,
     ): Promise<Asset | undefined> {
-        if (collection.featuredAsset) {
+        if (collection.featuredAsset !== undefined) {
             return collection.featuredAsset;
         }
         return this.assetService.getFeaturedAsset(ctx, collection);

+ 1 - 1
packages/core/src/api/resolvers/entity/order-line-entity.resolver.ts

@@ -31,7 +31,7 @@ export class OrderLineEntityResolver {
         @Ctx() ctx: RequestContext,
         @Parent() orderLine: OrderLine,
     ): Promise<Asset | undefined> {
-        if (orderLine.featuredAsset) {
+        if (orderLine.featuredAsset !== undefined) {
             return orderLine.featuredAsset;
         } else {
             return this.assetService.getFeaturedAsset(ctx, orderLine);

+ 1 - 1
packages/core/src/api/resolvers/entity/product-entity.resolver.ts

@@ -129,7 +129,7 @@ export class ProductEntityResolver {
 
     @ResolveField()
     async featuredAsset(@Ctx() ctx: RequestContext, @Parent() product: Product): Promise<Asset | undefined> {
-        if (product.featuredAsset) {
+        if (product.featuredAsset !== undefined) {
             return product.featuredAsset;
         }
         return this.assetService.getFeaturedAsset(ctx, product);

+ 2 - 2
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -14,9 +14,9 @@ import { Asset, Channel, FacetValue, Product, ProductOption, StockLevel, TaxRate
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
 import { StockMovement } from '../../../entity/stock-movement/stock-movement.entity';
 import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
-import { StockLevelService } from '../../../service/services/stock-level.service';
 import { AssetService } from '../../../service/services/asset.service';
 import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { StockLevelService } from '../../../service/services/stock-level.service';
 import { StockMovementService } from '../../../service/services/stock-movement.service';
 import { ApiType } from '../../common/get-api-type';
 import { RequestContext } from '../../common/request-context';
@@ -103,7 +103,7 @@ export class ProductVariantEntityResolver {
         @Ctx() ctx: RequestContext,
         @Parent() productVariant: ProductVariant,
     ): Promise<Asset | undefined> {
-        if (productVariant.featuredAsset) {
+        if (productVariant.featuredAsset !== undefined) {
             return productVariant.featuredAsset;
         }
         return this.assetService.getFeaturedAsset(ctx, productVariant);

+ 13 - 4
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -76,7 +76,8 @@ export class ShopOrderResolver {
     async order(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryOrderArgs,
-        @Relations(Order) relations: RelationPaths<Order>,
+        @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] })
+        relations: RelationPaths<Order>,
     ): Promise<Order | undefined> {
         const requiredRelations: RelationPaths<Order> = ['customer', 'customer.user'];
         const order = await this.orderService.findOne(
@@ -98,7 +99,8 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async activeOrder(
         @Ctx() ctx: RequestContext,
-        @Relations(Order) relations: RelationPaths<Order>,
+        @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] })
+        relations: RelationPaths<Order>,
         @Args() args: ActiveOrderArgs,
     ): Promise<Order | undefined> {
         if (ctx.authorizedAsOwnerOnly) {
@@ -107,7 +109,7 @@ export class ShopOrderResolver {
                 args[ACTIVE_ORDER_INPUT_FIELD_NAME],
             );
             if (sessionOrder) {
-                return this.orderService.findOne(ctx, sessionOrder.id);
+                return this.orderService.findOne(ctx, sessionOrder.id, relations);
             } else {
                 return;
             }
@@ -119,7 +121,8 @@ export class ShopOrderResolver {
     async orderByCode(
         @Ctx() ctx: RequestContext,
         @Args() args: QueryOrderByCodeArgs,
-        @Relations(Order) relations: RelationPaths<Order>,
+        @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] })
+        relations: RelationPaths<Order>,
     ): Promise<Order | undefined> {
         if (ctx.authorizedAsOwnerOnly) {
             const requiredRelations: RelationPaths<Order> = ['customer', 'customer.user'];
@@ -294,6 +297,8 @@ export class ShopOrderResolver {
     async addItemToOrder(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationAddItemToOrderArgs & ActiveOrderArgs,
+        @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] })
+        relations: RelationPaths<Order>,
     ): Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>> {
         const order = await this.activeOrderService.getActiveOrder(
             ctx,
@@ -306,6 +311,7 @@ export class ShopOrderResolver {
             args.productVariantId,
             args.quantity,
             (args as any).customFields,
+            relations,
         );
     }
 
@@ -315,6 +321,8 @@ export class ShopOrderResolver {
     async adjustOrderLine(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationAdjustOrderLineArgs & ActiveOrderArgs,
+        @Relations({ entity: Order, omit: ['aggregateOrder', 'sellerOrders'] })
+        relations: RelationPaths<Order>,
     ): Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>> {
         if (args.quantity === 0) {
             return this.removeOrderLine(ctx, { orderLineId: args.orderLineId });
@@ -330,6 +338,7 @@ export class ShopOrderResolver {
             args.orderLineId,
             args.quantity,
             (args as any).customFields,
+            relations,
         );
     }
 

+ 3 - 3
packages/core/src/config/config.module.ts

@@ -106,10 +106,11 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
         } = this.configService.shippingOptions;
         const { customPaymentProcess, process: paymentProcess } = this.configService.paymentOptions;
         const { entityIdStrategy: entityIdStrategyDeprecated } = this.configService;
-        const { entityIdStrategy } = this.configService.entityOptions;
+        const { entityIdStrategy: entityIdStrategyCurrent } = this.configService.entityOptions;
         const { healthChecks, errorHandlers } = this.configService.systemOptions;
         const { assetImportStrategy } = this.configService.importExportOptions;
         const { refundProcess: refundProcess } = this.configService.paymentOptions;
+        const entityIdStrategy = entityIdStrategyCurrent ?? entityIdStrategyDeprecated;
         return [
             ...adminAuthenticationStrategy,
             ...shopAuthenticationStrategy,
@@ -127,8 +128,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             checkoutMergeStrategy,
             orderCodeStrategy,
             orderByCodeAccessStrategy,
-            entityIdStrategyDeprecated,
-            ...[entityIdStrategy].filter(notNullOrUndefined),
+            entityIdStrategy,
             productVariantPriceCalculationStrategy,
             productVariantPriceUpdateStrategy,
             orderItemPriceCalculationStrategy,

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

@@ -84,6 +84,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         introspection: true,
         apolloServerPlugins: [],
     },
+    entityIdStrategy: new AutoIncrementIdStrategy(),
     authOptions: {
         disableAuth: false,
         tokenMethod: 'cookie',
@@ -118,7 +119,6 @@ export const defaultConfig: RuntimeVendureConfig = {
         stockDisplayStrategy: new DefaultStockDisplayStrategy(),
         stockLocationStrategy: new DefaultStockLocationStrategy(),
     },
-    entityIdStrategy: new AutoIncrementIdStrategy(),
     assetOptions: {
         assetNamingStrategy: new DefaultAssetNamingStrategy(),
         assetStorageStrategy: new NoAssetStorageStrategy(),
@@ -131,6 +131,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         type: 'mysql',
     },
     entityOptions: {
+        entityIdStrategy: new AutoIncrementIdStrategy(),
         moneyStrategy: new DefaultMoneyStrategy(),
         entityDuplicators: defaultEntityDuplicators,
         channelCacheTtl: 30000,

+ 1 - 1
packages/core/src/config/order/order-seller-strategy.ts

@@ -46,7 +46,7 @@ export interface OrderSellerStrategy extends InjectableStrategy {
      * This method is called whenever a new OrderLine is added to the Order via the `addItemToOrder` mutation or the
      * underlying `addItemToOrder()` method of the {@link OrderService}.
      *
-     * It should return the ID of the Channel to which this OrderLine will be assigned, which will be used to set the
+     * It should return the Channel to which this OrderLine will be assigned, which will be used to set the
      * {@link OrderLine} `sellerChannel` property.
      */
     setOrderLineSellerChannel?(

+ 3 - 0
packages/core/src/entity/order-line/order-line.entity.ts

@@ -76,6 +76,9 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
     @ManyToOne(type => TaxCategory)
     taxCategory: TaxCategory;
 
+    @EntityId({ nullable: true })
+    taxCategoryId: ID;
+
     @Index()
     @ManyToOne(type => Asset, asset => asset.featuredInVariants, { onDelete: 'SET NULL' })
     featuredAsset: Asset;

+ 6 - 0
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -107,6 +107,9 @@ export class ProductVariant
     @ManyToOne(type => Asset, asset => asset.featuredInVariants, { onDelete: 'SET NULL' })
     featuredAsset: Asset;
 
+    @EntityId({ nullable: true })
+    featuredAssetId: ID;
+
     @OneToMany(type => ProductVariantAsset, productVariantAsset => productVariantAsset.productVariant, {
         onDelete: 'SET NULL',
     })
@@ -116,6 +119,9 @@ export class ProductVariant
     @ManyToOne(type => TaxCategory, taxCategory => taxCategory.productVariants)
     taxCategory: TaxCategory;
 
+    @EntityId({ nullable: true })
+    taxCategoryId: ID;
+
     @OneToMany(type => ProductVariantPrice, price => price.variant, { eager: true })
     productVariantPrices: ProductVariantPrice[];
 

+ 5 - 1
packages/core/src/entity/product/product.entity.ts

@@ -1,4 +1,4 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
@@ -8,6 +8,7 @@ import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 import { CustomProductFields } from '../custom-entity-fields';
+import { EntityId } from '../entity-id.decorator';
 import { FacetValue } from '../facet-value/facet-value.entity';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
@@ -47,6 +48,9 @@ export class Product
     @ManyToOne(type => Asset, asset => asset.featuredInProducts, { onDelete: 'SET NULL' })
     featuredAsset: Asset;
 
+    @EntityId({ nullable: true })
+    featuredAssetId: ID;
+
     @OneToMany(type => ProductAsset, productAsset => productAsset.product)
     assets: ProductAsset[];
 

+ 16 - 3
packages/core/src/entity/tax-rate/tax-rate.entity.ts

@@ -1,5 +1,5 @@
 import { TaxLine } from '@vendure/common/lib/generated-types';
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, ManyToOne } from 'typeorm';
 
 import { grossPriceOf, netPriceOf, taxComponentOf, taxPayableOn } from '../../common/tax-utils';
@@ -8,6 +8,7 @@ import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { VendureEntity } from '../base/base.entity';
 import { CustomTaxRateFields } from '../custom-entity-fields';
 import { CustomerGroup } from '../customer-group/customer-group.entity';
+import { EntityId } from '../entity-id.decorator';
 import { TaxCategory } from '../tax-category/tax-category.entity';
 import { DecimalTransformer } from '../value-transformers';
 import { Zone } from '../zone/zone.entity';
@@ -38,10 +39,16 @@ export class TaxRate extends VendureEntity implements HasCustomFields {
     @ManyToOne(type => TaxCategory, taxCategory => taxCategory.taxRates)
     category: TaxCategory;
 
+    @EntityId({ nullable: true })
+    categoryId: ID;
+
     @Index()
     @ManyToOne(type => Zone, zone => zone.taxRates)
     zone: Zone;
 
+    @EntityId({ nullable: true })
+    zoneId: ID;
+
     @Index()
     @ManyToOne(type => CustomerGroup, customerGroup => customerGroup.taxRates, { nullable: true })
     customerGroup?: CustomerGroup;
@@ -84,7 +91,13 @@ export class TaxRate extends VendureEntity implements HasCustomFields {
         };
     }
 
-    test(zone: Zone, taxCategory: TaxCategory): boolean {
-        return idsAreEqual(taxCategory.id, this.category.id) && idsAreEqual(zone.id, this.zone.id);
+    test(zone: Zone | ID, taxCategory: TaxCategory | ID): boolean {
+        const taxCategoryId = this.isId(taxCategory) ? taxCategory : taxCategory.id;
+        const zoneId = this.isId(zone) ? zone : zone.id;
+        return idsAreEqual(taxCategoryId, this.categoryId) && idsAreEqual(zoneId, this.zoneId);
+    }
+
+    private isId<T>(entityOrId: T | ID): entityOrId is ID {
+        return typeof entityOrId === 'string' || typeof entityOrId === 'number';
     }
 }

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

@@ -405,7 +405,9 @@ export class IndexerController {
         await this.removeSyntheticVariants(ctx, variants);
         const productMap = new Map<ID, Product>();
 
+        const originalChannel = ctx.channel;
         for (const variant of variants) {
+            ctx.setChannel(originalChannel);
             let product = productMap.get(variant.productId);
             if (!product) {
                 product = await this.getProductInChannelQueryBuilder(ctx, variant.productId, ctx.channel);
@@ -496,6 +498,7 @@ export class IndexerController {
                 }
             }
         }
+        ctx.setChannel(originalChannel);
 
         await this.queue.push(() =>
             this.connection.getRepository(ctx, SearchIndexItem).save(items, { chunk: 2500 }),

+ 7 - 2
packages/core/src/service/helpers/active-order/active-order.service.ts

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { InternalServerError, UserInputError } from '../../../common/error/errors';
+import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { Order } from '../../../entity/order/order.entity';
@@ -90,7 +91,7 @@ export class ActiveOrderService {
         input: { [strategyName: string]: Record<string, any> | undefined } | undefined,
         createIfNotExists = false,
     ): Promise<Order | undefined> {
-        let order: any;
+        let order: Order | undefined;
         if (!order) {
             const { activeOrderStrategy } = this.configService.orderOptions;
             const strategyArray = Array.isArray(activeOrderStrategy)
@@ -119,7 +120,11 @@ export class ActiveOrderService {
             }
 
             if (order && ctx.session) {
-                await this.sessionService.setActiveOrder(ctx, ctx.session, order);
+                const orderAlreadyAssignedToSession =
+                    ctx.session.activeOrderId && idsAreEqual(ctx.session.activeOrderId, order.id);
+                if (!orderAlreadyAssignedToSession) {
+                    await this.sessionService.setActiveOrder(ctx, ctx.session, order);
+                }
             }
         }
         return order || undefined;

+ 11 - 12
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -1,6 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { filterAsync } from '@vendure/common/lib/filter-async';
 import { AdjustmentType } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { RequestContextCacheService } from '../../../cache/request-context-cache.service';
@@ -8,7 +9,7 @@ import { CacheKey } from '../../../common/constants';
 import { InternalServerError } from '../../../common/error/errors';
 import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
-import { OrderLine, TaxCategory, TaxRate } from '../../../entity';
+import { OrderLine, TaxRate } from '../../../entity';
 import { Order } from '../../../entity/order/order.entity';
 import { Promotion } from '../../../entity/promotion/promotion.entity';
 import { Zone } from '../../../entity/zone/zone.entity';
@@ -76,7 +77,6 @@ export class OrderCalculator {
                 ctx,
                 order,
                 updatedOrderLine,
-                activeTaxZone,
                 this.createTaxRateGetter(ctx, activeTaxZone),
             );
         }
@@ -113,7 +113,7 @@ export class OrderCalculator {
     private async applyTaxes(ctx: RequestContext, order: Order, activeZone: Zone) {
         const getTaxRate = this.createTaxRateGetter(ctx, activeZone);
         for (const line of order.lines) {
-            await this.applyTaxesToOrderLine(ctx, order, line, activeZone, getTaxRate);
+            await this.applyTaxesToOrderLine(ctx, order, line, getTaxRate);
         }
         this.calculateOrderTotals(order);
     }
@@ -126,10 +126,9 @@ export class OrderCalculator {
         ctx: RequestContext,
         order: Order,
         line: OrderLine,
-        activeZone: Zone,
-        getTaxRate: (taxCategory: TaxCategory) => Promise<TaxRate>,
+        getTaxRate: (taxCategoryId: ID) => Promise<TaxRate>,
     ) {
-        const applicableTaxRate = await getTaxRate(line.taxCategory);
+        const applicableTaxRate = await getTaxRate(line.taxCategoryId);
         const { taxLineCalculationStrategy } = this.configService.taxOptions;
         line.taxLines = await taxLineCalculationStrategy.calculate({
             ctx,
@@ -147,16 +146,16 @@ export class OrderCalculator {
     private createTaxRateGetter(
         ctx: RequestContext,
         activeZone: Zone,
-    ): (taxCategory: TaxCategory) => Promise<TaxRate> {
-        const taxRateCache = new Map<TaxCategory, TaxRate>();
+    ): (taxCategoryId: ID) => Promise<TaxRate> {
+        const taxRateCache = new Map<ID, TaxRate>();
 
-        return async (taxCategory: TaxCategory): Promise<TaxRate> => {
-            const cached = taxRateCache.get(taxCategory);
+        return async (taxCategoryId: ID): Promise<TaxRate> => {
+            const cached = taxRateCache.get(taxCategoryId);
             if (cached) {
                 return cached;
             }
-            const rate = await this.taxRateService.getApplicableTaxRate(ctx, activeZone, taxCategory);
-            taxRateCache.set(taxCategory, rate);
+            const rate = await this.taxRateService.getApplicableTaxRate(ctx, activeZone, taxCategoryId);
+            taxRateCache.set(taxCategoryId, rate);
             return rate;
         };
     }

+ 30 - 26
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -10,6 +10,7 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { getGraphQlInputName, summate } from '@vendure/common/lib/shared-utils';
+import { IsNull } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { isGraphQlErrorResult, JustErrorResults } from '../../../common/error/error-result';
@@ -140,7 +141,7 @@ export class OrderModifier {
     ): Promise<OrderLine | undefined> {
         for (const line of order.lines) {
             const match =
-                idsAreEqual(line.productVariant.id, productVariantId) &&
+                idsAreEqual(line.productVariantId, productVariantId) &&
                 (await this.customFieldsAreEqual(ctx, line, customFields, line.customFields));
             if (match) {
                 return line;
@@ -164,12 +165,13 @@ export class OrderModifier {
             return existingOrderLine;
         }
 
-        const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId);
+        const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId, order);
+        const featuredAssetId = productVariant.featuredAssetId ?? productVariant.featuredAssetId;
         const orderLine = await this.connection.getRepository(ctx, OrderLine).save(
             new OrderLine({
                 productVariant,
                 taxCategory: productVariant.taxCategory,
-                featuredAsset: productVariant.featuredAsset ?? productVariant.product.featuredAsset,
+                featuredAsset: featuredAssetId ? { id: featuredAssetId } : undefined,
                 listPrice: productVariant.listPrice,
                 listPriceIncludesTax: productVariant.listPriceIncludesTax,
                 adjustments: [],
@@ -189,26 +191,15 @@ export class OrderModifier {
                 .set(orderLine.sellerChannel);
         }
         await this.customFieldRelationService.updateRelations(ctx, OrderLine, { customFields }, orderLine);
-        const lineWithRelations = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLine.id, {
-            relations: [
-                'taxCategory',
-                'productVariant',
-                'productVariant.productVariantPrices',
-                'productVariant.taxCategory',
-            ],
-        });
-        lineWithRelations.productVariant = this.translator.translate(
-            await this.productVariantService.applyChannelPriceAndTax(
-                lineWithRelations.productVariant,
-                ctx,
-                order,
-            ),
-            ctx,
-        );
-        order.lines.push(lineWithRelations);
-        await this.connection.getRepository(ctx, Order).save(order, { reload: false });
-        await this.eventBus.publish(new OrderLineEvent(ctx, order, lineWithRelations, 'created'));
-        return lineWithRelations;
+        order.lines.push(orderLine);
+        await this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder()
+            .relation('lines')
+            .of(order)
+            .add(orderLine);
+        await this.eventBus.publish(new OrderLineEvent(ctx, order, orderLine, 'created'));
+        return orderLine;
     }
 
     /**
@@ -896,11 +887,24 @@ export class OrderModifier {
     private async getProductVariantOrThrow(
         ctx: RequestContext,
         productVariantId: ID,
+        order: Order,
     ): Promise<ProductVariant> {
-        const productVariant = await this.productVariantService.findOne(ctx, productVariantId);
-        if (!productVariant) {
+        const variant = await this.connection.findOneInChannel(
+            ctx,
+            ProductVariant,
+            productVariantId,
+            ctx.channelId,
+            {
+                relations: ['product', 'productVariantPrices', 'taxCategory'],
+                loadEagerRelations: false,
+                where: { deletedAt: IsNull() },
+            },
+        );
+
+        if (variant) {
+            return await this.productVariantService.applyChannelPriceAndTax(variant, ctx, order);
+        } else {
             throw new EntityNotFoundError('ProductVariant', productVariantId);
         }
-        return productVariant;
     }
 }

+ 2 - 0
packages/core/src/service/helpers/order-splitter/order-splitter.ts

@@ -90,7 +90,9 @@ export class OrderSplitter {
                 ...pick(line, [
                     'quantity',
                     'productVariant',
+                    'productVariantId',
                     'taxCategory',
+                    'taxCategoryId',
                     'featuredAsset',
                     'shippingLine',
                     'shippingLineId',

+ 2 - 0
packages/core/src/service/services/asset.service.ts

@@ -168,6 +168,7 @@ export class AssetService {
                 ctx.channelId,
                 {
                     relations: ['featuredAsset'],
+                    loadEagerRelations: false,
                 },
             );
         } else {
@@ -178,6 +179,7 @@ export class AssetService {
                     relations: {
                         featuredAsset: true,
                     },
+                    loadEagerRelations: false,
                     // TODO: satisfies
                 } as FindOneOptions<T>)
                 .then(result => result ?? undefined);

+ 1 - 0
packages/core/src/service/services/order-testing.service.ts

@@ -130,6 +130,7 @@ export class OrderTestingService {
                 taxLines: [],
                 quantity: line.quantity,
                 taxCategory: productVariant.taxCategory,
+                taxCategoryId: productVariant.taxCategoryId,
             });
             mockOrder.lines.push(orderLine);
 

+ 85 - 11
packages/core/src/service/services/order.service.ts

@@ -53,7 +53,6 @@ import {
     CancelPaymentError,
     EmptyOrderLineSelectionError,
     FulfillmentStateTransitionError,
-    RefundStateTransitionError,
     InsufficientStockOnHandError,
     ItemsAlreadyFulfilledError,
     ManualPaymentStateError,
@@ -61,6 +60,7 @@ import {
     NothingToRefundError,
     PaymentOrderMismatchError,
     RefundOrderStateError,
+    RefundStateTransitionError,
     SettlePaymentError,
 } from '../../common/error/generated-graphql-admin-errors';
 import {
@@ -489,7 +489,7 @@ export class OrderService {
      * @since 2.2.0
      */
     async updateOrderCustomer(ctx: RequestContext, { customerId, orderId, note }: SetOrderCustomerInput) {
-        const order = await this.getOrderOrThrow(ctx, orderId);
+        const order = await this.getOrderOrThrow(ctx, orderId, ['channels', 'customer']);
         const currentCustomer = order.customer;
         if (currentCustomer?.id === customerId) {
             // No change in customer, so just return the order as-is
@@ -539,6 +539,7 @@ export class OrderService {
         productVariantId: ID,
         quantity: number,
         customFields?: { [key: string]: any },
+        relations?: RelationPaths<Order>,
     ): Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>> {
         const order = await this.getOrderOrThrow(ctx, orderId);
         const existingOrderLine = await this.orderModifier.getExistingOrderLine(
@@ -561,6 +562,7 @@ export class OrderService {
                 enabled: true,
                 deletedAt: IsNull(),
             },
+            loadEagerRelations: false,
         });
         if (variant.product.enabled === false) {
             throw new EntityNotFoundError('ProductVariant', productVariantId);
@@ -596,7 +598,7 @@ export class OrderService {
             await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, correctedQuantity, order);
         }
         const quantityWasAdjustedDown = correctedQuantity < quantity;
-        const updatedOrder = await this.applyPriceAdjustments(ctx, order, [orderLine]);
+        const updatedOrder = await this.applyPriceAdjustments(ctx, order, [orderLine], relations);
         if (quantityWasAdjustedDown) {
             return new InsufficientStockError({ quantityAvailable: correctedQuantity, order: updatedOrder });
         } else {
@@ -614,6 +616,7 @@ export class OrderService {
         orderLineId: ID,
         quantity: number,
         customFields?: { [key: string]: any },
+        relations?: RelationPaths<Order>,
     ): Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>> {
         const order = await this.getOrderOrThrow(ctx, orderId);
         const orderLine = this.getOrderLineOrThrow(order, orderLineId);
@@ -660,7 +663,7 @@ export class OrderService {
             await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, correctedQuantity, order);
         }
         const quantityWasAdjustedDown = correctedQuantity < quantity;
-        const updatedOrder = await this.applyPriceAdjustments(ctx, order, updatedOrderLines);
+        const updatedOrder = await this.applyPriceAdjustments(ctx, order, updatedOrderLines, relations);
         if (quantityWasAdjustedDown) {
             return new InsufficientStockError({ quantityAvailable: correctedQuantity, order: updatedOrder });
         } else {
@@ -1663,8 +1666,23 @@ export class OrderService {
         return order;
     }
 
-    private async getOrderOrThrow(ctx: RequestContext, orderId: ID): Promise<Order> {
-        const order = await this.findOne(ctx, orderId);
+    private async getOrderOrThrow(
+        ctx: RequestContext,
+        orderId: ID,
+        relations?: RelationPaths<Order>,
+    ): Promise<Order> {
+        const order = await this.findOne(
+            ctx,
+            orderId,
+            relations ?? [
+                'lines',
+                'lines.productVariant',
+                'lines.productVariant.productVariantPrices',
+                'shippingLines',
+                'surcharges',
+                'customer',
+            ],
+        );
         if (!order) {
             throw new EntityNotFoundError('Order', orderId);
         }
@@ -1730,6 +1748,7 @@ export class OrderService {
         ctx: RequestContext,
         order: Order,
         updatedOrderLines?: OrderLine[],
+        relations?: RelationPaths<Order>,
     ): Promise<Order> {
         const promotions = await this.promotionService.getActivePromotionsInChannel(ctx);
         const activePromotionsPre = await this.promotionService.getActivePromotionsOnOrder(ctx, order.id);
@@ -1776,22 +1795,77 @@ export class OrderService {
             }
         }
 
+        // Get the shipping line IDs before doing the order calculation
+        // step, which can in some cases change the applied shipping lines.
+        const shippingLineIdsPre = order.shippingLines.map(l => l.id);
+
         const updatedOrder = await this.orderCalculator.applyPriceAdjustments(
             ctx,
             order,
             promotions,
             updatedOrderLines ?? [],
         );
+
+        const shippingLineIdsPost = updatedOrder.shippingLines.map(l => l.id);
+        await this.applyChangesToShippingLines(ctx, updatedOrder, shippingLineIdsPre, shippingLineIdsPost);
+
+        // Explicitly omit the shippingAddress and billingAddress properties to avoid
+        // a race condition where changing one or the other in parallel can
+        // overwrite the other's changes. The other omissions prevent the save
+        // function from doing more work than necessary.
         await this.connection
             .getRepository(ctx, Order)
-            // Explicitly omit the shippingAddress and billingAddress properties to avoid
-            // a race condition where changing one or the other in parallel can
-            // overwrite the other's changes.
-            .save(omit(updatedOrder, ['shippingAddress', 'billingAddress']), { reload: false });
+            .save(
+                omit(updatedOrder, [
+                    'shippingAddress',
+                    'billingAddress',
+                    'lines',
+                    'shippingLines',
+                    'aggregateOrder',
+                    'sellerOrders',
+                    'customer',
+                    'modifications',
+                ]),
+                {
+                    reload: false,
+                },
+            );
         await this.connection.getRepository(ctx, OrderLine).save(updatedOrder.lines, { reload: false });
         await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false });
         await this.promotionService.runPromotionSideEffects(ctx, order, activePromotionsPre);
 
-        return assertFound(this.findOne(ctx, order.id));
+        return assertFound(this.findOne(ctx, order.id, relations));
+    }
+
+    /**
+     * Applies changes to the shipping lines of an order, adding or removing the relations
+     * in the database.
+     */
+    private async applyChangesToShippingLines(
+        ctx: RequestContext,
+        order: Order,
+        shippingLineIdsPre: ID[],
+        shippingLineIdsPost: ID[],
+    ) {
+        const removedShippingLineIds = shippingLineIdsPre.filter(id => !shippingLineIdsPost.includes(id));
+        const newlyAddedShippingLineIds = shippingLineIdsPost.filter(id => !shippingLineIdsPre.includes(id));
+
+        for (const idToRemove of removedShippingLineIds) {
+            await this.connection
+                .getRepository(ctx, Order)
+                .createQueryBuilder()
+                .relation('shippingLines')
+                .of(order)
+                .remove(idToRemove);
+        }
+
+        for (const idToAdd of newlyAddedShippingLineIds) {
+            await this.connection
+                .getRepository(ctx, Order)
+                .createQueryBuilder()
+                .relation('shippingLines')
+                .of(order)
+                .add(idToAdd);
+        }
     }
 }

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

@@ -164,7 +164,11 @@ export class TaxRateService {
      * Returns the applicable TaxRate based on the specified Zone and TaxCategory. Used when calculating Order
      * prices.
      */
-    async getApplicableTaxRate(ctx: RequestContext, zone: Zone, taxCategory: TaxCategory): Promise<TaxRate> {
+    async getApplicableTaxRate(
+        ctx: RequestContext,
+        zone: Zone | ID,
+        taxCategory: TaxCategory | ID,
+    ): Promise<TaxRate> {
         const rate = (await this.getActiveTaxRates(ctx)).find(r => r.test(zone, taxCategory));
         return rate || this.defaultTaxRate;
     }

+ 17 - 2
packages/core/src/testing/order-test-utils.ts

@@ -76,7 +76,9 @@ export const taxRateDefaultStandard = new TaxRate({
     value: 20,
     enabled: true,
     zone: zoneDefault,
+    zoneId: zoneDefault.id,
     category: taxCategoryStandard,
+    categoryId: taxCategoryStandard.id,
 });
 export const taxRateDefaultReduced = new TaxRate({
     id: 'taxRateDefaultReduced',
@@ -84,7 +86,9 @@ export const taxRateDefaultReduced = new TaxRate({
     value: 10,
     enabled: true,
     zone: zoneDefault,
+    zoneId: zoneDefault.id,
     category: taxCategoryReduced,
+    categoryId: taxCategoryReduced.id,
 });
 export const taxRateDefaultZero = new TaxRate({
     id: 'taxRateDefaultZero',
@@ -92,7 +96,9 @@ export const taxRateDefaultZero = new TaxRate({
     value: 0,
     enabled: true,
     zone: zoneDefault,
+    zoneId: zoneDefault.id,
     category: taxCategoryZero,
+    categoryId: taxCategoryZero.id,
 });
 export const taxRateOtherStandard = new TaxRate({
     id: 'taxRateOtherStandard',
@@ -100,7 +106,9 @@ export const taxRateOtherStandard = new TaxRate({
     value: 15,
     enabled: true,
     zone: zoneOther,
+    zoneId: zoneOther.id,
     category: taxCategoryStandard,
+    categoryId: taxCategoryStandard.id,
 });
 export const taxRateOtherReduced = new TaxRate({
     id: 'taxRateOtherReduced',
@@ -108,7 +116,9 @@ export const taxRateOtherReduced = new TaxRate({
     value: 5,
     enabled: true,
     zone: zoneOther,
+    zoneId: zoneOther.id,
     category: taxCategoryReduced,
+    categoryId: taxCategoryReduced.id,
 });
 
 export class MockTaxRateService {
@@ -124,14 +134,18 @@ export class MockTaxRateService {
         /* noop */
     }
 
-    async getApplicableTaxRate(ctx: RequestContext, zone: Zone, taxCategory: TaxCategory): Promise<TaxRate> {
+    async getApplicableTaxRate(
+        ctx: RequestContext,
+        zone: Zone | ID,
+        taxCategory: TaxCategory | ID,
+    ): Promise<TaxRate> {
         const rate = this.activeTaxRates.find(r => r.test(zone, taxCategory));
         return rate || taxRateDefaultStandard;
     }
 }
 
 export function createOrder(
-    orderConfig: Partial<Omit<Order, 'lines', 'surcharges'>> & {
+    orderConfig: Partial<Omit<Order, 'lines' | 'surcharges'>> & {
         ctx: RequestContext;
         lines: Array<{
             listPrice: number;
@@ -145,6 +159,7 @@ export function createOrder(
         ({ listPrice, taxCategory, quantity }) =>
             new OrderLine({
                 taxCategory,
+                taxCategoryId: taxCategory.id,
                 quantity,
                 orderPlacedQuantity: 0,
                 listPrice,

+ 1 - 1
packages/dev-server/index.ts

@@ -8,7 +8,7 @@ import { devConfig } from './dev-config';
 bootstrap(devConfig)
     .then(app => {
         if (process.env.RUN_JOB_QUEUE === '1') {
-            app.get(JobQueueService).start();
+            return app.get(JobQueueService).start();
         }
     })
     .catch(err => {

+ 4 - 1
packages/dev-server/load-testing/init-load-test.ts

@@ -3,7 +3,7 @@
 import { bootstrap, JobQueueService, Logger } from '@vendure/core';
 import { populate } from '@vendure/core/cli/populate';
 import { clearAllTables, populateCustomers, SimpleGraphQLClient } from '@vendure/testing';
-import stringify from 'csv-stringify';
+import { stringify } from 'csv-stringify';
 import fs from 'fs';
 import path from 'path';
 
@@ -100,6 +100,9 @@ async function isDatabasePopulated(databaseName: string): Promise<boolean> {
         await client.connect();
         try {
             const res = await client.query('SELECT COUNT(id) as prodCount FROM product');
+            if (res.rows[0]?.prodcount < 1) {
+                return false;
+            }
             return true;
         } catch (e: any) {
             if (e.message === 'relation "product" does not exist') {

+ 1 - 1
packages/dev-server/load-testing/run-load-test.ts

@@ -2,7 +2,7 @@
 import { INestApplication } from '@nestjs/common';
 import { bootstrap, JobQueueService } from '@vendure/core';
 import { spawn } from 'child_process';
-import stringify from 'csv-stringify';
+import { stringify } from 'csv-stringify';
 import fs from 'fs';
 import path from 'path';
 

+ 9 - 2
packages/elasticsearch-plugin/src/indexing/indexer.controller.ts

@@ -511,7 +511,6 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
         if (!product) {
             return;
         }
-
         let updatedProductVariants: ProductVariant[] = [];
         try {
             updatedProductVariants = await this.connection.rawConnection.getRepository(ProductVariant).find({
@@ -541,6 +540,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             languageVariants.push(...variant.translations.map(t => t.languageCode));
 
         const uniqueLanguageVariants = unique(languageVariants);
+        const originalChannel = ctx.channel;
         for (const channel of product.channels) {
             ctx.setChannel(channel);
             const variantsInChannel = updatedProductVariants.filter(v =>
@@ -623,6 +623,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
                 }
             }
         }
+        ctx.setChannel(originalChannel);
 
         // Because we can have a huge amount of variant for 1 product, we also chunk update operations
         await this.executeBulkOperationsByChunks(
@@ -944,7 +945,13 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
 
             const productCustomMappings = Object.entries(this.options.customProductMappings);
             for (const [name, def] of productCustomMappings) {
-                item[`product-${name}`] = await def.valueFn(v.product, variants, languageCode, this.injector, ctx);
+                item[`product-${name}`] = await def.valueFn(
+                    v.product,
+                    variants,
+                    languageCode,
+                    this.injector,
+                    ctx,
+                );
             }
             return item;
         } catch (err: any) {