Sfoglia il codice sorgente

Merge branch 'master' into minor

Michael Bromley 3 anni fa
parent
commit
6a88ce8fb1
43 ha cambiato i file con 865 aggiunte e 298 eliminazioni
  1. 14 0
      CHANGELOG.md
  2. 166 0
      docs/content/developer-guide/authentication.md
  3. 1 1
      lerna.json
  4. 3 3
      packages/admin-ui-plugin/package.json
  5. 5 5
      packages/admin-ui/package.json
  6. 5 0
      packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.scss
  7. 19 1
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  8. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  9. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html
  10. 6 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.scss
  11. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts
  12. 4 0
      packages/admin-ui/src/lib/order/src/components/order-table/order-table-mixin.scss
  13. 6 0
      packages/admin-ui/src/lib/static/styles/global/_overrides.scss
  14. 3 3
      packages/asset-server-plugin/package.json
  15. 2 0
      packages/asset-server-plugin/src/plugin.ts
  16. 1 1
      packages/common/package.json
  17. 62 1
      packages/core/e2e/database-transactions.e2e-spec.ts
  18. 182 164
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  19. 85 4
      packages/core/e2e/fixtures/test-plugins/transaction-test-plugin.ts
  20. 52 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  21. 18 0
      packages/core/e2e/graphql/shop-definitions.ts
  22. 20 0
      packages/core/e2e/order.e2e-spec.ts
  23. 22 0
      packages/core/e2e/product.e2e-spec.ts
  24. 2 2
      packages/core/package.json
  25. 12 3
      packages/core/src/api/decorators/request-context.decorator.ts
  26. 26 4
      packages/core/src/api/middleware/transaction-interceptor.ts
  27. 1 0
      packages/core/src/common/constants.ts
  28. 11 4
      packages/core/src/connection/transaction-wrapper.ts
  29. 11 6
      packages/core/src/connection/transactional-connection.ts
  30. 18 3
      packages/core/src/event-bus/event-bus.ts
  31. 15 7
      packages/core/src/service/helpers/slug-validator/slug-validator.ts
  32. 4 2
      packages/core/src/service/helpers/utils/translate-entity.ts
  33. 0 2
      packages/core/src/service/services/order.service.ts
  34. 5 4
      packages/core/src/service/services/product-variant.service.ts
  35. 3 3
      packages/create/package.json
  36. 9 9
      packages/dev-server/package.json
  37. 3 3
      packages/elasticsearch-plugin/package.json
  38. 3 3
      packages/email-plugin/package.json
  39. 3 3
      packages/job-queue-plugin/package.json
  40. 4 4
      packages/payments-plugin/package.json
  41. 3 3
      packages/testing/package.json
  42. 4 4
      packages/ui-devkit/package.json
  43. 49 44
      yarn.lock

+ 14 - 0
CHANGELOG.md

