Skip to content

Commit fac3fb7

Browse files
fix(core): Optimize collection variants N+1 and fix ms type issues
1 parent 0396c48 commit fac3fb7

11 files changed

Lines changed: 70 additions & 39 deletions

File tree

packages/core/src/api/middleware/auth-guard.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Reflector } from '@nestjs/core';
33
import { Permission } from '@vendure/common/lib/generated-types';
44
import { Request, Response } from 'express';
55
import { GraphQLResolveInfo } from 'graphql';
6-
import ms, { type StringValue } from 'ms';
6+
import ms from 'ms';
77

88
import { ForbiddenError } from '../../common/error/errors';
99
import { API_KEY_AUTH_STRATEGY_NAME } from '../../config';
@@ -212,7 +212,7 @@ export class AuthGuard implements CanActivate {
212212
const lastUsedThreshold = new Date(
213213
Date.now() -
214214
(typeof strategy.lastUsedAtUpdateInterval === 'string'
215-
? ms(strategy.lastUsedAtUpdateInterval as StringValue)
215+
? ms(strategy.lastUsedAtUpdateInterval)
216216
: strategy.lastUsedAtUpdateInterval),
217217
);
218218
if (!apiKey.lastUsedAt || apiKey.lastUsedAt < lastUsedThreshold) {

packages/core/src/api/resolvers/admin/collection.resolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,11 @@ export class CollectionResolver {
7171
const countsPromise = this.collectionService.getProductVariantCounts(ctx, collectionIds);
7272
this.requestContextCache.set(ctx, CacheKey.CollectionVariantCounts, countsPromise);
7373
}
74-
if (isFieldInSelection(info, 'variants')) {
74+
if (isFieldInSelection(info, 'productVariants')) {
7575
const variantsPromise = this.collectionService.getProductVariantsForCollections(
7676
ctx,
7777
collectionIds,
78+
['taxCategory'],
7879
);
7980
this.requestContextCache.set(ctx, CacheKey.CollectionVariants, variantsPromise);
8081
}

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

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CacheKey } from '../../../common/constants';
1212
import { ListQueryOptions } from '../../../common/types/common-types';
1313
import { Translated } from '../../../common/types/locale-types';
1414
import { CollectionFilter } from '../../../config/catalog/collection-filter';
15+
import { ConfigService } from '../../../config/config.service';
1516
import { Asset, Collection, Product, ProductVariant } from '../../../entity';
1617
import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
1718
import { AssetService } from '../../../service/services/asset.service';
@@ -33,6 +34,7 @@ export class CollectionEntityResolver {
3334
private localeStringHydrator: LocaleStringHydrator,
3435
private configurableOperationCodec: ConfigurableOperationCodec,
3536
private requestContextCache: RequestContextCacheService,
37+
private configService: ConfigService,
3638
) {}
3739

3840
@ResolveField()
@@ -72,14 +74,26 @@ export class CollectionEntityResolver {
7274
const variantsMap = await cachedVariantsPromise;
7375
const variants = variantsMap.get(String(collection.id));
7476
if (variants) {
75-
const items = await this.productVariantService.applyPricesAndTranslateVariants(
76-
ctx,
77-
variants,
78-
);
79-
return {
80-
items,
81-
totalItems: items.length,
82-
};
77+
// Check if the requested relations are compatible with the cached data.
78+
// The cache was populated with default relations ['taxCategory'].
79+
// We can use the cache ONLY if the requested relations are a subset of or equal to the default relations.
80+
const defaultRelationsForCache = ['taxCategory'];
81+
const isCacheCompatible = relations.every(rel => defaultRelationsForCache.includes(rel));
82+
83+
if (isCacheCompatible) {
84+
// Cache is compatible, use it.
85+
const { adminListQueryLimit } = this.configService.apiOptions;
86+
const skip = args.options?.skip ?? 0;
87+
const take = args.options?.take ?? adminListQueryLimit;
88+
const items = await this.productVariantService.applyPricesAndTranslateVariants(
89+
ctx,
90+
variants.slice(skip, skip + take),
91+
);
92+
return {
93+
items,
94+
totalItems: variants.length,
95+
};
96+
}
8397
}
8498
}
8599
}

packages/core/src/config/auth/default-verification-token-strategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ms, { type StringValue } from 'ms';
1+
import ms from 'ms';
22

33
import { RequestContext } from '../../api/common/request-context';
44
import { Injector } from '../../common';
@@ -43,7 +43,7 @@ export class DefaultVerificationTokenStrategy implements VerificationTokenStrate
4343
const { verificationTokenDuration } = this.configService.authOptions;
4444
const verificationTokenDurationInMs =
4545
typeof verificationTokenDuration === 'string'
46-
? ms(verificationTokenDuration as StringValue)
46+
? ms(verificationTokenDuration)
4747
: verificationTokenDuration;
4848

4949
const [generatedOn] = token.split('_');

packages/core/src/config/config.module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { InjectableStrategy } from '../common/types/injectable-strategy';
77

88
import { resetConfig } from './config-helpers';
99
import { ConfigService } from './config.service';
10+
import { Logger } from './logger/vendure-logger';
1011

1112
@Module({
1213
providers: [ConfigService],
@@ -15,7 +16,7 @@ import { ConfigService } from './config.service';
1516
export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdown {
1617
constructor(
1718
private configService: ConfigService,
18-
@Optional() private moduleRef: ModuleRef,
19+
@Optional() private moduleRef: ModuleRef | undefined,
1920
) {}
2021

2122
async onApplicationBootstrap() {
@@ -38,6 +39,9 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
3839

3940
private async initInjectableStrategies() {
4041
if (!this.moduleRef) {
42+
Logger.warn(
43+
'ConfigModule: moduleRef missing — skipping initialization of injectable strategies.',
44+
);
4145
return;
4246
}
4347
const injector = new Injector(this.moduleRef);
@@ -58,6 +62,9 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
5862

5963
private async initConfigurableOperations() {
6064
if (!this.moduleRef) {
65+
Logger.warn(
66+
'ConfigModule: moduleRef missing — skipping initialization of configurable operations.',
67+
);
6168
return;
6269
}
6370
const injector = new Injector(this.moduleRef);

packages/core/src/config/order/order-by-code-access-strategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ms, { type StringValue } from 'ms';
1+
import ms from 'ms';
22

33
import { RequestContext } from '../../api/common/request-context';
44
import { InjectableStrategy } from '../../common/types/injectable-strategy';
@@ -68,7 +68,7 @@ export class DefaultOrderByCodeAccessStrategy implements OrderByCodeAccessStrate
6868
// For guest Customers, allow access to the Order for the following
6969
// time period
7070
const anonymousAccessPermitted = () => {
71-
const anonymousAccessLimit = ms(this.anonymousAccessDuration as StringValue);
71+
const anonymousAccessLimit = ms(this.anonymousAccessDuration);
7272
const orderPlaced = order.orderPlacedAt ? +order.orderPlacedAt : 0;
7373
const now = Date.now();
7474
return now - orderPlaced < anonymousAccessLimit;

packages/core/src/plugin/default-scheduler-plugin/default-scheduler-strategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { UpdateScheduledTaskInput } from '@vendure/common/lib/generated-types';
22
import { Cron } from 'croner';
3-
import ms, { type StringValue } from 'ms';
3+
import ms from 'ms';
44

55
import { Injector } from '../../common';
66
import { assertFound } from '../../common/utils';
@@ -85,7 +85,7 @@ export class DefaultSchedulerStrategy implements SchedulerStrategy {
8585
try {
8686
this.runningTasks.push(task);
8787
const timeout = task.options.timeout ?? (this.pluginOptions.defaultTimeout as number);
88-
const timeoutMs = typeof timeout === 'number' ? timeout : ms(timeout as StringValue);
88+
const timeoutMs = typeof timeout === 'number' ? timeout : ms(timeout);
8989

9090
let timeoutTimer: NodeJS.Timeout | undefined;
9191
const timeoutPromise = new Promise((_, reject) => {

packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,8 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
402402
qb.orWhere(existsClause.clause, existsClause.parameters);
403403
}
404404
} else {
405-
Logger.warn(
406-
`Could not build EXISTS subquery for custom property "${condition.isExistsCondition.customPropertyKey}". Skipping filter condition.`,
405+
throw new Error(
406+
`Could not build EXISTS subquery for custom property "${condition.isExistsCondition.customPropertyKey}". This filter condition cannot be applied.`,
407407
);
408408
}
409409
return;

packages/core/src/service/helpers/settings-store/settings-store.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
22
import { ModuleRef } from '@nestjs/core';
33
import { Permission, SettingsStoreScopeType } from '@vendure/common/lib/generated-types';
44
import { JsonCompatible } from '@vendure/common/lib/shared-types';
5-
import ms, { type StringValue } from 'ms';
5+
import ms from 'ms';
66

77
import { RequestContext } from '../../../api/common/request-context';
88
import { InternalServerError, UserInputError } from '../../../common/error/errors';
@@ -463,7 +463,7 @@ export class SettingsStoreService implements OnModuleInit {
463463
* Parse a duration string (e.g., '7d', '30m', '2h') into a Date object.
464464
*/
465465
private parseDuration(duration: string): Date {
466-
const milliseconds = ms(duration as StringValue);
466+
const milliseconds = ms(duration);
467467
if (!milliseconds) {
468468
throw new Error(`Invalid duration format: ${duration}. Use format like '7d', '2h', '30m'`);
469469
}

packages/core/src/service/services/collection.service.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ export class CollectionService implements OnModuleInit {
490490
ctx: RequestContext,
491491
input: PreviewCollectionVariantsInput,
492492
options?: ListQueryOptions<ProductVariant>,
493-
relations?: RelationPaths<Collection>,
493+
relations?: RelationPaths<ProductVariant>,
494494
): Promise<PaginatedList<ProductVariant>> {
495495
const applicableFilters = this.getCollectionFiltersFromInput(input);
496496
if (input.parentId && input.inheritFilters) {
@@ -503,13 +503,18 @@ export class CollectionService implements OnModuleInit {
503503
);
504504
applicableFilters.push(...parentFilters, ...ancestorFilters);
505505
}
506-
let qb = this.listQueryBuilder.build(ProductVariant, options, {
507-
relations: relations ?? ['taxCategory'],
508-
channelId: ctx.channelId,
509-
where: { deletedAt: IsNull() },
510-
ctx,
511-
entityAlias: 'productVariant',
512-
});
506+
507+
let qb = this.listQueryBuilder.build(
508+
ProductVariant,
509+
options || { take: undefined, skip: undefined },
510+
{
511+
relations: relations ?? ['taxCategory'],
512+
channelId: ctx.channelId,
513+
where: { deletedAt: IsNull() },
514+
ctx,
515+
entityAlias: 'productVariant',
516+
},
517+
);
513518

514519
const { collectionFilters } = this.configService.catalogOptions;
515520
for (const filterType of collectionFilters) {
@@ -1058,12 +1063,16 @@ export class CollectionService implements OnModuleInit {
10581063
return new Map();
10591064
}
10601065

1061-
const qb = this.listQueryBuilder.build(ProductVariant, options, {
1062-
relations: relations ?? ['taxCategory'],
1063-
channelId: ctx.channelId,
1064-
where: { deletedAt: IsNull() },
1065-
ctx,
1066-
});
1066+
const qb = this.listQueryBuilder.build(
1067+
ProductVariant,
1068+
{ take: undefined, skip: undefined },
1069+
{
1070+
relations: relations ?? ['taxCategory'],
1071+
channelId: ctx.channelId,
1072+
where: { deletedAt: IsNull() },
1073+
ctx,
1074+
},
1075+
);
10671076

10681077
// We explicitly join with the product to ensure we filter out soft-deleted products,
10691078
// matching the behavior of other collection-related variant queries.

0 commit comments

Comments
 (0)