diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..217e19a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,133 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + install_dependencies: + name: Install Project Dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Up Bun Package Manager + uses: oven-sh/setup-bun@v2 + + - name: Install Project Dependencies + run: bun install + + - name: Cache Node Modules + uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-node-modules- + + build_library: + name: Build Project Library + runs-on: ubuntu-latest + needs: install_dependencies + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Up Bun Package Manager + uses: oven-sh/setup-bun@v2 + + - name: Cache Node Modules + uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-node-modules- + + - name: Cache Build Output Directory + uses: actions/cache@v3 + with: + path: | + dist + key: ${{ runner.os }}-dist-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-dist- + + - name: Build the Project + run: bun run build + + run_tests: + name: Execute Test Suite + runs-on: ubuntu-latest + needs: build_library + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Up Bun Package Manager + uses: oven-sh/setup-bun@v2 + + - name: Cache Node Modules + uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-node-modules- + + - name: Cache Build Output Directory + uses: actions/cache@v3 + with: + path: | + dist + key: ${{ runner.os }}-dist-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-dist- + + - name: Install Dependencies for Example Project + run: | + cd example + rm -rf node_modules + bun install + + - name: Generate Prisma Client + run: | + cd example + bunx prisma generate + + - name: Run Test Suite + env: + REDIS_SERVICE_URI: ${{ secrets.REDIS_SERVICE_URI }} + POSTGRES_SERVICE_URI: ${{ secrets.POSTGRES_SERVICE_URI }} + run: bun test + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + # publish_to_npm: + # name: Publish Package to NPM + # runs-on: ubuntu-latest + # needs: run_tests + # steps: + # - name: Checkout Repository + # uses: actions/checkout@v4 + + # - name: Set Up Bun Package Manager + # uses: oven-sh/setup-bun@v2 + + # - name: Publish Package to NPM + # env: + # NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} + # run: bun publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c98fcc0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,56 @@ +name: test + +on: push + +jobs: + test: + name: test + runs-on: ubuntu-latest + services: + dragonfly: + image: "docker.dragonflydb.io/dragonflydb/dragonfly" + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 3 + volumes: + - dragonfly:/data + postgres: + image: postgres:alpine + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U user" + --health-interval 10s + --health-timeout 5s + --health-retries 3 + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: testdb + volumes: + - postgres:/var/lib/postgresql/data + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Up Bun Package Manager + uses: oven-sh/setup-bun@v2 + + - name: Install Project Dependencies + run: bun install + + - name: Run Test Suite + env: + REDIS_SERVICE_URI: redis://localhost:6379 + POSTGRES_SERVICE_URI: postgres://user:password@localhost:5432/testdb + run: bun run test + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index de4d1f0..4cfbd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ +.env +coverage dist node_modules diff --git a/README.md b/README.md index 1f1723a..c9f3705 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Prisma Extension Redis +[![test](https://github.com/yxx4c/prisma-extension-redis/actions/workflows/test.yml/badge.svg)](https://github.com/yxx4c/prisma-extension-redis/actions/workflows/test.yml) +[![codecov](https://codecov.io/github/yxx4c/prisma-extension-redis/graph/badge.svg?token=G7O92H6I7T)](https://codecov.io/github/yxx4c/prisma-extension-redis) ![NPM License](https://img.shields.io/npm/l/prisma-extension-redis) ![NPM Version](https://img.shields.io/npm/v/prisma-extension-redis) ![NPM Downloads](https://img.shields.io/npm/dw/prisma-extension-redis) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bf103f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ + +# Security Policy for prisma-extension-redis + +## Reporting a Vulnerability + +If a security vulnerability is discovered in `prisma-extension-redis`, please report it responsibly. Your assistance in keeping this project secure is greatly appreciated. + +To report a vulnerability, please follow these steps: + +1. **Do not disclose the vulnerability publicly** until it has been addressed. +2. **Email the maintainer** at mail.yxx4c+security@gmail.com with the following information: +- A description of the vulnerability +- Steps to reproduce the issue +- Any relevant code snippets or configurations + +Reports will be acknowledged as soon as possible, typically within a few days, depending on the complexity of the issue. + +## Supported Versions + +The latest version of `prisma-extension-redis` is currently supported. Users are encouraged to use the most recent version to benefit from any security updates. + +## Security Best Practices + +To enhance the security of applications using `prisma-extension-redis`, consider the following best practices: + +1. **Keep Dependencies Updated**: Regularly check for updates to `prisma-extension-redis` and its dependencies. +2. **Use Environment Variables**: Store sensitive information, such as Redis connection strings and credentials, in environment variables instead of hardcoding them in the application. +3. **Limit Redis Access**: Configure the Redis instance to allow access only from trusted sources. +4. **Monitor Logs**: Regularly review application logs for any unusual activity. + +## Additional Resources + +- [OWASP Top Ten](https://owasp.org/www-project-top-ten/) - A list of the top ten security risks for web applications. +- [Redis Security](https://redis.io/topics/security) - Best practices for securing Redis instances. + +Thank you for helping to keep `prisma-extension-redis` secure! diff --git a/bun.lockb b/bun.lockb index c18470b..33c771b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..a1223a6 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +[test] + +coverage = true +coverageReporter = ["text", "lcov"] \ No newline at end of file diff --git a/example/.env b/example/.env deleted file mode 100644 index 4eb6c07..0000000 --- a/example/.env +++ /dev/null @@ -1,2 +0,0 @@ -REDIS_HOST_NAME=localhost -REDIS_PORT=6379 diff --git a/example/compose.yaml b/example/compose.yaml index cacfbfa..2e02952 100644 --- a/example/compose.yaml +++ b/example/compose.yaml @@ -1,7 +1,7 @@ name: prisma services: dragonfly: - image: 'docker.dragonflydb.io/dragonflydb/dragonfly' + image: "docker.dragonflydb.io/dragonflydb/dragonfly" hostname: dragonfly container_name: dragonfly restart: always @@ -10,19 +10,19 @@ services: ulimits: memlock: -1 ports: - - '6379:6379' + - "6379:6379" volumes: - dragonfly:/data command: [ - '--default_lua_flags', - 'allow-undeclared-keys', - '--cluster_mode', - 'emulated', - '--lock_on_hashtags', + "--default_lua_flags", + "allow-undeclared-keys", + "--cluster_mode", + "emulated", + "--lock_on_hashtags", ] insight: - image: 'redis/redisinsight' + image: "redis/redisinsight" hostname: insight container_name: insight restart: always @@ -31,9 +31,40 @@ services: ulimits: memlock: -1 ports: - - '5540:5540' + - "5540:5540" volumes: - insight:/data + postgres: + image: postgres:alpine + hostname: postgres + container_name: postgres + restart: always + mem_limit: 512m + cpus: 1 + ulimits: + memlock: -1 + ports: + - "5432:5432" + volumes: + - postgres:/var/lib/postgresql/data + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: testdb + # To disable the simulation of network latency, comment out the code below. + latency: + image: alpine:latest + container_name: latency + cap_add: + - NET_ADMIN + command: > + sh -c 'apk add --no-cache iproute2 && + tc qdisc add dev eth0 root netem delay 100ms && + sleep 3600' # Keep the container running + network_mode: "service:postgres" + depends_on: + - postgres volumes: dragonfly: insight: + postgres: diff --git a/example/index.ts b/example/index.ts index c4392d8..a7b0b18 100644 --- a/example/index.ts +++ b/example/index.ts @@ -23,7 +23,7 @@ const logger = pino(); const auto: AutoCacheConfig = { excludedModels: ['Post'], // Models to exclude from auto-caching default behavior - excludedOperations: ['findFirst', 'count', 'findMany'], // Operations to exclude from auto-caching default behavior + excludedOperations: ['findFirst', 'findMany'], // Operations to exclude from auto-caching default behavior models: [ { model: 'User', @@ -40,24 +40,27 @@ const config: CacheConfig = { stale: 30, // Default Stale time after ttl in seconds auto, logger, // Logger for cache events - transformer: { - // Use, main serialize and deserialize function for additional functionality if required - deserialize: data => SuperJSON.parse(data), // default value of deserialize function - serialize: data => SuperJSON.stringify(data), // default value of serialize function - }, - onHit: (key: string) => console.log(`FOUND CACHE: ${key}`), - onMiss: (key: string) => console.log(`NOT FOUND CACHE: ${key}`), + // transformer: { + // // Use, main serialize and deserialize function for additional functionality if required + // deserialize: data => SuperJSON.parse(data), // default value of deserialize function + // serialize: data => SuperJSON.stringify(data), // default value of serialize function + // }, + // onHit: (key: string) => console.log(`FOUND CACHE: ${key}`), + // onMiss: (key: string) => console.log(`NOT FOUND CACHE: ${key}`), type: 'JSON', // the redis instance must support JSON module if you chose to use JSON type cache - cacheKey: { - case: CacheCase.SNAKE_CASE, - delimiter: '*', - prefix: 'awesomeness', - }, + // cacheKey: { + // case: CacheCase.CAMEL_CASE, + // delimiter: '*', + // prefix: 'awesomeness', + // }, }; const prisma = new PrismaClient(); const extendedPrisma = prisma.$extends(PrismaExtensionRedis({config, client})); +const resultSourceString = (isCached: boolean) => + isCached ? 'CACHE' : 'DATABASE'; + const main = async () => { await Promise.all( users.map(user => @@ -89,7 +92,23 @@ const main = async () => { .findUnique({ where: {email: userOne.email}, }) - .then(user => logger.info({type: 'AUTO: DATABASE: Find userOne', user})); + .then(({result: user, isCached}) => + console.info(`AUTO: ${resultSourceString(isCached)}: Find userOne`, { + user, + isCached, + }), + ); + + await extendedPrisma.user + .findUnique({ + where: {email: userOne.email}, + }) + .then(({result: user, isCached}) => + console.info(`AUTO: ${resultSourceString(isCached)}: Find userOne`, { + user, + isCached, + }), + ); await extendedPrisma.user .findUnique({ @@ -100,7 +119,12 @@ const main = async () => { }), }, }) - .then(user => logger.info({type: 'DATABASE: Find userOne', user})); + .then(({result: user, isCached}) => + console.info(`CUSTOM: ${resultSourceString(isCached)}: Find userOne`, { + user, + isCached, + }), + ); await extendedPrisma.user .findUnique({ @@ -111,11 +135,15 @@ const main = async () => { }), }, }) - .then(user => - logger.info({ - type: 'CACHE: Find userOne', + .then(({result: user, isCached}) => + console.info(`CUSTOM: ${resultSourceString(isCached)}: Find userOne`, { // transforming date type value retrieved from cache to confirm that the date is parsed correctly - user: {...user, createdAt: user?.createdAt.toLocaleDateString()}, + // user: { + // ...user, + // createdAt: user?.createdAt.toLocaleDateString(), + // }, + user, + isCached, }), ); @@ -130,7 +158,9 @@ const main = async () => { ], }, }) - .then(deleted => logger.info({type: 'DATABASE: Deleted userOne', deleted})); + .then(({result: deleted}) => + console.info({type: 'UNCACHE: DATABASE: Deleted userOne', deleted}), + ); const userTwo = getRandomValue(users.filter(u => !usedUsers.includes(u.id))); usedUsers.push(userTwo.id); @@ -147,7 +177,9 @@ const main = async () => { ], }, }) - .then(updated => logger.info({type: 'DATABASE: Update userTwo', updated})); + .then(({result: updated}) => + console.info({type: 'UNCACHE: DATABASE: Update userTwo', updated}), + ); await extendedPrisma.user .findUnique({ @@ -159,7 +191,12 @@ const main = async () => { ttl: 60, }, }) - .then(user => logger.info({type: 'DATABASE: Find userTwo', user})); + .then(({result: user, isCached}) => + console.info(`CUSTOM: ${resultSourceString(isCached)}: Find userTwo`, { + user, + isCached, + }), + ); await extendedPrisma.user .findUnique({ @@ -171,11 +208,15 @@ const main = async () => { ttl: 60, }, }) - .then(user => - logger.info({ - type: 'CACHE: Find userTwo', + .then(({result: user, isCached}) => + console.info(`CUSTOM: ${resultSourceString(isCached)}: Find userTwo`, { // transforming date type value retrieved from cache to confirm that the date is parsed correctly - user: {...user, createdAt: user?.createdAt.toLocaleDateString()}, + // user: { + // ...user, + // createdAt: user?.createdAt.toLocaleDateString(), + // }, + user, + isCached, }), ); @@ -184,7 +225,12 @@ const main = async () => { .findUnique({ where: {email: userOne.email}, }) - .then(user => logger.info({type: 'AUTO: CACHE: Find userOne', user})); + .then(({result: user, isCached}) => + console.info(`AUTO: ${resultSourceString(isCached)}: Find userOne`, { + user, + isCached, + }), + ); const args = {where: {email: userOne.email}}; @@ -202,10 +248,16 @@ const main = async () => { }), }, }) - .then(user => - logger.info({type: 'AUTO: CACHE: WITH KEY: Find userOne', user}), + .then(({result: user, isCached}) => + console.info( + `CUSTOM: ${resultSourceString(isCached)}: WITH AUTO KEY: Find userOne`, + { + user, + isCached, + }, + ), ); - }, 200000); + }, 100000); }; main() diff --git a/nodemon.json b/nodemon.json index 6a25906..cf7151b 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,5 +1,5 @@ { "watch": ["src", "example"], "ext": "ts", - "exec": "rm -rf node_modules dist && bun install && bun run build && bun link && cd example && docker compose up -d && rm -rf prisma/migrations node_modules && rm -f prisma/sqlite.db && bun install && bunx prisma generate && bunx prisma migrate dev --name init && bun index.ts" + "exec": "rm -rf node_modules dist && bun install && bun fix && bun run build && bun link && cd example && docker compose up -d && rm -rf prisma/migrations node_modules && rm -f prisma/sqlite.db && bun install && bunx prisma generate && bunx prisma migrate dev --name init && bun index.ts" } diff --git a/package.json b/package.json index 6a36d6c..edcc8f5 100644 --- a/package.json +++ b/package.json @@ -44,17 +44,18 @@ "biome": "biome", "dev": "tsup --watch", "format": "biome format", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "bun test", "lint": "biome lint", "clean": "biome clean", "compile": "tsc", "fix": "biome format --fix", "prepare": "bun run compile", - "pretest": "bun run compile", + "pretest": "bunx prisma migrate dev --schema ./test/prisma/schema.prisma", "posttest": "bun run lint" }, "devDependencies": { "@biomejs/biome": "^1.9.4", + "@types/bun": "^1.1.13", "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/node": "^20.16.1", diff --git a/src/cacheKey.ts b/src/cacheKey.ts index 9b166fa..ed88085 100644 --- a/src/cacheKey.ts +++ b/src/cacheKey.ts @@ -28,7 +28,7 @@ export const caseMap = { export const getKeyGen = ( delimiter = ':', - cacheCase: CacheCase = CacheCase.CAMEL_CASE, + cacheCase: CacheCase = CacheCase.SNAKE_CASE, prefix = 'prisma', ) => ({params, model, operation: op}: CacheKeyParams) => diff --git a/src/cacheUncache.ts b/src/cacheUncache.ts index ff1956a..8900f30 100644 --- a/src/cacheUncache.ts +++ b/src/cacheUncache.ts @@ -2,17 +2,17 @@ import micromatch from 'micromatch'; import {coalesceAsync} from 'promise-coalesce'; import type {Operation} from '@prisma/client/runtime/library'; -import type Redis from 'iovalkey'; import { AUTO_OPERATIONS, type ActionCheckParams, type ActionParams, CACHE_OPERATIONS, + type CacheContext, type CacheOptions, - type CacheType, type DeletePatterns, type GetDataParams, + type RedisCacheCommands, UNCACHE_OPERATIONS, type UncacheOptions, type autoOperations, @@ -42,39 +42,29 @@ export const unlinkPatterns = ({patterns, redis}: DeletePatterns) => }), ); -export const setCache = async ( - type: CacheType, - key: string, - value: string, - ttl: number | undefined, - redis: Redis, -) => { - switch (type) { - case 'JSON': { - if (ttl && ttl !== Number.POSITIVE_INFINITY) - return redis - .multi() - .call('JSON.SET', key, '$', value) - .call('EXPIRE', key, ttl) - .exec(); - return redis.multi().call('JSON.SET', key, '$', value).exec(); - } - - case 'STRING': { - if (ttl && ttl !== Number.POSITIVE_INFINITY) - return redis - .multi() - .call('SET', key, value) - .call('EXPIRE', key, ttl) - .exec(); - return await redis.multi().call('SET', key, value).exec(); - } - - default: - throw new Error( - 'Incorrect CacheType provided! Supported values: JSON | STRING', - ); - } +const commands: RedisCacheCommands = { + JSON: { + get: (redis, key) => redis.multi().call('JSON.GET', key).exec(), + + set: (redis, key, value, ttl) => { + const multi = redis.multi().call('JSON.SET', key, '$', value); + if (ttl && ttl !== Number.POSITIVE_INFINITY) { + multi.call('EXPIRE', key, ttl); + } + return multi.exec(); + }, + }, + STRING: { + get: (redis, key) => redis.multi().call('GET', key).exec(), + + set: (redis, key, value, ttl) => { + const multi = redis.multi().call('SET', key, value); + if (ttl && ttl !== Number.POSITIVE_INFINITY) { + multi.call('EXPIRE', key, ttl); + } + return multi.exec(); + }, + }, }; export const getCache = async ({ @@ -88,87 +78,75 @@ export const getCache = async ({ }: GetDataParams) => { const {onError, onHit, onMiss, transformer, type} = config; - try { - let cache: [error: Error | null, result: unknown][] | null = null; - - switch (type) { - case 'JSON': { - cache = await redis.multi().call('JSON.GET', key).exec(); - break; - } - - case 'STRING': { - cache = await redis.multi().call('GET', key).exec(); - break; - } - - default: - throw new Error( - 'Incorrect CacheType provided! Supported values: JSON | STRING', - ); - } - - const [[error, cached]] = cache ?? []; + if (!commands[type]) + throw new Error( + 'Incorrect CacheType provided! Supported values: JSON | STRING', + ); - if (onError && error) onError(error); + const command = commands[type]; - const timestamp = Date.now(); + const [[error, cached]] = (await command.get(redis, key)) ?? []; - const args = { - ...xArgs, - }; + if (onError && error) onError(error); - args.cache = undefined; + const timestamp = Date.now() / 1000; - if (cached) { - if (onHit) onHit(key); - const cacheContext = (transformer?.deserialize || JSON.parse)( - cached as string, - ); + const args = { + ...xArgs, + }; - const {result, ttl: cacheTtl, stale: cacheStale} = cacheContext; + args.cache = undefined; - if (timestamp < cacheTtl) return result; + if (cached) { + if (onHit) onHit(key); + const cacheContext: CacheContext = (transformer?.deserialize || JSON.parse)( + cached as string, + ); - if (timestamp <= cacheStale) { - query(args).then(result => { - const cacheContext = { - result, - isCached: true, - key, - ttl: ttl * 1000 + timestamp, - stale: (ttl + stale) * 1000 + timestamp, - }; - - const value = (transformer?.serialize || JSON.stringify)( - cacheContext, - ); - - setCache(type, key, value, ttl + stale, redis); - }); - return result; - } - } else if (onMiss) onMiss(key); + const { + isCached, + result, + stale: cacheStale, + timestamp: cacheTime, + ttl: cacheTtl, + } = cacheContext; + + if (timestamp < cacheTime + cacheTtl) return {result, isCached}; + + if (timestamp <= cacheTime + cacheStale) + query(args).then(result => { + const cacheContext = { + isCached: true, + key, + result, + stale, + timestamp, + ttl, + }; + command.set( + redis, + key, + (transformer?.serialize || JSON.stringify)(cacheContext), + ttl + stale, + ); + }); - const result = await query(args); + return {result, isCached}; + } - const cacheContext = { - result, - isCached: true, - key, - ttl: ttl * 1000 + timestamp, - stale: (ttl + stale) * 1000 + timestamp, - }; + if (!cached && onMiss) onMiss(key); - const value = (transformer?.serialize || JSON.stringify)(cacheContext); + const result = await query(args); - setCache(type, key, value, ttl + stale, redis); + const cacheContext = {isCached: true, key, result, stale, timestamp, ttl}; + command.set( + redis, + key, + (transformer?.serialize || JSON.stringify)(cacheContext), + ttl + stale, + ); - return result; - } catch (error) { - if (onError) onError(error); - else throw error; - } + return {result, isCached: false}; }; export const promiseCoalesceGetCache = ({key, ...rest}: GetDataParams) => @@ -220,8 +198,8 @@ export const customCacheAction = async ({ stale: customStale, } = args.cache as unknown as CacheOptions; - const stale = customStale ?? config.stale ?? 0; - const ttl = customTtl ?? config.ttl ?? Number.POSITIVE_INFINITY; + const ttl = customTtl ?? config.ttl; + const stale = customStale ?? config.stale; return await promiseCoalesceGetCache({ ttl, @@ -239,32 +217,24 @@ export const customUncacheAction = async ({ options: {args, query}, config, }: ActionParams) => { - const {onError} = config; + const {uncacheKeys, hasPattern} = args.uncache as unknown as UncacheOptions; - try { - const {uncacheKeys, hasPattern} = args.uncache as unknown as UncacheOptions; + if (hasPattern) { + const patternKeys = micromatch(uncacheKeys, ['*\\**', '*\\?*']); + const plainKeys = micromatch(uncacheKeys, ['*', '!*\\**', '!*\\?*']); - if (hasPattern) { - const patternKeys = micromatch(uncacheKeys, ['*\\**', '*\\?*']); - const plainKeys = micromatch(uncacheKeys, ['*', '!*\\**', '!*\\?*']); + const unlinkPromises = [ + ...unlinkPatterns({ + redis, + patterns: patternKeys, + }), + ...(plainKeys.length ? [redis.unlink(plainKeys)] : []), + ]; - const unlinkPromises = [ - ...unlinkPatterns({ - redis, - patterns: patternKeys, - }), - ...(plainKeys.length ? [redis.unlink(plainKeys)] : []), - ]; - - await Promise.all(unlinkPromises); - } else { - await redis.unlink(uncacheKeys); - } - } catch (error) { - if (onError) onError(error); - } + await Promise.all(unlinkPromises); + } else await redis.unlink(uncacheKeys); - return query({...args, uncache: undefined}); + return {result: await query({...args, uncache: undefined})}; }; export const isAutoCacheEnabled = ({ @@ -275,19 +245,20 @@ export const isAutoCacheEnabled = ({ if (xArgs.cache !== undefined && typeof xArgs.cache === 'boolean') return xArgs.cache; - if (auto) { - if (typeof auto === 'object') - return ( - filterOperations(...AUTO_OPERATIONS)(auto.excludedOperations).includes( - operation as autoOperations, - ) && - !auto.excludedModels?.includes(model) && - !auto.models - ?.find(m => m.model === model) - ?.excludedOperations?.includes(operation as autoOperations) - ); - return true; - } + + if (typeof auto === 'object') + return ( + filterOperations(...AUTO_OPERATIONS)(auto.excludedOperations).includes( + operation as autoOperations, + ) && + !auto.excludedModels?.includes(model) && + !auto.models + ?.find(m => m.model === model) + ?.excludedOperations?.includes(operation as autoOperations) + ); + + if (auto) return AUTO_OPERATIONS.includes(operation as autoOperations); + return false; }; diff --git a/src/index.ts b/src/index.ts index aad874c..1b2c29f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export type {RedisOptions} from 'iovalkey'; export {PrismaExtensionRedis} from './prismaExtensionRedis'; export type { AutoCacheConfig, diff --git a/src/prismaExtensionRedis.ts b/src/prismaExtensionRedis.ts index 322ec1a..9ac6829 100644 --- a/src/prismaExtensionRedis.ts +++ b/src/prismaExtensionRedis.ts @@ -16,13 +16,12 @@ import type {ExtendedModel, PrismaExtensionRedisOptions} from './types'; export const PrismaExtensionRedis = (options: PrismaExtensionRedisOptions) => { const { config, - config: { - auto, - cacheKey: {delimiter, case: cacheCase, prefix}, - }, + config: {auto, cacheKey}, client: redisOptions, } = options; + const {delimiter, case: cacheCase, prefix} = cacheKey ?? {}; + const redis = new Redis(redisOptions); const getKey = getKeyGen(delimiter, cacheCase, prefix); @@ -69,7 +68,9 @@ export const PrismaExtensionRedis = (options: PrismaExtensionRedisOptions) => { config, }); - return query({...args, cache: undefined}); + return { + result: await query({...args, cache: undefined}), + }; }, }, }, diff --git a/src/types.ts b/src/types.ts index 6c7814a..3ff517e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -167,35 +167,44 @@ type PrismaUncacheArgs = { uncache?: UncacheOptions; }; +type CacheResultPromise = Promise<{ + result: Prisma.Result; + isCached: boolean; +}>; + +type UnCacheResultPromise = Promise<{ + result: Prisma.Result; +}>; + type AutoRequiredArgsFunction = ( this: T, args: Prisma.Exact & PrismaAutoArgs>, -) => Promise>; +) => CacheResultPromise; type AutoOptionalArgsFunction = ( this: T, args?: Prisma.Exact & PrismaAutoArgs>, -) => Promise>; +) => CacheResultPromise; type CacheRequiredArgsFunction = ( this: T, args: Prisma.Exact & PrismaCacheArgs>, -) => Promise>; +) => CacheResultPromise; type CacheOptionalArgsFunction = ( this: T, args?: Prisma.Exact & PrismaCacheArgs>, -) => Promise>; +) => CacheResultPromise; type UncacheRequiredArgsFunction = ( this: T, args: Prisma.Exact & PrismaUncacheArgs>, -) => Promise>; +) => UnCacheResultPromise; type UncacheOptionalArgsFunction = ( this: T, args?: Prisma.Exact & PrismaUncacheArgs>, -) => Promise>; +) => UnCacheResultPromise; type OperationsConfig< RequiredArg extends Operation[], @@ -245,17 +254,21 @@ export type CacheType = 'JSON' | 'STRING'; export type CacheKey = { /** - * Cache key delimiter (default value: ':') + * Cache key delimiter + * Default value: ':' */ - delimiter: string; + delimiter?: string; /** - * Use CacheCase to set how the generated INBUILT type keys are formatted (default value: CacheCase.CAMEL_CASE) + * Use CacheCase to set how the generated INBUILT type keys are formatted + * Formatting strips non alpha-numeric characters + * Default value: CacheCase.SNAKE_CASE */ - case: CacheCase; + case?: CacheCase; /** - * AutoCache key prefix (default value: 'prisma') + * AutoCache key prefix + * Default value: 'prisma' */ prefix?: string; }; @@ -273,22 +286,27 @@ interface Logger { export type CacheConfig = { auto: AutoCacheConfig; + /** * Redis Cache Type (Redis instance must support JSON module to use JSON) */ type: CacheType; + /** * Inbuilt cache key generation config */ - cacheKey: CacheKey; + cacheKey?: CacheKey; + /** * Default time-to-live (ttl) value */ ttl: number; + /** * Default stale time after ttl */ stale: number; + /** * Custom transfomrer for serializing and deserializing data */ @@ -429,6 +447,32 @@ export type GetDataParams = { query: (args: JsArgs) => Promise; }; +export type CacheContext = { + isCached: boolean; + // biome-ignore lint/suspicious/noExplicitAny: + result: any; + stale: number; + timestamp: number; + ttl: number; +}; + +export type RedisCacheResultOrError = + | [error: Error | null, result: unknown][] + | null; + +export type RedisCacheCommands = Record< + string, + { + get: (redis: Redis, key: string) => Promise; + set: ( + redis: Redis, + key: string, + value: string, + ttl: number, + ) => Promise; + } +>; + export type CacheKeyParams = { /** * Key params to generate key diff --git a/test/client.ts b/test/client.ts new file mode 100644 index 0000000..30fda30 --- /dev/null +++ b/test/client.ts @@ -0,0 +1,90 @@ +import {PrismaClient} from '@prisma/client'; +import { + type AutoCacheConfig, + type CacheConfig, + PrismaExtensionRedis, + type RedisOptions, +} from '../src'; + +const client = process.env.REDIS_SERVICE_URI as RedisOptions; + +const auto: AutoCacheConfig = { + excludedModels: ['Post'], + excludedOperations: ['findFirst'], + models: [ + { + model: 'User', + excludedOperations: [], + ttl: 120, + stale: 30, + }, + ], + ttl: 30, +}; + +const config: CacheConfig = { + ttl: 60, + stale: 30, + auto, + type: 'JSON', +}; + +export const prisma = new PrismaClient(); + +export const extendedPrismaWithJsonAndCustomAutoCache = prisma.$extends( + PrismaExtensionRedis({config, client}), +); + +export const extendedPrismaWithJsonAndAutoCacheTrue = prisma.$extends( + PrismaExtensionRedis({ + config: { + ...config, + auto: true, + }, + client, + }), +); + +export const extendedPrismaWithStringAndCustomAutoCache = prisma.$extends( + PrismaExtensionRedis({ + config: { + ...config, + type: 'STRING', + }, + client, + }), +); + +export const extendedPrismaWithStringAndAutoCacheTrue = prisma.$extends( + PrismaExtensionRedis({ + config: { + ...config, + type: 'STRING', + auto: true, + }, + client, + }), +); + +export const extendedPrismaWithExtendedStale = prisma.$extends( + PrismaExtensionRedis({ + config: { + auto: true, + stale: 300, + ttl: 1, + type: 'JSON', + }, + client, + }), +); + +export const extendedPrismaWithInvalidCacheType = prisma.$extends( + PrismaExtensionRedis({ + config: { + ...config, + // @ts-ignore: Intnetionally using invalid type for testing + type: 'INVALID', + }, + client, + }), +); diff --git a/test/data.ts b/test/data.ts new file mode 100644 index 0000000..1bfd7dd --- /dev/null +++ b/test/data.ts @@ -0,0 +1,102 @@ +export const users = [ + { + id: 1, + name: 'sh4d0wbyt3', + email: 'shadowbyte123@gmail.com', + }, + { + id: 2, + name: 'c0d3n1nj4', + email: 'codeninja456@yahoo.com', + }, + { + id: 3, + name: 'cryp70k1ng', + email: 'cryptoking789@hotmail.com', + }, + { + id: 4, + name: 'd4rkweb5urf3r', + email: 'darkwebsurfer321@outlook.com', + }, + { + id: 5, + name: 'ph4nt0mc0d3r', + email: 'phantomcoder654@gmail.com', + }, + { + id: 6, + name: 'b1naryb4nd1t', + email: 'binarybandit987@yahoo.com', + }, + { + id: 7, + name: 'h4ck3rm4n', + email: 'hackerman123@hotmail.com', + }, + { + id: 8, + name: 'st34lthh4ck3r', + email: 'stealthhacker456@outlook.com', + }, + { + id: 9, + name: '3xpl01tm4st3r', + email: 'exploitmaster789@gmail.com', + }, + { + id: 10, + name: 'n3tgh0st', + email: 'netghost321@yahoo.com', + }, + { + id: 11, + name: 'scr1ptk1dd13', + email: 'scriptkiddie654@hotmail.com', + }, + { + id: 12, + name: 'cyb3rph4nt0m', + email: 'cyberphantom987@outlook.com', + }, + { + id: 13, + name: 'd4t4d3m0n', + email: 'datademon123@gmail.com', + }, + { + id: 14, + name: 'v1rusv1p3r', + email: 'virusviper456@yahoo.com', + }, + { + id: 15, + name: 'f1rew4llfr34k', + email: 'firewallfreak789@hotmail.com', + }, + { + id: 16, + name: 'p4ck3tsn1ff3r', + email: 'packetsniffer321@outlook.com', + }, + { + id: 17, + name: 'r00t4cc3ss', + email: 'rootaccess654@gmail.com', + }, + { + id: 18, + name: 'ph1sh1ngpr0', + email: 'phishingpro987@yahoo.com', + }, + { + id: 19, + name: 'm4lw4r3m4v3r1ck', + email: 'malwaremaverick123@hotmail.com', + }, + { + id: 20, + name: 'sqlsl34th', + email: 'sqlsleuth456@outlook.com', + }, +]; diff --git a/test/functions.ts b/test/functions.ts new file mode 100644 index 0000000..6fe0596 --- /dev/null +++ b/test/functions.ts @@ -0,0 +1,126 @@ +import type {Prisma} from '@prisma/client'; + +interface User { + id: number; + name: string; + email: string; +} + +// biome-ignore lint/suspicious/noExplicitAny: +type PrismaClient = any; + +export const createUser = async (extendedPrisma: PrismaClient, user: User) => + await extendedPrisma.user.create({ + data: user, + uncache: { + uncacheKeys: ['*'], + hasPattern: true, + }, + select: { + id: true, + name: true, + email: true, + }, + }); + +export const createManyUser = async ( + extendedPrisma: PrismaClient, + users: User[], +) => + await extendedPrisma.user.createManyAndReturn({ + data: users, + select: { + id: true, + name: true, + email: true, + }, + }); + +export const updateUserDetails = async ( + extendedPrisma: PrismaClient, + user: User, + uncache?: {uncacheKeys: string[]; hasPattern?: boolean}, +) => + await extendedPrisma.user.update({ + where: {id: user.id}, + data: user, + select: { + id: true, + name: true, + email: true, + }, + uncache, + }); + +export const autoFindUserByWhereUniqueInput = async ( + extendedPrisma: PrismaClient, + where: Prisma.UserWhereUniqueInput, +) => + await extendedPrisma.user.findUnique({ + where, + select: { + id: true, + name: true, + email: true, + }, + }); + +export const customFindUserByWhereUniqueInput = async ( + extendedPrisma: PrismaClient, + where: Prisma.UserWhereUniqueInput, + key: string, + infinite = false, +) => + await extendedPrisma.user.findUnique({ + where, + cache: { + key, + ...(infinite ? {ttl: Number.POSITIVE_INFINITY} : {}), + }, + select: { + id: true, + name: true, + email: true, + }, + }); + +export const deleteUserById = async ( + extendedPrisma: PrismaClient, + id: number, + uncacheKeys: string[], + hasPattern = false, +) => + await extendedPrisma.user.delete({ + where: { + id, + }, + uncache: { + uncacheKeys, + hasPattern, + }, + }); + +export const deleteAllUsers = async (extendedPrisma: PrismaClient) => + await extendedPrisma.user.deleteMany({ + uncache: { + uncacheKeys: [extendedPrisma.getKeyPattern({params: [{prisma: '*'}]})], + hasPattern: true, + }, + }); + +export const getCountOfUsersWithoutCaching = async ( + extendedPrisma: PrismaClient, +) => + await extendedPrisma.user.count({ + cache: false, + }); + +export const deleteAllUsersAndGetCountOfUsersWithoutCaching = ( + extendedPrisma: PrismaClient, +) => + deleteAllUsers(extendedPrisma).then(() => + getCountOfUsersWithoutCaching(extendedPrisma), + ); + +export const delay = (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); diff --git a/test/prisma/migrations/20241125080016_init/migration.sql b/test/prisma/migrations/20241125080016_init/migration.sql new file mode 100644 index 0000000..8622c26 --- /dev/null +++ b/test/prisma/migrations/20241125080016_init/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/test/prisma/migrations/migration_lock.toml b/test/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/test/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/test/prisma/schema.prisma b/test/prisma/schema.prisma new file mode 100644 index 0000000..dccccb7 --- /dev/null +++ b/test/prisma/schema.prisma @@ -0,0 +1,21 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("POSTGRES_SERVICE_URI") +} + +model User { + id Int @id + name String + email String @unique + createdAt DateTime @default(now()) +} diff --git a/test/unit/invalid-cache-type.test.ts b/test/unit/invalid-cache-type.test.ts new file mode 100644 index 0000000..8cd227b --- /dev/null +++ b/test/unit/invalid-cache-type.test.ts @@ -0,0 +1,76 @@ +import {expect, test} from 'bun:test'; +import { + createUser, + autoFindUserByWhereUniqueInput, + customFindUserByWhereUniqueInput, + deleteAllUsersAndGetCountOfUsersWithoutCaching, +} from '../functions'; + +import {users} from '../data'; +import {extendedPrismaWithInvalidCacheType} from '../client'; + +test('User Creation: should create a new user', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect( + createUser(extendedPrismaWithInvalidCacheType, userOne), + ).resolves.toEqual({ + result: userOne, + }); +}); + +test('User Retrieval: should fail when finding a user by email from the database', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrismaWithInvalidCacheType, { + email: userOne.email, + }), + ).rejects.toThrow( + 'Incorrect CacheType provided! Supported values: JSON | STRING', + ); +}); + +test('User Retrieval: should fail when finding a user by email from staled cache', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrismaWithInvalidCacheType, { + email: userOne.email, + }), + ).rejects.toThrow( + 'Incorrect CacheType provided! Supported values: JSON | STRING', + ); +}); + +test('Custom User Retrieval: should fail when finding a user by email from the database', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrismaWithInvalidCacheType, + {email: userThirteen.email}, + extendedPrismaWithInvalidCacheType.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + true, + ), + ).rejects.toThrow( + 'Incorrect CacheType provided! Supported values: JSON | STRING', + ); +}); + +test('Database Cleanup: should delete all users and clear cache', async () => { + const {result: dbUserCount} = + await deleteAllUsersAndGetCountOfUsersWithoutCaching( + extendedPrismaWithInvalidCacheType, + ); + const cacheKeyCount = await extendedPrismaWithInvalidCacheType.redis.dbsize(); + + expect(dbUserCount).toEqual(0); + expect(cacheKeyCount).toEqual(0); +}); diff --git a/test/unit/json-with-auto-cache-true.test.ts b/test/unit/json-with-auto-cache-true.test.ts new file mode 100644 index 0000000..8a47b7d --- /dev/null +++ b/test/unit/json-with-auto-cache-true.test.ts @@ -0,0 +1,212 @@ +import {expect, test} from 'bun:test'; +import { + createUser, + createManyUser, + updateUserDetails, + autoFindUserByWhereUniqueInput, + customFindUserByWhereUniqueInput, + deleteAllUsersAndGetCountOfUsersWithoutCaching, + deleteUserById, +} from '../functions'; + +import {users} from '../data'; +import {extendedPrismaWithJsonAndAutoCacheTrue as extendedPrisma} from '../client'; + +test('User Creation: should create a new user', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect(createUser(extendedPrisma, userOne)).resolves.toEqual({ + result: userOne, + }); +}); + +test('User Creation: should create multiple new users', async () => { + const newUsers = users.filter(user => ![1, 2, 3].includes(user.id)); + expect(createManyUser(extendedPrisma, newUsers)).resolves.toEqual({ + result: newUsers, + }); +}); + +test("User Update: should update a user's details", async () => { + const userOne = users.find(user => user.id === 1); + const userTwo = users.find(user => user.id === 2); + if (!userOne || !userTwo) throw new Error('Invalid user information!'); + + const updatedUser = {...userTwo, id: userOne.id}; + expect(updateUserDetails(extendedPrisma, updatedUser)).resolves.toEqual({ + result: updatedUser, + }); +}); + +test('User Retrieval: should find a user by email from the database', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: false, + }); +}); + +test('User Retrieval: should find a user by email from cache', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: true, + }); +}); + +test('Custom User Retrieval: should find a user by email from the database', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + true, + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: false, + }); +}); + +test('Custom User Retrieval: should find a user by email from cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: true, + }); +}); + +test('User Retrieval: should find a user with auto cache and then through custom cache', async () => { + const userFour = users.find(user => user.id === 4); + if (!userFour) throw new Error('Invalid user information!'); + + const args = { + where: {email: userFour.email}, + select: {id: true, name: true, email: true}, + }; + + const autoResult = await autoFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + ); + expect(autoResult).toEqual({result: userFour, isCached: false}); + + const key = extendedPrisma.getAutoKey({ + args, + model: 'user', + operation: 'findUnique', + }); + const customResult = await customFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + key, + ); + expect(customResult).toEqual({result: userFour, isCached: true}); +}); + +test('Cache Management: should update user and invalidate cache', async () => { + const userFour = users.find(user => user.id === 4); + const userThree = users.find(user => user.id === 3); + if (!userFour || !userThree) throw new Error('Invalid user information!'); + + const updatedUser = {...userThree, id: userFour.id}; + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {id: userFour.id.toString()}], + }); + + const userBeforeUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + const keyExistsBeforeUpdate = await extendedPrisma.redis.exists(key); + + expect(keyExistsBeforeUpdate).toEqual(1); + expect(userBeforeUpdate).toEqual({result: userFour, isCached: false}); + + await updateUserDetails(extendedPrisma, updatedUser, {uncacheKeys: [key]}); + + const keyExistsAfterUpdate = await extendedPrisma.redis.exists(key); + const userAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + + expect(keyExistsAfterUpdate).toEqual(0); + expect(userAfterUpdate).toEqual({result: updatedUser, isCached: false}); + + const userCachedAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + expect(userCachedAfterUpdate).toEqual({ + result: updatedUser, + isCached: true, + }); +}); + +test('Cache Management: should delete user from database and invalidate cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }); + + const keyExistsBeforeDelete = await extendedPrisma.redis.exists(key); + const {result: userBeforeDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsBeforeDelete).toEqual(1); + expect(userBeforeDelete).toEqual(userThirteen); + + await deleteUserById(extendedPrisma, userThirteen.id, [key]); + + const keyExistsAfterDelete = await extendedPrisma.redis.exists(key); + const {result: userAfterDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsAfterDelete).toEqual(0); + expect(userAfterDelete).toEqual(null); +}); + +test('Database Cleanup: should delete all users and clear cache', async () => { + const {result: dbUserCount} = + await deleteAllUsersAndGetCountOfUsersWithoutCaching(extendedPrisma); + const cacheKeyCount = await extendedPrisma.redis.dbsize(); + + expect(dbUserCount).toEqual(0); + expect(cacheKeyCount).toEqual(0); +}); diff --git a/test/unit/json-with-custom-auto-cache.test.ts b/test/unit/json-with-custom-auto-cache.test.ts new file mode 100644 index 0000000..3abcf0b --- /dev/null +++ b/test/unit/json-with-custom-auto-cache.test.ts @@ -0,0 +1,212 @@ +import {expect, test} from 'bun:test'; +import { + createUser, + createManyUser, + updateUserDetails, + autoFindUserByWhereUniqueInput, + customFindUserByWhereUniqueInput, + deleteAllUsersAndGetCountOfUsersWithoutCaching, + deleteUserById, +} from '../functions'; + +import {users} from '../data'; +import {extendedPrismaWithJsonAndCustomAutoCache as extendedPrisma} from '../client'; + +test('User Creation: should create a new user', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect(createUser(extendedPrisma, userOne)).resolves.toEqual({ + result: userOne, + }); +}); + +test('User Creation: should create multiple new users', async () => { + const newUsers = users.filter(user => ![1, 2, 3].includes(user.id)); + expect(createManyUser(extendedPrisma, newUsers)).resolves.toEqual({ + result: newUsers, + }); +}); + +test("User Update: should update a user's details", async () => { + const userOne = users.find(user => user.id === 1); + const userTwo = users.find(user => user.id === 2); + if (!userOne || !userTwo) throw new Error('Invalid user information!'); + + const updatedUser = {...userTwo, id: userOne.id}; + expect(updateUserDetails(extendedPrisma, updatedUser)).resolves.toEqual({ + result: updatedUser, + }); +}); + +test('User Retrieval: should find a user by email from the database', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: false, + }); +}); + +test('User Retrieval: should find a user by email from cache', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: true, + }); +}); + +test('Custom User Retrieval: should find a user by email from the database', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + true, + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: false, + }); +}); + +test('Custom User Retrieval: should find a user by email from cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: true, + }); +}); + +test('User Retrieval: should find a user with auto cache and then through custom cache', async () => { + const userFour = users.find(user => user.id === 4); + if (!userFour) throw new Error('Invalid user information!'); + + const args = { + where: {email: userFour.email}, + select: {id: true, name: true, email: true}, + }; + + const autoResult = await autoFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + ); + expect(autoResult).toEqual({result: userFour, isCached: false}); + + const key = extendedPrisma.getAutoKey({ + args, + model: 'user', + operation: 'findUnique', + }); + const customResult = await customFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + key, + ); + expect(customResult).toEqual({result: userFour, isCached: true}); +}); + +test('Cache Management: should update user and invalidate cache', async () => { + const userFour = users.find(user => user.id === 4); + const userThree = users.find(user => user.id === 3); + if (!userFour || !userThree) throw new Error('Invalid user information!'); + + const updatedUser = {...userThree, id: userFour.id}; + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {id: userFour.id.toString()}], + }); + + const userBeforeUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + const keyExistsBeforeUpdate = await extendedPrisma.redis.exists(key); + + expect(keyExistsBeforeUpdate).toEqual(1); + expect(userBeforeUpdate).toEqual({result: userFour, isCached: false}); + + await updateUserDetails(extendedPrisma, updatedUser, {uncacheKeys: [key]}); + + const keyExistsAfterUpdate = await extendedPrisma.redis.exists(key); + const userAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + + expect(keyExistsAfterUpdate).toEqual(0); + expect(userAfterUpdate).toEqual({result: updatedUser, isCached: false}); + + const userCachedAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + expect(userCachedAfterUpdate).toEqual({ + result: updatedUser, + isCached: true, + }); +}); + +test('Cache Management: should delete user from database and invalidate cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }); + + const keyExistsBeforeDelete = await extendedPrisma.redis.exists(key); + const {result: userBeforeDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsBeforeDelete).toEqual(1); + expect(userBeforeDelete).toEqual(userThirteen); + + await deleteUserById(extendedPrisma, userThirteen.id, [key]); + + const keyExistsAfterDelete = await extendedPrisma.redis.exists(key); + const {result: userAfterDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsAfterDelete).toEqual(0); + expect(userAfterDelete).toEqual(null); +}); + +test('Database Cleanup: should delete all users and clear cache', async () => { + const {result: dbUserCount} = + await deleteAllUsersAndGetCountOfUsersWithoutCaching(extendedPrisma); + const cacheKeyCount = await extendedPrisma.redis.dbsize(); + + expect(dbUserCount).toEqual(0); + expect(cacheKeyCount).toEqual(0); +}); diff --git a/test/unit/staled-cache.test.ts b/test/unit/staled-cache.test.ts new file mode 100644 index 0000000..bbdbc1f --- /dev/null +++ b/test/unit/staled-cache.test.ts @@ -0,0 +1,62 @@ +import {expect, test} from 'bun:test'; +import { + createUser, + autoFindUserByWhereUniqueInput, + deleteAllUsersAndGetCountOfUsersWithoutCaching, + delay, +} from '../functions'; + +import {users} from '../data'; +import {extendedPrismaWithExtendedStale} from '../client'; + +test('User Creation: should create a new user', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect(createUser(extendedPrismaWithExtendedStale, userOne)).resolves.toEqual( + { + result: userOne, + }, + ); +}); + +test('User Retrieval: should find a user by email from the database', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrismaWithExtendedStale, { + email: userOne.email, + }), + ).resolves.toEqual({ + result: userOne, + isCached: false, + }); +}); + +test('User Retrieval: should find a user by email from staled cache', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + await delay(1000); + + expect( + autoFindUserByWhereUniqueInput(extendedPrismaWithExtendedStale, { + email: userOne.email, + }), + ).resolves.toEqual({ + result: userOne, + isCached: true, + }); +}); + +test('Database Cleanup: should delete all users and clear cache', async () => { + const {result: dbUserCount} = + await deleteAllUsersAndGetCountOfUsersWithoutCaching( + extendedPrismaWithExtendedStale, + ); + const cacheKeyCount = await extendedPrismaWithExtendedStale.redis.dbsize(); + + expect(dbUserCount).toEqual(0); + expect(cacheKeyCount).toEqual(0); +}); diff --git a/test/unit/string-with-auto-cache-true.test.ts b/test/unit/string-with-auto-cache-true.test.ts new file mode 100644 index 0000000..965df14 --- /dev/null +++ b/test/unit/string-with-auto-cache-true.test.ts @@ -0,0 +1,212 @@ +import {expect, test} from 'bun:test'; +import { + createUser, + createManyUser, + updateUserDetails, + autoFindUserByWhereUniqueInput, + customFindUserByWhereUniqueInput, + deleteAllUsersAndGetCountOfUsersWithoutCaching, + deleteUserById, +} from '../functions'; + +import {users} from '../data'; +import {extendedPrismaWithStringAndAutoCacheTrue as extendedPrisma} from '../client'; + +test('User Creation: should create a new user', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect(createUser(extendedPrisma, userOne)).resolves.toEqual({ + result: userOne, + }); +}); + +test('User Creation: should create multiple new users', async () => { + const newUsers = users.filter(user => ![1, 2, 3].includes(user.id)); + expect(createManyUser(extendedPrisma, newUsers)).resolves.toEqual({ + result: newUsers, + }); +}); + +test("User Update: should update a user's details", async () => { + const userOne = users.find(user => user.id === 1); + const userTwo = users.find(user => user.id === 2); + if (!userOne || !userTwo) throw new Error('Invalid user information!'); + + const updatedUser = {...userTwo, id: userOne.id}; + expect(updateUserDetails(extendedPrisma, updatedUser)).resolves.toEqual({ + result: updatedUser, + }); +}); + +test('User Retrieval: should find a user by email from the database', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: false, + }); +}); + +test('User Retrieval: should find a user by email from cache', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: true, + }); +}); + +test('Custom User Retrieval: should find a user by email from the database', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + true, + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: false, + }); +}); + +test('Custom User Retrieval: should find a user by email from cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: true, + }); +}); + +test('User Retrieval: should find a user with auto cache and then through custom cache', async () => { + const userFour = users.find(user => user.id === 4); + if (!userFour) throw new Error('Invalid user information!'); + + const args = { + where: {email: userFour.email}, + select: {id: true, name: true, email: true}, + }; + + const autoResult = await autoFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + ); + expect(autoResult).toEqual({result: userFour, isCached: false}); + + const key = extendedPrisma.getAutoKey({ + args, + model: 'user', + operation: 'findUnique', + }); + const customResult = await customFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + key, + ); + expect(customResult).toEqual({result: userFour, isCached: true}); +}); + +test('Cache Management: should update user and invalidate cache', async () => { + const userFour = users.find(user => user.id === 4); + const userThree = users.find(user => user.id === 3); + if (!userFour || !userThree) throw new Error('Invalid user information!'); + + const updatedUser = {...userThree, id: userFour.id}; + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {id: userFour.id.toString()}], + }); + + const userBeforeUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + const keyExistsBeforeUpdate = await extendedPrisma.redis.exists(key); + + expect(keyExistsBeforeUpdate).toEqual(1); + expect(userBeforeUpdate).toEqual({result: userFour, isCached: false}); + + await updateUserDetails(extendedPrisma, updatedUser, {uncacheKeys: [key]}); + + const keyExistsAfterUpdate = await extendedPrisma.redis.exists(key); + const userAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + + expect(keyExistsAfterUpdate).toEqual(0); + expect(userAfterUpdate).toEqual({result: updatedUser, isCached: false}); + + const userCachedAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + expect(userCachedAfterUpdate).toEqual({ + result: updatedUser, + isCached: true, + }); +}); + +test('Cache Management: should delete user from database and invalidate cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }); + + const keyExistsBeforeDelete = await extendedPrisma.redis.exists(key); + const {result: userBeforeDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsBeforeDelete).toEqual(1); + expect(userBeforeDelete).toEqual(userThirteen); + + await deleteUserById(extendedPrisma, userThirteen.id, [key]); + + const keyExistsAfterDelete = await extendedPrisma.redis.exists(key); + const {result: userAfterDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsAfterDelete).toEqual(0); + expect(userAfterDelete).toEqual(null); +}); + +test('Database Cleanup: should delete all users and clear cache', async () => { + const {result: dbUserCount} = + await deleteAllUsersAndGetCountOfUsersWithoutCaching(extendedPrisma); + const cacheKeyCount = await extendedPrisma.redis.dbsize(); + + expect(dbUserCount).toEqual(0); + expect(cacheKeyCount).toEqual(0); +}); diff --git a/test/unit/string-with-custom-auto-cache.test.ts b/test/unit/string-with-custom-auto-cache.test.ts new file mode 100644 index 0000000..f0f9f6a --- /dev/null +++ b/test/unit/string-with-custom-auto-cache.test.ts @@ -0,0 +1,212 @@ +import {expect, test} from 'bun:test'; +import { + createUser, + createManyUser, + updateUserDetails, + autoFindUserByWhereUniqueInput, + customFindUserByWhereUniqueInput, + deleteAllUsersAndGetCountOfUsersWithoutCaching, + deleteUserById, +} from '../functions'; + +import {users} from '../data'; +import {extendedPrismaWithStringAndCustomAutoCache as extendedPrisma} from '../client'; + +test('User Creation: should create a new user', async () => { + const userOne = users.find(user => user.id === 1); + if (!userOne) throw new Error('Invalid user information!'); + + expect(createUser(extendedPrisma, userOne)).resolves.toEqual({ + result: userOne, + }); +}); + +test('User Creation: should create multiple new users', async () => { + const newUsers = users.filter(user => ![1, 2, 3].includes(user.id)); + expect(createManyUser(extendedPrisma, newUsers)).resolves.toEqual({ + result: newUsers, + }); +}); + +test("User Update: should update a user's details", async () => { + const userOne = users.find(user => user.id === 1); + const userTwo = users.find(user => user.id === 2); + if (!userOne || !userTwo) throw new Error('Invalid user information!'); + + const updatedUser = {...userTwo, id: userOne.id}; + expect(updateUserDetails(extendedPrisma, updatedUser)).resolves.toEqual({ + result: updatedUser, + }); +}); + +test('User Retrieval: should find a user by email from the database', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: false, + }); +}); + +test('User Retrieval: should find a user by email from cache', async () => { + const userTen = users.find(user => user.id === 10); + if (!userTen) throw new Error('Invalid user information!'); + + expect( + autoFindUserByWhereUniqueInput(extendedPrisma, {email: userTen.email}), + ).resolves.toEqual({ + result: userTen, + isCached: true, + }); +}); + +test('Custom User Retrieval: should find a user by email from the database', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + true, + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: false, + }); +}); + +test('Custom User Retrieval: should find a user by email from cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + expect( + customFindUserByWhereUniqueInput( + extendedPrisma, + {email: userThirteen.email}, + extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }), + ), + ).resolves.toEqual({ + result: userThirteen, + isCached: true, + }); +}); + +test('User Retrieval: should find a user with auto cache and then through custom cache', async () => { + const userFour = users.find(user => user.id === 4); + if (!userFour) throw new Error('Invalid user information!'); + + const args = { + where: {email: userFour.email}, + select: {id: true, name: true, email: true}, + }; + + const autoResult = await autoFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + ); + expect(autoResult).toEqual({result: userFour, isCached: false}); + + const key = extendedPrisma.getAutoKey({ + args, + model: 'user', + operation: 'findUnique', + }); + const customResult = await customFindUserByWhereUniqueInput( + extendedPrisma, + args.where, + key, + ); + expect(customResult).toEqual({result: userFour, isCached: true}); +}); + +test('Cache Management: should update user and invalidate cache', async () => { + const userFour = users.find(user => user.id === 4); + const userThree = users.find(user => user.id === 3); + if (!userFour || !userThree) throw new Error('Invalid user information!'); + + const updatedUser = {...userThree, id: userFour.id}; + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {id: userFour.id.toString()}], + }); + + const userBeforeUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + const keyExistsBeforeUpdate = await extendedPrisma.redis.exists(key); + + expect(keyExistsBeforeUpdate).toEqual(1); + expect(userBeforeUpdate).toEqual({result: userFour, isCached: false}); + + await updateUserDetails(extendedPrisma, updatedUser, {uncacheKeys: [key]}); + + const keyExistsAfterUpdate = await extendedPrisma.redis.exists(key); + const userAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + + expect(keyExistsAfterUpdate).toEqual(0); + expect(userAfterUpdate).toEqual({result: updatedUser, isCached: false}); + + const userCachedAfterUpdate = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userFour.id}, + key, + ); + expect(userCachedAfterUpdate).toEqual({ + result: updatedUser, + isCached: true, + }); +}); + +test('Cache Management: should delete user from database and invalidate cache', async () => { + const userThirteen = users.find(user => user.id === 13); + if (!userThirteen) throw new Error('Invalid user information!'); + + const key = extendedPrisma.getKey({ + params: [{prisma: 'User'}, {email: userThirteen.email}], + }); + + const keyExistsBeforeDelete = await extendedPrisma.redis.exists(key); + const {result: userBeforeDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsBeforeDelete).toEqual(1); + expect(userBeforeDelete).toEqual(userThirteen); + + await deleteUserById(extendedPrisma, userThirteen.id, [key]); + + const keyExistsAfterDelete = await extendedPrisma.redis.exists(key); + const {result: userAfterDelete} = await customFindUserByWhereUniqueInput( + extendedPrisma, + {id: userThirteen.id}, + key, + ); + + expect(keyExistsAfterDelete).toEqual(0); + expect(userAfterDelete).toEqual(null); +}); + +test('Database Cleanup: should delete all users and clear cache', async () => { + const {result: dbUserCount} = + await deleteAllUsersAndGetCountOfUsersWithoutCaching(extendedPrisma); + const cacheKeyCount = await extendedPrisma.redis.dbsize(); + + expect(dbUserCount).toEqual(0); + expect(cacheKeyCount).toEqual(0); +}); diff --git a/tsconfig.json b/tsconfig.json index d1f6f1d..5d03711 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "rootDir": ".", "outDir": "dist" }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts"] }