@@ -1,3 +1,17 @@
+## <small>1.5.2 (2022-04-21)</small>
+
+
+#### Fixes
+
+* **admin-ui** Correctly size images when using alternate asset servers ([e175f52](https://github.com/vendure-ecommerce/vendure/commit/e175f52)), closes [#1514](https://github.com/vendure-ecommerce/vendure/issues/1514)
+* **admin-ui** Correctly split path when displaying asset source filename ([54519f0](https://github.com/vendure-ecommerce/vendure/commit/54519f0))
+* **admin-ui** Fix disappearing sidenav menu ([2bb7f7c](https://github.com/vendure-ecommerce/vendure/commit/2bb7f7c)), closes [#1521](https://github.com/vendure-ecommerce/vendure/issues/1521)
+* **admin-ui** Fix issue with boolean configurable arg inputs ([a52c4c0](https://github.com/vendure-ecommerce/vendure/commit/a52c4c0)), closes [#1527](https://github.com/vendure-ecommerce/vendure/issues/1527)
+* **asset-server-plugin** Fix svg XSS vulnerability ([69a4486](https://github.com/vendure-ecommerce/vendure/commit/69a4486))
+* **core** Copy context on transaction start. Do not allow to run queries after transaction aborts. (#1481) ([6050279](https://github.com/vendure-ecommerce/vendure/commit/6050279)), closes [#1481](https://github.com/vendure-ecommerce/vendure/issues/1481)
+* **core** Correctly handle slug validation of deleted translations ([61de857](https://github.com/vendure-ecommerce/vendure/commit/61de857)), closes [#1527](https://github.com/vendure-ecommerce/vendure/issues/1527)
+* **core** Correctly resolve prices of deleted ProductVariants in orders ([5061dd9](https://github.com/vendure-ecommerce/vendure/commit/5061dd9)), closes [#1508](https://github.com/vendure-ecommerce/vendure/issues/1508)
+
 ## <small>1.5.1 (2022-03-31)</small>
 
 

+ 166 - 0
docs/content/developer-guide/authentication.md

@@ -155,6 +155,172 @@ export class GoogleAuthenticationStrategy implements AuthenticationStrategy<Goog
 }
 ```
 
+## Example: Facebook authentication
+
+This example demonstrates how to implement a Facebook login flow.
+
+### Storefront setup
+
+In this example we are assuming the use of the [Facebook SDK for JavaScript](https://developers.facebook.com/docs/javascript/) in the storefront.
+
+An implementation in React might look like this:
+
+```tsx
+/**
+ * Renders a Facebook login button.
+ */
+export const FBLoginButton = () => {
+  const fnName = `onFbLoginButtonSuccess`;
+  const router = useRouter();
+  const [error, setError] = useState('');
+  const [socialLoginMutation] = useMutation(AuthenticateDocument);
+
+  useEffect(() => {
+    (window as any)[fnName] = function () {
+      FB.getLoginStatus(login);
+    };
+    return () => {
+      delete (window as any)[fnName];
+    };
+  }, []);
+
+  useEffect(() => {
+    window?.FB?.XFBML.parse();
+  }, []);
+
+  const login = async (response: any) => {
+    const { status, authResponse } = response;
+    if (status === 'connected') {
+      const result = await socialLoginMutation({ variables: { token: authResponse.accessToken } });
+      if (result.data?.authenticate.__typename === 'CurrentUser') {
+        // The user has logged in, refresh the browser
+        trackLogin('facebook');
+        router.reload();
+        return;
+      }
+    }
+    setError('An error occurred!');
+  };
+
+  return (
+    <div className="text-center" style={{ width: 188, height: 28 }}>
+      <FacebookSDK />
+      <div
+        className="fb-login-button"
+        data-width=""
+        data-size="medium"
+        data-button-type="login_with"
+        data-layout="default"
+        data-auto-logout-link="false"
+        data-use-continue-as="false"
+        data-scope="public_profile,email"
+        data-onlogin={`${fnName}();`}
+      />
+      {error && <div className="text-sm text-red-500">{error}</div>}
+    </div>
+  );
+};
+```
+
+```TypeScript
+import {
+  AuthenticationStrategy,
+  ExternalAuthenticationService,
+  Injector,
+  Logger,
+  RequestContext,
+  User,
+  UserService,
+} from '@vendure/core';
+
+import { DocumentNode } from 'graphql';
+import gql from 'graphql-tag';
+import fetch from 'node-fetch';
+
+export type FacebookAuthData = {
+  token: string;
+};
+
+export type FacebookAuthConfig = {
+  appId: string;
+  appSecret: string;
+  clientToken: string;
+};
+
+export class FacebookAuthenticationStrategy implements AuthenticationStrategy<FacebookAuthData> {
+  readonly name = 'facebook';
+  private externalAuthenticationService: ExternalAuthenticationService;
+  private userService: UserService;
+
+  constructor(private config: FacebookAuthConfig) {}
+
+  init(injector: Injector) {
+    // The ExternalAuthenticationService is a helper service which encapsulates much
+    // of the common functionality related to dealing with external authentication
+    // providers.
+    this.externalAuthenticationService = injector.get(ExternalAuthenticationService);
+    this.userService = injector.get(UserService);
+  }
+
+  defineInputType(): DocumentNode {
+    // Here we define the expected input object expected by the `authenticate` mutation
+    // under the "google" key.
+    return gql`
+      input FacebookAuthInput {
+        token: String!
+      }
+    `;
+  }
+
+  private async getAppAccessToken() {
+    const resp = await fetch(
+      `https://graph.facebook.com/oauth/access_token?client_id=${this.config.appId}&client_secret=${this.config.appSecret}&grant_type=client_credentials`,
+    );
+    return await resp.json();
+  }
+
+  async authenticate(ctx: RequestContext, data: FacebookAuthData): Promise<User | false> {
+    const { token } = data;
+    const { access_token } = await this.getAppAccessToken();
+    const resp = await fetch(
+      `https://graph.facebook.com/debug_token?input_token=${token}&access_token=${access_token}`,
+    );
+    const result = await resp.json();
+
+    if (!result.data) {
+      return false;
+    }
+
+    const uresp = await fetch(`https://graph.facebook.com/me?access_token=${token}&fields=email,first_name,last_name`);
+    const uresult = (await uresp.json()) as { id?: string; email: string; first_name: string; last_name: string };
+
+    if (!uresult.id) {
+      return false;
+    }
+
+    const existingUser = await this.externalAuthenticationService.findCustomerUser(ctx, this.name, uresult.id);
+
+    if (existingUser) {
+      // This will select all the auth methods
+      return (await this.userService.getUserById(ctx, existingUser.id))!;
+    }
+
+    Logger.info(`User Create: ${JSON.stringify(uresult)}`);
+    const user = await this.externalAuthenticationService.createCustomerAndUser(ctx, {
+      strategy: this.name,
+      externalIdentifier: uresult.id,
+      verified: true,
+      emailAddress: uresult.email,
+      firstName: uresult.first_name,
+      lastName: uresult.last_name,
+    });
+
+    user.verified = true;
+    return user;
+  }
+}
+```
+
 ## Example: Keycloak authentication
 
 Here's an example of an AuthenticationStrategy intended to be used on the Admin API. The use-case is when the company has an existing identity server for employees, and you'd like your Vendure shop admins to be able to authenticate with their existing accounts.

+ 1 - 1
lerna.json

@@ -2,7 +2,7 @@
   "packages": [
     "packages/*"
   ],
-  "version": "1.5.1",
+  "version": "1.5.2",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "command": {

+ 3 - 3
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -21,8 +21,8 @@
   "devDependencies": {
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^1.5.1",
-    "@vendure/core": "^1.5.1",
+    "@vendure/common": "^1.5.2",
+    "@vendure/core": "^1.5.2",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"

+ 5 - 5
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "license": "MIT",
   "scripts": {
     "ng": "ng",
@@ -31,15 +31,15 @@
     "@angular/router": "12.2.16",
     "@apollo/client": "^3.5.5",
     "@biesbjerg/ngx-translate-extract-marker": "^1.0.0",
-    "@cds/core": "^5.5.2",
-    "@clr/angular": "^12.0.2",
+    "@cds/core": "^5.5.8",
+    "@clr/angular": "12.0.13",
     "@clr/core": "^4.0.15",
-    "@clr/icons": "^12.0.2",
+    "@clr/icons": "^12.0.12",
     "@clr/ui": "^12.0.2",
     "@ng-select/ng-select": "^7.2.0",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/http-loader": "^6.0.0",
-    "@vendure/common": "^1.5.1",
+    "@vendure/common": "^1.5.2",
     "@webcomponents/custom-elements": "^1.4.3",
     "apollo-angular": "^2.6.0",
     "apollo-upload-client": "^16.0.0",

+ 5 - 0
packages/admin-ui/src/lib/catalog/src/components/assets/assets.component.scss

@@ -48,6 +48,11 @@
         border: 2px solid var(--color-component-border-100);
         cursor: pointer;
 
+        img {
+            width: 50px;
+            height: 50px;
+        }
+
         &.featured {
             border-color: var(--color-primary-500);
         }

+ 19 - 1
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts

@@ -100,5 +100,23 @@ export function configurableOperationValueIsValid(
  * Returns a default value based on the type of the config arg.
  */
 export function getDefaultConfigArgValue(arg: ConfigArgDefinition): any {
-    return arg.list ? [] : arg.defaultValue ?? null;
+    if (arg.list) {
+        return [];
+    }
+    if (arg.defaultValue) {
+        return arg.defaultValue;
+    }
+    const type = arg.type as ConfigArgType;
+    switch (type) {
+        case 'string':
+        case 'datetime':
+        case 'float':
+        case 'ID':
+        case 'int':
+            return null;
+        case 'boolean':
+            return false;
+        default:
+            assertNever(type);
+    }
 }

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/version.ts

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '1.5.1';
+export const ADMIN_UI_VERSION = '1.5.2';

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html

@@ -11,6 +11,7 @@
             <img
                 class="asset-image"
                 [src]="asset | assetPreview: size"
+                [ngClass]="size"
                 #imageElement
                 (load)="onImageLoad()"
             />

+ 6 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.scss

@@ -31,6 +31,12 @@
 
     .asset-image {
         width: 100%;
+
+        &.tiny { max-width: 50px; max-height: 50px; }
+        &.thumb { max-width: 150px; max-height: 150px; }
+        &.small { max-width: 300px; max-height: 300px; }
+        &.medium { max-width: 500px; max-height: 500px; }
+        &.large { max-width: 800px; max-height: 800px; }
     }
 
     .focal-point-info {

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts

@@ -99,7 +99,7 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
     }
 
     getSourceFileName(): string {
-        const parts = this.asset.source.split('/');
+        const parts = this.asset.source.split(/[\\\/]/g);
         return parts[parts.length - 1];
     }
 

+ 4 - 0
packages/admin-ui/src/lib/order/src/components/order-table/order-table-mixin.scss

@@ -51,4 +51,8 @@
         cursor: pointer;
         text-transform: lowercase;
     }
+    .thumb img {
+        width: 50px;
+        height: 50px;
+    }
 }

+ 6 - 0
packages/admin-ui/src/lib/static/styles/global/_overrides.scss

@@ -5,6 +5,12 @@
     background-color: var(--clr-global-app-background);
 }
 
+.content-area img {
+    object-fit: cover;
+    width: 100%;
+    height: 100%;
+}
+
 a:link, a:visited {
     color: var(--clr-btn-link-color);
 }

+ 3 - 3
packages/asset-server-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/asset-server-plugin",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -24,8 +24,8 @@
     "@types/fs-extra": "^9.0.8",
     "@types/node-fetch": "^2.5.8",
     "@types/sharp": "^0.27.1",
-    "@vendure/common": "^1.5.1",
-    "@vendure/core": "^1.5.1",
+    "@vendure/common": "^1.5.2",
+    "@vendure/core": "^1.5.2",
     "aws-sdk": "^2.856.0",
     "express": "^4.17.1",
     "node-fetch": "^2.6.1",

+ 2 - 0
packages/asset-server-plugin/src/plugin.ts

@@ -212,6 +212,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
                     mimeType = (await fromBuffer(file))?.mime || 'application/octet-stream';
                 }
                 res.contentType(mimeType);
+                res.setHeader('content-security-policy', `default-src 'self'`);
                 res.send(file);
             } catch (e) {
                 const err = new Error('File not found');
@@ -251,6 +252,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
                             Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
                         }
                         res.set('Content-Type', `image/${(await image.metadata()).format}`);
+                        res.setHeader('content-security-policy', `default-src 'self'`);
                         res.send(imageBuffer);
                         return;
                     } catch (e) {

+ 1 - 1
packages/common/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/common",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "main": "index.js",
   "license": "MIT",
   "scripts": {

+ 62 - 1
packages/core/e2e/database-transactions.e2e-spec.ts

@@ -13,6 +13,14 @@ import {
     TRIGGER_ATTEMPTED_UPDATE_EMAIL,
 } from './fixtures/test-plugins/transaction-test-plugin';
 
+type DBType = 'mysql' | 'postgres' | 'sqlite' | 'sqljs';
+
+const itIfDb = (dbs: DBType[]) => {
+    return dbs.includes(process.env.DB as DBType || 'sqljs')
+        ? it
+        : it.skip
+} 
+
 describe('Transaction infrastructure', () => {
     const { server, adminClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
@@ -69,6 +77,26 @@ describe('Transaction infrastructure', () => {
         expect(!!verify.users.find((u: any) => u.identifier === 'test2')).toBe(false);
     });
 
+    it('failing mutation with promise concurrent execution', async () => {
+        try {
+            await adminClient.query(CREATE_N_ADMINS, {
+                emailAddress: 'testN-',
+                failFactor: 0.4,
+                n: 10
+            })
+            fail('Should have thrown');
+        } catch (e) {
+            expect(e.message).toContain('Failed!');
+        }
+
+        const { verify } = await adminClient.query(VERIFY_TEST);
+
+        expect(verify.admins.length).toBe(2);
+        expect(verify.users.length).toBe(2);
+        expect(!!verify.admins.find((a: any) => a.emailAddress.includes('testN'))).toBe(false);
+        expect(!!verify.users.find((u: any) => u.identifier.includes('testN'))).toBe(false);
+    });
+
     it('failing manual mutation', async () => {
         try {
             await adminClient.query(CREATE_ADMIN2, {
@@ -127,6 +155,27 @@ describe('Transaction infrastructure', () => {
         expect(!!verify.users.find((u: any) => u.identifier === 'test5')).toBe(false);
     });
 
+    itIfDb(['postgres', 'mysql'])('failing mutation inside connection.withTransaction() wrapper with context and promise concurrent execution', async () => {
+        try {
+            await adminClient.query(CREATE_N_ADMINS2, {
+                emailAddress: 'testN-',
+                failFactor: 0.4,
+                n: 10
+            })
+            fail('Should have thrown');
+        } catch (e) {
+            expect(e.message)
+                .toMatch(/^Failed!|Query runner already released. Cannot run queries anymore.$/);
+        }
+
+        const { verify } = await adminClient.query(VERIFY_TEST);
+
+        expect(verify.admins.length).toBe(2);
+        expect(verify.users.length).toBe(3);
+        expect(!!verify.admins.find((a: any) => a.emailAddress.includes('testN'))).toBe(false);
+        expect(!!verify.users.find((u: any) => u.identifier.includes('testN'))).toBe(false);
+    });
+
     it('failing mutation inside connection.withTransaction() wrapper without request context', async () => {
         try {
             await adminClient.query(CREATE_ADMIN5, {
@@ -228,6 +277,18 @@ const CREATE_ADMIN5 = gql`
     ${ADMIN_FRAGMENT}
 `;
 
+const CREATE_N_ADMINS = gql`
+    mutation CreateNTestAdmins($emailAddress: String!, $failFactor: Float!, $n: Int!) {
+        createNTestAdministrators(emailAddress: $emailAddress, failFactor: $failFactor, n: $n)
+    }
+`;
+
+const CREATE_N_ADMINS2 = gql`
+    mutation CreateNTestAdmins2($emailAddress: String!, $failFactor: Float!, $n: Int!) {
+        createNTestAdministrators2(emailAddress: $emailAddress, failFactor: $failFactor, n: $n)
+    }
+`;
+
 const VERIFY_TEST = gql`
     query VerifyTest {
         verify {
@@ -241,4 +302,4 @@ const VERIFY_TEST = gql`
             }
         }
     }
-`;
+`;

+ 182 - 164
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -88,9 +88,9 @@ interface SearchProductShopVariables extends SearchProductsShop.Variables {
 }
 
 describe('Default search plugin', () => {
-    const { server, adminClient, shopClient } = createTestEnvironment(
+    const {server, adminClient, shopClient} = createTestEnvironment(
         mergeConfig(testConfig(), {
-            plugins: [DefaultSearchPlugin.init({ indexStockStatus: true }), DefaultJobQueuePlugin],
+            plugins: [DefaultSearchPlugin.init({indexStockStatus: true}), DefaultJobQueuePlugin],
         }),
     );
 
@@ -279,7 +279,7 @@ describe('Default search plugin', () => {
             {
                 input: {
                     groupByProduct: true,
-                    facetValueFilters: [{ and: 'T_1' }, { and: 'T_2' }],
+                    facetValueFilters: [{and: 'T_1'}, {and: 'T_2'}],
                 },
             },
         );
@@ -299,7 +299,7 @@ describe('Default search plugin', () => {
             {
                 input: {
                     groupByProduct: true,
-                    facetValueFilters: [{ or: ['T_1', 'T_5'] }],
+                    facetValueFilters: [{or: ['T_1', 'T_5']}],
                 },
             },
         );
@@ -326,7 +326,7 @@ describe('Default search plugin', () => {
             {
                 input: {
                     groupByProduct: true,
-                    facetValueFilters: [{ and: 'T_1' }, { or: ['T_2', 'T_3'] }],
+                    facetValueFilters: [{and: 'T_1'}, {or: ['T_2', 'T_3']}],
                 },
             },
         );
@@ -351,7 +351,7 @@ describe('Default search plugin', () => {
                 input: {
                     facetValueIds: ['T_2', 'T_3'],
                     facetValueOperator: LogicalOperator.OR,
-                    facetValueFilters: [{ and: 'T_1' }],
+                    facetValueFilters: [{and: 'T_1'}],
                     groupByProduct: true,
                 },
             },
@@ -376,7 +376,7 @@ describe('Default search plugin', () => {
             {
                 input: {
                     facetValueIds: ['T_1'],
-                    facetValueFilters: [{ and: 'T_3' }],
+                    facetValueFilters: [{and: 'T_3'}],
                     facetValueOperator: LogicalOperator.AND,
                     groupByProduct: true,
                 },
@@ -436,16 +436,16 @@ describe('Default search plugin', () => {
         );
         expect(result.search.items).toEqual([
             {
-                price: { value: 129900 },
-                priceWithTax: { value: 155880 },
+                price: {value: 129900},
+                priceWithTax: {value: 155880},
             },
             {
-                price: { value: 139900 },
-                priceWithTax: { value: 167880 },
+                price: {value: 139900},
+                priceWithTax: {value: 167880},
             },
             {
-                price: { value: 219900 },
-                priceWithTax: { value: 263880 },
+                price: {value: 219900},
+                priceWithTax: {value: 263880},
             },
         ]);
     }
@@ -462,16 +462,16 @@ describe('Default search plugin', () => {
         );
         expect(result.search.items).toEqual([
             {
-                price: { min: 129900, max: 229900 },
-                priceWithTax: { min: 155880, max: 275880 },
+                price: {min: 129900, max: 229900},
+                priceWithTax: {min: 155880, max: 275880},
             },
             {
-                price: { min: 14374, max: 16994 },
-                priceWithTax: { min: 17249, max: 20393 },
+                price: {min: 14374, max: 16994},
+                priceWithTax: {min: 17249, max: 20393},
             },
             {
-                price: { min: 93120, max: 109995 },
-                priceWithTax: { min: 111744, max: 131994 },
+                price: {min: 93120, max: 109995},
+                priceWithTax: {min: 111744, max: 131994},
             },
         ]);
     }
@@ -519,12 +519,12 @@ describe('Default search plugin', () => {
                 },
             );
             expect(result.search.facetValues).toEqual([
-                { count: 21, facetValue: { id: 'T_1', name: 'electronics' } },
-                { count: 17, facetValue: { id: 'T_2', name: 'computers' } },
-                { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
-                { count: 10, facetValue: { id: 'T_4', name: 'sports equipment' } },
-                { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
-                { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
+                {count: 21, facetValue: {id: 'T_1', name: 'electronics'}},
+                {count: 17, facetValue: {id: 'T_2', name: 'computers'}},
+                {count: 4, facetValue: {id: 'T_3', name: 'photo'}},
+                {count: 10, facetValue: {id: 'T_4', name: 'sports equipment'}},
+                {count: 3, facetValue: {id: 'T_5', name: 'home & garden'}},
+                {count: 3, facetValue: {id: 'T_6', name: 'plants'}},
             ]);
         });
 
@@ -538,12 +538,12 @@ describe('Default search plugin', () => {
                 },
             );
             expect(result.search.facetValues).toEqual([
-                { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
-                { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
-                { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
-                { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
-                { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
-                { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
+                {count: 10, facetValue: {id: 'T_1', name: 'electronics'}},
+                {count: 6, facetValue: {id: 'T_2', name: 'computers'}},
+                {count: 4, facetValue: {id: 'T_3', name: 'photo'}},
+                {count: 7, facetValue: {id: 'T_4', name: 'sports equipment'}},
+                {count: 3, facetValue: {id: 'T_5', name: 'home & garden'}},
+                {count: 3, facetValue: {id: 'T_6', name: 'plants'}},
             ]);
         });
 
@@ -559,23 +559,23 @@ describe('Default search plugin', () => {
                 },
             );
             expect(result.search.facetValues).toEqual([
-                { count: 4, facetValue: { id: 'T_1', name: 'electronics' } },
-                { count: 4, facetValue: { id: 'T_2', name: 'computers' } },
+                {count: 4, facetValue: {id: 'T_1', name: 'electronics'}},
+                {count: 4, facetValue: {id: 'T_2', name: 'computers'}},
             ]);
         });
 
         it('omits facetValues of private facets', async () => {
-            const { createFacet } = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(
+            const {createFacet} = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(
                 CREATE_FACET,
                 {
                     input: {
                         code: 'profit-margin',
                         isPrivate: true,
-                        translations: [{ languageCode: LanguageCode.en, name: 'Profit Margin' }],
+                        translations: [{languageCode: LanguageCode.en, name: 'Profit Margin'}],
                         values: [
                             {
                                 code: 'massive',
-                                translations: [{ languageCode: LanguageCode.en, name: 'massive' }],
+                                translations: [{languageCode: LanguageCode.en, name: 'massive'}],
                             },
                         ],
                     },
@@ -600,12 +600,12 @@ describe('Default search plugin', () => {
                 },
             );
             expect(result.search.facetValues).toEqual([
-                { count: 10, facetValue: { id: 'T_1', name: 'electronics' } },
-                { count: 6, facetValue: { id: 'T_2', name: 'computers' } },
-                { count: 4, facetValue: { id: 'T_3', name: 'photo' } },
-                { count: 7, facetValue: { id: 'T_4', name: 'sports equipment' } },
-                { count: 3, facetValue: { id: 'T_5', name: 'home & garden' } },
-                { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
+                {count: 10, facetValue: {id: 'T_1', name: 'electronics'}},
+                {count: 6, facetValue: {id: 'T_2', name: 'computers'}},
+                {count: 4, facetValue: {id: 'T_3', name: 'photo'}},
+                {count: 7, facetValue: {id: 'T_4', name: 'sports equipment'}},
+                {count: 3, facetValue: {id: 'T_5', name: 'home & garden'}},
+                {count: 3, facetValue: {id: 'T_6', name: 'plants'}},
             ]);
         });
 
@@ -619,7 +619,7 @@ describe('Default search plugin', () => {
                 },
             );
             expect(result.search.collections).toEqual([
-                { collection: { id: 'T_2', name: 'Plants' }, count: 3 },
+                {collection: {id: 'T_2', name: 'Plants'}, count: 3},
             ]);
         });
 
@@ -633,7 +633,7 @@ describe('Default search plugin', () => {
                 },
             );
             expect(result.search.collections).toEqual([
-                { collection: { id: 'T_2', name: 'Plants' }, count: 3 },
+                {collection: {id: 'T_2', name: 'Plants'}, count: 3},
             ]);
         });
 
@@ -665,7 +665,7 @@ describe('Default search plugin', () => {
             await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                 UPDATE_PRODUCT_VARIANTS,
                 {
-                    input: [{ id: 'T_3', enabled: false }],
+                    input: [{id: 'T_3', enabled: false}],
                 },
             );
             await awaitRunningJobs(adminClient);
@@ -819,7 +819,7 @@ describe('Default search plugin', () => {
         describe('updating the index', () => {
             it('updates index when ProductVariants are changed', async () => {
                 await awaitRunningJobs(adminClient);
-                const { search } = await doAdminSearchQuery({ term: 'drive', groupByProduct: false });
+                const {search} = await doAdminSearchQuery({term: 'drive', groupByProduct: false});
                 expect(search.items.map(i => i.sku)).toEqual([
                     'IHD455T1',
                     'IHD455T2',
@@ -839,7 +839,7 @@ describe('Default search plugin', () => {
                 );
 
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const {search: search2} = await doAdminSearchQuery({
                     term: 'drive',
                     groupByProduct: false,
                 });
@@ -855,7 +855,7 @@ describe('Default search plugin', () => {
 
             it('updates index when ProductVariants are deleted', async () => {
                 await awaitRunningJobs(adminClient);
-                const { search } = await doAdminSearchQuery({ term: 'drive', groupByProduct: false });
+                const {search} = await doAdminSearchQuery({term: 'drive', groupByProduct: false});
 
                 await adminClient.query<DeleteProductVariant.Mutation, DeleteProductVariant.Variables>(
                     DELETE_PRODUCT_VARIANT,
@@ -865,7 +865,7 @@ describe('Default search plugin', () => {
                 );
 
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const {search: search2} = await doAdminSearchQuery({
                     term: 'drive',
                     groupByProduct: false,
                 });
@@ -886,7 +886,7 @@ describe('Default search plugin', () => {
                     },
                 });
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true });
+                const result = await doAdminSearchQuery({facetValueIds: ['T_2'], groupByProduct: true});
                 expect(result.search.items.map(i => i.productName)).toEqual([
                     'Curvy Monitor',
                     'Gaming PC',
@@ -897,13 +897,13 @@ describe('Default search plugin', () => {
             });
 
             it('updates index when a Product is deleted', async () => {
-                const { search } = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true });
+                const {search} = await doAdminSearchQuery({facetValueIds: ['T_2'], groupByProduct: true});
                 expect(search.items.map(i => i.productId)).toEqual(['T_2', 'T_3', 'T_4', 'T_5', 'T_6']);
                 await adminClient.query<DeleteProduct.Mutation, DeleteProduct.Variables>(DELETE_PRODUCT, {
                     id: 'T_5',
                 });
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const {search: search2} = await doAdminSearchQuery({
                     facetValueIds: ['T_2'],
                     groupByProduct: true,
                 });
@@ -937,7 +937,7 @@ describe('Default search plugin', () => {
                 await awaitRunningJobs(adminClient);
                 // add an additional check for the collection filters to update
                 await awaitRunningJobs(adminClient);
-                const result1 = await doAdminSearchQuery({ collectionId: 'T_2', groupByProduct: true });
+                const result1 = await doAdminSearchQuery({collectionId: 'T_2', groupByProduct: true});
 
                 expect(result1.search.items.map(i => i.productName)).toEqual([
                     'Road Bike',
@@ -949,7 +949,7 @@ describe('Default search plugin', () => {
                     'Running Shoe',
                 ]);
 
-                const result2 = await doAdminSearchQuery({ collectionSlug: 'plants', groupByProduct: true });
+                const result2 = await doAdminSearchQuery({collectionSlug: 'plants', groupByProduct: true});
 
                 expect(result2.search.items.map(i => i.productName)).toEqual([
                     'Road Bike',
@@ -963,10 +963,8 @@ describe('Default search plugin', () => {
             }, 10000);
 
             it('updates index when a Collection created', async () => {
-                const { createCollection } = await adminClient.query<
-                    CreateCollection.Mutation,
-                    CreateCollection.Variables
-                >(CREATE_COLLECTION, {
+                const {createCollection} = await adminClient.query<CreateCollection.Mutation,
+                    CreateCollection.Variables>(CREATE_COLLECTION, {
                     input: {
                         translations: [
                             {
@@ -1029,8 +1027,8 @@ describe('Default search plugin', () => {
                 );
                 expect(result.search.items).toEqual([
                     {
-                        price: { min: 129900, max: 229900 },
-                        priceWithTax: { min: 194850, max: 344850 },
+                        price: {min: 129900, max: 229900},
+                        priceWithTax: {min: 194850, max: 344850},
                     },
                 ]);
             });
@@ -1049,7 +1047,7 @@ describe('Default search plugin', () => {
                 }
 
                 it('updates index when asset focalPoint is changed', async () => {
-                    const { search: search1 } = await searchForLaptop();
+                    const {search: search1} = await searchForLaptop();
 
                     expect(search1.items[0].productAsset!.id).toBe('T_1');
                     expect(search1.items[0].productAsset!.focalPoint).toBeNull();
@@ -1066,14 +1064,14 @@ describe('Default search plugin', () => {
 
                     await awaitRunningJobs(adminClient);
 
-                    const { search: search2 } = await searchForLaptop();
+                    const {search: search2} = await searchForLaptop();
 
                     expect(search2.items[0].productAsset!.id).toBe('T_1');
-                    expect(search2.items[0].productAsset!.focalPoint).toEqual({ x: 0.42, y: 0.42 });
+                    expect(search2.items[0].productAsset!.focalPoint).toEqual({x: 0.42, y: 0.42});
                 });
 
                 it('updates index when asset deleted', async () => {
-                    const { search: search1 } = await searchForLaptop();
+                    const {search: search1} = await searchForLaptop();
 
                     const assetId = search1.items[0].productAsset?.id;
                     expect(assetId).toBeTruthy();
@@ -1087,28 +1085,26 @@ describe('Default search plugin', () => {
 
                     await awaitRunningJobs(adminClient);
 
-                    const { search: search2 } = await searchForLaptop();
+                    const {search: search2} = await searchForLaptop();
 
                     expect(search2.items[0].productAsset).toBeNull();
                 });
             });
 
             it('does not include deleted ProductVariants in index', async () => {
-                const { search: s1 } = await doAdminSearchQuery({
+                const {search: s1} = await doAdminSearchQuery({
                     term: 'hard drive',
                     groupByProduct: false,
                 });
 
-                const { deleteProductVariant } = await adminClient.query<
-                    DeleteProductVariant.Mutation,
-                    DeleteProductVariant.Variables
-                >(DELETE_PRODUCT_VARIANT, { id: s1.items[0].productVariantId });
+                const {deleteProductVariant} = await adminClient.query<DeleteProductVariant.Mutation,
+                    DeleteProductVariant.Variables>(DELETE_PRODUCT_VARIANT, {id: s1.items[0].productVariantId});
 
                 await awaitRunningJobs(adminClient);
 
-                const { search } = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
+                const {search} = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
                     SEARCH_GET_PRICES,
-                    { input: { term: 'hard drive', groupByProduct: true } },
+                    {input: {term: 'hard drive', groupByProduct: true}},
                 );
                 expect(search.items[0].price).toEqual({
                     min: 7896,
@@ -1117,11 +1113,11 @@ describe('Default search plugin', () => {
             });
 
             it('returns enabled field when not grouped', async () => {
-                const result = await doAdminSearchQuery({ groupByProduct: false, take: 3 });
+                const result = await doAdminSearchQuery({groupByProduct: false, take: 3});
                 expect(result.search.items.map(pick(['productVariantId', 'enabled']))).toEqual([
-                    { productVariantId: 'T_1', enabled: true },
-                    { productVariantId: 'T_2', enabled: true },
-                    { productVariantId: 'T_3', enabled: false },
+                    {productVariantId: 'T_1', enabled: true},
+                    {productVariantId: 'T_2', enabled: true},
+                    {productVariantId: 'T_3', enabled: false},
                 ]);
             });
 
@@ -1130,17 +1126,17 @@ describe('Default search plugin', () => {
                     UPDATE_PRODUCT_VARIANTS,
                     {
                         input: [
-                            { id: 'T_1', enabled: false },
-                            { id: 'T_2', enabled: false },
+                            {id: 'T_1', enabled: false},
+                            {id: 'T_2', enabled: false},
                         ],
                     },
                 );
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, take: 3 });
+                const result = await doAdminSearchQuery({groupByProduct: true, take: 3});
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
-                    { productId: 'T_1', enabled: true },
-                    { productId: 'T_2', enabled: true },
-                    { productId: 'T_3', enabled: true },
+                    {productId: 'T_1', enabled: true},
+                    {productId: 'T_2', enabled: true},
+                    {productId: 'T_3', enabled: true},
                 ]);
             });
 
@@ -1148,15 +1144,15 @@ describe('Default search plugin', () => {
                 await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                     UPDATE_PRODUCT_VARIANTS,
                     {
-                        input: [{ id: 'T_4', enabled: false }],
+                        input: [{id: 'T_4', enabled: false}],
                     },
                 );
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, take: 3 });
+                const result = await doAdminSearchQuery({groupByProduct: true, take: 3});
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
-                    { productId: 'T_1', enabled: false },
-                    { productId: 'T_2', enabled: true },
-                    { productId: 'T_3', enabled: true },
+                    {productId: 'T_1', enabled: false},
+                    {productId: 'T_2', enabled: true},
+                    {productId: 'T_3', enabled: true},
                 ]);
             });
 
@@ -1168,11 +1164,11 @@ describe('Default search plugin', () => {
                     },
                 });
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, take: 3 });
+                const result = await doAdminSearchQuery({groupByProduct: true, take: 3});
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
-                    { productId: 'T_1', enabled: false },
-                    { productId: 'T_2', enabled: true },
-                    { productId: 'T_3', enabled: false },
+                    {productId: 'T_1', enabled: false},
+                    {productId: 'T_2', enabled: true},
+                    {productId: 'T_3', enabled: false},
                 ]);
             });
 
@@ -1181,11 +1177,55 @@ describe('Default search plugin', () => {
                 await adminClient.query<Reindex.Mutation>(REINDEX);
 
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, take: 3 });
+                const result = await doAdminSearchQuery({groupByProduct: true, take: 3});
                 expect(result.search.items.map(pick(['productId', 'enabled']))).toEqual([
-                    { productId: 'T_1', enabled: false },
-                    { productId: 'T_2', enabled: true },
-                    { productId: 'T_3', enabled: false },
+                    {productId: 'T_1', enabled: false},
+                    {productId: 'T_2', enabled: true},
+                    {productId: 'T_3', enabled: false},
+                ]);
+            });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1482
+            it('price range omits disabled variant', async () => {
+                const result1 = await shopClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
+                    SEARCH_GET_PRICES,
+                    {
+                        input: {
+                            groupByProduct: true,
+                            term: 'monitor',
+                            take: 3,
+                        } as SearchInput,
+                    },
+                );
+                expect(result1.search.items).toEqual([
+                    {
+                        price: {min: 14374, max: 16994},
+                        priceWithTax: {min: 21561, max: 25491},
+                    },
+                ]);
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [{id: 'T_5', enabled: false}],
+                    },
+                );
+                await awaitRunningJobs(adminClient);
+
+                const result2 = await shopClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
+                    SEARCH_GET_PRICES,
+                    {
+                        input: {
+                            groupByProduct: true,
+                            term: 'monitor',
+                            take: 3,
+                        } as SearchInput,
+                    },
+                );
+                expect(result2.search.items).toEqual([
+                    {
+                        price: {min: 16994, max: 16994},
+                        priceWithTax: {min: 25491, max: 25491},
+                    },
                 ]);
             });
 
@@ -1194,14 +1234,12 @@ describe('Default search plugin', () => {
                 // We generate this long string out of random chars because Postgres uses compression
                 // when storing the string value, so e.g. a long series of a single character will not
                 // reproduce the error.
-                const description = Array.from({ length: 220 })
+                const description = Array.from({length: 220})
                     .map(() => Math.random().toString(36))
                     .join(' ');
 
-                const { createProduct } = await adminClient.query<
-                    CreateProduct.Mutation,
-                    CreateProduct.Variables
-                >(CREATE_PRODUCT, {
+                const {createProduct} = await adminClient.query<CreateProduct.Mutation,
+                    CreateProduct.Variables>(CREATE_PRODUCT, {
                     input: {
                         translations: [
                             {
@@ -1222,14 +1260,14 @@ describe('Default search plugin', () => {
                                 sku: 'VLD01',
                                 price: 100,
                                 translations: [
-                                    { languageCode: LanguageCode.en, name: 'Very long description variant' },
+                                    {languageCode: LanguageCode.en, name: 'Very long description variant'},
                                 ],
                             },
                         ],
                     },
                 );
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ term: 'aabbccdd' });
+                const result = await doAdminSearchQuery({term: 'aabbccdd'});
                 expect(result.search.items.map(i => i.productName)).toEqual([
                     'Very long description aabbccdd',
                 ]);
@@ -1244,10 +1282,8 @@ describe('Default search plugin', () => {
             let createdProductId: string;
 
             it('creates synthetic index item for Product with no variants', async () => {
-                const { createProduct } = await adminClient.query<
-                    CreateProduct.Mutation,
-                    CreateProduct.Variables
-                >(CREATE_PRODUCT, {
+                const {createProduct} = await adminClient.query<CreateProduct.Mutation,
+                    CreateProduct.Variables>(CREATE_PRODUCT, {
                     input: {
                         facetValueIds: ['T_1'],
                         translations: [
@@ -1262,7 +1298,7 @@ describe('Default search plugin', () => {
                 });
 
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, term: 'strawberry' });
+                const result = await doAdminSearchQuery({groupByProduct: true, term: 'strawberry'});
                 expect(
                     result.search.items.map(
                         pick([
@@ -1288,26 +1324,24 @@ describe('Default search plugin', () => {
             });
 
             it('removes synthetic index item once a variant is created', async () => {
-                const { createProductVariants } = await adminClient.query<
-                    CreateProductVariants.Mutation,
-                    CreateProductVariants.Variables
-                >(CREATE_PRODUCT_VARIANTS, {
+                const {createProductVariants} = await adminClient.query<CreateProductVariants.Mutation,
+                    CreateProductVariants.Variables>(CREATE_PRODUCT_VARIANTS, {
                     input: [
                         {
                             productId: createdProductId,
                             sku: 'SC01',
                             price: 1399,
                             translations: [
-                                { languageCode: LanguageCode.en, name: 'Strawberry Cheesecake Pie' },
+                                {languageCode: LanguageCode.en, name: 'Strawberry Cheesecake Pie'},
                             ],
                         },
                     ],
                 });
                 await awaitRunningJobs(adminClient);
 
-                const result = await doAdminSearchQuery({ groupByProduct: false, term: 'strawberry' });
+                const result = await doAdminSearchQuery({groupByProduct: false, term: 'strawberry'});
                 expect(result.search.items.map(pick(['productVariantName']))).toEqual([
-                    { productVariantName: 'Strawberry Cheesecake Pie' },
+                    {productVariantName: 'Strawberry Cheesecake Pie'},
                 ]);
             });
         });
@@ -1317,10 +1351,8 @@ describe('Default search plugin', () => {
             let secondChannel: ChannelFragment;
 
             beforeAll(async () => {
-                const { createChannel } = await adminClient.query<
-                    CreateChannel.Mutation,
-                    CreateChannel.Variables
-                >(CREATE_CHANNEL, {
+                const {createChannel} = await adminClient.query<CreateChannel.Mutation,
+                    CreateChannel.Variables>(CREATE_CHANNEL, {
                     input: {
                         code: 'second-channel',
                         token: SECOND_CHANNEL_TOKEN,
@@ -1339,22 +1371,20 @@ describe('Default search plugin', () => {
                 await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                     ASSIGN_PRODUCT_TO_CHANNEL,
                     {
-                        input: { channelId: secondChannel.id, productIds: ['T_1', 'T_2'] },
+                        input: {channelId: secondChannel.id, productIds: ['T_1', 'T_2']},
                     },
                 );
                 await awaitRunningJobs(adminClient);
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                const { search } = await doAdminSearchQuery({ groupByProduct: true });
+                const {search} = await doAdminSearchQuery({groupByProduct: true});
                 expect(search.items.map(i => i.productId)).toEqual(['T_1', 'T_2']);
             }, 10000);
 
             it('removing product from channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                const { removeProductsFromChannel } = await adminClient.query<
-                    RemoveProductsFromChannel.Mutation,
-                    RemoveProductsFromChannel.Variables
-                >(REMOVE_PRODUCT_FROM_CHANNEL, {
+                const {removeProductsFromChannel} = await adminClient.query<RemoveProductsFromChannel.Mutation,
+                    RemoveProductsFromChannel.Variables>(REMOVE_PRODUCT_FROM_CHANNEL, {
                     input: {
                         productIds: ['T_2'],
                         channelId: secondChannel.id,
@@ -1363,26 +1393,24 @@ describe('Default search plugin', () => {
                 await awaitRunningJobs(adminClient);
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                const { search } = await doAdminSearchQuery({ groupByProduct: true });
+                const {search} = await doAdminSearchQuery({groupByProduct: true});
                 expect(search.items.map(i => i.productId)).toEqual(['T_1']);
             }, 10000);
 
             it('adding product variant to channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                await adminClient.query<
-                    AssignProductVariantsToChannel.Mutation,
-                    AssignProductVariantsToChannel.Variables
-                >(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
-                    input: { channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15'] },
+                await adminClient.query<AssignProductVariantsToChannel.Mutation,
+                    AssignProductVariantsToChannel.Variables>(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, {
+                    input: {channelId: secondChannel.id, productVariantIds: ['T_10', 'T_15']},
                 });
                 await awaitRunningJobs(adminClient);
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
 
-                const { search: searchGrouped } = await doAdminSearchQuery({ groupByProduct: true });
+                const {search: searchGrouped} = await doAdminSearchQuery({groupByProduct: true});
                 expect(searchGrouped.items.map(i => i.productId)).toEqual(['T_1', 'T_3', 'T_4']);
 
-                const { search: searchUngrouped } = await doAdminSearchQuery({ groupByProduct: false });
+                const {search: searchUngrouped} = await doAdminSearchQuery({groupByProduct: false});
                 expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([
                     'T_1',
                     'T_2',
@@ -1395,20 +1423,18 @@ describe('Default search plugin', () => {
 
             it('removing product variant from channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                await adminClient.query<
-                    RemoveProductVariantsFromChannel.Mutation,
-                    RemoveProductVariantsFromChannel.Variables
-                >(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
-                    input: { channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15'] },
+                await adminClient.query<RemoveProductVariantsFromChannel.Mutation,
+                    RemoveProductVariantsFromChannel.Variables>(REMOVE_PRODUCTVARIANT_FROM_CHANNEL, {
+                    input: {channelId: secondChannel.id, productVariantIds: ['T_1', 'T_15']},
                 });
                 await awaitRunningJobs(adminClient);
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
 
-                const { search: searchGrouped } = await doAdminSearchQuery({ groupByProduct: true });
+                const {search: searchGrouped} = await doAdminSearchQuery({groupByProduct: true});
                 expect(searchGrouped.items.map(i => i.productId)).toEqual(['T_1', 'T_3']);
 
-                const { search: searchUngrouped } = await doAdminSearchQuery({ groupByProduct: false });
+                const {search: searchUngrouped} = await doAdminSearchQuery({groupByProduct: false});
                 expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([
                     'T_2',
                     'T_3',
@@ -1419,20 +1445,18 @@ describe('Default search plugin', () => {
 
             it('updating product affects current channel', async () => {
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                const { updateProduct } = await adminClient.query<
-                    UpdateProduct.Mutation,
-                    UpdateProduct.Variables
-                >(UPDATE_PRODUCT, {
+                const {updateProduct} = await adminClient.query<UpdateProduct.Mutation,
+                    UpdateProduct.Variables>(UPDATE_PRODUCT, {
                     input: {
                         id: 'T_3',
                         enabled: true,
-                        translations: [{ languageCode: LanguageCode.en, name: 'xyz' }],
+                        translations: [{languageCode: LanguageCode.en, name: 'xyz'}],
                     },
                 });
 
                 await awaitRunningJobs(adminClient);
 
-                const { search: searchGrouped } = await doAdminSearchQuery({
+                const {search: searchGrouped} = await doAdminSearchQuery({
                     groupByProduct: true,
                     term: 'xyz',
                 });
@@ -1441,7 +1465,7 @@ describe('Default search plugin', () => {
 
             it('updating product affects other channels', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                const { search: searchGrouped } = await doAdminSearchQuery({
+                const {search: searchGrouped} = await doAdminSearchQuery({
                     groupByProduct: true,
                     term: 'xyz',
                 });
@@ -1475,22 +1499,20 @@ describe('Default search plugin', () => {
                 await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                     ASSIGN_PRODUCT_TO_CHANNEL,
                     {
-                        input: { channelId: secondChannel.id, productIds: ['T_4'] },
+                        input: {channelId: secondChannel.id, productIds: ['T_4']},
                     },
                 );
                 await awaitRunningJobs(adminClient);
 
                 async function searchSecondChannelForDEProduct() {
                     adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                    const { search } = await adminClient.query<
-                        SearchProductsShop.Query,
-                        SearchProductShopVariables
-                    >(
+                    const {search} = await adminClient.query<SearchProductsShop.Query,
+                        SearchProductShopVariables>(
                         SEARCH_PRODUCTS,
                         {
-                            input: { term: 'product', groupByProduct: true },
+                            input: {term: 'product', groupByProduct: true},
                         },
-                        { languageCode: LanguageCode.de },
+                        {languageCode: LanguageCode.de},
                     );
                     return search;
                 }
@@ -1499,10 +1521,8 @@ describe('Default search plugin', () => {
                 expect(search1.items.map(i => i.productName)).toEqual(['product de']);
 
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                const { removeProductsFromChannel } = await adminClient.query<
-                    RemoveProductsFromChannel.Mutation,
-                    RemoveProductsFromChannel.Variables
-                >(REMOVE_PRODUCT_FROM_CHANNEL, {
+                const {removeProductsFromChannel} = await adminClient.query<RemoveProductsFromChannel.Mutation,
+                    RemoveProductsFromChannel.Variables>(REMOVE_PRODUCT_FROM_CHANNEL, {
                     input: {
                         productIds: ['T_4'],
                         channelId: secondChannel.id,
@@ -1531,10 +1551,8 @@ describe('Default search plugin', () => {
             }
 
             beforeAll(async () => {
-                const { updateProduct } = await adminClient.query<
-                    UpdateProduct.Mutation,
-                    UpdateProduct.Variables
-                >(UPDATE_PRODUCT, {
+                const {updateProduct} = await adminClient.query<UpdateProduct.Mutation,
+                    UpdateProduct.Variables>(UPDATE_PRODUCT, {
                     input: {
                         id: 'T_1',
                         translations: [
@@ -1575,13 +1593,13 @@ describe('Default search plugin', () => {
             });
 
             it('indexes product-level languages', async () => {
-                const { search: search1 } = await searchInLanguage(LanguageCode.de);
+                const {search: search1} = await searchInLanguage(LanguageCode.de);
 
                 expect(search1.items[0].productName).toBe('laptop name de');
                 expect(search1.items[0].slug).toBe('laptop-slug-de');
                 expect(search1.items[0].description).toBe('laptop description de');
 
-                const { search: search2 } = await searchInLanguage(LanguageCode.zh);
+                const {search: search2} = await searchInLanguage(LanguageCode.zh);
 
                 expect(search2.items[0].productName).toBe('laptop name zh');
                 expect(search2.items[0].slug).toBe('laptop-slug-zh');
@@ -1589,7 +1607,7 @@ describe('Default search plugin', () => {
             });
 
             it('indexes product variant-level languages', async () => {
-                const { search: search1 } = await searchInLanguage(LanguageCode.fr);
+                const {search: search1} = await searchInLanguage(LanguageCode.fr);
 
                 expect(search1.items[0].productName).toBe('Laptop');
                 expect(search1.items[0].productVariantName).toBe('laptop variant fr');

+ 85 - 4
packages/core/e2e/fixtures/test-plugins/transaction-test-plugin.ts

@@ -37,7 +37,8 @@ class TestUserService {
                 passwordHash: 'abc',
             }),
         );
-        const user = await this.connection.getRepository(ctx, User).save(
+
+        await this.connection.getRepository(ctx, User).insert(
             new User({
                 authenticationMethods: [authMethod],
                 identifier,
@@ -45,7 +46,10 @@ class TestUserService {
                 verified: true,
             }),
         );
-        return user;
+
+        return this.connection.getRepository(ctx, User).findOne({
+            where: { identifier }
+        });
     }
 }
 
@@ -55,9 +59,11 @@ class TestAdminService {
 
     async createAdministrator(ctx: RequestContext, emailAddress: string, fail: boolean) {
         const user = await this.userService.createUser(ctx, emailAddress);
+
         if (fail) {
             throw new InternalServerError('Failed!');
         }
+
         const admin = await this.connection.getRepository(ctx, Administrator).save(
             new Administrator({
                 emailAddress,
@@ -112,7 +118,7 @@ class TestResolver {
     @Mutation()
     async createTestAdministrator5(@Ctx() ctx: RequestContext, @Args() args: any) {
         if (args.noContext === true) {
-            return this.connection.withTransaction(ctx, async _ctx => {
+            return this.connection.withTransaction(async _ctx => {
                 const admin = await this.testAdminService.createAdministrator(
                     _ctx,
                     args.emailAddress,
@@ -121,7 +127,7 @@ class TestResolver {
                 return admin;
             });
         } else {
-            return this.connection.withTransaction(async _ctx => {
+            return this.connection.withTransaction(ctx, async _ctx => {
                 const admin = await this.testAdminService.createAdministrator(
                     _ctx,
                     args.emailAddress,
@@ -132,6 +138,61 @@ class TestResolver {
         }
     }
 
+    @Mutation()
+    @Transaction()
+    async createNTestAdministrators(@Ctx() ctx: RequestContext, @Args() args: any) {
+        let error: any;
+
+        const promises: Promise<any>[] = []
+        for (let i = 0; i < args.n; i++) {
+            promises.push(
+                new Promise(resolve => setTimeout(resolve, i * 10)).then(() =>
+                    this.testAdminService.createAdministrator(ctx, `${args.emailAddress}${i}`, i < args.n * args.failFactor)
+                )
+            )
+        }
+
+        const result = await Promise.all(promises).catch((e: any) => {
+            error = e;
+        })
+
+        await this.allSettled(promises)
+    
+        if (error) {
+            throw error;
+        }
+
+        return result;
+    }
+
+    @Mutation()
+    async createNTestAdministrators2(@Ctx() ctx: RequestContext, @Args() args: any) {
+        let error: any;
+
+        const promises: Promise<any>[] = []
+        const result = await this.connection.withTransaction(ctx, _ctx => {
+            for (let i = 0; i < args.n; i++) {
+                promises.push(
+                    new Promise(resolve => setTimeout(resolve, i * 10)).then(() =>
+                        this.testAdminService.createAdministrator(_ctx, `${args.emailAddress}${i}`, i < args.n * args.failFactor)
+                    )
+                )
+            }
+
+            return Promise.all(promises);
+        }).catch((e: any) => {
+            error = e;
+        })
+
+        await this.allSettled(promises)
+    
+        if (error) {
+            throw error;
+        }
+
+        return result;
+    }
+
     @Query()
     async verify() {
         const admins = await this.connection.getRepository(Administrator).find();
@@ -141,6 +202,24 @@ class TestResolver {
             users,
         };
     }
+
+    // Promise.allSettled polyfill
+    // Same as Promise.all but waits until all promises will be fulfilled or rejected.
+    private allSettled<T>(promises: Promise<T>[]): Promise<({status: 'fulfilled', value: T} | { status: 'rejected', reason: any})[]> {
+        return Promise.all(
+            promises.map((promise, i) =>
+              promise
+                .then(value => ({
+                  status: "fulfilled" as const,
+                  value,
+                }))
+                .catch(reason => ({
+                  status: "rejected" as const,
+                  reason,
+                }))
+            )
+          );
+    }
 }
 
 @VendurePlugin({
@@ -158,6 +237,8 @@ class TestResolver {
                     fail: Boolean!
                     noContext: Boolean!
                 ): Administrator
+                createNTestAdministrators(emailAddress: String!, failFactor: Float!, n: Int!): JSON
+                createNTestAdministrators2(emailAddress: String!, failFactor: Float!, n: Int!): JSON
             }
             type VerifyResult {
                 admins: [Administrator!]!

+ 52 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -3604,6 +3604,16 @@ export type GetActiveCustomerWithOrdersProductSlugQuery = {
     }>;
 };
 
+export type GetActiveCustomerWithOrdersProductPriceQueryVariables = Exact<{
+    options?: Maybe<OrderListOptions>;
+}>;
+
+export type GetActiveCustomerWithOrdersProductPriceQuery = {
+    activeCustomer?: Maybe<{
+        orders: { items: Array<{ lines: Array<{ productVariant: Pick<ProductVariant, 'price'> }> }> };
+    }>;
+};
+
 type DiscriminateUnion<T, U> = T extends U ? T : never;
 
 export namespace TestOrderFragment {
@@ -4269,3 +4279,45 @@ export namespace GetActiveCustomerWithOrdersProductSlug {
         >['product']
     >;
 }
+
+export namespace GetActiveCustomerWithOrdersProductPrice {
+    export type Variables = GetActiveCustomerWithOrdersProductPriceQueryVariables;
+    export type Query = GetActiveCustomerWithOrdersProductPriceQuery;
+    export type ActiveCustomer = NonNullable<GetActiveCustomerWithOrdersProductPriceQuery['activeCustomer']>;
+    export type Orders = NonNullable<
+        NonNullable<GetActiveCustomerWithOrdersProductPriceQuery['activeCustomer']>['orders']
+    >;
+    export type Items = NonNullable<
+        NonNullable<
+            NonNullable<
+                NonNullable<GetActiveCustomerWithOrdersProductPriceQuery['activeCustomer']>['orders']
+            >['items']
+        >[number]
+    >;
+    export type Lines = NonNullable<
+        NonNullable<
+            NonNullable<
+                NonNullable<
+                    NonNullable<
+                        NonNullable<GetActiveCustomerWithOrdersProductPriceQuery['activeCustomer']>['orders']
+                    >['items']
+                >[number]
+            >['lines']
+        >[number]
+    >;
+    export type ProductVariant = NonNullable<
+        NonNullable<
+            NonNullable<
+                NonNullable<
+                    NonNullable<
+                        NonNullable<
+                            NonNullable<
+                                GetActiveCustomerWithOrdersProductPriceQuery['activeCustomer']
+                            >['orders']
+                        >['items']
+                    >[number]
+                >['lines']
+            >[number]
+        >['productVariant']
+    >;
+}

+ 18 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -759,3 +759,21 @@ export const GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_SLUG = gql`
         }
     }
 `;
+export const GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_PRICE = gql`
+    query GetActiveCustomerWithOrdersProductPrice($options: OrderListOptions) {
+        activeCustomer {
+            orders(options: $options) {
+                items {
+                    lines {
+                        linePrice
+                        productVariant {
+                            id
+                            name
+                            price
+                        }
+                    }
+                }
+            }
+        }
+    }
+`;

+ 20 - 0
packages/core/e2e/order.e2e-spec.ts

@@ -71,6 +71,7 @@ import {
     ApplyCouponCode,
     DeletionResult,
     GetActiveCustomerOrderWithItemFulfillments,
+    GetActiveCustomerWithOrdersProductPrice,
     GetActiveCustomerWithOrdersProductSlug,
     GetActiveOrder,
     GetOrderByCodeWithPayments,
@@ -101,6 +102,7 @@ import {
     ADD_ITEM_TO_ORDER,
     ADD_PAYMENT,
     APPLY_COUPON_CODE,
+    GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_PRICE,
     GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_SLUG,
     GET_ACTIVE_ORDER,
     GET_ACTIVE_ORDER_CUSTOMER_WITH_ITEM_FULFILLMENTS,
@@ -2290,6 +2292,24 @@ describe('Orders resolver', () => {
                     .product.slug,
             ).toBe('gaming-pc');
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/1508
+        it('resolves price of deleted ProductVariant of OrderLine', async () => {
+            const { activeCustomer } = await shopClient.query<
+                GetActiveCustomerWithOrdersProductPrice.Query,
+                GetActiveCustomerWithOrdersProductPrice.Variables
+            >(GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_PRICE, {
+                options: {
+                    sort: {
+                        createdAt: SortOrder.ASC,
+                    },
+                },
+            });
+            expect(
+                activeCustomer!.orders.items[activeCustomer!.orders.items.length - 1].lines[0].productVariant
+                    .price,
+            ).toBe(108720);
+        });
     });
 });
 

+ 22 - 0
packages/core/e2e/product.e2e-spec.ts

@@ -1858,6 +1858,28 @@ describe('Product resolver', () => {
             expect(result.createProduct.slug).toBe(productToDelete.slug);
         });
 
+        // https://github.com/vendure-ecommerce/vendure/issues/1505
+        it('attempting to re-use deleted slug twice is not allowed', async () => {
+            const result = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
+                CREATE_PRODUCT,
+                {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Product reusing deleted slug',
+                                slug: productToDelete.slug,
+                                description: 'stuff',
+                            },
+                        ],
+                    },
+                },
+            );
+
+            expect(result.createProduct.slug).not.toBe(productToDelete.slug);
+            expect(result.createProduct.slug).toBe('laptop-2');
+        });
+
         // https://github.com/vendure-ecommerce/vendure/issues/800
         it('product can be fetched by slug of a deleted product', async () => {
             const { product } = await adminClient.query<GetProductSimple.Query, GetProductSimple.Variables>(

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/core",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "description": "A modern, headless ecommerce framework",
   "repository": {
     "type": "git",
@@ -49,7 +49,7 @@
     "@nestjs/testing": "7.6.17",
     "@nestjs/typeorm": "7.1.5",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^1.5.1",
+    "@vendure/common": "^1.5.2",
     "apollo-server-express": "2.24.1",
     "bcrypt": "^5.0.0",
     "body-parser": "^1.19.0",

+ 12 - 3
packages/core/src/api/decorators/request-context.decorator.ts

@@ -1,6 +1,6 @@
 import { ContextType, createParamDecorator, ExecutionContext } from '@nestjs/common';
 
-import { REQUEST_CONTEXT_KEY } from '../../common/constants';
+import { REQUEST_CONTEXT_KEY, REQUEST_CONTEXT_MAP_KEY } from '../../common/constants';
 
 /**
  * @description
@@ -19,11 +19,20 @@ import { REQUEST_CONTEXT_KEY } from '../../common/constants';
  * @docsPage Ctx Decorator
  */
 export const Ctx = createParamDecorator((data, ctx: ExecutionContext) => {
+    const getContext = (req: any) => {
+        const map: Map<Function, any> | undefined = req[REQUEST_CONTEXT_MAP_KEY];
+
+        // If a map contains associated transactional context with this handler
+        // we have to use it. It means that this handler was wrapped with @Transaction decorator.
+        // Otherwise use default context.
+        return map?.get(ctx.getHandler()) || req[REQUEST_CONTEXT_KEY];
+    }
+
     if (ctx.getType<ContextType | 'graphql'>() === 'graphql') {
         // GraphQL request
-        return ctx.getArgByIndex(2).req[REQUEST_CONTEXT_KEY];
+        return getContext(ctx.getArgByIndex(2).req);
     } else {
         // REST request
-        return ctx.switchToHttp().getRequest()[REQUEST_CONTEXT_KEY];
+        return getContext(ctx.switchToHttp().getRequest());
     }
 });

+ 26 - 4
packages/core/src/api/middleware/transaction-interceptor.ts

@@ -1,8 +1,9 @@
 import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { Observable, of } from 'rxjs';
+import { RequestContext } from '..';
 
-import { REQUEST_CONTEXT_KEY } from '../../common/constants';
+import { REQUEST_CONTEXT_KEY, REQUEST_CONTEXT_MAP_KEY } from '../../common/constants';
 import { TransactionWrapper } from '../../connection/transaction-wrapper';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import { parseContext } from '../common/parse-context';
@@ -20,24 +21,45 @@ export class TransactionInterceptor implements NestInterceptor {
         private transactionWrapper: TransactionWrapper,
         private reflector: Reflector,
     ) {}
+
     intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
         const { isGraphQL, req } = parseContext(context);
-        const ctx = (req as any)[REQUEST_CONTEXT_KEY];
+        const ctx: RequestContext | undefined = (req as any)[REQUEST_CONTEXT_KEY];
+
         if (ctx) {
             const transactionMode = this.reflector.get<TransactionMode>(
                 TRANSACTION_MODE_METADATA_KEY,
                 context.getHandler(),
             );
+            
             return of(
                 this.transactionWrapper.executeInTransaction(
                     ctx,
-                    () => next.handle(),
+                    (ctx) => {
+                        this.registerTransactionalContext(ctx, context.getHandler(), req);
+
+                        return next.handle()
+                    },
                     transactionMode,
                     this.connection.rawConnection,
-                ),
+                )
             );
         } else {
             return next.handle();
         }
     }
+
+    /**
+     * Registers transactional request context associated with execution handler function
+     * 
+     * @param ctx transactional request context
+     * @param handler handler function from ExecutionContext
+     * @param req Request object
+     */
+    registerTransactionalContext(ctx: RequestContext, handler: Function, req: any) {
+        const map: Map<Function, RequestContext> = req[REQUEST_CONTEXT_MAP_KEY] || new Map();
+        map.set(handler, ctx);
+
+        req[REQUEST_CONTEXT_MAP_KEY] = map;
+    }
 }

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

@@ -9,6 +9,7 @@ import { CrudPermissionDefinition, PermissionDefinition, PermissionMetadata } fr
 export const DEFAULT_LANGUAGE_CODE = LanguageCode.en;
 export const TRANSACTION_MANAGER_KEY = Symbol('TRANSACTION_MANAGER');
 export const REQUEST_CONTEXT_KEY = 'vendureRequestContext';
+export const REQUEST_CONTEXT_MAP_KEY = 'vendureRequestContextMap';
 export const DEFAULT_PERMISSIONS: PermissionDefinition[] = [
     new PermissionDefinition({
         name: 'Authenticated',

+ 11 - 4
packages/core/src/connection/transaction-wrapper.ts

@@ -18,19 +18,26 @@ export class TransactionWrapper {
      * Executes the `work` function within the context of a transaction. If the `work` function
      * resolves / completes, then all the DB operations it contains will be committed. If it
      * throws an error or rejects, then all DB operations will be rolled back.
+     * 
+     * @note
+     * This function does not mutate your context. Instead, this function makes a copy and passes
+     * context to work function.
      */
     async executeInTransaction<T>(
-        ctx: RequestContext,
-        work: () => Observable<T> | Promise<T>,
+        originalCtx: RequestContext,
+        work: (ctx: RequestContext) => Observable<T> | Promise<T>,
         mode: TransactionMode,
         connection: Connection,
     ): Promise<T> {
+        // Copy to make sure original context will remain valid after transaction completes
+        const ctx = originalCtx.copy();
+
         const queryRunnerExists = !!(ctx as any)[TRANSACTION_MANAGER_KEY];
         if (queryRunnerExists) {
             // If a QueryRunner already exists on the RequestContext, there must be an existing
             // outer transaction in progress. In that case, we just execute the work function
             // as usual without needing to further wrap in a transaction.
-            return from(work()).toPromise();
+            return from(work(ctx)).toPromise();
         }
         const queryRunner = connection.createQueryRunner();
         if (mode === 'auto') {
@@ -40,7 +47,7 @@ export class TransactionWrapper {
 
         try {
             const maxRetries = 5;
-            const result = await from(work())
+            const result = await from(work(ctx))
                 .pipe(
                     retryWhen(errors =>
                         errors.pipe(

+ 11 - 6
packages/core/src/connection/transactional-connection.ts

@@ -73,8 +73,9 @@ export class TransactionalConnection {
     ): Repository<Entity> {
         if (ctxOrTarget instanceof RequestContext) {
             const transactionManager = this.getTransactionManager(ctxOrTarget);
-            if (transactionManager && maybeTarget && !transactionManager.queryRunner?.isReleased) {
-                return transactionManager.getRepository(maybeTarget);
+            if (transactionManager) {
+                // tslint:disable-next-line:no-non-null-assertion
+                return transactionManager.getRepository(maybeTarget!);
             } else {
                 // tslint:disable-next-line:no-non-null-assertion
                 return getRepository(maybeTarget!);
@@ -101,15 +102,19 @@ export class TransactionalConnection {
      * of Vendure internal services.
      *
      * If there is already a {@link RequestContext} object available, you should pass it in as the first
-     * argument in order to add a new transaction to it. If not, omit the first argument and an empty
+     * argument in order to create transactional context as the copy. If not, omit the first argument and an empty
      * RequestContext object will be created, which is then used to propagate the transaction to
      * all inner method calls.
      *
      * @example
      * ```TypeScript
-     * private async transferCredit(fromId: ID, toId: ID, amount: number) {
-     *   await this.connection.withTransaction(ctx => {
+     * private async transferCredit(outerCtx: RequestContext, fromId: ID, toId: ID, amount: number) {
+     *   await this.connection.withTransaction(outerCtx, ctx => {
      *     await this.giftCardService.updateCustomerCredit(fromId, -amount);
+     * 
+     *     // Note you must not use outerCtx here, instead use ctx. Otherwise this query
+     *     // will be executed outside of transaction
+     *     await this.connection.getRepository(ctx, GiftCard).update(fromId, { transferred: true })
      *
      *     // If some intermediate logic here throws an Error,
      *     // then all DB transactions will be rolled back and neither Customer's
@@ -138,7 +143,7 @@ export class TransactionalConnection {
             ctx = RequestContext.empty();
             work = ctxOrWork;
         }
-        return this.transactionWrapper.executeInTransaction(ctx, () => work(ctx), 'auto', this.rawConnection);
+        return this.transactionWrapper.executeInTransaction(ctx, work, 'auto', this.rawConnection);
     }
 
     /**

+ 18 - 3
packages/core/src/event-bus/event-bus.ts

@@ -110,14 +110,29 @@ export class EventBus implements OnModuleDestroy {
      * * https://github.com/vendure-ecommerce/vendure/issues/1107
      */
     private async awaitActiveTransactions<T extends VendureEvent>(event: T): Promise<T> {
-        const ctx = Object.values(event).find(value => value instanceof RequestContext);
-        if (!ctx) {
+        const entry = Object.entries(event).find(([_, value]) => value instanceof RequestContext);
+
+        if (!entry) {
             return event;
         }
+
+        const [key, ctx]: [string, RequestContext] = entry;
+        
         const transactionManager: EntityManager | undefined = (ctx as any)[TRANSACTION_MANAGER_KEY];
         if (!transactionManager?.queryRunner) {
             return event;
         }
-        return this.transactionSubscriber.awaitRelease(transactionManager.queryRunner).then(() => event);
+
+        return this.transactionSubscriber.awaitRelease(transactionManager.queryRunner).then(() => {
+            // Copy context and remove transaction manager
+            // This will prevent queries to released query runner
+            const newContext = ctx.copy();
+            delete (newContext as any)[TRANSACTION_MANAGER_KEY];
+
+            // Reassign new context
+            (event as any)[key] = newContext
+
+            return event;
+        });
     }
 }

+ 15 - 7
packages/core/src/service/helpers/slug-validator/slug-validator.ts

@@ -43,6 +43,7 @@ export class SlugValidator {
                     t.slug = normalizeString(t.slug, '-');
                     let match: E | undefined;
                     let suffix = 1;
+                    const seen: ID[] = [];
                     const alreadySuffixed = /-\d+$/;
                     do {
                         const qb = this.connection
@@ -52,20 +53,27 @@ export class SlugValidator {
                             .where(`translation.slug = :slug`, { slug: t.slug })
                             .andWhere(`translation.languageCode = :languageCode`, {
                                 languageCode: t.languageCode,
-                            });
+                            })
                         if (input.id) {
                             qb.andWhere(`translation.base != :id`, { id: input.id });
                         }
+                        if (seen.length) {
+                            qb.andWhere(`translation.id NOT IN (:...seen)`, { seen });
+                        }
                         match = await qb.getOne();
-                        if (match && !match.base.deletedAt) {
-                            suffix++;
-                            if (alreadySuffixed.test(t.slug)) {
-                                t.slug = t.slug.replace(alreadySuffixed, `-${suffix}`);
+                        if (match) {
+                            if (!match.base.deletedAt) {
+                                suffix++;
+                                if (alreadySuffixed.test(t.slug)) {
+                                    t.slug = t.slug.replace(alreadySuffixed, `-${suffix}`);
+                                } else {
+                                    t.slug = `${t.slug}-${suffix}`;
+                                }
                             } else {
-                                t.slug = `${t.slug}-${suffix}`;
+                                seen.push(match.id);
                             }
                         }
-                    } while (match && !match.base.deletedAt);
+                    } while (match);
                 }
             }
         }

+ 4 - 2
packages/core/src/service/helpers/utils/translate-entity.ts

@@ -60,8 +60,10 @@ export function translateEntity<T extends Translatable & VendureEntity>(
         });
     }
 
-    const translated = { ...(translatable as any) };
-    Object.setPrototypeOf(translated, Object.getPrototypeOf(translatable));
+    const translated = Object.create(
+        Object.getPrototypeOf(translatable),
+        Object.getOwnPropertyDescriptors(translatable),
+    );
 
     for (const [key, value] of Object.entries(translation)) {
         if (key === 'customFields') {

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

@@ -306,8 +306,6 @@ export class OrderService {
                 relations: relations ?? [
                     'lines',
                     'lines.items',
-                    'lines.productVariant',
-                    'lines.productVariant.options',
                     'customer',
                     'channels',
                     'shippingLines',

+ 5 - 4
packages/core/src/service/services/product-variant.service.ts

@@ -229,9 +229,10 @@ export class ProductVariantService {
      */
     async getVariantByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise<Translated<ProductVariant>> {
         const { productVariant } = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLineId, {
-            relations: ['productVariant'],
+            relations: ['productVariant', 'productVariant.taxCategory'],
+            includeSoftDeleted: true,
         });
-        return translateDeep(productVariant, ctx.languageCode);
+        return translateDeep(await this.applyChannelPriceAndTax(productVariant, ctx), ctx.languageCode);
     }
 
     /**
@@ -567,7 +568,7 @@ export class ProductVariantService {
                             ctx,
                             ProductVariant,
                             variant.id,
-                            { relations: ['productVariantPrices'] },
+                            { relations: ['productVariantPrices'], includeSoftDeleted: true },
                         );
                         variant.productVariantPrices = variantWithPrices.productVariantPrices;
                     }
@@ -576,7 +577,7 @@ export class ProductVariantService {
                             ctx,
                             ProductVariant,
                             variant.id,
-                            { relations: ['taxCategory'] },
+                            { relations: ['taxCategory'], includeSoftDeleted: true },
                         );
                         variant.taxCategory = variantWithTaxCategory.taxCategory;
                     }

+ 3 - 3
packages/create/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/create",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "license": "MIT",
   "bin": {
     "create": "./index.js"
@@ -28,13 +28,13 @@
     "@types/handlebars": "^4.1.0",
     "@types/listr": "^0.14.2",
     "@types/semver": "^6.2.2",
-    "@vendure/core": "^1.5.1",
+    "@vendure/core": "^1.5.2",
     "rimraf": "^3.0.2",
     "ts-node": "^10.2.1",
     "typescript": "4.3.5"
   },
   "dependencies": {
-    "@vendure/common": "^1.5.1",
+    "@vendure/common": "^1.5.2",
     "chalk": "^4.1.0",
     "commander": "^7.1.0",
     "cross-spawn": "^7.0.3",

+ 9 - 9
packages/dev-server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dev-server",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "main": "index.js",
   "license": "MIT",
   "private": true,
@@ -14,18 +14,18 @@
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   "dependencies": {
-    "@vendure/admin-ui-plugin": "^1.5.1",
-    "@vendure/asset-server-plugin": "^1.5.1",
-    "@vendure/common": "^1.5.1",
-    "@vendure/core": "^1.5.1",
-    "@vendure/elasticsearch-plugin": "^1.5.1",
-    "@vendure/email-plugin": "^1.5.1",
+    "@vendure/admin-ui-plugin": "^1.5.2",
+    "@vendure/asset-server-plugin": "^1.5.2",
+    "@vendure/common": "^1.5.2",
+    "@vendure/core": "^1.5.2",
+    "@vendure/elasticsearch-plugin": "^1.5.2",
+    "@vendure/email-plugin": "^1.5.2",
     "typescript": "4.3.5"
   },
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
-    "@vendure/testing": "^1.5.1",
-    "@vendure/ui-devkit": "^1.5.1",
+    "@vendure/testing": "^1.5.2",
+    "@vendure/ui-devkit": "^1.5.2",
     "commander": "^7.1.0",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3",

+ 3 - 3
packages/elasticsearch-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/elasticsearch-plugin",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -25,8 +25,8 @@
     "fast-deep-equal": "^3.1.3"
   },
   "devDependencies": {
-    "@vendure/common": "^1.5.1",
-    "@vendure/core": "^1.5.1",
+    "@vendure/common": "^1.5.2",
+    "@vendure/core": "^1.5.2",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"
   }

+ 3 - 3
packages/email-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/email-plugin",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -35,8 +35,8 @@
     "@types/fs-extra": "^9.0.1",
     "@types/handlebars": "^4.1.0",
     "@types/mjml": "^4.0.4",
-    "@vendure/common": "^1.5.1",
-    "@vendure/core": "^1.5.1",
+    "@vendure/common": "^1.5.2",
+    "@vendure/core": "^1.5.2",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"
   }

+ 3 - 3
packages/job-queue-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/job-queue-plugin",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "license": "MIT",
   "main": "package/index.js",
   "types": "package/index.d.ts",
@@ -24,8 +24,8 @@
   "devDependencies": {
     "@google-cloud/pubsub": "^2.8.0",
     "@types/redis": "^2.8.28",
-    "@vendure/common": "^1.5.1",
-    "@vendure/core": "^1.5.1",
+    "@vendure/common": "^1.5.2",
+    "@vendure/core": "^1.5.2",
     "bullmq": "^1.40.1",
     "redis": "^3.0.2",
     "rimraf": "^3.0.2",

+ 4 - 4
packages/payments-plugin/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/payments-plugin",
-    "version": "1.5.1",
+    "version": "1.5.2",
     "license": "MIT",
     "main": "package/index.js",
     "types": "package/index.d.ts",
@@ -28,9 +28,9 @@
     "devDependencies": {
         "@mollie/api-client": "^3.6.0",
         "@types/braintree": "^2.22.15",
-        "@vendure/common": "^1.5.1",
-        "@vendure/core": "^1.5.1",
-        "@vendure/testing": "^1.5.1",
+        "@vendure/common": "^1.5.2",
+        "@vendure/core": "^1.5.2",
+        "@vendure/testing": "^1.5.2",
         "braintree": "^3.0.0",
         "nock": "^13.1.4",
         "rimraf": "^3.0.2",

+ 3 - 3
packages/testing/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/testing",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "description": "End-to-end testing tools for Vendure projects",
   "keywords": [
     "vendure",
@@ -34,7 +34,7 @@
   },
   "dependencies": {
     "@types/node-fetch": "^2.5.4",
-    "@vendure/common": "^1.5.1",
+    "@vendure/common": "^1.5.2",
     "faker": "^4.1.0",
     "form-data": "^3.0.0",
     "graphql": "15.5.1",
@@ -45,7 +45,7 @@
   "devDependencies": {
     "@types/mysql": "^2.15.15",
     "@types/pg": "^7.14.5",
-    "@vendure/core": "^1.5.1",
+    "@vendure/core": "^1.5.2",
     "mysql": "^2.18.1",
     "pg": "^8.4.0",
     "rimraf": "^3.0.0",

+ 4 - 4
packages/ui-devkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/ui-devkit",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
     "vendure",
@@ -40,8 +40,8 @@
     "@angular/cli": "12.2.16",
     "@angular/compiler": "12.2.16",
     "@angular/compiler-cli": "12.2.16",
-    "@vendure/admin-ui": "^1.5.1",
-    "@vendure/common": "^1.5.1",
+    "@vendure/admin-ui": "^1.5.2",
+    "@vendure/common": "^1.5.2",
     "chalk": "^4.1.0",
     "chokidar": "^3.5.1",
     "fs-extra": "^10.0.0",
@@ -52,7 +52,7 @@
     "@rollup/plugin-node-resolve": "^11.2.0",
     "@types/fs-extra": "^9.0.8",
     "@types/glob": "^7.1.3",
-    "@vendure/core": "^1.5.1",
+    "@vendure/core": "^1.5.2",
     "rimraf": "^3.0.2",
     "rollup": "^2.40.0",
     "rollup-plugin-terser": "^7.0.2",

+ 49 - 44
yarn.lock

@@ -1425,25 +1425,25 @@
   resolved "https://registry.npmjs.org/@cds/city/-/city-1.1.0.tgz#5b7323750d3d64671ce2e3a804bcf260fbea1154"
   integrity sha512-S9K+Q39BGOghyLHmR0Wdcmu1i1noSUk8HcvMj+3IaohZw02WFd99aPTQDHJeseXrXZP3CNovaSlePI0R11NcFg==
 
-"@cds/core@^5.5.2":
-  version "5.5.2"
-  resolved "https://registry.npmjs.org/@cds/core/-/core-5.5.2.tgz#916631e1c815e226d3b2f8137b4d13f889183131"
-  integrity sha512-QdHQ7VEStmJd8KYQNFFak5yWnj8asZ8mXLJPN/y76xenlkYrIl3P1Ui6QsjCjLcBUfprsCILX1yyyr4gcY1nmw==
+"@cds/core@^5.5.8":
+  version "5.7.1"
+  resolved "https://registry.npmjs.org/@cds/core/-/core-5.7.1.tgz#2db04bac721536f490536875384aa7b914225a00"
+  integrity sha512-0qHuOZYIgmZgiYtbHY3+cz9IFUDsMtAB9lEJv6+Afatg7qCW/0UAEfj8tZORjbHuhPK7tMBTWyvjc45prHtIPw==
   dependencies:
-    lit "2.0.0-rc.2"
-    ramda "^0.27.1"
-    tslib "^2.2.0"
+    lit "^2.1.3"
+    ramda "^0.28.0"
+    tslib "^2.3.1"
   optionalDependencies:
     "@cds/city" "^1.1.0"
     "@types/resize-observer-browser" "^0.1.5"
     modern-normalize "1.1.0"
 
-"@clr/angular@^12.0.2":
-  version "12.0.2"
-  resolved "https://registry.npmjs.org/@clr/angular/-/angular-12.0.2.tgz#090af364b98d98828c8c6415042bb94da6dc9a32"
-  integrity sha512-atzE/g16QVC1lL9jXaswCQXN1A7RHgPpjh6j6N/YzHnLfHGqAxtLbziS8uJ1TBlBF3omiOTL+PX5/8oZJWauYg==
+"@clr/angular@12.0.13":
+  version "12.0.13"
+  resolved "https://registry.npmjs.org/@clr/angular/-/angular-12.0.13.tgz#708793d7764828e4ddd8c26e815acfedb7e0eec8"
+  integrity sha512-bsZiNoIkO6qzDugCcY1KIZUxD7Bt3seChIe2R5wEDwcSYfsRQ88gIeqGS88AJ513X492PKKdNU9uOT68/NcSRA==
   dependencies:
-    tslib "^2.1.0"
+    tslib "^2.2.0"
 
 "@clr/city@^1.1.0":
   version "1.1.0"
@@ -1468,10 +1468,10 @@
     css-vars-ponyfill "^2.3.2"
     normalize.css "^8.0.1"
 
-"@clr/icons@^12.0.2":
-  version "12.0.2"
-  resolved "https://registry.npmjs.org/@clr/icons/-/icons-12.0.2.tgz#ce9a2051b6fb9e86a66096d05d8cd4f40d9e0832"
-  integrity sha512-C033dfrRp8rCnb7vvLCMvH3a6jnATwzFRyZd6Cu76myswlFDcAS+Pj774aclfGkverIBSv+zA2518/nBkZgKKw==
+"@clr/icons@^12.0.12":
+  version "12.0.12"
+  resolved "https://registry.npmjs.org/@clr/icons/-/icons-12.0.12.tgz#9dee3ddd541e9469be6a2ba8bbf87c303d9827f9"
+  integrity sha512-HWiDp9MuF+6/Rsy40dKAOySkITnvnXRcgGjG7M8hMTzkE86bODE7HWC5IkFi93bRAWCxBfmuVis5wel4OitEBg==
 
 "@clr/ui@^12.0.2":
   version "12.0.2"
@@ -3149,10 +3149,10 @@
     npmlog "^4.1.2"
     write-file-atomic "^3.0.3"
 
-"@lit/reactive-element@^1.0.0-rc.2":
-  version "1.0.0-rc.3"
-  resolved "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.0.0-rc.3.tgz#5032f493fbf39781b187a7e2dd5d256537c8760c"
-  integrity sha512-Rs2px1keOQUNJUo5B+WExl5v244ZNCiN/iMVNO9evFdJjAdWCIupR/p14zRPkNHsciRBELLTcOZ379cI9O6PDg==
+"@lit/reactive-element@^1.3.0":
+  version "1.3.1"
+  resolved "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.3.1.tgz#3021ad0fa30a75a41212c5e7f1f169c5762ef8bb"
+  integrity sha512-nOJARIr3pReqK3hfFCSW2Zg/kFcFsSAlIE7z4a0C9D2dPrgD/YSn3ZP2ET/rxKB65SXyG7jJbkynBRm+tGlacw==
 
 "@mapbox/node-pre-gyp@^1.0.0":
   version "1.0.5"
@@ -4431,10 +4431,10 @@
   resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
   integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
 
-"@types/trusted-types@^1.0.1":
-  version "1.0.6"
-  resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz#569b8a08121d3203398290d602d84d73c8dcf5da"
-  integrity sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
 "@types/undertaker-registry@*":
   version "1.0.1"
@@ -12338,34 +12338,34 @@ lit-element@^2.3.1:
   dependencies:
     lit-html "^1.1.1"
 
-lit-element@^3.0.0-rc.2:
-  version "3.0.0-rc.3"
-  resolved "https://registry.npmjs.org/lit-element/-/lit-element-3.0.0-rc.3.tgz#cece8f092d28eb6f9c6b23e4138ff5d7260897ef"
-  integrity sha512-NDe7yjW18gfYQb1GIEQr1T8sB1GUAb1HB62pdAEw+SK6lUW7OFPKQqCOlRhZ6qJXsw9KxMnyYIprLZT4FZdYdQ==
+lit-element@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.npmjs.org/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7"
+  integrity sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==
   dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-html "^2.0.0-rc.4"
+    "@lit/reactive-element" "^1.3.0"
+    lit-html "^2.2.0"
 
 lit-html@^1.1.1, lit-html@^1.2.1:
   version "1.4.1"
   resolved "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz#0c6f3ee4ad4eb610a49831787f0478ad8e9ae5e0"
   integrity sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==
 
-lit-html@^2.0.0-rc.3, lit-html@^2.0.0-rc.4:
-  version "2.0.0-rc.4"
-  resolved "https://registry.npmjs.org/lit-html/-/lit-html-2.0.0-rc.4.tgz#1015fa8f1f7c8c5b79999ed0bc11c3b79ff1aab5"
-  integrity sha512-WSLGu3vxq7y8q/oOd9I3zxyBELNLLiDk6gAYoKK4PGctI5fbh6lhnO/jVBdy0PV/vTc+cLJCA/occzx3YoNPeg==
+lit-html@^2.2.0:
+  version "2.2.2"
+  resolved "https://registry.npmjs.org/lit-html/-/lit-html-2.2.2.tgz#06ced65dd3fb2d7a214d998c65acc576ae2cb3c4"
+  integrity sha512-cJofCRXuizwyaiGt9pJjJOcauezUlSB6t87VBXsPwRhbzF29MgD8GH6fZ0BuZdXAAC02IRONZBd//VPUuU8QbQ==
   dependencies:
-    "@types/trusted-types" "^1.0.1"
+    "@types/trusted-types" "^2.0.2"
 
-lit@2.0.0-rc.2:
-  version "2.0.0-rc.2"
-  resolved "https://registry.npmjs.org/lit/-/lit-2.0.0-rc.2.tgz#724a2d621aa098001d73bf7106f3a72b7b5948ef"
-  integrity sha512-BOCuoJR04WaTV8UqTKk09cNcQA10Aq2LCcBOiHuF7TzWH5RNDsbCBP5QM9sLBSotGTXbDug/gFO08jq6TbyEtw==
+lit@^2.1.3:
+  version "2.2.2"
+  resolved "https://registry.npmjs.org/lit/-/lit-2.2.2.tgz#b7f729d6ca7e17efbf2bf589df2d5eb04d9620ba"
+  integrity sha512-eN3+2QRHn/erxYB88AXiiRgQA6RltE9MhzySCwX+ACOxA/MLWN3VdXvcbZD9PN09zmUwlgzDvW3T84YWj2Sa0A==
   dependencies:
-    "@lit/reactive-element" "^1.0.0-rc.2"
-    lit-element "^3.0.0-rc.2"
-    lit-html "^2.0.0-rc.3"
+    "@lit/reactive-element" "^1.3.0"
+    lit-element "^3.2.0"
+    lit-html "^2.2.0"
 
 load-json-file@^1.0.0:
   version "1.1.0"
@@ -16086,11 +16086,16 @@ quick-lru@^4.0.1:
   resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
   integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
 
-ramda@^0.27.0, ramda@^0.27.1:
+ramda@^0.27.0:
   version "0.27.1"
   resolved "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9"
   integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==
 
+ramda@^0.28.0:
+  version "0.28.0"
+  resolved "https://registry.npmjs.org/ramda/-/ramda-0.28.0.tgz#acd785690100337e8b063cab3470019be427cc97"
+  integrity sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==
+
 randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -18476,7 +18481,7 @@ tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
   resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tslib@^2, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@~2.3.0:
+tslib@^2, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@~2.3.0:
   version "2.3.1"
   resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
   integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==