diff --git a/package.json b/package.json index ceaff5dd..ea29ac31 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "cache-manager-ioredis": "^2.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "ioredis": "^5.3.2", "mongodb": "^4.14.0", "nestjs-swagger-api-implicit-queries-decorator": "^1.0.0", "passport": "^0.6.0", diff --git a/src/modules/cron/CronModule.ts b/src/modules/cron/CronModule.ts index 21e40b66..e4fc87f5 100644 --- a/src/modules/cron/CronModule.ts +++ b/src/modules/cron/CronModule.ts @@ -15,6 +15,7 @@ import {ConfigService} from '@nestjs/config'; import * as redisStore from 'cache-manager-ioredis'; import {RedisCacheService} from '../../services/cache/redis.cache.service'; import {XpmCron} from './xpm.cron'; +import {SearchCron} from './search.cron'; // import {OutfitWarsRankingsCron} from './outfitwars.rankings.cron'; // import OutfitwarsRankingEntity from '../data/entities/instance/outfitwars.ranking.entity'; @@ -48,6 +49,7 @@ import {XpmCron} from './xpm.cron'; BracketCron, // OutfitWarsRankingsCron, XpmCron, + SearchCron, ], }) export class CronModule {} diff --git a/src/modules/cron/search.cron.ts b/src/modules/cron/search.cron.ts new file mode 100644 index 00000000..13cefa3d --- /dev/null +++ b/src/modules/cron/search.cron.ts @@ -0,0 +1,187 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {Inject, Injectable, Logger} from '@nestjs/common'; +import {Cron, CronExpression} from '@nestjs/schedule'; +import MongoOperationsService from '../../services/mongo/mongo.operations.service'; +import {RedisCacheService} from '../../services/cache/redis.cache.service'; +import GlobalCharacterAggregateEntity from '../data/entities/aggregate/global/global.character.aggregate.entity'; +import {Bracket} from '../data/ps2alerts-constants/bracket'; +import Pagination from '../../services/mongo/pagination'; +import GlobalOutfitAggregateEntity from '../data/entities/aggregate/global/global.outfit.aggregate.entity'; +import {pcWorldArray, World} from '../data/ps2alerts-constants/world'; +import {Ps2AlertsEventType} from '../data/ps2alerts-constants/ps2AlertsEventType'; + +@Injectable() +export class SearchCron { + private readonly logger = new Logger(SearchCron.name); + private readonly pageSize = 10000; + private readonly listPrefix = 'search'; + private readonly filter = {searchIndexed: false, bracket: Bracket.TOTAL, ps2AlertsEventType: Ps2AlertsEventType.LIVE_METAGAME}; + + constructor( + @Inject(MongoOperationsService) private readonly mongoOperationsService: MongoOperationsService, + private readonly cacheService: RedisCacheService, + ) {} + + @Cron(CronExpression.EVERY_5_SECONDS) + async handleCron(): Promise { + this.logger.log('Running Search sync job'); + + const lock = await this.cacheService.get('locks:search'); + + if (lock) { + this.logger.log('Search sync job already running'); + return; + } + + await this.cacheService.set('locks:search', Date.now(), 60 * 60); // 1 hour lock + + await this.syncCharacters(); + await this.syncOutfits(); + + await this.cacheService.unset('locks:search'); + + // @See CronHealthIndicator + // This sets the fact that the cron has run, so if it hasn't been run it will be terminated. + const key = '/crons/search'; + await this.cacheService.set(key, Date.now(), 595); // Just under 5 minutes = deadline for this cron + this.logger.debug('Set search cron run time'); + } + + async syncCharacters(): Promise { + this.logger.log('==== Syncing Characters ===='); + let page = 0; + let endOfRecords = false; + + const numberOfRecords = await this.mongoOperationsService.countDocuments(GlobalCharacterAggregateEntity, this.filter); + + if (numberOfRecords === 0) { + this.logger.log('No records to process'); + return; + } + + this.logger.log(`Found ${numberOfRecords} records to add to search cache`); + + // Loop through all Character records until we have less than 1000 returned + while (!endOfRecords) { + this.logger.log(`Processing records ${page * this.pageSize} -> ${(page * this.pageSize) + (this.pageSize - 1)}...`); + // Get all records that are not indexed + const records: GlobalCharacterAggregateEntity[] = await this.mongoOperationsService.findMany( + GlobalCharacterAggregateEntity, + this.filter, + new Pagination({page, pageSize: this.pageSize}), // We are purposefully NOT sorting here as it causes a full table scan and it's super fucking slow + ); + + if (records.length < this.pageSize) { + endOfRecords = true; + this.logger.log('At the end of character records'); + } + + // Loop through all records and add them to the cache + for await (const record of records) { + const environment = this.getEnvironment(record.world); + + // Store the lowercase version of the name acting as "normalized" for searching purposes + await this.cacheService.addDataToSortedSet(`${this.listPrefix}:${environment}:character_index`, [record.character.name.toLowerCase()], 0); + + // Create a key which contains the lowercase name as the key and the char ID as the value, which will be used by the search API to pull the record out of the DB. + // JSON.stringify is needed here as for some reason the client library favours using int64, this forces it to be a string + await this.cacheService.setPermanent(`${this.listPrefix}:${environment}:character_ids:${record.character.name.toLowerCase()}`, JSON.stringify(record.character.id)); + + // Mark the character as search indexed in the database to prevent being processed again + await this.mongoOperationsService.upsert(GlobalCharacterAggregateEntity, [{$set: {searchIndexed: true}}], [{'character.id': record.character.id}]); + } + + this.logger.log(`Added ${records.length} records to character search cache`); + this.logger.log(`${page * this.pageSize + records.length}/${numberOfRecords} processed`); + page++; + } + } + + async syncOutfits(): Promise { + this.logger.log('==== Syncing Outfits ===='); + let page = 0; + let endOfRecords = false; + let corruptOutfits = 0; + + const numberOfRecords = await this.mongoOperationsService.countDocuments(GlobalOutfitAggregateEntity, this.filter); + + if (numberOfRecords === 0) { + this.logger.log('No records to process'); + return; + } + + this.logger.log(`Found ${numberOfRecords} records to add to search cache`); + + // Loop through all Character records until we have less than 1000 returned + while (!endOfRecords) { + this.logger.log(`Processing records ${page * this.pageSize} -> ${(page * this.pageSize) + (this.pageSize - 1)}...`); + + // Get all records that are not indexed + const records: GlobalOutfitAggregateEntity[] = await this.mongoOperationsService.findMany( + GlobalOutfitAggregateEntity, + this.filter, + new Pagination({page, pageSize: this.pageSize}), // We are purposefully NOT sorting here as it causes a full table scan and it's super fucking slow + ); + + if (records.length < this.pageSize) { + endOfRecords = true; + this.logger.log('At the end of outfit records'); + } + + // Loop through all records and add them to the cache + for await (const record of records) { + const environment = this.getEnvironment(record.world); + + // Handle outfit corruptions that come up occasionally + if (!record.outfit.name || !record.outfit.id) { + this.logger.error('Corrupt outfit detected!'); + corruptOutfits++; + + try { + await this.mongoOperationsService.deleteOne(GlobalOutfitAggregateEntity, {_id: record._id}); + } catch (err) { + this.logger.error(err); + } + + continue; + } + + await this.cacheService.addDataToSortedSet(`${this.listPrefix}:${environment}:outfit_index`, [record.outfit.name.toLowerCase()]); + + if (record.outfit.tag) { + await this.cacheService.addDataToSortedSet(`${this.listPrefix}:${environment}:outfit_tag_index`, [record.outfit.tag.toLowerCase()]); + } + + // Create a key which contains the lowercase name as the key and the outfit ID as the value, which will be used by the search API to pull the record out of the DB + // JSON.stringify is needed here as for some reason the client library favours using int64, this forces it to be a string + await this.cacheService.setPermanent(`${this.listPrefix}:${environment}:outfit_ids:${record.outfit.name.toLowerCase()}`, JSON.stringify(record.outfit.id)); + + // Do the same for tag if it exists + if (record.outfit.tag) { + await this.cacheService.setPermanent(`${this.listPrefix}:${environment}:outfit_ids_tag:${record.outfit.tag.toLowerCase()}`, JSON.stringify(record.outfit.id)); + } + + // Mark the character as search indexed in the database + await this.mongoOperationsService.upsert(GlobalOutfitAggregateEntity, [{$set: {searchIndexed: true}}], [{'outfit.id': record.outfit.id}]); + } + + this.logger.log(`Added ${records.length} records to outfit search cache`); + this.logger.log(`${page * this.pageSize + records.length}/${numberOfRecords} processed`); + this.logger.error(`Corrupt outfits: ${corruptOutfits}`); + + page++; + } + } + + getEnvironment(world: World): string { + if (pcWorldArray.includes(world)) { + return 'pc'; + } else if (world === World.CERES) { + return 'ps4_eu'; + } else { + return 'ps4_us'; + } + + return 'UNKNOWN'; + } +} diff --git a/src/modules/data/entities/aggregate/global/global.character.aggregate.entity.ts b/src/modules/data/entities/aggregate/global/global.character.aggregate.entity.ts index 2833dae6..77cc0039 100644 --- a/src/modules/data/entities/aggregate/global/global.character.aggregate.entity.ts +++ b/src/modules/data/entities/aggregate/global/global.character.aggregate.entity.ts @@ -101,17 +101,10 @@ export default class GlobalCharacterAggregateEntity { }) ps2AlertsEventType: Ps2AlertsEventType; - @Exclude() - @ApiProperty({ - example: 100, - description: 'Search score weighting', - }) - searchScore?: number; - - @Exclude() - @ApiProperty({ - example: 'character', - description: 'Search result type', + @ApiProperty({example: true, description: 'Denotes if this aggregate is indexed for searching'}) + @Column({ + type: 'boolean', + default: false, }) - searchResultType?: string; + searchIndexed: boolean; } diff --git a/src/modules/data/entities/aggregate/global/global.outfit.aggregate.entity.ts b/src/modules/data/entities/aggregate/global/global.outfit.aggregate.entity.ts index 34cf409b..a0d0699b 100644 --- a/src/modules/data/entities/aggregate/global/global.outfit.aggregate.entity.ts +++ b/src/modules/data/entities/aggregate/global/global.outfit.aggregate.entity.ts @@ -107,18 +107,4 @@ export default class GlobalOutfitAggregateEntity { default: Ps2AlertsEventType.LIVE_METAGAME, }) ps2AlertsEventType: Ps2AlertsEventType; - - @Exclude() - @ApiProperty({ - example: 100, - description: 'Search score weighting', - }) - searchScore?: number; - - @Exclude() - @ApiProperty({ - example: 'character', - description: 'Search result type', - }) - searchResultType?: string; } diff --git a/src/modules/healthcheck/controllers/healthcheck.controller.ts b/src/modules/healthcheck/controllers/healthcheck.controller.ts index 5b4e4e0c..cc00e7ba 100644 --- a/src/modules/healthcheck/controllers/healthcheck.controller.ts +++ b/src/modules/healthcheck/controllers/healthcheck.controller.ts @@ -66,6 +66,7 @@ export default class HealthcheckController { indicators.push(async () => this.cronHealth.isHealthy('combatHistory', 65)); // indicators.push(async () => this.cronHealth.isHealthy('outfitwarsrankings', 60 * 60 * 24 + 300)); indicators.push(async () => this.cronHealth.isHealthy('xpm', 35)); + indicators.push(async () => this.cronHealth.isHealthy('search', 605)); } return this.health.check(indicators); diff --git a/src/modules/rest/controllers/rest.search.controller.ts b/src/modules/rest/controllers/rest.search.controller.ts index f4ff495f..1e42c1df 100644 --- a/src/modules/rest/controllers/rest.search.controller.ts +++ b/src/modules/rest/controllers/rest.search.controller.ts @@ -1,151 +1,99 @@ -import {Controller, Get, Inject, Optional, Query} from '@nestjs/common'; +import {Controller, Get, Inject, Query} from '@nestjs/common'; import MongoOperationsService from '../../../services/mongo/mongo.operations.service'; import {ApiOperation, ApiResponse, ApiTags} from '@nestjs/swagger'; -import {ApiImplicitQueries} from 'nestjs-swagger-api-implicit-queries-decorator'; -import {PAGINATION_IMPLICIT_QUERIES} from './common/rest.pagination.queries'; import GlobalCharacterAggregateEntity from '../../data/entities/aggregate/global/global.character.aggregate.entity'; import GlobalOutfitAggregateEntity from '../../data/entities/aggregate/global/global.outfit.aggregate.entity'; -import Pagination from '../../../services/mongo/pagination'; -import {SearchTermInterface} from '../../../interfaces/SearchTermInterface'; +import {Ps2AlertsEventType} from '../../data/ps2alerts-constants/ps2AlertsEventType'; +import {RedisCacheService} from '../../../services/cache/redis.cache.service'; +import {Bracket} from '../../data/ps2alerts-constants/bracket'; @ApiTags('Search') @Controller('search') export default class RestSearchController { + private readonly environments = ['pc', 'ps4_eu', 'ps4_us']; constructor( @Inject(MongoOperationsService) private readonly mongoOperationsService: MongoOperationsService, + private readonly cacheService: RedisCacheService, ) {} - @Get() - @ApiOperation({summary: 'Searches GlobalCharacterAggregateEntity and GlobalOutfitAggregateEntity for a term'}) - @ApiImplicitQueries([...PAGINATION_IMPLICIT_QUERIES, { - name: 'type', - required: false, - description: 'The type of the data to be searched, either "characters" or "outfits". If not specified, both types will be searched.', - type: String, - enum: ['characters', 'outfits'], - }]) + @Get('characters') + @ApiOperation({summary: 'Searches GlobalCharacterAggregateEntity for a term'}) @ApiResponse({ status: 200, - description: 'The list of GlobalCharacterAggregateEntity and GlobalOutfitAggregateEntity for a search term', + description: 'The list of GlobalCharacterAggregateEntity for a search term', type: Object, - isArray: false, + isArray: true, }) - async search( + async searchCharacters( @Query('searchTerm') searchTerm: string, - @Query('type') @Optional() type?: string, - @Query('sortBy') sortBy?: string, - @Query('order') order?: string, - ): Promise> { - let characterResults: GlobalCharacterAggregateEntity[] = []; - let outfitResults: GlobalOutfitAggregateEntity[] = []; - - const pagination = new Pagination({sortBy, order, page: 0, pageSize: 10}, false); - - if (type === 'characters' || type === undefined) { - const characterSearchTerm: SearchTermInterface = { - field: 'character.name', - term: searchTerm, - options: 'i', - }; - characterResults = await this.mongoOperationsService.searchText( - GlobalCharacterAggregateEntity, - characterSearchTerm, - {$and: [{bracket: 0}]}, - pagination, - ); - } - - if (type === 'outfits' || type === undefined) { - const outfitNameSearchTerm: SearchTermInterface = { - field: 'outfit.name', - term: searchTerm, - options: 'i', - }; - const outfitTagSearchTerm: SearchTermInterface = { - field: 'outfit.tag', - term: searchTerm, - options: 'i', - }; - - // First, search for outfits by tag - const outfitTagResults = await this.mongoOperationsService.searchText( - GlobalOutfitAggregateEntity, - outfitTagSearchTerm, - {$and: [{bracket: 0}]}, - pagination, - ); - - const outfitNameResults = await this.mongoOperationsService.searchText( - GlobalOutfitAggregateEntity, - outfitNameSearchTerm, - {$and: [{bracket: 0}]}, - pagination, - ); - - // Combine both arrays, ensuring that the tag results appear first - outfitResults = [...outfitTagResults, ...outfitNameResults]; - - // Deduplicate outfits based on name - const outfitsMap = new Map(outfitResults.map((outfit) => [outfit.outfit.name, outfit])); - outfitResults = Array.from(outfitsMap.values()); - } - - const searchTermLower = searchTerm.toLowerCase(); - - // For outfits - outfitResults.forEach((outfit) => { - let score = 0; - - // Higher weight for exact matches on tag and name - if (outfit.outfit.tag?.toLowerCase() === searchTermLower || outfit.outfit.name.toLowerCase() === searchTermLower) { - score += 100; - } else { - // Lower weight for partial matches - const searchTermRegex = new RegExp(searchTerm, 'i'); - - if (outfit.outfit.tag?.match(searchTermRegex)) { - score += 20; - } else if (outfit.outfit.name.match(searchTermRegex)) { - score += 10; - } + ): Promise { + // Time for some voodoo + const characterIds: string[] = []; + + // Loop through each environment and perform a prefix search via Redis using an insensitive version of the search term. This will return the names of the characters that match the search term. + for (const environment of this.environments) { + const nameListKey = `search:${environment}:character_index`; + const characterNames = await this.cacheService.searchDataInSortedSet(nameListKey, searchTerm.toLowerCase()); + + // Now we have the character names, we need to grab their IDs by performing a lookup by lowercase name in the database + for (const characterName of characterNames) { + characterIds.push(String(await this.cacheService.get(`search:${environment}:character_ids:${characterName}`))); } + } - outfit.searchScore = score; - outfit.searchResultType = 'outfit'; // added type field + // Now we have a list of character IDs to grab, we now need to actually grab the characters from the database + return await this.mongoOperationsService.findMany(GlobalCharacterAggregateEntity, { + 'character.id': {$in: characterIds}, + bracket: Bracket.TOTAL, + ps2AlertsEventType: Ps2AlertsEventType.LIVE_METAGAME, }); + } - // For characters - characterResults.forEach((character) => { - let score = 0; - - // Higher weight for exact matches - if (character.character.name.toLowerCase() === searchTermLower) { - score += 100; - } else { - // Lower weight for partial matches - const searchTermRegex = new RegExp(searchTerm, 'i'); - - if (character.character.name.match(searchTermRegex)) { - score += 5; - } + @Get('outfits') + @ApiOperation({summary: 'Searches GlobalOutfitAggregateEntity for a term'}) + @ApiResponse({ + status: 200, + description: 'The list of GlobalOutfitAggregateEntity for a search term', + type: Object, + isArray: false, + }) + async searchOutfits( + @Query('searchTerm') searchTerm: string, + ): Promise { + // Time for some extra voodoo + let outfitIds: string[] = []; + + // Loop through each environment and perform a prefix search via Redis using an insensitive version of the search term. This will return the names of the outfits that match the search term. + for (const environment of this.environments) { + const nameListKey = `search:${environment}:outfit_index`; + const tagListKey = `search:${environment}:outfit_tag_index`; + const outfitNames = await this.cacheService.searchDataInSortedSet(nameListKey, searchTerm.toLowerCase()); + const outfitTags = await this.cacheService.searchDataInSortedSet(tagListKey, searchTerm.toLowerCase()); + + // Now we have the character names, we need to grab their IDs by performing a lookup by lowercase name in the database + for (const outfitName of outfitNames) { + outfitIds.push( + String(await this.cacheService.get(`search:${environment}:outfit_ids:${outfitName}`)), + ); } - character.searchScore = score; - character.searchResultType = 'character'; // added type field - }); - - // Sort by score - characterResults.sort((a, b) => this.searchScores(a.searchScore, b.searchScore)); - - // Combine both arrays and return combined array sorted by score - return [...characterResults, ...outfitResults].sort((a, b) => this.searchScores(a.searchScore, b.searchScore)); - } - - private searchScores(a: number | undefined, b: number | undefined): number { - if (a && b) { - return b - a; + // We also need to search on outfit tag for possible hits + for (const outfitTag of outfitTags) { + outfitIds.push( + String(await this.cacheService.get(`search:${environment}:outfit_ids_tag:${outfitTag}`)), + ); + } } - return 0; + // Deduplicate the outfit IDs + const outfitIdsSet = new Set(outfitIds); + outfitIds = Array.from(outfitIdsSet); + + // Now we have a list of character IDs to grab, we now need to actually grab the characters from the database + return await this.mongoOperationsService.findMany(GlobalOutfitAggregateEntity, { + 'outfit.id': {$in: outfitIds}, + bracket: Bracket.TOTAL, + ps2AlertsEventType: Ps2AlertsEventType.LIVE_METAGAME, + }); } } diff --git a/src/services/cache/redis.cache.service.ts b/src/services/cache/redis.cache.service.ts index e0185ae6..48ff3478 100644 --- a/src/services/cache/redis.cache.service.ts +++ b/src/services/cache/redis.cache.service.ts @@ -1,20 +1,45 @@ import {CACHE_MANAGER, Inject, Injectable} from '@nestjs/common'; import {Cache} from 'cache-manager'; +import * as Redis from 'ioredis'; @Injectable() export class RedisCacheService { + private readonly redisClient: Redis.Redis; constructor( @Inject(CACHE_MANAGER) private readonly cache: Cache, - ) {} + ) { + // Yay for packages that don't have full support for TS >:( + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access + this.redisClient = (this.cache.store as any).getClient() as Redis.Redis; + } async set(key: string, data: T, ttl = 3600): Promise { await this.cache.set(key, data, {ttl}); return data; } + async setPermanent(key: string, data: string): Promise { + await this.redisClient.set(key, data); + return data; + } + + async unset(key: string): Promise { + await this.cache.del(key); + } + async get(key: string): Promise { const data: T | null = await this.cache.get(key) ?? null; return data ?? null; } + + async addDataToSortedSet(key: string, data: string[], score = 0): Promise { + // Flattening array of [score, data] pairs + const args: Array = data.reduce>((arr, item) => [...arr, score, item], []); + await this.redisClient.zadd(key, ...args); + } + + async searchDataInSortedSet(key: string, prefix: string): Promise { + return this.redisClient.zrangebylex(key, `[${prefix}`, `[${prefix}\xff`); + } } diff --git a/src/services/mongo/mongo.operations.service.ts b/src/services/mongo/mongo.operations.service.ts index 2a032955..52d24e94 100644 --- a/src/services/mongo/mongo.operations.service.ts +++ b/src/services/mongo/mongo.operations.service.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-argument */ -import {CollectionAggregationOptions, MongoEntityManager, ObjectID, ObjectLiteral} from 'typeorm'; +import {CollectionAggregationOptions, FindOptionsWhere, MongoEntityManager, ObjectID, ObjectLiteral} from 'typeorm'; import {InjectEntityManager} from '@nestjs/typeorm'; import {Injectable} from '@nestjs/common'; import Pagination from './pagination'; @@ -176,7 +176,7 @@ export default class MongoOperationsService { public async searchText(entity: new () => T, searchTerm?: {field: string, term: string, options: string}, filter?: object, pagination?: Pagination): Promise { // Create a base filter with bracket = 0 - const baseFilter: { [key: string]: any } = {bracket: 0}; + const baseFilter: { [key: string]: any } = {}; // If a search term is provided, add a regex query for the specified field if (searchTerm) { @@ -192,6 +192,11 @@ export default class MongoOperationsService { return await this.em.find(entity, MongoOperationsService.createFindOptions(baseFilter, pagination)); } + public async countDocuments(entity: new () => T, filter: FindOptionsWhere): Promise { + const repository = this.em.getRepository(entity); + return repository.countBy(filter); + } + // eslint-disable-next-line @typescript-eslint/ban-types private static createFindOptions(filter?: {[k: string]: any}, pagination?: Pagination): object { let findOptions: {[k: string]: any} = {}; diff --git a/src/services/mongo/pagination.ts b/src/services/mongo/pagination.ts index f05d09c3..584da966 100644 --- a/src/services/mongo/pagination.ts +++ b/src/services/mongo/pagination.ts @@ -6,16 +6,8 @@ export default class Pagination { public constructor(pageQuery: {sortBy?: string, order?: string, pageSize?: number, page?: number}, limited = false) { this.take = 100; - if (pageQuery.pageSize) { - if (pageQuery.pageSize < 1000) { - this.take = pageQuery.pageSize; - } else { - this.take = 1000; - } - } - - if (!limited && !pageQuery.pageSize) { - this.take = undefined; + if (!limited && pageQuery.pageSize) { + this.take = pageQuery.pageSize; } if (pageQuery.pageSize && pageQuery.page) { diff --git a/yarn.lock b/yarn.lock index 461e74ab..0fdb8fe0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -981,6 +981,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + "@jridgewell/gen-mapping@^0.3.0": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" @@ -2596,6 +2601,11 @@ denque@^1.1.0: resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf" integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + depd@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" @@ -3787,6 +3797,21 @@ ioredis@^4.14.1: redis-parser "^3.0.0" standard-as-callback "^2.1.0" +ioredis@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.3.2.tgz#9139f596f62fc9c72d873353ac5395bcf05709f7" + integrity sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + ip@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da"