diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..8c2c482 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,212 @@ +name: Release and Publish + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Version bump type (auto = determined by conventional commits)' + required: true + default: 'auto' + type: choice + options: + - auto + - patch + - minor + - major + dry_run: + description: 'Dry run (preview changes without publishing)' + required: false + default: false + type: boolean + +jobs: + release: + name: Release and Publish + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Run tests + run: npm run test + env: + CI: true + + - name: Check for changes + id: check + run: | + CHANGED=$(npx lerna changed --json 2>/dev/null || echo "[]") + if [ "$CHANGED" == "[]" ]; then + echo "changed=false" >> $GITHUB_OUTPUT + echo "No packages have changed since last release" + else + echo "changed=true" >> $GITHUB_OUTPUT + echo "Changed packages:" + echo "$CHANGED" | jq -r '.[].name' + fi + + - name: Dry Run - Preview Changes + if: inputs.dry_run == true + run: | + echo "=== DRY RUN MODE ===" + echo "" + echo "Version type: ${{ inputs.version_type }}" + if [ "${{ inputs.version_type }}" == "auto" ]; then + echo " (auto = determined by conventional commits)" + echo " - fix: commits → patch bump" + echo " - feat: commits → minor bump" + echo " - BREAKING CHANGE → major bump" + fi + echo "" + echo "=== Changed packages ===" + npx lerna changed -l || echo "No packages changed" + echo "" + echo "=== Preview version bumps ===" + VERSION_ARG="" + if [ "${{ inputs.version_type }}" != "auto" ]; then + VERSION_ARG="${{ inputs.version_type }}" + fi + npx lerna version $VERSION_ARG --conventional-commits --no-git-tag-version --no-push --yes 2>/dev/null || true + echo "" + echo "=== Files that would change ===" + git diff --name-only + git checkout -- . + + - name: Version packages + if: inputs.dry_run == false && steps.check.outputs.changed == 'true' + id: version + run: | + # Determine version argument (empty for auto = let conventional commits decide) + VERSION_ARG="" + if [ "${{ inputs.version_type }}" != "auto" ]; then + VERSION_ARG="${{ inputs.version_type }}" + fi + + # Version packages with conventional commits + # When VERSION_ARG is empty (auto), lerna uses conventional commits to determine version: + # fix: → patch, feat: → minor, BREAKING CHANGE → major + npx lerna version $VERSION_ARG \ + --yes \ + --conventional-commits \ + --changelog-preset angular \ + --message "chore(release): publish" + + # Get the new version for release notes + NEW_VERSION=$(node -p "require('./lerna.json').version || 'independent'") + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Release Notes + if: inputs.dry_run == false && steps.check.outputs.changed == 'true' + id: release_notes + run: | + # Get the latest tag + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + # Generate changelog from commits + if [ -n "$LATEST_TAG" ]; then + PREV_TAG=$(git describe --tags --abbrev=0 ${LATEST_TAG}^ 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + COMMITS=$(git log ${PREV_TAG}..${LATEST_TAG} --pretty=format:"- %s (%h)" --no-merges) + else + COMMITS=$(git log ${LATEST_TAG} --pretty=format:"- %s (%h)" --no-merges -20) + fi + else + COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20) + fi + + # Create release notes file + cat > release_notes.md << 'NOTES_EOF' + ## What's Changed + + NOTES_EOF + + # Add package versions + echo "### Published Packages" >> release_notes.md + echo "" >> release_notes.md + npx lerna ls -l --json | jq -r '.[] | "- \(.name)@\(.version)"' >> release_notes.md + echo "" >> release_notes.md + + # Add commits + echo "### Commits" >> release_notes.md + echo "" >> release_notes.md + if [ -n "$COMMITS" ]; then + echo "$COMMITS" >> release_notes.md + else + echo "- Initial release" >> release_notes.md + fi + echo "" >> release_notes.md + + # Add install instructions + echo "### Installation" >> release_notes.md + echo "" >> release_notes.md + echo '```bash' >> release_notes.md + echo 'npm install @hokify/node-ts-cache' >> release_notes.md + echo '```' >> release_notes.md + + cat release_notes.md + + - name: Publish to npm + if: inputs.dry_run == false && steps.check.outputs.changed == 'true' + run: | + npx lerna publish from-git --yes + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + if: inputs.dry_run == false && steps.check.outputs.changed == 'true' + run: | + # Get the latest tag created by lerna + LATEST_TAG=$(git describe --tags --abbrev=0) + + # Create the GitHub release + gh release create "$LATEST_TAG" \ + --title "Release $LATEST_TAG" \ + --notes-file release_notes.md + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + if: inputs.dry_run == false && steps.check.outputs.changed == 'true' + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Successfully released the following packages:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + npx lerna ls -l --json | jq -r '.[] | "- **\(.name)** @ \(.version)"' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + LATEST_TAG=$(git describe --tags --abbrev=0) + echo "GitHub Release: https://github.com/${{ github.repository }}/releases/tag/$LATEST_TAG" >> $GITHUB_STEP_SUMMARY + + - name: No changes to release + if: steps.check.outputs.changed == 'false' + run: | + echo "## No Changes" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No packages have changed since the last release. Nothing to publish." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87f08c0..712a5c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,42 +1,64 @@ -name: Run Test -on: [push, pull_request, workflow_dispatch] +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + workflow_dispatch: jobs: - build: + test: + name: Test on Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest strategy: matrix: - node-version: [18.19.0] + node-version: ['20', '22', '24'] + fail-fast: false + steps: - - name: Git checkout - uses: actions/checkout@v2 + - name: Checkout repository + uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + cache: 'npm' - - name: Cache node modules - uses: actions/cache@v2 - env: - cache-name: cache-node-modules - with: - # npm cache files are stored in `~/.npm` on Linux/macOS - path: ~/.npm - key: ${{ runner.os }}-build-nodetscache-${{ hashFiles('**/package-lock.lock') }} - restore-keys: | - ${{ runner.os }}-build-nodetscache- - - - name: Update npm - run: npm -g install npm@latest - - - name: Install Packages + - name: Install dependencies run: npm ci - name: Build run: npm run build - - name: Test + - name: Run tests run: npm run test env: CI: true + + lint: + name: Lint & Format + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build (required for type-aware linting) + run: npm run build + + - name: Run ESLint + run: npm run lint + + - name: Check Prettier formatting + run: npm run format diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6c89d1c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: node_js -node_js: - - "stable" - - 8 - - 10 - - 12 - -branches: - only: - - master - -before_script: - - npm run build diff --git a/README.md b/README.md index dcbf09c..768f584 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,131 @@ -# @hokify/node-ts-cache -Simple and extensible caching module supporting decorators +# node-ts-cache -## Main package +[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Node.js Version](https://img.shields.io/node/v/@hokify/node-ts-cache.svg)](https://nodejs.org) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.1-blue.svg)](https://www.typescriptlang.org/) -[node-ts-cache](/ts-cache) -This is the main package, it includes a memory and fs storage. +Simple and extensible caching module for TypeScript/Node.js with decorator support. -## Additional Storages +## Features -* [RedisStorage](/storages/redis) -* [RedisIOStorage](/storages/redisio) -* [NodeCacheStorage](/storages/node-cache) -* [LRUStorage](/storages/lru) -* [LRURedisStorage](/storages/lru-redis) +- **Decorator-based caching** - Use `@Cache`, `@SyncCache`, and `@MultiCache` decorators for elegant caching +- **Multiple storage backends** - Memory, File System, Redis, LRU Cache, and more +- **Flexible expiration strategies** - TTL-based expiration with lazy or eager invalidation +- **Multi-tier caching** - Chain multiple cache layers (e.g., local LRU + remote Redis) +- **TypeScript-first** - Full type safety with comprehensive interfaces +- **ESM support** - Modern ES modules with Node.js 18+ + +## Quick Start + +```bash +npm install @hokify/node-ts-cache +``` + +```typescript +import { Cache, ExpirationStrategy, MemoryStorage } from '@hokify/node-ts-cache'; + +const cacheStrategy = new ExpirationStrategy(new MemoryStorage()); + +class UserService { + @Cache(cacheStrategy, { ttl: 300 }) + async getUser(id: string): Promise { + // This result will be cached for 5 minutes + return await fetchUserFromDatabase(id); + } +} +``` + +## Packages + +This is a monorepo containing the following packages: + +### Core Package + +| Package | Version | Description | +| ----------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [@hokify/node-ts-cache](./ts-cache) | ![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache.svg) | Core caching module with decorators, strategies, and built-in storages | + +### Storage Adapters + +| Package | Version | Description | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------ | +| [@hokify/node-ts-cache-redis-storage](./storages/redis) | ![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-redis-storage.svg) | Redis storage using `redis` package (v3.x) | +| [@hokify/node-ts-cache-redisio-storage](./storages/redisio) | ![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-redisio-storage.svg) | Redis storage using `ioredis` with compression support | +| [@hokify/node-ts-cache-node-cache-storage](./storages/node-cache) | ![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-node-cache-storage.svg) | In-memory cache using `node-cache` | +| [@hokify/node-ts-cache-lru-storage](./storages/lru) | ![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-lru-storage.svg) | LRU cache with automatic eviction | +| [@hokify/node-ts-cache-lru-redis-storage](./storages/lru-redis) | ![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-lru-redis-storage.svg) | Two-tier caching (local LRU + remote Redis) | + +## Documentation + +For detailed documentation, see the [main package README](./ts-cache/README.md). + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Application │ +├─────────────────────────────────────────────────────────────────┤ +│ Decorators Layer │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ @Cache │ │ @SyncCache │ │ @MultiCache │ │ +│ │ (async) │ │ (sync) │ │ (multi-tier/batch) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ +├──────────┼────────────────┼────────────────────┼────────────────┤ +│ └────────────────┼────────────────────┘ │ +│ ▼ │ +│ ┌────────────────┐ │ +│ │ Strategy │ │ +│ │ (Expiration) │ │ +│ └───────┬────────┘ │ +├──────────────────────────┼──────────────────────────────────────┤ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Storage Layer │ │ +│ ├──────────┬──────────┬──────────┬──────────┬─────────────┤ │ +│ │ Memory │ FS │ Redis │ LRU │ LRU+Redis │ │ +│ └──────────┴──────────┴──────────┴──────────┴─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Choosing a Storage + +| Storage | Type | Use Case | Features | +| ----------------------- | ----- | ---------------------------- | ----------------------------- | +| **MemoryStorage** | Sync | Development, small datasets | Zero config, bundled | +| **FsJsonStorage** | Async | Persistent local cache | File-based, survives restarts | +| **NodeCacheStorage** | Sync | Production single-instance | TTL support, multi-ops | +| **LRUStorage** | Sync | Memory-constrained apps | Auto-eviction, size limits | +| **RedisStorage** | Async | Distributed systems | Shared cache, legacy redis | +| **RedisIOStorage** | Async | Distributed systems | Compression, modern ioredis | +| **LRUWithRedisStorage** | Async | High-performance distributed | Local + remote tiers | + +## Requirements + +- Node.js >= 18.0.0 +- TypeScript >= 5.0 (for decorator support) + +## Development + +```bash +# Install dependencies +npm install + +# Build all packages +npm run build + +# Run tests +npm test +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Credits + +Originally created by [hokify](https://github.com/hokify), now maintained by [simllll](https://github.com/simllll). diff --git a/gulpfile.js b/gulpfile.js index 2216e16..bd2dc70 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -3,19 +3,19 @@ const del = require('del'); const glob = require('glob'); const childProcess = require('child_process'); -task('cleanModules', function() { +task('cleanModules', function () { return del(['./ts-cache/node_modules', './storages/*/node_modules']); }); -task('cleanTmp', function() { +task('cleanTmp', function () { return del(['./ts-cache/.tmp', './storages/*/.tmp']); }); -task('cleanDist', function() { +task('cleanDist', function () { return del(['./ts-cache/dist', './storages/*/dist']); }); -task('updatePackages', function(cb) { +task('updatePackages', function (cb) { const check = pkgJsonPath => { try { return childProcess.execSync(`npx ncu --packageFile ${pkgJsonPath} -u`).toString(); diff --git a/lerna.json b/lerna.json index 38e0210..b2406c1 100644 --- a/lerna.json +++ b/lerna.json @@ -1,8 +1,5 @@ { - "packages": [ - "storages/*", - "ts-cache" - ], + "packages": ["storages/*", "ts-cache"], "version": "independent", "command": { "publish": { @@ -13,8 +10,5 @@ "forceLocal": true, "conventionalCommits": true, "reject-cycles": true, - "ignoreChanges": [ - "**/test/**", - "**/*.md" - ] + "ignoreChanges": ["**/test/**", "**/*.md"] } diff --git a/package.json b/package.json index db928e8..d48be5f 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,35 @@ { - "name": "root", - "private": true, - "scripts": { - "publish": "lerna publish", - "build": "lerna run build", - "test": "lerna run test", - "release": "lerna publish", - "prepublishOnly": "npm run test", - "check-package-updates": "ncu -u && gulp updatePackages" - }, - "devDependencies": { - "@hokify/eslint-config": "^2.2.1", - "@types/mocha": "10.0.6", - "@types/node": "18.19.0", - "lerna": "^8.0.0", - "mocha": "10.4.0", - "ts-node": "10.9.1", - "typescript": "5.1.6" - }, - "dependencies": { - "del": "^6.0.0", - "glob": "^7.2.0", - "gulp": "^4.0.2", - "prettier": "^2.4.1" - }, - "workspaces": [ - "storages/*", - "ts-cache" - ] + "name": "root", + "private": true, + "workspaces": [ + "storages/*", + "ts-cache" + ], + "scripts": { + "build": "lerna run build", + "check-package-updates": "ncu -u && gulp updatePackages", + "format": "prettier --check \"**/*.{ts,js,json,md}\" --ignore-path .gitignore", + "format:fix": "prettier --write \"**/*.{ts,js,json,md}\" --ignore-path .gitignore", + "lint": "eslint --ext .ts ts-cache/src storages/*/src", + "lint:fix": "eslint --ext .ts ts-cache/src storages/*/src --fix", + "prepublishOnly": "npm run test", + "publish": "lerna publish", + "release": "lerna publish", + "test": "lerna run test" + }, + "dependencies": { + "del": "^6.0.0", + "glob": "^7.2.0", + "gulp": "^4.0.2", + "prettier": "^2.4.1" + }, + "devDependencies": { + "@hokify/eslint-config": "^2.2.1", + "@types/mocha": "10.0.6", + "@types/node": "18.19.0", + "lerna": "^8.0.0", + "mocha": "10.4.0", + "ts-node": "10.9.1", + "typescript": "5.1.6" + } } diff --git a/storages/lru-redis/README.md b/storages/lru-redis/README.md index 5ba7443..e447a4b 100644 --- a/storages/lru-redis/README.md +++ b/storages/lru-redis/README.md @@ -1,6 +1,210 @@ # @hokify/node-ts-cache-lru-redis-storage -LRU local cache and central redis cache, -if not found local, redis is checked and in case there is a result it is saved locally +[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-lru-redis-storage.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache-lru-redis-storage) -all timeout values are in SECONDS +Two-tier cache storage adapter for [@hokify/node-ts-cache](https://www.npmjs.com/package/@hokify/node-ts-cache) combining local LRU cache with remote Redis fallback. + +## Features + +- **Two-tier architecture**: Fast local LRU cache + shared Redis backend +- Local cache for hot data (sub-millisecond access) +- Redis fallback for cache misses +- Automatic population of local cache from Redis hits +- Reduced Redis round-trips +- Ideal for high-traffic, distributed applications + +## How It Works + +``` +┌─────────────────────────────────────────────────────────┐ +│ Application │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────┐ │ +│ │ LRUWithRedisStorage │ │ +│ └───────────┬───────────┘ │ +│ │ │ +│ ┌─────────────┴─────────────┐ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Local LRU │ miss │ Redis │ │ +│ │ (in-memory) │ ──────> │ (remote) │ │ +│ │ ~0.01ms │ <────── │ ~1-5ms │ │ +│ └─────────────────┘ hit └─────────────────┘ │ +│ (populate) │ +└─────────────────────────────────────────────────────────┘ +``` + +1. **Get**: Check local LRU first. On miss, check Redis. If found in Redis, populate local LRU. +2. **Set**: Write to both local LRU and Redis. +3. **Clear**: Clear both caches. + +## Installation + +```bash +npm install @hokify/node-ts-cache @hokify/node-ts-cache-lru-redis-storage ioredis +``` + +## Usage + +### Basic Usage + +```typescript +import { Cache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import LRUWithRedisStorage from '@hokify/node-ts-cache-lru-redis-storage'; +import Redis from 'ioredis'; + +const redisClient = new Redis({ + host: 'localhost', + port: 6379 +}); + +const storage = new LRUWithRedisStorage( + { max: 1000 }, // LRU options: max 1000 items locally + () => redisClient // Redis client factory +); + +const strategy = new ExpirationStrategy(storage); + +class UserService { + @Cache(strategy, { ttl: 300 }) + async getUser(id: string): Promise { + return await db.users.findById(id); + } +} +``` + +### With LRU TTL + +```typescript +const storage = new LRUWithRedisStorage( + { + max: 500, + maxAge: 1000 * 60 // Local cache TTL: 1 minute (milliseconds) + }, + () => redisClient +); +``` + +### Direct API Usage + +```typescript +const storage = new LRUWithRedisStorage({ max: 100 }, () => redisClient); +const strategy = new ExpirationStrategy(storage); + +// Store (writes to both LRU and Redis) +await strategy.setItem('user:123', { name: 'John' }, { ttl: 60 }); + +// First get - might hit Redis if not in LRU +const user1 = await strategy.getItem('user:123'); + +// Second get - hits local LRU (fast!) +const user2 = await strategy.getItem('user:123'); + +// Clear both caches +await strategy.clear(); +``` + +## Constructor + +```typescript +new LRUWithRedisStorage( + lruOptions: LRU.Options, + redis: () => Redis.Redis +) +``` + +| Parameter | Type | Description | +| ------------ | ------------------- | ---------------------------------------------------------------------------------------------- | +| `lruOptions` | `LRU.Options` | Options for local LRU cache (see [lru-cache](https://www.npmjs.com/package/lru-cache#options)) | +| `redis` | `() => Redis.Redis` | Factory function returning an ioredis client | + +### LRU Options + +| Option | Type | Default | Description | +| ----------------- | ---------- | -------- | ----------------------------- | +| `max` | `number` | Required | Maximum items in local cache | +| `maxAge` | `number` | - | Local TTL in **milliseconds** | +| `maxSize` | `number` | - | Maximum total size | +| `sizeCalculation` | `function` | - | Size calculator function | + +## Interface + +```typescript +interface IAsynchronousCacheType { + getItem(key: string): Promise; + setItem(key: string, content: any, options?: any): Promise; + clear(): Promise; +} +``` + +## Use Cases + +### High-Traffic APIs + +```typescript +class ProductAPI { + @Cache(strategy, { ttl: 60 }) + async getProduct(id: string): Promise { + // Hot products served from local LRU (~0.01ms) + // Cold products fetched from Redis (~1-5ms) + // Very cold products hit database + return await db.products.findById(id); + } +} +``` + +### Distributed Systems + +Multiple application instances share the same Redis cache while maintaining their own local LRU: + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Instance 1 │ │ Instance 2 │ │ Instance 3 │ +│ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │ +│ │ LRU │ │ │ │ LRU │ │ │ │ LRU │ │ +│ └────┬───┘ │ │ └────┬───┘ │ │ └────┬───┘ │ +└───────┼──────┘ └───────┼──────┘ └───────┼──────┘ + │ │ │ + └────────────────────┼────────────────────┘ + │ + ┌──────┴──────┐ + │ Redis │ + │ (shared) │ + └─────────────┘ +``` + +### Session Caching + +```typescript +class SessionService { + @Cache(strategy, { ttl: 1800 }) // 30 minutes + async getSession(token: string): Promise { + // Active sessions stay in local LRU + // Inactive sessions fall back to Redis + return await db.sessions.findByToken(token); + } +} +``` + +## Performance Considerations + +- **Local LRU hit**: ~0.01ms (in-process memory access) +- **Redis hit**: ~1-5ms (network round-trip) +- **Set local `max`** based on your memory budget and access patterns +- **Shorter local TTL** = fresher data but more Redis hits +- **Longer local TTL** = better performance but potentially stale data + +## Dependencies + +- `lru-cache` ^6.0.0 +- `ioredis` ^5.3.2 + +## Requirements + +- Node.js >= 18.0.0 +- Redis server + +## License + +MIT diff --git a/storages/lru-redis/test/lru.storage.test.ts b/storages/lru-redis/test/lru.storage.test.ts index 02cc18a..0497d26 100644 --- a/storages/lru-redis/test/lru.storage.test.ts +++ b/storages/lru-redis/test/lru.storage.test.ts @@ -1,42 +1,40 @@ -import * as Assert from "assert"; -import LRURedisStorage from "../src/index.js"; - +import * as Assert from 'assert'; +import LRURedisStorage from '../src/index.js'; // @ts-ignore -import RedisMock from "ioredis-mock"; +import RedisMock from 'ioredis-mock'; const MockedRedis = new RedisMock({ - host: "host", - port: 123, - password: "pass" + host: 'host', + port: 123, + password: 'pass' }); - const storage = new LRURedisStorage({}, () => MockedRedis); -describe("LRUStorage", () => { - it("Should add cache item correctly", async () => { - const content = { data: { name: "deep" } }; - const key = "test"; +describe('LRUStorage', () => { + it('Should add cache item correctly', async () => { + const content = { data: { name: 'deep' } }; + const key = 'test'; - await storage.setItem(key, content); - Assert.strictEqual(await storage.getItem(key), content); - }); + await storage.setItem(key, content); + Assert.strictEqual(await storage.getItem(key), content); + }); - it("Should clear without errors", async () => { - await storage.clear(); - }); + it('Should clear without errors', async () => { + await storage.clear(); + }); - it("Should delete cache item if set to undefined", async () => { - await storage.setItem("test", undefined); + it('Should delete cache item if set to undefined', async () => { + await storage.setItem('test', undefined); - Assert.strictEqual(await storage.getItem("test"), undefined); - }); + Assert.strictEqual(await storage.getItem('test'), undefined); + }); - it("Should return undefined if cache not hit", async () => { - await storage.clear(); - const item = await storage.getItem("item123"); + it('Should return undefined if cache not hit', async () => { + await storage.clear(); + const item = await storage.getItem('item123'); - Assert.strictEqual(item, undefined); - }); + Assert.strictEqual(item, undefined); + }); }); diff --git a/storages/lru-redis/test/tsconfig.json b/storages/lru-redis/test/tsconfig.json index 47d1067..432c978 100644 --- a/storages/lru-redis/test/tsconfig.json +++ b/storages/lru-redis/test/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../tsconfig.json", - "include": ["*.ts", "../src/**/*.ts"] + "extends": "../tsconfig.json", + "include": ["*.ts", "../src/**/*.ts"] } diff --git a/storages/lru-redis/tsconfig.json b/storages/lru-redis/tsconfig.json index 6a62dbc..ef88f77 100644 --- a/storages/lru-redis/tsconfig.json +++ b/storages/lru-redis/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] } diff --git a/storages/lru/README.md b/storages/lru/README.md index f9996c5..22a9b20 100644 --- a/storages/lru/README.md +++ b/storages/lru/README.md @@ -1,14 +1,184 @@ # @hokify/node-ts-cache-lru-storage -LRU Storage module for node-ts-cache +[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-lru-storage.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache-lru-storage) -wrapper for [lru-cache](https://www.npmjs.com/package/lru-cache) +LRU (Least Recently Used) cache storage adapter for [@hokify/node-ts-cache](https://www.npmjs.com/package/@hokify/node-ts-cache) using [lru-cache](https://www.npmjs.com/package/lru-cache). +## Features + +- Synchronous operations +- Automatic eviction of least recently used items +- Configurable maximum size (items or memory) +- Built-in TTL support +- Multi-get/set operations +- Memory-safe with bounded cache size + +## Installation + +```bash +npm install @hokify/node-ts-cache @hokify/node-ts-cache-lru-storage +``` + +## Usage + +### Basic Usage + +```typescript +import { SyncCache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import LRUStorage from '@hokify/node-ts-cache-lru-storage'; + +const storage = new LRUStorage({ + max: 500 // Maximum 500 items +}); +const strategy = new ExpirationStrategy(storage); + +class DataService { + @SyncCache(strategy, { ttl: 60 }) + getData(key: string): Data { + return computeExpensiveData(key); + } +} +``` + +### With TTL + +```typescript +const storage = new LRUStorage({ + max: 1000, + maxAge: 1000 * 60 * 5 // 5 minutes in milliseconds +}); +``` + +> **Note:** LRU cache uses `maxAge` in **milliseconds**, while ExpirationStrategy uses `ttl` in **seconds**. + +### Memory-Based Limit + +```typescript +const storage = new LRUStorage({ + max: 500, + maxSize: 5000, // Maximum total "size" units + sizeCalculation: (value, key) => { + // Return the "size" of each item + return JSON.stringify(value).length; + } +}); +``` + +### Async Usage + +```typescript +import { Cache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import LRUStorage from '@hokify/node-ts-cache-lru-storage'; + +const storage = new LRUStorage({ max: 1000 }); +const strategy = new ExpirationStrategy(storage); + +class UserService { + @Cache(strategy, { ttl: 300 }) + async getUser(id: string): Promise { + return await db.users.findById(id); + } +} +``` + +### Multi-Operations + +```typescript +import { MultiCache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import LRUStorage from '@hokify/node-ts-cache-lru-storage'; + +const storage = new LRUStorage({ max: 1000 }); +const strategy = new ExpirationStrategy(storage); + +class ProductService { + @MultiCache([strategy], 0, id => `product:${id}`) + async getProducts(ids: string[]): Promise { + return await db.products.findByIds(ids); + } +} ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import LRUStorage from 'node-ts-cache-lru-storage'; -const myStrategy = new ExpirationStrategy(new LRUStorage()); +### Direct API Usage + +```typescript +const storage = new LRUStorage({ max: 100 }); +const strategy = new ExpirationStrategy(storage); + +// Store +strategy.setItem('key', { data: 'value' }, { ttl: 60 }); + +// Retrieve (also marks as "recently used") +const value = strategy.getItem<{ data: string }>('key'); + +// Clear all +strategy.clear(); +``` + +## Constructor Options + +Accepts [lru-cache options](https://www.npmjs.com/package/lru-cache#options) (v6.x): + +| Option | Type | Default | Description | +| ----------------- | ---------- | -------- | ----------------------------------------------- | +| `max` | `number` | Required | Maximum number of items | +| `maxAge` | `number` | - | Maximum age in **milliseconds** | +| `maxSize` | `number` | - | Maximum total size (requires `sizeCalculation`) | +| `sizeCalculation` | `function` | - | Function to calculate item size | +| `updateAgeOnGet` | `boolean` | `false` | Reset TTL on get | +| `dispose` | `function` | - | Called when items are evicted | + +## Interface + +```typescript +interface ISynchronousCacheType { + getItem(key: string): T | undefined; + setItem(key: string, content: any, options?: any): void; + clear(): void; +} + +interface IMultiSynchronousCacheType { + getItems(keys: string[]): { [key: string]: T | undefined }; + setItems(values: { key: string; content: any }[], options?: any): void; + clear(): void; +} +``` + +## TTL Behavior + +When using with `ExpirationStrategy`: + +- LRU's `maxAge` is in **milliseconds** +- ExpirationStrategy's `ttl` is in **seconds** +- Both TTLs apply - the shorter one wins + +**Recommendation:** Set LRU's `maxAge` longer than your strategy TTL, or omit it entirely and let ExpirationStrategy handle expiration: + +```typescript +// Option 1: Let ExpirationStrategy handle TTL +const storage = new LRUStorage({ max: 1000 }); // No maxAge +const strategy = new ExpirationStrategy(storage); + +// Option 2: Use LRU TTL with "forever" strategy +const storage = new LRUStorage({ max: 1000, maxAge: 60000 }); +strategy.setItem('key', value, { isCachedForever: true }); ``` -all timeout values are in SECONDS +## LRU Eviction + +When the cache reaches `max` items, the least recently accessed items are automatically evicted to make room for new ones. This makes LRU ideal for: + +- Memory-constrained environments +- Hot-data caching (frequently accessed items stay cached) +- Preventing unbounded memory growth + +## Dependencies + +- `lru-cache` ^6.0.0 + +## Requirements + +- Node.js >= 18.0.0 + +## License + +MIT diff --git a/storages/lru/test/lru.storage.test.ts b/storages/lru/test/lru.storage.test.ts index c4584a5..fdea701 100644 --- a/storages/lru/test/lru.storage.test.ts +++ b/storages/lru/test/lru.storage.test.ts @@ -1,31 +1,31 @@ -import * as Assert from "assert"; -import LRUStorage from "../src/index.js"; +import * as Assert from 'assert'; +import LRUStorage from '../src/index.js'; const storage = new LRUStorage({}); -describe("LRUStorage", () => { - it("Should add cache item correctly", async () => { - const content = { data: { name: "deep" } }; - const key = "test"; +describe('LRUStorage', () => { + it('Should add cache item correctly', async () => { + const content = { data: { name: 'deep' } }; + const key = 'test'; - await storage.setItem(key, content); - Assert.strictEqual(await storage.getItem(key), content); - }); + await storage.setItem(key, content); + Assert.strictEqual(await storage.getItem(key), content); + }); - it("Should clear without errors", async () => { - await storage.clear(); - }); + it('Should clear without errors', async () => { + await storage.clear(); + }); - it("Should delete cache item if set to undefined", async () => { - await storage.setItem("test", undefined); + it('Should delete cache item if set to undefined', async () => { + await storage.setItem('test', undefined); - Assert.strictEqual(await storage.getItem("test"), undefined); - }); + Assert.strictEqual(await storage.getItem('test'), undefined); + }); - it("Should return undefined if cache not hit", async () => { - await storage.clear(); - const item = await storage.getItem("item123"); + it('Should return undefined if cache not hit', async () => { + await storage.clear(); + const item = await storage.getItem('item123'); - Assert.strictEqual(item, undefined); - }); + Assert.strictEqual(item, undefined); + }); }); diff --git a/storages/lru/test/tsconfig.json b/storages/lru/test/tsconfig.json index 47d1067..432c978 100644 --- a/storages/lru/test/tsconfig.json +++ b/storages/lru/test/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../tsconfig.json", - "include": ["*.ts", "../src/**/*.ts"] + "extends": "../tsconfig.json", + "include": ["*.ts", "../src/**/*.ts"] } diff --git a/storages/lru/tsconfig.json b/storages/lru/tsconfig.json index 1b270d3..da8945a 100644 --- a/storages/lru/tsconfig.json +++ b/storages/lru/tsconfig.json @@ -1,8 +1,8 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "allowSyntheticDefaultImports": true - }, - "include": ["./src"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "allowSyntheticDefaultImports": true + }, + "include": ["./src"] } diff --git a/storages/node-cache/README.md b/storages/node-cache/README.md index 146d727..8de833c 100644 --- a/storages/node-cache/README.md +++ b/storages/node-cache/README.md @@ -1,12 +1,153 @@ # @hokify/node-ts-cache-node-cache-storage -Node-Cache Storage module for node-ts-cache +[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-node-cache-storage.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache-node-cache-storage) -wrapper for [node-cache](https://www.npmjs.com/package/node-cache) +In-memory storage adapter for [@hokify/node-ts-cache](https://www.npmjs.com/package/@hokify/node-ts-cache) using [node-cache](https://www.npmjs.com/package/node-cache). +## Features + +- Synchronous operations (no Promises needed) +- Built-in TTL and automatic cleanup +- Multi-get/set operations for batch caching +- Statistics tracking +- Key count limits +- Clone on get/set (data isolation) + +## Installation + +```bash +npm install @hokify/node-ts-cache @hokify/node-ts-cache-node-cache-storage +``` + +## Usage + +### Basic Usage + +```typescript +import { SyncCache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import NodeCacheStorage from '@hokify/node-ts-cache-node-cache-storage'; + +const storage = new NodeCacheStorage(); +const strategy = new ExpirationStrategy(storage); + +class ConfigService { + @SyncCache(strategy, { ttl: 60 }) + getConfig(key: string): Config { + return loadConfigFromFile(key); + } +} +``` + +### With Options + +```typescript +const storage = new NodeCacheStorage({ + stdTTL: 100, // Default TTL in seconds + checkperiod: 120, // Cleanup check interval in seconds + maxKeys: 1000, // Maximum number of keys (-1 = unlimited) + useClones: true // Clone objects on get/set (data isolation) +}); +``` + +### Async Usage + +Works with `@Cache` decorator as well (operations are still synchronous internally): + +```typescript +import { Cache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import NodeCacheStorage from '@hokify/node-ts-cache-node-cache-storage'; + +const storage = new NodeCacheStorage(); +const strategy = new ExpirationStrategy(storage); + +class UserService { + @Cache(strategy, { ttl: 300 }) + async getUser(id: string): Promise { + return await db.users.findById(id); + } +} +``` + +### Multi-Operations with @MultiCache + +```typescript +import { MultiCache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import NodeCacheStorage from '@hokify/node-ts-cache-node-cache-storage'; + +const storage = new NodeCacheStorage(); +const strategy = new ExpirationStrategy(storage); + +class ProductService { + @MultiCache([strategy], 0, id => `product:${id}`) + async getProductsByIds(ids: string[]): Promise { + return await db.products.findByIds(ids); + } +} +``` + +### Direct API Usage + +```typescript +const storage = new NodeCacheStorage(); +const strategy = new ExpirationStrategy(storage); + +// Single operations +strategy.setItem('key', { data: 'value' }, { ttl: 60 }); +const value = strategy.getItem<{ data: string }>('key'); + +// Clear all +strategy.clear(); ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import NodeCacheStorage from 'node-ts-cache-node-cache-storage'; -const myStrategy = new ExpirationStrategy(new NodeCacheStorage()); +## Constructor Options + +Accepts all [node-cache options](https://www.npmjs.com/package/node-cache#options): + +| Option | Type | Default | Description | +| ---------------- | --------- | ------- | ------------------------------------------ | +| `stdTTL` | `number` | `0` | Default TTL in seconds (0 = unlimited) | +| `checkperiod` | `number` | `600` | Automatic delete check interval in seconds | +| `maxKeys` | `number` | `-1` | Maximum number of keys (-1 = unlimited) | +| `useClones` | `boolean` | `true` | Clone objects on get/set | +| `deleteOnExpire` | `boolean` | `true` | Delete expired keys automatically | + +## Interface + +```typescript +interface ISynchronousCacheType { + getItem(key: string): T | undefined; + setItem(key: string, content: any, options?: any): void; + clear(): void; +} + +interface IMultiSynchronousCacheType { + getItems(keys: string[]): { [key: string]: T | undefined }; + setItems(values: { key: string; content: any }[], options?: any): void; + clear(): void; +} ``` + +## TTL Behavior + +When using with `ExpirationStrategy`: + +- `node-cache`'s `stdTTL` sets the storage-level TTL +- `ExpirationStrategy`'s `ttl` option sets the strategy-level TTL +- Both apply - the shorter one determines actual expiration + +For best results, either: + +- Set `stdTTL: 0` and let ExpirationStrategy handle TTL +- Or set `isCachedForever: true` in strategy options and let node-cache handle TTL + +## Dependencies + +- `node-cache` ^5.1.2 + +## Requirements + +- Node.js >= 18.0.0 + +## License + +MIT diff --git a/storages/node-cache/test/node-cache.storage.test.ts b/storages/node-cache/test/node-cache.storage.test.ts index 0192398..3760143 100644 --- a/storages/node-cache/test/node-cache.storage.test.ts +++ b/storages/node-cache/test/node-cache.storage.test.ts @@ -1,31 +1,31 @@ -import * as Assert from "assert"; -import NodeCacheStorage from "../src/index.js"; +import * as Assert from 'assert'; +import NodeCacheStorage from '../src/index.js'; const storage = new NodeCacheStorage({}); -describe("NodeCacheStorage", () => { - it("Should add cache item correctly", async () => { - const content = { data: { name: "deep" } }; - const key = "test"; +describe('NodeCacheStorage', () => { + it('Should add cache item correctly', async () => { + const content = { data: { name: 'deep' } }; + const key = 'test'; - await storage.setItem(key, content); - Assert.deepStrictEqual(await storage.getItem(key), content); - }); + await storage.setItem(key, content); + Assert.deepStrictEqual(await storage.getItem(key), content); + }); - it("Should clear without errors", async () => { - await storage.clear(); - }); + it('Should clear without errors', async () => { + await storage.clear(); + }); - it("Should delete cache item if set to undefined", async () => { - await storage.setItem("test", undefined); + it('Should delete cache item if set to undefined', async () => { + await storage.setItem('test', undefined); - Assert.strictEqual(await storage.getItem("test"), undefined); - }); + Assert.strictEqual(await storage.getItem('test'), undefined); + }); - it("Should return undefined if cache not hit", async () => { - await storage.clear(); - const item = await storage.getItem("item123"); + it('Should return undefined if cache not hit', async () => { + await storage.clear(); + const item = await storage.getItem('item123'); - Assert.strictEqual(item, undefined); - }); + Assert.strictEqual(item, undefined); + }); }); diff --git a/storages/node-cache/test/tsconfig.json b/storages/node-cache/test/tsconfig.json index 47d1067..432c978 100644 --- a/storages/node-cache/test/tsconfig.json +++ b/storages/node-cache/test/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../tsconfig.json", - "include": ["*.ts", "../src/**/*.ts"] + "extends": "../tsconfig.json", + "include": ["*.ts", "../src/**/*.ts"] } diff --git a/storages/node-cache/tsconfig.json b/storages/node-cache/tsconfig.json index 6a62dbc..ef88f77 100644 --- a/storages/node-cache/tsconfig.json +++ b/storages/node-cache/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] } diff --git a/storages/redis/README.md b/storages/redis/README.md index dfa57c7..560b205 100644 --- a/storages/redis/README.md +++ b/storages/redis/README.md @@ -1,10 +1,99 @@ -# @hokify/node-ts-cache-redius-storage +# @hokify/node-ts-cache-redis-storage -Redis Storage module for node-ts-cache +[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-redis-storage.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache-redis-storage) +Redis storage adapter for [@hokify/node-ts-cache](https://www.npmjs.com/package/@hokify/node-ts-cache) using the legacy `redis` package (v3.x). + +> **Note:** For new projects, consider using [@hokify/node-ts-cache-redisio-storage](https://www.npmjs.com/package/@hokify/node-ts-cache-redisio-storage) which uses the modern `ioredis` client with additional features like compression and multi-operations. + +## Installation + +```bash +npm install @hokify/node-ts-cache @hokify/node-ts-cache-redis-storage ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import RedisStorage from 'node-ts-cache-redis-storage'; -const myStrategy = new ExpirationStrategy(new RedisStorage()); +## Usage + +### Basic Usage + +```typescript +import { Cache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import RedisStorage from '@hokify/node-ts-cache-redis-storage'; + +const storage = new RedisStorage({ + host: 'localhost', + port: 6379 +}); + +const strategy = new ExpirationStrategy(storage); + +class UserService { + @Cache(strategy, { ttl: 300 }) + async getUser(id: string): Promise { + return await db.users.findById(id); + } +} ``` + +### With Authentication + +```typescript +const storage = new RedisStorage({ + host: 'redis.example.com', + port: 6379, + password: 'your-password', + db: 0 +}); +``` + +### Direct API Usage + +```typescript +const storage = new RedisStorage({ host: 'localhost', port: 6379 }); +const strategy = new ExpirationStrategy(storage); + +// Store a value +await strategy.setItem('user:123', { name: 'John' }, { ttl: 60 }); + +// Retrieve a value +const user = await strategy.getItem<{ name: string }>('user:123'); + +// Clear all cached items +await strategy.clear(); +``` + +## Constructor Options + +The constructor accepts [RedisClientOptions](https://github.com/redis/node-redis/tree/v3.1.2#options-object-properties) from the `redis` package: + +| Option | Type | Default | Description | +| ---------- | -------- | ------------- | ------------------------------- | +| `host` | `string` | `"127.0.0.1"` | Redis server hostname | +| `port` | `number` | `6379` | Redis server port | +| `password` | `string` | - | Redis authentication password | +| `db` | `number` | `0` | Redis database number | +| `url` | `string` | - | Redis URL (overrides host/port) | + +## Interface + +```typescript +interface IAsynchronousCacheType { + getItem(key: string): Promise; + setItem(key: string, content: any, options?: any): Promise; + clear(): Promise; +} +``` + +## Dependencies + +- `redis` ^3.1.2 - Redis client for Node.js +- `bluebird` 3.7.2 - Promise library for async operations + +## Requirements + +- Node.js >= 18.0.0 +- Redis server + +## License + +MIT diff --git a/storages/redis/src/redis.storage.ts b/storages/redis/src/redis.storage.ts index 28de65f..5b16535 100644 --- a/storages/redis/src/redis.storage.ts +++ b/storages/redis/src/redis.storage.ts @@ -13,7 +13,6 @@ export class RedisStorage implements IAsynchronousCacheType { constructor(private redisOptions: ClientOpts, redis = Redis) { this.client = redis.createClient(this.redisOptions) as IRedisClient; - console.log('this.client', this.client); } public async getItem(key: string): Promise { diff --git a/storages/redis/test/redis.storage.test.ts b/storages/redis/test/redis.storage.test.ts index 1bc930c..88a06bc 100644 --- a/storages/redis/test/redis.storage.test.ts +++ b/storages/redis/test/redis.storage.test.ts @@ -16,13 +16,12 @@ const storage = new RedisStorage( RedisMock ); - describe('RedisStorage', () => { it('Should clear Redis without errors', async () => { await storage.clear(); }); - /* + /* it('Should delete cache item if set to undefined', async () => { await storage.setItem('test', undefined); @@ -39,12 +38,15 @@ describe('RedisStorage', () => { }); it.skip('Should throw an Error if connection to redis fails', async () => { - const testStorage = new RedisStorage({ - host: 'unknown-host', - port: 123, - password: 'pass', - connect_timeout: 1000 - }, RedisMock); + const testStorage = new RedisStorage( + { + host: 'unknown-host', + port: 123, + password: 'pass', + connect_timeout: 1000 + }, + RedisMock + ); const errorMsg = 'Should have thrown an error, but did not'; try { diff --git a/storages/redis/test/tsconfig.json b/storages/redis/test/tsconfig.json index 47d1067..432c978 100644 --- a/storages/redis/test/tsconfig.json +++ b/storages/redis/test/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../tsconfig.json", - "include": ["*.ts", "../src/**/*.ts"] + "extends": "../tsconfig.json", + "include": ["*.ts", "../src/**/*.ts"] } diff --git a/storages/redis/tsconfig.json b/storages/redis/tsconfig.json index 6a62dbc..ef88f77 100644 --- a/storages/redis/tsconfig.json +++ b/storages/redis/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] } diff --git a/storages/redisio/README.md b/storages/redisio/README.md index 758943c..48d744f 100644 --- a/storages/redisio/README.md +++ b/storages/redisio/README.md @@ -1,11 +1,179 @@ -# @hokify/node-ts-cache-rediusio-storage -RedisIO Storage module for node-ts-cache +# @hokify/node-ts-cache-redisio-storage +[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache-redisio-storage.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache-redisio-storage) + +Modern Redis storage adapter for [@hokify/node-ts-cache](https://www.npmjs.com/package/@hokify/node-ts-cache) using [ioredis](https://github.com/redis/ioredis) with optional Snappy compression. + +## Features + +- Modern `ioredis` client +- Optional Snappy compression for reduced bandwidth +- Multi-get/set operations for batch caching +- Built-in TTL support (uses Redis native SETEX) +- Custom error handler support +- Non-blocking write operations (optional) + +## Installation + +```bash +npm install @hokify/node-ts-cache @hokify/node-ts-cache-redisio-storage ioredis +``` + +## Usage + +### Basic Usage + +```typescript +import { Cache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import RedisIOStorage from '@hokify/node-ts-cache-redisio-storage'; +import Redis from 'ioredis'; + +const redisClient = new Redis({ + host: 'localhost', + port: 6379 +}); + +const storage = new RedisIOStorage( + () => redisClient, + { maxAge: 3600 } // TTL in seconds (default: 86400 = 24 hours) +); + +const strategy = new ExpirationStrategy(storage); + +class UserService { + @Cache(strategy, { ttl: 300 }) + async getUser(id: string): Promise { + return await db.users.findById(id); + } +} ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import RedisIOStorage from 'node-ts-cache-redisio-storage'; -const myStrategy = new ExpirationStrategy(new RedisIOStorage()); +### With Compression + +Enable Snappy compression to reduce bandwidth usage (useful for large objects): + +```typescript +const storage = new RedisIOStorage(() => redisClient, { maxAge: 3600, compress: true }); +``` + +### With Error Handler + +Configure a custom error handler for non-blocking write operations: + +```typescript +const storage = new RedisIOStorage(() => redisClient, { maxAge: 3600 }); + +storage.onError(error => { + // Log errors without blocking the application + console.error('Redis cache error:', error); + metrics.incrementCacheError(); +}); ``` -all timeout values are in SECONDS +When an error handler is set, write operations don't await the Redis response, making them non-blocking. + +### Multi-Operations with @MultiCache + +This storage supports batch operations, making it ideal for multi-tier caching: + +```typescript +import { MultiCache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import RedisIOStorage from '@hokify/node-ts-cache-redisio-storage'; +import NodeCacheStorage from '@hokify/node-ts-cache-node-cache-storage'; + +const localCache = new ExpirationStrategy(new NodeCacheStorage()); +const redisCache = new RedisIOStorage(() => redisClient, { maxAge: 3600 }); + +class UserService { + @MultiCache([localCache, redisCache], 0, id => `user:${id}`) + async getUsersByIds(ids: string[]): Promise { + return await db.users.findByIds(ids); + } +} +``` + +### Direct API Usage + +```typescript +const storage = new RedisIOStorage(() => redisClient, { maxAge: 3600 }); + +// Single item operations +await storage.setItem('user:123', { name: 'John' }, { ttl: 60 }); +const user = await storage.getItem<{ name: string }>('user:123'); + +// Multi-item operations +const users = await storage.getItems(['user:1', 'user:2', 'user:3']); +await storage.setItems( + [ + { key: 'user:1', content: { name: 'Alice' } }, + { key: 'user:2', content: { name: 'Bob' } } + ], + { ttl: 60 } +); + +// Clear all (uses FLUSHDB - use with caution!) +await storage.clear(); +``` + +## Constructor + +```typescript +new RedisIOStorage( + redis: () => Redis.Redis, + options?: { + maxAge?: number; // TTL in seconds (default: 86400) + compress?: boolean; // Enable Snappy compression (default: false) + } +) +``` + +| Parameter | Type | Description | +| ------------------ | ------------------- | ----------------------------------------------------- | +| `redis` | `() => Redis.Redis` | Factory function returning an ioredis client instance | +| `options.maxAge` | `number` | Default TTL in seconds (default: 86400 = 24 hours) | +| `options.compress` | `boolean` | Enable Snappy compression (default: false) | + +## Interface + +```typescript +interface IAsynchronousCacheType { + getItem(key: string): Promise; + setItem(key: string, content: any, options?: { ttl?: number }): Promise; + clear(): Promise; +} + +interface IMultiIAsynchronousCacheType { + getItems(keys: string[]): Promise<{ [key: string]: T | undefined }>; + setItems(values: { key: string; content: any }[], options?: { ttl?: number }): Promise; + clear(): Promise; +} +``` + +## TTL Behavior + +This storage uses Redis native TTL (SETEX command) rather than relying solely on ExpirationStrategy: + +- `options.maxAge` in constructor sets the default TTL +- `options.ttl` in setItem/setItems overrides the default +- When used with ExpirationStrategy, both TTLs apply (Redis TTL for storage-level, strategy TTL for metadata) + +## Value Handling + +- `undefined`: Deletes the key from Redis +- `null`: Stores as empty string `""` +- Objects: JSON stringified before storage +- Primitives: Stored directly + +## Dependencies + +- `ioredis` ^5.3.2 - Modern Redis client +- `snappy` ^7.0.5 - Fast compression library + +## Requirements + +- Node.js >= 18.0.0 +- Redis server + +## License + +MIT diff --git a/storages/redisio/src/redisio.storage.ts b/storages/redisio/src/redisio.storage.ts index 3b22b33..8033968 100644 --- a/storages/redisio/src/redisio.storage.ts +++ b/storages/redisio/src/redisio.storage.ts @@ -3,146 +3,146 @@ import * as Redis from 'ioredis'; import * as snappy from 'snappy'; export class RedisIOStorage implements IAsynchronousCacheType, IMultiIAsynchronousCacheType { - constructor( - private redis: () => Redis.Redis, - private options: { - maxAge: number; - compress?: boolean; - } = { maxAge: 86400 } - ) { } - - private errorHandler: ((error: Error) => void) | undefined; - - onError(listener: (error: Error) => void) { - this.errorHandler = listener; - } - - async getItems(keys: string[]): Promise<{ [key: string]: T | undefined }> { - const mget = this.options.compress - ? await (this.redis() as any).mgetBuffer(...keys) - : await this.redis().mget(...keys); - const res = Object.fromEntries( - await Promise.all( - mget.map(async (entry: Buffer | string, i: number) => { - if (entry === null) { - return [keys[i], undefined]; // value does not exist yet - } - - if (entry === '') { - return [keys[i], null as any]; // value does exist, but is empty - } - - let finalItem: string = - entry && this.options.compress - ? await this.uncompress(entry as Buffer) - : (entry as string); - - try { - finalItem = finalItem && JSON.parse(finalItem); - } catch (error) { - /** ignore */ - } - - return [keys[i], finalItem]; - }) - ) - ); - return res; - } - - async compress(uncompressed: string): Promise { - const result = await snappy.compress(uncompressed); - return result; - } - - async uncompress(compressed: Buffer): Promise { - const result = await snappy.uncompress(compressed, { asBuffer: false }); - - return result.toString(); - } - - async setItems( - values: { key: string; content: any }[], - options?: { ttl?: number } - ): Promise { - const redisPipeline = this.redis().pipeline(); - await Promise.all( - values.map(async val => { - if (val.content === undefined) return; - - let content: string | Buffer = JSON.stringify(val.content); - - if (this.options.compress) { - content = await this.compress(content); - } - - const ttl = options?.ttl ?? this.options.maxAge; - if (ttl) { - redisPipeline.setex(val.key, ttl, content); - } else { - redisPipeline.set(val.key, content); - } - }) - ); - const savePromise = redisPipeline.exec(); - - if (this.errorHandler) { - // if we have an error handler, we do not need to await the result - savePromise.catch(err => this.errorHandler && this.errorHandler(err)); - } else { - await savePromise; - } - } - - public async getItem(key: string): Promise { - const entry = this.options.compress - ? await this.redis().getBuffer(key) - : await this.redis().get(key); - if (entry === null) { - return undefined; - } - if (entry === '') { - return null as any; - } - let finalItem: string = - entry && this.options.compress ? await this.uncompress(entry as Buffer) : (entry as string); - - try { - finalItem = JSON.parse(finalItem); - } catch (error) { - /** ignore */ - } - return finalItem as unknown as T; - } - - public async setItem(key: string, content: unknown, options?: { ttl?: number }): Promise { - if (typeof content === 'object') { - content = JSON.stringify(content); - } else if (content === undefined) { - await this.redis().del(key); - return; - } - - if (this.options.compress) { - content = await this.compress(content as string); - } - - const ttl = options?.ttl ?? this.options.maxAge; - let savePromise: Promise; - if (ttl) { - savePromise = this.redis().setex(key, ttl, content as Buffer | string); - } else { - savePromise = this.redis().set(key, content as Buffer | string); - } - if (this.errorHandler) { - // if we have an error handler, we do not need to await the result - savePromise.catch(err => this.errorHandler && this.errorHandler(err)); - } else { - await savePromise; - } - } - - public async clear(): Promise { - await this.redis().flushdb(); - } + constructor( + private redis: () => Redis.Redis, + private options: { + maxAge: number; + compress?: boolean; + } = { maxAge: 86400 } + ) {} + + private errorHandler: ((error: Error) => void) | undefined; + + onError(listener: (error: Error) => void) { + this.errorHandler = listener; + } + + async getItems(keys: string[]): Promise<{ [key: string]: T | undefined }> { + const mget = this.options.compress + ? await (this.redis() as any).mgetBuffer(...keys) + : await this.redis().mget(...keys); + const res = Object.fromEntries( + await Promise.all( + mget.map(async (entry: Buffer | string, i: number) => { + if (entry === null) { + return [keys[i], undefined]; // value does not exist yet + } + + if (entry === '') { + return [keys[i], null as any]; // value does exist, but is empty + } + + let finalItem: string = + entry && this.options.compress + ? await this.uncompress(entry as Buffer) + : (entry as string); + + try { + finalItem = finalItem && JSON.parse(finalItem); + } catch (error) { + /** ignore */ + } + + return [keys[i], finalItem]; + }) + ) + ); + return res; + } + + async compress(uncompressed: string): Promise { + const result = await snappy.compress(uncompressed); + return result; + } + + async uncompress(compressed: Buffer): Promise { + const result = await snappy.uncompress(compressed, { asBuffer: false }); + + return result.toString(); + } + + async setItems( + values: { key: string; content: any }[], + options?: { ttl?: number } + ): Promise { + const redisPipeline = this.redis().pipeline(); + await Promise.all( + values.map(async val => { + if (val.content === undefined) return; + + let content: string | Buffer = JSON.stringify(val.content); + + if (this.options.compress) { + content = await this.compress(content); + } + + const ttl = options?.ttl ?? this.options.maxAge; + if (ttl) { + redisPipeline.setex(val.key, ttl, content); + } else { + redisPipeline.set(val.key, content); + } + }) + ); + const savePromise = redisPipeline.exec(); + + if (this.errorHandler) { + // if we have an error handler, we do not need to await the result + savePromise.catch(err => this.errorHandler && this.errorHandler(err)); + } else { + await savePromise; + } + } + + public async getItem(key: string): Promise { + const entry = this.options.compress + ? await this.redis().getBuffer(key) + : await this.redis().get(key); + if (entry === null) { + return undefined; + } + if (entry === '') { + return null as any; + } + let finalItem: string = + entry && this.options.compress ? await this.uncompress(entry as Buffer) : (entry as string); + + try { + finalItem = JSON.parse(finalItem); + } catch (error) { + /** ignore */ + } + return finalItem as unknown as T; + } + + public async setItem(key: string, content: unknown, options?: { ttl?: number }): Promise { + if (typeof content === 'object') { + content = JSON.stringify(content); + } else if (content === undefined) { + await this.redis().del(key); + return; + } + + if (this.options.compress) { + content = await this.compress(content as string); + } + + const ttl = options?.ttl ?? this.options.maxAge; + let savePromise: Promise; + if (ttl) { + savePromise = this.redis().setex(key, ttl, content as Buffer | string); + } else { + savePromise = this.redis().set(key, content as Buffer | string); + } + if (this.errorHandler) { + // if we have an error handler, we do not need to await the result + savePromise.catch(err => this.errorHandler && this.errorHandler(err)); + } else { + await savePromise; + } + } + + public async clear(): Promise { + await this.redis().flushdb(); + } } diff --git a/storages/redisio/test/redis.storage.test.ts b/storages/redisio/test/redis.storage.test.ts index d7feffc..a90c749 100644 --- a/storages/redisio/test/redis.storage.test.ts +++ b/storages/redisio/test/redis.storage.test.ts @@ -1,14 +1,14 @@ -import * as Assert from "assert"; -import RedisIOStorage from "../src/index.js"; +import * as Assert from 'assert'; +import RedisIOStorage from '../src/index.js'; //import * as snappy from "snappy"; // @ts-ignore -import RedisMock from "ioredis-mock"; +import RedisMock from 'ioredis-mock'; const MockedRedis = new RedisMock({ - host: "host", - port: 123, - password: "pass", + host: 'host', + port: 123, + password: 'pass' }); const storage = new RedisIOStorage(() => MockedRedis); @@ -19,27 +19,27 @@ const compressedStorage = new RedisIOStorage(() => MockedRedis, { }); */ -describe("RedisIOStorage", () => { - it("Should clear Redis without errors", async () => { - await storage.clear(); - }); +describe('RedisIOStorage', () => { + it('Should clear Redis without errors', async () => { + await storage.clear(); + }); - describe("undefined handled correctly", () => { - it("Should delete cache item if set to undefined", async () => { - await storage.setItem("test", undefined); + describe('undefined handled correctly', () => { + it('Should delete cache item if set to undefined', async () => { + await storage.setItem('test', undefined); - Assert.strictEqual(await storage.getItem("test"), undefined); - }); + Assert.strictEqual(await storage.getItem('test'), undefined); + }); - it("Should return undefined if cache not hit", async () => { - await storage.clear(); - const item = await storage.getItem("item123"); + it('Should return undefined if cache not hit', async () => { + await storage.clear(); + const item = await storage.getItem('item123'); - Assert.strictEqual(item, undefined); - }); - }); + Assert.strictEqual(item, undefined); + }); + }); - /* + /* describe("compression", () => { it("Should set and retrieve item correclty", async () => { await compressedStorage.setItem("test", { asdf: 1 }); @@ -73,32 +73,26 @@ describe("RedisIOStorage", () => { }); */ - describe("uncompressed", () => { - it("Should set and retrieve item correclty", async () => { - await storage.setItem("test", { asdf: 2 }); + describe('uncompressed', () => { + it('Should set and retrieve item correclty', async () => { + await storage.setItem('test', { asdf: 2 }); - Assert.deepEqual( - await MockedRedis.get("test"), - JSON.stringify({ asdf: 2 }) - ); + Assert.deepEqual(await MockedRedis.get('test'), JSON.stringify({ asdf: 2 })); - Assert.deepEqual(await storage.getItem("test"), { asdf: 2 }); - }); + Assert.deepEqual(await storage.getItem('test'), { asdf: 2 }); + }); - it("Mutli Should set and retrieve item correclty", async () => { - await storage.setItems([ - { key: "test", content: { asdf: 2 } }, - { key: "test2", content: "2" }, - ]); + it('Mutli Should set and retrieve item correclty', async () => { + await storage.setItems([ + { key: 'test', content: { asdf: 2 } }, + { key: 'test2', content: '2' } + ]); - Assert.deepEqual( - await MockedRedis.get("test"), - JSON.stringify({ asdf: 2 }) - ); - Assert.deepEqual(await MockedRedis.get("test2"), JSON.stringify("2")); + Assert.deepEqual(await MockedRedis.get('test'), JSON.stringify({ asdf: 2 })); + Assert.deepEqual(await MockedRedis.get('test2'), JSON.stringify('2')); - Assert.deepEqual(await storage.getItem("test"), { asdf: 2 }); - Assert.deepEqual(await storage.getItem("test2"), "2"); - }); - }); + Assert.deepEqual(await storage.getItem('test'), { asdf: 2 }); + Assert.deepEqual(await storage.getItem('test2'), '2'); + }); + }); }); diff --git a/storages/redisio/test/tsconfig.json b/storages/redisio/test/tsconfig.json index 47d1067..432c978 100644 --- a/storages/redisio/test/tsconfig.json +++ b/storages/redisio/test/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../tsconfig.json", - "include": ["*.ts", "../src/**/*.ts"] + "extends": "../tsconfig.json", + "include": ["*.ts", "../src/**/*.ts"] } diff --git a/storages/redisio/tsconfig.json b/storages/redisio/tsconfig.json index 6a62dbc..ef88f77 100644 --- a/storages/redisio/tsconfig.json +++ b/storages/redisio/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src"] + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] } diff --git a/ts-cache/README.md b/ts-cache/README.md index 2ac1b66..4ef36da 100644 --- a/ts-cache/README.md +++ b/ts-cache/README.md @@ -1,274 +1,763 @@ -[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache) -[![The MIT License](https://img.shields.io/npm/l/@hokify/node-ts-cache.svg)](http://opensource.org/licenses/MIT) - -[![NPM](https://nodei.co/npm/@hokify/node-ts-cache.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/@hokify/node-ts-cache/) - # @hokify/node-ts-cache -Simple and extensible caching module supporting decorators +[![npm](https://img.shields.io/npm/v/@hokify/node-ts-cache.svg)](https://www.npmjs.org/package/@hokify/node-ts-cache) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Node.js CI](https://github.com/simllll/node-ts-cache/actions/workflows/test.yml/badge.svg)](https://github.com/simllll/node-ts-cache/actions/workflows/test.yml) + +Simple and extensible caching module for TypeScript/Node.js with decorator support. - +## Table of Contents -- [Install](#install) -- [Usage](#usage) - - [With decorator](#with-decorator) - - [Directly](#directly) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Decorators](#decorators) + - [@Cache](#cache) + - [@SyncCache](#synccache) + - [@MultiCache](#multicache) +- [Direct API Usage](#direct-api-usage) - [Strategies](#strategies) - [ExpirationStrategy](#expirationstrategy) - [Storages](#storages) -- [Test](#test) - - + - [Built-in Storages](#built-in-storages) + - [Additional Storages](#additional-storages) +- [Custom Key Strategies](#custom-key-strategies) +- [Interface Definitions](#interface-definitions) +- [Advanced Usage](#advanced-usage) +- [Environment Variables](#environment-variables) +- [Testing](#testing) -# Install +## Installation ```bash -npm install --save @ħokify/node-ts-cache +npm install @hokify/node-ts-cache ``` -# Usage +## Quick Start -## With decorator +```typescript +import { Cache, ExpirationStrategy, MemoryStorage } from '@hokify/node-ts-cache'; -Caches function response using the given options. Works with different strategies and storages. Uses all arguments to build an unique key. +// Create a caching strategy with in-memory storage +const cacheStrategy = new ExpirationStrategy(new MemoryStorage()); -`@Cache(strategy, options)` +class UserService { + @Cache(cacheStrategy, { ttl: 60 }) + async getUser(id: string): Promise { + // Expensive operation - result will be cached for 60 seconds + return await database.findUser(id); + } +} +``` + +## Decorators -Standard method to cache an async method. +### @Cache -- `strategy`: A supported caching [Strategy](#strategies) (Async) -- `options`: Options passed to the strategy for this particular method +Caches async method responses. The cache key is generated from the class name, method name, and stringified arguments. -_Note: @Cache always converts the method response to a promise because caching might be async._ +**Signature:** -E.g. +```typescript +@Cache(strategy: IAsynchronousCacheType | ISynchronousCacheType, options?: ExpirationOptions, keyStrategy?: IAsyncKeyStrategy) +``` -```ts -import { Cache, ExpirationStrategy, MemoryStorage } from "@hokify/node-ts-cache"; +**Parameters:** -const myStrategy = new ExpirationStrategy(new MemoryStorage()); +- `strategy` - A caching strategy instance (e.g., `ExpirationStrategy`) +- `options` - Options passed to the strategy (see [ExpirationStrategy](#expirationstrategy)) +- `keyStrategy` - Optional custom key generation strategy -class MyService { - @Cache(myStrategy, { ttl: 60 }) - public async getUsers(): Promise { - return ["Max", "User"]; - } +**Important:** `@Cache` always returns a Promise, even for synchronous methods, because cache operations may be asynchronous. + +**Example:** + +```typescript +import { Cache, ExpirationStrategy, MemoryStorage } from '@hokify/node-ts-cache'; + +const strategy = new ExpirationStrategy(new MemoryStorage()); + +class ProductService { + @Cache(strategy, { ttl: 300 }) + async getProduct(id: string): Promise { + console.log('Fetching product from database...'); + return await db.products.findById(id); + } + + @Cache(strategy, { ttl: 3600, isCachedForever: false }) + async getCategories(): Promise { + return await db.categories.findAll(); + } } + +// Usage +const service = new ProductService(); + +// First call - hits database +const product1 = await service.getProduct('123'); + +// Second call with same args - returns cached result +const product2 = await service.getProduct('123'); + +// Different args - hits database again +const product3 = await service.getProduct('456'); ``` -`@SyncCache(strategy, options)` -Method to use only sync caches. This allows you to use caching without a promise function. - -- `strategy`: A supported caching [Strategy](#strategies) (Sync) -- `options`: Options passed to the strategy for this particular method +### @SyncCache -E.g. +Caches synchronous method responses without converting to Promises. Use this when your storage is synchronous (like `MemoryStorage` or `LRUStorage`). -```ts -import { SyncCache, ExpirationStrategy, MemoryStorage } from "@hokify/node-ts-cache"; +**Signature:** -const myStrategy = new ExpirationStrategy(new MemoryStorage()); +```typescript +@SyncCache(strategy: ISynchronousCacheType, options?: ExpirationOptions, keyStrategy?: ISyncKeyStrategy) +``` -class MyService { - @SyncCache(myStrategy, { ttl: 60 }) - public getUsers(): string[] { - return ["Max", "User"]; - } +**Example:** + +```typescript +import { SyncCache, ExpirationStrategy, MemoryStorage } from '@hokify/node-ts-cache'; + +const strategy = new ExpirationStrategy(new MemoryStorage()); + +class ConfigService { + @SyncCache(strategy, { ttl: 60 }) + getConfig(key: string): ConfigValue { + // Expensive computation + return computeConfig(key); + } } + +// Usage - returns value directly, not a Promise +const config = new ConfigService().getConfig('theme'); ``` -`@MultiCache(strategy, parameterIndex, cacheKey, options)` -This method uses multi get and multi set of the cache providers if supported and therefore can use -different input paramters and still cache each variation. +### @MultiCache -- `strategies`: A list of caching [Strategy](#strategies), which is handled by provided order -- `parameterIndex`: The parameter index of the array -- `cacheKey`: a custom cache key function for each element of the cache -- `options`: Options passed to the strategy for this particular method +Enables multi-tier caching with batch operations. Useful for: -E.g. -```ts -import { MultiCache } from "@hokify/node-ts-cache"; -import NodeCacheStorage from '@hokify/node-ts-cache-node-cache-storage'; -import RedisIOStorage from '@hokify/node-ts-cache-redisio-storage'; +- Caching array-based lookups efficiently +- Implementing local + remote cache tiers +- Reducing database queries for batch operations -const localStrategy = new NodeCacheStorage(); -const centralStrategy = new RedisIOStorage({/*..*/}); +**Signature:** -class MyService { - @MultiCache([localStrategy, centralStrategy], 0) - public getUserNames(userIds: string[]): Promise[] { - return getUserNamesFromDatabase(userIds); // beware: return same order and number of userIds -> name - // e.g. 1,2,3 .. shoudl return [userName1, userName2, userName3] (or null for entries that do not exist) - // if you return undefined (instead of null) for one entry, it is queried the next time again. - } -} +```typescript +@MultiCache( + strategies: Array, + parameterIndex: number, + cacheKeyFn?: (element: any) => string, + options?: ExpirationOptions +) +``` -const a = new MyService(); -/** -* this call checks local cache if it has user id1, 2 or 3.. -* all cache misses are then checked by the central cache -* if there are still some missing entries, they are retrieved with the original getUsers method. -*/ -const result = await a.getUsers([1,2,3]); - -``` - - -Cache decorator generates cache key according to class name, class method and args (with JSON.stringify). -If you want another key creation logic you can bypass key creation strategy to the Cache decorator. - -```ts -import { - Cache, - ExpirationStrategy, - ISyncKeyStrategy, - MemoryStorage -} from "@hokify/node-ts-cache"; - -class MyKeyStrategy implements ISyncKeyStrategy { - public getKey( - className: string, - methodName: string, - args: any[] - ): Promise | string { - // Here you can implement your own way of creating cache keys - return `foo bar baz`; - } -} +**Parameters:** -const myStrategy = new ExpirationStrategy(new MemoryStorage()); -const myKeyStrategy = new MyKeyStrategy(); +- `strategies` - Array of cache strategies, checked in order (first = fastest, last = slowest) +- `parameterIndex` - Index of the array parameter in the method signature +- `cacheKeyFn` - Optional function to generate cache keys for each element +- `options` - Options passed to strategies -class MyService { - @Cache(myStrategy, { ttl: 60 }, myKeyStrategy) - public async getUsers(): Promise { - return ["Max", "User"]; - } +**Example:** + +```typescript +import { MultiCache, ExpirationStrategy } from '@hokify/node-ts-cache'; +import NodeCacheStorage from '@hokify/node-ts-cache-node-cache-storage'; +import RedisIOStorage from '@hokify/node-ts-cache-redisio-storage'; + +// Local cache (fastest) -> Redis (shared) -> Database (slowest) +const localCache = new ExpirationStrategy(new NodeCacheStorage()); +const redisCache = new RedisIOStorage(() => redisClient, { maxAge: 3600 }); + +class UserService { + @MultiCache([localCache, redisCache], 0, userId => `user:${userId}`, { ttl: 300 }) + async getUsersByIds(userIds: string[]): Promise { + // This only runs for IDs not found in any cache + // IMPORTANT: Return results in the same order as input IDs + return await db.users.findByIds(userIds); + } } + +// Usage +const service = new UserService(); + +// First call - checks local, then redis, then hits database +const users = await service.getUsersByIds(['1', '2', '3']); + +// Second call - user 1 & 2 from local cache, user 4 from database +const moreUsers = await service.getUsersByIds(['1', '2', '4']); ``` -## Directly +**Return Value Requirements:** -```ts -import { ExpirationStrategy, MemoryStorage } from "@hokify/node-ts-cache"; +- Return an array with the same length and order as the input array +- Use `null` for entries that exist but are empty +- Use `undefined` for entries that should be re-queried next time -const myCache = new ExpirationStrategy(new MemoryStorage()); +## Direct API Usage -class MyService { - public async getUsers(): Promise { - const cachedUsers = await myCache.getItem("users"); - if (cachedUsers) { - return cachedUsers; - } +You can use the caching strategy directly without decorators: + +```typescript +import { ExpirationStrategy, MemoryStorage } from '@hokify/node-ts-cache'; + +const cache = new ExpirationStrategy(new MemoryStorage()); + +class DataService { + async getData(key: string): Promise { + // Check cache first + const cached = await cache.getItem(key); + if (cached !== undefined) { + return cached; + } + + // Fetch fresh data + const data = await fetchData(key); + + // Store in cache + await cache.setItem(key, data, { ttl: 300 }); + + return data; + } - const newUsers = ["Max", "User"]; - await myCache.setItem("users", newUsers, { ttl: 60 }); + async invalidate(key: string): Promise { + await cache.setItem(key, undefined); + } - return newUsers; - } + async clearAll(): Promise { + await cache.clear(); + } } ``` -# Strategies +## Strategies + +### ExpirationStrategy + +Time-based cache expiration strategy. Items are automatically invalidated after a specified TTL (Time To Live). + +**Constructor:** + +```typescript +new ExpirationStrategy(storage: IAsynchronousCacheType | ISynchronousCacheType) +``` + +**Options:** + +| Option | Type | Default | Description | +| ----------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------- | +| `ttl` | `number` | `60` | Time to live in **seconds** | +| `isLazy` | `boolean` | `true` | If `true`, items are deleted when accessed after expiration. If `false`, items are deleted automatically via `setTimeout` | +| `isCachedForever` | `boolean` | `false` | If `true`, items never expire (ignores `ttl`) | + +**Example:** + +```typescript +import { ExpirationStrategy, MemoryStorage } from '@hokify/node-ts-cache'; + +const storage = new MemoryStorage(); +const strategy = new ExpirationStrategy(storage); + +// Cache for 5 minutes with lazy expiration +await strategy.setItem('key1', 'value', { ttl: 300, isLazy: true }); + +// Cache forever +await strategy.setItem('key2', 'value', { isCachedForever: true }); + +// Cache for 10 seconds with eager expiration (auto-delete) +await strategy.setItem('key3', 'value', { ttl: 10, isLazy: false }); +``` + +**Lazy vs Eager Expiration:** + +- **Lazy (`isLazy: true`)**: Expired items remain in storage until accessed. Memory is freed on read. Better for large caches. +- **Eager (`isLazy: false`)**: Items are deleted via `setTimeout` after TTL. Frees memory automatically but uses timers. + +## Storages + +### Built-in Storages + +These storages are included in the core package: + +#### MemoryStorage + +Simple in-memory storage using a JavaScript object. Best for development and simple use cases. + +```typescript +import { MemoryStorage, ExpirationStrategy } from '@hokify/node-ts-cache'; + +const storage = new MemoryStorage(); +const strategy = new ExpirationStrategy(storage); +``` + +**Characteristics:** + +- Synchronous operations +- No external dependencies +- Data lost on process restart +- No size limits (can cause memory issues) + +#### FsJsonStorage + +File-based storage that persists cache to a JSON file. Useful for persistent local caching. + +```typescript +import { FsJsonStorage, ExpirationStrategy } from '@hokify/node-ts-cache'; + +const storage = new FsJsonStorage('/tmp/cache.json'); +const strategy = new ExpirationStrategy(storage); +``` + +**Characteristics:** + +- Asynchronous operations +- Survives process restarts +- Slower than memory storage +- Good for development/single-instance deployments + +### Additional Storages + +Install these separately based on your needs: + +#### NodeCacheStorage + +Wrapper for [node-cache](https://www.npmjs.com/package/node-cache) - a simple in-memory cache with TTL support. + +```bash +npm install @hokify/node-ts-cache-node-cache-storage +``` + +```typescript +import { ExpirationStrategy } from '@hokify/node-ts-cache'; +import NodeCacheStorage from '@hokify/node-ts-cache-node-cache-storage'; + +const storage = new NodeCacheStorage({ + stdTTL: 100, // Default TTL in seconds + checkperiod: 120, // Cleanup interval in seconds + maxKeys: 1000 // Maximum number of keys +}); +const strategy = new ExpirationStrategy(storage); +``` + +**Characteristics:** + +- Synchronous operations +- Supports multi-get/set operations +- Built-in TTL and cleanup +- Good for production single-instance apps -## ExpirationStrategy +#### LRUStorage -Cached items expire after a given amount of time. +Wrapper for [lru-cache](https://www.npmjs.com/package/lru-cache) - Least Recently Used cache with automatic eviction. -- `ttl`: _(Default: 60)_ Number of seconds to expire the cachte item -- `isLazy`: _(Default: true)_ If true, expired cache entries will be deleted on touch. If false, entries will be deleted after the given _ttl_. -- `isCachedForever`: _(Default: false)_ If true, cache entry has no expiration. +```bash +npm install @hokify/node-ts-cache-lru-storage +``` + +```typescript +import { ExpirationStrategy } from '@hokify/node-ts-cache'; +import LRUStorage from '@hokify/node-ts-cache-lru-storage'; + +const storage = new LRUStorage({ + max: 500, // Maximum number of items + maxAge: 1000 * 60 // Max age in milliseconds (note: different from strategy TTL) +}); +const strategy = new ExpirationStrategy(storage); +``` + +**Characteristics:** -# Storages +- Synchronous operations +- Automatic eviction when max size reached +- Memory-safe with bounded size +- Supports multi-get/set operations -| Storage | Needed library | -| ---------------- | :--------------------------------------------: | -| FsStorage | (bundled) | -| MemoryStorage | (bundled) | -| RedisStorage | `npm install @hokify/node-ts-cache-redis-storage` | -| RedisIOStorage | `npm install @hokify/node-ts-cache-redisio-storage` | -| NodeCacheStorage | `npm install @hokify/node-ts-cache-node-cache-storage` | -| LRUStorage | `npm install node-ts-lru-storage` | +**Note:** LRU cache has its own TTL (`maxAge` in milliseconds). When using with `ExpirationStrategy`, both TTLs apply. Set `maxAge` higher than your strategy TTL or use `isCachedForever` in the strategy. -#### MemoryStorage() +#### RedisStorage -in memory +Redis storage using the legacy `redis` package (v3.x). For new projects, consider using `RedisIOStorage` instead. +```bash +npm install @hokify/node-ts-cache-redis-storage ``` -import { Cache, ExpirationStrategy, MemoryStorage } from "@hokify/node-ts-cache"; -const myStrategy = new ExpirationStrategy(new MemoryStorage()); +```typescript +import { ExpirationStrategy } from '@hokify/node-ts-cache'; +import RedisStorage from '@hokify/node-ts-cache-redis-storage'; + +const storage = new RedisStorage({ + host: 'localhost', + port: 6379, + password: 'optional' +}); +const strategy = new ExpirationStrategy(storage); ``` -#### FsJsonStorage(`fileName: string`) +**Characteristics:** + +- Asynchronous operations +- Uses legacy `redis` package with Bluebird promises +- Shared cache across multiple instances +- No compression support -file based +#### RedisIOStorage +Modern Redis storage using [ioredis](https://github.com/redis/ioredis) with optional Snappy compression. + +```bash +npm install @hokify/node-ts-cache-redisio-storage ``` -import { Cache, ExpirationStrategy, FileStorage } from "@hokify/node-ts-cache"; -const myStrategy = new ExpirationStrategy(new FileStorage()); +```typescript +import { ExpirationStrategy } from '@hokify/node-ts-cache'; +import RedisIOStorage from '@hokify/node-ts-cache-redisio-storage'; +import Redis from 'ioredis'; + +const redisClient = new Redis({ + host: 'localhost', + port: 6379 +}); + +// Basic usage +const storage = new RedisIOStorage( + () => redisClient, + { maxAge: 3600 } // TTL in seconds +); + +// With compression (reduces bandwidth, increases CPU usage) +const compressedStorage = new RedisIOStorage(() => redisClient, { maxAge: 3600, compress: true }); + +// With error handler (non-blocking writes) +storage.onError(error => { + console.error('Redis error:', error); +}); + +const strategy = new ExpirationStrategy(storage); ``` -#### RedisStorage(`clientOpts:` [RedisClientOptions](https://github.com/NodeRedis/node_redis#options-object-properties)) +**Characteristics:** + +- Asynchronous operations +- Supports multi-get/set operations +- Optional Snappy compression +- Modern ioredis client +- Custom error handler support +- Can bypass ExpirationStrategy TTL (uses Redis native TTL) -redis client backend +**Constructor Options:** +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxAge` | `number` | `86400` | TTL in seconds (used by Redis SETEX) | +| `compress` | `boolean` | `false` | Enable Snappy compression | +#### LRUWithRedisStorage + +Two-tier caching: fast local LRU cache with Redis fallback. Provides the best of both worlds. + +```bash +npm install @hokify/node-ts-cache-lru-redis-storage ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import RedisStorage from 'node-ts-cache-redis-storage'; -const myStrategy = new ExpirationStrategy(new RedisStorage()); +```typescript +import { ExpirationStrategy } from '@hokify/node-ts-cache'; +import LRUWithRedisStorage from '@hokify/node-ts-cache-lru-redis-storage'; +import Redis from 'ioredis'; + +const redisClient = new Redis(); + +const storage = new LRUWithRedisStorage( + { max: 1000 }, // LRU options + () => redisClient // Redis client factory +); +const strategy = new ExpirationStrategy(storage); ``` -#### RedisIOStorage(`clientOpts:` [RedisIOClientOptions](https://github.com/NodeRedis/node_redis#options-object-properties)) +**Characteristics:** -redis io client backend +- Asynchronous operations +- Local LRU for hot data +- Redis fallback for cache misses +- Reduces Redis round-trips +- Good for high-traffic applications +## Custom Key Strategies + +By default, cache keys are generated as: `ClassName:methodName:JSON.stringify(args)` + +You can implement custom key strategies for different needs: + +### Synchronous Key Strategy + +```typescript +import { Cache, ExpirationStrategy, MemoryStorage, ISyncKeyStrategy } from '@hokify/node-ts-cache'; + +class CustomKeyStrategy implements ISyncKeyStrategy { + getKey(className: string, methodName: string, args: any[]): string | undefined { + // Return undefined to skip caching for this call + if (args[0] === 'skip') { + return undefined; + } + + // Custom key format + return `${className}::${methodName}::${args.join('-')}`; + } +} + +const strategy = new ExpirationStrategy(new MemoryStorage()); +const keyStrategy = new CustomKeyStrategy(); + +class MyService { + @Cache(strategy, { ttl: 60 }, keyStrategy) + async getData(id: string): Promise { + return fetchData(id); + } +} ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import RedisIOStorage from 'node-ts-cache-redisio-storage'; -const myStrategy = new RedisIOStorage(); +### Asynchronous Key Strategy + +For key generation that requires async operations (e.g., fetching user context): + +```typescript +import { Cache, ExpirationStrategy, MemoryStorage, IAsyncKeyStrategy } from '@hokify/node-ts-cache'; + +class AsyncKeyStrategy implements IAsyncKeyStrategy { + async getKey(className: string, methodName: string, args: any[]): Promise { + // Async operation to build key + const userId = await getCurrentUserId(); + return `${userId}:${className}:${methodName}:${JSON.stringify(args)}`; + } +} ``` -#### NodeCacheStorage(`options:` [NodeCacheOptions](https://www.npmjs.com/package/node-cache#options)) +## Interface Definitions -wrapper for [node-cache](https://www.npmjs.com/package/node-cache) +### Storage Interfaces + +```typescript +/** + * Cache entry structure stored in backends + */ +interface ICacheEntry { + content: any; // The cached value + meta: any; // Metadata (e.g., TTL, createdAt) +} + +/** + * Asynchronous storage for single items + */ +interface IAsynchronousCacheType { + /** Retrieve an item by key. Returns undefined if not found. */ + getItem(key: string): Promise; + + /** Store an item. Pass undefined as content to delete. */ + setItem(key: string, content: C | undefined, options?: any): Promise; + + /** Clear all items from the cache. */ + clear(): Promise; +} +/** + * Synchronous storage for single items + */ +interface ISynchronousCacheType { + /** Retrieve an item by key. Returns undefined if not found. */ + getItem(key: string): T | undefined; + + /** Store an item. Pass undefined as content to delete. */ + setItem(key: string, content: C | undefined, options?: any): void; + + /** Clear all items from the cache. */ + clear(): void; +} + +/** + * Asynchronous storage with batch operations + */ +interface IMultiIAsynchronousCacheType { + /** Retrieve multiple items by keys. */ + getItems(keys: string[]): Promise<{ [key: string]: T | undefined }>; + + /** Store multiple items at once. */ + setItems(values: { key: string; content: C | undefined }[], options?: any): Promise; + + /** Clear all items from the cache. */ + clear(): Promise; +} + +/** + * Synchronous storage with batch operations + */ +interface IMultiSynchronousCacheType { + /** Retrieve multiple items by keys. */ + getItems(keys: string[]): { [key: string]: T | undefined }; + + /** Store multiple items at once. */ + setItems(values: { key: string; content: C | undefined }[], options?: any): void; + + /** Clear all items from the cache. */ + clear(): void; +} ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import NodeCacheStorage from 'node-ts-cache-node-cache-storage'; -const myStrategy = new NodeCacheStorage(); +### Key Strategy Interfaces + +```typescript +/** + * Synchronous key generation strategy + */ +interface ISyncKeyStrategy { + /** + * Generate a cache key from method context + * @param className - Name of the class containing the method + * @param methodName - Name of the cached method + * @param args - Arguments passed to the method + * @returns Cache key string, or undefined to skip caching + */ + getKey(className: string, methodName: string, args: any[]): string | undefined; +} + +/** + * Asynchronous key generation strategy + */ +interface IAsyncKeyStrategy { + /** + * Generate a cache key from method context (can be async) + * @param className - Name of the class containing the method + * @param methodName - Name of the cached method + * @param args - Arguments passed to the method + * @returns Cache key string, or undefined to skip caching + */ + getKey( + className: string, + methodName: string, + args: any[] + ): Promise | string | undefined; +} ``` -#### LRUStorage(`options:` [LRUCacheOptions](https://www.npmjs.com/package/lru-cache#options)) +### ExpirationStrategy Options + +```typescript +interface ExpirationOptions { + /** Time to live in seconds (default: 60) */ + ttl?: number; -wrapper for [lru-cache](https://www.npmjs.com/package/lru-cache) + /** If true, delete on access after expiration. If false, delete via setTimeout (default: true) */ + isLazy?: boolean; + /** If true, cache forever ignoring TTL (default: false) */ + isCachedForever?: boolean; +} ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import LRUStorage from 'node-ts-cache-lru-storage'; -const myStrategy = new LRUStorage(); +## Advanced Usage + +### Call Deduplication + +The `@Cache` decorator automatically deduplicates concurrent calls with the same cache key. If multiple calls are made before the first one completes, they all receive the same result: + +```typescript +class DataService { + @Cache(strategy, { ttl: 60 }) + async fetchData(id: string): Promise { + console.log('Fetching...'); // Only logged once + return await slowApiCall(id); + } +} + +const service = new DataService(); + +// All three calls share the same pending promise +const [a, b, c] = await Promise.all([ + service.fetchData('123'), + service.fetchData('123'), + service.fetchData('123') +]); +// "Fetching..." is logged only once, all three get the same result ``` +### Handling Undefined vs Null -#### LRURedisStorage(`options:` [LRUCacheOptions](https://www.npmjs.com/package/lru-cache#options), () => Redis.Redis) +The cache distinguishes between: -wrapper for [lru-cache](https://www.npmjs.com/package/lru-cache) with a remote cache redis backend +- `undefined`: No value found in cache, or value should not be cached +- `null`: Explicit null value that is cached +```typescript +class UserService { + @Cache(strategy, { ttl: 60 }) + async findUser(id: string): Promise { + const user = await db.findUser(id); + // Return null for non-existent users to cache the "not found" result + // Return undefined would cause re-fetching on every call + return user ?? null; + } +} ``` -import { Cache, ExpirationStrategy } from "@hokify/node-ts-cache"; -import LRUStorage from 'node-ts-cache-lru-redis-storage'; -const myStrategy = new LRUStorage({}, () => RedisConnectionInstance); +### Error Handling + +Cache errors are logged but don't break the application flow. If caching fails, the method executes normally: + +```typescript +// Cache read/write failures are logged as warnings: +// "@hokify/node-ts-cache: reading cache failed [key] [error]" +// "@hokify/node-ts-cache: writing result to cache failed [key] [error]" + +// For RedisIOStorage, you can add a custom error handler: +storage.onError(error => { + metrics.incrementCacheError(); + logger.error('Cache error', error); +}); ``` -# Test +## Environment Variables + +| Variable | Description | +| ------------------------- | -------------------------------------------------------------------------- | +| `DISABLE_CACHE_DECORATOR` | Set to any value to disable all `@Cache` decorators (useful for debugging) | + +## Testing ```bash +# Run all tests npm test + +# Run tests in watch mode +npm run tdd + +# Run tests with debugger +npm run tdd-debug-brk ``` + +## API Reference + +### Exports + +```typescript +// Decorators +export { Cache } from './decorator/cache.decorator'; +export { SyncCache } from './decorator/synccache.decorator'; +export { MultiCache } from './decorator/multicache.decorator'; + +// Strategies +export { ExpirationStrategy } from './strategy/caching/expiration.strategy'; + +// Built-in Storages +export { MemoryStorage } from './storage/memory'; +export { FsJsonStorage } from './storage/fs'; + +// Interfaces +export { + IAsynchronousCacheType, + ISynchronousCacheType, + IMultiIAsynchronousCacheType, + IMultiSynchronousCacheType +} from './types/cache.types'; +export { ISyncKeyStrategy, IAsyncKeyStrategy } from './types/key.strategy.types'; +``` + +## License + +MIT License diff --git a/ts-cache/src/decorator/multicache.decorator.ts b/ts-cache/src/decorator/multicache.decorator.ts index 18ca693..961d3e2 100644 --- a/ts-cache/src/decorator/multicache.decorator.ts +++ b/ts-cache/src/decorator/multicache.decorator.ts @@ -51,9 +51,9 @@ export function MultiCache( }; const parameters = args[parameterIndex]; - const cacheKeys: (string | undefined)[] = parameters.map((parameter: any) => { - return keyStrategy.getKey(className, methodName, parameter, args, 'read'); - }); + const cacheKeys: (string | undefined)[] = parameters.map((parameter: any) => + keyStrategy.getKey(className, methodName, parameter, args, 'read') + ); let result: any[] = []; if (!process.env.DISABLE_CACHE_DECORATOR) { diff --git a/ts-cache/src/index.ts b/ts-cache/src/index.ts index 685d5fb..a0606ab 100644 --- a/ts-cache/src/index.ts +++ b/ts-cache/src/index.ts @@ -5,7 +5,7 @@ export { IMultiSynchronousCacheType } from './types/cache.types.js'; export { ExpirationStrategy } from './strategy/caching/expiration.strategy.js'; -export { ISyncKeyStrategy } from './types/key.strategy.types.js'; +export { ISyncKeyStrategy, IAsyncKeyStrategy } from './types/key.strategy.types.js'; export { Cache } from './decorator/cache.decorator.js'; export { SyncCache } from './decorator/synccache.decorator.js'; export { MultiCache } from './decorator/multicache.decorator.js'; diff --git a/ts-cache/src/storage/fs/fs.json.storage.ts b/ts-cache/src/storage/fs/fs.json.storage.ts index 2bf73a2..3f1c4f9 100644 --- a/ts-cache/src/storage/fs/fs.json.storage.ts +++ b/ts-cache/src/storage/fs/fs.json.storage.ts @@ -27,27 +27,27 @@ export class FsJsonStorage implements IAsynchronousCacheType { } private async setCache(newCache: any): Promise { - await new Promise((resolve, reject) => + await new Promise((resolve, reject) => { writeFile(this.jsonFilePath, JSON.stringify(newCache), err => { if (err) { reject(err); return; } resolve(); - }) - ); + }); + }); } private async getCacheObject(): Promise { - const fileContent: Buffer = await new Promise((resolve, reject) => + const fileContent: Buffer = await new Promise((resolve, reject) => { readFile(this.jsonFilePath, (err, result) => { if (err) { reject(err); return; } resolve(result); - }) - ); + }); + }); return JSON.parse(fileContent.toString()); } diff --git a/ts-cache/test/cache.decorator.test.ts b/ts-cache/test/cache.decorator.test.ts index 3b51b8d..c682199 100644 --- a/ts-cache/test/cache.decorator.test.ts +++ b/ts-cache/test/cache.decorator.test.ts @@ -1,315 +1,267 @@ -import * as Assert from "assert"; -import { Cache, ExpirationStrategy, ISyncKeyStrategy } from "../src/index.js"; -import { MemoryStorage } from "../src/storage/memory/index.js"; -import { IAsyncKeyStrategy } from "../src/types/key.strategy.types.js"; +import * as Assert from 'assert'; +import { Cache, ExpirationStrategy, ISyncKeyStrategy } from '../src/index.js'; +import { MemoryStorage } from '../src/storage/memory/index.js'; +import { IAsyncKeyStrategy } from '../src/types/key.strategy.types.js'; const storage = new MemoryStorage(); const strategy = new ExpirationStrategy(storage); -const data = ["user", "max", "test"]; +const data = ['user', 'max', 'test']; class TestClassOne { - callCount = 0; - - @Cache(storage, {}) // beware, no expirationstrategy used, therefore this is never ttled because memory storage cannot do this by its own - public storageUser(): string[] { - return data; - } - - @Cache(strategy, { ttl: 1000 }) - public getUsers(): string[] { - this.callCount++; - return data; - } - - @Cache(strategy, { ttl: 1000 }) - public getUsersPromise(): Promise { - return Promise.resolve(data); - } - - @Cache(strategy, { ttl: 1000 }) - public getUndefinedValue(): Promise { - this.callCount++; - return Promise.resolve(undefined); - } - - @Cache(strategy, { ttl: 1000 }) - public getFalseValue(): Promise { - this.callCount++; - return Promise.resolve(false); - } - - @Cache(strategy, { ttl: 1000 }) - public getNullValue(): Promise { - this.callCount++; - return Promise.resolve(null); - } + callCount = 0; + + @Cache(storage, {}) // beware, no expirationstrategy used, therefore this is never ttled because memory storage cannot do this by its own + public storageUser(): string[] { + return data; + } + + @Cache(strategy, { ttl: 1000 }) + public getUsers(): string[] { + this.callCount++; + return data; + } + + @Cache(strategy, { ttl: 1000 }) + public getUsersPromise(): Promise { + return Promise.resolve(data); + } + + @Cache(strategy, { ttl: 1000 }) + public getUndefinedValue(): Promise { + this.callCount++; + return Promise.resolve(undefined); + } + + @Cache(strategy, { ttl: 1000 }) + public getFalseValue(): Promise { + this.callCount++; + return Promise.resolve(false); + } + + @Cache(strategy, { ttl: 1000 }) + public getNullValue(): Promise { + this.callCount++; + return Promise.resolve(null); + } } class TestClassTwo { - @Cache(strategy, { ttl: 20000 }) - public async getUsers(): Promise { - return new Promise((resolve) => { - setTimeout(() => resolve(data), 0); - }); - } - - public async throwErrorPlain(): Promise { - throw new Error("stacktrace?"); - } - - @Cache(strategy, { ttl: 20000 }) - public async throwError(): Promise { - throw new Error("stacktrace?"); - } - - @Cache( - strategy, - { ttl: 20000 }, - { - getKey(): string | undefined { - return undefined; // no cache - }, - } - ) - public async throwErrorNoCache(): Promise { - throw new Error("stacktrace?"); - } + @Cache(strategy, { ttl: 20000 }) + public async getUsers(): Promise { + return new Promise(resolve => { + setTimeout(() => resolve(data), 0); + }); + } + + public async throwErrorPlain(): Promise { + throw new Error('stacktrace?'); + } + + @Cache(strategy, { ttl: 20000 }) + public async throwError(): Promise { + throw new Error('stacktrace?'); + } + + @Cache( + strategy, + { ttl: 20000 }, + { + getKey(): string | undefined { + return undefined; // no cache + } + } + ) + public async throwErrorNoCache(): Promise { + throw new Error('stacktrace?'); + } } class CustomJsonStrategy implements ISyncKeyStrategy { - public getKey(className: string, methodName: string, args: any[]): string { - return `${className}:${methodName}:${JSON.stringify(args)}:foo`; - } + public getKey(className: string, methodName: string, args: any[]): string { + return `${className}:${methodName}:${JSON.stringify(args)}:foo`; + } } /** * This custom test key strategy only uses the method name as caching key */ class CustomKeyStrategy implements IAsyncKeyStrategy { - public getKey( - _className: string, - methodName: string, - _args: any[] - ): Promise | string { - return new Promise((resolve) => { - setTimeout(() => resolve(methodName), 0); - }); - } + public getKey(_className: string, methodName: string, _args: any[]): Promise | string { + return new Promise(resolve => { + setTimeout(() => resolve(methodName), 0); + }); + } } class TestClassThree { - @Cache(strategy, { ttl: 1000 }, new CustomJsonStrategy()) - public getUsers(): string[] { - return data; - } - - @Cache(strategy, { ttl: 1000 }, new CustomJsonStrategy()) - public getUsersPromise(): Promise { - return Promise.resolve(data); - } + @Cache(strategy, { ttl: 1000 }, new CustomJsonStrategy()) + public getUsers(): string[] { + return data; + } + + @Cache(strategy, { ttl: 1000 }, new CustomJsonStrategy()) + public getUsersPromise(): Promise { + return Promise.resolve(data); + } } class TestClassFour { - @Cache(strategy, { ttl: 500 }, new CustomKeyStrategy()) - public getUsersPromise(): Promise { - return Promise.resolve(data); - } + @Cache(strategy, { ttl: 500 }, new CustomKeyStrategy()) + public getUsersPromise(): Promise { + return Promise.resolve(data); + } } -describe("CacheDecorator", () => { - beforeEach(async () => { - await strategy.clear(); - }); +describe('CacheDecorator', () => { + beforeEach(async () => { + await strategy.clear(); + }); - it("Should decorate function with ExpirationStrategy correctly", async () => { - const myClass = new TestClassOne(); - await myClass.getUsersPromise(); - }); + it('Should decorate function with ExpirationStrategy correctly', async () => { + const myClass = new TestClassOne(); + await myClass.getUsersPromise(); + }); - it("Should cache function call correctly", async () => { - const myClass = new TestClassOne(); + it('Should cache function call correctly', async () => { + const myClass = new TestClassOne(); - const users = await myClass.getUsers(); + const users = await myClass.getUsers(); - Assert.strictEqual(data, users); - Assert.strictEqual( - await strategy.getItem("TestClassOne:getUsers:[]"), - data - ); - }); + Assert.strictEqual(data, users); + Assert.strictEqual(await strategy.getItem('TestClassOne:getUsers:[]'), data); + }); - it("Should cache function call correctly via storage", async () => { - const myClass = new TestClassOne(); + it('Should cache function call correctly via storage', async () => { + const myClass = new TestClassOne(); - const users = await myClass.storageUser(); + const users = await myClass.storageUser(); - Assert.strictEqual(data, users); - Assert.strictEqual( - await storage.getItem("TestClassOne:storageUser:[]"), - data - ); - }); + Assert.strictEqual(data, users); + Assert.strictEqual(await storage.getItem('TestClassOne:storageUser:[]'), data); + }); - it("Should prevent calling same method several times", async () => { - const myClass = new TestClassOne(); + it('Should prevent calling same method several times', async () => { + const myClass = new TestClassOne(); - await Promise.all([ - myClass.getUsers(), - myClass.getUsers(), - myClass.getUsers(), - ]); + await Promise.all([myClass.getUsers(), myClass.getUsers(), myClass.getUsers()]); - Assert.strictEqual(myClass.callCount, 1); + Assert.strictEqual(myClass.callCount, 1); - await Promise.all([ - myClass.getUsers(), - myClass.getUsers(), - myClass.getUsers(), - ]); - - Assert.strictEqual(myClass.callCount, 1); - }); + await Promise.all([myClass.getUsers(), myClass.getUsers(), myClass.getUsers()]); - it("Check if undefined return values is NOT cached", async () => { - const myClass = new TestClassOne(); - - await myClass.getUndefinedValue(); - - Assert.strictEqual(myClass.callCount, 1); - - await myClass.getUndefinedValue(); - - Assert.strictEqual(myClass.callCount, 2); - }); + Assert.strictEqual(myClass.callCount, 1); + }); - it("Check if false return values is cached", async () => { - const myClass = new TestClassOne(); - - await Promise.all([ - myClass.getFalseValue(), - myClass.getFalseValue(), - myClass.getFalseValue(), - ]); - - Assert.strictEqual(myClass.callCount, 1); - - await Promise.all([ - myClass.getFalseValue(), - myClass.getFalseValue(), - myClass.getFalseValue(), - ]); - - Assert.strictEqual(myClass.callCount, 1); - }); - - it("Check if null return values is also cached", async () => { - const myClass = new TestClassOne(); - - await Promise.all([ - myClass.getNullValue(), - myClass.getNullValue(), - myClass.getNullValue(), - ]); - - Assert.strictEqual(myClass.callCount, 1); - - await Promise.all([ - myClass.getNullValue(), - myClass.getNullValue(), - myClass.getNullValue(), - ]); - - Assert.strictEqual(myClass.callCount, 1); - }); - - it("Should cache Promise response correctly", async () => { - const myClass = new TestClassOne(); - - await myClass.getUsersPromise().then(async (response) => { - Assert.strictEqual(data, response); - Assert.strictEqual( - await strategy.getItem("TestClassOne:getUsersPromise:[]"), - data - ); - }); - }); - - it("Should cache async response correctly", async () => { - const myClass = new TestClassTwo(); - - const users = await myClass.getUsers(); - Assert.strictEqual(data, users); - Assert.strictEqual( - await strategy.getItem("TestClassTwo:getUsers:[]"), - data - ); - }); - - it("Should have valid stacktrace", async () => { - const myClass = new TestClassTwo(); - - try { - await myClass.throwError(); - } catch (err: any) { - console.log(err.stack); - } - }); - - it("Should have valid stacktrace - no cache", async () => { - const myClass = new TestClassTwo(); - - try { - await myClass.throwErrorNoCache(); - } catch (err: any) { - console.log(err.stack); - } - }); - - it("Should have valid stacktrace - plain", async () => { - const myClass = new TestClassTwo(); - - try { - await myClass.throwErrorPlain(); - } catch (err: any) { - console.log(err.stack); - } - }); - - it("Should cache function call correctly (custom key strategy)", async () => { - const myClass = new TestClassThree(); - - const users = await myClass.getUsers(); - - Assert.strictEqual(data, users); - Assert.strictEqual( - await strategy.getItem("TestClassThree:getUsers:[]:foo"), - data - ); - }); - - it("Should cache Promise response correctly (custom key strategy)", async () => { - const myClass = new TestClassThree(); - - await myClass.getUsersPromise().then(async (response) => { - Assert.strictEqual(data, response); - Assert.strictEqual( - await strategy.getItem( - "TestClassThree:getUsersPromise:[]:foo" - ), - data - ); - }); - }); - - it("Should cache users with async custom key strategy correctly", async () => { - const myClass = new TestClassFour(); - - await myClass.getUsersPromise().then(async (response) => { - Assert.strictEqual(data, response); - Assert.strictEqual( - await strategy.getItem("getUsersPromise"), - data - ); - }); - }); + it('Check if undefined return values is NOT cached', async () => { + const myClass = new TestClassOne(); + + await myClass.getUndefinedValue(); + + Assert.strictEqual(myClass.callCount, 1); + + await myClass.getUndefinedValue(); + + Assert.strictEqual(myClass.callCount, 2); + }); + + it('Check if false return values is cached', async () => { + const myClass = new TestClassOne(); + + await Promise.all([myClass.getFalseValue(), myClass.getFalseValue(), myClass.getFalseValue()]); + + Assert.strictEqual(myClass.callCount, 1); + + await Promise.all([myClass.getFalseValue(), myClass.getFalseValue(), myClass.getFalseValue()]); + + Assert.strictEqual(myClass.callCount, 1); + }); + + it('Check if null return values is also cached', async () => { + const myClass = new TestClassOne(); + + await Promise.all([myClass.getNullValue(), myClass.getNullValue(), myClass.getNullValue()]); + + Assert.strictEqual(myClass.callCount, 1); + + await Promise.all([myClass.getNullValue(), myClass.getNullValue(), myClass.getNullValue()]); + + Assert.strictEqual(myClass.callCount, 1); + }); + + it('Should cache Promise response correctly', async () => { + const myClass = new TestClassOne(); + + await myClass.getUsersPromise().then(async response => { + Assert.strictEqual(data, response); + Assert.strictEqual(await strategy.getItem('TestClassOne:getUsersPromise:[]'), data); + }); + }); + + it('Should cache async response correctly', async () => { + const myClass = new TestClassTwo(); + + const users = await myClass.getUsers(); + Assert.strictEqual(data, users); + Assert.strictEqual(await strategy.getItem('TestClassTwo:getUsers:[]'), data); + }); + + it('Should have valid stacktrace', async () => { + const myClass = new TestClassTwo(); + + try { + await myClass.throwError(); + } catch (err: any) { + console.log(err.stack); + } + }); + + it('Should have valid stacktrace - no cache', async () => { + const myClass = new TestClassTwo(); + + try { + await myClass.throwErrorNoCache(); + } catch (err: any) { + console.log(err.stack); + } + }); + + it('Should have valid stacktrace - plain', async () => { + const myClass = new TestClassTwo(); + + try { + await myClass.throwErrorPlain(); + } catch (err: any) { + console.log(err.stack); + } + }); + + it('Should cache function call correctly (custom key strategy)', async () => { + const myClass = new TestClassThree(); + + const users = await myClass.getUsers(); + + Assert.strictEqual(data, users); + Assert.strictEqual(await strategy.getItem('TestClassThree:getUsers:[]:foo'), data); + }); + + it('Should cache Promise response correctly (custom key strategy)', async () => { + const myClass = new TestClassThree(); + + await myClass.getUsersPromise().then(async response => { + Assert.strictEqual(data, response); + Assert.strictEqual( + await strategy.getItem('TestClassThree:getUsersPromise:[]:foo'), + data + ); + }); + }); + + it('Should cache users with async custom key strategy correctly', async () => { + const myClass = new TestClassFour(); + + await myClass.getUsersPromise().then(async response => { + Assert.strictEqual(data, response); + Assert.strictEqual(await strategy.getItem('getUsersPromise'), data); + }); + }); }); diff --git a/ts-cache/test/tsconfig.json b/ts-cache/test/tsconfig.json index 35717b5..80e07d2 100644 --- a/ts-cache/test/tsconfig.json +++ b/ts-cache/test/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.json", - "include": ["*.ts", "../src/**/*.ts"] + "extends": "../../tsconfig.json", + "include": ["*.ts", "../src/**/*.ts"] } diff --git a/ts-cache/tsconfig.json b/ts-cache/tsconfig.json index 1f5b2f6..383e036 100644 --- a/ts-cache/tsconfig.json +++ b/ts-cache/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["./src"] + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src"] } diff --git a/tsconfig.json b/tsconfig.json index 5639a44..b6e80c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,24 @@ { - "compilerOptions": { - "module": "esnext", - "target": "es2021", - "declaration": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "moduleResolution": "node", - "noEmitOnError": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "noImplicitAny": true, - "noImplicitReturns": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "emitDecoratorMetadata": true, - "lib": [ - "es2019" - ], - "sourceMap": true, - "experimentalDecorators": true, - "allowJs": false, - "outDir": "./dist" - }, - "compileOnSave": true + "compilerOptions": { + "module": "esnext", + "target": "es2021", + "declaration": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "moduleResolution": "node", + "noEmitOnError": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "emitDecoratorMetadata": true, + "lib": ["es2019"], + "sourceMap": true, + "experimentalDecorators": true, + "allowJs": false, + "outDir": "./dist" + }, + "compileOnSave": true } -