Răsfoiți Sursa

Merge branch 'master' into minor

Michael Bromley 3 ani în urmă
părinte
comite
6a88ce8fb1
43 a modificat fișierele cu 865 adăugiri și 298 ștergeri
  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>
 ## <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
 ## 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.
 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": [
     "packages/*"
     "packages/*"
   ],
   ],
-  "version": "1.5.1",
+  "version": "1.5.2",
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
   "command": {
   "command": {

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

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@vendure/admin-ui",
   "name": "@vendure/admin-ui",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "ng": "ng",
     "ng": "ng",
@@ -31,15 +31,15 @@
     "@angular/router": "12.2.16",
     "@angular/router": "12.2.16",
     "@apollo/client": "^3.5.5",
     "@apollo/client": "^3.5.5",
     "@biesbjerg/ngx-translate-extract-marker": "^1.0.0",
     "@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/core": "^4.0.15",
-    "@clr/icons": "^12.0.2",
+    "@clr/icons": "^12.0.12",
     "@clr/ui": "^12.0.2",
     "@clr/ui": "^12.0.2",
     "@ng-select/ng-select": "^7.2.0",
     "@ng-select/ng-select": "^7.2.0",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/http-loader": "^6.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",
     "@webcomponents/custom-elements": "^1.4.3",
     "apollo-angular": "^2.6.0",
     "apollo-angular": "^2.6.0",
     "apollo-upload-client": "^16.0.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);
         border: 2px solid var(--color-component-border-100);
         cursor: pointer;
         cursor: pointer;
 
 
+        img {
+            width: 50px;
+            height: 50px;
+        }
+
         &.featured {
         &.featured {
             border-color: var(--color-primary-500);
             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.
  * Returns a default value based on the type of the config arg.
  */
  */
 export function getDefaultConfigArgValue(arg: ConfigArgDefinition): any {
 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.
 // 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
             <img
                 class="asset-image"
                 class="asset-image"
                 [src]="asset | assetPreview: size"
                 [src]="asset | assetPreview: size"
+                [ngClass]="size"
                 #imageElement
                 #imageElement
                 (load)="onImageLoad()"
                 (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 {
     .asset-image {
         width: 100%;
         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 {
     .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 {
     getSourceFileName(): string {
-        const parts = this.asset.source.split('/');
+        const parts = this.asset.source.split(/[\\\/]/g);
         return parts[parts.length - 1];
         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;
         cursor: pointer;
         text-transform: lowercase;
         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);
     background-color: var(--clr-global-app-background);
 }
 }
 
 
+.content-area img {
+    object-fit: cover;
+    width: 100%;
+    height: 100%;
+}
+
 a:link, a:visited {
 a:link, a:visited {
     color: var(--clr-btn-link-color);
     color: var(--clr-btn-link-color);
 }
 }

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@vendure/asset-server-plugin",
   "name": "@vendure/asset-server-plugin",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "main": "lib/index.js",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "types": "lib/index.d.ts",
   "files": [
   "files": [
@@ -24,8 +24,8 @@
     "@types/fs-extra": "^9.0.8",
     "@types/fs-extra": "^9.0.8",
     "@types/node-fetch": "^2.5.8",
     "@types/node-fetch": "^2.5.8",
     "@types/sharp": "^0.27.1",
     "@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",
     "aws-sdk": "^2.856.0",
     "express": "^4.17.1",
     "express": "^4.17.1",
     "node-fetch": "^2.6.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';
                     mimeType = (await fromBuffer(file))?.mime || 'application/octet-stream';
                 }
                 }
                 res.contentType(mimeType);
                 res.contentType(mimeType);
+                res.setHeader('content-security-policy', `default-src 'self'`);
                 res.send(file);
                 res.send(file);
             } catch (e) {
             } catch (e) {
                 const err = new Error('File not found');
                 const err = new Error('File not found');
@@ -251,6 +252,7 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
                             Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
                             Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
                         }
                         }
                         res.set('Content-Type', `image/${(await image.metadata()).format}`);
                         res.set('Content-Type', `image/${(await image.metadata()).format}`);
+                        res.setHeader('content-security-policy', `default-src 'self'`);
                         res.send(imageBuffer);
                         res.send(imageBuffer);
                         return;
                         return;
                     } catch (e) {
                     } catch (e) {

+ 1 - 1
packages/common/package.json

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

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

@@ -13,6 +13,14 @@ import {
     TRIGGER_ATTEMPTED_UPDATE_EMAIL,
     TRIGGER_ATTEMPTED_UPDATE_EMAIL,
 } from './fixtures/test-plugins/transaction-test-plugin';
 } 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', () => {
 describe('Transaction infrastructure', () => {
     const { server, adminClient } = createTestEnvironment(
     const { server, adminClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
         mergeConfig(testConfig(), {
@@ -69,6 +77,26 @@ describe('Transaction infrastructure', () => {
         expect(!!verify.users.find((u: any) => u.identifier === 'test2')).toBe(false);
         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 () => {
     it('failing manual mutation', async () => {
         try {
         try {
             await adminClient.query(CREATE_ADMIN2, {
             await adminClient.query(CREATE_ADMIN2, {
@@ -127,6 +155,27 @@ describe('Transaction infrastructure', () => {
         expect(!!verify.users.find((u: any) => u.identifier === 'test5')).toBe(false);
         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 () => {
     it('failing mutation inside connection.withTransaction() wrapper without request context', async () => {
         try {
         try {
             await adminClient.query(CREATE_ADMIN5, {
             await adminClient.query(CREATE_ADMIN5, {
@@ -228,6 +277,18 @@ const CREATE_ADMIN5 = gql`
     ${ADMIN_FRAGMENT}
     ${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`
 const VERIFY_TEST = gql`
     query VerifyTest {
     query VerifyTest {
         verify {
         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', () => {
 describe('Default search plugin', () => {
-    const { server, adminClient, shopClient } = createTestEnvironment(
+    const {server, adminClient, shopClient} = createTestEnvironment(
         mergeConfig(testConfig(), {
         mergeConfig(testConfig(), {
-            plugins: [DefaultSearchPlugin.init({ indexStockStatus: true }), DefaultJobQueuePlugin],
+            plugins: [DefaultSearchPlugin.init({indexStockStatus: true}), DefaultJobQueuePlugin],
         }),
         }),
     );
     );
 
 
@@ -279,7 +279,7 @@ describe('Default search plugin', () => {
             {
             {
                 input: {
                 input: {
                     groupByProduct: true,
                     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: {
                 input: {
                     groupByProduct: true,
                     groupByProduct: true,
-                    facetValueFilters: [{ or: ['T_1', 'T_5'] }],
+                    facetValueFilters: [{or: ['T_1', 'T_5']}],
                 },
                 },
             },
             },
         );
         );
@@ -326,7 +326,7 @@ describe('Default search plugin', () => {
             {
             {
                 input: {
                 input: {
                     groupByProduct: true,
                     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: {
                 input: {
                     facetValueIds: ['T_2', 'T_3'],
                     facetValueIds: ['T_2', 'T_3'],
                     facetValueOperator: LogicalOperator.OR,
                     facetValueOperator: LogicalOperator.OR,
-                    facetValueFilters: [{ and: 'T_1' }],
+                    facetValueFilters: [{and: 'T_1'}],
                     groupByProduct: true,
                     groupByProduct: true,
                 },
                 },
             },
             },
@@ -376,7 +376,7 @@ describe('Default search plugin', () => {
             {
             {
                 input: {
                 input: {
                     facetValueIds: ['T_1'],
                     facetValueIds: ['T_1'],
-                    facetValueFilters: [{ and: 'T_3' }],
+                    facetValueFilters: [{and: 'T_3'}],
                     facetValueOperator: LogicalOperator.AND,
                     facetValueOperator: LogicalOperator.AND,
                     groupByProduct: true,
                     groupByProduct: true,
                 },
                 },
@@ -436,16 +436,16 @@ describe('Default search plugin', () => {
         );
         );
         expect(result.search.items).toEqual([
         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([
         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([
             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([
             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([
             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 () => {
         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,
                 CREATE_FACET,
                 {
                 {
                     input: {
                     input: {
                         code: 'profit-margin',
                         code: 'profit-margin',
                         isPrivate: true,
                         isPrivate: true,
-                        translations: [{ languageCode: LanguageCode.en, name: 'Profit Margin' }],
+                        translations: [{languageCode: LanguageCode.en, name: 'Profit Margin'}],
                         values: [
                         values: [
                             {
                             {
                                 code: 'massive',
                                 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([
             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([
             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([
             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>(
             await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                 UPDATE_PRODUCT_VARIANTS,
                 UPDATE_PRODUCT_VARIANTS,
                 {
                 {
-                    input: [{ id: 'T_3', enabled: false }],
+                    input: [{id: 'T_3', enabled: false}],
                 },
                 },
             );
             );
             await awaitRunningJobs(adminClient);
             await awaitRunningJobs(adminClient);
@@ -819,7 +819,7 @@ describe('Default search plugin', () => {
         describe('updating the index', () => {
         describe('updating the index', () => {
             it('updates index when ProductVariants are changed', async () => {
             it('updates index when ProductVariants are changed', async () => {
                 await awaitRunningJobs(adminClient);
                 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([
                 expect(search.items.map(i => i.sku)).toEqual([
                     'IHD455T1',
                     'IHD455T1',
                     'IHD455T2',
                     'IHD455T2',
@@ -839,7 +839,7 @@ describe('Default search plugin', () => {
                 );
                 );
 
 
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const {search: search2} = await doAdminSearchQuery({
                     term: 'drive',
                     term: 'drive',
                     groupByProduct: false,
                     groupByProduct: false,
                 });
                 });
@@ -855,7 +855,7 @@ describe('Default search plugin', () => {
 
 
             it('updates index when ProductVariants are deleted', async () => {
             it('updates index when ProductVariants are deleted', async () => {
                 await awaitRunningJobs(adminClient);
                 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>(
                 await adminClient.query<DeleteProductVariant.Mutation, DeleteProductVariant.Variables>(
                     DELETE_PRODUCT_VARIANT,
                     DELETE_PRODUCT_VARIANT,
@@ -865,7 +865,7 @@ describe('Default search plugin', () => {
                 );
                 );
 
 
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const {search: search2} = await doAdminSearchQuery({
                     term: 'drive',
                     term: 'drive',
                     groupByProduct: false,
                     groupByProduct: false,
                 });
                 });
@@ -886,7 +886,7 @@ describe('Default search plugin', () => {
                     },
                     },
                 });
                 });
                 await awaitRunningJobs(adminClient);
                 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([
                 expect(result.search.items.map(i => i.productName)).toEqual([
                     'Curvy Monitor',
                     'Curvy Monitor',
                     'Gaming PC',
                     'Gaming PC',
@@ -897,13 +897,13 @@ describe('Default search plugin', () => {
             });
             });
 
 
             it('updates index when a Product is deleted', async () => {
             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']);
                 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, {
                 await adminClient.query<DeleteProduct.Mutation, DeleteProduct.Variables>(DELETE_PRODUCT, {
                     id: 'T_5',
                     id: 'T_5',
                 });
                 });
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
-                const { search: search2 } = await doAdminSearchQuery({
+                const {search: search2} = await doAdminSearchQuery({
                     facetValueIds: ['T_2'],
                     facetValueIds: ['T_2'],
                     groupByProduct: true,
                     groupByProduct: true,
                 });
                 });
@@ -937,7 +937,7 @@ describe('Default search plugin', () => {
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
                 // add an additional check for the collection filters to update
                 // add an additional check for the collection filters to update
                 await awaitRunningJobs(adminClient);
                 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([
                 expect(result1.search.items.map(i => i.productName)).toEqual([
                     'Road Bike',
                     'Road Bike',
@@ -949,7 +949,7 @@ describe('Default search plugin', () => {
                     'Running Shoe',
                     '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([
                 expect(result2.search.items.map(i => i.productName)).toEqual([
                     'Road Bike',
                     'Road Bike',
@@ -963,10 +963,8 @@ describe('Default search plugin', () => {
             }, 10000);
             }, 10000);
 
 
             it('updates index when a Collection created', async () => {
             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: {
                     input: {
                         translations: [
                         translations: [
                             {
                             {
@@ -1029,8 +1027,8 @@ describe('Default search plugin', () => {
                 );
                 );
                 expect(result.search.items).toEqual([
                 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 () => {
                 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!.id).toBe('T_1');
                     expect(search1.items[0].productAsset!.focalPoint).toBeNull();
                     expect(search1.items[0].productAsset!.focalPoint).toBeNull();
@@ -1066,14 +1064,14 @@ describe('Default search plugin', () => {
 
 
                     await awaitRunningJobs(adminClient);
                     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!.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 () => {
                 it('updates index when asset deleted', async () => {
-                    const { search: search1 } = await searchForLaptop();
+                    const {search: search1} = await searchForLaptop();
 
 
                     const assetId = search1.items[0].productAsset?.id;
                     const assetId = search1.items[0].productAsset?.id;
                     expect(assetId).toBeTruthy();
                     expect(assetId).toBeTruthy();
@@ -1087,28 +1085,26 @@ describe('Default search plugin', () => {
 
 
                     await awaitRunningJobs(adminClient);
                     await awaitRunningJobs(adminClient);
 
 
-                    const { search: search2 } = await searchForLaptop();
+                    const {search: search2} = await searchForLaptop();
 
 
                     expect(search2.items[0].productAsset).toBeNull();
                     expect(search2.items[0].productAsset).toBeNull();
                 });
                 });
             });
             });
 
 
             it('does not include deleted ProductVariants in index', async () => {
             it('does not include deleted ProductVariants in index', async () => {
-                const { search: s1 } = await doAdminSearchQuery({
+                const {search: s1} = await doAdminSearchQuery({
                     term: 'hard drive',
                     term: 'hard drive',
                     groupByProduct: false,
                     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);
                 await awaitRunningJobs(adminClient);
 
 
-                const { search } = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
+                const {search} = await adminClient.query<SearchGetPrices.Query, SearchGetPrices.Variables>(
                     SEARCH_GET_PRICES,
                     SEARCH_GET_PRICES,
-                    { input: { term: 'hard drive', groupByProduct: true } },
+                    {input: {term: 'hard drive', groupByProduct: true}},
                 );
                 );
                 expect(search.items[0].price).toEqual({
                 expect(search.items[0].price).toEqual({
                     min: 7896,
                     min: 7896,
@@ -1117,11 +1113,11 @@ describe('Default search plugin', () => {
             });
             });
 
 
             it('returns enabled field when not grouped', async () => {
             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([
                 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,
                     UPDATE_PRODUCT_VARIANTS,
                     {
                     {
                         input: [
                         input: [
-                            { id: 'T_1', enabled: false },
-                            { id: 'T_2', enabled: false },
+                            {id: 'T_1', enabled: false},
+                            {id: 'T_2', enabled: false},
                         ],
                         ],
                     },
                     },
                 );
                 );
                 await awaitRunningJobs(adminClient);
                 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([
                 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>(
                 await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                     UPDATE_PRODUCT_VARIANTS,
                     UPDATE_PRODUCT_VARIANTS,
                     {
                     {
-                        input: [{ id: 'T_4', enabled: false }],
+                        input: [{id: 'T_4', enabled: false}],
                     },
                     },
                 );
                 );
                 await awaitRunningJobs(adminClient);
                 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([
                 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);
                 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([
                 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 adminClient.query<Reindex.Mutation>(REINDEX);
 
 
                 await awaitRunningJobs(adminClient);
                 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([
                 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
                 // 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
                 // when storing the string value, so e.g. a long series of a single character will not
                 // reproduce the error.
                 // reproduce the error.
-                const description = Array.from({ length: 220 })
+                const description = Array.from({length: 220})
                     .map(() => Math.random().toString(36))
                     .map(() => Math.random().toString(36))
                     .join(' ');
                     .join(' ');
 
 
-                const { createProduct } = await adminClient.query<
-                    CreateProduct.Mutation,
-                    CreateProduct.Variables
-                >(CREATE_PRODUCT, {
+                const {createProduct} = await adminClient.query<CreateProduct.Mutation,
+                    CreateProduct.Variables>(CREATE_PRODUCT, {
                     input: {
                     input: {
                         translations: [
                         translations: [
                             {
                             {
@@ -1222,14 +1260,14 @@ describe('Default search plugin', () => {
                                 sku: 'VLD01',
                                 sku: 'VLD01',
                                 price: 100,
                                 price: 100,
                                 translations: [
                                 translations: [
-                                    { languageCode: LanguageCode.en, name: 'Very long description variant' },
+                                    {languageCode: LanguageCode.en, name: 'Very long description variant'},
                                 ],
                                 ],
                             },
                             },
                         ],
                         ],
                     },
                     },
                 );
                 );
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ term: 'aabbccdd' });
+                const result = await doAdminSearchQuery({term: 'aabbccdd'});
                 expect(result.search.items.map(i => i.productName)).toEqual([
                 expect(result.search.items.map(i => i.productName)).toEqual([
                     'Very long description aabbccdd',
                     'Very long description aabbccdd',
                 ]);
                 ]);
@@ -1244,10 +1282,8 @@ describe('Default search plugin', () => {
             let createdProductId: string;
             let createdProductId: string;
 
 
             it('creates synthetic index item for Product with no variants', async () => {
             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: {
                     input: {
                         facetValueIds: ['T_1'],
                         facetValueIds: ['T_1'],
                         translations: [
                         translations: [
@@ -1262,7 +1298,7 @@ describe('Default search plugin', () => {
                 });
                 });
 
 
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
-                const result = await doAdminSearchQuery({ groupByProduct: true, term: 'strawberry' });
+                const result = await doAdminSearchQuery({groupByProduct: true, term: 'strawberry'});
                 expect(
                 expect(
                     result.search.items.map(
                     result.search.items.map(
                         pick([
                         pick([
@@ -1288,26 +1324,24 @@ describe('Default search plugin', () => {
             });
             });
 
 
             it('removes synthetic index item once a variant is created', async () => {
             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: [
                     input: [
                         {
                         {
                             productId: createdProductId,
                             productId: createdProductId,
                             sku: 'SC01',
                             sku: 'SC01',
                             price: 1399,
                             price: 1399,
                             translations: [
                             translations: [
-                                { languageCode: LanguageCode.en, name: 'Strawberry Cheesecake Pie' },
+                                {languageCode: LanguageCode.en, name: 'Strawberry Cheesecake Pie'},
                             ],
                             ],
                         },
                         },
                     ],
                     ],
                 });
                 });
                 await awaitRunningJobs(adminClient);
                 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([
                 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;
             let secondChannel: ChannelFragment;
 
 
             beforeAll(async () => {
             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: {
                     input: {
                         code: 'second-channel',
                         code: 'second-channel',
                         token: SECOND_CHANNEL_TOKEN,
                         token: SECOND_CHANNEL_TOKEN,
@@ -1339,22 +1371,20 @@ describe('Default search plugin', () => {
                 await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                 await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                     ASSIGN_PRODUCT_TO_CHANNEL,
                     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);
                 await awaitRunningJobs(adminClient);
 
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                 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']);
                 expect(search.items.map(i => i.productId)).toEqual(['T_1', 'T_2']);
             }, 10000);
             }, 10000);
 
 
             it('removing product from channel', async () => {
             it('removing product from channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
                 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: {
                     input: {
                         productIds: ['T_2'],
                         productIds: ['T_2'],
                         channelId: secondChannel.id,
                         channelId: secondChannel.id,
@@ -1363,26 +1393,24 @@ describe('Default search plugin', () => {
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
 
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                 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']);
                 expect(search.items.map(i => i.productId)).toEqual(['T_1']);
             }, 10000);
             }, 10000);
 
 
             it('adding product variant to channel', async () => {
             it('adding product variant to channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
                 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);
                 await awaitRunningJobs(adminClient);
 
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                 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']);
                 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([
                 expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([
                     'T_1',
                     'T_1',
                     'T_2',
                     'T_2',
@@ -1395,20 +1423,18 @@ describe('Default search plugin', () => {
 
 
             it('removing product variant from channel', async () => {
             it('removing product variant from channel', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
                 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);
                 await awaitRunningJobs(adminClient);
 
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                 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']);
                 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([
                 expect(searchUngrouped.items.map(i => i.productVariantId)).toEqual([
                     'T_2',
                     'T_2',
                     'T_3',
                     'T_3',
@@ -1419,20 +1445,18 @@ describe('Default search plugin', () => {
 
 
             it('updating product affects current channel', async () => {
             it('updating product affects current channel', async () => {
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                 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: {
                     input: {
                         id: 'T_3',
                         id: 'T_3',
                         enabled: true,
                         enabled: true,
-                        translations: [{ languageCode: LanguageCode.en, name: 'xyz' }],
+                        translations: [{languageCode: LanguageCode.en, name: 'xyz'}],
                     },
                     },
                 });
                 });
 
 
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
 
 
-                const { search: searchGrouped } = await doAdminSearchQuery({
+                const {search: searchGrouped} = await doAdminSearchQuery({
                     groupByProduct: true,
                     groupByProduct: true,
                     term: 'xyz',
                     term: 'xyz',
                 });
                 });
@@ -1441,7 +1465,7 @@ describe('Default search plugin', () => {
 
 
             it('updating product affects other channels', async () => {
             it('updating product affects other channels', async () => {
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
-                const { search: searchGrouped } = await doAdminSearchQuery({
+                const {search: searchGrouped} = await doAdminSearchQuery({
                     groupByProduct: true,
                     groupByProduct: true,
                     term: 'xyz',
                     term: 'xyz',
                 });
                 });
@@ -1475,22 +1499,20 @@ describe('Default search plugin', () => {
                 await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                 await adminClient.query<AssignProductsToChannel.Mutation, AssignProductsToChannel.Variables>(
                     ASSIGN_PRODUCT_TO_CHANNEL,
                     ASSIGN_PRODUCT_TO_CHANNEL,
                     {
                     {
-                        input: { channelId: secondChannel.id, productIds: ['T_4'] },
+                        input: {channelId: secondChannel.id, productIds: ['T_4']},
                     },
                     },
                 );
                 );
                 await awaitRunningJobs(adminClient);
                 await awaitRunningJobs(adminClient);
 
 
                 async function searchSecondChannelForDEProduct() {
                 async function searchSecondChannelForDEProduct() {
                     adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                     adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
-                    const { search } = await adminClient.query<
-                        SearchProductsShop.Query,
-                        SearchProductShopVariables
-                    >(
+                    const {search} = await adminClient.query<SearchProductsShop.Query,
+                        SearchProductShopVariables>(
                         SEARCH_PRODUCTS,
                         SEARCH_PRODUCTS,
                         {
                         {
-                            input: { term: 'product', groupByProduct: true },
+                            input: {term: 'product', groupByProduct: true},
                         },
                         },
-                        { languageCode: LanguageCode.de },
+                        {languageCode: LanguageCode.de},
                     );
                     );
                     return search;
                     return search;
                 }
                 }
@@ -1499,10 +1521,8 @@ describe('Default search plugin', () => {
                 expect(search1.items.map(i => i.productName)).toEqual(['product de']);
                 expect(search1.items.map(i => i.productName)).toEqual(['product de']);
 
 
                 adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
                 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: {
                     input: {
                         productIds: ['T_4'],
                         productIds: ['T_4'],
                         channelId: secondChannel.id,
                         channelId: secondChannel.id,
@@ -1531,10 +1551,8 @@ describe('Default search plugin', () => {
             }
             }
 
 
             beforeAll(async () => {
             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: {
                     input: {
                         id: 'T_1',
                         id: 'T_1',
                         translations: [
                         translations: [
@@ -1575,13 +1593,13 @@ describe('Default search plugin', () => {
             });
             });
 
 
             it('indexes product-level languages', async () => {
             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].productName).toBe('laptop name de');
                 expect(search1.items[0].slug).toBe('laptop-slug-de');
                 expect(search1.items[0].slug).toBe('laptop-slug-de');
                 expect(search1.items[0].description).toBe('laptop description 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].productName).toBe('laptop name zh');
                 expect(search2.items[0].slug).toBe('laptop-slug-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 () => {
             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].productName).toBe('Laptop');
                 expect(search1.items[0].productVariantName).toBe('laptop variant fr');
                 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',
                 passwordHash: 'abc',
             }),
             }),
         );
         );
-        const user = await this.connection.getRepository(ctx, User).save(
+
+        await this.connection.getRepository(ctx, User).insert(
             new User({
             new User({
                 authenticationMethods: [authMethod],
                 authenticationMethods: [authMethod],
                 identifier,
                 identifier,
@@ -45,7 +46,10 @@ class TestUserService {
                 verified: true,
                 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) {
     async createAdministrator(ctx: RequestContext, emailAddress: string, fail: boolean) {
         const user = await this.userService.createUser(ctx, emailAddress);
         const user = await this.userService.createUser(ctx, emailAddress);
+
         if (fail) {
         if (fail) {
             throw new InternalServerError('Failed!');
             throw new InternalServerError('Failed!');
         }
         }
+
         const admin = await this.connection.getRepository(ctx, Administrator).save(
         const admin = await this.connection.getRepository(ctx, Administrator).save(
             new Administrator({
             new Administrator({
                 emailAddress,
                 emailAddress,
@@ -112,7 +118,7 @@ class TestResolver {
     @Mutation()
     @Mutation()
     async createTestAdministrator5(@Ctx() ctx: RequestContext, @Args() args: any) {
     async createTestAdministrator5(@Ctx() ctx: RequestContext, @Args() args: any) {
         if (args.noContext === true) {
         if (args.noContext === true) {
-            return this.connection.withTransaction(ctx, async _ctx => {
+            return this.connection.withTransaction(async _ctx => {
                 const admin = await this.testAdminService.createAdministrator(
                 const admin = await this.testAdminService.createAdministrator(
                     _ctx,
                     _ctx,
                     args.emailAddress,
                     args.emailAddress,
@@ -121,7 +127,7 @@ class TestResolver {
                 return admin;
                 return admin;
             });
             });
         } else {
         } else {
-            return this.connection.withTransaction(async _ctx => {
+            return this.connection.withTransaction(ctx, async _ctx => {
                 const admin = await this.testAdminService.createAdministrator(
                 const admin = await this.testAdminService.createAdministrator(
                     _ctx,
                     _ctx,
                     args.emailAddress,
                     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()
     @Query()
     async verify() {
     async verify() {
         const admins = await this.connection.getRepository(Administrator).find();
         const admins = await this.connection.getRepository(Administrator).find();
@@ -141,6 +202,24 @@ class TestResolver {
             users,
             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({
 @VendurePlugin({
@@ -158,6 +237,8 @@ class TestResolver {
                     fail: Boolean!
                     fail: Boolean!
                     noContext: Boolean!
                     noContext: Boolean!
                 ): Administrator
                 ): Administrator
+                createNTestAdministrators(emailAddress: String!, failFactor: Float!, n: Int!): JSON
+                createNTestAdministrators2(emailAddress: String!, failFactor: Float!, n: Int!): JSON
             }
             }
             type VerifyResult {
             type VerifyResult {
                 admins: [Administrator!]!
                 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;
 type DiscriminateUnion<T, U> = T extends U ? T : never;
 
 
 export namespace TestOrderFragment {
 export namespace TestOrderFragment {
@@ -4269,3 +4279,45 @@ export namespace GetActiveCustomerWithOrdersProductSlug {
         >['product']
         >['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,
     ApplyCouponCode,
     DeletionResult,
     DeletionResult,
     GetActiveCustomerOrderWithItemFulfillments,
     GetActiveCustomerOrderWithItemFulfillments,
+    GetActiveCustomerWithOrdersProductPrice,
     GetActiveCustomerWithOrdersProductSlug,
     GetActiveCustomerWithOrdersProductSlug,
     GetActiveOrder,
     GetActiveOrder,
     GetOrderByCodeWithPayments,
     GetOrderByCodeWithPayments,
@@ -101,6 +102,7 @@ import {
     ADD_ITEM_TO_ORDER,
     ADD_ITEM_TO_ORDER,
     ADD_PAYMENT,
     ADD_PAYMENT,
     APPLY_COUPON_CODE,
     APPLY_COUPON_CODE,
+    GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_PRICE,
     GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_SLUG,
     GET_ACTIVE_CUSTOMER_WITH_ORDERS_PRODUCT_SLUG,
     GET_ACTIVE_ORDER,
     GET_ACTIVE_ORDER,
     GET_ACTIVE_ORDER_CUSTOMER_WITH_ITEM_FULFILLMENTS,
     GET_ACTIVE_ORDER_CUSTOMER_WITH_ITEM_FULFILLMENTS,
@@ -2290,6 +2292,24 @@ describe('Orders resolver', () => {
                     .product.slug,
                     .product.slug,
             ).toBe('gaming-pc');
             ).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);
             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
         // https://github.com/vendure-ecommerce/vendure/issues/800
         it('product can be fetched by slug of a deleted product', async () => {
         it('product can be fetched by slug of a deleted product', async () => {
             const { product } = await adminClient.query<GetProductSimple.Query, GetProductSimple.Variables>(
             const { product } = await adminClient.query<GetProductSimple.Query, GetProductSimple.Variables>(

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@vendure/core",
   "name": "@vendure/core",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "description": "A modern, headless ecommerce framework",
   "description": "A modern, headless ecommerce framework",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -49,7 +49,7 @@
     "@nestjs/testing": "7.6.17",
     "@nestjs/testing": "7.6.17",
     "@nestjs/typeorm": "7.1.5",
     "@nestjs/typeorm": "7.1.5",
     "@types/fs-extra": "^9.0.1",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^1.5.1",
+    "@vendure/common": "^1.5.2",
     "apollo-server-express": "2.24.1",
     "apollo-server-express": "2.24.1",
     "bcrypt": "^5.0.0",
     "bcrypt": "^5.0.0",
     "body-parser": "^1.19.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 { 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
  * @description
@@ -19,11 +19,20 @@ import { REQUEST_CONTEXT_KEY } from '../../common/constants';
  * @docsPage Ctx Decorator
  * @docsPage Ctx Decorator
  */
  */
 export const Ctx = createParamDecorator((data, ctx: ExecutionContext) => {
 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') {
     if (ctx.getType<ContextType | 'graphql'>() === 'graphql') {
         // GraphQL request
         // GraphQL request
-        return ctx.getArgByIndex(2).req[REQUEST_CONTEXT_KEY];
+        return getContext(ctx.getArgByIndex(2).req);
     } else {
     } else {
         // REST request
         // 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 { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { Reflector } from '@nestjs/core';
 import { Observable, of } from 'rxjs';
 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 { TransactionWrapper } from '../../connection/transaction-wrapper';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import { parseContext } from '../common/parse-context';
 import { parseContext } from '../common/parse-context';
@@ -20,24 +21,45 @@ export class TransactionInterceptor implements NestInterceptor {
         private transactionWrapper: TransactionWrapper,
         private transactionWrapper: TransactionWrapper,
         private reflector: Reflector,
         private reflector: Reflector,
     ) {}
     ) {}
+
     intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
     intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
         const { isGraphQL, req } = parseContext(context);
         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) {
         if (ctx) {
             const transactionMode = this.reflector.get<TransactionMode>(
             const transactionMode = this.reflector.get<TransactionMode>(
                 TRANSACTION_MODE_METADATA_KEY,
                 TRANSACTION_MODE_METADATA_KEY,
                 context.getHandler(),
                 context.getHandler(),
             );
             );
+            
             return of(
             return of(
                 this.transactionWrapper.executeInTransaction(
                 this.transactionWrapper.executeInTransaction(
                     ctx,
                     ctx,
-                    () => next.handle(),
+                    (ctx) => {
+                        this.registerTransactionalContext(ctx, context.getHandler(), req);
+
+                        return next.handle()
+                    },
                     transactionMode,
                     transactionMode,
                     this.connection.rawConnection,
                     this.connection.rawConnection,
-                ),
+                )
             );
             );
         } else {
         } else {
             return next.handle();
             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 DEFAULT_LANGUAGE_CODE = LanguageCode.en;
 export const TRANSACTION_MANAGER_KEY = Symbol('TRANSACTION_MANAGER');
 export const TRANSACTION_MANAGER_KEY = Symbol('TRANSACTION_MANAGER');
 export const REQUEST_CONTEXT_KEY = 'vendureRequestContext';
 export const REQUEST_CONTEXT_KEY = 'vendureRequestContext';
+export const REQUEST_CONTEXT_MAP_KEY = 'vendureRequestContextMap';
 export const DEFAULT_PERMISSIONS: PermissionDefinition[] = [
 export const DEFAULT_PERMISSIONS: PermissionDefinition[] = [
     new PermissionDefinition({
     new PermissionDefinition({
         name: 'Authenticated',
         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
      * 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
      * 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.
      * 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>(
     async executeInTransaction<T>(
-        ctx: RequestContext,
-        work: () => Observable<T> | Promise<T>,
+        originalCtx: RequestContext,
+        work: (ctx: RequestContext) => Observable<T> | Promise<T>,
         mode: TransactionMode,
         mode: TransactionMode,
         connection: Connection,
         connection: Connection,
     ): Promise<T> {
     ): 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];
         const queryRunnerExists = !!(ctx as any)[TRANSACTION_MANAGER_KEY];
         if (queryRunnerExists) {
         if (queryRunnerExists) {
             // If a QueryRunner already exists on the RequestContext, there must be an existing
             // 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
             // outer transaction in progress. In that case, we just execute the work function
             // as usual without needing to further wrap in a transaction.
             // as usual without needing to further wrap in a transaction.
-            return from(work()).toPromise();
+            return from(work(ctx)).toPromise();
         }
         }
         const queryRunner = connection.createQueryRunner();
         const queryRunner = connection.createQueryRunner();
         if (mode === 'auto') {
         if (mode === 'auto') {
@@ -40,7 +47,7 @@ export class TransactionWrapper {
 
 
         try {
         try {
             const maxRetries = 5;
             const maxRetries = 5;
-            const result = await from(work())
+            const result = await from(work(ctx))
                 .pipe(
                 .pipe(
                     retryWhen(errors =>
                     retryWhen(errors =>
                         errors.pipe(
                         errors.pipe(

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

@@ -73,8 +73,9 @@ export class TransactionalConnection {
     ): Repository<Entity> {
     ): Repository<Entity> {
         if (ctxOrTarget instanceof RequestContext) {
         if (ctxOrTarget instanceof RequestContext) {
             const transactionManager = this.getTransactionManager(ctxOrTarget);
             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 {
             } else {
                 // tslint:disable-next-line:no-non-null-assertion
                 // tslint:disable-next-line:no-non-null-assertion
                 return getRepository(maybeTarget!);
                 return getRepository(maybeTarget!);
@@ -101,15 +102,19 @@ export class TransactionalConnection {
      * of Vendure internal services.
      * of Vendure internal services.
      *
      *
      * If there is already a {@link RequestContext} object available, you should pass it in as the first
      * 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
      * RequestContext object will be created, which is then used to propagate the transaction to
      * all inner method calls.
      * all inner method calls.
      *
      *
      * @example
      * @example
      * ```TypeScript
      * ```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);
      *     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,
      *     // If some intermediate logic here throws an Error,
      *     // then all DB transactions will be rolled back and neither Customer's
      *     // then all DB transactions will be rolled back and neither Customer's
@@ -138,7 +143,7 @@ export class TransactionalConnection {
             ctx = RequestContext.empty();
             ctx = RequestContext.empty();
             work = ctxOrWork;
             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
      * * https://github.com/vendure-ecommerce/vendure/issues/1107
      */
      */
     private async awaitActiveTransactions<T extends VendureEvent>(event: T): Promise<T> {
     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;
             return event;
         }
         }
+
+        const [key, ctx]: [string, RequestContext] = entry;
+        
         const transactionManager: EntityManager | undefined = (ctx as any)[TRANSACTION_MANAGER_KEY];
         const transactionManager: EntityManager | undefined = (ctx as any)[TRANSACTION_MANAGER_KEY];
         if (!transactionManager?.queryRunner) {
         if (!transactionManager?.queryRunner) {
             return event;
             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, '-');
                     t.slug = normalizeString(t.slug, '-');
                     let match: E | undefined;
                     let match: E | undefined;
                     let suffix = 1;
                     let suffix = 1;
+                    const seen: ID[] = [];
                     const alreadySuffixed = /-\d+$/;
                     const alreadySuffixed = /-\d+$/;
                     do {
                     do {
                         const qb = this.connection
                         const qb = this.connection
@@ -52,20 +53,27 @@ export class SlugValidator {
                             .where(`translation.slug = :slug`, { slug: t.slug })
                             .where(`translation.slug = :slug`, { slug: t.slug })
                             .andWhere(`translation.languageCode = :languageCode`, {
                             .andWhere(`translation.languageCode = :languageCode`, {
                                 languageCode: t.languageCode,
                                 languageCode: t.languageCode,
-                            });
+                            })
                         if (input.id) {
                         if (input.id) {
                             qb.andWhere(`translation.base != :id`, { id: 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();
                         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 {
                             } 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)) {
     for (const [key, value] of Object.entries(translation)) {
         if (key === 'customFields') {
         if (key === 'customFields') {

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

@@ -306,8 +306,6 @@ export class OrderService {
                 relations: relations ?? [
                 relations: relations ?? [
                     'lines',
                     'lines',
                     'lines.items',
                     'lines.items',
-                    'lines.productVariant',
-                    'lines.productVariant.options',
                     'customer',
                     'customer',
                     'channels',
                     'channels',
                     'shippingLines',
                     '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>> {
     async getVariantByOrderLineId(ctx: RequestContext, orderLineId: ID): Promise<Translated<ProductVariant>> {
         const { productVariant } = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLineId, {
         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,
                             ctx,
                             ProductVariant,
                             ProductVariant,
                             variant.id,
                             variant.id,
-                            { relations: ['productVariantPrices'] },
+                            { relations: ['productVariantPrices'], includeSoftDeleted: true },
                         );
                         );
                         variant.productVariantPrices = variantWithPrices.productVariantPrices;
                         variant.productVariantPrices = variantWithPrices.productVariantPrices;
                     }
                     }
@@ -576,7 +577,7 @@ export class ProductVariantService {
                             ctx,
                             ctx,
                             ProductVariant,
                             ProductVariant,
                             variant.id,
                             variant.id,
-                            { relations: ['taxCategory'] },
+                            { relations: ['taxCategory'], includeSoftDeleted: true },
                         );
                         );
                         variant.taxCategory = variantWithTaxCategory.taxCategory;
                         variant.taxCategory = variantWithTaxCategory.taxCategory;
                     }
                     }

+ 3 - 3
packages/create/package.json

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "dev-server",
   "name": "dev-server",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "main": "index.js",
   "main": "index.js",
   "license": "MIT",
   "license": "MIT",
   "private": true,
   "private": true,
@@ -14,18 +14,18 @@
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   },
   "dependencies": {
   "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"
     "typescript": "4.3.5"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
     "@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",
     "commander": "^7.1.0",
     "concurrently": "^5.0.0",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3",
     "csv-stringify": "^5.3.3",

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

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

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

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

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

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

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

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

+ 3 - 3
packages/testing/package.json

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@vendure/ui-devkit",
   "name": "@vendure/ui-devkit",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "description": "A library for authoring Vendure Admin UI extensions",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
   "keywords": [
     "vendure",
     "vendure",
@@ -40,8 +40,8 @@
     "@angular/cli": "12.2.16",
     "@angular/cli": "12.2.16",
     "@angular/compiler": "12.2.16",
     "@angular/compiler": "12.2.16",
     "@angular/compiler-cli": "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",
     "chalk": "^4.1.0",
     "chokidar": "^3.5.1",
     "chokidar": "^3.5.1",
     "fs-extra": "^10.0.0",
     "fs-extra": "^10.0.0",
@@ -52,7 +52,7 @@
     "@rollup/plugin-node-resolve": "^11.2.0",
     "@rollup/plugin-node-resolve": "^11.2.0",
     "@types/fs-extra": "^9.0.8",
     "@types/fs-extra": "^9.0.8",
     "@types/glob": "^7.1.3",
     "@types/glob": "^7.1.3",
-    "@vendure/core": "^1.5.1",
+    "@vendure/core": "^1.5.2",
     "rimraf": "^3.0.2",
     "rimraf": "^3.0.2",
     "rollup": "^2.40.0",
     "rollup": "^2.40.0",
     "rollup-plugin-terser": "^7.0.2",
     "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"
   resolved "https://registry.npmjs.org/@cds/city/-/city-1.1.0.tgz#5b7323750d3d64671ce2e3a804bcf260fbea1154"
   integrity sha512-S9K+Q39BGOghyLHmR0Wdcmu1i1noSUk8HcvMj+3IaohZw02WFd99aPTQDHJeseXrXZP3CNovaSlePI0R11NcFg==
   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:
   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:
   optionalDependencies:
     "@cds/city" "^1.1.0"
     "@cds/city" "^1.1.0"
     "@types/resize-observer-browser" "^0.1.5"
     "@types/resize-observer-browser" "^0.1.5"
     modern-normalize "1.1.0"
     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:
   dependencies:
-    tslib "^2.1.0"
+    tslib "^2.2.0"
 
 
 "@clr/city@^1.1.0":
 "@clr/city@^1.1.0":
   version "1.1.0"
   version "1.1.0"
@@ -1468,10 +1468,10 @@
     css-vars-ponyfill "^2.3.2"
     css-vars-ponyfill "^2.3.2"
     normalize.css "^8.0.1"
     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":
 "@clr/ui@^12.0.2":
   version "12.0.2"
   version "12.0.2"
@@ -3149,10 +3149,10 @@
     npmlog "^4.1.2"
     npmlog "^4.1.2"
     write-file-atomic "^3.0.3"
     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":
 "@mapbox/node-pre-gyp@^1.0.0":
   version "1.0.5"
   version "1.0.5"
@@ -4431,10 +4431,10 @@
   resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
   resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
   integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
   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@*":
 "@types/undertaker-registry@*":
   version "1.0.1"
   version "1.0.1"
@@ -12338,34 +12338,34 @@ lit-element@^2.3.1:
   dependencies:
   dependencies:
     lit-html "^1.1.1"
     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:
   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:
 lit-html@^1.1.1, lit-html@^1.2.1:
   version "1.4.1"
   version "1.4.1"
   resolved "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz#0c6f3ee4ad4eb610a49831787f0478ad8e9ae5e0"
   resolved "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz#0c6f3ee4ad4eb610a49831787f0478ad8e9ae5e0"
   integrity sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==
   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:
   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:
   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:
 load-json-file@^1.0.0:
   version "1.1.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"
   resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
   integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
   integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
 
 
-ramda@^0.27.0, ramda@^0.27.1:
+ramda@^0.27.0:
   version "0.27.1"
   version "0.27.1"
   resolved "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9"
   resolved "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9"
   integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==
   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:
 randombytes@^2.1.0:
   version "2.1.0"
   version "2.1.0"
   resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
   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"
   resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
   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"
   version "2.3.1"
   resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
   resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
   integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
   integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==