From 64f03cc82b47203094c569227d9081c866d9817a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 06:30:24 +0000 Subject: [PATCH] examples: add NestJS 11 microservice demo with @Cacheable and @CacheEvict Add a runnable examples/nestjs-microservice app that wires TriCacheModule.register(), @Cacheable/@CacheEvict (ttl seconds), and the CACHE_MANAGER TriCacheStore adapter. Includes README curl walkthrough, pnpm verify, and a root contract test. Fixes #29 Co-authored-by: David --- docs/integrations/nestjs.md | 87 +- examples/nestjs-microservice/.gitignore | 6 + examples/nestjs-microservice/README.md | 165 +++ examples/nestjs-microservice/package.json | 30 + examples/nestjs-microservice/pnpm-lock.yaml | 1210 +++++++++++++++++ .../nestjs-microservice/pnpm-workspace.yaml | 3 + .../nestjs-microservice/src/app.controller.ts | 27 + .../nestjs-microservice/src/app.module.ts | 24 + .../nestjs-microservice/src/cache-options.ts | 34 + examples/nestjs-microservice/src/catalog.ts | 12 + .../src/items.controller.ts | 64 + .../src/items.repository.ts | 58 + .../nestjs-microservice/src/items.service.ts | 86 ++ examples/nestjs-microservice/src/main.ts | 35 + .../src/notes.controller.ts | 36 + .../nestjs-microservice/src/notes.service.ts | 55 + examples/nestjs-microservice/src/verify.ts | 220 +++ examples/nestjs-microservice/tsconfig.json | 18 + tests/nestjs-microservice-example.test.ts | 129 ++ 19 files changed, 2282 insertions(+), 17 deletions(-) create mode 100644 examples/nestjs-microservice/.gitignore create mode 100644 examples/nestjs-microservice/README.md create mode 100644 examples/nestjs-microservice/package.json create mode 100644 examples/nestjs-microservice/pnpm-lock.yaml create mode 100644 examples/nestjs-microservice/pnpm-workspace.yaml create mode 100644 examples/nestjs-microservice/src/app.controller.ts create mode 100644 examples/nestjs-microservice/src/app.module.ts create mode 100644 examples/nestjs-microservice/src/cache-options.ts create mode 100644 examples/nestjs-microservice/src/catalog.ts create mode 100644 examples/nestjs-microservice/src/items.controller.ts create mode 100644 examples/nestjs-microservice/src/items.repository.ts create mode 100644 examples/nestjs-microservice/src/items.service.ts create mode 100644 examples/nestjs-microservice/src/main.ts create mode 100644 examples/nestjs-microservice/src/notes.controller.ts create mode 100644 examples/nestjs-microservice/src/notes.service.ts create mode 100644 examples/nestjs-microservice/src/verify.ts create mode 100644 examples/nestjs-microservice/tsconfig.json create mode 100644 tests/nestjs-microservice-example.test.ts diff --git a/docs/integrations/nestjs.md b/docs/integrations/nestjs.md index f1999dc..a4c2fd7 100644 --- a/docs/integrations/nestjs.md +++ b/docs/integrations/nestjs.md @@ -4,10 +4,25 @@ TriCache provides an official NestJS dynamic module (`TriCacheModule`) and declarative method decorators (`@Cacheable`, `@CacheEvict`) for high-concurrency NestJS microservices. +### Ready-to-run NestJS 11 demo + +A self-contained microservice lives at [`examples/nestjs-microservice`](https://github.com/Kareem411/TriCache/tree/main/examples/nestjs-microservice). It exercises `TriCacheModule.register()`, `@Cacheable({ ttl, tags })`, `@CacheEvict({ tags })`, and the exported `CACHE_MANAGER` / `TriCacheStore` cache-manager adapter. + +```bash +pnpm install && pnpm build +cd examples/nestjs-microservice +pnpm install +pnpm dev +``` + +Then follow the curl walkthrough in that README (`GET /items/:id` hit vs `PATCH` eviction, plus `PUT`/`GET /store/notes/:id`). + --- ## Installation & Module Registration +`TriCacheModule.register(options)` forwards `options` to `CacheService.create()` (`CacheOptions`). The dynamic module is always registered as `global: true` and exports `TRICACHE_SERVICE` (`CacheService`) plus `CACHE_MANAGER` (`TriCacheStore`). There is no `preset` or `isGlobal` field on this options object. + ### Synchronous Registration In your root `AppModule`: @@ -19,10 +34,10 @@ import { TriCacheModule } from 'tricache/nestjs'; @Module({ imports: [ TriCacheModule.register({ - preset: 'microservice', - redisHost: process.env.REDIS_HOST ?? '127.0.0.1', + namespace: 'orders-service', + redisHost: process.env.REDIS_HOST, redisPort: Number(process.env.REDIS_PORT ?? 6379), - isGlobal: true, // Exports CacheService across all NestJS modules + disableRedis: !process.env.REDIS_HOST, }), ], }) @@ -44,10 +59,10 @@ import { TriCacheModule } from 'tricache/nestjs'; imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ - preset: 'microservice', + namespace: 'orders-service', redisHost: config.get('REDIS_HOST'), - redisPort: config.get('REDIS_PORT'), - isGlobal: true, + redisPort: config.get('REDIS_PORT') ?? 6379, + disableRedis: !config.get('REDIS_HOST'), }), }), ], @@ -63,17 +78,21 @@ export class AppModule {} Automatically caches method return values with Singleflight coalescing and SWR: ```typescript -import { Injectable } from '@nestjs/common'; -import { Cacheable } from 'tricache/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; +import type { CacheService } from 'tricache'; +import { Cacheable, TRICACHE_SERVICE } from 'tricache/nestjs'; @Injectable() export class UserService { - constructor(private readonly prisma: PrismaService) {} + constructor( + @Inject(TRICACHE_SERVICE) readonly cacheService: CacheService, + private readonly prisma: PrismaService, + ) {} @Cacheable({ key: (userId: string) => `user:${userId}`, - ttlSec: 300, - swrSec: 60, + ttl: 300, + swr: 60, tags: ['users'], }) async getUserById(userId: string) { @@ -82,15 +101,22 @@ export class UserService { } ``` +`ttl` is seconds by default (`ttlUnit?: 'seconds' | 'milliseconds'`). Decorators look up the engine on `this.cacheService`, `this.cache`, or `this.cacheStore.cache`. + ### `@CacheEvict` Evicts specific keys or tags upon mutation: ```typescript -import { Injectable } from '@nestjs/common'; -import { CacheEvict } from 'tricache/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; +import type { CacheService } from 'tricache'; +import { CacheEvict, TRICACHE_SERVICE } from 'tricache/nestjs'; @Injectable() export class UserService { + constructor( + @Inject(TRICACHE_SERVICE) readonly cacheService: CacheService, + ) {} + @CacheEvict({ key: (userId: string) => `user:${userId}`, tags: ['users'], @@ -105,15 +131,18 @@ export class UserService { ## Direct `CacheService` Injection -Inject `CacheService` directly into services and controllers: +Inject the `TRICACHE_SERVICE` token (the module does not bind the `CacheService` class itself): ```typescript -import { Injectable } from '@nestjs/common'; -import { CacheService } from 'tricache'; +import { Inject, Injectable } from '@nestjs/common'; +import type { CacheService } from 'tricache'; +import { TRICACHE_SERVICE } from 'tricache/nestjs'; @Injectable() export class OrderService { - constructor(private readonly cache: CacheService) {} + constructor( + @Inject(TRICACHE_SERVICE) private readonly cache: CacheService, + ) {} async processOrder(orderId: string) { return await this.cache.lock(`order:${orderId}`, async () => { @@ -123,3 +152,27 @@ export class OrderService { } } ``` + +## `@nestjs/cache-manager` store (`CACHE_MANAGER`) + +`TriCacheModule` also exports `CACHE_MANAGER` bound to `TriCacheStore`. That adapter implements the cache-manager v5/v6 `CacheStore` contract (`get` / `set` / `del` / `reset`, plus `mget` / `mset` / `mdel` / `keys` / `ttl`). `set(key, value, ttl)` and `ttl(key)` use **milliseconds**. + +```typescript +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_MANAGER, type TriCacheStore } from 'tricache/nestjs'; + +@Injectable() +export class SessionService { + constructor( + @Inject(CACHE_MANAGER) private readonly cacheStore: TriCacheStore, + ) {} + + async save(id: string, value: unknown) { + await this.cacheStore.set(`session:${id}`, value, 60_000); + } + + async load(id: string) { + return this.cacheStore.get(`session:${id}`); + } +} +``` diff --git a/examples/nestjs-microservice/.gitignore b/examples/nestjs-microservice/.gitignore new file mode 100644 index 0000000..63c72d4 --- /dev/null +++ b/examples/nestjs-microservice/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.pnpm-debug.log* +.DS_Store +*.tsbuildinfo diff --git a/examples/nestjs-microservice/README.md b/examples/nestjs-microservice/README.md new file mode 100644 index 0000000..c883d28 --- /dev/null +++ b/examples/nestjs-microservice/README.md @@ -0,0 +1,165 @@ +# TriCache NestJS Microservice Demo + +Minimal NestJS 11 TypeScript app that uses the official [`tricache/nestjs`](https://kareem411.github.io/TriCache/integrations/nestjs) entry: + +| Surface | What to look for | +|---|---| +| `TriCacheModule.register({ ... })` | Wired in `AppModule` with in-process L1 options (Redis optional) | +| `@Cacheable({ ttl: 120, tags: ['items'] })` | `GET /items` and `GET /items/:id` reuse `computedAt` + `originReads` | +| `@CacheEvict({ tags: ['items'] })` | `POST` / `PATCH` / `DELETE` invalidate the `items` tag | +| `CACHE_MANAGER` / `TriCacheStore` | `PUT`/`GET`/`DELETE /store/notes/:id` — Nest cache-manager store contract | + +The published decorator option is **`ttl` (seconds by default)**, plus optional `ttlUnit`. It is not `ttlSeconds` / `ttlSec`. Decorators resolve the engine from `this.cacheService` / `this.cache` / `this.cacheStore.cache`, so `ItemsService` injects `TRICACHE_SERVICE` onto `cacheService`. + +Origin work is a simulated **250ms** catalog read. Cache hits replay the stored JSON and skip that delay. + +Redis is not required. The demo uses an in-process L1 cache (`disableRedis: true`, `disableDisk: true`) unless `REDIS_HOST` is set. + +--- + +## Run locally + +From the **repository root**, build the local `tricache` package (the example links to `../..`): + +```bash +pnpm install +pnpm build +``` + +Then start the demo: + +```bash +cd examples/nestjs-microservice +pnpm install +pnpm dev +``` + +`pnpm start` is the same command. The process listens on `http://127.0.0.1:3000`. Override with `PORT` / `HOST` / `ORIGIN_LATENCY_MS`. Optional L2: `REDIS_HOST` / `REDIS_PORT`. + +If you installed `tricache` from npm instead of the repo link, `node --import tsx src/main.ts` (or `pnpm dev`) is enough — no root build step. + +--- + +## Try it with `curl` + +Keep the server running in another terminal. + +### 1. Cold miss — `@Cacheable` + +```bash +curl -s 'http://127.0.0.1:3000/items/1' +``` + +Expect `originReads: 1`, a `computedAt` timestamp, and ~250ms. The service method ran. + +### 2. Repeat GET — cache hit + +```bash +curl -s 'http://127.0.0.1:3000/items/1' +``` + +Expect the **same** `computedAt` and `originReads: 1`, and a much faster response. `@Cacheable` served `item:1` from TriCache. + +`GET /items` is a second key (`items:list`) with the same `items` tag. + +### 3. Mutation — `@CacheEvict({ tags: ['items'] })` + +```bash +curl -s -X PATCH 'http://127.0.0.1:3000/items/1' \ + -H 'content-type: application/json' \ + -d '{"name":"Ortho Keyboard","price":149}' +``` + +Expect `evictedTags: ["items"]`. + +### 4. GET after eviction — miss and refill + +```bash +curl -s 'http://127.0.0.1:3000/items/1' +``` + +Expect a **new** `computedAt`, `originReads: 2` (or higher if you also fetched the list), and `data.name: "Ortho Keyboard"`. + +`GET /items` also misses — tag invalidation drops every key tagged `items`. + +### 5. `@nestjs/cache-manager` store (`CACHE_MANAGER` → `TriCacheStore`) + +`set` / `ttl` use **milliseconds**, matching cache-manager v5/v6: + +```bash +curl -s 'http://127.0.0.1:3000/store/notes/n1' +# {"key":"note:n1","note":null,"cache":"miss"} + +curl -s -X PUT 'http://127.0.0.1:3000/store/notes/n1' \ + -H 'content-type: application/json' \ + -d '{"body":"session-token","ttlMs":60000}' + +curl -s 'http://127.0.0.1:3000/store/notes/n1' +# {"cache":"hit","note":{"id":"n1","body":"session-token",...},"ttlMs":...} + +curl -s -X DELETE 'http://127.0.0.1:3000/store/notes/n1' +curl -s 'http://127.0.0.1:3000/store/notes/n1' +# {"cache":"miss"} +``` + +`GET /store/keys` lists keys currently resident in `TriCacheStore`. + +--- + +## Automated check + +```bash +pnpm verify +``` + +Starts the server on port `34568` and asserts cacheable hit/miss, tag eviction, and the store read/write path. + +```bash +pnpm typecheck +``` + +--- + +## How the module is wired + +```typescript +import { Module } from '@nestjs/common'; +import { TriCacheModule } from 'tricache/nestjs'; + +@Module({ + imports: [ + TriCacheModule.register({ + namespace: 'nestjs-microservice-demo', + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, + }), + ], +}) +export class AppModule {} +``` + +`register()` accepts `CacheOptions` and calls `CacheService.create(options)`. The dynamic module is always `global: true` and exports: + +`tricache/nestjs` types a structural `DynamicModule` so `@nestjs/common` can stay an optional peer. The example casts that object to Nest's `DynamicModule` at the `AppModule` boundary — runtime shape is unchanged. + +- `TRICACHE_SERVICE` — `CacheService` instance +- `CACHE_MANAGER` — `TriCacheStore` (same token string as `@nestjs/cache-manager`) + +```typescript +@Inject(TRICACHE_SERVICE) readonly cacheService: CacheService +@Inject(CACHE_MANAGER) readonly cacheStore: TriCacheStore +``` + +```typescript +@Cacheable({ key: (id: string) => `item:${id}`, ttl: 120, ttlUnit: 'seconds', tags: ['items'] }) +async findOne(id: string) { /* origin read */ } + +@CacheEvict({ tags: ['items'] }) +async update(id: string, patch: ItemMutation) { /* mutation */ } + +await this.cacheStore.set('note:n1', note, 60_000); // milliseconds +await this.cacheStore.get('note:n1'); +``` + +`registerAsync({ useFactory, inject })` is available for `ConfigService`; this demo uses `register()` so it runs without extra Nest config modules. diff --git a/examples/nestjs-microservice/package.json b/examples/nestjs-microservice/package.json new file mode 100644 index 0000000..186a4a6 --- /dev/null +++ b/examples/nestjs-microservice/package.json @@ -0,0 +1,30 @@ +{ + "name": "nestjs-microservice", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "TriCache NestJS 11 demo: TriCacheModule.register, @Cacheable, @CacheEvict, and CACHE_MANAGER / TriCacheStore", + "scripts": { + "dev": "tsx src/main.ts", + "start": "tsx src/main.ts", + "typecheck": "tsc --noEmit", + "verify": "tsx src/verify.ts" + }, + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "tricache": "link:../.." + }, + "devDependencies": { + "@types/node": "^22.18.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=20.10.0" + }, + "packageManager": "pnpm@11.22.0" +} diff --git a/examples/nestjs-microservice/pnpm-lock.yaml b/examples/nestjs-microservice/pnpm-lock.yaml new file mode 100644 index 0000000..cf85eda --- /dev/null +++ b/examples/nestjs-microservice/pnpm-lock.yaml @@ -0,0 +1,1210 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@nestjs/common': + specifier: ^11.0.0 + version: 11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.2.5(@nestjs/common@11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.0.0 + version: 11.2.5(@nestjs/common@11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.5) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + tricache: + specifier: link:../.. + version: link:../.. + devDependencies: + '@types/node': + specifier: ^22.18.0 + version: 22.20.3 + tsx: + specifier: ^4.20.5 + version: 4.23.13 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@nestjs/common@11.2.5': + resolution: {integrity: sha512-x3LYEZnbGZMIeu9m60ws2+ZWzFAok9zGwjXZjcmdAeCLw6BYEjp9lreimuEpDIsLXK9bi6iYcJsYQGat9wq/rQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.2.5': + resolution: {integrity: sha512-ZgF8aitL7h8VPyVQ+vlUDrSVzbR7AojCv9Je9JqJVfHw3TtD3OOQ82RsCKrxfx09xpoveRr7UBHPA/d24bu2lA==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/platform-express@11.2.5': + resolution: {integrity: sha512-R3LSHqgPQo7ZTdYqA39lvdHS2RBnVJUePKIpL+cr1oB3HtYj9qPdhdmS307eLDc1VeTPFXAnSuVfcG9lMDEFSw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@types/node@22.20.3': + resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + '@borewit/text-codec@0.2.2': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@lukeed/csprng@1.1.0': {} + + '@nestjs/common@11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.2.5(@nestjs/common@11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.2.5(@nestjs/common@11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.5) + + '@nestjs/platform-express@11.2.5(@nestjs/common@11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.5)': + dependencies: + '@nestjs/common': 11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.5(@nestjs/common@11.2.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.2.0 + path-to-regexp: 8.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@types/node@22.20.3': + dependencies: + undici-types: 6.21.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + append-field@1.0.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-safe-stringify@2.1.1: {} + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + iterare@1.2.1: {} + + load-esm@1.0.3: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + reflect-metadata@0.2.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tslib@2.8.1: {} + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript@5.9.3: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} diff --git a/examples/nestjs-microservice/pnpm-workspace.yaml b/examples/nestjs-microservice/pnpm-workspace.yaml new file mode 100644 index 0000000..1b56dcb --- /dev/null +++ b/examples/nestjs-microservice/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: false + msgpackr-extract: false diff --git a/examples/nestjs-microservice/src/app.controller.ts b/examples/nestjs-microservice/src/app.controller.ts new file mode 100644 index 0000000..1456929 --- /dev/null +++ b/examples/nestjs-microservice/src/app.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + @Get() + index() { + return { + name: 'TriCache NestJS microservice demo', + docs: 'See README.md for curl walkthroughs', + routes: { + items: 'GET /items GET /items/:id POST /items PATCH /items/:id DELETE /items/:id', + store: 'PUT /store/notes/:id GET /store/notes/:id DELETE /store/notes/:id GET /store/keys', + health: 'GET /healthz', + }, + try: { + cacheable: 'GET /items/1 twice — identical computedAt / originReads on the second call', + evict: 'PATCH /items/1 then GET /items/1 — new computedAt (tag eviction)', + cacheManager: 'PUT /store/notes/n1 then GET /store/notes/n1 (cache: "hit")', + }, + }; + } + + @Get('healthz') + health() { + return { ok: true }; + } +} diff --git a/examples/nestjs-microservice/src/app.module.ts b/examples/nestjs-microservice/src/app.module.ts new file mode 100644 index 0000000..19b947c --- /dev/null +++ b/examples/nestjs-microservice/src/app.module.ts @@ -0,0 +1,24 @@ +import { Module, type DynamicModule } from '@nestjs/common'; +import { TriCacheModule } from 'tricache/nestjs'; +import { AppController } from './app.controller.js'; +import { createDemoCacheOptions } from './cache-options.js'; +import { ItemsController } from './items.controller.js'; +import { ItemsRepository } from './items.repository.js'; +import { ItemsService } from './items.service.js'; +import { NotesController } from './notes.controller.js'; +import { NotesService } from './notes.service.js'; + +/** + * `tricache/nestjs` types a structural DynamicModule so Nest stays an optional + * peer. The runtime object is a Nest dynamic module (`global: true`). + */ +function registerTriCache(): DynamicModule { + return TriCacheModule.register(createDemoCacheOptions()) as unknown as DynamicModule; +} + +@Module({ + imports: [registerTriCache()], + controllers: [AppController, ItemsController, NotesController], + providers: [ItemsRepository, ItemsService, NotesService], +}) +export class AppModule {} diff --git a/examples/nestjs-microservice/src/cache-options.ts b/examples/nestjs-microservice/src/cache-options.ts new file mode 100644 index 0000000..2676196 --- /dev/null +++ b/examples/nestjs-microservice/src/cache-options.ts @@ -0,0 +1,34 @@ +import type { CacheOptions } from 'tricache'; + +/** + * Local smoke-demo defaults: L1 only (no Redis, no disk). + * Set REDIS_HOST (and optional REDIS_PORT) to attach L2. + * + * `TriCacheModule.register()` forwards this object to `CacheService.create()`. + * There is no `preset` / `isGlobal` field on CacheOptions — the Nest module is + * already registered as `global: true`. + */ +export function createDemoCacheOptions(): CacheOptions { + const redisHost = process.env.REDIS_HOST; + return { + namespace: 'nestjs-microservice-demo', + disableRedis: !redisHost, + disableDisk: true, + invalidationBackplane: false, + ...(redisHost + ? { + redisHost, + redisPort: Number(process.env.REDIS_PORT ?? 6379), + } + : {}), + }; +} + +export function originLatencyMs(): number { + const n = Number(process.env.ORIGIN_LATENCY_MS); + return Number.isFinite(n) && n >= 0 ? n : 250; +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/examples/nestjs-microservice/src/catalog.ts b/examples/nestjs-microservice/src/catalog.ts new file mode 100644 index 0000000..4666403 --- /dev/null +++ b/examples/nestjs-microservice/src/catalog.ts @@ -0,0 +1,12 @@ +export interface Item { + id: string; + name: string; + price: number; +} + +/** Seed catalog used by the in-process repository (no database required). */ +export const INITIAL_ITEMS: readonly Item[] = [ + { id: '1', name: 'Mechanical Keyboard', price: 129 }, + { id: '2', name: 'Wireless Mouse', price: 79 }, + { id: '3', name: 'Studio Headphones', price: 249 }, +]; diff --git a/examples/nestjs-microservice/src/items.controller.ts b/examples/nestjs-microservice/src/items.controller.ts new file mode 100644 index 0000000..daf5aae --- /dev/null +++ b/examples/nestjs-microservice/src/items.controller.ts @@ -0,0 +1,64 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + Inject, + Param, + Patch, + Post, +} from '@nestjs/common'; +import { ItemsService } from './items.service.js'; +import type { ItemMutation } from './items.repository.js'; + +@Controller('items') +export class ItemsController { + constructor(@Inject(ItemsService) private readonly items: ItemsService) {} + + @Get() + async list() { + const payload = await this.items.list(); + return { + ...payload, + note: 'computedAt / originReads stay frozen on a @Cacheable hit. PATCH/POST/DELETE evict tag "items".', + }; + } + + @Get(':id') + async findOne(@Param('id') id: string) { + const payload = await this.items.findOne(id); + return { + ...payload, + note: 'Same computedAt + originReads on a later GET means a cache hit. A mutation evicts this key via tags.', + }; + } + + @Post() + async create(@Body() body: { name?: string; price?: number }) { + const name = typeof body?.name === 'string' ? body.name.trim() : ''; + const price = Number(body?.price); + if (!name || !Number.isFinite(price)) { + throw new BadRequestException('name (string) and price (number) are required'); + } + const item = await this.items.create({ name, price }); + return { item, evictedTags: ['items'] }; + } + + @Patch(':id') + async update(@Param('id') id: string, @Body() body: ItemMutation) { + const patch: ItemMutation = {}; + if (typeof body?.name === 'string') patch.name = body.name; + if (body?.price !== undefined) patch.price = Number(body.price); + const item = await this.items.update(id, patch); + return { item, evictedTags: ['items'] }; + } + + @Delete(':id') + @HttpCode(200) + async remove(@Param('id') id: string) { + const item = await this.items.remove(id); + return { item, evictedTags: ['items'] }; + } +} diff --git a/examples/nestjs-microservice/src/items.repository.ts b/examples/nestjs-microservice/src/items.repository.ts new file mode 100644 index 0000000..0586774 --- /dev/null +++ b/examples/nestjs-microservice/src/items.repository.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { INITIAL_ITEMS, type Item } from './catalog.js'; + +export interface ItemMutation { + name?: string; + price?: number; +} + +@Injectable() +export class ItemsRepository { + private readonly items = new Map( + INITIAL_ITEMS.map((item) => [item.id, { ...item }]), + ); + private nextId = INITIAL_ITEMS.length + 1; + + /** Incremented only on origin reads (list / findById). Cached payloads snapshot this. */ + originReads = 0; + + findAll(): Item[] { + this.originReads += 1; + return [...this.items.values()].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true })); + } + + findById(id: string): Item | undefined { + this.originReads += 1; + const item = this.items.get(id); + return item ? { ...item } : undefined; + } + + create(input: { name: string; price: number }): Item { + const item: Item = { + id: String(this.nextId++), + name: input.name, + price: input.price, + }; + this.items.set(item.id, item); + return { ...item }; + } + + update(id: string, patch: ItemMutation): Item | undefined { + const current = this.items.get(id); + if (!current) return undefined; + const next: Item = { + ...current, + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.price !== undefined ? { price: patch.price } : {}), + }; + this.items.set(id, next); + return { ...next }; + } + + remove(id: string): Item | undefined { + const current = this.items.get(id); + if (!current) return undefined; + this.items.delete(id); + return { ...current }; + } +} diff --git a/examples/nestjs-microservice/src/items.service.ts b/examples/nestjs-microservice/src/items.service.ts new file mode 100644 index 0000000..a37c9e0 --- /dev/null +++ b/examples/nestjs-microservice/src/items.service.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import type { CacheService } from 'tricache'; +import { Cacheable, CacheEvict, TRICACHE_SERVICE } from 'tricache/nestjs'; +import { originLatencyMs, sleep } from './cache-options.js'; +import { ItemsRepository, type ItemMutation } from './items.repository.js'; +import type { Item } from './catalog.js'; + +export interface CachedPayload { + data: T; + computedAt: string; + originReads: number; + originLatencyMs: number; +} + +/** + * Decorators resolve the engine via `this.cacheService` | `this.cache` | + * `this.cacheStore.cache`. The injected property name must be one of those. + */ +@Injectable() +export class ItemsService { + constructor( + @Inject(TRICACHE_SERVICE) readonly cacheService: CacheService, + @Inject(ItemsRepository) private readonly repo: ItemsRepository, + ) {} + + @Cacheable({ + key: () => 'items:list', + ttl: 120, + ttlUnit: 'seconds', + tags: ['items'], + }) + async list(): Promise> { + const started = Date.now(); + await sleep(originLatencyMs()); + return { + data: this.repo.findAll(), + computedAt: new Date().toISOString(), + originReads: this.repo.originReads, + originLatencyMs: Date.now() - started, + }; + } + + @Cacheable({ + key: (id: string) => `item:${id}`, + ttl: 120, + ttlUnit: 'seconds', + tags: ['items'], + }) + async findOne(id: string): Promise> { + const started = Date.now(); + await sleep(originLatencyMs()); + const item = this.repo.findById(id); + if (!item) { + throw new NotFoundException(`item ${id} not found`); + } + return { + data: item, + computedAt: new Date().toISOString(), + originReads: this.repo.originReads, + originLatencyMs: Date.now() - started, + }; + } + + @CacheEvict({ tags: ['items'] }) + async create(input: { name: string; price: number }): Promise { + return this.repo.create(input); + } + + @CacheEvict({ tags: ['items'] }) + async update(id: string, patch: ItemMutation): Promise { + const item = this.repo.update(id, patch); + if (!item) { + throw new NotFoundException(`item ${id} not found`); + } + return item; + } + + @CacheEvict({ tags: ['items'] }) + async remove(id: string): Promise { + const item = this.repo.remove(id); + if (!item) { + throw new NotFoundException(`item ${id} not found`); + } + return item; + } +} diff --git a/examples/nestjs-microservice/src/main.ts b/examples/nestjs-microservice/src/main.ts new file mode 100644 index 0000000..adf96fa --- /dev/null +++ b/examples/nestjs-microservice/src/main.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import type { CacheService } from 'tricache'; +import { TRICACHE_SERVICE } from 'tricache/nestjs'; +import { AppModule } from './app.module.js'; + +const PORT = Number(process.env.PORT) || 3000; +const HOST = process.env.HOST ?? '127.0.0.1'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + await app.listen(PORT, HOST); + console.log(`TriCache NestJS demo listening on http://${HOST}:${PORT}`); + + const cache = app.get(TRICACHE_SERVICE); + + const shutdown = async (signal: string): Promise => { + console.log(`\n${signal} received, shutting down`); + await app.close().catch(() => {}); + await cache.destroy().catch(() => {}); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); +} + +void bootstrap(); diff --git a/examples/nestjs-microservice/src/notes.controller.ts b/examples/nestjs-microservice/src/notes.controller.ts new file mode 100644 index 0000000..699d225 --- /dev/null +++ b/examples/nestjs-microservice/src/notes.controller.ts @@ -0,0 +1,36 @@ +import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Put, Query } from '@nestjs/common'; +import { NotesService } from './notes.service.js'; + +@Controller('store') +export class NotesController { + constructor(@Inject(NotesService) private readonly notes: NotesService) {} + + @Get('keys') + listKeys() { + return this.notes.listKeys(); + } + + @Get('notes/:id') + read(@Param('id') id: string) { + return this.notes.read(id); + } + + @Put('notes/:id') + write( + @Param('id') id: string, + @Body() body: { body?: string; ttlMs?: number }, + @Query('ttlMs') ttlQuery?: string, + ) { + const text = typeof body?.body === 'string' ? body.body : ''; + if (!text) { + throw new BadRequestException('JSON body { "body": "..." } is required'); + } + const ttlMs = Number(body?.ttlMs ?? ttlQuery ?? 60_000); + return this.notes.write(id, text, Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : 60_000); + } + + @Delete('notes/:id') + erase(@Param('id') id: string) { + return this.notes.erase(id); + } +} diff --git a/examples/nestjs-microservice/src/notes.service.ts b/examples/nestjs-microservice/src/notes.service.ts new file mode 100644 index 0000000..0c9ac52 --- /dev/null +++ b/examples/nestjs-microservice/src/notes.service.ts @@ -0,0 +1,55 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_MANAGER, type TriCacheStore } from 'tricache/nestjs'; + +export interface Note { + id: string; + body: string; + writtenAt: string; +} + +/** + * Official @nestjs/cache-manager store path. + * + * `TriCacheModule` exports `CACHE_MANAGER` (token string `'CACHE_MANAGER'`) + * bound to `TriCacheStore`. That class is the cache-manager v5/v6 CacheStore: + * `get` / `set` / `del` / `reset` / `mget` / `mset` / `mdel` / `keys` / `ttl`. + * `set(key, value, ttl)` takes **milliseconds**, matching Nest's cache-manager. + */ +@Injectable() +export class NotesService { + constructor( + @Inject(CACHE_MANAGER) readonly cacheStore: TriCacheStore, + ) {} + + async read(id: string) { + const key = noteKey(id); + const note = await this.cacheStore.get(key); + if (!note) { + return { key, note: null, cache: 'miss' as const }; + } + const ttlMs = await this.cacheStore.ttl(key); + return { key, note, cache: 'hit' as const, ttlMs }; + } + + async write(id: string, body: string, ttlMs = 60_000) { + const key = noteKey(id); + const note: Note = { id, body, writtenAt: new Date().toISOString() }; + await this.cacheStore.set(key, note, ttlMs); + return { key, note, cache: 'stored' as const, ttlMs }; + } + + async erase(id: string) { + const key = noteKey(id); + await this.cacheStore.del(key); + return { key, cache: 'deleted' as const }; + } + + async listKeys() { + const keys = await this.cacheStore.keys('note:'); + return { keys, store: 'TriCacheStore' }; + } +} + +function noteKey(id: string): string { + return `note:${id}`; +} diff --git a/examples/nestjs-microservice/src/verify.ts b/examples/nestjs-microservice/src/verify.ts new file mode 100644 index 0000000..2c2bb1e --- /dev/null +++ b/examples/nestjs-microservice/src/verify.ts @@ -0,0 +1,220 @@ +/** + * Smoke-checks the demo the same way the README curl walkthrough does: + * @Cacheable hit vs miss, @CacheEvict tag invalidation, CACHE_MANAGER / TriCacheStore. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import http from 'node:http'; +import { setTimeout as delay } from 'node:timers/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const port = Number(process.env.VERIFY_PORT) || 34568; +const host = '127.0.0.1'; + +interface Probe { + status: number; + headers: Record; + body: string; + json: Record | null; + ms: number; +} + +function request( + method: string, + urlPath: string, + opts: { headers?: Record; json?: unknown } = {}, +): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const payload = opts.json !== undefined ? Buffer.from(JSON.stringify(opts.json)) : undefined; + const req = http.request( + { + host, + port, + method, + path: urlPath, + headers: { + ...(payload ? { 'content-type': 'application/json', 'content-length': String(payload.length) } : {}), + ...opts.headers, + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => { + chunks.push(chunk); + }); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + let json: Record | null = null; + if (body) { + try { + json = JSON.parse(body) as Record; + } catch { + json = null; + } + } + const normalized: Record = {}; + for (const [key, value] of Object.entries(res.headers)) { + if (typeof value === 'string') normalized[key.toLowerCase()] = value; + else if (Array.isArray(value)) normalized[key.toLowerCase()] = value.join(', '); + } + resolve({ + status: res.statusCode ?? 0, + headers: normalized, + body, + json, + ms: Date.now() - started, + }); + }); + }, + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function waitForHealth(timeoutMs = 25_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await request('GET', '/healthz'); + if (res.status === 200) return; + lastError = new Error(`healthz ${res.status}`); + } catch (err) { + lastError = err; + } + await delay(100); + } + throw new Error(`server did not become healthy: ${String(lastError)}`); +} + +async function main(): Promise { + const child: ChildProcess = spawn( + process.execPath, + ['--import', 'tsx', path.join(root, 'main.ts')], + { + cwd: path.join(root, '..'), + env: { + ...process.env, + PORT: String(port), + HOST: host, + ORIGIN_LATENCY_MS: process.env.ORIGIN_LATENCY_MS ?? '200', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + child.stdout?.on('data', (chunk: Buffer) => { + process.stdout.write(chunk); + }); + child.stderr?.on('data', (chunk: Buffer) => { + process.stderr.write(chunk); + }); + + const exitError = new Promise((_, reject) => { + child.on('exit', (code) => { + reject(new Error(`demo server exited early with code ${code}`)); + }); + child.on('error', reject); + }); + + try { + await Promise.race([waitForHealth(), exitError]); + + const miss = await request('GET', '/items/1'); + assert(miss.status === 200, `cold GET /items/1 expected 200, got ${miss.status} ${miss.body}`); + assert(typeof miss.json?.computedAt === 'string', 'cold GET missing computedAt'); + assert(miss.json?.originReads === 1, `cold GET expected originReads=1, got ${String(miss.json?.originReads)}`); + const computedAt = miss.json?.computedAt as string; + const item = miss.json?.data as { id: string; name: string } | undefined; + assert(item?.id === '1', `expected item 1, got ${JSON.stringify(item)}`); + console.log(`1. @Cacheable miss ${miss.status} originReads=${String(miss.json?.originReads)} ${miss.ms}ms`); + + const hit = await request('GET', '/items/1'); + assert(hit.status === 200, `repeat GET /items/1 expected 200, got ${hit.status}`); + assert(hit.json?.computedAt === computedAt, 'cache hit must reuse computedAt'); + assert(hit.json?.originReads === 1, `cache hit must reuse originReads, got ${String(hit.json?.originReads)}`); + assert(hit.ms < miss.ms, `cache hit should be faster than miss (${hit.ms}ms vs ${miss.ms}ms)`); + console.log(`2. @Cacheable hit ${hit.status} same computedAt + originReads ${hit.ms}ms`); + + const listMiss = await request('GET', '/items'); + assert(listMiss.status === 200, `GET /items expected 200, got ${listMiss.status}`); + assert(listMiss.json?.originReads === 2, `list miss expected originReads=2, got ${String(listMiss.json?.originReads)}`); + const listAt = listMiss.json?.computedAt as string; + const listHit = await request('GET', '/items'); + assert(listHit.json?.computedAt === listAt, 'list cache hit must reuse computedAt'); + assert(listHit.json?.originReads === 2, 'list cache hit must reuse originReads'); + console.log(`3. list hit ${listHit.status} originReads=2 ${listHit.ms}ms`); + + const patch = await request('PATCH', '/items/1', { + json: { name: 'Ortho Keyboard', price: 149 }, + }); + assert(patch.status === 200, `PATCH expected 200, got ${patch.status} ${patch.body}`); + const patched = patch.json?.item as { name: string } | undefined; + assert(patched?.name === 'Ortho Keyboard', `PATCH should rename item, got ${JSON.stringify(patched)}`); + assert(JSON.stringify(patch.json?.evictedTags) === JSON.stringify(['items']), 'PATCH should evict tag items'); + console.log(`4. @CacheEvict ${patch.status} tags=['items']`); + + const refill = await request('GET', '/items/1'); + assert(refill.status === 200, `post-evict GET expected 200, got ${refill.status}`); + assert(refill.json?.computedAt !== computedAt, 'eviction must recompute computedAt'); + assert(refill.json?.originReads === 3, `post-evict GET expected originReads=3, got ${String(refill.json?.originReads)}`); + const refilled = refill.json?.data as { name: string } | undefined; + assert(refilled?.name === 'Ortho Keyboard', `refill should see mutation, got ${JSON.stringify(refilled)}`); + console.log(`5. refill after evict ${refill.status} originReads=3 ${refill.ms}ms`); + + const listRefill = await request('GET', '/items'); + assert(listRefill.json?.computedAt !== listAt, 'tag eviction must also miss the list key'); + assert(listRefill.json?.originReads === 4, `list refill expected originReads=4, got ${String(listRefill.json?.originReads)}`); + console.log(`6. list refill ${listRefill.status} originReads=4 ${listRefill.ms}ms`); + + const empty = await request('GET', '/store/notes/n1'); + assert(empty.status === 200, `GET note miss expected 200, got ${empty.status}`); + assert(empty.json?.cache === 'miss', `expected cache=miss, got ${String(empty.json?.cache)}`); + + const stored = await request('PUT', '/store/notes/n1', { + json: { body: 'session-token', ttlMs: 60_000 }, + }); + assert(stored.status === 200, `PUT note expected 200, got ${stored.status} ${stored.body}`); + assert(stored.json?.cache === 'stored', `expected cache=stored, got ${String(stored.json?.cache)}`); + assert(stored.json?.ttlMs === 60_000, `expected ttlMs=60000, got ${String(stored.json?.ttlMs)}`); + + const storeHit = await request('GET', '/store/notes/n1'); + assert(storeHit.json?.cache === 'hit', `expected cache=hit, got ${String(storeHit.json?.cache)}`); + const note = storeHit.json?.note as { body: string } | undefined; + assert(note?.body === 'session-token', `store hit body mismatch: ${JSON.stringify(note)}`); + assert(typeof storeHit.json?.ttlMs === 'number', 'store hit should report remaining ttlMs'); + + const keys = await request('GET', '/store/keys'); + const keyList = keys.json?.keys as string[] | undefined; + assert(Array.isArray(keyList) && keyList.includes('note:n1'), `expected note:n1 in keys, got ${JSON.stringify(keyList)}`); + + const deleted = await request('DELETE', '/store/notes/n1'); + assert(deleted.json?.cache === 'deleted', `expected cache=deleted, got ${String(deleted.json?.cache)}`); + const afterDel = await request('GET', '/store/notes/n1'); + assert(afterDel.json?.cache === 'miss', 'DELETE via TriCacheStore.del should miss'); + console.log('7. CACHE_MANAGER miss → set → hit → del → miss'); + + console.log('\nAll NestJS demo checks passed.'); + } finally { + child.kill('SIGTERM'); + await delay(400); + if (child.exitCode === null && child.killed === false) { + child.kill('SIGKILL'); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/nestjs-microservice/tsconfig.json b/examples/nestjs-microservice/tsconfig.json new file mode 100644 index 0000000..e3c4df7 --- /dev/null +++ b/examples/nestjs-microservice/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noEmit": true, + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tests/nestjs-microservice-example.test.ts b/tests/nestjs-microservice-example.test.ts new file mode 100644 index 0000000..b166a66 --- /dev/null +++ b/tests/nestjs-microservice-example.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { Cacheable, CacheEvict } from '../src/nestjs/decorators.js'; +import { TriCacheStore } from '../src/nestjs/tricache.store.js'; +import { INITIAL_ITEMS } from '../examples/nestjs-microservice/src/catalog.js'; +import { ItemsRepository } from '../examples/nestjs-microservice/src/items.repository.js'; + +function applyDecorator(target: any, propertyKey: string, decorator: MethodDecorator): void { + const desc = Object.getOwnPropertyDescriptor(target.prototype, propertyKey)!; + const newDesc = decorator(target.prototype, propertyKey, desc) || desc; + Object.defineProperty(target.prototype, propertyKey, newDesc); +} + +describe('NestJS microservice reference example (examples/nestjs-microservice)', () => { + let cache: CacheService; + + beforeEach(() => { + cache = CacheService.create({ + namespace: `nest_demo_test_${Date.now()}_${Math.random().toString(16).slice(2)}`, + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, + }); + }); + + afterEach(async () => { + await cache.destroy(); + }); + + describe('in-memory catalog repository', () => { + it('seeds the demo catalog and supports CRUD', () => { + const repo = new ItemsRepository(); + expect(repo.findAll().map((item) => item.id)).toEqual(INITIAL_ITEMS.map((item) => item.id)); + expect(repo.originReads).toBe(1); + + const created = repo.create({ name: 'USB Hub', price: 89 }); + expect(created.id).toBe('4'); + expect(repo.update('4', { price: 99 })?.price).toBe(99); + expect(repo.remove('4')?.name).toBe('USB Hub'); + expect(repo.findById('4')).toBeUndefined(); + }); + }); + + describe('@Cacheable / @CacheEvict contract used by ItemsService', () => { + it('caches reads and evicts the items tag on mutation', async () => { + const repo = new ItemsRepository(); + + class DemoItemsService { + cacheService = cache; + + async findOne(id: string) { + const item = repo.findById(id); + return { item, originReads: repo.originReads, computedAt: Date.now() }; + } + + async list() { + return { items: repo.findAll(), originReads: repo.originReads, computedAt: Date.now() }; + } + + async update(id: string, name: string) { + return repo.update(id, { name }); + } + } + + applyDecorator( + DemoItemsService, + 'findOne', + Cacheable({ key: (id: string) => `item:${id}`, ttl: 120, ttlUnit: 'seconds', tags: ['items'] }), + ); + applyDecorator( + DemoItemsService, + 'list', + Cacheable({ key: () => 'items:list', ttl: 120, tags: ['items'] }), + ); + applyDecorator( + DemoItemsService, + 'update', + CacheEvict({ tags: ['items'] }), + ); + + const service = new DemoItemsService(); + + const miss = await service.findOne('1'); + expect(miss.item?.name).toBe('Mechanical Keyboard'); + expect(repo.originReads).toBe(1); + + const hit = await service.findOne('1'); + expect(hit.computedAt).toBe(miss.computedAt); + expect(repo.originReads).toBe(1); + + const listMiss = await service.list(); + expect(listMiss.items).toHaveLength(INITIAL_ITEMS.length); + expect(repo.originReads).toBe(2); + const listHit = await service.list(); + expect(listHit.computedAt).toBe(listMiss.computedAt); + expect(repo.originReads).toBe(2); + + await service.update('1', 'Ortho Keyboard'); + const refill = await service.findOne('1'); + expect(refill.item?.name).toBe('Ortho Keyboard'); + expect(refill.computedAt).not.toBe(miss.computedAt); + expect(repo.originReads).toBe(3); + + const listRefill = await service.list(); + expect(listRefill.computedAt).not.toBe(listMiss.computedAt); + expect(repo.originReads).toBe(4); + }); + }); + + describe('CACHE_MANAGER / TriCacheStore path used by NotesService', () => { + it('implements cache-manager get/set/del/keys/ttl with millisecond TTL', async () => { + const store = new TriCacheStore(cache); + + expect(await store.get('note:n1')).toBeUndefined(); + + const note = { id: 'n1', body: 'session-token', writtenAt: '2026-01-01T00:00:00.000Z' }; + await store.set('note:n1', note, 60_000); + + expect(await store.get('note:n1')).toEqual(note); + const remaining = await store.ttl('note:n1'); + expect(remaining).toBeGreaterThan(50_000); + expect(remaining).toBeLessThanOrEqual(60_000); + expect(await store.keys('note:')).toContain('note:n1'); + + await store.del('note:n1'); + expect(await store.get('note:n1')).toBeUndefined(); + }); + }); +});