From 69a00f95419875ff39401fdb4b9895c4087ffe84 Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 6 Apr 2026 08:42:23 +0100 Subject: [PATCH 1/2] docs(COMPT-59): add README, update peer deps, create v0.1.0 changeset --- .changeset/compt-59-v0-1-0.md | 16 ++ README.md | 313 ++++++++++++++++++++++++++-------- package.json | 13 +- 3 files changed, 266 insertions(+), 76 deletions(-) create mode 100644 .changeset/compt-59-v0-1-0.md diff --git a/.changeset/compt-59-v0-1-0.md b/.changeset/compt-59-v0-1-0.md new file mode 100644 index 0000000..0ed3030 --- /dev/null +++ b/.changeset/compt-59-v0-1-0.md @@ -0,0 +1,16 @@ +--- +"@ciscode/cachekit": minor +--- + +Initial public release of @ciscode/cachekit v0.1.0. + +### Added + +- `CacheModule.register()` and `CacheModule.registerAsync()` — dynamic NestJS module with in-memory and Redis store support +- `CacheService` — injectable service with `get`, `set`, `delete`, `clear`, `has`, and `wrap` (cache-aside) methods +- `@Cacheable(key, ttl?)` — method decorator for transparent cache-aside with `{n}` argument interpolation +- `@CacheEvict(key)` — method decorator to evict cache entries after successful method execution +- `ICacheStore` port — interface for custom store adapter implementations +- `InMemoryCacheStore` — zero-dependency Map-backed adapter with lazy TTL expiry +- `RedisCacheStore` — ioredis-backed adapter with key prefix and full `ICacheStore` contract +- Peer dependencies: `@nestjs/common`, `@nestjs/core`, `ioredis` (optional — only required for Redis store) diff --git a/README.md b/README.md index cb4e83e..abb0777 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,278 @@ -# CacheKit +# @ciscode/cachekit -CacheKit provides reusable caching utilities and integrations for NestJS services. +> Production-ready NestJS caching module with pluggable store adapters, a +> cache-aside service, and method-level `@Cacheable` / `@CacheEvict` decorators. -## 🎯 What You Get - -- ✅ **CSR Architecture** - Controller-Service-Repository pattern -- ✅ **TypeScript** - Strict mode with path aliases -- ✅ **Testing** - Jest with 80% coverage threshold -- ✅ **Code Quality** - ESLint + Prettier + Husky -- ✅ **Versioning** - Changesets for semantic versioning -- ✅ **CI/CD** - GitHub Actions workflows -- ✅ **Documentation** - Complete Copilot instructions -- ✅ **Examples** - Full working examples for all layers +--- ## 📦 Installation ```bash -# Clone CacheKit -git clone https://github.com/CISCODE-MA/CacheKit.git cachekit -cd cachekit +npm install @ciscode/cachekit +``` -# Install dependencies -npm install +### Peer dependencies + +Install the peers that match what your app already uses: + +```bash +# Always required +npm install @nestjs/common @nestjs/core -# Start developing -npm run build -npm test +# Required when using the Redis store +npm install ioredis ``` -## 🏗️ Architecture +--- + +## 🚀 Quick Start +### 1. Register with an in-memory store (zero config) + +```typescript +import { Module } from "@nestjs/common"; +import { CacheModule } from "@ciscode/cachekit"; + +@Module({ + imports: [ + CacheModule.register({ + store: "memory", + ttl: 60, // default TTL in seconds (optional) + }), + ], +}) +export class AppModule {} ``` -src/ - ├── index.ts # PUBLIC API exports - ├── {module-name}.module.ts # NestJS module definition - │ - ├── controllers/ # HTTP Layer - │ └── example.controller.ts - │ - ├── services/ # Business Logic - │ └── example.service.ts - │ - ├── entities/ # Domain Models - │ └── example.entity.ts - │ - ├── repositories/ # Data Access - │ └── example.repository.ts - │ - ├── guards/ # Auth Guards - │ └── example.guard.ts - │ - ├── decorators/ # Custom Decorators - │ └── example.decorator.ts - │ - ├── dto/ # Data Transfer Objects - │ ├── create-example.dto.ts - │ └── update-example.dto.ts - │ - ├── filters/ # Exception Filters - ├── middleware/ # Middleware - ├── config/ # Configuration - └── utils/ # Utilities + +### 2. Register with a Redis store + +```typescript +import { Module } from "@nestjs/common"; +import { CacheModule } from "@ciscode/cachekit"; + +@Module({ + imports: [ + CacheModule.register({ + store: "redis", + ttl: 300, + redis: { + client: "redis://localhost:6379", + keyPrefix: "myapp:", + }, + }), + ], +}) +export class AppModule {} ``` -## 🚀 Usage +### 3. Register asynchronously (with ConfigService) -### 1. Customize Your Module +```typescript +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { CacheModule } from "@ciscode/cachekit"; + +@Module({ + imports: [ + ConfigModule.forRoot(), + CacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + store: cfg.get<"redis" | "memory">("CACHE_STORE", "memory"), + ttl: cfg.get("CACHE_TTL", 60), + redis: { + client: cfg.get("REDIS_URL", "redis://localhost:6379"), + keyPrefix: cfg.get("CACHE_PREFIX", "app:"), + }, + }), + }), + ], +}) +export class AppModule {} +``` + +--- + +## 🔧 CacheService API + +Inject `CacheService` wherever you need direct cache access: ```typescript -// src/example-kit.module.ts -import { Module, DynamicModule } from "@nestjs/common"; -import { ExampleService } from "@services/example.service"; +import { Injectable } from "@nestjs/common"; +import { CacheService } from "@ciscode/cachekit"; -@Module({}) -export class ExampleKitModule { - static forRoot(options: ExampleKitOptions): DynamicModule { - return { - module: ExampleKitModule, - providers: [ExampleService], - exports: [ExampleService], - }; +@Injectable() +export class ProductsService { + constructor(private readonly cache: CacheService) {} + + async getProduct(id: string) { + // Manual cache-aside pattern + const cached = await this.cache.get(`product:${id}`); + if (cached) return cached; + + const product = await this.db.findProduct(id); + await this.cache.set(`product:${id}`, product, 120); // TTL = 120 s + return product; + } + + async deleteProduct(id: string) { + await this.db.deleteProduct(id); + await this.cache.delete(`product:${id}`); + } + + // wrap() — cache-aside in one call + async getAll(): Promise { + return this.cache.wrap( + "products:all", + () => this.db.findAllProducts(), + 300, // TTL = 300 s + ); } } ``` -### 2. Create Services +### Full method reference + +| Method | Signature | Description | +|--------|-----------|-------------| +| `get` | `get(key): Promise` | Retrieve a value; returns `null` on miss or expiry | +| `set` | `set(key, value, ttl?): Promise` | Store a value; `ttl` overrides module default | +| `delete` | `delete(key): Promise` | Remove a single entry | +| `clear` | `clear(): Promise` | Remove all entries (scoped to key prefix for Redis) | +| `has` | `has(key): Promise` | Return `true` if key exists and has not expired | +| `wrap` | `wrap(key, fn, ttl?): Promise` | Return cached value or call `fn`, cache result, return it | + +--- + +## 🎯 Method Decorators + +### `@Cacheable(key, ttl?)` + +Cache the return value of a method automatically (cache-aside). The decorated +method is only called on a cache miss; subsequent calls return the stored value. + +**Key templates** — use `{0}`, `{1}`, … to interpolate method arguments: ```typescript -// src/services/example.service.ts import { Injectable } from "@nestjs/common"; +import { Cacheable } from "@ciscode/cachekit"; @Injectable() -export class ExampleService { - async doSomething(data: string): Promise { - return `Processed: ${data}`; +export class UserService { + // Static key — same result cached for all calls + @Cacheable("users:all", 300) + async findAll(): Promise { + return this.db.findAllUsers(); + } + + // Dynamic key — "user:42" for userId = 42 + @Cacheable("user:{0}", 120) + async findById(userId: number): Promise { + return this.db.findUser(userId); + } + + // Multi-argument key — "org:5:user:99" + @Cacheable("org:{0}:user:{1}", 60) + async findByOrg(orgId: number, userId: number): Promise { + return this.db.findUserInOrg(orgId, userId); } } ``` +### `@CacheEvict(key)` + +Evict (delete) a cache entry after the decorated method completes successfully. +If the method throws, the entry is **not** evicted. + +```typescript +import { Injectable } from "@nestjs/common"; +import { CacheEvict } from "@ciscode/cachekit"; + +@Injectable() +export class UserService { + // Evict "users:all" whenever a user is created + @CacheEvict("users:all") + async createUser(dto: CreateUserDto): Promise { + return this.db.createUser(dto); + } + + // Evict the specific user entry — "user:42" for userId = 42 + @CacheEvict("user:{0}") + async updateUser(userId: number, dto: UpdateUserDto): Promise { + return this.db.updateUser(userId, dto); + } + + // Evict on delete + @CacheEvict("user:{0}") + async deleteUser(userId: number): Promise { + await this.db.deleteUser(userId); + } +} +``` + +--- + +## ⚙️ Configuration reference + +### `CacheModuleOptions` (synchronous) + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `store` | `"memory" \| "redis"` | ✅ | — | Backing store adapter | +| `ttl` | `number` | ❌ | `undefined` | Default TTL in seconds for all `set()` calls | +| `redis` | `RedisCacheStoreOptions` | When `store: "redis"` | — | Redis connection config | + +### `RedisCacheStoreOptions` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `client` | `string \| Redis` | ✅ | Redis URL (`redis://…`) or existing ioredis instance | +| `keyPrefix` | `string` | ❌ | Prefix for all keys, e.g. `"myapp:"` | + +--- + +## 🏗️ Architecture + +``` +src/ + ├── index.ts # Public API exports + ├── cache-kit.module.ts # CacheModule (dynamic NestJS module) + ├── constants.ts # DI tokens: CACHE_STORE, CACHE_MODULE_OPTIONS + │ + ├── ports/ + │ └── cache-store.port.ts # ICacheStore interface + │ + ├── adapters/ + │ ├── in-memory-cache-store.adapter.ts # Map-backed adapter (no deps) + │ └── redis-cache-store.adapter.ts # ioredis-backed adapter + │ + ├── services/ + │ └── cache.service.ts # CacheService (public API) + │ + ├── decorators/ + │ ├── cacheable.decorator.ts # @Cacheable + │ └── cache-evict.decorator.ts # @CacheEvict + │ + └── utils/ + ├── cache-service-ref.ts # Singleton holder for decorators + └── resolve-cache-key.util.ts # {0}, {1} key template resolver +``` + +--- + +## 🔐 Security notes + +- Never pass credentials directly in source code — use environment variables or `ConfigService` +- The Redis `keyPrefix` isolates cache entries from other apps sharing the same instance +- `clear()` without a key prefix will `FLUSHDB` the entire Redis database — use prefixes in production + +--- + +## 📄 License + +MIT © [CisCode](https://github.com/CISCODE-MA) + ### 3. Define DTOs ```typescript diff --git a/package.json b/package.json index aba7a47..a06aca8 100644 --- a/package.json +++ b/package.json @@ -45,15 +45,14 @@ "peerDependencies": { "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", - "@nestjs/platform-express": "^10 || ^11", - "reflect-metadata": "^0.2.2", - "rxjs": "^7" + "ioredis": "^5" }, - "dependencies": { - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "ioredis": "^5.10.1" + "peerDependenciesMeta": { + "ioredis": { + "optional": true + } }, + "dependencies": {}, "devDependencies": { "@changesets/cli": "^2.27.7", "@eslint/js": "^9.18.0", From e5d52170117e498c18a5d284515aafa8b8eb3966 Mon Sep 17 00:00:00 2001 From: yasser Date: Mon, 6 Apr 2026 08:45:03 +0100 Subject: [PATCH 2/2] style: fix Prettier formatting across all files --- README.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index abb0777..508f8e2 100644 --- a/README.md +++ b/README.md @@ -134,14 +134,14 @@ export class ProductsService { ### Full method reference -| Method | Signature | Description | -|--------|-----------|-------------| -| `get` | `get(key): Promise` | Retrieve a value; returns `null` on miss or expiry | -| `set` | `set(key, value, ttl?): Promise` | Store a value; `ttl` overrides module default | -| `delete` | `delete(key): Promise` | Remove a single entry | -| `clear` | `clear(): Promise` | Remove all entries (scoped to key prefix for Redis) | -| `has` | `has(key): Promise` | Return `true` if key exists and has not expired | -| `wrap` | `wrap(key, fn, ttl?): Promise` | Return cached value or call `fn`, cache result, return it | +| Method | Signature | Description | +| -------- | ----------------------------------------- | --------------------------------------------------------- | +| `get` | `get(key): Promise` | Retrieve a value; returns `null` on miss or expiry | +| `set` | `set(key, value, ttl?): Promise` | Store a value; `ttl` overrides module default | +| `delete` | `delete(key): Promise` | Remove a single entry | +| `clear` | `clear(): Promise` | Remove all entries (scoped to key prefix for Redis) | +| `has` | `has(key): Promise` | Return `true` if key exists and has not expired | +| `wrap` | `wrap(key, fn, ttl?): Promise` | Return cached value or call `fn`, cache result, return it | --- @@ -217,18 +217,18 @@ export class UserService { ### `CacheModuleOptions` (synchronous) -| Field | Type | Required | Default | Description | -|-------|------|----------|---------|-------------| -| `store` | `"memory" \| "redis"` | ✅ | — | Backing store adapter | -| `ttl` | `number` | ❌ | `undefined` | Default TTL in seconds for all `set()` calls | -| `redis` | `RedisCacheStoreOptions` | When `store: "redis"` | — | Redis connection config | +| Field | Type | Required | Default | Description | +| ------- | ------------------------ | --------------------- | ----------- | -------------------------------------------- | +| `store` | `"memory" \| "redis"` | ✅ | — | Backing store adapter | +| `ttl` | `number` | ❌ | `undefined` | Default TTL in seconds for all `set()` calls | +| `redis` | `RedisCacheStoreOptions` | When `store: "redis"` | — | Redis connection config | ### `RedisCacheStoreOptions` -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `client` | `string \| Redis` | ✅ | Redis URL (`redis://…`) or existing ioredis instance | -| `keyPrefix` | `string` | ❌ | Prefix for all keys, e.g. `"myapp:"` | +| Field | Type | Required | Description | +| ----------- | ----------------- | -------- | ---------------------------------------------------- | +| `client` | `string \| Redis` | ✅ | Redis URL (`redis://…`) or existing ioredis instance | +| `keyPrefix` | `string` | ❌ | Prefix for all keys, e.g. `"myapp:"` | ---