diff --git a/package.json b/package.json index 92aac08a..1600018a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.22", + "version": "1.2.23", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -28,6 +28,7 @@ "jest": "^26.2.2", "mongodb-memory-server": "^6.6.1", "nodemon": "^2.0.2", + "redis-mock": "^0.56.3", "ts-jest": "^26.1.4", "ts-node": "^10.9.1", "typescript": "^4.7.4" @@ -82,6 +83,7 @@ "mongodb": "^3.7.3", "morgan": "^1.10.1", "prom-client": "^15.1.3", + "redis": "^4.7.0", "safe-regex": "^2.1.0", "ts-node-dev": "^2.0.0", "uuid": "^8.3.2" diff --git a/src/index.ts b/src/index.ts index 0fca32f0..d84776b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { graphqlUploadExpress } from 'graphql-upload'; import { metricsMiddleware, createMetricsServer, graphqlMetricsPlugin } from './metrics'; import { requestLogger } from './utils/logger'; import ReleasesFactory from './models/releasesFactory'; +import RedisHelper from './redisHelper'; /** * Option to enable playground @@ -148,6 +149,7 @@ class HawkAPI { /** * Creates factories to work with models * @param dataLoaders - dataLoaders for fetching data form database + * @returns factories object */ private static setupFactories(dataLoaders: DataLoaders): ContextFactories { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -212,23 +214,9 @@ class HawkAPI { /** * Initializing accounting SDK - */ - let tlsVerify; - - /** * Checking env variables * If at least one path is not transmitted, the variable tlsVerify is undefined */ - if ( - ![process.env.TLS_CA_CERT, process.env.TLS_CERT, process.env.TLS_KEY].some(value => value === undefined || value.length === 0) - ) { - tlsVerify = { - tlsCaCertPath: `${process.env.TLS_CA_CERT}`, - tlsCertPath: `${process.env.TLS_CERT}`, - tlsKeyPath: `${process.env.TLS_KEY}`, - }; - } - /* * const accounting = new Accounting({ * baseURL: `${process.env.CODEX_ACCOUNTING_URL}`, @@ -252,6 +240,12 @@ class HawkAPI { public async start(): Promise { await mongo.setupConnections(); await rabbitmq.setupConnections(); + + // Initialize Redis singleton with auto-reconnect + const redis = RedisHelper.getInstance(); + + await redis.initialize(); + await this.server.start(); this.app.use(graphqlUploadExpress()); this.server.applyMiddleware({ app: this.app }); diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 5374be0b..9b27ee4b 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -1,6 +1,8 @@ import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates'; import safe from 'safe-regex'; import { createProjectEventsByIdLoader } from '../dataLoaders'; +import RedisHelper from '../redisHelper'; +import ChartDataService from '../services/chartDataService'; const Factory = require('./modelFactory'); const mongo = require('../mongo'); @@ -85,11 +87,21 @@ class EventsFactory extends Factory { /** * Creates Event instance - * @param {ObjectId} projectId - project ID + * @param {ObjectId} projectId */ constructor(projectId) { super(); + /** + * Redis helper instance (singleton) + */ + this.redis = RedisHelper.getInstance(); + + /** + * Chart data service for fetching data from Redis TimeSeries + */ + this.chartDataService = new ChartDataService(this.redis); + if (!projectId) { throw new Error('Can not construct Event model, because projectId is not provided'); } @@ -414,6 +426,57 @@ class EventsFactory extends Factory { }; } + /** + * Get project chart data from Redis or fallback to MongoDB + * + * @param {string} projectId - project ID + * @param {string} startDate - start date (ISO string) + * @param {string} endDate - end date (ISO string) + * @param {number} groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day) + * @param {number} timezoneOffset - user's local timezone offset in minutes + * @returns {Promise} + */ + async getProjectChartData(projectId, startDate, endDate, groupBy = 60, timezoneOffset = 0) { + // Calculate days for MongoDB fallback + const start = new Date(startDate).getTime(); + const end = new Date(endDate).getTime(); + const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000)); + + try { + const redisData = await this.chartDataService.getProjectChartData( + projectId, + startDate, + endDate, + groupBy, + timezoneOffset + ); + + if (redisData && redisData.length > 0) { + return redisData; + } + + // Fallback to Mongo (empty groupHash for project-level data) + return this.findChartData(days, timezoneOffset, ''); + } catch (err) { + console.error('[EventsFactory] getProjectChartData error:', err); + + // Fallback to Mongo on error (empty groupHash for project-level data) + return this.findChartData(days, timezoneOffset, ''); + } + } + + /** + * Get event daily chart data from MongoDB only + * + * @param {string} groupHash - event's group hash + * @param {number} days - how many days to fetch + * @param {number} timezoneOffset - user's local timezone offset in minutes + * @returns {Promise} + */ + async getEventDailyChart(groupHash, days, timezoneOffset = 0) { + return this.findChartData(days, timezoneOffset, groupHash); + } + /** * Fetch timestamps and total count of errors (or target error) for each day since * diff --git a/src/redisHelper.ts b/src/redisHelper.ts new file mode 100644 index 00000000..b8c2b585 --- /dev/null +++ b/src/redisHelper.ts @@ -0,0 +1,168 @@ +import HawkCatcher from '@hawk.so/nodejs'; +import { createClient, RedisClientType } from 'redis'; + +// eslint call error: 0:0 error Parsing error: Cannot read properties of undefined (reading 'map') +// export type TsRangeResult = [timestamp: string, value: string]; +export type TsRangeResult = any; + +/** + * Helper class for working with Redis + */ +export default class RedisHelper { + /** + * TTL for lock records in Redis (in seconds) + */ + private static readonly LOCK_TTL = 10; + + /** + * Singleton instance + */ + private static instance: RedisHelper | null = null; + + /** + * Redis client instance + */ + private redisClient: RedisClientType | null = null; + + /** + * Flag to track if we're currently reconnecting + */ + private isReconnecting = false; + + /** + * Constructor + * Initializes the Redis client and sets up error handling with auto-reconnect + */ + constructor() { + if (!process.env.REDIS_URL) { + console.warn('[Redis] REDIS_URL not set, Redis features will be disabled'); + return; + } + + try { + this.redisClient = createClient({ + url: process.env.REDIS_URL, + socket: { + reconnectStrategy: (retries) => { + /* + * Exponential backoff: wait longer between each retry + * Max wait time: 30 seconds + */ + const delay = Math.min(retries * 1000, 30000); + console.log(`[Redis] Reconnecting... attempt ${retries}, waiting ${delay}ms`); + return delay; + }, + }, + }); + + // Handle connection errors + this.redisClient.on('error', (error) => { + console.error('[Redis] Client error:', error); + if (error) { + HawkCatcher.send(error); + } + }); + + // Handle successful reconnection + this.redisClient.on('ready', () => { + console.log('[Redis] Client ready'); + this.isReconnecting = false; + }); + + // Handle reconnecting event + this.redisClient.on('reconnecting', () => { + console.log('[Redis] Client reconnecting...'); + this.isReconnecting = true; + }); + + // Handle connection end + this.redisClient.on('end', () => { + console.log('[Redis] Connection ended'); + }); + } catch (error) { + console.error('[Redis] Error creating client:', error); + HawkCatcher.send(error as Error); + this.redisClient = null; + } + } + + /** + * Get singleton instance + */ + public static getInstance(): RedisHelper { + if (!RedisHelper.instance) { + RedisHelper.instance = new RedisHelper(); + } + return RedisHelper.instance; + } + + /** + * Connect to Redis + */ + public async initialize(): Promise { + if (!this.redisClient) { + console.warn('[Redis] Client not initialized, skipping connection'); + return; + } + + try { + if (!this.redisClient.isOpen && !this.isReconnecting) { + await this.redisClient.connect(); + console.log('[Redis] Connected successfully'); + } + } catch (error) { + console.error('[Redis] Connection failed:', error); + HawkCatcher.send(error as Error); + // Don't throw - let reconnectStrategy handle it + } + } + + /** + * Close Redis client + */ + public async close(): Promise { + if (this.redisClient?.isOpen) { + await this.redisClient.quit(); + console.log('[Redis] Connection closed'); + } + } + + /** + * Check if Redis is connected + */ + public isConnected(): boolean { + return Boolean(this.redisClient?.isOpen); + } + + /** + * Execute TS.RANGE command with aggregation + * + * @param key - Redis TimeSeries key + * @param start - start timestamp in milliseconds + * @param end - end timestamp in milliseconds + * @param aggregationType - aggregation type (sum, avg, min, max, etc.) + * @param bucketMs - bucket size in milliseconds + * @returns Array of [timestamp, value] tuples + */ + public async tsRange( + key: string, + start: string, + end: string, + aggregationType: string, + bucketMs: string + ): Promise { + if (!this.redisClient) { + throw new Error('Redis client not initialized'); + } + + return (await this.redisClient.sendCommand([ + 'TS.RANGE', + key, + start, + end, + 'AGGREGATION', + aggregationType, + bucketMs, + ])) as TsRangeResult[]; + } +} diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 5b8804da..b9f59ea3 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -86,7 +86,7 @@ module.exports = { async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) { const factory = getEventsFactory(context, projectId); - return factory.findChartData(days, timezoneOffset, groupHash); + return factory.getEventDailyChart(groupHash, days, timezoneOffset); }, /** diff --git a/src/resolvers/project.js b/src/resolvers/project.js index f21cfd05..786aff11 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -493,10 +493,10 @@ module.exports = { * * @return {Promise} */ - async chartData(project, { days, timezoneOffset }, context) { + async chartData(project, { startDate, endDate, groupBy, timezoneOffset }, context) { const factory = getEventsFactory(context, project._id); - return factory.findChartData(days, timezoneOffset); + return factory.getProjectChartData(project._id, startDate, endDate, groupBy, timezoneOffset); }, /** diff --git a/src/services/chartDataService.ts b/src/services/chartDataService.ts new file mode 100644 index 00000000..ff7dc21f --- /dev/null +++ b/src/services/chartDataService.ts @@ -0,0 +1,91 @@ +import RedisHelper, { TsRangeResult } from '../redisHelper'; +import { composeProjectMetricsKey, getTimeSeriesSuffix } from '../utils/chartStorageKeys'; + +/** + * Service for fetching chart data from Redis TimeSeries + */ +export default class ChartDataService { + private redisHelper: RedisHelper; + + constructor(redisHelper: RedisHelper) { + this.redisHelper = redisHelper; + } + + /** + * Get project chart data from Redis TimeSeries + * + * @param projectId - project ID + * @param startDate - start date as ISO string (e.g., '2025-01-01T00:00:00Z') + * @param endDate - end date as ISO string (e.g., '2025-01-31T23:59:59Z') + * @param groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day) + * @param timezoneOffset - user's local timezone offset in minutes (default: 0) + * @returns Array of data points with timestamp and count + * @throws Error if Redis is not connected (caller should fallback to MongoDB) + */ + public async getProjectChartData( + projectId: string, + startDate: string, + endDate: string, + groupBy: number, + timezoneOffset = 0 + ): Promise<{ timestamp: number; count: number }[]> { + // Check if Redis is connected + if (!this.redisHelper.isConnected()) { + console.warn('[ChartDataService] Redis not connected, will fallback to MongoDB'); + throw new Error('Redis client not connected'); + } + + // Determine granularity and compose key + const granularity = getTimeSeriesSuffix(groupBy); + const key = composeProjectMetricsKey(granularity, projectId); + + // Parse ISO date strings to milliseconds + const start = new Date(startDate).getTime(); + const end = new Date(endDate).getTime(); + const bucketMs = groupBy * 60 * 1000; + + // Fetch data from Redis + let result: TsRangeResult[] = []; + try { + result = await this.redisHelper.tsRange( + key, + start.toString(), + end.toString(), + 'sum', + bucketMs.toString() + ); + } catch (err: any) { + if (err.message.includes('TSDB: the key does not exist')) { + console.warn(`[ChartDataService] Key ${key} does not exist, returning zeroed data`); + result = []; + } else { + throw err; + } + } + + // Transform data from Redis + const dataPoints: { [ts: number]: number } = {}; + for (const [tsStr, valStr] of result) { + const tsMs = Number(tsStr); + dataPoints[tsMs] = Number(valStr) || 0; + } + + // Fill missing intervals with zeros + const filled: { timestamp: number; count: number }[] = []; + let current = start; + + // Round current to the nearest bucket boundary + current = Math.floor(current / bucketMs) * bucketMs; + + while (current <= end) { + const count = dataPoints[current] || 0; + filled.push({ + timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000), + count, + }); + current += bucketMs; + } + + return filled.sort((a, b) => a.timestamp - b.timestamp); + } +} diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index ead9af4a..c1f4ec77 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -278,7 +278,7 @@ type Event { usersAffected: Int """ - Return graph of the error rate for the last few days + Return graph of the error rate for the specified period """ chartData( """ diff --git a/src/typeDefs/project.ts b/src/typeDefs/project.ts index 93dfb35b..f5ce891e 100644 --- a/src/typeDefs/project.ts +++ b/src/typeDefs/project.ts @@ -354,9 +354,19 @@ type Project { """ chartData( """ - How many days we need to fetch for displaying in a chart + Start date (ISO 8601 DateTime string) """ - days: Int! = 0 + startDate: DateTime! + + """ + End date (ISO 8601 DateTime string) + """ + endDate: DateTime! + + """ + Grouping interval in minutes (1=minute, 60=hour, 1440=day) + """ + groupBy: Int! = 60 """ User's local timezone offset in minutes diff --git a/src/utils/chartStorageKeys.ts b/src/utils/chartStorageKeys.ts new file mode 100644 index 00000000..be51e077 --- /dev/null +++ b/src/utils/chartStorageKeys.ts @@ -0,0 +1,50 @@ +/** + * Utilities for composing Redis TimeSeries keys for chart data storage + * + * Note: Event-level metrics use MongoDB only (not Redis TimeSeries) + * Only project-level metrics are stored in Redis TimeSeries + */ + +/** + * Compose Redis TimeSeries key for project-level metrics + * + * @param granularity - time granularity (minutely, hourly, daily) + * @param projectId - project ID + * @param metricType - metric type (default: 'events-accepted') + * @returns Redis key string in format: ts:project-{metricType}:{projectId}:{granularity} + * + * @example + * composeProjectMetricsKey('hourly', '123abc') + * // => 'ts:project-events-accepted:123abc:hourly' + * + * @example + * composeProjectMetricsKey('daily', '123abc', 'events-rate-limited') + * // => 'ts:project-events-rate-limited:123abc:daily' + */ +export function composeProjectMetricsKey( + granularity: string, + projectId: string, + metricType = 'events-accepted' +): string { + return `ts:project-${metricType}:${projectId}:${granularity}`; +} + +/** + * Get time granularity suffix based on groupBy interval + * + * @param groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day) + * @returns suffix string (minutely, hourly, daily) + */ +export function getTimeSeriesSuffix(groupBy: number): string { + switch (groupBy) { + case 1: + return 'minutely'; + case 60: + return 'hourly'; + case 1440: + return 'daily'; + default: + // For custom intervals, fallback to minutely with aggregation + return 'minutely'; + } +} \ No newline at end of file diff --git a/test/integration/api.env b/test/integration/api.env index 69703097..8598b55f 100644 --- a/test/integration/api.env +++ b/test/integration/api.env @@ -7,7 +7,9 @@ MONGO_HAWK_DB_URL=mongodb://mongodb:27017/hawk # Events database URL MONGO_EVENTS_DB_URL=mongodb://mongodb:27017/hawk_events - +# Redis URL (optional, for TimeSeries metrics) +# If not set, Redis features will be disabled gracefully +REDIS_URL= # MongoDB settings MONGO_RECONNECT_TRIES=60 diff --git a/test/integration/jestEnv.js b/test/integration/jestEnv.js index cedfba31..0249f971 100644 --- a/test/integration/jestEnv.js +++ b/test/integration/jestEnv.js @@ -1,6 +1,7 @@ const NodeEnvironment = require('jest-environment-node'); const amqp = require('amqplib'); const mongodb = require('mongodb'); +const { installRedisMock, uninstallRedisMock } = require('./redisMock'); /** * Custom test environment for defining global connections @@ -19,6 +20,12 @@ class CustomEnvironment extends NodeEnvironment { await mongoClient.db('hawk').dropDatabase(); // await mongoClient.db('codex_accounting').dropDatabase(); + /** + * Use redis-mock instead of a real Redis connection. + * This avoids spinning up Redis during integration tests while keeping the API surface. + */ + this.global.redisClient = installRedisMock(); + this.rabbitMqConnection = await amqp.connect('amqp://guest:guest@rabbitmq:5672/'); this.global.rabbitChannel = await this.rabbitMqConnection.createChannel(); await this.global.rabbitChannel.purgeQueue('cron-tasks/limiter'); @@ -41,6 +48,8 @@ class CustomEnvironment extends NodeEnvironment { if (this.rabbitMqConnection) { await this.rabbitMqConnection.close(); } + + uninstallRedisMock(); } catch (error) { console.error('Error during teardown:', error); } diff --git a/test/integration/redisMock.js b/test/integration/redisMock.js new file mode 100644 index 00000000..1c98efcd --- /dev/null +++ b/test/integration/redisMock.js @@ -0,0 +1,72 @@ +const path = require('path'); +const redisMock = require('redis-mock'); + +let originalRedisModule = null; +let redisModulePath = null; + +/** + * Create Redis mock client compatible with node-redis v4 API portions we use. + * + * @returns {object} mocked redis client + */ +function createMockClient() { + const client = redisMock.createClient(); + + client.isOpen = true; + client.connect = async () => client; + client.quit = async () => undefined; + client.sendCommand = async () => []; + client.on = () => client; + + return client; +} + +/** + * Install redis-mock into Node's module cache so that `require('redis')` + * returns the mocked client factory. + * + * @returns {object} mock client instance to be reused in tests + */ +function installRedisMock() { + redisModulePath = require.resolve('redis'); + originalRedisModule = require.cache[redisModulePath] || null; + + const mockExports = { + createClient: () => createMockClient(), + }; + + require.cache[redisModulePath] = { + id: redisModulePath, + filename: redisModulePath, + loaded: true, + exports: mockExports, + path: path.dirname(redisModulePath), + children: [], + }; + + return mockExports.createClient(); +} + +/** + * Restore original `redis` module if it existed. + */ +function uninstallRedisMock() { + if (!redisModulePath) { + return; + } + + if (originalRedisModule) { + require.cache[redisModulePath] = originalRedisModule; + } else { + delete require.cache[redisModulePath]; + } + + originalRedisModule = null; + redisModulePath = null; +} + +module.exports = { + installRedisMock, + uninstallRedisMock, +}; + diff --git a/yarn.lock b/yarn.lock index deea0104..44240cfb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -788,6 +788,40 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@redis/bloom@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71" + integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg== + +"@redis/client@1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.6.1.tgz#c4636b7cb34e96008a988409b7e787364ae761a2" + integrity sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw== + dependencies: + cluster-key-slot "1.1.2" + generic-pool "3.9.0" + yallist "4.0.0" + +"@redis/graph@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.1.tgz#8c10df2df7f7d02741866751764031a957a170ea" + integrity sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw== + +"@redis/json@1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.7.tgz#016257fcd933c4cbcb9c49cde8a0961375c6893b" + integrity sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ== + +"@redis/search@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.2.0.tgz#50976fd3f31168f585666f7922dde111c74567b8" + integrity sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw== + +"@redis/time-series@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.1.0.tgz#cba454c05ec201bd5547aaf55286d44682ac8eb5" + integrity sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g== + "@shelf/jest-mongodb@^1.2.2": version "1.3.4" resolved "https://registry.yarnpkg.com/@shelf/jest-mongodb/-/jest-mongodb-1.3.4.tgz#200bac386cf513bed2d41952b1857689f0b88f31" @@ -2138,6 +2172,11 @@ cloudpayments@^6.0.1: object-hash "^2.2.0" qs "^6.10.1" +cluster-key-slot@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -3326,6 +3365,11 @@ gauge@^3.0.0: strip-ansi "^6.0.1" wide-align "^1.1.2" +generic-pool@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4" + integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -5718,6 +5762,23 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +redis-mock@^0.56.3: + version "0.56.3" + resolved "https://registry.yarnpkg.com/redis-mock/-/redis-mock-0.56.3.tgz#e96471bcc774ddc514c2fc49cdd03cab2baecd89" + integrity sha512-ynaJhqk0Qf3Qajnwvy4aOjS4Mdf9IBkELWtjd+NYhpiqu4QCNq6Vf3Q7c++XRPGiKiwRj9HWr0crcwy7EiPjYQ== + +redis@^4.7.0: + version "4.7.1" + resolved "https://registry.yarnpkg.com/redis/-/redis-4.7.1.tgz#08588a30936be0e7ad9c0f3e1ac6a85ccaf73e94" + integrity sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ== + dependencies: + "@redis/bloom" "1.2.0" + "@redis/client" "1.6.1" + "@redis/graph" "1.1.1" + "@redis/json" "1.0.7" + "@redis/search" "1.2.0" + "@redis/time-series" "1.1.0" + regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" @@ -7107,7 +7168,7 @@ y18n@^4.0.0: resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== -yallist@^4.0.0: +yallist@4.0.0, yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==