Browse Source

Merge branch 'master' into next

Michael Bromley 5 years ago
parent
commit
fec1ed16f0
32 changed files with 485 additions and 116 deletions
  1. 16 0
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 2 2
      packages/admin-ui-plugin/package.json
  4. 1 1
      packages/admin-ui/package.json
  5. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  6. 10 1
      packages/admin-ui/src/lib/core/src/data/data.module.ts
  7. 1 1
      packages/admin-ui/src/lib/core/src/public_api.ts
  8. 1 1
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html
  9. 4 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.scss
  10. 22 12
      packages/admin-ui/src/lib/settings/src/components/country-detail/country-detail.component.ts
  11. 2 2
      packages/asset-server-plugin/package.json
  12. 86 0
      packages/core/e2e/fixtures/test-plugins/slow-mutation-plugin.ts
  13. 50 0
      packages/core/e2e/graphql/shared-definitions.ts
  14. 143 0
      packages/core/e2e/parallel-transactions.e2e-spec.ts
  15. 2 28
      packages/core/e2e/product-option.e2e-spec.ts
  16. 2 24
      packages/core/e2e/product.e2e-spec.ts
  17. 1 1
      packages/core/package.json
  18. 71 6
      packages/core/src/api/middleware/transaction-interceptor.ts
  19. 2 1
      packages/core/src/config/promotion/actions/facet-values-discount-action.ts
  20. 1 3
      packages/core/src/config/promotion/conditions/contains-products-condition.ts
  21. 1 3
      packages/core/src/config/promotion/conditions/customer-group-condition.ts
  22. 3 4
      packages/core/src/config/promotion/conditions/has-facet-values-condition.ts
  23. 1 3
      packages/core/src/config/promotion/conditions/min-order-amount-condition.ts
  24. 1 0
      packages/core/src/config/promotion/index.ts
  25. 40 2
      packages/core/src/config/promotion/utils/facet-value-checker.ts
  26. 2 2
      packages/create/package.json
  27. 8 8
      packages/dev-server/package.json
  28. 2 2
      packages/elasticsearch-plugin/package.json
  29. 2 2
      packages/email-plugin/package.json
  30. 2 2
      packages/testing/package.json
  31. 3 3
      packages/ui-devkit/package.json
  32. 1 0
      scripts/codegen/generate-graphql-types.ts

+ 16 - 0
CHANGELOG.md

