From f3881b1f29d7fb097d1132faea5b1a325b4d384a Mon Sep 17 00:00:00 2001 From: jlenon7 Date: Mon, 18 Aug 2025 11:16:30 -0300 Subject: [PATCH 1/3] feat(driver): implement redis driver --- .github/workflows/ci.yml | 27 +-- compose.yml | 8 + configurer/cache | 42 ++++ package-lock.json | 88 +++++++- package.json | 3 +- src/cache/CacheImpl.ts | 20 +- src/cache/drivers/Driver.ts | 29 +++ src/cache/drivers/RedisDriver.ts | 220 ++++++++++++++++++++ src/factories/StoreFactory.ts | 23 +- src/types/StoreOptions.ts | 8 + tests/fixtures/config/cache.ts | 9 + tests/unit/cache/drivers/RedisDriverTest.ts | 152 ++++++++++++++ tests/unit/factories/StoreFactoryTest.ts | 10 +- 13 files changed, 605 insertions(+), 34 deletions(-) create mode 100644 compose.yml create mode 100644 configurer/cache create mode 100644 src/cache/drivers/RedisDriver.ts create mode 100644 tests/unit/cache/drivers/RedisDriverTest.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5054bc..471377b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,11 @@ on: jobs: linux: runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - '6382:6379' strategy: matrix: node-version: @@ -32,25 +37,3 @@ jobs: - name: Test code compilation run: npm run build - - windows: - runs-on: windows-latest - strategy: - matrix: - node-version: - - 21.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies - run: npm install - - - name: Run tests - run: npm run test:coverage - - - name: Test code compilation - run: npm run build diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..e781296 --- /dev/null +++ b/compose.yml @@ -0,0 +1,8 @@ +version: '3' + +services: + redis: + container_name: athenna_db_redis + image: redis:6.2-alpine + ports: + - '6382:6379' diff --git a/configurer/cache b/configurer/cache new file mode 100644 index 0000000..30ac9ea --- /dev/null +++ b/configurer/cache @@ -0,0 +1,42 @@ +import { Env } from '@athenna/config' + +export default { + /* + |-------------------------------------------------------------------------- + | Default Cache Store Name + |-------------------------------------------------------------------------- + | + | Athenna's cache API supports an assortment of back-ends via a single + | API, giving you convenient access to each back-end using the same + | syntax for every one. Here you may define a default store. + | + */ + + default: Env('CACHE_STORE', 'memory'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may configure the store connection information for each server that + | is used by your application. A default configuration has been added + | for each back-end shipped with Athenna. You are free to add more. + | + | Drivers: "redis", "memory" + | + */ + + stores: { + memory: { + driver: 'memory' + }, + + redis: { + driver: 'redis', + + url: Env('REDIS_URL', 'redis://localhost:6379?database=0') + } + } +} + diff --git a/package-lock.json b/package-lock.json index 4eff2b9..1097a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "5.1.0", "license": "MIT", "dependencies": { - "lru-cache": "^11.1.0" + "lru-cache": "^11.1.0", + "redis": "^5.8.1" }, "devDependencies": { "@athenna/artisan": "^5.7.0", @@ -1571,6 +1572,66 @@ "dev": true, "license": "MIT" }, + "node_modules/@redis/bloom": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz", + "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" + } + }, + "node_modules/@redis/client": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz", + "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/json": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz", + "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" + } + }, + "node_modules/@redis/search": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz", + "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz", + "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3216,6 +3277,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/collect.js": { "version": "4.36.1", "resolved": "https://registry.npmjs.org/collect.js/-/collect.js-4.36.1.tgz", @@ -9727,6 +9797,22 @@ "node": ">= 12.13.0" } }, + "node_modules/redis": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.1.tgz", + "integrity": "sha512-RZjBKYX/qFF809x6vDcE5VA6L3MmiuT+BkbXbIyyyeU0lPD47V4z8qTzN+Z/kKFwpojwCItOfaItYuAjNs8pTQ==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.8.1", + "@redis/client": "5.8.1", + "@redis/json": "5.8.1", + "@redis/search": "5.8.1", + "@redis/time-series": "5.8.1" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", diff --git a/package.json b/package.json index 0ea3580..734f827 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ } }, "dependencies": { - "lru-cache": "^11.1.0" + "lru-cache": "^11.1.0", + "redis": "^5.8.1" } } diff --git a/src/cache/CacheImpl.ts b/src/cache/CacheImpl.ts index c6394c3..700fab6 100644 --- a/src/cache/CacheImpl.ts +++ b/src/cache/CacheImpl.ts @@ -10,7 +10,8 @@ import { Macroable, Options } from '@athenna/common' import { StoreFactory } from '#src/factories/StoreFactory' import type { StoreOptions } from '#src/types/StoreOptions' -import { MemoryDriver } from '#src/cache/drivers/MemoryDriver' +import type { RedisDriver } from '#src/cache/drivers/RedisDriver' +import type { MemoryDriver } from '#src/cache/drivers/MemoryDriver' import type { Driver as DriverImpl } from '#src/cache/drivers/Driver' export class CacheImpl extends Macroable { @@ -22,7 +23,7 @@ export class CacheImpl extends Macroable { /** * The drivers responsible for handling cache operations. */ - public driver: Driver = null + public driver: RedisDriver | MemoryDriver = null /** * Creates a new instance of CacheImpl. @@ -33,13 +34,19 @@ export class CacheImpl extends Macroable { this.driver = StoreFactory.fabricate( this.storeName, athennaCacheOpts?.options - ) as unknown as Driver + ) this.connect(athennaCacheOpts) } + public store(store: 'redis', options?: StoreOptions): CacheImpl public store(store: 'memory', options?: StoreOptions): CacheImpl - public store(store: 'memory' | string): CacheImpl + + public store( + store: 'redis' | 'memory' | string, + options?: StoreOptions + ): CacheImpl | CacheImpl + /** * Change the store connection. * @@ -48,7 +55,10 @@ export class CacheImpl extends Macroable { * await Cache.store('redis').set('my:cache:key', 'hello') * ``` */ - public store(store: 'memory' | string, options?: StoreOptions) { + public store( + store: 'redis' | 'memory' | string, + options?: StoreOptions + ): CacheImpl { const driver = StoreFactory.fabricate(store, options?.options) const cache = new CacheImpl(options) diff --git a/src/cache/drivers/Driver.ts b/src/cache/drivers/Driver.ts index d10407b..2c47309 100644 --- a/src/cache/drivers/Driver.ts +++ b/src/cache/drivers/Driver.ts @@ -36,6 +36,11 @@ export abstract class Driver { */ public ttl: number + /** + * Set the cache prefix of the driver. + */ + public prefix: string + /** * Define the max number of items that could be inserted in the cache. */ @@ -59,6 +64,7 @@ export abstract class Driver { this.ttl = options?.ttl || config.ttl this.maxItems = options?.maxItems || config.maxItems || 1000 this.maxEntrySize = options?.maxEntrySize || config.maxEntrySize + this.prefix = this.sanitizePrefix(options?.prefix || config?.prefix) this.store = store if (client) { @@ -93,6 +99,29 @@ export abstract class Driver { return this } + /** + * Sanitize the cache prefix by removing any trailing colons. + */ + public sanitizePrefix(prefix: string) { + if (!prefix) { + return '' + } + + return prefix.replace(/:+$/, '') + } + + /** + * Automatically set the cache prefix if it exists. + * Otherwise just return the key defined. + */ + public getCacheKey(key: string) { + if (this.prefix) { + return `${this.prefix}:${key}` + } + + return key + } + /** * Connect to client. */ diff --git a/src/cache/drivers/RedisDriver.ts b/src/cache/drivers/RedisDriver.ts new file mode 100644 index 0000000..f056ba1 --- /dev/null +++ b/src/cache/drivers/RedisDriver.ts @@ -0,0 +1,220 @@ +/** + * @athenna/cache + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Log } from '@athenna/logger' +import { Config } from '@athenna/config' +import type { StoreOptions } from '#src/types' +import { Driver } from '#src/cache/drivers/Driver' +import { Is, Parser, Options } from '@athenna/common' +import { StoreFactory } from '#src/factories/StoreFactory' +import { createClient, type RedisClientType } from 'redis' + +export class RedisDriver extends Driver { + /** + * Redis database URL. + */ + public url: string + + /** + * Redis database socket. + */ + public socket: any + + /** + * Redis database host. + */ + public host: string + + /** + * Redis database port. + */ + public port: number + + /** + * Redis connection protocol. + */ + public protocol: string + + /** + * Redis database username. + */ + public username: string + + /** + * Redis database password. + */ + public password: string + + /** + * Redis database number. + */ + public database: number + + public constructor( + store: string, + client: any = null, + options?: StoreOptions['options'] + ) { + super(store, client, options) + + const config = Config.get(`cache.stores.${store}`) + + this.url = options?.url || config?.url + this.host = options?.host || config?.host + this.port = options?.port || config?.port || 6379 + this.socket = options?.socket || config?.socket + this.username = options?.username || config?.username + this.password = options?.password || config?.password + this.database = options?.database || config?.database || 0 + this.protocol = options?.protocol || config?.protocol || 'redis' + + if (!this.url) { + this.url = Parser.connectionObjToDbUrl({ + host: this.host, + port: this.port, + user: this.username, + password: this.password, + protocol: this.protocol, + database: '' + }) + + this.url = `${this.url.slice(0, -1)}?database=${this.database}` + } + } + + /** + * Connect to client. + */ + public connect(options: StoreOptions) { + options = Options.create(options, { + force: false, + connect: true, + saveOnFactory: true + }) + + if (!options.connect) { + return + } + + if (this.isConnected && !options.force) { + return + } + + this.client = createClient({ url: this.url, socket: this.socket }) + this.client + .connect() + .then(() => { + if (Config.is('rc.bootLogs', true)) { + Log.channelOrVanilla('application').success( + `Successfully connected to ({yellow} ${this.store}) cache store` + ) + } + }) + .catch(err => { + console.error(err) + }) + this.isConnected = true + this.isSavedOnFactory = options.saveOnFactory + + if (options.saveOnFactory) { + StoreFactory.setClient(this.store, this.client) + } + } + + /** + * Close the connection with the client in this instance. + */ + public async close() { + if (!this.client || !this.isConnected) { + return + } + + try { + await this.client.quit() + } finally { + this.client = null + this.isConnected = false + + StoreFactory.setClient(this.store, null) + } + } + + /** + * Reset all data defined inside cache. + */ + public async truncate() { + let cursor = '0' + + do { + const response = await this.client.scan(cursor, { + COUNT: 500, + MATCH: `${this.prefix}*` + }) + + cursor = response.cursor + + if (response.keys.length) { + await this.client.del(response.keys) + } + } while (cursor !== '0') + } + + /** + * Get a value from the cache. + */ + public async get(key: string, defaultValue?: T): Promise { + const value = await this.client.get(this.getCacheKey(key)) + + if (Is.Null(value) || Is.Undefined(value)) { + return defaultValue + } + + return value as T + } + + /** + * Validate if a value exists in the cache. + */ + public async has(key: string): Promise { + const value = await this.get(key) + + return !!value + } + + /** + * Set a value in the cache. + */ + public async set(key: string, value: any, options?: { ttl?: number }) { + await this.client.set(this.getCacheKey(key), value, { + expiration: { + type: 'EX', + value: Math.ceil((options?.ttl || this.ttl) / 1000) + } + }) + } + + /** + * Get a value from the cache and delete it at + * the same time. + */ + public async pull(key: string) { + const value = await this.get(key) + + await this.delete(key) + + return value + } + + /** + * Delete a value from the cache. + */ + public async delete(key: string) { + await this.client.del(this.getCacheKey(key)) + } +} diff --git a/src/factories/StoreFactory.ts b/src/factories/StoreFactory.ts index 0309ea4..a4e84d8 100644 --- a/src/factories/StoreFactory.ts +++ b/src/factories/StoreFactory.ts @@ -10,6 +10,7 @@ import { debug } from '#src/debug' import type { StoreOptions } from '#src/types' import type { Driver } from '#src/cache/drivers/Driver' +import { RedisDriver } from '#src/cache/drivers/RedisDriver' import { MemoryDriver } from '#src/cache/drivers/MemoryDriver' import { NotFoundDriverException } from '#src/exceptions/NotFoundDriverException' import { NotImplementedConfigException } from '#src/exceptions/NotImplementedConfigException' @@ -23,10 +24,19 @@ export class StoreFactory { /** * Holds all the Athenna drivers implementations available. */ - public static drivers: Map = new Map().set( - 'memory', - MemoryDriver - ) + public static drivers: Map = new Map() + .set('redis', RedisDriver) + .set('memory', MemoryDriver) + + public static fabricate( + store: 'redis', + options?: StoreOptions['options'] + ): RedisDriver + + public static fabricate( + store: 'redis' | string, + options?: StoreOptions['options'] + ): RedisDriver public static fabricate( con: 'memory', @@ -38,6 +48,11 @@ export class StoreFactory { options?: StoreOptions['options'] ): MemoryDriver + public static fabricate( + con: 'redis' | 'memory' | string, + options?: StoreOptions['options'] + ): RedisDriver | MemoryDriver + /** * Fabricate a new connection for a specific driver. */ diff --git a/src/types/StoreOptions.ts b/src/types/StoreOptions.ts index e4d3bcc..597e11c 100644 --- a/src/types/StoreOptions.ts +++ b/src/types/StoreOptions.ts @@ -53,6 +53,14 @@ export type StoreOptions = { */ ttl?: number + /** + * Define a prefix for the store. By default, prefix + * will always be used in front of your keys if it exists. + * + * @default Config.get(`cache.stores.${store}.prefix`) + */ + prefix?: string + /** * Define the max number of items that could be inserted in the cache. * diff --git a/tests/fixtures/config/cache.ts b/tests/fixtures/config/cache.ts index 41357ea..2b7880f 100644 --- a/tests/fixtures/config/cache.ts +++ b/tests/fixtures/config/cache.ts @@ -37,6 +37,15 @@ export default { */ stores: { + redis: { + driver: 'redis', + ttl: 1000, + + host: 'localhost', + port: 6382, + database: 0 + }, + memory: { driver: 'memory', ttl: 1000 diff --git a/tests/unit/cache/drivers/RedisDriverTest.ts b/tests/unit/cache/drivers/RedisDriverTest.ts new file mode 100644 index 0000000..bc11c9c --- /dev/null +++ b/tests/unit/cache/drivers/RedisDriverTest.ts @@ -0,0 +1,152 @@ +/** + * @athenna/cache + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Cache, CacheProvider } from '#src' +import { Path, Sleep } from '@athenna/common' +import { Test, type Context, BeforeEach, AfterEach } from '@athenna/test' + +export class RedisDriverTest { + @BeforeEach() + public async beforeEach() { + await Config.loadAll(Path.fixtures('config')) + + new CacheProvider().register() + + /** + * Sleep to wait until redis is connected. + */ + await Sleep.for(100).milliseconds().wait() + } + + @AfterEach() + public async afterEach() { + await Cache.store('redis').truncate() + await Cache.closeAll() + ioc.reconstruct() + + Config.clear() + } + + @Test() + public async shouldBeAbleToConnectToDriver({ assert }: Context) { + Cache.store('redis') + + await Sleep.for(100).milliseconds().wait() + + assert.isTrue(Cache.isConnected()) + } + + @Test() + public async shouldBeAbleToCloseTheConnectionWithDriver({ assert }: Context) { + const cache = Cache.store('redis') + + await Sleep.for(100).milliseconds().wait() + + await cache.close() + + assert.isFalse(cache.isConnected()) + } + + @Test() + public async shouldBeAbleToCloneTheCacheInstance({ assert }: Context) { + const driver = Cache.store('redis').driver + const otherDriver = driver.clone() + + await Sleep.for(100).milliseconds().wait() + + driver.isConnected = false + + assert.isTrue(otherDriver.isConnected) + } + + @Test() + public async shouldBeAbleToGetDriverClient({ assert }: Context) { + const client = Cache.store('redis').driver.getClient() + + assert.isDefined(client) + } + + @Test() + public async shouldBeAbleToSetDifferentClientForDriver({ assert }: Context) { + const driver = Cache.store('redis').driver + + driver.setClient({ hello: 'world' } as any) + + assert.deepEqual(driver.client, { hello: 'world' }) + } + + @Test() + public async shouldBeAbleToSetValueToTheCache({ assert }: Context) { + const cache = Cache.store('redis') + + await cache.set('hello', 'world') + + assert.isDefined(cache.driver.client.get('hello')) + } + + @Test() + public async shouldBeAbleToVerifyIfTheCacheKeyExists({ assert }: Context) { + const cache = Cache.store('redis') + + assert.isFalse(await cache.has('hello')) + + await cache.set('hello', 'world') + + assert.isTrue(await cache.has('hello')) + } + + @Test() + public async shouldBeAbleToGetAValueFromTheCache({ assert }: Context) { + const cache = Cache.store('redis') + + await cache.set('hello', 'world') + + assert.deepEqual(await cache.get('hello'), 'world') + } + + @Test() + public async shouldBeAbleToDeleteAValueFromTheCache({ assert }: Context) { + const cache = Cache.store('redis') + + await cache.set('hello', 'world') + + assert.isTrue(await cache.has('hello')) + + await cache.delete('hello') + + assert.isFalse(await cache.has('hello')) + } + + @Test() + public async shouldBeAbleToPullAValueFromTheCache({ assert }: Context) { + const cache = Cache.store('redis') + + await cache.set('hello', 'world') + + assert.isTrue(await cache.has('hello')) + assert.deepEqual(await cache.pull('hello'), 'world') + assert.isFalse(await cache.has('hello')) + } + + @Test() + public async shouldBeAbleToTruncateTheCache({ assert }: Context) { + const cache = Cache.store('redis') + + await cache.set('hello', 'world') + await cache.set('other', 'world') + + assert.isTrue(await cache.has('hello')) + assert.isTrue(await cache.has('other')) + + await cache.truncate() + + assert.isFalse(await cache.has('hello')) + assert.isFalse(await cache.has('other')) + } +} diff --git a/tests/unit/factories/StoreFactoryTest.ts b/tests/unit/factories/StoreFactoryTest.ts index 6eac1e8..5f62f3d 100644 --- a/tests/unit/factories/StoreFactoryTest.ts +++ b/tests/unit/factories/StoreFactoryTest.ts @@ -10,6 +10,7 @@ import { MemoryDriver } from '#src' import { Path } from '@athenna/common' import { StoreFactory } from '#src/factories/StoreFactory' +import { RedisDriver } from '#src/cache/drivers/RedisDriver' import { TestDriver } from '#tests/fixtures/drivers/TestDriver' import { AfterEach, BeforeEach, Test, type Context } from '@athenna/test' import { NotFoundDriverException } from '#src/exceptions/NotFoundDriverException' @@ -32,7 +33,7 @@ export class StoreFactoryTest { public async shouldBeAbleToGetAllAvailableDrivers({ assert }: Context) { const availableDrivers = StoreFactory.availableDrivers() - assert.deepEqual(availableDrivers, ['memory']) + assert.deepEqual(availableDrivers, ['redis', 'memory']) } @Test() @@ -51,6 +52,13 @@ export class StoreFactoryTest { assert.deepEqual(availableStores, ['test']) } + @Test() + public async shouldBeAbleToFabricateNewStoresAndReturnRedisDriverInstance({ assert }: Context) { + const driver = StoreFactory.fabricate('redis') + + assert.instanceOf(driver, RedisDriver) + } + @Test() public async shouldBeAbleToFabricateNewStoresAndReturnMemoryDriverInstance({ assert }: Context) { const driver = StoreFactory.fabricate('memory') From c2651c922f69266a546dd6ee4a88c54aa6c171f7 Mon Sep 17 00:00:00 2001 From: jlenon7 Date: Mon, 18 Aug 2025 11:26:57 -0300 Subject: [PATCH 2/3] feat(driver): safe import driver dependencies --- package-lock.json | 16 +++++++++++----- package.json | 8 +++----- src/cache/drivers/Driver.ts | 19 +++++++++++++++++++ src/cache/drivers/MemoryDriver.ts | 4 +++- src/cache/drivers/RedisDriver.ts | 4 +++- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1097a75..4571f28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,6 @@ "name": "@athenna/cache", "version": "5.1.0", "license": "MIT", - "dependencies": { - "lru-cache": "^11.1.0", - "redis": "^5.8.1" - }, "devDependencies": { "@athenna/artisan": "^5.7.0", "@athenna/common": "^5.17.0", @@ -33,7 +29,9 @@ "eslint-plugin-promise": "^6.6.0", "husky": "^3.1.0", "lint-staged": "^12.5.0", - "prettier": "^2.8.8" + "lru-cache": "^11.1.0", + "prettier": "^2.8.8", + "redis": "^5.8.1" }, "engines": { "node": ">=20.0.0" @@ -1576,6 +1574,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz", "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 18" @@ -1588,6 +1587,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz", "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==", + "dev": true, "license": "MIT", "dependencies": { "cluster-key-slot": "1.1.2" @@ -1600,6 +1600,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz", "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 18" @@ -1612,6 +1613,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz", "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 18" @@ -1624,6 +1626,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz", "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 18" @@ -3281,6 +3284,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=0.10.0" @@ -8422,6 +8426,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "dev": true, "license": "ISC", "engines": { "node": "20 || >=22" @@ -9801,6 +9806,7 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.1.tgz", "integrity": "sha512-RZjBKYX/qFF809x6vDcE5VA6L3MmiuT+BkbXbIyyyeU0lPD47V4z8qTzN+Z/kKFwpojwCItOfaItYuAjNs8pTQ==", + "dev": true, "license": "MIT", "dependencies": { "@redis/bloom": "5.8.1", diff --git a/package.json b/package.json index 734f827..133325a 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,9 @@ "eslint-plugin-promise": "^6.6.0", "husky": "^3.1.0", "lint-staged": "^12.5.0", - "prettier": "^2.8.8" + "lru-cache": "^11.1.0", + "prettier": "^2.8.8", + "redis": "^5.8.1" }, "c8": { "all": true, @@ -164,9 +166,5 @@ } ] } - }, - "dependencies": { - "lru-cache": "^11.1.0", - "redis": "^5.8.1" } } diff --git a/src/cache/drivers/Driver.ts b/src/cache/drivers/Driver.ts index 2c47309..7a03539 100644 --- a/src/cache/drivers/Driver.ts +++ b/src/cache/drivers/Driver.ts @@ -9,6 +9,7 @@ import { Config } from '@athenna/config' import type { StoreOptions } from '#src/types' +import { Module } from '@athenna/common' export abstract class Driver { /** @@ -74,6 +75,24 @@ export abstract class Driver { } } + /** + * Import the redis module if it exists. + */ + public getRedis() { + const require = Module.createRequire(import.meta.url) + + return require('redis') + } + + /** + * Import the lru-cache module if it exists. + */ + public getLruCache() { + const require = Module.createRequire(import.meta.url) + + return require('lru-cache') + } + /** * Clone the driver instance. */ diff --git a/src/cache/drivers/MemoryDriver.ts b/src/cache/drivers/MemoryDriver.ts index 5ca1506..b24ac5f 100644 --- a/src/cache/drivers/MemoryDriver.ts +++ b/src/cache/drivers/MemoryDriver.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import { LRUCache } from 'lru-cache' +import type { LRUCache } from 'lru-cache' import type { StoreOptions } from '#src/types' import { Driver } from '#src/cache/drivers/Driver' import { Parser, Options, Is } from '@athenna/common' @@ -32,6 +32,8 @@ export class MemoryDriver extends Driver> { return } + const { LRUCache } = this.getLruCache() + this.client = new LRUCache({ max: this.maxItems, maxEntrySize: this.maxEntrySize diff --git a/src/cache/drivers/RedisDriver.ts b/src/cache/drivers/RedisDriver.ts index f056ba1..bb845c7 100644 --- a/src/cache/drivers/RedisDriver.ts +++ b/src/cache/drivers/RedisDriver.ts @@ -9,11 +9,11 @@ import { Log } from '@athenna/logger' import { Config } from '@athenna/config' +import type { RedisClientType } from 'redis' import type { StoreOptions } from '#src/types' import { Driver } from '#src/cache/drivers/Driver' import { Is, Parser, Options } from '@athenna/common' import { StoreFactory } from '#src/factories/StoreFactory' -import { createClient, type RedisClientType } from 'redis' export class RedisDriver extends Driver { /** @@ -106,6 +106,8 @@ export class RedisDriver extends Driver { return } + const { createClient } = this.getRedis() + this.client = createClient({ url: this.url, socket: this.socket }) this.client .connect() From d3375324aad702166215e5e87d30c3b29c10973c Mon Sep 17 00:00:00 2001 From: jlenon7 Date: Mon, 18 Aug 2025 11:48:17 -0300 Subject: [PATCH 3/3] feat(configurer): auto add redis docker compose --- configurer/docker/redis/file.yml | 8 +++++ configurer/docker/redis/service.ts | 14 ++++++++ configurer/index.ts | 58 +++++++++++++++++++++++++++++- package-lock.json | 4 +-- package.json | 2 +- 5 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 configurer/docker/redis/file.yml create mode 100644 configurer/docker/redis/service.ts diff --git a/configurer/docker/redis/file.yml b/configurer/docker/redis/file.yml new file mode 100644 index 0000000..1e3a7bc --- /dev/null +++ b/configurer/docker/redis/file.yml @@ -0,0 +1,8 @@ +version: '3' + +services: + redis: + container_name: athenna_redis + image: redis + ports: + - '6379:6379' diff --git a/configurer/docker/redis/service.ts b/configurer/docker/redis/service.ts new file mode 100644 index 0000000..3cf2459 --- /dev/null +++ b/configurer/docker/redis/service.ts @@ -0,0 +1,14 @@ +/** + * @athenna/cache + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export default { + container_name: 'athenna_redis', + image: 'redis', + ports: ['6379:6379'] +} diff --git a/configurer/index.ts b/configurer/index.ts index 32641e5..9fbf672 100644 --- a/configurer/index.ts +++ b/configurer/index.ts @@ -7,12 +7,18 @@ * file that was distributed with this source code. */ -import { File, Path } from '@athenna/common' import { BaseConfigurer } from '@athenna/artisan' +import { File, Path, Parser, Module } from '@athenna/common' export default class CacheConfigurer extends BaseConfigurer { public async configure() { + const stores = await this.prompt.checkbox( + 'Which cache stores do you plan to use?', + ['redis', 'memory'] + ) + const task = this.logger.task() + const hasSelectedRedis = stores.find(store => store === 'redis') task.addPromise(`Create cache.${Path.ext()} config file`, () => { return new File('./cache').copy(Path.config(`cache.${Path.ext()}`)) @@ -24,6 +30,56 @@ export default class CacheConfigurer extends BaseConfigurer { .save() }) + task.addPromise('Update .env, .env.test and .env.example', () => { + let envs = '\nCACHE_STORE=memory\n' + + if (hasSelectedRedis) { + envs += 'REDIS_URL=redis://localhost:6379?database=0\n' + } + + return new File(Path.pwd('.env'), '') + .append(envs) + .then(() => new File(Path.pwd('.env.test'), '').append(envs)) + .then(() => new File(Path.pwd('.env.example'), '').append(envs)) + }) + + if (hasSelectedRedis) { + task.addPromise('Add service to docker-compose.yml file', async () => { + const hasDockerCompose = await File.exists( + Path.pwd('docker-compose.yml') + ) + + if (hasDockerCompose) { + const docker = await new File( + Path.pwd('docker-compose.yml') + ).getContentAsYaml() + + docker.services.redis = await Module.get( + import('./docker/redis/service.js') + ) + + return new File(Path.pwd('docker-compose.yml')).setContent( + Parser.objectToYamlString(docker) + ) + } + + return new File(`./docker/redis/file.yml`).copy( + Path.pwd('docker-compose.yml') + ) + }) + } + + const libraries = { + redis: ['redis'], + memory: ['lru-cache'] + } + + task.addPromise(`Install ${stores.join(', ')} libraries`, () => { + return stores.athenna.concurrently(store => { + return this.npm.install(libraries[store]) + }) + }) + await task.run() console.log() diff --git a/package-lock.json b/package-lock.json index 4571f28..d6e56d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/cache", - "version": "5.1.0", + "version": "5.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/cache", - "version": "5.1.0", + "version": "5.2.0", "license": "MIT", "devDependencies": { "@athenna/artisan": "^5.7.0", diff --git a/package.json b/package.json index 133325a..2a8ea55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/cache", - "version": "5.1.0", + "version": "5.2.0", "description": "The cache handler for Athenna Framework.", "license": "MIT", "author": "João Lenon ",