@@ -1,3 +1,19 @@
+## <small>0.16.3 (2020-11-05)</small>
+
+
+#### Fixes
+
+* **admin-ui** Add missing I18n state tokens ([215a637](https://github.com/vendure-ecommerce/vendure/commit/215a637))
+* **admin-ui** Fix Apollo cache warning for GlobalSettings.serverConfig ([8b135ad](https://github.com/vendure-ecommerce/vendure/commit/8b135ad))
+* **admin-ui** Fix CustomerGroupList layout in Firefox ([c432a14](https://github.com/vendure-ecommerce/vendure/commit/c432a14)), closes [#531](https://github.com/vendure-ecommerce/vendure/issues/531)
+* **admin-ui** Fix overflow that made ui unusable on mobile ([f129e0c](https://github.com/vendure-ecommerce/vendure/commit/f129e0c))
+* **admin-ui** Fix saving countries in other languages ([11a1004](https://github.com/vendure-ecommerce/vendure/commit/11a1004)), closes [#528](https://github.com/vendure-ecommerce/vendure/issues/528)
+* **core** Add retry logic in case of transaction deadlocks ([3b60bcb](https://github.com/vendure-ecommerce/vendure/commit/3b60bcb)), closes [#527](https://github.com/vendure-ecommerce/vendure/issues/527)
+
+#### Features
+
+* **core** Export FacetValueChecker promotion utility ([fc3890e](https://github.com/vendure-ecommerce/vendure/commit/fc3890e))
+
 ## <small>0.16.2 (2020-10-22)</small>
 
 

+ 1 - 1
lerna.json

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -20,7 +20,7 @@
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.1",
     "@vendure/common": "^0.16.2",
-    "@vendure/core": "^0.16.2",
+    "@vendure/core": "^0.16.3",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "license": "MIT",
   "scripts": {
     "ng": "ng",

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

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

+ 10 - 1
packages/admin-ui/src/lib/core/src/data/data.module.ts

@@ -1,4 +1,4 @@
-import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
+import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
 import { APP_INITIALIZER, Injector, NgModule } from '@angular/core';
 import { ApolloClientOptions, InMemoryCache } from '@apollo/client/core';
 import { setContext } from '@apollo/client/link/context';
@@ -31,6 +31,15 @@ export function createApollo(
     const serverLocation = getServerLocation();
     const apolloCache = new InMemoryCache({
         possibleTypes: introspectionResult.possibleTypes,
+        typePolicies: {
+            GlobalSettings: {
+                fields: {
+                    serverConfig: {
+                        merge: (existing, incoming) => ({ ...existing, ...incoming }),
+                    },
+                },
+            },
+        },
     });
     apolloCache.writeQuery({
         query: GET_CLIENT_STATE,

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

@@ -175,9 +175,9 @@ export * from './shared/pipes/custom-field-label.pipe';
 export * from './shared/pipes/duration.pipe';
 export * from './shared/pipes/file-size.pipe';
 export * from './shared/pipes/has-permission.pipe';
-export * from './shared/pipes/state-i18n-token.pipe';
 export * from './shared/pipes/sentence-case.pipe';
 export * from './shared/pipes/sort.pipe';
+export * from './shared/pipes/state-i18n-token.pipe';
 export * from './shared/pipes/string-to-color.pipe';
 export * from './shared/pipes/time-ago.pipe';
 export * from './shared/providers/routing/can-deactivate-detail-guard';

+ 1 - 1
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html

@@ -9,7 +9,7 @@
     </vdr-ab-right>
 </vdr-action-bar>
 <div class="group-wrapper">
-    <table class="table group-list" *ngIf="!(listIsEmpty$ | async); else emptyPlaceholder">
+    <table class="table group-list" [class.expanded]="activeGroup$ | async" *ngIf="!(listIsEmpty$ | async); else emptyPlaceholder">
         <tbody>
             <tr *ngFor="let group of groups$ | async" [class.active]="group.id === (activeGroup$ | async)?.id">
                 <td class="left align-middle"><vdr-entity-info [entity]="group"></vdr-entity-info></td>

+ 4 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.scss

@@ -12,6 +12,10 @@
         tr.active {
             background-color: $color-grey-200;
         }
+        &.expanded {
+            // Fix for Firefox layout https://github.com/vendure-ecommerce/vendure/issues/531
+            width: calc(100% - 40vw);
+        }
     }
 }
 .group-members {

+ 22 - 12
packages/admin-ui/src/lib/settings/src/components/country-detail/country-detail.component.ts

@@ -2,12 +2,17 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { BaseDetailComponent } from '@vendure/admin-ui/core';
-import { Country, CreateCountryInput, LanguageCode, UpdateCountryInput } from '@vendure/admin-ui/core';
-import { createUpdatedTranslatable } from '@vendure/admin-ui/core';
-import { NotificationService } from '@vendure/admin-ui/core';
-import { DataService } from '@vendure/admin-ui/core';
-import { ServerConfigService } from '@vendure/admin-ui/core';
+import {
+    BaseDetailComponent,
+    Country,
+    CreateCountryInput,
+    createUpdatedTranslatable,
+    DataService,
+    LanguageCode,
+    NotificationService,
+    ServerConfigService,
+    UpdateCountryInput,
+} from '@vendure/admin-ui/core';
 import { combineLatest, Observable } from 'rxjs';
 import { mergeMap, take } from 'rxjs/operators';
 
@@ -16,7 +21,8 @@ import { mergeMap, take } from 'rxjs/operators';
     templateUrl: './country-detail.component.html',
     styleUrls: ['./country-detail.component.scss'],
 })
-export class CountryDetailComponent extends BaseDetailComponent<Country.Fragment>
+export class CountryDetailComponent
+    extends BaseDetailComponent<Country.Fragment>
     implements OnInit, OnDestroy {
     country$: Observable<Country.Fragment>;
     detailForm: FormGroup;
@@ -69,7 +75,7 @@ export class CountryDetailComponent extends BaseDetailComponent<Country.Fragment
                 }),
             )
             .subscribe(
-                (data) => {
+                data => {
                     this.notificationService.success(_('common.notify-create-success'), {
                         entity: 'Country',
                     });
@@ -77,7 +83,7 @@ export class CountryDetailComponent extends BaseDetailComponent<Country.Fragment
                     this.changeDetector.markForCheck();
                     this.router.navigate(['../', data.createCountry.id], { relativeTo: this.route });
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-create-error'), {
                         entity: 'Country',
                     });
@@ -95,19 +101,23 @@ export class CountryDetailComponent extends BaseDetailComponent<Country.Fragment
                         translatable: country,
                         updatedFields: formValue,
                         languageCode,
+                        defaultTranslation: {
+                            name: formValue.name,
+                            languageCode,
+                        },
                     });
                     return this.dataService.settings.updateCountry(input);
                 }),
             )
             .subscribe(
-                (data) => {
+                data => {
                     this.notificationService.success(_('common.notify-update-success'), {
                         entity: 'Country',
                     });
                     this.detailForm.markAsPristine();
                     this.changeDetector.markForCheck();
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Country',
                     });
@@ -116,7 +126,7 @@ export class CountryDetailComponent extends BaseDetailComponent<Country.Fragment
     }
 
     protected setFormValues(country: Country, languageCode: LanguageCode): void {
-        const currentTranslation = country.translations.find((t) => t.languageCode === languageCode);
+        const currentTranslation = country.translations.find(t => t.languageCode === languageCode);
 
         this.detailForm.patchValue({
             code: country.code,

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/asset-server-plugin",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -23,7 +23,7 @@
     "@types/node-fetch": "^2.5.7",
     "@types/sharp": "^0.26.0",
     "@vendure/common": "^0.16.2",
-    "@vendure/core": "^0.16.2",
+    "@vendure/core": "^0.16.3",
     "aws-sdk": "^2.766.0",
     "express": "^4.17.1",
     "node-fetch": "^2.6.1",

+ 86 - 0
packages/core/e2e/fixtures/test-plugins/slow-mutation-plugin.ts

@@ -0,0 +1,86 @@
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+import {
+    Asset,
+    AssetType,
+    Country,
+    Ctx,
+    PluginCommonModule,
+    Product,
+    ProductAsset,
+    RequestContext,
+    TaxCategory,
+    TaxRate,
+    Transaction,
+    TransactionalConnection,
+    VendurePlugin,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+
+@Resolver()
+export class SlowMutationResolver {
+    constructor(private connection: TransactionalConnection) {}
+
+    /**
+     * A mutation which simulates some slow DB operations occurring within a transaction.
+     */
+    @Transaction()
+    @Mutation()
+    async slowMutation(@Ctx() ctx: RequestContext, @Args() args: { delay: number }) {
+        const delay = Math.round(args.delay / 2);
+        const country = await this.connection.getRepository(ctx, Country).findOneOrFail({
+            where: {
+                code: 'AT',
+            },
+        });
+        country.enabled = false;
+        await new Promise(resolve => setTimeout(resolve, delay));
+        await this.connection.getRepository(ctx, Country).save(country);
+        country.enabled = true;
+        await new Promise(resolve => setTimeout(resolve, delay));
+        await this.connection.getRepository(ctx, Country).save(country);
+        return true;
+    }
+
+    /**
+     * This mutation attempts to cause a deadlock
+     */
+    @Transaction()
+    @Mutation()
+    async attemptDeadlock(@Ctx() ctx: RequestContext) {
+        const product = await this.connection.getRepository(ctx, Product).findOneOrFail(1);
+        const asset = await this.connection.getRepository(ctx, Asset).save(
+            new Asset({
+                name: 'test',
+                type: AssetType.BINARY,
+                mimeType: 'test/test',
+                fileSize: 1,
+                source: '',
+                preview: '',
+            }),
+        );
+        await new Promise(resolve => setTimeout(resolve, 100));
+        const productAsset = await this.connection.getRepository(ctx, ProductAsset).save(
+            new ProductAsset({
+                assetId: asset.id,
+                productId: product.id,
+                position: 0,
+            }),
+        );
+        await this.connection.getRepository(ctx, Product).update(product.id, { enabled: false });
+        return true;
+    }
+}
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    adminApiExtensions: {
+        resolvers: [SlowMutationResolver],
+        schema: gql`
+            extend type Mutation {
+                slowMutation(delay: Int!): Boolean!
+                attemptDeadlock: Boolean!
+            }
+        `,
+    },
+})
+export class SlowMutationPlugin {}

+ 50 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -735,3 +735,53 @@ export const GET_PRODUCTS_WITH_VARIANT_PRICES = gql`
         }
     }
 `;
+
+export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
+    fragment ProductOptionGroup on ProductOptionGroup {
+        id
+        code
+        name
+        options {
+            id
+            code
+            name
+        }
+        translations {
+            id
+            languageCode
+            name
+        }
+    }
+`;
+
+export const CREATE_PRODUCT_OPTION_GROUP = gql`
+    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
+        createProductOptionGroup(input: $input) {
+            ...ProductOptionGroup
+        }
+    }
+    ${PRODUCT_OPTION_GROUP_FRAGMENT}
+`;
+
+export const PRODUCT_WITH_OPTIONS_FRAGMENT = gql`
+    fragment ProductWithOptions on Product {
+        id
+        optionGroups {
+            id
+            code
+            options {
+                id
+                code
+            }
+        }
+    }
+`;
+
+export const ADD_OPTION_GROUP_TO_PRODUCT = gql`
+    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
+        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
+            ...ProductWithOptions
+        }
+    }
+    ${PRODUCT_WITH_OPTIONS_FRAGMENT}
+`;

+ 143 - 0
packages/core/e2e/parallel-transactions.e2e-spec.ts

@@ -0,0 +1,143 @@
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { createTestEnvironment } from '../../testing/lib/create-test-environment';
+
+import { SlowMutationPlugin } from './fixtures/test-plugins/slow-mutation-plugin';
+import {
+    AddOptionGroupToProduct,
+    CreateProduct,
+    CreateProductOptionGroup,
+    CreateProductVariants,
+    LanguageCode,
+} from './graphql/generated-e2e-admin-types';
+import {
+    ADD_OPTION_GROUP_TO_PRODUCT,
+    CREATE_PRODUCT,
+    CREATE_PRODUCT_OPTION_GROUP,
+    CREATE_PRODUCT_VARIANTS,
+} from './graphql/shared-definitions';
+
+describe('Parallel transactions', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        plugins: [SlowMutationPlugin],
+    });
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('does not fail on many concurrent, slow transactions', async () => {
+        const CONCURRENCY_LIMIT = 20;
+
+        const slowMutations = Array.from({ length: CONCURRENCY_LIMIT }).map(i =>
+            adminClient.query(SLOW_MUTATION, { delay: 50 }),
+        );
+        const result = await Promise.all(slowMutations);
+
+        expect(result).toEqual(Array.from({ length: CONCURRENCY_LIMIT }).map(() => ({ slowMutation: true })));
+    }, 100000);
+
+    it('does not fail on attempted deadlock', async () => {
+        const CONCURRENCY_LIMIT = 4;
+
+        const slowMutations = Array.from({ length: CONCURRENCY_LIMIT }).map(i =>
+            adminClient.query(ATTEMPT_DEADLOCK),
+        );
+        const result = await Promise.all(slowMutations);
+
+        expect(result).toEqual(
+            Array.from({ length: CONCURRENCY_LIMIT }).map(() => ({ attemptDeadlock: true })),
+        );
+    }, 100000);
+
+    // A real-world error-case originally reported in https://github.com/vendure-ecommerce/vendure/issues/527
+    it('does not deadlock on concurrent creating ProductVariants', async () => {
+        const CONCURRENCY_LIMIT = 4;
+
+        const { createProduct } = await adminClient.query<CreateProduct.Mutation, CreateProduct.Variables>(
+            CREATE_PRODUCT,
+            {
+                input: {
+                    translations: [
+                        { languageCode: LanguageCode.en, name: 'Test', slug: 'test', description: 'test' },
+                    ],
+                },
+            },
+        );
+
+        const sizes = Array.from({ length: CONCURRENCY_LIMIT }).map(i => `size-${i}`);
+
+        const { createProductOptionGroup } = await adminClient.query<
+            CreateProductOptionGroup.Mutation,
+            CreateProductOptionGroup.Variables
+        >(CREATE_PRODUCT_OPTION_GROUP, {
+            input: {
+                code: 'size',
+                options: sizes.map(size => ({
+                    code: size,
+                    translations: [{ languageCode: LanguageCode.en, name: size }],
+                })),
+                translations: [{ languageCode: LanguageCode.en, name: 'size' }],
+            },
+        });
+
+        await adminClient.query<AddOptionGroupToProduct.Mutation, AddOptionGroupToProduct.Variables>(
+            ADD_OPTION_GROUP_TO_PRODUCT,
+            {
+                productId: createProduct.id,
+                optionGroupId: createProductOptionGroup.id,
+            },
+        );
+
+        const createVariantMutations = createProductOptionGroup.options
+            .filter((_, index) => index < CONCURRENCY_LIMIT)
+            .map((option, i) => {
+                return adminClient.query<CreateProductVariants.Mutation, CreateProductVariants.Variables>(
+                    CREATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                sku: `VARIANT-${i}`,
+                                productId: createProduct.id,
+                                optionIds: [option.id],
+                                translations: [{ languageCode: LanguageCode.en, name: `Variant ${i}` }],
+                                price: 1000,
+                                taxCategoryId: 'T_1',
+                                facetValueIds: ['T_1', 'T_2'],
+                                featuredAssetId: 'T_1',
+                                assetIds: ['T_1'],
+                            },
+                        ],
+                    },
+                );
+            });
+
+        const results = await Promise.all(createVariantMutations);
+        expect(results.length).toBe(CONCURRENCY_LIMIT);
+    }, 100000);
+});
+
+const SLOW_MUTATION = gql`
+    mutation SlowMutation($delay: Int!) {
+        slowMutation(delay: $delay)
+    }
+`;
+
+const ATTEMPT_DEADLOCK = gql`
+    mutation AttemptDeadlock {
+        attemptDeadlock
+    }
+`;

+ 2 - 28
packages/core/e2e/product-option.e2e-spec.ts

@@ -3,7 +3,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { omit } from '../../common/lib/omit';
 
 import {
@@ -14,6 +14,7 @@ import {
     UpdateProductOption,
     UpdateProductOptionGroup,
 } from './graphql/generated-e2e-admin-types';
+import { CREATE_PRODUCT_OPTION_GROUP, PRODUCT_OPTION_GROUP_FRAGMENT } from './graphql/shared-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
@@ -150,33 +151,6 @@ describe('ProductOption resolver', () => {
     });
 });
 
-const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
-    fragment ProductOptionGroup on ProductOptionGroup {
-        id
-        code
-        name
-        options {
-            id
-            code
-            name
-        }
-        translations {
-            id
-            languageCode
-            name
-        }
-    }
-`;
-
-const CREATE_PRODUCT_OPTION_GROUP = gql`
-    mutation CreateProductOptionGroup($input: CreateProductOptionGroupInput!) {
-        createProductOptionGroup(input: $input) {
-            ...ProductOptionGroup
-        }
-    }
-    ${PRODUCT_OPTION_GROUP_FRAGMENT}
-`;
-
 const UPDATE_PRODUCT_OPTION_GROUP = gql`
     mutation UpdateProductOptionGroup($input: UpdateProductOptionGroupInput!) {
         updateProductOptionGroup(input: $input) {

+ 2 - 24
packages/core/e2e/product.e2e-spec.ts

@@ -32,6 +32,7 @@ import {
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
 import {
+    ADD_OPTION_GROUP_TO_PRODUCT,
     CREATE_PRODUCT,
     CREATE_PRODUCT_VARIANTS,
     DELETE_PRODUCT,
@@ -40,11 +41,11 @@ import {
     GET_PRODUCT_LIST,
     GET_PRODUCT_SIMPLE,
     GET_PRODUCT_WITH_VARIANTS,
+    PRODUCT_WITH_OPTIONS_FRAGMENT,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
-import { sortById } from './utils/test-order-utils';
 
 // tslint:disable:no-non-null-assertion
 
@@ -1168,29 +1169,6 @@ describe('Product resolver', () => {
     });
 });
 
-const PRODUCT_WITH_OPTIONS_FRAGMENT = gql`
-    fragment ProductWithOptions on Product {
-        id
-        optionGroups {
-            id
-            code
-            options {
-                id
-                code
-            }
-        }
-    }
-`;
-
-export const ADD_OPTION_GROUP_TO_PRODUCT = gql`
-    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
-        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
-            ...ProductWithOptions
-        }
-    }
-    ${PRODUCT_WITH_OPTIONS_FRAGMENT}
-`;
-
 export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`
     mutation RemoveOptionGroupFromProduct($productId: ID!, $optionGroupId: ID!) {
         removeOptionGroupFromProduct(productId: $productId, optionGroupId: $optionGroupId) {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/core",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "description": "A modern, headless ecommerce framework",
   "repository": {
     "type": "git",

+ 71 - 6
packages/core/src/api/middleware/transaction-interceptor.ts

@@ -1,12 +1,15 @@
 import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { Observable, of } from 'rxjs';
+import { retryWhen, take, tap } from 'rxjs/operators';
+import { QueryRunner } from 'typeorm';
+import { TransactionAlreadyStartedError } from 'typeorm/error/TransactionAlreadyStartedError';
 
 import { REQUEST_CONTEXT_KEY, TRANSACTION_MANAGER_KEY } from '../../common/constants';
 import { TransactionalConnection } from '../../service/transaction/transactional-connection';
 import { parseContext } from '../common/parse-context';
 import { RequestContext } from '../common/request-context';
-import { TRANSACTION_MODE_METADATA_KEY, TransactionMode } from '../decorators/transaction.decorator';
+import { TransactionMode, TRANSACTION_MODE_METADATA_KEY } from '../decorators/transaction.decorator';
 
 /**
  * @description
@@ -24,7 +27,7 @@ export class TransactionInterceptor implements NestInterceptor {
                 TRANSACTION_MODE_METADATA_KEY,
                 context.getHandler(),
             );
-            return of(this.withTransaction(ctx, () => next.handle().toPromise(), transactionMode));
+            return of(this.withTransaction(ctx, () => next.handle(), transactionMode));
         } else {
             return next.handle();
         }
@@ -34,22 +37,40 @@ export class TransactionInterceptor implements NestInterceptor {
      * @description
      * Executes the `work` function within the context of a transaction.
      */
-    private async withTransaction<T>(ctx: RequestContext, work: () => T, mode: TransactionMode): Promise<T> {
+    private async withTransaction<T>(
+        ctx: RequestContext,
+        work: () => Observable<T>,
+        mode: TransactionMode,
+    ): Promise<T> {
         const queryRunnerExists = !!(ctx as any)[TRANSACTION_MANAGER_KEY];
         if (queryRunnerExists) {
             // If a QueryRunner already exists on the RequestContext, there must be an existing
             // outer transaction in progress. In that case, we just execute the work function
             // as usual without needing to further wrap in a transaction.
-            return work();
+            return work().toPromise();
         }
         const queryRunner = this.connection.rawConnection.createQueryRunner();
         if (mode === 'auto') {
-            await queryRunner.startTransaction();
+            await this.startTransaction(queryRunner);
         }
         (ctx as any)[TRANSACTION_MANAGER_KEY] = queryRunner.manager;
 
         try {
-            const result = await work();
+            const maxRetries = 5;
+            const result = await work()
+                .pipe(
+                    retryWhen(errors =>
+                        errors.pipe(
+                            tap(err => {
+                                if (!this.isRetriableError(err)) {
+                                    throw err;
+                                }
+                            }),
+                            take(maxRetries),
+                        ),
+                    ),
+                )
+                .toPromise();
             if (queryRunner.isTransactionActive) {
                 await queryRunner.commitTransaction();
             }
@@ -65,4 +86,48 @@ export class TransactionInterceptor implements NestInterceptor {
             }
         }
     }
+
+    /**
+     * Attempts to start a DB transaction, with retry logic in the case that a transaction
+     * is already started for the connection (which is mainly a problem with SQLite/Sql.js)
+     */
+    private async startTransaction(queryRunner: QueryRunner) {
+        const maxRetries = 25;
+        let attempts = 0;
+        let lastError: any;
+        // Returns false if a transaction is already in progress
+        async function attemptStartTransaction(): Promise<boolean> {
+            try {
+                await queryRunner.startTransaction();
+                return true;
+            } catch (err) {
+                lastError = err;
+                if (err instanceof TransactionAlreadyStartedError) {
+                    return false;
+                }
+                throw err;
+            }
+        }
+        while (attempts < maxRetries) {
+            const result = await attemptStartTransaction();
+            if (result) {
+                return;
+            }
+            attempts++;
+            // insert an increasing delay before retrying
+            await new Promise(resolve => setTimeout(resolve, attempts * 20));
+        }
+        throw lastError;
+    }
+
+    /**
+     * If the resolver function throws an error, there are certain cases in which
+     * we want to retry the whole thing again - notably in the case of a deadlock
+     * situation, which can usually be retried with success.
+     */
+    private isRetriableError(err: any): boolean {
+        const mysqlDeadlock = err.code === 'ER_LOCK_DEADLOCK';
+        const postgresDeadlock = err.code === 'deadlock_detected';
+        return mysqlDeadlock || postgresDeadlock;
+    }
 }

+ 2 - 1
packages/core/src/config/promotion/actions/facet-values-discount-action.ts

@@ -1,5 +1,6 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
+import { TransactionalConnection } from '../../../service/transaction/transactional-connection';
 import { PromotionItemAction } from '../promotion-action';
 import { FacetValueChecker } from '../utils/facet-value-checker';
 
@@ -22,7 +23,7 @@ export const discountOnItemWithFacets = new PromotionItemAction({
         },
     },
     init(injector) {
-        facetValueChecker = new FacetValueChecker(injector.getConnection());
+        facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
     },
     async execute(ctx, orderItem, orderLine, args) {
         if (await facetValueChecker.hasFacetValues(orderLine, args.facets)) {

+ 1 - 3
packages/core/src/config/promotion/conditions/contains-products-condition.ts

@@ -1,10 +1,8 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
-import { RequestContext } from '../../../api/common/request-context';
 import { idsAreEqual } from '../../../common/utils';
 import { OrderLine } from '../../../entity/order-line/order-line.entity';
-import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
 
 export const containsProducts = new PromotionCondition({
@@ -21,7 +19,7 @@ export const containsProducts = new PromotionCondition({
             label: [{ languageCode: LanguageCode.en, value: 'Product variants' }],
         },
     },
-    async check(ctx: RequestContext, order: Order, args) {
+    async check(ctx, order, args) {
         const ids = args.productVariantIds;
         let matches = 0;
         for (const line of order.lines) {

+ 1 - 3
packages/core/src/config/promotion/conditions/customer-group-condition.ts

@@ -1,10 +1,8 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
-import { RequestContext } from '../../../api/common/request-context';
 import { TtlCache } from '../../../common/ttl-cache';
 import { idsAreEqual } from '../../../common/utils';
-import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
 
 let customerService: import('../../../service/services/customer.service').CustomerService;
@@ -27,7 +25,7 @@ export const customerGroup = new PromotionCondition({
         const { CustomerService } = await import('../../../service/services/customer.service');
         customerService = injector.get(CustomerService);
     },
-    async check(ctx: RequestContext, order: Order, args) {
+    async check(ctx, order, args) {
         if (!order.customer) {
             return false;
         }

+ 3 - 4
packages/core/src/config/promotion/conditions/has-facet-values-condition.ts

@@ -1,7 +1,6 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
-import { RequestContext } from '../../../api/common/request-context';
-import { Order } from '../../../entity/order/order.entity';
+import { TransactionalConnection } from '../../../service/transaction/transactional-connection';
 import { PromotionCondition } from '../promotion-condition';
 import { FacetValueChecker } from '../utils/facet-value-checker';
 
@@ -17,10 +16,10 @@ export const hasFacetValues = new PromotionCondition({
         facets: { type: 'ID', list: true, ui: { component: 'facet-value-form-input' } },
     },
     init(injector) {
-        facetValueChecker = new FacetValueChecker(injector.getConnection());
+        facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
     },
     // tslint:disable-next-line:no-shadowed-variable
-    async check(ctx: RequestContext, order: Order, args) {
+    async check(ctx, order, args) {
         let matches = 0;
         for (const line of order.lines) {
             if (await facetValueChecker.hasFacetValues(line, args.facets)) {

+ 1 - 3
packages/core/src/config/promotion/conditions/min-order-amount-condition.ts

@@ -1,7 +1,5 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
-import { RequestContext } from '../../../api/common/request-context';
-import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
 
 export const minimumOrderAmount = new PromotionCondition({
@@ -14,7 +12,7 @@ export const minimumOrderAmount = new PromotionCondition({
         },
         taxInclusive: { type: 'boolean' },
     },
-    check(ctx: RequestContext, order: Order, args) {
+    check(ctx, order, args) {
         if (args.taxInclusive) {
             return order.subTotal >= args.amount;
         } else {

+ 1 - 0
packages/core/src/config/promotion/index.ts

@@ -15,6 +15,7 @@ export * from './conditions/has-facet-values-condition';
 export * from './conditions/min-order-amount-condition';
 export * from './conditions/contains-products-condition';
 export * from './conditions/customer-group-condition';
+export * from './utils/facet-value-checker';
 
 export const defaultPromotionActions = [
     orderPercentageDiscount,

+ 40 - 2
packages/core/src/config/promotion/utils/facet-value-checker.ts

@@ -1,16 +1,54 @@
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
-import { Connection } from 'typeorm';
 
 import { TtlCache } from '../../../common/ttl-cache';
 import { idsAreEqual } from '../../../common/utils';
 import { OrderLine } from '../../../entity/order-line/order-line.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { TransactionalConnection } from '../../../service/transaction/transactional-connection';
 
+/**
+ * @description
+ * The FacetValueChecker is a helper class used to determine whether a given OrderLine consists
+ * of ProductVariants containing the given FacetValues.
+ *
+ * @example
+ * ```TypeScript
+ * import { FacetValueChecker, LanguageCode, PromotionCondition, TransactionalConnection } from '\@vendure/core';
+ *
+ * let facetValueChecker: FacetValueChecker;
+ *
+ * export const hasFacetValues = new PromotionCondition({
+ *   code: 'at_least_n_with_facets',
+ *   description: [
+ *     { languageCode: LanguageCode.en, value: 'Buy at least { minimum } products with the given facets' },
+ *   ],
+ *   args: {
+ *     minimum: { type: 'int' },
+ *     facets: { type: 'ID', list: true, ui: { component: 'facet-value-form-input' } },
+ *   },
+ *   init(injector) {
+ *     facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
+ *   },
+ *   // tslint:disable-next-line:no-shadowed-variable
+ *   async check(ctx, order, args) {
+ *     let matches = 0;
+ *     for (const line of order.lines) {
+ *       if (await facetValueChecker.hasFacetValues(line, args.facets)) {
+ *           matches += line.quantity;
+ *       }
+ *     }
+ *     return args.minimum <= matches;
+ *   },
+ * });
+ * ```
+ *
+ * @docsCategory Promotions
+ */
 export class FacetValueChecker {
     private variantCache = new TtlCache<ID, ProductVariant>({ ttl: 5000 });
 
-    constructor(private connection: Connection) {}
+    constructor(private connection: TransactionalConnection) {}
     /**
      * @description
      * Checks a given {@link OrderLine} against the facetValueIds and returns

+ 2 - 2
packages/create/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/create",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "license": "MIT",
   "bin": {
     "create": "./index.js"
@@ -26,7 +26,7 @@
     "@types/handlebars": "^4.1.0",
     "@types/listr": "^0.14.2",
     "@types/semver": "^6.2.2",
-    "@vendure/core": "^0.16.2",
+    "@vendure/core": "^0.16.3",
     "rimraf": "^3.0.2",
     "ts-node": "^9.0.0",
     "typescript": "4.0.3"

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

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/elasticsearch-plugin",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -23,7 +23,7 @@
   },
   "devDependencies": {
     "@vendure/common": "^0.16.2",
-    "@vendure/core": "^0.16.2",
+    "@vendure/core": "^0.16.3",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"
   }

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/email-plugin",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -34,7 +34,7 @@
     "@types/mjml": "^4.0.4",
     "@types/nodemailer": "^6.4.0",
     "@vendure/common": "^0.16.2",
-    "@vendure/core": "^0.16.2",
+    "@vendure/core": "^0.16.3",
     "rimraf": "^3.0.2",
     "typescript": "4.0.3"
   }

+ 2 - 2
packages/testing/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/testing",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "description": "End-to-end testing tools for Vendure projects",
   "keywords": [
     "vendure",
@@ -44,7 +44,7 @@
   "devDependencies": {
     "@types/mysql": "^2.15.15",
     "@types/pg": "^7.14.5",
-    "@vendure/core": "^0.16.2",
+    "@vendure/core": "^0.16.3",
     "mysql": "^2.18.1",
     "pg": "^8.4.0",
     "rimraf": "^3.0.0",

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/ui-devkit",
-  "version": "0.16.2",
+  "version": "0.16.3",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
     "vendure",
@@ -39,7 +39,7 @@
     "@angular/cli": "^10.1.4",
     "@angular/compiler": "^10.1.4",
     "@angular/compiler-cli": "^10.1.4",
-    "@vendure/admin-ui": "^0.16.2",
+    "@vendure/admin-ui": "^0.16.3",
     "@vendure/common": "^0.16.2",
     "chalk": "^4.1.0",
     "chokidar": "^3.4.2",
@@ -51,7 +51,7 @@
     "@rollup/plugin-node-resolve": "^9.0.0",
     "@types/fs-extra": "^9.0.1",
     "@types/glob": "^7.1.3",
-    "@vendure/core": "^0.16.2",
+    "@vendure/core": "^0.16.3",
     "rimraf": "^3.0.2",
     "rollup": "^2.28.2",
     "rollup-plugin-terser": "^7.0.2",

+ 1 - 0
scripts/codegen/generate-graphql-types.ts

@@ -21,6 +21,7 @@ const specFileToIgnore = [
     'shop-order.e2e-spec',
     'database-transactions.e2e-spec',
     'custom-permissions.e2e-spec',
+    'parallel-transactions.e2e-spec',
 ];
 const E2E_ADMIN_QUERY_FILES = path.join(
     __dirname,