From 96338559a9d376df6fd113c6015b3930da1a967f Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 18 Sep 2026 19:18:43 +0300 Subject: [PATCH 01/11] fix(cli): improve Windows named pipe discovery and top error handling --- src/cli.ts | 15 ++++++++++++--- src/ipc-telemetry.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index db0888c..6043982 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -73,11 +73,20 @@ export async function runCli(args: string[] = process.argv.slice(2)): Promise 0) { targetSocket = active[0]; - } else { - targetSocket = resolveIpcSocketPath(process.pid); } } + if (!targetSocket) { + console.error('No active TriCache instances found to monitor.\n'); + console.error('To use `tricache top`:'); + console.error(' 1. Ensure your running application enables IPC telemetry:'); + console.error(" const cache = new CacheService({ namespace: 'my-app', enableIpc: true, ... });\n"); + console.error(' 2. If the application is already running, specify its PID or socket:'); + console.error(' npx tricache top --pid '); + console.error(' npx tricache top --socket \n'); + process.exit(1); + } + const client = new IpcTelemetryClient(targetSocket); const intervalMs = typeof values.interval === 'string' ? Math.max(200, parseInt(values.interval, 10)) @@ -148,7 +157,7 @@ export async function runCli(args: string[] = process.argv.slice(2)): Promise { await client.ping(100); active.push(currentPipe); } catch { - // Pipe not present or not active + // Pipe not present or not active on current PID } + + try { + const { execFileSync } = await import('node:child_process'); + const stdout = execFileSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', '[System.IO.Directory]::GetFiles("\\\\.\\pipe\\") | Where-Object { $_ -like "*tricache-*" }'], + { timeout: 1500, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const candidates = stdout + .split(/\r?\n/) + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !active.includes(name)); + + await Promise.all( + candidates.map(async (pipePath) => { + const testClient = new IpcTelemetryClient(pipePath); + try { + await testClient.ping(100); + active.push(pipePath); + } catch { + // Stale or non-responsive pipe + } + }), + ); + } catch { + // Ignore enumeration errors (e.g. timeout or restricted environment) + } + return active; } From 1c8ead68882b25269c2145fea673f215d1b2e8e1 Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 16:14:13 +0300 Subject: [PATCH 02/11] feat(v0.9.0): add Hono Node middleware, ecosystem reference demos, edge hardening, and docs --- CHANGELOG.md | 34 + README.md | 5 +- docs/.vitepress/config.ts | 3 +- .../theme/components/IntegrationGrid.vue | 2 +- docs/api-reference.md | 13 +- docs/changelog.md | 34 + docs/integrations/drizzle.md | 20 +- docs/integrations/edge.md | 50 +- docs/integrations/hono.md | 76 + docs/integrations/http.md | 22 +- docs/integrations/index.md | 3 +- docs/integrations/nestjs.md | 87 +- examples/drizzle-orm/.gitignore | 10 + examples/drizzle-orm/README.md | 131 ++ examples/drizzle-orm/package.json | 30 + examples/drizzle-orm/pnpm-lock.yaml | 716 ++++++++++ examples/drizzle-orm/pnpm-workspace.yaml | 4 + examples/drizzle-orm/src/cache.ts | 34 + examples/drizzle-orm/src/config.ts | 28 + examples/drizzle-orm/src/db.ts | 43 + examples/drizzle-orm/src/demo.ts | 133 ++ examples/drizzle-orm/src/observe.ts | 19 + examples/drizzle-orm/src/queries.ts | 57 + examples/drizzle-orm/src/schema.ts | 11 + examples/drizzle-orm/src/seed-data.ts | 16 + examples/drizzle-orm/src/seed.ts | 41 + examples/drizzle-orm/src/time.ts | 3 + examples/drizzle-orm/src/verify.ts | 105 ++ examples/drizzle-orm/tsconfig.json | 16 + examples/edge-hono/.gitignore | 9 + examples/edge-hono/README.md | 176 +++ examples/edge-hono/package.json | 29 + examples/edge-hono/pnpm-lock.yaml | 1225 +++++++++++++++++ examples/edge-hono/pnpm-workspace.yaml | 4 + examples/edge-hono/scripts/verify.ts | 223 +++ examples/edge-hono/src/catalog.ts | 185 +++ examples/edge-hono/src/index.ts | 149 ++ examples/edge-hono/src/memory-remote.ts | 51 + examples/edge-hono/tsconfig.json | 15 + examples/edge-hono/wrangler.jsonc | 11 + examples/fastify-api/.gitignore | 6 + examples/fastify-api/README.md | 176 +++ examples/fastify-api/package.json | 26 + examples/fastify-api/pnpm-lock.yaml | 682 +++++++++ examples/fastify-api/pnpm-workspace.yaml | 3 + examples/fastify-api/src/catalog.ts | 185 +++ examples/fastify-api/src/server.ts | 179 +++ examples/fastify-api/src/verify.ts | 251 ++++ examples/fastify-api/tsconfig.json | 16 + 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 + package.json | 15 +- pnpm-lock.yaml | 236 ++-- src/edge/hono.ts | 20 +- src/hono/index.ts | 257 ++++ src/http/index.ts | 5 + tests/drizzle-orm-example.test.ts | 81 ++ tests/edge-hono.test.ts | 19 + tests/fastify-api-example.test.ts | 223 +++ tests/hono.test.ts | 197 +++ tests/nestjs-microservice-example.test.ts | 129 ++ 76 files changed, 8445 insertions(+), 167 deletions(-) create mode 100644 docs/integrations/hono.md create mode 100644 examples/drizzle-orm/.gitignore create mode 100644 examples/drizzle-orm/README.md create mode 100644 examples/drizzle-orm/package.json create mode 100644 examples/drizzle-orm/pnpm-lock.yaml create mode 100644 examples/drizzle-orm/pnpm-workspace.yaml create mode 100644 examples/drizzle-orm/src/cache.ts create mode 100644 examples/drizzle-orm/src/config.ts create mode 100644 examples/drizzle-orm/src/db.ts create mode 100644 examples/drizzle-orm/src/demo.ts create mode 100644 examples/drizzle-orm/src/observe.ts create mode 100644 examples/drizzle-orm/src/queries.ts create mode 100644 examples/drizzle-orm/src/schema.ts create mode 100644 examples/drizzle-orm/src/seed-data.ts create mode 100644 examples/drizzle-orm/src/seed.ts create mode 100644 examples/drizzle-orm/src/time.ts create mode 100644 examples/drizzle-orm/src/verify.ts create mode 100644 examples/drizzle-orm/tsconfig.json create mode 100644 examples/edge-hono/.gitignore create mode 100644 examples/edge-hono/README.md create mode 100644 examples/edge-hono/package.json create mode 100644 examples/edge-hono/pnpm-lock.yaml create mode 100644 examples/edge-hono/pnpm-workspace.yaml create mode 100644 examples/edge-hono/scripts/verify.ts create mode 100644 examples/edge-hono/src/catalog.ts create mode 100644 examples/edge-hono/src/index.ts create mode 100644 examples/edge-hono/src/memory-remote.ts create mode 100644 examples/edge-hono/tsconfig.json create mode 100644 examples/edge-hono/wrangler.jsonc create mode 100644 examples/fastify-api/.gitignore create mode 100644 examples/fastify-api/README.md create mode 100644 examples/fastify-api/package.json create mode 100644 examples/fastify-api/pnpm-lock.yaml create mode 100644 examples/fastify-api/pnpm-workspace.yaml create mode 100644 examples/fastify-api/src/catalog.ts create mode 100644 examples/fastify-api/src/server.ts create mode 100644 examples/fastify-api/src/verify.ts create mode 100644 examples/fastify-api/tsconfig.json 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 src/hono/index.ts create mode 100644 tests/drizzle-orm-example.test.ts create mode 100644 tests/fastify-api-example.test.ts create mode 100644 tests/hono.test.ts create mode 100644 tests/nestjs-microservice-example.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd97cd4..83b8527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] — 2026-10-02 + +### Added +- **First-Class Node.js Hono Middleware Adapter (`src/hono/index.ts`, `tricache/hono`)** ([#9](https://github.com/Kareem411/TriCache/issues/9), [#45](https://github.com/Kareem411/TriCache/pull/45)): + - Dedicated package entry `tricache/hono` exporting `cacheMiddleware` and `createHonoMiddleware` for Node.js runtimes. + - Backed directly by Node `CacheService` with full three-tier caching (L1 RAM → L1.5 NVMe `/dev/shm` → L2 Redis), SWR background revalidation, and generational tag invalidation. + - Weak ETag generation (`W/"..."`), RFC 7232 `If-None-Match` conditional 304 Not Modified responses, deterministic query sorting, header whitelisting, and non-2xx status response gating. + - Comprehensive unit test coverage in `tests/hono.test.ts`. +- **Drizzle ORM Query Caching Reference Example (`examples/drizzle-orm/`)** ([#28](https://github.com/Kareem411/TriCache/issues/28), [#37](https://github.com/Kareem411/TriCache/pull/37)): + - Full runnable SQLite + Drizzle reference demonstration showcasing `withCache` query extension. + - Demonstrates deterministic SQL query text + bind parameters fingerprinting (`generateDrizzleCacheKey`), background SWR revalidation (`swr: 60`), and tag invalidation across table mutations (`cache.invalidateTag('users')`). + - Automated verification test suite in `tests/drizzle-orm-example.test.ts`. +- **Fastify REST API Reference Example (`examples/fastify-api/`)** ([#26](https://github.com/Kareem411/TriCache/issues/26), [#38](https://github.com/Kareem411/TriCache/pull/38)): + - Complete runnable Fastify REST API demo illustrating `createFastifyPlugin` (global application hook) and `fastifyCache` (route-level `preHandler` hook). + - Demonstrates `onRequest` short-circuiting, `onSend` response payload capture, weak ETags, and 304 conditional responses. + - Automated verification test suite in `tests/fastify-api-example.test.ts`. +- **Hono & Cloudflare Workers Edge Caching Demo (`examples/edge-hono/`)** ([#27](https://github.com/Kareem411/TriCache/issues/27), [#39](https://github.com/Kareem411/TriCache/pull/39)): + - Standalone Cloudflare Workers + Wrangler reference architecture demonstrating `tricache/edge`. + - Pure Web Standards execution (`Request`, `Response`, `crypto.subtle`) with zero Node native dependencies. + - In-memory `Murmur3BloomFilter` defense against cold miss penetration. +- **NestJS 11 Microservice Reference Example (`examples/nestjs-microservice/`)** ([#29](https://github.com/Kareem411/TriCache/issues/29), [#40](https://github.com/Kareem411/TriCache/pull/40)): + - Runnable NestJS 11 TypeScript service demonstrating `TriCacheModule.register()`. + - Method-level `@Cacheable({ ttl, tags })` and `@CacheEvict({ tags })` decorators, plus `@nestjs/cache-manager` v5/v6 store compatibility via `TriCacheStore`. + - Automated verification test suite in `tests/nestjs-microservice-example.test.ts`. +- **Prometheus Observability Recipe & Express API Demo** ([#30](https://github.com/Kareem411/TriCache/issues/30), [#31](https://github.com/Kareem411/TriCache/pull/31), [#25](https://github.com/Kareem411/TriCache/issues/25), [#35](https://github.com/Kareem411/TriCache/pull/35)): + - Complete Prometheus `/metrics` scraping recipe with Grafana dashboard configuration (`docs/recipes/prometheus-metrics.md`). + - Production Express microservice demo in `examples/express-api/` with weak ETags and 304 validation. + +### Fixed +- **Hono Edge Response Assignment (`src/edge/hono.ts`)** ([#39](https://github.com/Kareem411/TriCache/pull/39)): + - Fixed an issue where Hono's `compose` ignored middleware return values once `next()` sets `c.res`. Explicitly assigns `c.res = response` via `applyEdgeResponse` so weak ETags and 304s are preserved on both cache misses and hits. +- **Windows Named Pipe Discovery & CLI Top Error Handling (`src/cli.ts`, `src/ipc-telemetry.ts`)**: + - Improved Windows named pipe path resolution (`\\.\pipe\tricache-`) and graceful error handling during `tricache top` monitoring. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/README.md b/README.md index 334b5b7..22342c8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Docs](https://img.shields.io/badge/docs-VitePress-blue.svg)](https://kareem411.github.io/TriCache/) [![npm version](https://img.shields.io/npm/v/tricache.svg)](https://www.npmjs.com/package/tricache) [![npm downloads](https://img.shields.io/npm/dm/tricache.svg)](https://www.npmjs.com/package/tricache) -[![Tests](https://img.shields.io/badge/tests-816%20passing-brightgreen)](tests) +[![Tests](https://img.shields.io/badge/tests-834%20passing-brightgreen)](tests) [![Code Quality](https://img.shields.io/badge/oxlint-0%20warnings-brightgreen)](src) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js ≥ 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) @@ -122,7 +122,8 @@ const cache = CacheService.preset('enterprise-hardened', { redisHost: 'redis.int | **Prisma ORM** | `tricache/prisma` | `$extends` client extension with query hashing and auto-mutation tag eviction. | | **Drizzle ORM** | `tricache/drizzle` | `withCache(query, opts)` query wrapper with SQL+parameters hashing and background SWR. | | **Express & Fastify** | `tricache/http` | Route caching middleware with deterministic query sorting, weak ETag, and `304 Not Modified`. | -| **Hono & Edge Isolates** | `tricache/edge` | Zero-Node-dependency implementation for Cloudflare Workers, Fastly Compute, Hono, and Vercel Edge. | +| **Node Hono** | `tricache/hono` | First-class `cacheMiddleware` on `CacheService` with ttl/tags/SWR and `304 Not Modified`. | +| **Edge Isolates & Hono** | `tricache/edge` | Zero-Node-dependency implementation for Cloudflare Workers, Fastly Compute, Hono, and Vercel Edge. | | **SSE Dashboard** | `tricache/dashboard` | Zero-dependency Server-Sent Events real-time admin dashboard. | | **Live CLI Top** | `npx tricache top` | Real-time terminal ASCII monitor over Unix sockets and Windows named pipes. | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4af0186..48652c3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -136,7 +136,8 @@ export default defineConfig({ { text: 'NestJS Dynamic Module', link: '/integrations/nestjs' }, { text: 'Prisma ORM Extension', link: '/integrations/prisma' }, { text: 'Drizzle ORM Wrapper', link: '/integrations/drizzle' }, - { text: 'Express & Hono Middleware', link: '/integrations/http' }, + { text: 'Express & Fastify Middleware', link: '/integrations/http' }, + { text: 'Hono Node Middleware', link: '/integrations/hono' }, { text: 'Edge Isolates (Workers)', link: '/integrations/edge' }, { text: 'Visual Dashboard & CLI', link: '/integrations/dashboard' }, ], diff --git a/docs/.vitepress/theme/components/IntegrationGrid.vue b/docs/.vitepress/theme/components/IntegrationGrid.vue index 8e54e9e..270d60e 100644 --- a/docs/.vitepress/theme/components/IntegrationGrid.vue +++ b/docs/.vitepress/theme/components/IntegrationGrid.vue @@ -102,7 +102,7 @@ withDefaults(defineProps(), {
Express / Hono - Edge Middleware + Node + Edge Middleware
diff --git a/docs/api-reference.md b/docs/api-reference.md index a81bf54..42acf54 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -463,7 +463,7 @@ The TriCache engine honors the following environment variables across all enviro --- -## 9. HTTP & Framework Middlewares (`tricache/http` & `tricache/edge`) +## 9. HTTP & Framework Middlewares (`tricache/http`, `tricache/hono` & `tricache/edge`) ### `createExpressMiddleware(cache, options?)` Creates an Express/Connect route middleware with deterministic query sorting, weak ETag calculation, and RFC 7232 `304 Not Modified` short-circuiting. @@ -486,6 +486,17 @@ import { createFastifyPlugin } from 'tricache/http'; await fastify.register(createFastifyPlugin(cache, { ttlSeconds: 120 })); ``` +### `cacheMiddleware(options?)` (`tricache/hono`) +Creates Node Hono middleware on `CacheService` with Express-aligned ttl/tags/SWR, weak ETags, and RFC 7232 `304 Not Modified`. Non-2xx responses are not cached. + +```typescript +import { cacheMiddleware } from 'tricache/hono'; + +app.get('/api/posts', cacheMiddleware({ ttl: 300, tags: ['posts'] }), (c) => { + return c.json({ data: '...' }); +}); +``` + ### `createHonoEdgeMiddleware(edgeCache, options?)` Creates a decoupled Hono edge middleware using pure Web Standards (`Request`, `Response`, `crypto.subtle`) with zero Node native dependencies. diff --git a/docs/changelog.md b/docs/changelog.md index cd97cd4..83b8527 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] — 2026-10-02 + +### Added +- **First-Class Node.js Hono Middleware Adapter (`src/hono/index.ts`, `tricache/hono`)** ([#9](https://github.com/Kareem411/TriCache/issues/9), [#45](https://github.com/Kareem411/TriCache/pull/45)): + - Dedicated package entry `tricache/hono` exporting `cacheMiddleware` and `createHonoMiddleware` for Node.js runtimes. + - Backed directly by Node `CacheService` with full three-tier caching (L1 RAM → L1.5 NVMe `/dev/shm` → L2 Redis), SWR background revalidation, and generational tag invalidation. + - Weak ETag generation (`W/"..."`), RFC 7232 `If-None-Match` conditional 304 Not Modified responses, deterministic query sorting, header whitelisting, and non-2xx status response gating. + - Comprehensive unit test coverage in `tests/hono.test.ts`. +- **Drizzle ORM Query Caching Reference Example (`examples/drizzle-orm/`)** ([#28](https://github.com/Kareem411/TriCache/issues/28), [#37](https://github.com/Kareem411/TriCache/pull/37)): + - Full runnable SQLite + Drizzle reference demonstration showcasing `withCache` query extension. + - Demonstrates deterministic SQL query text + bind parameters fingerprinting (`generateDrizzleCacheKey`), background SWR revalidation (`swr: 60`), and tag invalidation across table mutations (`cache.invalidateTag('users')`). + - Automated verification test suite in `tests/drizzle-orm-example.test.ts`. +- **Fastify REST API Reference Example (`examples/fastify-api/`)** ([#26](https://github.com/Kareem411/TriCache/issues/26), [#38](https://github.com/Kareem411/TriCache/pull/38)): + - Complete runnable Fastify REST API demo illustrating `createFastifyPlugin` (global application hook) and `fastifyCache` (route-level `preHandler` hook). + - Demonstrates `onRequest` short-circuiting, `onSend` response payload capture, weak ETags, and 304 conditional responses. + - Automated verification test suite in `tests/fastify-api-example.test.ts`. +- **Hono & Cloudflare Workers Edge Caching Demo (`examples/edge-hono/`)** ([#27](https://github.com/Kareem411/TriCache/issues/27), [#39](https://github.com/Kareem411/TriCache/pull/39)): + - Standalone Cloudflare Workers + Wrangler reference architecture demonstrating `tricache/edge`. + - Pure Web Standards execution (`Request`, `Response`, `crypto.subtle`) with zero Node native dependencies. + - In-memory `Murmur3BloomFilter` defense against cold miss penetration. +- **NestJS 11 Microservice Reference Example (`examples/nestjs-microservice/`)** ([#29](https://github.com/Kareem411/TriCache/issues/29), [#40](https://github.com/Kareem411/TriCache/pull/40)): + - Runnable NestJS 11 TypeScript service demonstrating `TriCacheModule.register()`. + - Method-level `@Cacheable({ ttl, tags })` and `@CacheEvict({ tags })` decorators, plus `@nestjs/cache-manager` v5/v6 store compatibility via `TriCacheStore`. + - Automated verification test suite in `tests/nestjs-microservice-example.test.ts`. +- **Prometheus Observability Recipe & Express API Demo** ([#30](https://github.com/Kareem411/TriCache/issues/30), [#31](https://github.com/Kareem411/TriCache/pull/31), [#25](https://github.com/Kareem411/TriCache/issues/25), [#35](https://github.com/Kareem411/TriCache/pull/35)): + - Complete Prometheus `/metrics` scraping recipe with Grafana dashboard configuration (`docs/recipes/prometheus-metrics.md`). + - Production Express microservice demo in `examples/express-api/` with weak ETags and 304 validation. + +### Fixed +- **Hono Edge Response Assignment (`src/edge/hono.ts`)** ([#39](https://github.com/Kareem411/TriCache/pull/39)): + - Fixed an issue where Hono's `compose` ignored middleware return values once `next()` sets `c.res`. Explicitly assigns `c.res = response` via `applyEdgeResponse` so weak ETags and 304s are preserved on both cache misses and hits. +- **Windows Named Pipe Discovery & CLI Top Error Handling (`src/cli.ts`, `src/ipc-telemetry.ts`)**: + - Improved Windows named pipe path resolution (`\\.\pipe\tricache-`) and graceful error handling during `tricache top` monitoring. + ## [0.8.0] — 2026-09-16 ### Added diff --git a/docs/integrations/drizzle.md b/docs/integrations/drizzle.md index c27d220..acba3bb 100644 --- a/docs/integrations/drizzle.md +++ b/docs/integrations/drizzle.md @@ -4,6 +4,20 @@ TriCache provides a lightweight query wrapper for Drizzle ORM (`withCache`) that transparently caches query results using deterministic SQL + parameterized argument hashing. +### Ready-to-run SQLite demo + +A self-contained TypeScript project lives at [`examples/drizzle-orm`](https://github.com/Kareem411/TriCache/tree/main/examples/drizzle-orm). It exercises query fingerprinting (`generateDrizzleCacheKey`), background SWR (`swr: 60`), and `cache.invalidateTag('users')` after mutations. + +```bash +pnpm install && pnpm build +cd examples/drizzle-orm +pnpm install +pnpm seed +pnpm demo +``` + +Then follow the printed walkthrough (or `pnpm verify`) in that README. + --- ## Usage @@ -25,9 +39,9 @@ const query = db // Execute or return from TriCache const admins = await withCache(query, { cache, - ttlSec: 600, // 10-minute hard TTL - swrSec: 60, // 60-second SWR soft TTL - tags: ['admins'], // Generational invalidation tag + ttl: 600, // 10-minute hard TTL (WrapOptions.ttl, seconds) + swr: 60, // 60-second SWR grace (WrapOptions.swr, seconds) + tags: ['admins'], // Semantic tag for cache.invalidateTag() }); ``` diff --git a/docs/integrations/edge.md b/docs/integrations/edge.md index c9584d7..24edcb9 100644 --- a/docs/integrations/edge.md +++ b/docs/integrations/edge.md @@ -11,6 +11,19 @@ TriCache provides a zero-Node-dependency caching engine engineered specifically `tricache/edge` is completely decoupled from Node.js native bindings (`node:fs`, `node:worker_threads`, and SQLite) and runs strictly on Web standard APIs (`Request`, `Response`, `crypto.subtle`). +### Ready-to-run Hono + Cloudflare Workers demo + +A self-contained Worker lives at [`examples/edge-hono`](https://github.com/Kareem411/TriCache/tree/main/examples/edge-hono). It exercises `honoEdgeCache`, Web Crypto weak ETags, `If-None-Match` → `304`, deterministic query sorting, and an in-memory `Murmur3BloomFilter` cold-miss defense. + +```bash +pnpm install && pnpm build +cd examples/edge-hono +pnpm install +pnpm dev +``` + +Then follow the `curl -i` walkthrough in that README (`pnpm verify` automates the same checks). + --- ## 1. Quick Start in Cloudflare Workers @@ -21,8 +34,8 @@ import { EdgeCacheService } from 'tricache/edge'; export default { async fetch(request: Request, env: Env): Promise { const cache = new EdgeCacheService({ - maxEntries: 5_000, - bloomFilter: true, // WASM Murmur3 Bloom filter + maxKeys: 5_000, + bloomFilter: true, // WasmBloomFilter, or pass `new Murmur3BloomFilter()` }); const url = new URL(request.url); @@ -41,22 +54,23 @@ export default { --- -## 2. Decoupled Hono Edge Middleware (`createHonoEdgeMiddleware`) +## 2. Decoupled Hono Edge Middleware (`honoEdgeCache`) -TriCache includes native middleware for Hono edge applications: +TriCache includes native middleware for Hono edge applications. The published export is `honoEdgeCache({ cache, ttl, … })` from `tricache/edge` (`src/edge/hono.ts`). ```typescript import { Hono } from 'hono'; -import { EdgeCacheService, createHonoEdgeMiddleware } from 'tricache/edge'; +import { EdgeCacheService, honoEdgeCache } from 'tricache/edge'; const app = new Hono(); -const edgeCache = new EdgeCacheService({ maxEntries: 2_000 }); +const edgeCache = new EdgeCacheService({ maxKeys: 2_000 }); // Mount route cache with ETag calculation & 304 short-circuiting app.get( '/api/feed', - createHonoEdgeMiddleware(edgeCache, { - ttlSeconds: 180, + honoEdgeCache({ + cache: edgeCache, + ttl: 180, headerWhitelist: ['accept-language'], }), async (c) => { @@ -81,10 +95,10 @@ In edge environments where TCP sockets are unavailable, TriCache connects to dis ### Upstash Redis (HTTPS REST) ```typescript -import { EdgeCacheService, createUpstashAdapter } from 'tricache/edge'; +import { EdgeCacheService, UpstashRedisAdapter } from 'tricache/edge'; const cache = new EdgeCacheService({ - remoteStorage: createUpstashAdapter({ + remoteStorage: new UpstashRedisAdapter({ url: env.UPSTASH_REDIS_REST_URL, token: env.UPSTASH_REDIS_REST_TOKEN, }), @@ -93,10 +107,10 @@ const cache = new EdgeCacheService({ ### Cloudflare Workers KV ```typescript -import { EdgeCacheService, createCloudflareKvAdapter } from 'tricache/edge'; +import { EdgeCacheService, CloudflareKVAdapter } from 'tricache/edge'; const cache = new EdgeCacheService({ - remoteStorage: createCloudflareKvAdapter(env.MY_KV_NAMESPACE), + remoteStorage: new CloudflareKVAdapter(env.MY_KV_NAMESPACE), }); ``` @@ -109,3 +123,15 @@ Edge subrequests to remote HTTP key-value stores incur metered API costs and 20 TriCache includes an in-memory **MurmurHash3 Bloom Filter** running directly inside the V8 isolate: * Definite misses for unknown keys abort in **~300 nanoseconds**. * Eliminates up to **99% of wasted remote subrequests** caused by automated vulnerability scanners and 404 route penetration. + +Pass `bloomFilter: true` for the WASM filter (Murmur3 TypeScript fallback), or pass an explicit instance: + +```typescript +import { EdgeCacheService, Murmur3BloomFilter } from 'tricache/edge'; + +const cache = new EdgeCacheService({ + bloomFilter: new Murmur3BloomFilter(), + remoteStorage, // Bloom only gates remote L2 lookups +}); +``` + diff --git a/docs/integrations/hono.md b/docs/integrations/hono.md new file mode 100644 index 0000000..1938090 --- /dev/null +++ b/docs/integrations/hono.md @@ -0,0 +1,76 @@ +# Hono Node Middleware + +> Package entry: `tricache/hono` + +First-class **Node.js** Hono middleware backed by `CacheService` (L1 RAM → L1.5 disk → L2 Redis). This is the adapter requested for Hono apps running on Node — not the Web-Crypto edge helper under [`tricache/edge`](/integrations/edge). + +```typescript +import { Hono } from 'hono'; +import { cacheMiddleware } from 'tricache/hono'; + +const app = new Hono(); + +app.get('/api/posts', cacheMiddleware({ ttl: 300, tags: ['posts'] }), (c) => { + return c.json({ data: '...' }); +}); +``` + +Pass an explicit `CacheService` when you already have one: + +```typescript +import { CacheService } from 'tricache'; +import { cacheMiddleware } from 'tricache/hono'; + +const cache = CacheService.create(); + +app.get( + '/api/posts', + cacheMiddleware({ + cache, + ttl: 300, + swr: 60, + tags: ['posts'], + headerWhitelist: ['accept-language'], + }), + (c) => c.json({ data: '...' }), +); +``` + +`createHonoMiddleware` is an alias of `cacheMiddleware`. + +--- + +## Node vs edge + +| Entry | Runtime | Cache engine | Import | +|:---|:---|:---|:---| +| **`tricache/hono`** | Node.js | `CacheService` | `import { cacheMiddleware } from 'tricache/hono'` | +| **`tricache/edge`** | Workers / edge isolates | `EdgeCacheService` | `import { honoEdgeCache } from 'tricache/edge'` | + +`tricache/http` still re-exports the edge helper as `honoCache` for compatibility. New Node Hono apps should import `tricache/hono`. + +--- + +## Behavior + +* **Safe methods only**: `GET` and `HEAD` are cached; other methods pass through. +* **Weak ETags**: SHA-1 weak validators (`ETag: W/"…"`) via the same Node helper as Express. +* **304 Not Modified**: matching `If-None-Match` short-circuits with an empty body. +* **Status gate**: non-2xx responses are never kept (4xx/5xx cannot poison a key). +* **Bypass**: `Cache-Control: no-cache` / `no-store` and a custom `skipCache` predicate skip the cache. +* **SWR & tags**: `ttl`, `swr`, and `tags` are forwarded to `CacheService.get`, matching Express middleware. + +--- + +## Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `cache` | `CacheService` | singleton | TriCache instance. If omitted, lazily resolves `CacheService.create()` | +| `ttl` | `number` | `300` | Time-to-live in seconds | +| `swr` | `number` | `undefined` | Stale-While-Revalidate window in seconds | +| `etag` | `boolean` | `true` | Generate and evaluate weak ETags (`W/"…"`) | +| `keyGenerator` | `(c) => string` | method + URL + sorted query | Custom cache key from the Hono context | +| `headerWhitelist` | `string[]` | `[]` | Request headers incorporated into the cache key | +| `skipCache` | `(c) => boolean` | `undefined` | Predicate returning true to bypass cache | +| `tags` | `string[] \| ((c) => string[])` | `[]` | Semantic tags for targeted `cache.invalidateTag()` | diff --git a/docs/integrations/http.md b/docs/integrations/http.md index ac78551..3fc35e5 100644 --- a/docs/integrations/http.md +++ b/docs/integrations/http.md @@ -17,6 +17,19 @@ pnpm dev Then follow the `curl -i` walkthrough in that README. +### Ready-to-run Fastify demo + +A self-contained TypeScript app lives at [`examples/fastify-api`](https://github.com/Kareem411/TriCache/tree/main/examples/fastify-api). It exercises both official surfaces — global `createFastifyPlugin` / `fastifyCachePlugin` (`onRequest` short-circuit + `onSend` capture) and route-level `preHandler: fastifyCache(...)` — plus weak ETags and `If-None-Match` → `304`. + +```bash +pnpm install && pnpm build +cd examples/fastify-api +pnpm install +pnpm dev +``` + +Then follow the `curl -i` walkthrough in that README. + --- ## 1. Express & Connect (`createExpressMiddleware`) @@ -72,13 +85,14 @@ await fastify.register(createFastifyPlugin({ ``` ### Route-Level `preHandler` Hook -```typescript -import { createFastifyPlugin } from 'tricache/http'; -const plugin = createFastifyPlugin({ cache, ttl: 300 }); +`createFastifyPlugin` returns a Fastify plugin (lifecycle hooks), not an object with `.preHandler`. Use `fastifyCache` when you want the same options object as either a plugin or a route hook: + +```typescript +import { fastifyCache } from 'tricache/http'; fastify.get('/api/catalog', { - preHandler: plugin.preHandler, + preHandler: fastifyCache({ cache, ttl: 300 }), }, async (request, reply) => { return await fetchCatalog(); }); diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 26f10f7..ba3b01b 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -16,6 +16,7 @@ Explore the dedicated guides for your application stack: | **[NestJS Module](/integrations/nestjs)** | `tricache/nestjs` | Dynamic `TriCacheModule` (`register`/`registerAsync`), `@Cacheable` and `@CacheEvict` decorators. | | **[Prisma ORM Extension](/integrations/prisma)** | `tricache/prisma` | `$extends(withTriCache())`, automatic mutation invalidation, deterministic query key hashing. | | **[Drizzle ORM Wrapper](/integrations/drizzle)** | `tricache/drizzle` | `withCache(query)`, SQL + parameterized argument hashing, custom TTL and tag assignment. | -| **[Express & Hono Middleware](/integrations/http)** | `tricache/http` | Route caching middleware, weak ETag calculation, RFC 7232 `304 Not Modified` short-circuiting. | +| **[Express & Fastify Middleware](/integrations/http)** | `tricache/http` | Route caching middleware, weak ETag calculation, RFC 7232 `304 Not Modified` short-circuiting. | +| **[Hono Node Middleware](/integrations/hono)** | `tricache/hono` | First-class `cacheMiddleware` on `CacheService` with ttl/tags/SWR and `304 Not Modified`. | | **[Edge Isolates (Workers)](/integrations/edge)** | `tricache/edge` | Universal zero-Node runtime for Cloudflare Workers, Fastly Compute, Web Crypto, WASM Bloom. | | **[Visual Dashboard & CLI](/integrations/dashboard)** | `tricache/dashboard` | Real-time SSE Web UI, Next.js route handlers, standalone management server, CLI. | 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/drizzle-orm/.gitignore b/examples/drizzle-orm/.gitignore new file mode 100644 index 0000000..d2cb29e --- /dev/null +++ b/examples/drizzle-orm/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +data +*.log +.pnpm-debug.log* +.DS_Store +*.tsbuildinfo +*.sqlite +*.sqlite-journal +*.db diff --git a/examples/drizzle-orm/README.md b/examples/drizzle-orm/README.md new file mode 100644 index 0000000..890209e --- /dev/null +++ b/examples/drizzle-orm/README.md @@ -0,0 +1,131 @@ +# TriCache Drizzle ORM Demo + +Minimal TypeScript + SQLite project that uses [`withCache`](../../src/drizzle/index.ts) from [`tricache/drizzle`](https://kareem411.github.io/TriCache/integrations/drizzle). + +It shows the three behaviors from [Kareem411/TriCache#28](https://github.com/Kareem411/TriCache/issues/28): + +| Behavior | What to look for | +|---|---| +| Query fingerprinting | `query.toSQL()` → `generateDrizzleCacheKey({ sql, params })` → `drizzle:<32 hex chars>`. Same SQL + bind params share a key; `role = admin` vs `role = member` do not. | +| Background SWR | `withCache(query, { ttl, swr: 60, tags })`. After the hard TTL the next read is still a **HIT** (stale) and `cache.metrics().revalidations` increments while SQLite runs in the background. | +| Tag invalidation | `withCache(..., { tags: ['users'] })` then `cache.invalidateTag('users')` after an `INSERT`. The next read is a **MISS** and includes the new row. | + +Origin `SELECT`s are delayed by **200ms** (`QUERY_LATENCY_MS`) so HIT vs MISS is visible in wall-clock time. Hits skip that delay and do not increment the SQL logger. + +Redis is not required. The demo uses an in-process L1 cache (`disableRedis: true`, `disableDisk: true`). + +SQLite is file-backed via `better-sqlite3` (no Postgres / Docker). + +--- + +## Run locally + +From the **repository root**, build the local `tricache` package (the example links to `../..`): + +```bash +pnpm install +pnpm build +``` + +Then install and run the demo: + +```bash +cd examples/drizzle-orm +pnpm install +pnpm seed +pnpm demo +``` + +`pnpm start` and `pnpm dev` are the same as `pnpm demo`. + +`pnpm seed` writes `data/demo.sqlite` (gitignored). `pnpm demo` reseeds that file at startup so the walkthrough is deterministic. + +If you installed `tricache` from npm instead of the repo link, `pnpm demo` is enough — no root build step. + +### Exact commands (copy-paste) + +```bash +# from the TriCache repository root +pnpm install +pnpm build +cd examples/drizzle-orm +pnpm install +pnpm seed +pnpm demo +``` + +--- + +## What the demo prints + +### 1. Fingerprinting + +`usersByRoleQuery('admin').toSQL()` is hashed with `generateDrizzleCacheKey`. Building the same query twice yields the same `drizzle:…` key. Switching the bind param to `'member'` yields a different key. + +### 2. Miss then hit + +The first `withCache` call records `onMiss`, pays the 200ms SELECT, and increments `cache.metrics().gets.fetches`. The second call records `onHit('l1')`, returns in well under 200ms, and does **not** increment the SQL logger. + +### 3. Background SWR (`swr: 60`) + +The adapter option is `swr` (seconds) on `WrapOptions` / `DrizzleCacheOptions` — not `swrSec`. This demo uses `ttl: 2` so you do not wait a full minute to observe the stale window. After ~2.2s the next `withCache` is still a HIT (stale body, no SELECT delay) and `cache.metrics().revalidations.total` goes up; a SELECT appears in the logger a moment later. + +Override timings if you want: + +```bash +QUERY_TTL_SEC=2 QUERY_SWR_SEC=60 QUERY_LATENCY_MS=200 pnpm demo +``` + +### 4. Tag invalidation + +An `INSERT` of a new admin does **not** change the cached list. `await cache.invalidateTag('users')` drops every entry tagged `users`. The following `withCache` is a MISS and includes the new row. + +--- + +## Automated check + +```bash +pnpm verify +``` + +Reseeds SQLite and asserts fingerprint equality/inequality, miss/hit timings + SQL counts, SWR stale serve + `metrics().revalidations`, and `cache.invalidateTag('users')` after a mutation. + +```bash +pnpm typecheck +``` + +--- + +## How `withCache` is wired + +```typescript +import { CacheService } from 'tricache'; +import { generateDrizzleCacheKey, withCache } from 'tricache/drizzle'; +import { eq } from 'drizzle-orm'; +import { db } from './db'; +import { users } from './schema'; + +const cache = CacheService.create({ + namespace: 'drizzle-orm-demo', + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, +}); + +const query = db.select().from(users).where(eq(users.role, 'admin')); +const { sql, params } = query.toSQL(); +const key = generateDrizzleCacheKey({ sql, params }); // drizzle: + +const admins = await withCache(query, { + cache, + ttl: 2, + swr: 60, + tags: ['users'], +}); + +await cache.invalidateTag('users'); +``` + +The published options object is `{ cache, ttl, swr, tags }` — the same `WrapOptions` fields the adapter forwards to `cache.wrap()`. + +`tricache` / `tricache/drizzle` resolve through `"tricache": "link:../.."` (same pattern as `examples/express-api`). diff --git a/examples/drizzle-orm/package.json b/examples/drizzle-orm/package.json new file mode 100644 index 0000000..9a61dbc --- /dev/null +++ b/examples/drizzle-orm/package.json @@ -0,0 +1,30 @@ +{ + "name": "drizzle-orm-demo", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "TriCache Drizzle ORM demo: withCache query fingerprinting, background SWR, and tag invalidation", + "scripts": { + "seed": "tsx src/seed.ts", + "demo": "tsx src/demo.ts", + "dev": "tsx src/demo.ts", + "start": "tsx src/demo.ts", + "typecheck": "tsc --noEmit", + "verify": "tsx src/verify.ts" + }, + "dependencies": { + "better-sqlite3": "^12.4.1", + "drizzle-orm": "^0.45.2", + "tricache": "link:../.." + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@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/drizzle-orm/pnpm-lock.yaml b/examples/drizzle-orm/pnpm-lock.yaml new file mode 100644 index 0000000..711785e --- /dev/null +++ b/examples/drizzle-orm/pnpm-lock.yaml @@ -0,0 +1,716 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + better-sqlite3: + specifier: ^12.4.1 + version: 12.11.1 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) + tricache: + specifier: link:../.. + version: link:../.. + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@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: + + '@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] + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/node@22.20.3': + resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + engines: {node: '>=10'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + '@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 + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.20.3 + + '@types/node@22.20.3': + dependencies: + undici-types: 6.21.0 + + base64-js@1.5.1: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + chownr@1.1.4: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + detect-libc@2.1.2: {} + + drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + better-sqlite3: 12.11.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.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 + + expand-template@2.0.3: {} + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + github-from-package@0.0.0: {} + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.96.0: + dependencies: + semver: 7.8.5 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + safe-buffer@5.2.1: {} + + semver@7.8.5: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + util-deprecate@1.0.2: {} + + wrappy@1.0.2: {} diff --git a/examples/drizzle-orm/pnpm-workspace.yaml b/examples/drizzle-orm/pnpm-workspace.yaml new file mode 100644 index 0000000..038dbf6 --- /dev/null +++ b/examples/drizzle-orm/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: false + msgpackr-extract: false + better-sqlite3: true diff --git a/examples/drizzle-orm/src/cache.ts b/examples/drizzle-orm/src/cache.ts new file mode 100644 index 0000000..67e8f1d --- /dev/null +++ b/examples/drizzle-orm/src/cache.ts @@ -0,0 +1,34 @@ +import { CacheService } from 'tricache'; + +export type CacheTier = 'l1' | 'disk' | 'l2'; + +export type CacheEvent = + | { kind: 'hit'; key: string; tier: CacheTier } + | { kind: 'miss'; key: string }; + +export const cacheEvents: CacheEvent[] = []; + +/** + * In-process L1 only so the demo runs without Redis or a writable disk tier. + * `onHit` / `onMiss` are the CacheService hooks used for HIT/MISS output. + */ +export function createDemoCache(namespace: string): CacheService { + cacheEvents.length = 0; + return CacheService.create({ + namespace, + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, + ttlJitterFactor: 0, + onHit(key, tier) { + cacheEvents.push({ kind: 'hit', key, tier }); + }, + onMiss(key) { + cacheEvents.push({ kind: 'miss', key }); + }, + }); +} + +export function lastCacheEvent(): CacheEvent | undefined { + return cacheEvents[cacheEvents.length - 1]; +} diff --git a/examples/drizzle-orm/src/config.ts b/examples/drizzle-orm/src/config.ts new file mode 100644 index 0000000..aa27eb4 --- /dev/null +++ b/examples/drizzle-orm/src/config.ts @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function envInt(name: string, fallback: number): number { + const raw = Number(process.env[name]); + return Number.isFinite(raw) && raw >= 0 ? raw : fallback; +} + +/** File-backed SQLite so `pnpm seed` and `pnpm demo` share the same data. */ +export const DB_PATH = process.env.DB_PATH ?? path.join(root, 'data', 'demo.sqlite'); + +/** + * Hard TTL passed to `withCache` / `cache.wrap` (`ttl` seconds). + * Kept short so background SWR is observable without waiting a full minute. + * Production would typically use something like `ttl: 300` with `swr: 60`. + */ +export const QUERY_TTL_SEC = envInt('QUERY_TTL_SEC', 2); + +/** Stale-While-Revalidate grace passed to the adapter (`swr` seconds). */ +export const QUERY_SWR_SEC = envInt('QUERY_SWR_SEC', 60); + +/** + * Simulated SELECT cost (ms). Cache hits skip SQLite + this delay, so HIT vs + * MISS is obvious in wall-clock time as well as `cache.metrics()`. + */ +export const QUERY_LATENCY_MS = envInt('QUERY_LATENCY_MS', 200); diff --git a/examples/drizzle-orm/src/db.ts b/examples/drizzle-orm/src/db.ts new file mode 100644 index 0000000..f87067e --- /dev/null +++ b/examples/drizzle-orm/src/db.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { Logger } from 'drizzle-orm/logger'; +import { DB_PATH } from './config.js'; +import * as schema from './schema.js'; + +type BetterSqlite3 = typeof import('better-sqlite3'); +const Database = createRequire(import.meta.url)('better-sqlite3') as BetterSqlite3; + +export interface QueryRecord { + sql: string; + params: unknown[]; +} + +export const queryLog = { + records: [] as QueryRecord[], + get count(): number { + return this.records.length; + }, + get selects(): number { + return this.records.filter((r) => /^\s*select/i.test(r.sql)).length; + }, + reset(): void { + this.records.length = 0; + }, +}; + +const logger: Logger = { + logQuery(query: string, params: unknown[]): void { + queryLog.records.push({ sql: query, params }); + }, +}; + +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + +export const sqlite = new Database(DB_PATH); + +export const db = drizzle(sqlite, { + schema, + logger, +}); diff --git a/examples/drizzle-orm/src/demo.ts b/examples/drizzle-orm/src/demo.ts new file mode 100644 index 0000000..c99f5ba --- /dev/null +++ b/examples/drizzle-orm/src/demo.ts @@ -0,0 +1,133 @@ +import { generateDrizzleCacheKey } from 'tricache/drizzle'; +import { db, queryLog } from './db.js'; +import { users } from './schema.js'; +import { resetAndSeed } from './seed.js'; +import { createDemoCache, lastCacheEvent } from './cache.js'; +import { cachedUsersByRole, fingerprintUsersByRole } from './queries.js'; +import { QUERY_LATENCY_MS, QUERY_SWR_SEC, QUERY_TTL_SEC } from './config.js'; +import { USERS_TAG } from './seed-data.js'; +import { sleep, snapshot } from './observe.js'; + +function banner(title: string): void { + console.log(`\n=== ${title} ===`); +} + +function eventLabel(): string { + const ev = lastCacheEvent(); + if (!ev) return 'n/a'; + return ev.kind === 'hit' ? `HIT (${ev.tier})` : 'MISS'; +} + +async function main(): Promise { + resetAndSeed(); + queryLog.reset(); + + const cache = createDemoCache(`drizzle-orm-demo-${Date.now()}`); + + console.log('TriCache + Drizzle ORM (`tricache/drizzle` `withCache`)'); + console.log(`ttl: ${QUERY_TTL_SEC}s swr: ${QUERY_SWR_SEC}s simulated SELECT: ${QUERY_LATENCY_MS}ms`); + console.log('Redis is not required (disableRedis / disableDisk).'); + + banner('1. Query fingerprinting (SQL text + bind params → cache key)'); + const adminFp = fingerprintUsersByRole('admin'); + const adminFpAgain = fingerprintUsersByRole('admin'); + const memberFp = fingerprintUsersByRole('member'); + + console.log('admin SQL:', adminFp.sql); + console.log('admin params:', adminFp.params); + console.log('admin key: ', adminFp.key); + console.log('same SQL+params again →', adminFpAgain.key); + console.log('member SQL:', memberFp.sql); + console.log('member params:', memberFp.params); + console.log('member key: ', memberFp.key); + console.log( + 'identical fingerprints:', + adminFp.key === adminFpAgain.key, + ' different roles differ:', + adminFp.key !== memberFp.key, + ); + console.log('key format: generateDrizzleCacheKey() → drizzle:'); + + banner('2. withCache miss then hit'); + const miss = await cachedUsersByRole(cache, 'admin'); + const afterMiss = snapshot(cache); + console.log( + `first admin query: ${eventLabel()} ${miss.ms}ms rows=${miss.value.length} selects=${afterMiss.selects} fetches=${afterMiss.fetches}`, + ); + console.log( + 'names:', + miss.value.map((u) => u.name).join(', '), + ); + + const hit = await cachedUsersByRole(cache, 'admin'); + const afterHit = snapshot(cache); + console.log( + `second admin query: ${eventLabel()} ${hit.ms}ms rows=${hit.value.length} selects=${afterHit.selects} l1Hits=${afterHit.l1Hits}`, + ); + console.log('same key:', miss.key === hit.key, ' names unchanged:', hit.value.map((u) => u.name).join(', ')); + + const member = await cachedUsersByRole(cache, 'member'); + const afterMember = snapshot(cache); + console.log( + `member query (different params): ${eventLabel()} ${member.ms}ms key=${member.key} selects=${afterMember.selects}`, + ); + console.log('member names:', member.value.map((u) => u.name).join(', ')); + + banner(`3. Background SWR (ttl: ${QUERY_TTL_SEC}, swr: ${QUERY_SWR_SEC})`); + console.log(`waiting ${QUERY_TTL_SEC * 1000 + 200}ms so the hard TTL elapses (SWR grace still open)...`); + await sleep(QUERY_TTL_SEC * 1000 + 200); + + const selectsBeforeSwr = queryLog.selects; + const revalidationsBefore = cache.metrics().revalidations.total; + const stale = await cachedUsersByRole(cache, 'admin'); + const afterStale = snapshot(cache); + console.log( + `stale serve: ${eventLabel()} ${stale.ms}ms rows=${stale.value.length} sync selects still ${selectsBeforeSwr} (background refresh scheduled)`, + ); + console.log(`revalidations counter: ${revalidationsBefore} → ${afterStale.revalidations}`); + + await sleep(QUERY_LATENCY_MS + 150); + const afterRefresh = snapshot(cache); + console.log( + `after background refresh: selects ${selectsBeforeSwr} → ${afterRefresh.selects} revalidations=${afterRefresh.revalidations}`, + ); + + banner(`4. Tag invalidation (cache.invalidateTag('${USERS_TAG}'))`); + db.insert(users) + .values({ + name: 'Barbara Liskov', + email: 'barbara@example.com', + role: 'admin', + createdAt: new Date(), + }) + .run(); + console.log('inserted Barbara Liskov (admin). cached admin list still stale until the tag is invalidated.'); + + const staleAfterInsert = await cachedUsersByRole(cache, 'admin'); + console.log( + `before invalidate: ${eventLabel()} names=${staleAfterInsert.value.map((u) => u.name).join(', ')}`, + ); + + await cache.invalidateTag(USERS_TAG); + console.log(`called cache.invalidateTag('${USERS_TAG}')`); + + const fresh = await cachedUsersByRole(cache, 'admin'); + const afterInvalidate = snapshot(cache); + console.log( + `after invalidate: ${eventLabel()} ${fresh.ms}ms names=${fresh.value.map((u) => u.name).join(', ')} selects=${afterInvalidate.selects}`, + ); + + const final = snapshot(cache); + console.log('\nDone.'); + console.log( + `metrics: gets=${final.gets} l1Hits=${final.l1Hits} fetches=${final.fetches} revalidations=${final.revalidations} selects=${final.selects}`, + ); + console.log( + 'fingerprint helper still matches runtime key:', + generateDrizzleCacheKey({ sql: fresh.sql, params: fresh.params }) === fresh.key, + ); + + await cache.destroy().catch(() => {}); +} + +await main(); diff --git a/examples/drizzle-orm/src/observe.ts b/examples/drizzle-orm/src/observe.ts new file mode 100644 index 0000000..30efcc4 --- /dev/null +++ b/examples/drizzle-orm/src/observe.ts @@ -0,0 +1,19 @@ +import type { CacheService } from 'tricache'; +import { queryLog } from './db.js'; +import { cacheEvents, lastCacheEvent } from './cache.js'; + +export function snapshot(cache: CacheService) { + const metrics = cache.metrics(); + return { + selects: queryLog.selects, + queries: queryLog.count, + lastEvent: lastCacheEvent(), + events: cacheEvents.slice(), + gets: metrics.gets.total, + l1Hits: metrics.gets.l1Hits, + fetches: metrics.gets.fetches, + revalidations: metrics.revalidations.total, + }; +} + +export { sleep } from './time.js'; diff --git a/examples/drizzle-orm/src/queries.ts b/examples/drizzle-orm/src/queries.ts new file mode 100644 index 0000000..541e98d --- /dev/null +++ b/examples/drizzle-orm/src/queries.ts @@ -0,0 +1,57 @@ +import { eq } from 'drizzle-orm'; +import { generateDrizzleCacheKey, withCache, type DrizzleExecutableQuery } from 'tricache/drizzle'; +import type { CacheService } from 'tricache'; +import { db } from './db.js'; +import { users, type User } from './schema.js'; +import { QUERY_LATENCY_MS, QUERY_SWR_SEC, QUERY_TTL_SEC } from './config.js'; +import { USERS_TAG, type UserRole } from './seed-data.js'; +import { sleep } from './time.js'; + +export interface CachedQueryResult { + value: T; + key: string; + sql: string; + params: unknown[]; + ms: number; +} + +/** + * Fresh builder every call. `execute()` is async so the simulated SELECT + * delay yields — SWR background refresh must not block the stale serve + * (better-sqlite3 itself is synchronous). + */ +export function usersByRoleQuery(role: UserRole): DrizzleExecutableQuery { + const query = db.select().from(users).where(eq(users.role, role)); + return { + toSQL: () => query.toSQL(), + async execute() { + await sleep(QUERY_LATENCY_MS); + if (typeof query.execute === 'function') { + return await query.execute(); + } + return await query; + }, + }; +} + +export function fingerprintUsersByRole(role: UserRole): { key: string; sql: string; params: unknown[] } { + const { sql, params } = usersByRoleQuery(role).toSQL(); + return { key: generateDrizzleCacheKey({ sql, params }), sql, params }; +} + +export async function cachedUsersByRole( + cache: CacheService, + role: UserRole, +): Promise> { + const query = usersByRoleQuery(role); + const { sql, params } = query.toSQL(); + const key = generateDrizzleCacheKey({ sql, params }); + const started = Date.now(); + const value = await withCache(query, { + cache, + ttl: QUERY_TTL_SEC, + swr: QUERY_SWR_SEC, + tags: [USERS_TAG], + }); + return { value, key, sql, params, ms: Date.now() - started }; +} diff --git a/examples/drizzle-orm/src/schema.ts b/examples/drizzle-orm/src/schema.ts new file mode 100644 index 0000000..f39d1d3 --- /dev/null +++ b/examples/drizzle-orm/src/schema.ts @@ -0,0 +1,11 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + email: text('email').notNull().unique(), + role: text('role', { enum: ['admin', 'member'] }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}); + +export type User = typeof users.$inferSelect; diff --git a/examples/drizzle-orm/src/seed-data.ts b/examples/drizzle-orm/src/seed-data.ts new file mode 100644 index 0000000..ac5a1be --- /dev/null +++ b/examples/drizzle-orm/src/seed-data.ts @@ -0,0 +1,16 @@ +/** Plain seed rows — no Drizzle import so root tests can reuse them. */ +export type UserRole = 'admin' | 'member'; + +export interface SeedUser { + name: string; + email: string; + role: UserRole; +} + +export const SEED_USERS: readonly SeedUser[] = [ + { name: 'Ada Lovelace', email: 'ada@example.com', role: 'admin' }, + { name: 'Alan Turing', email: 'alan@example.com', role: 'admin' }, + { name: 'Grace Hopper', email: 'grace@example.com', role: 'member' }, +]; + +export const USERS_TAG = 'users'; diff --git a/examples/drizzle-orm/src/seed.ts b/examples/drizzle-orm/src/seed.ts new file mode 100644 index 0000000..3a6ff8b --- /dev/null +++ b/examples/drizzle-orm/src/seed.ts @@ -0,0 +1,41 @@ +import { pathToFileURL } from 'node:url'; +import { sqlite, db } from './db.js'; +import { users } from './schema.js'; +import { SEED_USERS } from './seed-data.js'; +import { DB_PATH } from './config.js'; + +const CREATE_USERS_SQL = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + created_at INTEGER NOT NULL +); +`; + +/** Drop + recreate + insert the sample roster. Safe to call from demo/verify. */ +export function resetAndSeed(): void { + sqlite.exec('DROP TABLE IF EXISTS users'); + sqlite.exec(CREATE_USERS_SQL); + const now = new Date(); + db.insert(users) + .values(SEED_USERS.map((row) => ({ ...row, createdAt: now }))) + .run(); +} + +function isDirectRun(): boolean { + const entry = process.argv[1]; + if (!entry) return false; + try { + return import.meta.url === pathToFileURL(entry).href; + } catch { + return entry.endsWith('seed.ts') || entry.endsWith('seed.js'); + } +} + +if (isDirectRun()) { + resetAndSeed(); + const count = sqlite.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }; + console.log(`Seeded ${count.n} users into ${DB_PATH}`); +} diff --git a/examples/drizzle-orm/src/time.ts b/examples/drizzle-orm/src/time.ts new file mode 100644 index 0000000..421bda0 --- /dev/null +++ b/examples/drizzle-orm/src/time.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/examples/drizzle-orm/src/verify.ts b/examples/drizzle-orm/src/verify.ts new file mode 100644 index 0000000..b6bb868 --- /dev/null +++ b/examples/drizzle-orm/src/verify.ts @@ -0,0 +1,105 @@ +/** + * Asserts the three behaviors from Kareem411/TriCache#28: + * query fingerprinting, background SWR, and tag invalidation via cache.invalidateTag(). + */ +import { generateDrizzleCacheKey } from 'tricache/drizzle'; +import { db, queryLog } from './db.js'; +import { users } from './schema.js'; +import { resetAndSeed } from './seed.js'; +import { cacheEvents, createDemoCache } from './cache.js'; +import { cachedUsersByRole, fingerprintUsersByRole } from './queries.js'; +import { QUERY_LATENCY_MS, QUERY_TTL_SEC } from './config.js'; +import { USERS_TAG } from './seed-data.js'; +import { sleep } from './observe.js'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function main(): Promise { + resetAndSeed(); + queryLog.reset(); + const cache = createDemoCache(`drizzle-orm-verify-${Date.now()}`); + + try { + const adminA = fingerprintUsersByRole('admin'); + const adminB = fingerprintUsersByRole('admin'); + const member = fingerprintUsersByRole('member'); + assert(adminA.key === adminB.key, 'identical SQL+params must share a fingerprint'); + assert(adminA.key !== member.key, 'different bind params must produce a different fingerprint'); + assert(/^drizzle:[a-f0-9]{32}$/.test(adminA.key), `unexpected key shape: ${adminA.key}`); + assert(adminA.sql.toLowerCase().includes('from'), 'toSQL() should return compiled SQL'); + assert(adminA.params.includes('admin'), 'admin fingerprint must include the role bind param'); + assert(member.params.includes('member'), 'member fingerprint must include the role bind param'); + + const miss = await cachedUsersByRole(cache, 'admin'); + assert(miss.key === adminA.key, 'withCache key must match generateDrizzleCacheKey(toSQL())'); + assert(miss.value.length === 2, `expected 2 seeded admins, got ${miss.value.length}`); + assert(miss.ms >= QUERY_LATENCY_MS * 0.5, `miss should pay SELECT latency, took ${miss.ms}ms`); + assert(cacheEvents.some((e) => e.kind === 'miss' && e.key === miss.key), 'first read should record onMiss'); + const selectsAfterMiss = queryLog.selects; + assert(selectsAfterMiss === 1, `first admin query should hit SQLite once, got ${selectsAfterMiss}`); + + const hit = await cachedUsersByRole(cache, 'admin'); + assert(hit.key === miss.key, 'repeat query must reuse the same fingerprint'); + assert(hit.value.map((u) => u.email).join() === miss.value.map((u) => u.email).join(), 'hit payload must match'); + assert(queryLog.selects === selectsAfterMiss, 'cache hit must not run another SELECT'); + assert(hit.ms < QUERY_LATENCY_MS, `hit should skip the ${QUERY_LATENCY_MS}ms SELECT delay, took ${hit.ms}ms`); + assert(cache.metrics().gets.l1Hits >= 1, 'metrics().gets.l1Hits should increment on the second read'); + + const memberRead = await cachedUsersByRole(cache, 'member'); + assert(memberRead.key === member.key, 'member query must use the member fingerprint'); + assert(memberRead.value.length === 1, 'seeded member roster should be Grace Hopper only'); + assert(queryLog.selects === selectsAfterMiss + 1, 'new params should miss and run a fresh SELECT'); + + await sleep(QUERY_TTL_SEC * 1000 + 250); + const selectsBeforeSwr = queryLog.selects; + const revalidationsBefore = cache.metrics().revalidations.total; + const stale = await cachedUsersByRole(cache, 'admin'); + assert(stale.value.length === 2, 'SWR should still serve the cached admin roster'); + assert(stale.ms < QUERY_LATENCY_MS, `SWR stale serve must be instant, took ${stale.ms}ms`); + assert(queryLog.selects === selectsBeforeSwr, 'SWR must not block on the background SELECT'); + assert( + cache.metrics().revalidations.total === revalidationsBefore + 1, + `expected revalidations ${revalidationsBefore} + 1, got ${cache.metrics().revalidations.total}`, + ); + + await sleep(QUERY_LATENCY_MS + 200); + assert(queryLog.selects === selectsBeforeSwr + 1, 'background SWR refresh should execute one SELECT'); + + db.insert(users) + .values({ + name: 'Barbara Liskov', + email: 'barbara@example.com', + role: 'admin', + createdAt: new Date(), + }) + .run(); + + const cachedAfterInsert = await cachedUsersByRole(cache, 'admin'); + assert( + !cachedAfterInsert.value.some((u) => u.email === 'barbara@example.com'), + 'mutation without invalidateTag must keep serving the cached roster', + ); + + await cache.invalidateTag(USERS_TAG); + const fresh = await cachedUsersByRole(cache, 'admin'); + assert( + fresh.value.some((u) => u.email === 'barbara@example.com'), + 'after cache.invalidateTag("users") the next withCache read must include the insert', + ); + assert(fresh.value.length === 3, `expected 3 admins after invalidation, got ${fresh.value.length}`); + assert( + generateDrizzleCacheKey({ sql: fresh.sql, params: fresh.params }) === fresh.key, + 'runtime key must stay a SHA-256 fingerprint of SQL + params', + ); + + console.log('verify: fingerprinting, SWR, and tag invalidation all passed'); + } finally { + await cache.destroy().catch(() => {}); + } +} + +await main(); diff --git a/examples/drizzle-orm/tsconfig.json b/examples/drizzle-orm/tsconfig.json new file mode 100644 index 0000000..33470e4 --- /dev/null +++ b/examples/drizzle-orm/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/edge-hono/.gitignore b/examples/edge-hono/.gitignore new file mode 100644 index 0000000..f6b8084 --- /dev/null +++ b/examples/edge-hono/.gitignore @@ -0,0 +1,9 @@ +node_modules +dist +*.log +.pnpm-debug.log* +.DS_Store +*.tsbuildinfo +.wrangler +.dev.vars +.mf diff --git a/examples/edge-hono/README.md b/examples/edge-hono/README.md new file mode 100644 index 0000000..c0574fb --- /dev/null +++ b/examples/edge-hono/README.md @@ -0,0 +1,176 @@ +# TriCache Hono + Cloudflare Workers Demo + +Minimal [Hono](https://hono.dev) Worker that uses [`honoEdgeCache`](../../src/edge/hono.ts) and [`EdgeCacheService`](../../src/edge/cache.ts) from [`tricache/edge`](https://kareem411.github.io/TriCache/integrations/edge). + +It shows the behaviors from [Kareem411/TriCache#27](https://github.com/Kareem411/TriCache/issues/27): + +| Behavior | What to look for | +|---|---| +| Pure Web Standards | Worker path uses `Request` / `Response` / `crypto.subtle` only — no `nodejs_compat`, no `tricache` (Node) import | +| Weak ETag | `ETag: W/"…"` on `200` responses (`computeEdgeETag` via `crypto.subtle.digest('SHA-1', …)`) | +| RFC 7232 `304` | Repeat with `If-None-Match` → empty `304 Not Modified` | +| Deterministic query sorting | `?limit=5&page=2` and `?page=2&limit=5` share `generatedAt` + ETag | +| `headerWhitelist: ['accept-language']` | `en` vs `fr` are separate cache entries | +| MurmurHash3 Bloom filter | After a cached feed, `GET /stats/bloom-probe` reports `bloomMightContain: false` and `remoteGetSkipped: true` | + +Origin work is a simulated **250ms** catalog query. Cache hits replay the stored JSON and skip that delay. Hits do **not** replay `X-TriCache-Demo: origin` — that header is set only when the route handler runs. + +The published export is **`honoEdgeCache({ cache, ttl, … })`**. Older docs called this `createHonoEdgeMiddleware(cache, { ttlSeconds })` — that name is not exported from `tricache/edge`. + +--- + +## Run locally + +From the **repository root**, build the local `tricache` package (the example links to `../..`): + +```bash +pnpm install +pnpm build +``` + +Then start the Worker with Wrangler (`pnpm dev` is `wrangler dev`): + +```bash +cd examples/edge-hono +pnpm install +pnpm dev +``` + +`pnpm start` is the same command. Wrangler prints a local URL (default `http://127.0.0.1:8787`). + +If you installed `tricache` from npm instead of the repo link, `pnpm dev` is enough — no root build step. + +--- + +## Try it with `curl -i` + +Keep `wrangler dev` running in another terminal. Use an explicit `Accept-Language`: an omitted language header and `Accept-Language: en` are **different** cache keys (the whitelist only adds the header when it is present). + +Replace `8787` if Wrangler bound a different port. + +### 1. Cold miss — weak ETag + +```bash +curl -i 'http://127.0.0.1:8787/api/feed?limit=5&page=2' \ + -H 'Accept-Language: en' +``` + +Expect `HTTP/1.1 200`, `ETag: W/"…"`, `X-TriCache-Demo: origin`, and a `generatedAt` timestamp. This request takes ~250ms. + +### 2. Same page, swapped query — cache hit + +```bash +curl -i 'http://127.0.0.1:8787/api/feed?page=2&limit=5' \ + -H 'Accept-Language: en' +``` + +Expect the **same** `ETag` and `generatedAt`, no `X-TriCache-Demo` header, and a much faster response. TriCache sorts query parameters before hashing the key. + +### 3. Conditional GET — `304 Not Modified` + +Capture the ETag from a **GET** (`curl -sI` is HEAD, and HEAD is a different cache key): + +```bash +ETAG=$(curl -sD - -o /dev/null 'http://127.0.0.1:8787/api/feed?limit=5&page=2' \ + -H 'Accept-Language: en' \ + | awk -F': ' 'tolower($1)=="etag"{gsub("\r","",$2); print $2}') + +curl -i 'http://127.0.0.1:8787/api/feed?limit=5&page=2' \ + -H 'Accept-Language: en' \ + -H "If-None-Match: $ETAG" +``` + +Expect `HTTP/1.1 304 Not Modified`, the same `ETag`, and an **empty** body. + +### 4. Language variants — `headerWhitelist` + +```bash +curl -i 'http://127.0.0.1:8787/api/feed?limit=5&page=2' \ + -H 'Accept-Language: fr' +``` + +Expect a new origin fetch (`X-TriCache-Demo: origin`), a **different** ETag, `lang: "fr"`, and localized names (for example `Haut-parleurs de bureau`). + +### 5. Authenticated request — `skipCache` + +```bash +curl -i 'http://127.0.0.1:8787/api/feed?limit=5&page=2' \ + -H 'Accept-Language: en' \ + -H 'Authorization: Bearer demo' +``` + +Expect `cacheBypassed: true`, `X-TriCache-Demo: origin`, **no** `ETag`, and a new `generatedAt` on every call. + +`Cache-Control: no-cache` / `no-store` also bypass the cache (built into `honoEdgeCache`). + +### 6. Bloom filter cold-miss defense + +```bash +curl -s 'http://127.0.0.1:8787/stats' | jq . +curl -s 'http://127.0.0.1:8787/stats/bloom-probe' | jq . +``` + +After at least one cached `/api/feed`, `/stats` shows `bloom.insertions > 0`. `/stats/bloom-probe` looks up a never-seen key: `bloomMightContain` is `false` and `remoteGetSkipped` is `true` — `EdgeCacheService` does not call L2. + +--- + +## Automated check + +```bash +pnpm verify +``` + +Starts `wrangler dev` on port `18787` and asserts ETag / 304 / query sorting / language / skipCache / Bloom skip. + +--- + +## How the middleware is wired + +```typescript +import { Hono } from 'hono'; +import { + EdgeCacheService, + Murmur3BloomFilter, + honoEdgeCache, +} from 'tricache/edge'; + +const bloom = new Murmur3BloomFilter(); +const cache = new EdgeCacheService({ + namespace: 'edge-hono-demo', + maxKeys: 2_000, + bloomFilter: bloom, + remoteStorage, // demo: in-memory IEdgeRemoteStorage; prod: CloudflareKVAdapter +}); + +const app = new Hono(); + +app.get( + '/api/feed', + honoEdgeCache({ + cache, + ttl: 60, + swr: 30, + etag: true, + tags: ['feed'], + headerWhitelist: ['accept-language'], + skipCache: (c) => Boolean(c.req.header('authorization')), + }), + feedHandler, +); +``` + +The published options object is `{ cache, ttl, swr, etag, tags, headerWhitelist, skipCache }` — not `(cache, { ttlSeconds })`. After `next()`, Hono marks `c.res` finalized; `honoEdgeCache` assigns the cached `Response` onto `c.res` so weak ETags are visible on the miss path as well as on hits. + +Production L2: pass `new CloudflareKVAdapter(env.CACHE_KV)` or `new UpstashRedisAdapter({ url, token })` as `remoteStorage`. Those classes are exported from `tricache/edge`. + +--- + +## Deploy + +```bash +pnpm deploy +``` + +Requires a Cloudflare account and `wrangler login` (or API token env vars). This demo does not need KV / R2 / Durable Object bindings. Add a KV namespace and `CloudflareKVAdapter` when you want shared L2 across isolates. + +Do not enable `nodejs_compat` unless you later import Node-only packages. `tricache/edge` is designed to run without it. diff --git a/examples/edge-hono/package.json b/examples/edge-hono/package.json new file mode 100644 index 0000000..0ccd5fd --- /dev/null +++ b/examples/edge-hono/package.json @@ -0,0 +1,29 @@ +{ + "name": "edge-hono", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "TriCache Hono + Cloudflare Workers edge demo: Web Crypto weak ETag, RFC 7232 304, MurmurHash3 Bloom filter", + "scripts": { + "dev": "wrangler dev", + "start": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "verify": "tsx scripts/verify.ts" + }, + "dependencies": { + "hono": "^4.13.8", + "tricache": "link:../.." + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260702.1", + "@types/node": "^22.18.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2", + "wrangler": "^4.135.0" + }, + "engines": { + "node": ">=20.10.0" + }, + "packageManager": "pnpm@11.22.0" +} diff --git a/examples/edge-hono/pnpm-lock.yaml b/examples/edge-hono/pnpm-lock.yaml new file mode 100644 index 0000000..c82912e --- /dev/null +++ b/examples/edge-hono/pnpm-lock.yaml @@ -0,0 +1,1225 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + hono: + specifier: ^4.13.8 + version: 4.13.8 + tricache: + specifier: link:../.. + version: link:../.. + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260702.1 + version: 4.20260702.1 + '@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 + wrangler: + specifier: ^4.135.0 + version: 4.135.0(@cloudflare/workers-types@4.20260702.1)(@types/node@22.20.3) + +packages: + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260918.1': + resolution: {integrity: sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260918.1': + resolution: {integrity: sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260918.1': + resolution: {integrity: sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260918.1': + resolution: {integrity: sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260918.1': + resolution: {integrity: sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@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.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + 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.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + 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.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + 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.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@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.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + 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.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + 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.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + 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.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + 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.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + 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.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + 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.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + 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.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + 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.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + 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.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@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.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + 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.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@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.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + 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.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@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.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + 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.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + '@types/node@22.20.3': + resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.13.8: + resolution: {integrity: sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==} + engines: {node: '>=16.9.0'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + miniflare@5.20260918.0-alpha: + resolution: {integrity: sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg==} + engines: {node: '>=22.0.0'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + 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 + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260918.1: + resolution: {integrity: sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.135.0: + resolution: {integrity: sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260918.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260918.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260918.1 + + '@cloudflare/workerd-darwin-64@1.20260918.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260918.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260918.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260918.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260918.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + + '@types/node@22.20.3': + dependencies: + undici-types: 6.21.0 + + blake3-wasm@2.1.5: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + 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 + + fsevents@2.3.3: + optional: true + + hono@4.13.8: {} + + kleur@4.1.5: {} + + miniflare@5.20260918.0-alpha(@types/node@22.20.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@22.20.3) + undici: 7.29.0 + workerd: 1.20260918.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + semver@7.8.5: {} + + sharp@0.35.4(@types/node@22.20.3): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 22.20.3 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260918.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260918.1 + '@cloudflare/workerd-darwin-arm64': 1.20260918.1 + '@cloudflare/workerd-linux-64': 1.20260918.1 + '@cloudflare/workerd-linux-arm64': 1.20260918.1 + '@cloudflare/workerd-windows-64': 1.20260918.1 + + wrangler@4.135.0(@cloudflare/workers-types@4.20260702.1)(@types/node@22.20.3): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260918.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260918.0-alpha(@types/node@22.20.3) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260918.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/examples/edge-hono/pnpm-workspace.yaml b/examples/edge-hono/pnpm-workspace.yaml new file mode 100644 index 0000000..1a61d4b --- /dev/null +++ b/examples/edge-hono/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: false + msgpackr-extract: false + workerd: true diff --git a/examples/edge-hono/scripts/verify.ts b/examples/edge-hono/scripts/verify.ts new file mode 100644 index 0000000..b44ff3d --- /dev/null +++ b/examples/edge-hono/scripts/verify.ts @@ -0,0 +1,223 @@ +/** + * Smoke-checks the Worker the same way the README curl -i walkthrough does: + * weak ETag, 304, sorted query keys, accept-language, skipCache, Bloom skip. + * + * Uses node:http (not fetch). Undici fetch can add Cache-Control on conditional + * GETs, and honoEdgeCache treats no-cache / no-store as a bypass. + * + * This script runs in Node. The Worker itself stays on Web Standards only. + */ +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 exampleRoot = path.join(root, '..'); +const port = Number(process.env.VERIFY_PORT) || 18787; +const host = '127.0.0.1'; + +interface Probe { + status: number; + headers: Record; + body: string; + json: Record | null; + ms: number; +} + +function header(res: Probe, name: string): string | undefined { + return res.headers[name.toLowerCase()]; +} + +function request(urlPath: string, headers: Record = {}): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const req = http.request( + { host, port, path: urlPath, 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); + req.end(); + }); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function waitForHealth(timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await request('/healthz'); + if (res.status === 200) return; + lastError = new Error(`healthz ${res.status}`); + } catch (err) { + lastError = err; + } + await delay(250); + } + throw new Error(`wrangler dev did not become healthy: ${String(lastError)}`); +} + +function spawnWrangler(): ChildProcess { + return spawn( + 'pnpm', + [ + 'exec', + 'wrangler', + 'dev', + '--ip', + host, + '--port', + String(port), + '--inspector-port', + '0', + ], + { + cwd: exampleRoot, + env: { + ...process.env, + CI: 'true', + WRANGLER_SEND_METRICS: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); +} + +async function main(): Promise { + const child = spawnWrangler(); + + 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(`wrangler dev exited early with code ${code}`)); + }); + child.on('error', reject); + }); + + try { + await Promise.race([waitForHealth(), exitError]); + + const miss = await request('/api/feed?limit=5&page=2', { + 'accept-language': 'en', + }); + const etag = header(miss, 'etag'); + assert(miss.status === 200, `cold GET expected 200, got ${miss.status}`); + assert(etag?.startsWith('W/"'), `expected weak ETag, got ${etag}`); + assert(header(miss, 'x-tricache-demo') === 'origin', 'cold GET should hit origin'); + assert(typeof miss.json?.generatedAt === 'string', 'cold GET missing generatedAt'); + const generatedAt = miss.json?.generatedAt as string; + console.log(`1. cold miss ${miss.status} ${etag} ${miss.ms}ms`); + + const swapped = await request('/api/feed?page=2&limit=5', { + 'accept-language': 'en', + }); + assert(swapped.status === 200, `sorted-query GET expected 200, got ${swapped.status}`); + assert(header(swapped, 'etag') === etag, 'query order must share ETag'); + assert(swapped.json?.generatedAt === generatedAt, 'query order must share generatedAt'); + assert(header(swapped, 'x-tricache-demo') !== 'origin', 'sorted-query GET should be a cache hit'); + console.log(`2. query sort ${swapped.status} same ETag + generatedAt ${swapped.ms}ms`); + + const notModified = await request('/api/feed?limit=5&page=2', { + 'accept-language': 'en', + 'if-none-match': etag ?? '', + }); + assert(notModified.status === 304, `If-None-Match expected 304, got ${notModified.status}`); + assert(notModified.body === '', `304 should have an empty body, got ${notModified.body.slice(0, 80)}`); + assert(header(notModified, 'etag') === etag, '304 should echo the weak ETag'); + console.log(`3. 304 ${notModified.status} empty body ${notModified.ms}ms`); + + const french = await request('/api/feed?limit=5&page=2', { + 'accept-language': 'fr', + }); + assert(french.status === 200, `fr GET expected 200, got ${french.status}`); + assert(header(french, 'etag') !== etag, 'Accept-Language must change the cache key / ETag'); + assert(french.json?.lang === 'fr', `expected lang=fr, got ${String(french.json?.lang)}`); + assert(header(french, 'x-tricache-demo') === 'origin', 'first fr GET should hit origin'); + const frenchNames = ((french.json?.items as Array<{ name: string }> | undefined) ?? []).map((item) => item.name); + assert( + frenchNames.includes('Haut-parleurs de bureau'), + `expected localized French catalog, got ${frenchNames.join(', ')}`, + ); + console.log(`4. language ${french.status} lang=fr ${header(french, 'etag')} ${french.ms}ms`); + + const authA = await request('/api/feed?limit=5&page=2', { + 'accept-language': 'en', + authorization: 'Bearer demo', + }); + const authB = await request('/api/feed?limit=5&page=2', { + 'accept-language': 'en', + authorization: 'Bearer demo', + }); + assert(authA.status === 200 && authB.status === 200, 'auth GET should be 200'); + assert(authA.json?.cacheBypassed === true && authB.json?.cacheBypassed === true, 'auth responses should set cacheBypassed'); + assert(header(authA, 'x-tricache-demo') === 'origin' && header(authB, 'x-tricache-demo') === 'origin', 'auth should skip cache'); + assert(!header(authA, 'etag') && !header(authB, 'etag'), 'skipCache should not attach an ETag'); + assert(authA.json?.generatedAt !== authB.json?.generatedAt, 'auth requests must not reuse generatedAt'); + console.log(`5. skipCache ${authA.status}/${authB.status} distinct generatedAt ${authA.ms}ms/${authB.ms}ms`); + + const stats = await request('/stats'); + assert(stats.status === 200, `stats expected 200, got ${stats.status}`); + const bloom = stats.json?.bloom as { insertions?: number } | undefined; + assert((bloom?.insertions ?? 0) > 0, `expected bloom insertions after cached feed, got ${String(bloom?.insertions)}`); + + const probe = await request('/stats/bloom-probe'); + assert(probe.status === 200, `bloom-probe expected 200, got ${probe.status}`); + assert(probe.json?.bloomMightContain === false, 'unknown key should be a definite Bloom miss'); + assert(probe.json?.remoteGetSkipped === true, 'Bloom miss should skip remote L2 get'); + console.log(`6. bloom skip remoteGetSkipped=${String(probe.json?.remoteGetSkipped)} insertions=${String(bloom?.insertions)}`); + + console.log('\nAll Hono edge demo checks passed.'); + } finally { + child.kill('SIGTERM'); + await delay(500); + if (child.exitCode === null && child.killed === false) { + child.kill('SIGKILL'); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/edge-hono/src/catalog.ts b/examples/edge-hono/src/catalog.ts new file mode 100644 index 0000000..5b9f758 --- /dev/null +++ b/examples/edge-hono/src/catalog.ts @@ -0,0 +1,185 @@ +export type CatalogLang = 'en' | 'fr' | 'es'; + +export interface LocalizedCopy { + name: string; + category: string; +} + +export interface CatalogProduct { + id: number; + sku: string; + price: number; + copy: Record; +} + +export interface PublicProduct { + id: number; + sku: string; + name: string; + category: string; + price: number; +} + +export const CATALOG: CatalogProduct[] = [ + { + id: 1, + sku: 'kb-01', + price: 129, + copy: { + en: { name: 'Mechanical Keyboard', category: 'peripherals' }, + fr: { name: 'Clavier mécanique', category: 'périphériques' }, + es: { name: 'Teclado mecánico', category: 'periféricos' }, + }, + }, + { + id: 2, + sku: 'ms-02', + price: 79, + copy: { + en: { name: 'Wireless Mouse', category: 'peripherals' }, + fr: { name: 'Souris sans fil', category: 'périphériques' }, + es: { name: 'Ratón inalámbrico', category: 'periféricos' }, + }, + }, + { + id: 3, + sku: 'hd-03', + price: 249, + copy: { + en: { name: 'Studio Headphones', category: 'audio' }, + fr: { name: 'Casque studio', category: 'audio' }, + es: { name: 'Auriculares de estudio', category: 'audio' }, + }, + }, + { + id: 4, + sku: 'mn-04', + price: 399, + copy: { + en: { name: '4K Monitor', category: 'displays' }, + fr: { name: 'Moniteur 4K', category: 'écrans' }, + es: { name: 'Monitor 4K', category: 'pantallas' }, + }, + }, + { + id: 5, + sku: 'dk-05', + price: 189, + copy: { + en: { name: 'Standing Desk Converter', category: 'furniture' }, + fr: { name: 'Convertisseur de bureau debout', category: 'mobilier' }, + es: { name: 'Conversor de escritorio de pie', category: 'mobiliario' }, + }, + }, + { + id: 6, + sku: 'wb-06', + price: 59, + copy: { + en: { name: 'Webcam 1080p', category: 'peripherals' }, + fr: { name: 'Webcam 1080p', category: 'périphériques' }, + es: { name: 'Cámara web 1080p', category: 'periféricos' }, + }, + }, + { + id: 7, + sku: 'sp-07', + price: 149, + copy: { + en: { name: 'Desktop Speakers', category: 'audio' }, + fr: { name: 'Haut-parleurs de bureau', category: 'audio' }, + es: { name: 'Altavoces de escritorio', category: 'audio' }, + }, + }, + { + id: 8, + sku: 'ht-08', + price: 89, + copy: { + en: { name: 'USB Hub', category: 'peripherals' }, + fr: { name: 'Hub USB', category: 'périphériques' }, + es: { name: 'Concentrador USB', category: 'periféricos' }, + }, + }, + { + id: 9, + sku: 'lt-09', + price: 45, + copy: { + en: { name: 'Desk Lamp', category: 'furniture' }, + fr: { name: 'Lampe de bureau', category: 'mobilier' }, + es: { name: 'Lámpara de escritorio', category: 'mobiliario' }, + }, + }, + { + id: 10, + sku: 'pd-10', + price: 69, + copy: { + en: { name: 'Laptop Stand', category: 'furniture' }, + fr: { name: 'Support pour ordinateur portable', category: 'mobilier' }, + es: { name: 'Soporte para portátil', category: 'mobiliario' }, + }, + }, + { + id: 11, + sku: 'mc-11', + price: 119, + copy: { + en: { name: 'USB Microphone', category: 'audio' }, + fr: { name: 'Microphone USB', category: 'audio' }, + es: { name: 'Micrófono USB', category: 'audio' }, + }, + }, + { + id: 12, + sku: 'dp-12', + price: 219, + copy: { + en: { name: 'Ultrawide Display', category: 'displays' }, + fr: { name: 'Écran ultra-large', category: 'écrans' }, + es: { name: 'Pantalla ultrawide', category: 'pantallas' }, + }, + }, +]; + +const SUPPORTED: CatalogLang[] = ['en', 'fr', 'es']; + +/** Map `Accept-Language` to a catalog locale. Unrecognized values fall back to `en`. */ +export function resolveLanguage(header: string | string[] | undefined): CatalogLang { + const raw = Array.isArray(header) ? header[0] : header; + if (!raw) return 'en'; + + const tokens = raw.toLowerCase().split(','); + for (const token of tokens) { + const tag = token.split(';')[0]?.trim() ?? ''; + const base = tag.split('-')[0] ?? ''; + if (SUPPORTED.includes(base as CatalogLang)) { + return base as CatalogLang; + } + } + return 'en'; +} + +export function localizeProduct(product: CatalogProduct, lang: CatalogLang): PublicProduct { + const copy = product.copy[lang]; + return { + id: product.id, + sku: product.sku, + name: copy.name, + category: copy.category, + price: product.price, + }; +} + +export function paginateCatalog(lang: CatalogLang, page: number, limit: number): { + items: PublicProduct[]; + total: number; +} { + const items = CATALOG.map((product) => localizeProduct(product, lang)); + const start = (page - 1) * limit; + return { + items: items.slice(start, start + limit), + total: items.length, + }; +} diff --git a/examples/edge-hono/src/index.ts b/examples/edge-hono/src/index.ts new file mode 100644 index 0000000..9238ac2 --- /dev/null +++ b/examples/edge-hono/src/index.ts @@ -0,0 +1,149 @@ +import { Hono } from 'hono'; +import { + EdgeCacheService, + Murmur3BloomFilter, + honoEdgeCache, +} from 'tricache/edge'; +import { paginateCatalog, resolveLanguage } from './catalog.js'; +import { MemoryRemoteStorage } from './memory-remote.js'; + +/** + * Isolate-scoped singletons. A Worker isolate may handle many requests; + * constructing these per-request would wipe L1, Bloom, and L2 on every hit. + * + * Published exports used here (see `src/edge/index.ts`): + * - `EdgeCacheService` + * - `honoEdgeCache` (docs historically said `createHonoEdgeMiddleware` — that name is not exported) + * - `Murmur3BloomFilter` + */ +const NAMESPACE = 'edge-hono-demo'; +const bloom = new Murmur3BloomFilter(); +const remote = new MemoryRemoteStorage(); +const cache = new EdgeCacheService({ + namespace: NAMESPACE, + maxKeys: 2_000, + defaultTtlSeconds: 60, + bloomFilter: bloom, + remoteStorage: remote, +}); + +const feedCache = honoEdgeCache({ + cache, + ttl: 60, + swr: 30, + etag: true, + tags: ['feed'], + /** `en` vs `fr` become distinct keys; `User-Agent` / other headers do not. */ + headerWhitelist: ['accept-language'], + /** Authenticated traffic is user-specific — never store it. */ + skipCache: (c) => Boolean(c.req.header('authorization')), +}); + +type Bindings = { + ORIGIN_LATENCY_MS?: string; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +app.get('/', (c) => { + return c.json({ + name: 'TriCache Hono + Cloudflare Workers edge demo', + runtime: 'workerd', + import: 'tricache/edge', + middleware: 'honoEdgeCache', + docs: 'See README.md for wrangler dev and curl -i walkthroughs', + routes: { + feed: 'GET /api/feed?page=1&limit=5', + health: 'GET /healthz', + stats: 'GET /stats', + bloomProbe: 'GET /stats/bloom-probe', + }, + try: { + etag: 'GET /api/feed — look for ETag: W/"..."', + notModified: 'repeat with If-None-Match', + querySort: '/api/feed?limit=5&page=2 vs ?page=2&limit=5', + language: 'Accept-Language: en | fr | es', + skipAuth: 'Authorization: Bearer demo', + bloom: 'GET /stats/bloom-probe after a cached /api/feed', + }, + }); +}); + +app.get('/healthz', (c) => c.json({ ok: true })); + +app.get('/stats', (c) => { + return c.json({ + cache: cache.stats(), + bloom: { + insertions: bloom.insertions, + maxCapacity: bloom.maxCapacity, + ...bloom.stats, + }, + remote: { + getCount: remote.getCount, + }, + note: 'Bloom insertions increment on cache.set. Unknown keys return mightContain=false and skip remote L2.', + }); +}); + +/** + * Observable cold-miss defense: probe a never-seen key. + * `EdgeCacheService.get` consults the Bloom filter before `remoteStorage.get`. + */ +app.get('/stats/bloom-probe', async (c) => { + const probeKey = `scanner:${crypto.randomUUID()}`; + const namespaced = `${NAMESPACE}:${probeKey}`; + const remoteGetsBefore = remote.getCount; + const bloomMightContain = bloom.mightContain(namespaced); + const value = await cache.get(probeKey); + const remoteGetsAfter = remote.getCount; + + return c.json({ + probeKey, + namespaced, + bloomMightContain, + cacheValue: value, + remoteGetsBefore, + remoteGetsAfter, + remoteGetSkipped: remoteGetsAfter === remoteGetsBefore, + note: 'Unknown keys are definite MurmurHash3 Bloom misses. EdgeCacheService skips remote L2 (remoteGetSkipped: true).', + }); +}); + +app.get('/api/feed', feedCache, async (c) => { + const started = Date.now(); + const latencyMs = Number(c.env?.ORIGIN_LATENCY_MS) || 250; + await sleep(latencyMs); + + const page = parsePositiveInt(c.req.query('page'), 1, 50); + const limit = parsePositiveInt(c.req.query('limit'), 5, 50); + const lang = resolveLanguage(c.req.header('accept-language')); + const { items, total } = paginateCatalog(lang, page, limit); + const authorized = Boolean(c.req.header('authorization')); + + // Present on origin (cache miss / skipCache) only. Cache hits replay JSON + ETag. + c.header('X-TriCache-Demo', 'origin'); + return c.json({ + lang, + page, + limit, + total, + generatedAt: new Date().toISOString(), + originLatencyMs: Date.now() - started, + cacheBypassed: authorized, + note: 'generatedAt is stamped by the origin. Identical values mean a cache hit. Query order is irrelevant; Accept-Language is part of the key; Authorization skips the cache.', + items, + }); +}); + +function parsePositiveInt(value: string | undefined, fallback: number, max: number): number { + const n = Number(value); + if (!Number.isFinite(n) || n < 1) return fallback; + return Math.min(Math.floor(n), max); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default app; diff --git a/examples/edge-hono/src/memory-remote.ts b/examples/edge-hono/src/memory-remote.ts new file mode 100644 index 0000000..a3127a0 --- /dev/null +++ b/examples/edge-hono/src/memory-remote.ts @@ -0,0 +1,51 @@ +import type { IEdgeRemoteStorage } from 'tricache/edge'; + +interface MemoryRemoteEntry { + value: string; + expiresAt?: number; +} + +/** + * In-isolate L2 stand-in implementing the published `IEdgeRemoteStorage` contract. + * + * Production Workers should swap this for `CloudflareKVAdapter` or `UpstashRedisAdapter`. + * The demo keeps L2 in-memory so Bloom-filter cold-miss skips are observable + * (`getCount` does not increase when `Murmur3BloomFilter.mightContain` is false). + */ +export class MemoryRemoteStorage implements IEdgeRemoteStorage { + private readonly store = new Map(); + getCount = 0; + + async get(key: string): Promise { + this.getCount += 1; + const entry = this.store.get(key); + if (!entry) return null; + if (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.value; + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + const expiresAt = + typeof ttlSeconds === 'number' && ttlSeconds > 0 + ? Date.now() + Math.round(ttlSeconds * 1000) + : undefined; + this.store.set(key, { value, expiresAt }); + } + + async delete(key: string): Promise { + this.store.delete(key); + } + + async clear(prefix?: string): Promise { + if (!prefix) { + this.store.clear(); + return; + } + for (const key of this.store.keys()) { + if (key.startsWith(prefix)) this.store.delete(key); + } + } +} diff --git a/examples/edge-hono/tsconfig.json b/examples/edge-hono/tsconfig.json new file mode 100644 index 0000000..1ff27c7 --- /dev/null +++ b/examples/edge-hono/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "rootDir": "src", + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/edge-hono/wrangler.jsonc b/examples/edge-hono/wrangler.jsonc new file mode 100644 index 0000000..9c6621c --- /dev/null +++ b/examples/edge-hono/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "tricache-edge-hono", + "main": "src/index.ts", + "compatibility_date": "2026-09-19", + // Intentionally no nodejs_compat — tricache/edge is Web Standards only + // (Request, Response, crypto.subtle). Do not import tricache (Node) from this Worker. + "vars": { + "ORIGIN_LATENCY_MS": "250" + } +} diff --git a/examples/fastify-api/.gitignore b/examples/fastify-api/.gitignore new file mode 100644 index 0000000..63c72d4 --- /dev/null +++ b/examples/fastify-api/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.pnpm-debug.log* +.DS_Store +*.tsbuildinfo diff --git a/examples/fastify-api/README.md b/examples/fastify-api/README.md new file mode 100644 index 0000000..f6ff29d --- /dev/null +++ b/examples/fastify-api/README.md @@ -0,0 +1,176 @@ +# TriCache Fastify API Demo + +Minimal Fastify TypeScript app that uses the official Fastify helpers from [`tricache/http`](https://kareem411.github.io/TriCache/integrations/http): + +| Export | Role in this demo | +|---|---| +| [`createFastifyPlugin`](../../src/http/fastify.ts) | Global `fastify.register(...)` — `onRequest` short-circuit + `onSend` capture | +| `fastifyCachePlugin` | Same factory with no preset options (`createFastifyPlugin()`); pass `{ cache, ttl, ... }` at `register()` | +| `fastifyCache` | Dual helper used as a route `preHandler` on `/api/catalog` | + +It shows the behaviors from [Kareem411/TriCache#26](https://github.com/Kareem411/TriCache/issues/26): + +| Behavior | What to look for | +|---|---| +| Global plugin | `GET /api/products` is cached by `onRequest` / `onSend` | +| Route `preHandler` | `GET /api/catalog` is cached by `preHandler: fastifyCache({ ... })` | +| Weak ETag | `ETag: W/"…"` on `200` responses | +| RFC 7232 `304` | Repeat with `If-None-Match` → empty `304 Not Modified` | +| Deterministic query sorting | `?limit=5&page=2` and `?page=2&limit=5` share `generatedAt` + ETag | +| `headerWhitelist: ['accept-language']` | `en` vs `fr` are separate cache entries (`/api/products`) | +| `skipCache` for auth | `Authorization: Bearer …` on `/api/products` always hits origin | + +Origin work is a simulated **350ms** catalog query. Cache hits replay the stored JSON and skip that delay. Hits do **not** replay `X-TriCache-Demo: origin` — that header is set only when the route handler runs. + +The published API is `createFastifyPlugin(options)` / `fastifyCache(options)` with an optional `cache` field — not `createFastifyPlugin(cache, options)`, and not `plugin.preHandler`. + +Redis is not required. The demo uses an in-process L1 cache (`disableRedis: true`, `disableDisk: true`). + +--- + +## 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/fastify-api +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`. + +If you installed `tricache` from npm instead of the repo link, `node --import tsx src/server.ts` (or `pnpm dev`) is enough — no root build step. + +--- + +## Try it with `curl -i` + +Keep the server running in another terminal. Use an explicit `Accept-Language`: an omitted language header and `Accept-Language: en` are **different** cache keys (the whitelist only adds the header when it is present). + +Capture the ETag from a **GET** (`curl -sI` is HEAD, and HEAD is a different cache key). + +### 1. Global plugin — cold miss, weak ETag + +```bash +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' +``` + +Expect `HTTP/1.1 200`, `ETag: W/"…"`, `X-TriCache-Demo: origin`, `"style":"global-plugin"`, and a `generatedAt` timestamp. This request takes ~350ms. The plugin captures the serialized body in `onSend`. + +### 2. Global plugin — swapped query, cache hit (`onRequest` short-circuit) + +```bash +curl -i 'http://127.0.0.1:3000/api/products?page=2&limit=5' \ + -H 'Accept-Language: en' +``` + +Expect the **same** `ETag` and `generatedAt`, no `X-TriCache-Demo` header, and a much faster response. The handler does not run. + +### 3. Global plugin — `304 Not Modified` + +```bash +ETAG=$(curl -sD - -o /dev/null 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' \ + | awk -F': ' 'tolower($1)=="etag"{gsub("\r","",$2); print $2}') + +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' \ + -H "If-None-Match: $ETAG" +``` + +Expect `HTTP/1.1 304 Not Modified`, the same `ETag`, and an **empty** body. + +### 4. Language variants — `headerWhitelist` + +```bash +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: fr' +``` + +Expect a new origin fetch (`X-TriCache-Demo: origin`), a **different** ETag, `lang: "fr"`, and localized names (for example `Haut-parleurs de bureau`). + +### 5. Authenticated request — `skipCache` + +```bash +curl -i 'http://127.0.0.1:3000/api/products?limit=5&page=2' \ + -H 'Accept-Language: en' \ + -H 'Authorization: Bearer demo' +``` + +Expect `cacheBypassed: true`, `X-TriCache-Demo: origin`, **no** `ETag`, and a new `generatedAt` on every call. + +### 6. Route `preHandler` — miss, hit, and `304` + +```bash +curl -i 'http://127.0.0.1:3000/api/catalog?limit=5&page=2' \ + -H 'Accept-Language: en' +``` + +Expect `"style":"route-preHandler"` and a weak ETag. Repeat the swapped query and the `If-None-Match` dance from steps 2–3 against `/api/catalog` — same hit / empty `304` behaviour, implemented by `fastifyCache` wrapping `reply.send` instead of Fastify lifecycle hooks. + +`Cache-Control: no-cache` / `no-store` also bypass the cache (built into `tricache/http`). + +--- + +## Automated check + +```bash +pnpm typecheck +pnpm verify +``` + +`pnpm verify` starts the server on port `34568` and asserts plugin + preHandler hit / 304 behaviour. + +--- + +## How the plugin is wired + +```typescript +import Fastify from 'fastify'; +import { CacheService } from 'tricache'; +import { createFastifyPlugin, fastifyCache, fastifyCachePlugin } from 'tricache/http'; + +const cache = CacheService.create({ + namespace: 'fastify-api-demo', + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, +}); + +const app = Fastify(); + +// Global: onRequest short-circuit + onSend persistence. +await app.register(createFastifyPlugin({ + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['products'], + headerWhitelist: ['accept-language'], + skipCache: (req) => req.url?.split('?')[0] !== '/api/products' + || Boolean(req.headers?.authorization), +})); + +// Equivalent: await app.register(fastifyCachePlugin, { cache, ttl: 120, ... }) + +app.get('/api/catalog', { + preHandler: fastifyCache({ + cache, + ttl: 120, + etag: true, + tags: ['catalog'], + headerWhitelist: ['accept-language'], + }), +}, async () => fetchCatalog()); +``` + +The published options object is `{ cache, ttl, swr, etag, tags, headerWhitelist, skipCache }` — not `(cache, { ttlSeconds })`. diff --git a/examples/fastify-api/package.json b/examples/fastify-api/package.json new file mode 100644 index 0000000..499c996 --- /dev/null +++ b/examples/fastify-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "fastify-api", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "TriCache Fastify microservice demo: createFastifyPlugin, fastifyCache preHandler, weak ETag, 304 Not Modified", + "scripts": { + "dev": "tsx src/server.ts", + "start": "tsx src/server.ts", + "typecheck": "tsc --noEmit", + "verify": "tsx src/verify.ts" + }, + "dependencies": { + "fastify": "^5.6.1", + "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/fastify-api/pnpm-lock.yaml b/examples/fastify-api/pnpm-lock.yaml new file mode 100644 index 0000000..7a7beef --- /dev/null +++ b/examples/fastify-api/pnpm-lock.yaml @@ -0,0 +1,682 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + fastify: + specifier: ^5.6.1 + version: 5.12.5 + 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: + + '@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] + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.1': + resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@types/node@22.20.3': + resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-uri@4.1.5: + resolution: {integrity: sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg==} + + fastify@5.12.5: + resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@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 + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.5 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.1': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@pinojs/redact@0.4.0': {} + + '@types/node@22.20.3': + dependencies: + undici-types: 6.21.0 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.3 + + cookie@1.1.1: {} + + dequal@2.0.3: {} + + 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 + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.5 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.8: {} + + fast-uri@4.1.5: {} + + fastify@5.12.5: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.1 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + fsevents@2.3.3: + optional: true + + ipaddr.js@2.5.0: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + on-exit-leak-free@2.1.2: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + toad-cache@3.7.4: {} + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/examples/fastify-api/pnpm-workspace.yaml b/examples/fastify-api/pnpm-workspace.yaml new file mode 100644 index 0000000..1b56dcb --- /dev/null +++ b/examples/fastify-api/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: false + msgpackr-extract: false diff --git a/examples/fastify-api/src/catalog.ts b/examples/fastify-api/src/catalog.ts new file mode 100644 index 0000000..5b9f758 --- /dev/null +++ b/examples/fastify-api/src/catalog.ts @@ -0,0 +1,185 @@ +export type CatalogLang = 'en' | 'fr' | 'es'; + +export interface LocalizedCopy { + name: string; + category: string; +} + +export interface CatalogProduct { + id: number; + sku: string; + price: number; + copy: Record; +} + +export interface PublicProduct { + id: number; + sku: string; + name: string; + category: string; + price: number; +} + +export const CATALOG: CatalogProduct[] = [ + { + id: 1, + sku: 'kb-01', + price: 129, + copy: { + en: { name: 'Mechanical Keyboard', category: 'peripherals' }, + fr: { name: 'Clavier mécanique', category: 'périphériques' }, + es: { name: 'Teclado mecánico', category: 'periféricos' }, + }, + }, + { + id: 2, + sku: 'ms-02', + price: 79, + copy: { + en: { name: 'Wireless Mouse', category: 'peripherals' }, + fr: { name: 'Souris sans fil', category: 'périphériques' }, + es: { name: 'Ratón inalámbrico', category: 'periféricos' }, + }, + }, + { + id: 3, + sku: 'hd-03', + price: 249, + copy: { + en: { name: 'Studio Headphones', category: 'audio' }, + fr: { name: 'Casque studio', category: 'audio' }, + es: { name: 'Auriculares de estudio', category: 'audio' }, + }, + }, + { + id: 4, + sku: 'mn-04', + price: 399, + copy: { + en: { name: '4K Monitor', category: 'displays' }, + fr: { name: 'Moniteur 4K', category: 'écrans' }, + es: { name: 'Monitor 4K', category: 'pantallas' }, + }, + }, + { + id: 5, + sku: 'dk-05', + price: 189, + copy: { + en: { name: 'Standing Desk Converter', category: 'furniture' }, + fr: { name: 'Convertisseur de bureau debout', category: 'mobilier' }, + es: { name: 'Conversor de escritorio de pie', category: 'mobiliario' }, + }, + }, + { + id: 6, + sku: 'wb-06', + price: 59, + copy: { + en: { name: 'Webcam 1080p', category: 'peripherals' }, + fr: { name: 'Webcam 1080p', category: 'périphériques' }, + es: { name: 'Cámara web 1080p', category: 'periféricos' }, + }, + }, + { + id: 7, + sku: 'sp-07', + price: 149, + copy: { + en: { name: 'Desktop Speakers', category: 'audio' }, + fr: { name: 'Haut-parleurs de bureau', category: 'audio' }, + es: { name: 'Altavoces de escritorio', category: 'audio' }, + }, + }, + { + id: 8, + sku: 'ht-08', + price: 89, + copy: { + en: { name: 'USB Hub', category: 'peripherals' }, + fr: { name: 'Hub USB', category: 'périphériques' }, + es: { name: 'Concentrador USB', category: 'periféricos' }, + }, + }, + { + id: 9, + sku: 'lt-09', + price: 45, + copy: { + en: { name: 'Desk Lamp', category: 'furniture' }, + fr: { name: 'Lampe de bureau', category: 'mobilier' }, + es: { name: 'Lámpara de escritorio', category: 'mobiliario' }, + }, + }, + { + id: 10, + sku: 'pd-10', + price: 69, + copy: { + en: { name: 'Laptop Stand', category: 'furniture' }, + fr: { name: 'Support pour ordinateur portable', category: 'mobilier' }, + es: { name: 'Soporte para portátil', category: 'mobiliario' }, + }, + }, + { + id: 11, + sku: 'mc-11', + price: 119, + copy: { + en: { name: 'USB Microphone', category: 'audio' }, + fr: { name: 'Microphone USB', category: 'audio' }, + es: { name: 'Micrófono USB', category: 'audio' }, + }, + }, + { + id: 12, + sku: 'dp-12', + price: 219, + copy: { + en: { name: 'Ultrawide Display', category: 'displays' }, + fr: { name: 'Écran ultra-large', category: 'écrans' }, + es: { name: 'Pantalla ultrawide', category: 'pantallas' }, + }, + }, +]; + +const SUPPORTED: CatalogLang[] = ['en', 'fr', 'es']; + +/** Map `Accept-Language` to a catalog locale. Unrecognized values fall back to `en`. */ +export function resolveLanguage(header: string | string[] | undefined): CatalogLang { + const raw = Array.isArray(header) ? header[0] : header; + if (!raw) return 'en'; + + const tokens = raw.toLowerCase().split(','); + for (const token of tokens) { + const tag = token.split(';')[0]?.trim() ?? ''; + const base = tag.split('-')[0] ?? ''; + if (SUPPORTED.includes(base as CatalogLang)) { + return base as CatalogLang; + } + } + return 'en'; +} + +export function localizeProduct(product: CatalogProduct, lang: CatalogLang): PublicProduct { + const copy = product.copy[lang]; + return { + id: product.id, + sku: product.sku, + name: copy.name, + category: copy.category, + price: product.price, + }; +} + +export function paginateCatalog(lang: CatalogLang, page: number, limit: number): { + items: PublicProduct[]; + total: number; +} { + const items = CATALOG.map((product) => localizeProduct(product, lang)); + const start = (page - 1) * limit; + return { + items: items.slice(start, start + limit), + total: items.length, + }; +} diff --git a/examples/fastify-api/src/server.ts b/examples/fastify-api/src/server.ts new file mode 100644 index 0000000..1a5210d --- /dev/null +++ b/examples/fastify-api/src/server.ts @@ -0,0 +1,179 @@ +import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; +import { CacheService } from 'tricache'; +import { createFastifyPlugin, fastifyCache } from 'tricache/http'; +import { paginateCatalog, resolveLanguage } from './catalog.js'; + +const PORT = Number(process.env.PORT) || 3000; +const HOST = process.env.HOST ?? '127.0.0.1'; +const ORIGIN_LATENCY_MS = Number(process.env.ORIGIN_LATENCY_MS) || 350; + +/** + * In-process only so the demo runs without Redis or a writable disk tier. + * Swap these flags (or use CacheService.preset('microservice')) for a clustered deploy. + */ +const cache = CacheService.create({ + namespace: 'fastify-api-demo', + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, +}); + +function requestPath(req: { url?: string }): string { + const raw = req.url ?? '/'; + const q = raw.indexOf('?'); + return q >= 0 ? raw.slice(0, q) : raw; +} + +/** + * Shared options object. Official Fastify helpers take `{ cache?, ttl, ... }`, + * not `createFastifyPlugin(cache, options)`. + * + * `fastifyCachePlugin` is `createFastifyPlugin()` with no preset options — + * equivalent registration: `fastify.register(fastifyCachePlugin, productsCacheOptions)`. + */ +const productsCacheOptions = { + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['products'], + /** `en` vs `fr` become distinct keys; `User-Agent` / other headers do not. */ + headerWhitelist: ['accept-language'], + /** + * Limit the global `onRequest`/`onSend` plugin to `/api/products`. + * `/api/catalog` is owned by the route `preHandler` below. + * Authenticated traffic is user-specific — never store it. + */ + skipCache: (req: { url?: string; headers?: Record }) => + requestPath(req) !== '/api/products' || Boolean(req.headers?.authorization), +}; + +const catalogCacheOptions = { + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['catalog'], + headerWhitelist: ['accept-language'], +}; + +const app = Fastify({ logger: false }); + +// Style 1 — global plugin: early short-circuit in `onRequest`, capture in `onSend`. +await app.register(createFastifyPlugin(productsCacheOptions)); + +app.get('/', async () => ({ + name: 'TriCache Fastify API demo', + docs: 'See README.md for curl -i walkthroughs', + exports: { + createFastifyPlugin: 'plugin factory — register(createFastifyPlugin({ cache, ttl, ... }))', + fastifyCachePlugin: 'alias of createFastifyPlugin() with no preset options', + fastifyCache: 'dual helper — register() plugin or route preHandler', + }, + routes: { + products: 'GET /api/products?page=1&limit=5 (global createFastifyPlugin)', + catalog: 'GET /api/catalog?page=1&limit=5 (route preHandler: fastifyCache)', + health: 'GET /healthz', + }, + try: { + etag: 'GET /api/products — look for ETag: W/"..."', + notModified: 'repeat with If-None-Match', + querySort: '/api/products?limit=5&page=2 vs ?page=2&limit=5', + language: 'Accept-Language: en | fr | es', + skipAuth: 'Authorization: Bearer demo on /api/products', + preHandler: 'GET /api/catalog — same ETag/304 via fastifyCache preHandler', + }, +})); + +app.get('/healthz', async () => ({ ok: true })); + +app.get('/api/products', async (request: FastifyRequest, reply: FastifyReply) => { + const started = Date.now(); + await sleep(ORIGIN_LATENCY_MS); + + const query = request.query as Record; + const page = parsePositiveInt(query.page, 1, 50); + const limit = parsePositiveInt(query.limit, 5, 50); + const lang = resolveLanguage(request.headers['accept-language']); + const { items, total } = paginateCatalog(lang, page, limit); + const authorized = Boolean(request.headers.authorization); + + // Origin-only. Cache hits short-circuit in onRequest and never reach this handler. + reply.header('X-TriCache-Demo', 'origin'); + return { + style: 'global-plugin', + hook: 'onRequest + onSend', + lang, + page, + limit, + total, + generatedAt: new Date().toISOString(), + originLatencyMs: Date.now() - started, + cacheBypassed: authorized, + note: 'generatedAt is stamped by the origin. Identical values mean a cache hit. Query order is irrelevant; Accept-Language is part of the key; Authorization skips the cache.', + items, + }; +}); + +// Style 2 — route-level dual handler: probe in preHandler, persist by wrapping reply.send. +app.get( + '/api/catalog', + { preHandler: fastifyCache(catalogCacheOptions) }, + async (request: FastifyRequest, reply: FastifyReply) => { + const started = Date.now(); + await sleep(ORIGIN_LATENCY_MS); + + const query = request.query as Record; + const page = parsePositiveInt(query.page, 1, 50); + const limit = parsePositiveInt(query.limit, 5, 50); + const lang = resolveLanguage(request.headers['accept-language']); + const { items, total } = paginateCatalog(lang, page, limit); + + reply.header('X-TriCache-Demo', 'origin'); + return { + style: 'route-preHandler', + hook: 'fastifyCache preHandler', + lang, + page, + limit, + total, + generatedAt: new Date().toISOString(), + originLatencyMs: Date.now() - started, + cacheBypassed: false, + note: 'This route is cached only by preHandler: fastifyCache({ cache, ttl, ... }). Hits never reach this handler.', + items, + }; + }, +); + +function parsePositiveInt(value: unknown, fallback: number, max: number): number { + const raw = Array.isArray(value) ? value[0] : value; + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) return fallback; + return Math.min(Math.floor(n), max); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function start(): Promise { + await app.listen({ port: PORT, host: HOST }); + console.log(`TriCache Fastify demo listening on http://${HOST}:${PORT}`); +} + +async function shutdown(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 start(); diff --git a/examples/fastify-api/src/verify.ts b/examples/fastify-api/src/verify.ts new file mode 100644 index 0000000..44314b6 --- /dev/null +++ b/examples/fastify-api/src/verify.ts @@ -0,0 +1,251 @@ +/** + * Smoke-checks the demo the same way the README curl -i walkthrough does: + * global plugin (onRequest/onSend), route preHandler, weak ETag, 304, + * sorted query keys, accept-language, skipCache for Authorization. + * + * Uses node:http (not fetch). Undici fetch adds Cache-Control on conditional + * GETs, and tricache/http treats no-cache / no-store as a bypass. + * + * The Fastify plugin persists via fire-and-forget `cache.set` in `onSend`, + * so this script polls briefly after a miss until the hit is observable. + */ +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 header(res: Probe, name: string): string | undefined { + return res.headers[name.toLowerCase()]; +} + +function request(urlPath: string, headers: Record = {}): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const req = http.request( + { host, port, path: urlPath, 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); + req.end(); + }); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function waitForHealth(timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await request('/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 waitForHit( + urlPath: string, + headers: Record, + expectedGeneratedAt: string, + timeoutMs = 2_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: Probe | undefined; + while (Date.now() < deadline) { + last = await request(urlPath, headers); + const isHit = + last.status === 200 + && header(last, 'x-tricache-demo') !== 'origin' + && last.json?.generatedAt === expectedGeneratedAt; + if (isHit) return last; + await delay(25); + } + throw new Error( + `did not observe a cache hit for ${urlPath}: status=${last?.status} origin=${header(last ?? { headers: {} } as Probe, 'x-tricache-demo')} generatedAt=${String(last?.json?.generatedAt)}`, + ); +} + +async function main(): Promise { + const child: ChildProcess = spawn( + process.execPath, + ['--import', 'tsx', path.join(root, 'server.ts')], + { + cwd: path.join(root, '..'), + env: { + ...process.env, + PORT: String(port), + HOST: host, + ORIGIN_LATENCY_MS: process.env.ORIGIN_LATENCY_MS ?? '250', + }, + 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('/api/products?limit=5&page=2', { + 'accept-language': 'en', + }); + const etag = header(miss, 'etag'); + assert(miss.status === 200, `cold GET /api/products expected 200, got ${miss.status}`); + assert(etag?.startsWith('W/"'), `expected weak ETag on products, got ${etag}`); + assert(header(miss, 'x-tricache-demo') === 'origin', 'cold products GET should hit origin'); + assert(miss.json?.style === 'global-plugin', `expected style=global-plugin, got ${String(miss.json?.style)}`); + assert(typeof miss.json?.generatedAt === 'string', 'cold products GET missing generatedAt'); + const generatedAt = miss.json?.generatedAt as string; + console.log(`1. plugin miss ${miss.status} ${etag} ${miss.ms}ms`); + + const swapped = await waitForHit('/api/products?page=2&limit=5', { + 'accept-language': 'en', + }, generatedAt); + assert(header(swapped, 'etag') === etag, 'query order must share ETag (onRequest hit)'); + assert(swapped.json?.generatedAt === generatedAt, 'query order must share generatedAt'); + assert(swapped.ms < 200, `plugin cache hit should skip origin latency, took ${swapped.ms}ms`); + console.log(`2. plugin hit ${swapped.status} same ETag + generatedAt ${swapped.ms}ms`); + + const notModified = await request('/api/products?limit=5&page=2', { + 'accept-language': 'en', + 'if-none-match': etag ?? '', + }); + assert(notModified.status === 304, `If-None-Match expected 304, got ${notModified.status}`); + assert(notModified.body === '', `304 should have an empty body, got ${notModified.body.slice(0, 80)}`); + assert(header(notModified, 'etag') === etag, '304 should echo the weak ETag'); + console.log(`3. plugin 304 ${notModified.status} empty body ${notModified.ms}ms`); + + const french = await request('/api/products?limit=5&page=2', { + 'accept-language': 'fr', + }); + assert(french.status === 200, `fr GET expected 200, got ${french.status}`); + assert(header(french, 'etag') !== etag, 'Accept-Language must change the cache key / ETag'); + assert(french.json?.lang === 'fr', `expected lang=fr, got ${String(french.json?.lang)}`); + assert(header(french, 'x-tricache-demo') === 'origin', 'first fr GET should hit origin'); + const frenchNames = ((french.json?.items as Array<{ name: string }> | undefined) ?? []).map((item) => item.name); + assert( + frenchNames.includes('Haut-parleurs de bureau'), + `expected localized French catalog, got ${frenchNames.join(', ')}`, + ); + console.log(`4. plugin lang ${french.status} lang=fr ${header(french, 'etag')} ${french.ms}ms`); + + const authA = await request('/api/products?limit=5&page=2', { + 'accept-language': 'en', + authorization: 'Bearer demo', + }); + const authB = await request('/api/products?limit=5&page=2', { + 'accept-language': 'en', + authorization: 'Bearer demo', + }); + assert(authA.status === 200 && authB.status === 200, 'auth GET should be 200'); + assert(authA.json?.cacheBypassed === true && authB.json?.cacheBypassed === true, 'auth responses should set cacheBypassed'); + assert(header(authA, 'x-tricache-demo') === 'origin' && header(authB, 'x-tricache-demo') === 'origin', 'auth should skip cache'); + assert(!header(authA, 'etag') && !header(authB, 'etag'), 'skipCache should not attach an ETag'); + assert(authA.json?.generatedAt !== authB.json?.generatedAt, 'auth requests must not reuse generatedAt'); + console.log(`5. plugin skip ${authA.status}/${authB.status} distinct generatedAt ${authA.ms}ms/${authB.ms}ms`); + + const catalogMiss = await request('/api/catalog?limit=5&page=2', { + 'accept-language': 'en', + }); + const catalogEtag = header(catalogMiss, 'etag'); + assert(catalogMiss.status === 200, `cold GET /api/catalog expected 200, got ${catalogMiss.status}`); + assert(catalogEtag?.startsWith('W/"'), `expected weak ETag on catalog, got ${catalogEtag}`); + assert(header(catalogMiss, 'x-tricache-demo') === 'origin', 'cold catalog GET should hit origin'); + assert(catalogMiss.json?.style === 'route-preHandler', `expected style=route-preHandler, got ${String(catalogMiss.json?.style)}`); + assert(typeof catalogMiss.json?.generatedAt === 'string', 'cold catalog GET missing generatedAt'); + const catalogGeneratedAt = catalogMiss.json?.generatedAt as string; + console.log(`6. preHandler miss ${catalogMiss.status} ${catalogEtag} ${catalogMiss.ms}ms`); + + const catalogHit = await waitForHit('/api/catalog?page=2&limit=5', { + 'accept-language': 'en', + }, catalogGeneratedAt); + assert(header(catalogHit, 'etag') === catalogEtag, 'catalog query order must share ETag'); + assert(catalogHit.json?.generatedAt === catalogGeneratedAt, 'catalog query order must share generatedAt'); + assert(catalogHit.ms < 200, `preHandler cache hit should skip origin latency, took ${catalogHit.ms}ms`); + console.log(`7. preHandler hit ${catalogHit.status} same ETag + generatedAt ${catalogHit.ms}ms`); + + const catalog304 = await request('/api/catalog?limit=5&page=2', { + 'accept-language': 'en', + 'if-none-match': catalogEtag ?? '', + }); + assert(catalog304.status === 304, `catalog If-None-Match expected 304, got ${catalog304.status}`); + assert(catalog304.body === '', `catalog 304 should have an empty body, got ${catalog304.body.slice(0, 80)}`); + assert(header(catalog304, 'etag') === catalogEtag, 'catalog 304 should echo the weak ETag'); + console.log(`8. preHandler 304 ${catalog304.status} empty body ${catalog304.ms}ms`); + + console.log('\nAll Fastify demo checks passed.'); + } finally { + child.kill('SIGTERM'); + await delay(300); + if (child.exitCode === null && child.killed === false) { + child.kill('SIGKILL'); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/fastify-api/tsconfig.json b/examples/fastify-api/tsconfig.json new file mode 100644 index 0000000..33470e4 --- /dev/null +++ b/examples/fastify-api/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} 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/package.json b/package.json index 08cb0ff..92bab3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tricache", - "version": "0.8.0", + "version": "0.9.0", "description": "Three-tier Node.js cache: L1 smart RAM (adaptive LFU/LRU + Count-Min Sketch) → L1.5 NVMe disk spill → L2 Redis/Valkey. Includes AES-256-GCM at-rest encryption, WASM Bloom filter, Stale-While-Revalidate, and thundering-herd prevention.", "keywords": [ "cache", @@ -70,6 +70,11 @@ "import": "./dist/http/index.js", "require": "./dist/http/index.cjs" }, + "./hono": { + "types": "./dist/hono/index.d.ts", + "import": "./dist/hono/index.js", + "require": "./dist/hono/index.cjs" + }, "./dashboard": { "types": "./dist/dashboard/index.d.ts", "import": "./dist/dashboard/index.js", @@ -93,7 +98,7 @@ "LICENSE" ], "scripts": { - "build": "tsup src/index.ts src/cli.ts src/serialize-worker.ts src/next/index.ts src/nestjs/index.ts src/prisma/index.ts src/drizzle/index.ts src/http/index.ts src/dashboard/index.ts src/edge/index.ts --format esm,cjs --dts --clean", + "build": "tsup src/index.ts src/cli.ts src/serialize-worker.ts src/next/index.ts src/nestjs/index.ts src/prisma/index.ts src/drizzle/index.ts src/http/index.ts src/hono/index.ts src/dashboard/index.ts src/edge/index.ts --format esm,cjs --dts --clean", "postbuild": "node --input-type=module -e \"import{readdirSync,rmSync,statSync}from'fs';import{join}from'path';function clean(d){for(const f of readdirSync(d)){const p=join(d,f);if(statSync(p).isDirectory())clean(p);else if(p.endsWith('.d.cts'))rmSync(p,{force:true});}}clean('dist');\"", "dev": "tsup src/index.ts src/serialize-worker.ts --format esm,cjs --dts --watch", "typecheck": "tsc --noEmit", @@ -129,12 +134,12 @@ "devDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", - "@types/node": "^26.2.0", + "@types/node": "^26.6.2", "markdown-it-mathjax3": "^4.3.2", - "oxlint": "^1.79.0", + "oxlint": "^1.85.0", "redis": "^6.2.1", "tsup": "^8.5.1", - "tsx": "^4.23.12", + "tsx": "^4.23.15", "typescript": "^6.0.3", "vite": "^8.2.2", "vitepress": "2.0.0-alpha.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38e242c..2f5191f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,35 +27,35 @@ importers: specifier: ^11.0.0 version: 11.2.1(@nestjs/common@11.2.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/node': - specifier: ^26.2.0 - version: 26.2.0 + specifier: ^26.6.2 + version: 26.6.3 markdown-it-mathjax3: specifier: ^4.3.2 version: 4.3.2 oxlint: - specifier: ^1.79.0 - version: 1.79.0 + specifier: ^1.85.0 + version: 1.86.0 redis: specifier: ^6.2.1 version: 6.2.1 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3) + version: 8.5.1(postcss@8.5.26)(tsx@4.23.15)(typescript@6.0.3) tsx: - specifier: ^4.23.12 - version: 4.23.12 + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^6.0.3 version: 6.0.3 vite: specifier: ^8.2.2 - version: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12) + version: 8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15) vitepress: specifier: 2.0.0-alpha.20 - version: 2.0.0-alpha.20(@types/node@26.2.0)(esbuild@0.28.2)(markdown-it-mathjax3@4.3.2)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3) + version: 2.0.0-alpha.20(@types/node@26.6.3)(esbuild@0.28.2)(markdown-it-mathjax3@4.3.2)(postcss@8.5.26)(tsx@4.23.15)(typescript@6.0.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)) + version: 4.1.11(@types/node@26.6.3)(vite@8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15)) packages: @@ -341,124 +341,124 @@ packages: '@oxc-project/types@0.149.0': resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} - '@oxlint/binding-android-arm-eabi@1.79.0': - resolution: {integrity: sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==} + '@oxlint/binding-android-arm-eabi@1.86.0': + resolution: {integrity: sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.79.0': - resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} + '@oxlint/binding-android-arm64@1.86.0': + resolution: {integrity: sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.79.0': - resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} + '@oxlint/binding-darwin-arm64@1.86.0': + resolution: {integrity: sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.79.0': - resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} + '@oxlint/binding-darwin-x64@1.86.0': + resolution: {integrity: sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.79.0': - resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} + '@oxlint/binding-freebsd-x64@1.86.0': + resolution: {integrity: sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': - resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + resolution: {integrity: sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.79.0': - resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} + '@oxlint/binding-linux-arm-musleabihf@1.86.0': + resolution: {integrity: sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.79.0': - resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} + '@oxlint/binding-linux-arm64-gnu@1.86.0': + resolution: {integrity: sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.79.0': - resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} + '@oxlint/binding-linux-arm64-musl@1.86.0': + resolution: {integrity: sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.79.0': - resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} + '@oxlint/binding-linux-ppc64-gnu@1.86.0': + resolution: {integrity: sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.79.0': - resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} + '@oxlint/binding-linux-riscv64-gnu@1.86.0': + resolution: {integrity: sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.79.0': - resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} + '@oxlint/binding-linux-riscv64-musl@1.86.0': + resolution: {integrity: sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.79.0': - resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} + '@oxlint/binding-linux-s390x-gnu@1.86.0': + resolution: {integrity: sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.79.0': - resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} + '@oxlint/binding-linux-x64-gnu@1.86.0': + resolution: {integrity: sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.79.0': - resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} + '@oxlint/binding-linux-x64-musl@1.86.0': + resolution: {integrity: sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.79.0': - resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} + '@oxlint/binding-openharmony-arm64@1.86.0': + resolution: {integrity: sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.79.0': - resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} + '@oxlint/binding-win32-arm64-msvc@1.86.0': + resolution: {integrity: sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.79.0': - resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} + '@oxlint/binding-win32-ia32-msvc@1.86.0': + resolution: {integrity: sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.79.0': - resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} + '@oxlint/binding-win32-x64-msvc@1.86.0': + resolution: {integrity: sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -805,8 +805,8 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/node@26.2.0': - resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1374,12 +1374,12 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - oxlint@1.79.0: - resolution: {integrity: sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==} + oxlint@1.86.0: + resolution: {integrity: sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=7.0.2001' + oxlint-tsgolint: '>=7.0.2003' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -1595,8 +1595,8 @@ packages: typescript: optional: true - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} hasBin: true @@ -1616,8 +1616,8 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -1940,61 +1940,61 @@ snapshots: '@oxc-project/types@0.149.0': {} - '@oxlint/binding-android-arm-eabi@1.79.0': + '@oxlint/binding-android-arm-eabi@1.86.0': optional: true - '@oxlint/binding-android-arm64@1.79.0': + '@oxlint/binding-android-arm64@1.86.0': optional: true - '@oxlint/binding-darwin-arm64@1.79.0': + '@oxlint/binding-darwin-arm64@1.86.0': optional: true - '@oxlint/binding-darwin-x64@1.79.0': + '@oxlint/binding-darwin-x64@1.86.0': optional: true - '@oxlint/binding-freebsd-x64@1.79.0': + '@oxlint/binding-freebsd-x64@1.86.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.79.0': + '@oxlint/binding-linux-arm-musleabihf@1.86.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.79.0': + '@oxlint/binding-linux-arm64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.79.0': + '@oxlint/binding-linux-arm64-musl@1.86.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.79.0': + '@oxlint/binding-linux-ppc64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.79.0': + '@oxlint/binding-linux-riscv64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.79.0': + '@oxlint/binding-linux-riscv64-musl@1.86.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.79.0': + '@oxlint/binding-linux-s390x-gnu@1.86.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.79.0': + '@oxlint/binding-linux-x64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-x64-musl@1.79.0': + '@oxlint/binding-linux-x64-musl@1.86.0': optional: true - '@oxlint/binding-openharmony-arm64@1.79.0': + '@oxlint/binding-openharmony-arm64@1.86.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.79.0': + '@oxlint/binding-win32-arm64-msvc@1.86.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.79.0': + '@oxlint/binding-win32-ia32-msvc@1.86.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.79.0': + '@oxlint/binding-win32-x64-msvc@1.86.0': optional: true '@redis/bloom@6.2.1(@redis/client@6.2.1)': @@ -2221,9 +2221,9 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/node@26.2.0': + '@types/node@26.6.3': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/unist@3.0.3': {} @@ -2231,10 +2231,10 @@ snapshots: '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12))(vue@3.5.42(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15))(vue@3.5.42(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15) vue: 3.5.42(typescript@6.0.3) '@vitest/expect@4.1.11': @@ -2246,13 +2246,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15) '@vitest/pretty-format@4.1.11': dependencies: @@ -2795,27 +2795,27 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - oxlint@1.79.0: + oxlint@1.86.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.79.0 - '@oxlint/binding-android-arm64': 1.79.0 - '@oxlint/binding-darwin-arm64': 1.79.0 - '@oxlint/binding-darwin-x64': 1.79.0 - '@oxlint/binding-freebsd-x64': 1.79.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 - '@oxlint/binding-linux-arm-musleabihf': 1.79.0 - '@oxlint/binding-linux-arm64-gnu': 1.79.0 - '@oxlint/binding-linux-arm64-musl': 1.79.0 - '@oxlint/binding-linux-ppc64-gnu': 1.79.0 - '@oxlint/binding-linux-riscv64-gnu': 1.79.0 - '@oxlint/binding-linux-riscv64-musl': 1.79.0 - '@oxlint/binding-linux-s390x-gnu': 1.79.0 - '@oxlint/binding-linux-x64-gnu': 1.79.0 - '@oxlint/binding-linux-x64-musl': 1.79.0 - '@oxlint/binding-openharmony-arm64': 1.79.0 - '@oxlint/binding-win32-arm64-msvc': 1.79.0 - '@oxlint/binding-win32-ia32-msvc': 1.79.0 - '@oxlint/binding-win32-x64-msvc': 1.79.0 + '@oxlint/binding-android-arm-eabi': 1.86.0 + '@oxlint/binding-android-arm64': 1.86.0 + '@oxlint/binding-darwin-arm64': 1.86.0 + '@oxlint/binding-darwin-x64': 1.86.0 + '@oxlint/binding-freebsd-x64': 1.86.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.86.0 + '@oxlint/binding-linux-arm-musleabihf': 1.86.0 + '@oxlint/binding-linux-arm64-gnu': 1.86.0 + '@oxlint/binding-linux-arm64-musl': 1.86.0 + '@oxlint/binding-linux-ppc64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-musl': 1.86.0 + '@oxlint/binding-linux-s390x-gnu': 1.86.0 + '@oxlint/binding-linux-x64-gnu': 1.86.0 + '@oxlint/binding-linux-x64-musl': 1.86.0 + '@oxlint/binding-openharmony-arm64': 1.86.0 + '@oxlint/binding-win32-arm64-msvc': 1.86.0 + '@oxlint/binding-win32-ia32-msvc': 1.86.0 + '@oxlint/binding-win32-x64-msvc': 1.86.0 parse5-htmlparser2-tree-adapter@6.0.1: dependencies: @@ -2841,12 +2841,12 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.26)(tsx@4.23.12): + postcss-load-config@6.0.1(postcss@8.5.26)(tsx@4.23.15): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.26 - tsx: 4.23.12 + tsx: 4.23.15 postcss@8.5.26: dependencies: @@ -3033,7 +3033,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3): + tsup@8.5.1(postcss@8.5.26)(tsx@4.23.15)(typescript@6.0.3): dependencies: bundle-require: 5.1.0(esbuild@0.28.2) cac: 6.7.14 @@ -3044,7 +3044,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.26)(tsx@4.23.12) + postcss-load-config: 6.0.1(postcss@8.5.26)(tsx@4.23.15) resolve-from: 5.0.0 rollup: 4.62.4 source-map: 0.7.6 @@ -3061,7 +3061,7 @@ snapshots: - tsx - yaml - tsx@4.23.12: + tsx@4.23.15: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -3077,7 +3077,7 @@ snapshots: uint8array-extras@1.5.0: {} - undici-types@8.3.0: {} + undici-types@8.9.0: {} unist-util-is@6.0.1: dependencies: @@ -3114,7 +3114,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12): + vite@8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -3122,12 +3122,12 @@ snapshots: rolldown: 1.2.8 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.2.0 + '@types/node': 26.6.3 esbuild: 0.28.2 fsevents: 2.3.3 - tsx: 4.23.12 + tsx: 4.23.15 - vitepress@2.0.0-alpha.20(@types/node@26.2.0)(esbuild@0.28.2)(markdown-it-mathjax3@4.3.2)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3): + vitepress@2.0.0-alpha.20(@types/node@26.6.3)(esbuild@0.28.2)(markdown-it-mathjax3@4.3.2)(postcss@8.5.26)(tsx@4.23.15)(typescript@6.0.3): dependencies: '@docsearch/css': 4.7.0 '@docsearch/js': 4.7.0 @@ -3135,7 +3135,7 @@ snapshots: '@iconify-json/simple-icons': 1.2.95 '@shikijs/transformers': 4.4.3 '@types/markdown-it': 14.2.0 - '@vitejs/plugin-vue': 6.0.8(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12))(vue@3.5.42(typescript@6.0.3)) + '@vitejs/plugin-vue': 6.0.8(vite@8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15))(vue@3.5.42(typescript@6.0.3)) '@vue/devtools-api': 8.2.1 '@vue/shared': 3.5.42 '@vueuse/core': 14.4.0(vue@3.5.42(typescript@6.0.3)) @@ -3144,7 +3144,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.2.0 shiki: 4.4.3 - vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15) vue: 3.5.42(typescript@6.0.3) optionalDependencies: markdown-it-mathjax3: 4.3.2 @@ -3175,10 +3175,10 @@ snapshots: - universal-cookie - yaml - vitest@4.1.11(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)): + vitest@4.1.11(@types/node@26.6.3)(vite@8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -3195,10 +3195,10 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.2(@types/node@26.6.3)(esbuild@0.28.2)(tsx@4.23.15) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.2.0 + '@types/node': 26.6.3 transitivePeerDependencies: - msw diff --git a/src/edge/hono.ts b/src/edge/hono.ts index 070776d..e17065d 100644 --- a/src/edge/hono.ts +++ b/src/edge/hono.ts @@ -191,13 +191,29 @@ export function honoEdgeCache(options: HonoEdgeCacheOptions = {}) { } if (cached.etag && ifNoneMatch === cached.etag) { - return c.body(null, 304, { ETag: cached.etag }); + return applyEdgeResponse(c, null, 304, { ETag: cached.etag }); } const headers: Record = {}; if (cached.etag) headers['ETag'] = cached.etag; if (cached.contentType) headers['Content-Type'] = cached.contentType; - return c.body(cached.body, cached.status ?? 200, headers); + return applyEdgeResponse(c, cached.body, cached.status ?? 200, headers); }; } + +/** + * Hono's `compose` ignores a middleware return value once `next()` has set + * `c.res` (`finalized === true`). Assigning `c.res` replaces the downstream + * body so weak ETags and 304s are visible on both cache miss and hit. + */ +function applyEdgeResponse( + c: any, + body: string | null, + status: number, + headers: Record, +): Response { + const response = c.body(body, status, headers); + c.res = response; + return response; +} diff --git a/src/hono/index.ts b/src/hono/index.ts new file mode 100644 index 0000000..ca860b8 --- /dev/null +++ b/src/hono/index.ts @@ -0,0 +1,257 @@ +/** + * tricache/hono — first-class Node.js Hono middleware on CacheService. + * + * This is the Node three-tier path (L1 RAM → L1.5 disk → L2 Redis), not the + * Web-Crypto edge helper exported from `tricache/edge` / `tricache/http`. + * + * Usage: + * import { Hono } from 'hono'; + * import { cacheMiddleware } from 'tricache/hono'; + * + * const app = new Hono(); + * app.get('/api/posts', cacheMiddleware({ ttl: 300, tags: ['posts'] }), (c) => { + * return c.json({ data: '...' }); + * }); + */ + +import type { CacheService } from '../cache-service.js'; +import type { WrapOptions } from '../types.js'; +import { + buildDeterministicKey, + generateETag, + shouldSkipCache, + type KeyDerivationOptions, +} from '../http/utils.js'; + +/** + * Minimal Hono context surface used by the middleware. + * Compatible with `import('hono').Context` without taking a runtime dependency. + */ +export interface HonoCacheContext { + req: { + method: string; + url: string; + header?: (name: string) => string | undefined; + headers?: Headers | Record; + }; + res?: { + status?: number; + headers?: { get(name: string): string | null | undefined }; + clone?: () => { text: () => Promise }; + body?: unknown; + }; + body: (data: unknown, status?: number, headers?: Record) => unknown; + executionCtx?: unknown; +} + +export type HonoNext = () => Promise; +export type HonoMiddleware = (c: HonoCacheContext, next: HonoNext) => Promise; + +export interface HonoCacheOptions extends Omit, Omit { + /** TriCache instance. If omitted, lazily resolves the default singleton via CacheService.create(). */ + cache?: CacheService; + /** Whether to generate and evaluate weak ETags. Default: true. */ + etag?: boolean; + /** Custom cache key. Overrides default method + URL + sorted query derivation. */ + keyGenerator?: (c: HonoCacheContext) => string; + /** Custom predicate to skip caching dynamically (e.g. authenticated sessions). */ + skipCache?: (c: HonoCacheContext) => boolean; + /** Dynamic tags derivation from the Hono context. */ + tags?: string[] | ((c: HonoCacheContext) => string[]); +} + +export interface CachedHonoResponse { + body: string; + contentType?: string; + etag?: string; + status: number; +} + +function readRequestHeader(c: HonoCacheContext, name: string): string | undefined { + const headerFn = c.req.header; + if (typeof headerFn === 'function') { + const viaFn = headerFn(name) ?? headerFn(name.toLowerCase()); + if (viaFn !== undefined && viaFn !== null && viaFn !== '') { + return viaFn; + } + } + + const raw = c.req.headers; + if (!raw) return undefined; + + if (typeof (raw as Headers).get === 'function') { + const viaGet = (raw as Headers).get(name); + if (viaGet) return viaGet; + } + + const record = raw as Record; + const val = record[name.toLowerCase()] ?? record[name]; + if (val === undefined || val === null || val === '') return undefined; + return Array.isArray(val) ? val.join(',') : String(val); +} + +function toRequestLike(c: HonoCacheContext, headerWhitelist?: string[]) { + const headers: Record = {}; + const cacheControl = readRequestHeader(c, 'cache-control'); + const ifNoneMatch = readRequestHeader(c, 'if-none-match'); + + if (cacheControl) { + headers['cache-control'] = cacheControl; + headers['Cache-Control'] = cacheControl; + } + if (ifNoneMatch) { + headers['if-none-match'] = ifNoneMatch; + headers['If-None-Match'] = ifNoneMatch; + } + + if (headerWhitelist) { + for (const h of headerWhitelist) { + headers[h.toLowerCase()] = readRequestHeader(c, h); + } + } + + return { + method: (c.req.method || 'GET').toUpperCase(), + url: c.req.url || '/', + headers, + }; +} + +function isSuccessStatus(status: number): boolean { + return status >= 200 && status < 300; +} + +function applyHonoCachedResponse( + c: HonoCacheContext, + cached: CachedHonoResponse, + ifNoneMatch: string | undefined, +): unknown { + if (cached.etag && ifNoneMatch === cached.etag) { + const result = c.body(null, 304, { ETag: cached.etag }); + if (result != null) { + c.res = result as HonoCacheContext['res']; + } + return result; + } + + const headers: Record = {}; + if (cached.etag) headers['ETag'] = cached.etag; + if (cached.contentType) headers['Content-Type'] = cached.contentType; + + const result = c.body(cached.body, cached.status ?? 200, headers); + if (result != null) { + c.res = result as HonoCacheContext['res']; + } + return result; +} + +async function snapshotHonoResponse( + c: HonoCacheContext, + etag: boolean, +): Promise { + const res = c.res; + if (!res) { + return { body: '', status: 0 }; + } + + const clone = typeof res.clone === 'function' ? res.clone() : undefined; + const text = clone && typeof clone.text === 'function' + ? await clone.text() + : typeof res.body === 'string' + ? res.body + : ''; + + const status = res.status ?? 200; + const contentType = res.headers?.get?.('content-type') || 'text/plain; charset=utf-8'; + const bodyEtag = etag ? generateETag(text) : undefined; + + return { + body: text, + contentType, + etag: bodyEtag, + status, + }; +} + +/** + * Creates a Hono middleware that caches GET/HEAD responses through Node + * `CacheService`, with Express-aligned ttl/tags/SWR options, weak ETags, and + * RFC 7232 `If-None-Match` → `304 Not Modified`. Non-2xx responses are never kept. + * + * @example + * import { Hono } from 'hono'; + * import { CacheService } from 'tricache'; + * import { cacheMiddleware } from 'tricache/hono'; + * + * const app = new Hono(); + * const cache = CacheService.create(); + * + * app.get( + * '/api/posts', + * cacheMiddleware({ cache, ttl: 300, tags: ['posts'] }), + * (c) => c.json({ data: '...' }), + * ); + */ +export function cacheMiddleware(options: HonoCacheOptions = {}): HonoMiddleware { + const { + cache, + etag = true, + ttl = 300, + swr, + tags, + skipCache, + keyGenerator, + headerWhitelist, + } = options; + + return async (c, next) => { + const method = (c.req.method || 'GET').toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + return await next(); + } + + const reqLike = toRequestLike(c, headerWhitelist); + if (shouldSkipCache(reqLike, skipCache ? () => skipCache(c) : undefined)) { + return await next(); + } + + let activeCache = cache; + if (!activeCache) { + const { CacheService } = await import('../cache-service.js'); + activeCache = CacheService.create(); + } + + const key = keyGenerator + ? keyGenerator(c) + : buildDeterministicKey(reqLike, { headerWhitelist }); + + const ifNoneMatch = readRequestHeader(c, 'if-none-match'); + const resolvedTags = typeof tags === 'function' ? tags(c) : tags; + + let ranNext = false; + const cached = await activeCache.get( + key, + async () => { + ranNext = true; + await next(); + return snapshotHonoResponse(c, etag); + }, + ttl, + { swr, tags: resolvedTags }, + ); + + // Status gate: only cache 2xx successful responses + if (typeof cached.status === 'number' && !isSuccessStatus(cached.status)) { + await activeCache.delete(key).catch(() => {}); + if (!ranNext) { + return await next(); + } + return; + } + + return applyHonoCachedResponse(c, cached, ifNoneMatch); + }; +} + +/** Alias matching `createExpressMiddleware` / `createKoaMiddleware` naming. */ +export const createHonoMiddleware = cacheMiddleware; diff --git a/src/http/index.ts b/src/http/index.ts index 0b2629b..340f34c 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -10,8 +10,13 @@ * import { fastifyCachePlugin } from 'tricache/http'; * await fastify.register(fastifyCachePlugin, { cache, ttl: 300 }); * + * For Node Hono (CacheService, three-tier L1/L1.5/L2), use the dedicated entry: + * import { cacheMiddleware } from 'tricache/hono'; + * * For Edge runtimes (Cloudflare Workers, Vercel Edge, Deno) and Hono, use: * import { honoEdgeCache } from 'tricache/edge'; + * + * `honoCache` below remains the edge helper re-export for compatibility. */ export { diff --git a/tests/drizzle-orm-example.test.ts b/tests/drizzle-orm-example.test.ts new file mode 100644 index 0000000..7769f08 --- /dev/null +++ b/tests/drizzle-orm-example.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { generateDrizzleCacheKey, withCache } from '../src/drizzle/index.js'; +import { SEED_USERS, USERS_TAG } from '../examples/drizzle-orm/src/seed-data.js'; + +describe('Drizzle ORM Reference Example (examples/drizzle-orm)', () => { + let cache: CacheService; + + beforeEach(() => { + cache = CacheService.create({ + namespace: `drizzle_demo_test_${Date.now()}_${Math.random().toString(16).slice(2)}`, + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, + ttlJitterFactor: 0, + }); + }); + + afterEach(async () => { + await cache.destroy(); + }); + + it('seeds two admins and one member', () => { + expect(SEED_USERS.filter((u) => u.role === 'admin')).toHaveLength(2); + expect(SEED_USERS.filter((u) => u.role === 'member')).toHaveLength(1); + expect(USERS_TAG).toBe('users'); + }); + + it('fingerprints SQL + bind params the same way the demo does', () => { + const admin = { sql: 'select "id" from "users" where "role" = ?', params: ['admin'] }; + const adminAgain = { sql: 'select "id" from "users" where "role" = ?', params: ['admin'] }; + const member = { sql: 'select "id" from "users" where "role" = ?', params: ['member'] }; + + const k1 = generateDrizzleCacheKey(admin); + const k2 = generateDrizzleCacheKey(adminAgain); + const k3 = generateDrizzleCacheKey(member); + + expect(k1).toBe(k2); + expect(k1).not.toBe(k3); + expect(k1).toMatch(/^drizzle:[a-f0-9]{32}$/); + }); + + it('implements demo pipeline: withCache miss/hit, background SWR, invalidateTag', async () => { + let dbExecutions = 0; + const admins = SEED_USERS.filter((u) => u.role === 'admin'); + + const makeQuery = (params: unknown[] = ['admin']) => ({ + toSQL: () => ({ + sql: 'select * from users where role = ?', + params, + }), + execute: async () => { + dbExecutions++; + return admins.map((u) => ({ ...u })); + }, + }); + + const first = await withCache(makeQuery(), { cache, ttl: 1, swr: 60, tags: [USERS_TAG] }); + expect(first).toEqual(admins); + expect(dbExecutions).toBe(1); + + const second = await withCache(makeQuery(), { cache, ttl: 1, swr: 60, tags: [USERS_TAG] }); + expect(second).toEqual(admins); + expect(dbExecutions).toBe(1); + expect(cache.metrics().gets.l1Hits).toBeGreaterThanOrEqual(1); + + await new Promise((resolve) => setTimeout(resolve, 1100)); + const revalidationsBefore = cache.metrics().revalidations.total; + const stale = await withCache(makeQuery(), { cache, ttl: 1, swr: 60, tags: [USERS_TAG] }); + expect(stale).toEqual(admins); + expect(cache.metrics().revalidations.total).toBe(revalidationsBefore + 1); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(dbExecutions).toBe(2); + + await cache.invalidateTag(USERS_TAG); + const afterInvalidate = await withCache(makeQuery(), { cache, ttl: 1, swr: 60, tags: [USERS_TAG] }); + expect(afterInvalidate).toEqual(admins); + expect(dbExecutions).toBe(3); + }); +}); diff --git a/tests/edge-hono.test.ts b/tests/edge-hono.test.ts index a4a377d..8c14878 100644 --- a/tests/edge-hono.test.ts +++ b/tests/edge-hono.test.ts @@ -168,6 +168,25 @@ describe('Decoupled Hono Edge Middleware - Phase 2', () => { expect(res2.headers['ETag']).toBe(etag); }); + it('replaces a Hono-finalized downstream Response so the miss path still emits ETag', async () => { + const middleware = honoEdgeCache({ cache: edgeCache, ttl: 60 }); + const c = createMockHonoContext('/api/finalized-miss'); + Object.defineProperty(c, 'finalized', { value: true, writable: true }); + + await middleware(c, async () => { + c.res = new Response(JSON.stringify({ miss: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const res = c.getResult(); + expect(res.status).toBe(200); + expect(res.headers['ETag']?.startsWith('W/"')).toBe(true); + expect(c.res).toBeInstanceOf(Response); + expect((c.res as Response).headers.get('ETag')).toBe(res.headers['ETag']); + }); + it('bypasses cache when Cache-Control: no-store is passed', async () => { const middleware = honoEdgeCache({ cache: edgeCache, ttl: 60 }); let callCount = 0; diff --git a/tests/fastify-api-example.test.ts b/tests/fastify-api-example.test.ts new file mode 100644 index 0000000..b715b34 --- /dev/null +++ b/tests/fastify-api-example.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { createFastifyPlugin, fastifyCache, fastifyCachePlugin } from '../src/http/index.js'; +import { CATALOG, paginateCatalog, resolveLanguage } from '../examples/fastify-api/src/catalog.js'; + +describe('Fastify API Reference Example (examples/fastify-api)', () => { + let cache: CacheService; + let namespace: string; + + beforeEach(() => { + namespace = `fastify_demo_test_${Date.now()}`; + cache = CacheService.create({ + namespace, + disableRedis: true, + disableDisk: true, + invalidationBackplane: false, + }); + }); + + afterEach(async () => { + await cache.destroy(); + }); + + describe('Catalog Localization & Pagination', () => { + it('resolves supported languages and falls back gracefully to default en', () => { + expect(resolveLanguage('fr-FR,fr;q=0.9')).toBe('fr'); + expect(resolveLanguage('es-ES,es;q=0.8')).toBe('es'); + expect(resolveLanguage('de-DE')).toBe('en'); + expect(resolveLanguage(undefined)).toBe('en'); + }); + + it('paginates the catalog correctly and translates items', () => { + const page1 = paginateCatalog('fr', 1, 3); + expect(page1.items).toHaveLength(3); + expect(page1.total).toBe(CATALOG.length); + expect(page1.items[0].name).toBe('Clavier mécanique'); + + const page2 = paginateCatalog('en', 2, 3); + expect(page2.items).toHaveLength(3); + expect(page2.items[0].name).toBe('4K Monitor'); + }); + }); + + describe('Official Fastify surfaces used by the demo', () => { + it('exports fastifyCachePlugin as createFastifyPlugin() with no preset options', () => { + expect(typeof fastifyCachePlugin).toBe('function'); + expect(typeof createFastifyPlugin).toBe('function'); + expect(typeof fastifyCache).toBe('function'); + }); + + function createMockFastifyApp() { + const hooks: Record> = { + onRequest: [], + onSend: [], + }; + + return { + addHook(name: string, fn: Function) { + hooks[name].push(fn); + }, + async runRequest(req: any, reply: any) { + for (const hook of hooks.onRequest) { + await hook(req, reply); + if (reply.sent) return; + } + }, + async runSend(req: any, reply: any, payload: any) { + let current = payload; + for (const hook of hooks.onSend) { + current = await hook(req, reply, current); + } + return current; + }, + }; + } + + function createMockFastifyReply() { + const headers: Record = {}; + let statusCode = 200; + let sentPayload: any = null; + let isSent = false; + + return { + headers, + statusCode, + get sent() { return isSent; }, + header(name: string, value: string) { + headers[name.toLowerCase()] = value; + return this; + }, + getHeader(name: string) { + return headers[name.toLowerCase()]; + }, + code(code: number) { + statusCode = code; + this.statusCode = code; + return this; + }, + send(payload?: any) { + sentPayload = payload; + isSent = true; + return this; + }, + getPayload: () => sentPayload, + }; + } + + it('createFastifyPlugin: onRequest short-circuit, onSend capture, weak ETag, 304, query order, skipCache', async () => { + const plugin = createFastifyPlugin({ + cache, + ttl: 120, + swr: 30, + etag: true, + tags: ['products'], + headerWhitelist: ['accept-language'], + skipCache: (req: { headers?: Record }) => Boolean(req.headers?.authorization), + }); + + const app = createMockFastifyApp(); + await plugin(app); + + const payload = JSON.stringify({ generatedAt: 't0', catalog: 'sample-data' }); + + const req1 = { method: 'GET', url: '/api/products?limit=5&page=2', headers: { 'accept-language': 'en' } }; + const reply1 = createMockFastifyReply(); + await app.runRequest(req1, reply1); + expect(reply1.sent).toBe(false); + await app.runSend(req1, reply1, payload); + expect(reply1.headers['etag']).toMatch(/^W\/"/); + const etag = reply1.headers['etag']; + + await new Promise((r) => setTimeout(r, 15)); + + const req2 = { method: 'GET', url: '/api/products?page=2&limit=5', headers: { 'accept-language': 'en' } }; + const reply2 = createMockFastifyReply(); + await app.runRequest(req2, reply2); + expect(reply2.sent).toBe(true); + expect(reply2.getPayload()).toBe(payload); + expect(reply2.headers['etag']).toBe(etag); + + const req3 = { + method: 'GET', + url: '/api/products?limit=5&page=2', + headers: { 'accept-language': 'en', 'if-none-match': etag }, + }; + const reply3 = createMockFastifyReply(); + await app.runRequest(req3, reply3); + expect(reply3.sent).toBe(true); + expect(reply3.statusCode).toBe(304); + + const req4 = { method: 'GET', url: '/api/products?limit=5&page=2', headers: { authorization: 'Bearer x' } }; + const reply4 = createMockFastifyReply(); + await app.runRequest(req4, reply4); + expect(reply4.sent).toBe(false); + }); + + it('fastifyCache preHandler: miss, hit, and If-None-Match 304', async () => { + const middleware = fastifyCache({ + cache, + ttl: 120, + etag: true, + tags: ['catalog'], + headerWhitelist: ['accept-language'], + }); + + let handlerCalls = 0; + const body = { style: 'route-preHandler', generatedAt: 't0' }; + + const headers1: Record = {}; + const req1 = { method: 'GET', url: '/api/catalog?limit=5&page=2', headers: { 'accept-language': 'en' } }; + const reply1: any = { + sent: false, + header: (k: string, v: string) => { headers1[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers1[k.toLowerCase()], + code: (c: number) => { reply1.statusCode = c; return reply1; }, + send: (b: any) => { reply1.payload = b; reply1.sent = true; return reply1; }, + }; + + await middleware(req1, reply1); + if (!reply1.sent) { + handlerCalls++; + reply1.send(body); + } + expect(handlerCalls).toBe(1); + expect(headers1['etag']).toMatch(/^W\/"/); + const etag = headers1['etag']; + + await new Promise((r) => setTimeout(r, 15)); + + const headers2: Record = {}; + const req2 = { method: 'GET', url: '/api/catalog?page=2&limit=5', headers: { 'accept-language': 'en' } }; + const reply2: any = { + sent: false, + header: (k: string, v: string) => { headers2[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers2[k.toLowerCase()], + code: (c: number) => { reply2.statusCode = c; return reply2; }, + send: (b: any) => { reply2.payload = b; reply2.sent = true; return reply2; }, + }; + await middleware(req2, reply2); + expect(reply2.sent).toBe(true); + expect(reply2.payload).toEqual(body); + expect(headers2['etag']).toBe(etag); + + const headers3: Record = {}; + let status3 = 200; + const req3 = { + method: 'GET', + url: '/api/catalog?limit=5&page=2', + headers: { 'accept-language': 'en', 'if-none-match': etag }, + }; + const reply3: any = { + sent: false, + header: (k: string, v: string) => { headers3[k.toLowerCase()] = v; }, + getHeader: (k: string) => headers3[k.toLowerCase()], + code: (c: number) => { status3 = c; return reply3; }, + send: () => { reply3.sent = true; return reply3; }, + }; + await middleware(req3, reply3); + expect(status3).toBe(304); + expect(reply3.sent).toBe(true); + }); + }); +}); diff --git a/tests/hono.test.ts b/tests/hono.test.ts new file mode 100644 index 0000000..caffe39 --- /dev/null +++ b/tests/hono.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { cacheMiddleware, createHonoMiddleware } from '../src/hono/index.js'; +import { generateETag } from '../src/http/utils.js'; + +describe('Hono Node middleware (tricache/hono)', () => { + let cache: CacheService | null = null; + + afterEach(async () => { + if (cache) { + await cache.destroy(); + cache = null; + } + }); + + const mockHonoContext = (headers: Record = {}, url = 'https://example.com/api/items') => { + return { + req: { + method: 'GET', + url, + header: (name: string) => headers[name.toLowerCase()], + }, + res: { + status: 200 as number, + clone: () => ({ + text: async () => JSON.stringify({ item: 1 }), + }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn((data, status, hdrs) => ({ data, status, headers: hdrs })), + }; + }; + + it('exports createHonoMiddleware as an alias of cacheMiddleware', () => { + expect(createHonoMiddleware).toBe(cacheMiddleware); + }); + + it('serves a cache miss then a HIT with ETag headers without re-running the handler', async () => { + cache = new CacheService({ + namespace: `hono-hit-${Date.now()}`, + disableRedis: true, + }); + + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + + const c1 = mockHonoContext(); + await middleware(c1, async () => { controllerCalls++; }); + + expect(controllerCalls).toBe(1); + expect(c1.body).toHaveBeenCalledWith( + JSON.stringify({ item: 1 }), + 200, + expect.objectContaining({ ETag: expect.any(String), 'Content-Type': 'application/json' }), + ); + + const etag = (c1.body.mock.calls[0][2] as { ETag: string }).ETag; + expect(etag).toBe(generateETag(JSON.stringify({ item: 1 }))); + + const c2 = mockHonoContext(); + await middleware(c2, async () => { controllerCalls++; }); + + expect(controllerCalls).toBe(1); + expect(c2.body).toHaveBeenCalledWith( + JSON.stringify({ item: 1 }), + 200, + expect.objectContaining({ ETag: etag, 'Content-Type': 'application/json' }), + ); + }); + + it('intercepts Hono Context, generates ETag, and returns 304 Not Modified', async () => { + cache = new CacheService({ + namespace: `hono-test-${Date.now()}`, + disableRedis: true, + }); + + const middleware = cacheMiddleware({ cache, ttl: 60 }); + + let controllerCalls = 0; + + const c1 = mockHonoContext(); + await middleware(c1, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + expect(c1.body).toHaveBeenCalledWith( + JSON.stringify({ item: 1 }), + 200, + expect.objectContaining({ ETag: expect.any(String) }), + ); + + const etag = (c1.body.mock.calls[0][2] as { ETag: string }).ETag; + + const c2 = mockHonoContext({ 'if-none-match': etag }); + await middleware(c2, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + expect(c2.body).toHaveBeenCalledWith(null, 304, { ETag: etag }); + }); + + it('does not cache a 500 response and refetches on the next request', async () => { + cache = new CacheService({ + namespace: `hono-err-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + + let controllerCalls = 0; + const makeCtx = () => ({ + req: { + method: 'GET', + url: 'https://example.com/api/flaky', + header: (_name: string) => undefined, + }, + res: { + status: 200 as number, + clone: () => ({ + text: async () => JSON.stringify( + controllerCalls === 1 ? { error: 'upstream down' } : { item: 'ok' }, + ), + }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn(), + }); + + const c1 = makeCtx(); + await middleware(c1, async () => { controllerCalls++; c1.res.status = 500; }); + + const c2 = makeCtx(); + await middleware(c2, async () => { controllerCalls++; c2.res.status = 200; }); + + expect(controllerCalls).toBe(2); + expect(c2.body).not.toHaveBeenCalledWith( + expect.stringContaining('error'), + 200, + expect.anything(), + ); + expect(c2.body).toHaveBeenCalledWith( + JSON.stringify({ item: 'ok' }), + 200, + expect.objectContaining({ ETag: expect.any(String) }), + ); + }); + + it('uses CacheService.get with ttl, swr, and tags (Node path, not EdgeCacheService)', async () => { + cache = new CacheService({ + namespace: `hono-opts-${Date.now()}`, + disableRedis: true, + }); + const getSpy = vi.spyOn(cache, 'get'); + const middleware = cacheMiddleware({ + cache, + ttl: 42, + swr: 7, + tags: ['posts'], + }); + + const c = mockHonoContext(); + await middleware(c, async () => {}); + + expect(getSpy).toHaveBeenCalled(); + const [, , ttl, opts] = getSpy.mock.calls[0]; + expect(ttl).toBe(42); + expect(opts).toEqual(expect.objectContaining({ swr: 7, tags: ['posts'] })); + getSpy.mockRestore(); + }); + + it('skips caching for non-GET/HEAD methods', async () => { + cache = new CacheService({ + namespace: `hono-post-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + + const postCtx = () => ({ + req: { + method: 'POST', + url: 'https://example.com/api/items', + header: () => undefined, + }, + res: { + status: 200, + clone: () => ({ text: async () => JSON.stringify({ n: controllerCalls }) }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn(), + }); + + const c1 = postCtx(); + await middleware(c1, async () => { controllerCalls++; }); + const c2 = postCtx(); + await middleware(c2, async () => { controllerCalls++; }); + + expect(controllerCalls).toBe(2); + expect(c1.body).not.toHaveBeenCalled(); + expect(c2.body).not.toHaveBeenCalled(); + }); +}); 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(); + }); + }); +}); From 1c1f4f457105ecf241f2eeede0f192d22a8f391c Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 16:29:24 +0300 Subject: [PATCH 03/11] chore: configure hono optional peerDependency and dynamic CLI version resolution --- package.json | 7 ++++++- pnpm-lock.yaml | 9 +++++++++ src/cli.ts | 10 +++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 92bab3f..7d37309 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,8 @@ }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", - "@nestjs/core": "^10.0.0 || ^11.0.0" + "@nestjs/core": "^10.0.0 || ^11.0.0", + "hono": ">=3.0.0 <5.0.0" }, "peerDependenciesMeta": { "@nestjs/common": { @@ -125,6 +126,9 @@ }, "@nestjs/core": { "optional": true + }, + "hono": { + "optional": true } }, "dependencies": { @@ -135,6 +139,7 @@ "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@types/node": "^26.6.2", + "hono": "^4.0.0", "markdown-it-mathjax3": "^4.3.2", "oxlint": "^1.85.0", "redis": "^6.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f5191f..b170ec3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@types/node': specifier: ^26.6.2 version: 26.6.3 + hono: + specifier: ^4.0.0 + version: 4.13.12 markdown-it-mathjax3: specifier: ^4.3.2 version: 4.3.2 @@ -1154,6 +1157,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.13.12: + resolution: {integrity: sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -2576,6 +2583,8 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hono@4.13.12: {} + hookable@5.5.3: {} html-void-elements@3.0.0: {} diff --git a/src/cli.ts b/src/cli.ts index 6043982..bc0bda5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ * npx tricache clear [--redis redis://localhost:6379] [--namespace ] [--prefix ] */ +import { readFileSync } from 'node:fs'; import { parseArgs } from 'node:util'; import { CacheService } from './cache-service.js'; import { @@ -59,7 +60,14 @@ export async function runCli(args: string[] = process.argv.slice(2)): Promise Date: Fri, 2 Oct 2026 16:50:49 +0300 Subject: [PATCH 04/11] fix(hono,edge): prevent SWR/concurrency cache poisoning, streaming hangs, and header loss --- README.md | 2 +- src/edge/hono.ts | 149 +++++++++++++++------ src/hono/index.ts | 117 ++++++++++++----- tests/edge-hono.test.ts | 91 +++++++++++++ tests/hono.test.ts | 278 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 565 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 22342c8..65d5f3e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Docs](https://img.shields.io/badge/docs-VitePress-blue.svg)](https://kareem411.github.io/TriCache/) [![npm version](https://img.shields.io/npm/v/tricache.svg)](https://www.npmjs.com/package/tricache) [![npm downloads](https://img.shields.io/npm/dm/tricache.svg)](https://www.npmjs.com/package/tricache) -[![Tests](https://img.shields.io/badge/tests-834%20passing-brightgreen)](tests) +[![Tests](https://img.shields.io/badge/tests-844%20passing-brightgreen)](tests) [![Code Quality](https://img.shields.io/badge/oxlint-0%20warnings-brightgreen)](src) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js ≥ 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) diff --git a/src/edge/hono.ts b/src/edge/hono.ts index e17065d..d303c8e 100644 --- a/src/edge/hono.ts +++ b/src/edge/hono.ts @@ -22,6 +22,7 @@ export interface HonoEdgeCacheOptions { export interface CachedEdgeHttpResponse { body: string; contentType: string; + headers?: Record; etag?: string; status: number; } @@ -95,6 +96,14 @@ export function buildEdgeDeterministicKey(c: any, options?: { keyGenerator?: (c: return key; } +class NonCacheableEdgeResponseError extends Error { + readonly isNonCacheable = true; + constructor(readonly response: CachedEdgeHttpResponse) { + super(`Non-cacheable status: ${response.status}`); + this.name = 'NonCacheableEdgeResponseError'; + } +} + /** * Pure Web Standards Hono middleware for V8 Edge Isolates (Cloudflare Workers, Fastly, Vercel Edge). * @@ -150,55 +159,111 @@ export function honoEdgeCache(options: HonoEdgeCacheOptions = {}) { const ifNoneMatch = c.req.header?.('if-none-match') || c.req.header?.('If-None-Match'); const resolvedTags = typeof tags === 'function' ? tags(c) : tags; - const cached = await cache.get( - key, - async () => { - await next(); - const res = c.res; - if (!res) { - return null; + let ranNext = false; + try { + const cached = await cache.get( + key, + async () => { + ranNext = true; + await next(); + const res = c.res; + if (!res) { + return null; + } + + const contentType = res.headers?.get?.('content-type') || 'text/plain; charset=utf-8'; + if (contentType.toLowerCase().includes('text/event-stream')) { + throw new NonCacheableEdgeResponseError({ + body: '', + contentType, + status: 0, + }); + } + + const cc = res.headers?.get?.('cache-control'); + if (cc) { + const lowerCc = cc.toLowerCase(); + if (lowerCc.includes('no-store') || lowerCc.includes('no-cache') || lowerCc.includes('private')) { + throw new NonCacheableEdgeResponseError({ + body: '', + contentType, + status: 0, + }); + } + } + + const statusCode = res.status ?? 200; + if (statusCode < 200 || statusCode >= 300 || statusCode === 206) { + throw new NonCacheableEdgeResponseError({ + body: '', + contentType, + status: statusCode, + }); + } + + const clone = res.clone(); + const text = await clone.text(); + const bodyEtag = etag ? await computeEdgeETag(text) : undefined; + + const capturedHeaders: Record = {}; + if (res.headers && typeof res.headers.forEach === 'function') { + res.headers.forEach((value: string, name: string) => { + const lower = name.toLowerCase(); + if ( + lower !== 'content-length' && + lower !== 'transfer-encoding' && + lower !== 'connection' && + lower !== 'etag' && + lower !== 'content-type' + ) { + capturedHeaders[name] = value; + } + }); + } + + const snapshot: CachedEdgeHttpResponse = { + body: text, + contentType, + headers: capturedHeaders, + etag: bodyEtag, + status: statusCode, + }; + + return snapshot; + }, + ttl, + { + swr, + tags: resolvedTags, + ctx: c.executionCtx, } + ); - const clone = res.clone(); - const text = await clone.text(); - const statusCode = res.status ?? 200; - const bodyEtag = etag ? await computeEdgeETag(text) : undefined; - const contentType = res.headers.get('content-type') || 'text/plain; charset=utf-8'; - - return { - body: text, - contentType, - etag: bodyEtag, - status: statusCode, - }; - }, - ttl, - { - swr, - tags: resolvedTags, - ctx: c.executionCtx, + if (!cached) { + return; } - ); - if (!cached) { - return; - } + if (cached.etag && ifNoneMatch === cached.etag) { + const notModifiedHeaders: Record = { ETag: cached.etag }; + if (cached.headers?.['cache-control']) notModifiedHeaders['Cache-Control'] = cached.headers['cache-control']; + if (cached.headers?.['vary']) notModifiedHeaders['Vary'] = cached.headers['vary']; + return applyEdgeResponse(c, null, 304, notModifiedHeaders); + } - // Status gate: never cache error responses - if (typeof cached.status === 'number' && (cached.status < 200 || cached.status >= 300)) { - await cache.delete?.(key); - return; - } + const headers: Record = { ...cached.headers }; + if (cached.etag) headers['ETag'] = cached.etag; + if (cached.contentType) headers['Content-Type'] = cached.contentType; - if (cached.etag && ifNoneMatch === cached.etag) { - return applyEdgeResponse(c, null, 304, { ETag: cached.etag }); + return applyEdgeResponse(c, cached.body, cached.status ?? 200, headers); + } catch (err: unknown) { + if (err instanceof NonCacheableEdgeResponseError) { + if (!ranNext) { + return await next(); + } + return; + } + throw err; } - - const headers: Record = {}; - if (cached.etag) headers['ETag'] = cached.etag; - if (cached.contentType) headers['Content-Type'] = cached.contentType; - - return applyEdgeResponse(c, cached.body, cached.status ?? 200, headers); }; } diff --git a/src/hono/index.ts b/src/hono/index.ts index ca860b8..2fb0f6c 100644 --- a/src/hono/index.ts +++ b/src/hono/index.ts @@ -63,6 +63,7 @@ export interface HonoCacheOptions extends Omit, Omit; etag?: string; status: number; } @@ -117,8 +118,15 @@ function toRequestLike(c: HonoCacheContext, headerWhitelist?: string[]) { }; } -function isSuccessStatus(status: number): boolean { - return status >= 200 && status < 300; +function isCacheableStatus(status: number): boolean { + // Only standard successful full responses (never 206 Partial Content or non-2xx) + return status >= 200 && status < 300 && status !== 206; +} + +function hasNoStoreDirective(cacheControl: string | null | undefined): boolean { + if (!cacheControl) return false; + const lower = cacheControl.toLowerCase(); + return lower.includes('no-store') || lower.includes('no-cache') || lower.includes('private'); } function applyHonoCachedResponse( @@ -127,14 +135,17 @@ function applyHonoCachedResponse( ifNoneMatch: string | undefined, ): unknown { if (cached.etag && ifNoneMatch === cached.etag) { - const result = c.body(null, 304, { ETag: cached.etag }); + const notModifiedHeaders: Record = { ETag: cached.etag }; + if (cached.headers?.['cache-control']) notModifiedHeaders['Cache-Control'] = cached.headers['cache-control']; + if (cached.headers?.['vary']) notModifiedHeaders['Vary'] = cached.headers['vary']; + const result = c.body(null, 304, notModifiedHeaders); if (result != null) { c.res = result as HonoCacheContext['res']; } return result; } - const headers: Record = {}; + const headers: Record = { ...cached.headers }; if (cached.etag) headers['ETag'] = cached.etag; if (cached.contentType) headers['Content-Type'] = cached.contentType; @@ -154,6 +165,23 @@ async function snapshotHonoResponse( return { body: '', status: 0 }; } + const contentType = res.headers?.get?.('content-type') || 'text/plain; charset=utf-8'; + if (contentType.toLowerCase().includes('text/event-stream')) { + // Never buffer or cache Server-Sent Events / infinite streams + return { body: '', contentType, status: 0 }; + } + + const cacheControl = res.headers?.get?.('cache-control'); + if (hasNoStoreDirective(cacheControl)) { + // Response explicitly forbids caching / shared caching + return { body: '', contentType, status: 0 }; + } + + const status = res.status ?? 200; + if (!isCacheableStatus(status)) { + return { body: '', contentType, status }; + } + const clone = typeof res.clone === 'function' ? res.clone() : undefined; const text = clone && typeof clone.text === 'function' ? await clone.text() @@ -161,22 +189,46 @@ async function snapshotHonoResponse( ? res.body : ''; - const status = res.status ?? 200; - const contentType = res.headers?.get?.('content-type') || 'text/plain; charset=utf-8'; const bodyEtag = etag ? generateETag(text) : undefined; + const capturedHeaders: Record = {}; + if (res.headers && typeof (res.headers as any).forEach === 'function') { + (res.headers as any).forEach((value: string, name: string) => { + const lower = name.toLowerCase(); + if ( + lower !== 'content-length' && + lower !== 'transfer-encoding' && + lower !== 'connection' && + lower !== 'etag' && + lower !== 'content-type' + ) { + capturedHeaders[name] = value; + } + }); + } + return { body: text, contentType, + headers: capturedHeaders, etag: bodyEtag, status, }; } +class NonCacheableHonoResponseError extends Error { + readonly isNonCacheable = true; + constructor(readonly response: CachedHonoResponse) { + super(`Non-cacheable response: status=${response.status}`); + this.name = 'NonCacheableHonoResponseError'; + } +} + /** * Creates a Hono middleware that caches GET/HEAD responses through Node * `CacheService`, with Express-aligned ttl/tags/SWR options, weak ETags, and - * RFC 7232 `If-None-Match` → `304 Not Modified`. Non-2xx responses are never kept. + * RFC 7232 `If-None-Match` → `304 Not Modified`. Non-2xx, 206 Partial Content, + * private/no-store, and streaming SSE responses are never kept. * * @example * import { Hono } from 'hono'; @@ -204,6 +256,8 @@ export function cacheMiddleware(options: HonoCacheOptions = {}): HonoMiddleware headerWhitelist, } = options; + let resolvedCache = cache; + return async (c, next) => { const method = (c.req.method || 'GET').toUpperCase(); if (method !== 'GET' && method !== 'HEAD') { @@ -215,11 +269,11 @@ export function cacheMiddleware(options: HonoCacheOptions = {}): HonoMiddleware return await next(); } - let activeCache = cache; - if (!activeCache) { + if (!resolvedCache) { const { CacheService } = await import('../cache-service.js'); - activeCache = CacheService.create(); + resolvedCache = CacheService.create(); } + const activeCache = resolvedCache; const key = keyGenerator ? keyGenerator(c) @@ -229,27 +283,32 @@ export function cacheMiddleware(options: HonoCacheOptions = {}): HonoMiddleware const resolvedTags = typeof tags === 'function' ? tags(c) : tags; let ranNext = false; - const cached = await activeCache.get( - key, - async () => { - ranNext = true; - await next(); - return snapshotHonoResponse(c, etag); - }, - ttl, - { swr, tags: resolvedTags }, - ); - - // Status gate: only cache 2xx successful responses - if (typeof cached.status === 'number' && !isSuccessStatus(cached.status)) { - await activeCache.delete(key).catch(() => {}); - if (!ranNext) { - return await next(); + try { + const cached = await activeCache.get( + key, + async () => { + ranNext = true; + await next(); + const snapshot = await snapshotHonoResponse(c, etag); + if (!isCacheableStatus(snapshot.status)) { + throw new NonCacheableHonoResponseError(snapshot); + } + return snapshot; + }, + ttl, + { swr, tags: resolvedTags }, + ); + + return applyHonoCachedResponse(c, cached, ifNoneMatch); + } catch (err: unknown) { + if (err instanceof NonCacheableHonoResponseError) { + if (!ranNext) { + return await next(); + } + return; } - return; + throw err; } - - return applyHonoCachedResponse(c, cached, ifNoneMatch); }; } diff --git a/tests/edge-hono.test.ts b/tests/edge-hono.test.ts index 8c14878..08354d2 100644 --- a/tests/edge-hono.test.ts +++ b/tests/edge-hono.test.ts @@ -205,5 +205,96 @@ describe('Decoupled Hono Edge Middleware - Phase 2', () => { await middleware(c2, async () => downstream(c2)); expect(callCount).toBe(2); }); + + it('does not cache responses with response-level Cache-Control: no-store or private', async () => { + const middleware = honoEdgeCache({ cache: edgeCache, ttl: 60 }); + let callCount = 0; + + const downstream = async (c: any) => { + callCount++; + c.res = new Response(`token-${callCount}`, { + status: 200, + headers: { 'Cache-Control': 'no-store, private' }, + }); + }; + + const c1 = createMockHonoContext('/api/edge-private'); + await middleware(c1, async () => downstream(c1)); + expect(callCount).toBe(1); + + const c2 = createMockHonoContext('/api/edge-private'); + await middleware(c2, async () => downstream(c2)); + expect(callCount).toBe(2); + }); + + it('never buffers or caches SSE streaming responses (text/event-stream)', async () => { + const middleware = honoEdgeCache({ cache: edgeCache, ttl: 60 }); + let callCount = 0; + + const downstream = async (c: any) => { + callCount++; + c.res = new Response('data: ping\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }; + + const c1 = createMockHonoContext('/api/edge-sse'); + await middleware(c1, async () => downstream(c1)); + expect(callCount).toBe(1); + + const c2 = createMockHonoContext('/api/edge-sse'); + await middleware(c2, async () => downstream(c2)); + expect(callCount).toBe(2); + }); + + it('does not cache 206 Partial Content responses on edge', async () => { + const middleware = honoEdgeCache({ cache: edgeCache, ttl: 60 }); + let callCount = 0; + + const downstream = async (c: any) => { + callCount++; + c.res = new Response('partial-bytes', { + status: 206, + headers: { 'Content-Type': 'video/mp4' }, + }); + }; + + const c1 = createMockHonoContext('/video.mp4'); + await middleware(c1, async () => downstream(c1)); + expect(callCount).toBe(1); + + const c2 = createMockHonoContext('/video.mp4'); + await middleware(c2, async () => downstream(c2)); + expect(callCount).toBe(2); + }); + + it('preserves custom response headers on cache hit', async () => { + const middleware = honoEdgeCache({ cache: edgeCache, ttl: 60 }); + let callCount = 0; + + const downstream = async (c: any) => { + callCount++; + c.res = new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Edge-Region': 'iad', + 'Access-Control-Allow-Origin': '*', + }, + }); + }; + + const c1 = createMockHonoContext('/api/edge-headers'); + await middleware(c1, async () => downstream(c1)); + expect(callCount).toBe(1); + + const c2 = createMockHonoContext('/api/edge-headers'); + await middleware(c2, async () => downstream(c2)); + expect(callCount).toBe(1); + const res2 = c2.getResult(); + expect(res2.headers['x-edge-region']).toBe('iad'); + expect(res2.headers['access-control-allow-origin']).toBe('*'); + }); }); }); diff --git a/tests/hono.test.ts b/tests/hono.test.ts index caffe39..31c6b54 100644 --- a/tests/hono.test.ts +++ b/tests/hono.test.ts @@ -194,4 +194,282 @@ describe('Hono Node middleware (tricache/hono)', () => { expect(c1.body).not.toHaveBeenCalled(); expect(c2.body).not.toHaveBeenCalled(); }); + + it('does not overwrite stale valid cache with 500 response during SWR revalidation', async () => { + cache = new CacheService({ + namespace: `hono-swr-err-${Date.now()}`, + disableRedis: true, + ttlJitterFactor: 0, + }); + const middleware = cacheMiddleware({ cache, ttl: 1, swr: 60 }); + let controllerCalls = 0; + let returnStatus = 200; + + const makeCtx = () => ({ + req: { + method: 'GET', + url: 'https://example.com/api/swr-test', + header: () => undefined, + }, + res: { + status: returnStatus, + clone: () => ({ + text: async () => JSON.stringify( + returnStatus === 200 ? { data: 'healthy' } : { error: 'upstream failure' } + ), + }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn(), + }); + + // Request 1: populate cache with 200 OK + const c1 = makeCtx(); + await middleware(c1, async () => { + controllerCalls++; + c1.res.status = 200; + }); + expect(controllerCalls).toBe(1); + + // Wait 1.1s for TTL to expire, entering SWR window + await new Promise((r) => setTimeout(r, 1100)); + + // Request 2: upstream fails with 500 + returnStatus = 500; + const c2 = makeCtx(); + await middleware(c2, async () => { + controllerCalls++; + c2.res.status = 500; + }); + + // Request 2 should have served the stale healthy data + expect(c2.body).toHaveBeenCalledWith( + JSON.stringify({ data: 'healthy' }), + 200, + expect.anything(), + ); + + // Request 3: verify the cache was NOT poisoned with 500 + returnStatus = 200; + const c3 = makeCtx(); + await middleware(c3, async () => { + controllerCalls++; + c3.res.status = 200; + }); + + // Cache serves healthy data, NOT the 500 error! + expect(c3.body).toHaveBeenCalledWith( + JSON.stringify({ data: 'healthy' }), + 200, + expect.anything(), + ); + }); + + it('does not cache responses with response-level Cache-Control: no-store or private', async () => { + cache = new CacheService({ + namespace: `hono-nostore-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + + const makeCtx = (headers: Map) => ({ + req: { + method: 'GET', + url: 'https://example.com/api/private-data', + header: () => undefined, + }, + res: { + status: 200, + clone: () => ({ + text: async () => JSON.stringify({ token: `secret-${controllerCalls}` }), + }), + headers, + }, + body: vi.fn(), + }); + + const c1 = makeCtx(new Map([ + ['content-type', 'application/json'], + ['cache-control', 'no-store, private'], + ])); + await middleware(c1, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + + const c2 = makeCtx(new Map([ + ['content-type', 'application/json'], + ['cache-control', 'no-store, private'], + ])); + await middleware(c2, async () => { controllerCalls++; }); + // Because c1 set no-store, c2 must trigger a fresh controller call! + expect(controllerCalls).toBe(2); + }); + + it('never buffers or caches SSE streaming responses (text/event-stream)', async () => { + cache = new CacheService({ + namespace: `hono-sse-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + let cloneTextCalled = false; + + const makeSseCtx = () => ({ + req: { + method: 'GET', + url: 'https://example.com/api/sse-events', + header: () => undefined, + }, + res: { + status: 200, + clone: () => ({ + text: async () => { + cloneTextCalled = true; + return 'data: hello\n\n'; + }, + }), + headers: new Map([['content-type', 'text/event-stream']]), + }, + body: vi.fn(), + }); + + const c1 = makeSseCtx(); + await middleware(c1, async () => { controllerCalls++; }); + + expect(controllerCalls).toBe(1); + // clone().text() must NEVER be called on open event streams + expect(cloneTextCalled).toBe(false); + + const c2 = makeSseCtx(); + await middleware(c2, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(2); + }); + + it('does not cache 206 Partial Content responses', async () => { + cache = new CacheService({ + namespace: `hono-206-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + + const makePartialCtx = () => ({ + req: { + method: 'GET', + url: 'https://example.com/video.mp4', + header: () => undefined, + }, + res: { + status: 206, + clone: () => ({ text: async () => 'bytes 0-100' }), + headers: new Map([['content-type', 'video/mp4']]), + }, + body: vi.fn(), + }); + + const c1 = makePartialCtx(); + await middleware(c1, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + + const c2 = makePartialCtx(); + await middleware(c2, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(2); + }); + + it('preserves downstream custom headers on cache hit', async () => { + cache = new CacheService({ + namespace: `hono-hdrs-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let controllerCalls = 0; + + const makeCtx = () => ({ + req: { + method: 'GET', + url: 'https://example.com/api/custom-headers', + header: () => undefined, + }, + res: { + status: 200, + clone: () => ({ text: async () => JSON.stringify({ ok: true }) }), + headers: new Map([ + ['content-type', 'application/json'], + ['x-custom-header', 'tricache-value'], + ['access-control-allow-origin', '*'], + ]), + }, + body: vi.fn(), + }); + + const c1 = makeCtx(); + await middleware(c1, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + + const c2 = makeCtx(); + await middleware(c2, async () => { controllerCalls++; }); + expect(controllerCalls).toBe(1); + expect(c2.body).toHaveBeenCalledWith( + JSON.stringify({ ok: true }), + 200, + expect.objectContaining({ + 'x-custom-header': 'tricache-value', + 'access-control-allow-origin': '*', + 'Content-Type': 'application/json', + }), + ); + }); + + it('allows coalesced concurrent requests to fall back to next() if in-flight request fails', async () => { + cache = new CacheService({ + namespace: `hono-coalesce-${Date.now()}`, + disableRedis: true, + }); + const middleware = cacheMiddleware({ cache, ttl: 60 }); + let req1Resolve: () => void; + const req1Gate = new Promise((r) => { req1Resolve = r; }); + + const c1 = { + req: { method: 'GET', url: 'https://example.com/api/shared', header: () => undefined }, + res: { + status: 500, + clone: () => ({ text: async () => JSON.stringify({ error: 'req1 failed' }) }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn(), + }; + + const c2 = { + req: { method: 'GET', url: 'https://example.com/api/shared', header: () => undefined }, + res: { + status: 200, + clone: () => ({ text: async () => JSON.stringify({ success: 'req2 ok' }) }), + headers: new Map([['content-type', 'application/json']]), + }, + body: vi.fn(), + }; + + let c2CalledNext = false; + + // Start request 1 (will pause inside next) + const p1 = middleware(c1, async () => { + await req1Gate; + c1.res.status = 500; + }); + + // Start request 2 concurrently while req 1 is in-flight (coalescing) + const p2 = middleware(c2, async () => { + c2CalledNext = true; + c2.res.status = 200; + }); + + // Let req1 complete with 500 + req1Resolve!(); + await Promise.all([p1, p2]); + + // Request 2 was coalesced onto req1, but because req1 failed with 500, + // req2 must have safely fallen back to running its own next()! + expect(c2CalledNext).toBe(true); + expect(c2.res.status).toBe(200); + }); }); From 76229c9544634db2b99fc1785b83386fbbe7b93f Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 17:16:23 +0300 Subject: [PATCH 05/11] docs(site): document v0.9.0 audit hardening, SWR error resilience, and updated changelog --- CHANGELOG.md | 14 ++++++++++++++ docs/changelog.md | 14 ++++++++++++++ docs/integrations/edge.md | 9 +++++++-- docs/integrations/hono.md | 16 ++++++++++------ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b8527..64407f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Production Express microservice demo in `examples/express-api/` with weak ETags and 304 validation. ### Fixed +- **SWR Background Revalidation & Concurrency Cache Poisoning (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Fixed vulnerability where non-2xx responses (e.g. 500/404) during background SWR revalidation or singleflight request coalescing could overwrite valid cached data in L1 memory and L2 Redis. Non-2xx responses now throw `NonCacheableHonoResponseError` / `NonCacheableEdgeResponseError` before storage, preventing cache corruption and allowing coalesced callers to fall back gracefully to their own `next()`. +- **Server-Sent Events (SSE) Infinite Buffering Guard (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Pre-inspects `Content-Type: text/event-stream` before calling response clone text buffering, preventing infinite event streams from blocking the Node event loop or V8 edge worker isolates. +- **Response-Level Cache-Control Protection (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Pre-inspects downstream `Cache-Control` response headers (`no-store`, `no-cache`, `private`), preventing authenticated or private responses from being saved to shared multi-user L1/L2 caches. +- **RFC 7234 Section 3 HTTP 206 Partial Content Exclusion (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Explicitly excludes HTTP 206 byte-range responses from full-response URL cache keys to prevent asset corruption. +- **Downstream Response Headers Preservation & RFC 7232 304 Hygiene (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Captures downstream headers (such as CORS `Access-Control-Allow-Origin` and custom response headers) on cache misses and restores them on L1/L2 hits; strips representation metadata on conditional 304 Not Modified responses. +- **Dynamic CLI Version Resolution (`src/cli.ts`)**: + Resolved hardcoded version fallback by dynamically loading `version` from `package.json`. +- **Peer Dependency Optimization for Hono (`package.json`)**: + Configured `hono` as an optional peer dependency (`peerDependenciesMeta: { "hono": { "optional": true } }`) ensuring non-Hono users (Fastify, Express, NestJS) do not incur extra bundle weight. - **Hono Edge Response Assignment (`src/edge/hono.ts`)** ([#39](https://github.com/Kareem411/TriCache/pull/39)): - Fixed an issue where Hono's `compose` ignored middleware return values once `next()` sets `c.res`. Explicitly assigns `c.res = response` via `applyEdgeResponse` so weak ETags and 304s are preserved on both cache misses and hits. - **Windows Named Pipe Discovery & CLI Top Error Handling (`src/cli.ts`, `src/ipc-telemetry.ts`)**: diff --git a/docs/changelog.md b/docs/changelog.md index 83b8527..64407f7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -34,6 +34,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Production Express microservice demo in `examples/express-api/` with weak ETags and 304 validation. ### Fixed +- **SWR Background Revalidation & Concurrency Cache Poisoning (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Fixed vulnerability where non-2xx responses (e.g. 500/404) during background SWR revalidation or singleflight request coalescing could overwrite valid cached data in L1 memory and L2 Redis. Non-2xx responses now throw `NonCacheableHonoResponseError` / `NonCacheableEdgeResponseError` before storage, preventing cache corruption and allowing coalesced callers to fall back gracefully to their own `next()`. +- **Server-Sent Events (SSE) Infinite Buffering Guard (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Pre-inspects `Content-Type: text/event-stream` before calling response clone text buffering, preventing infinite event streams from blocking the Node event loop or V8 edge worker isolates. +- **Response-Level Cache-Control Protection (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Pre-inspects downstream `Cache-Control` response headers (`no-store`, `no-cache`, `private`), preventing authenticated or private responses from being saved to shared multi-user L1/L2 caches. +- **RFC 7234 Section 3 HTTP 206 Partial Content Exclusion (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Explicitly excludes HTTP 206 byte-range responses from full-response URL cache keys to prevent asset corruption. +- **Downstream Response Headers Preservation & RFC 7232 304 Hygiene (`src/hono/index.ts`, `src/edge/hono.ts`)**: + Captures downstream headers (such as CORS `Access-Control-Allow-Origin` and custom response headers) on cache misses and restores them on L1/L2 hits; strips representation metadata on conditional 304 Not Modified responses. +- **Dynamic CLI Version Resolution (`src/cli.ts`)**: + Resolved hardcoded version fallback by dynamically loading `version` from `package.json`. +- **Peer Dependency Optimization for Hono (`package.json`)**: + Configured `hono` as an optional peer dependency (`peerDependenciesMeta: { "hono": { "optional": true } }`) ensuring non-Hono users (Fastify, Express, NestJS) do not incur extra bundle weight. - **Hono Edge Response Assignment (`src/edge/hono.ts`)** ([#39](https://github.com/Kareem411/TriCache/pull/39)): - Fixed an issue where Hono's `compose` ignored middleware return values once `next()` sets `c.res`. Explicitly assigns `c.res = response` via `applyEdgeResponse` so weak ETags and 304s are preserved on both cache misses and hits. - **Windows Named Pipe Discovery & CLI Top Error Handling (`src/cli.ts`, `src/ipc-telemetry.ts`)**: diff --git a/docs/integrations/edge.md b/docs/integrations/edge.md index 24edcb9..5bd20c1 100644 --- a/docs/integrations/edge.md +++ b/docs/integrations/edge.md @@ -83,9 +83,14 @@ app.get( ### Key Edge Middleware Features: * **Zero Node Native Dependencies**: Pure Web Standards (`crypto.subtle`, `Headers`, `Response`). * **Web Crypto Weak ETags**: Automatically calculates SHA-1 / Murmur3 digests using `crypto.subtle.digest('SHA-1', ...)`. -* **RFC 7232 304 Not Modified**: Intercepts matching `If-None-Match` headers for instant 304 responses with 0 bytes transmitted. +* **RFC 7232 304 Not Modified**: Intercepts matching `If-None-Match` headers for instant 304 responses, omitting representation headers per RFC 7232. * **Deterministic Query Sorting**: Groups identical query permutations into a single cache entry. -* **Conditional Bypass**: Automatically honors `Cache-Control: no-cache, no-store` and custom `skipCache` rules. +* **Non-2xx & 206 Status Gating**: Never caches error responses or `206 Partial Content` slices. +* **SWR Revalidation Safety**: Rejects upstream error responses during background revalidations, preserving healthy stale cache entries. +* **Response Cache-Control Protection**: Honors downstream `Cache-Control: no-store`, `no-cache`, and `private` headers. +* **Streaming Response Passthrough**: Automatically bypasses Server-Sent Events (`text/event-stream`), avoiding isolate buffer hangs. +* **Header Preservation**: Restores downstream custom headers (e.g. CORS and regional routing headers) on cache hits. +* **Conditional Bypass**: Automatically honors request `Cache-Control: no-cache, no-store` and custom `skipCache` rules. --- diff --git a/docs/integrations/hono.md b/docs/integrations/hono.md index 1938090..1183fe2 100644 --- a/docs/integrations/hono.md +++ b/docs/integrations/hono.md @@ -53,12 +53,16 @@ app.get( ## Behavior -* **Safe methods only**: `GET` and `HEAD` are cached; other methods pass through. -* **Weak ETags**: SHA-1 weak validators (`ETag: W/"…"`) via the same Node helper as Express. -* **304 Not Modified**: matching `If-None-Match` short-circuits with an empty body. -* **Status gate**: non-2xx responses are never kept (4xx/5xx cannot poison a key). -* **Bypass**: `Cache-Control: no-cache` / `no-store` and a custom `skipCache` predicate skip the cache. -* **SWR & tags**: `ttl`, `swr`, and `tags` are forwarded to `CacheService.get`, matching Express middleware. +* **Safe methods only**: `GET` and `HEAD` are cached; other HTTP methods pass through untouched. +* **Weak ETags**: SHA-1 weak validators (`ETag: W/"…"`) via the Node cryptographic helper. +* **RFC 7232 304 Not Modified**: matching `If-None-Match` short-circuits with a `304` status, omitting representation headers per RFC 7232. +* **Status gate**: non-2xx responses and `206 Partial Content` are never cached (prevents error or truncated range poisoning). +* **SWR error resilience**: if upstream fails with 5xx/4xx during background Stale-While-Revalidate, healthy stale data is retained instead of overwriting cache with errors. +* **Response Cache-Control protection**: downstream responses with `Cache-Control: no-store`, `no-cache`, or `private` are strictly excluded from shared multi-user cache tiers. +* **Streaming response passthrough**: Server-Sent Events (`Content-Type: text/event-stream`) bypass clone buffering automatically, preventing event-loop hangs. +* **Response header preservation**: custom headers (CORS `Access-Control-Allow-Origin`, custom trace IDs) are captured on miss and restored on cache hits. +* **Bypass**: request `Cache-Control: no-cache` / `no-store` and custom `skipCache` predicates bypass the cache. +* **SWR & tags**: `ttl`, `swr`, and `tags` are forwarded to `CacheService.get` with multi-tier Redis/in-memory generational tag invalidation. --- From 02a00ee386f7908e13504622bf7a8f6a4b00f4b5 Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 17:48:00 +0300 Subject: [PATCH 06/11] fix(core): patch 5 systemic concurrency & lifecycle cracks with adversarial test suite --- README.md | 2 +- src/cache-service.ts | 243 ++++++++++++++++--- src/types.ts | 10 + tests/cache-service.test.ts | 4 +- tests/systemic-cracks.test.ts | 432 ++++++++++++++++++++++++++++++++++ 5 files changed, 660 insertions(+), 31 deletions(-) create mode 100644 tests/systemic-cracks.test.ts diff --git a/README.md b/README.md index 65d5f3e..abdec90 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Docs](https://img.shields.io/badge/docs-VitePress-blue.svg)](https://kareem411.github.io/TriCache/) [![npm version](https://img.shields.io/npm/v/tricache.svg)](https://www.npmjs.com/package/tricache) [![npm downloads](https://img.shields.io/npm/dm/tricache.svg)](https://www.npmjs.com/package/tricache) -[![Tests](https://img.shields.io/badge/tests-844%20passing-brightgreen)](tests) +[![Tests](https://img.shields.io/badge/tests-854%20passing-brightgreen)](tests) [![Code Quality](https://img.shields.io/badge/oxlint-0%20warnings-brightgreen)](src) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js ≥ 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) diff --git a/src/cache-service.ts b/src/cache-service.ts index 34acccc..8ecf98d 100644 --- a/src/cache-service.ts +++ b/src/cache-service.ts @@ -469,6 +469,7 @@ export class CacheService { redisHost: string; redisPort: number; redisTls: boolean; disableRedis: boolean; encryptionKey: string | undefined; encryptionMode: 'aes-256-gcm' | 'aes-128-gcm' | 'aes-128-ctr' | 'xor' | undefined; snapshotPath: string; snapshotMaxAgeMs: number; invalidationBackplane: boolean; + awaitInvalidationBackplane: boolean; oomProtection: boolean; oomHeapThreshold: number; oomCheckIntervalMs: number; oomEvictPercent: number; onMetrics: ((m: CacheMetrics) => void) | undefined; @@ -573,6 +574,28 @@ export class CacheService { private streamClient: AnyRedisClient | null = null; private _lastStreamId = '$'; private _destroyed = false; + private readonly _pendingDiskDeletes = new Set(); + private readonly _keyMutationEpochs = new Map(); + + private _assertActive(operation: string): void { + if (this._destroyed) { + throw new Error(`TriCacheError: Cannot perform '${operation}' on a destroyed CacheService instance (namespace: '${this._namespace || "(default)"}').`); + } + } + + private _bumpKeyMutation(namespacedKey: string): number { + const next = (this._keyMutationEpochs.get(namespacedKey) ?? 0) + 1; + this._keyMutationEpochs.set(namespacedKey, next); + if (this._keyMutationEpochs.size > 50_000) { + const oldest = this._keyMutationEpochs.keys().next().value; + if (oldest !== undefined) this._keyMutationEpochs.delete(oldest); + } + return next; + } + + private _getKeyMutationEpoch(namespacedKey: string): number { + return this._keyMutationEpochs.get(namespacedKey) ?? 0; + } private _ipcServer?: IpcTelemetryServer; /** Timestamp (Date.now()) when the backplane subscriber last lost its connection. */ private _subDisconnectedAt: number | null = null; @@ -679,6 +702,7 @@ export class CacheService { os.tmpdir(), ns ? `tricache-snapshot-${ns}.msgpack` : 'tricache-snapshot.msgpack'), snapshotMaxAgeMs: options.snapshotMaxAgeMs ?? DEFAULT_SNAPSHOT_MAX_AGE, invalidationBackplane: options.invalidationBackplane ?? true, + awaitInvalidationBackplane: options.awaitInvalidationBackplane ?? false, oomProtection: options.oomProtection ?? true, oomHeapThreshold: options.oomHeapThreshold ?? 0.85, oomCheckIntervalMs: options.oomCheckIntervalMs ?? 10_000, @@ -1082,7 +1106,7 @@ export class CacheService { 'redisHost', 'redisPort', 'redisTls', 'disableRedis', 'redisClusterNodes', 'redisSentinel', 'encryptionKey', 'encryptionMode', 'l1MaxBytes', 'l1MaxEntries', 'namespace', 'frozen', 'adaptiveTtl', - 'l2WriteMode', 'instanceName', 'invalidationBackplane', + 'l2WriteMode', 'instanceName', 'invalidationBackplane', 'awaitInvalidationBackplane', ]; /** Returns the names of options that differ between `a` and the live `b`. */ @@ -1129,6 +1153,7 @@ export class CacheService { frozen: o.frozen, adaptiveTtl: o.adaptiveTtl, invalidationBackplane: o.invalidationBackplane, + awaitInvalidationBackplane: o.awaitInvalidationBackplane, strictSingleton: o.strictSingleton, remoteSnapshot: o.remoteSnapshot, crossRegion: o.crossRegion, @@ -1447,10 +1472,25 @@ export class CacheService { private _applyInvalidationEvent(op: string, key: string, tagVersion?: number): void { if (op === 'del') { this.l1.delete(key); - setImmediate(() => { if (!this._diskDisabled) this.disk.delete(key); }); + this._bumpKeyMutation(key); + this._pendingDiskDeletes.add(key); + setImmediate(() => { + try { + if (!this._diskDisabled) this.disk.delete(key); + } finally { + this._pendingDiskDeletes.delete(key); + } + }); this._cascadeDependencies(key); } else if (op === 'del-glob') { this.l1.deletePattern(key); + // Invalidate in-flight mutations for any known live keys matching pattern + const pfx = key.endsWith('*') ? key.slice(0, -1) : key; + for (const liveKey of this.l1.liveKeys()) { + if (liveKey.startsWith(pfx)) { + this._bumpKeyMutation(liveKey); + } + } } else if (op === 'tag_incr') { const tag = key; const ver = typeof tagVersion === 'number' ? tagVersion : ((this.tagVersions.get(tag)?.version ?? 0) + 1); @@ -1497,7 +1537,11 @@ export class CacheService { if (!isCrossRegionRelay && this.opts.crossRegion) { const shouldBroadcast = isExplicitInvalidation || Boolean(this.opts.crossRegion.broadcastOnSet); if (shouldBroadcast) { - void this._broadcastCrossRegion(op, key, tagVersion); + if (this.opts.awaitInvalidationBackplane) { + await this._broadcastCrossRegion(op, key, tagVersion); + } else { + void this._broadcastCrossRegion(op, key, tagVersion); + } } } if (!this.opts.invalidationBackplane || this._redisDisabled) return; @@ -1535,7 +1579,12 @@ export class CacheService { ); } this.counters.invSent++; - } catch { /* non-critical — never block the caller */ } + } catch (err) { + this.logger.warn('Backplane invalidation publish failed', { op, key, error: (err as Error).message }); + if (this.opts.awaitInvalidationBackplane) { + throw err; + } + } } private _markCrossRegionEventSeen(id: string): void { @@ -1776,6 +1825,7 @@ export class CacheService { } private async getRedis(): Promise { + this._assertActive('getRedis'); if (!this.cb.isAllowed()) throw new Error('tricache: L2 circuit breaker is open'); if (this.redis) return this.redis; if (this.redisConnecting) return this.redisConnecting; @@ -2152,6 +2202,7 @@ export class CacheService { tags?: string[]; } = {}, ): Promise { + this._assertActive('get'); const span = this._startSpan('tricache.get'); if (this.opts.tracer) span.setAttribute('cache.key_prefix', cacheKey.split(':')[0]); const k = this.nk(cacheKey); // namespaced key used for all storage @@ -2192,7 +2243,14 @@ export class CacheService { if (swrGraceMs > 0 && !this.revalidating.has(k)) { const priority = optPriority ?? inferPriority(cacheKey); this.revalidating.add(k); - void this._revalidate(k, fetchFn, ttlSeconds * 1_000, swrGraceMs, priority); + void this._revalidate( + k, + fetchFn, + ttlSeconds * 1_000, + swrGraceMs, + priority, + l1Hit.tagVersions ?? (optTags ? Object.fromEntries(optTags.map(t => [t, 0])) : undefined), + ); this.counters.swrRevalidations++; this.logger.debug('SWR: serving stale, revalidating', { cacheKey }); } else { @@ -2217,7 +2275,14 @@ export class CacheService { if ((shouldRefreshAhead || shouldXFetch) && !this.revalidating.has(k)) { const priority = optPriority ?? inferPriority(cacheKey); this.revalidating.add(k); - void this._revalidate(k, fetchFn, entryTtl, optSwr * 1_000, priority); + void this._revalidate( + k, + fetchFn, + entryTtl, + optSwr * 1_000, + priority, + l1Hit.tagVersions ?? (optTags ? Object.fromEntries(optTags.map(t => [t, 0])) : undefined), + ); this.counters.swrRevalidations++; this.logger.debug( shouldXFetch ? 'XFetch: proactive background recompute' : 'Refresh-ahead: proactive background recompute', @@ -2257,10 +2322,11 @@ export class CacheService { return existing as Promise; } + const mutationEpochAtStart = this._getKeyMutationEpoch(k); const executionPromise: Promise = (async () => { try { // ── Tier 1.5: disk tier (evicted L1 entries) — protected by latency watchdog ── - if (!this._diskDisabled) { + if (!this._diskDisabled && !this._pendingDiskDeletes.has(k)) { const isDiskAllowed = this.watchdog.isDiskAllowed(); if (isDiskAllowed) { const diskStart = performance.now(); @@ -2430,6 +2496,15 @@ export class CacheService { } } + if (this._getKeyMutationEpoch(k) !== mutationEpochAtStart) { + this.logger.debug('Aborting in-flight cache commit — key was mutated or deleted during fetch', { cacheKey }); + span.setAttribute('cache.aborted_mutation_race', true); + if (this.opts.frozen) deepFreeze(data); + return (this.opts.cloneStrategy === 'structuredClone' && data != null && typeof data === 'object') + ? structuredClone(data) + : data; + } + this.l1.set(k, data, storeTtl, priority, staleAt, delta, activeTagVersions); if (!this._redisDisabled) { @@ -2565,25 +2640,47 @@ export class CacheService { ttlMs: number, swrGraceMs: number, priority: CachePriority, + knownTagVersions?: Record, ): Promise { + const startEpoch = this._getKeyMutationEpoch(cacheKey); try { const fetchStart = Date.now(); const data = await fetchFn(); const delta = Date.now() - fetchStart; const staleAt = Date.now() + ttlMs; + // Invalidation fence: if the key was deleted or mutated while fetchFn was in-flight, abort commit! + if (this._getKeyMutationEpoch(cacheKey) !== startEpoch) { + this.logger.debug('SWR: aborting revalidation commit — key was mutated or deleted during fetch', { cacheKey }); + return; + } + // Keep the latency tracker current during SWR background revalidations too if (this.latencyTracker && data != null) this.latencyTracker.record(cacheKey, delta); let activeTagVersions: Record | undefined; const l1Existing = this.l1.get(cacheKey); - if (this.opts.tagStrategy === 'generational' && l1Existing?.tagVersions) { - const tags = Object.keys(l1Existing.tagVersions); + const tagVersionsSource = l1Existing?.tagVersions ?? knownTagVersions; + if (this.opts.tagStrategy === 'generational' && tagVersionsSource) { + const tags = Object.keys(tagVersionsSource); const vers = await Promise.all(tags.map(tag => this._getTagVersion(tag))); activeTagVersions = {}; for (let i = 0; i < tags.length; i++) { activeTagVersions[tags[i]] = vers[i]; } + if (knownTagVersions) { + for (const t of tags) { + if (knownTagVersions[t] !== undefined && activeTagVersions[t] !== knownTagVersions[t]) { + this.logger.debug('SWR: aborting revalidation commit — tag version bumped during revalidation', { cacheKey, tag: t }); + return; + } + } + } + } + + if (this._getKeyMutationEpoch(cacheKey) !== startEpoch) { + this.logger.debug('SWR: aborting revalidation commit — key was mutated or deleted during tag resolution', { cacheKey }); + return; } this.l1.set(cacheKey, data, ttlMs + swrGraceMs, priority, staleAt, delta, activeTagVersions); @@ -2629,6 +2726,7 @@ export class CacheService { /** Explicitly write a value into L1 (+ L2 in production). */ async set(cacheKey: string, data: T, ttlSeconds = 300, priority?: CachePriority, opts?: { tags?: string[]; dependsOn?: string[] }): Promise { + this._assertActive('set'); const span = this._startSpan('tricache.set'); if (this.opts.tracer) { span.setAttribute('cache.key_prefix', cacheKey.split(':')[0]); @@ -2642,6 +2740,8 @@ export class CacheService { const ttlMs = this._jitterTtl(effectiveTtlSeconds * 1_000); const p = priority ?? inferPriority(cacheKey); const k = this.nk(cacheKey); + this._bumpKeyMutation(k); + this._pendingDiskDeletes.delete(k); this.counters.sets++; let activeTagVersions: Record | undefined; @@ -2700,7 +2800,11 @@ export class CacheService { } } - void this.publishInvalidation('del', k); + if (this.opts.awaitInvalidationBackplane) { + await this.publishInvalidation('del', k); + } else { + void this.publishInvalidation('del', k); + } } catch (err) { span.setStatus({ code: 2, message: err instanceof Error ? err.message : String(err) }); span.recordException?.(err); @@ -2744,6 +2848,7 @@ export class CacheService { * await cache.delete('user:abc:*'); // all keys for user abc */ async delete(cacheKey: string): Promise { + this._assertActive('delete'); const span = this._startSpan('tricache.delete'); if (this.opts.tracer) span.setAttribute('cache.key_prefix', cacheKey.split(':')[0]); try { @@ -2752,16 +2857,24 @@ export class CacheService { this.counters.deletes++; if (isPattern) { + const pfx = k.endsWith('*') ? k.slice(0, -1) : k; + for (const liveKey of this.l1.liveKeys()) { + if (liveKey.startsWith(pfx)) { + this._bumpKeyMutation(liveKey); + } + } this.l1.deletePattern(k); } else { + this._bumpKeyMutation(k); this.l1.delete(k); - // Defer the synchronous SHA-256 hash + fs syscalls to the next event-loop tick so - // the caller's await resolves without blocking. Matches what the backplane handler - // already does for remote invalidations: setImmediate(() => this.disk.delete(msg.key)). - // A re-get in the narrow window before the deferred call fires would get an L1 miss - // and promote the disk entry back — acceptable for a cache (same trade-off the backplane - // path already accepts). - setImmediate(() => { if (!this._diskDisabled) this.disk.delete(k); }); + this._pendingDiskDeletes.add(k); + setImmediate(() => { + try { + if (!this._diskDisabled) this.disk.delete(k); + } finally { + this._pendingDiskDeletes.delete(k); + } + }); // Cascade: invalidate any key that declared it depends on this exact key's pattern this._cascadeDependencies(k); // Clean up: remove k from all dependency registrations (it is gone) @@ -2788,7 +2901,11 @@ export class CacheService { } } - void this.publishInvalidation(isPattern ? 'del-glob' : 'del', k, undefined, false, true); + if (this.opts.awaitInvalidationBackplane) { + await this.publishInvalidation(isPattern ? 'del-glob' : 'del', k, undefined, false, true); + } else { + void this.publishInvalidation(isPattern ? 'del-glob' : 'del', k, undefined, false, true); + } } catch (err) { span.setStatus({ code: 2, message: err instanceof Error ? err.message : String(err) }); span.recordException?.(err); @@ -2817,6 +2934,7 @@ export class CacheService { * fleet-wide. */ async increment(cacheKey: string, ttlSeconds?: number): Promise { + this._assertActive('increment'); const k = this.nk(cacheKey); if (this._redisDisabled) { @@ -2862,6 +2980,7 @@ export class CacheService { * await cache.clear('user:abc'); // flush all keys for one user */ async clear(prefix?: string): Promise { + this._assertActive('clear'); const span = this._startSpan('tricache.clear'); if (this.opts.tracer) span.setAttribute('cache.prefix', prefix || '*'); try { @@ -2871,6 +2990,12 @@ export class CacheService { : undefined; if (k) { + const pfx = k.endsWith('*') ? k.slice(0, -1) : k; + for (const liveKey of this.l1.liveKeys()) { + if (liveKey.startsWith(pfx)) { + this._bumpKeyMutation(liveKey); + } + } this.l1.deletePattern(k); // Disk-tier pattern delete is not supported (files are keyed by SHA-256 hash); // prefix-scoped clears only evict from L1, matching existing delete('glob*') semantics. @@ -2880,6 +3005,8 @@ export class CacheService { this._l1Counters.clear(); this.tagIndex.clear(); this.tagVersions.clear(); + this._pendingDiskDeletes.clear(); + this._keyMutationEpochs.clear(); } if (!this._redisDisabled && this.opts.l2WriteMode === 'read-write') { @@ -2893,9 +3020,15 @@ export class CacheService { } } - void this.publishInvalidation('del-glob', - k ?? (this._namespace ? `${this._namespace}:*` : '*'), - undefined, false, true); + if (this.opts.awaitInvalidationBackplane) { + await this.publishInvalidation('del-glob', + k ?? (this._namespace ? `${this._namespace}:*` : '*'), + undefined, false, true); + } else { + void this.publishInvalidation('del-glob', + k ?? (this._namespace ? `${this._namespace}:*` : '*'), + undefined, false, true); + } } catch (err) { span.setStatus({ code: 2, message: err instanceof Error ? err.message : String(err) }); span.recordException?.(err); @@ -2911,6 +3044,7 @@ export class CacheService { * Returns the number of entries evicted. */ rebalance(): number { + this._assertActive('rebalance'); return this.l1.rebalance(); } @@ -2920,6 +3054,7 @@ export class CacheService { * Only reflects L1 state — does not query Redis or disk. */ ttl(cacheKey: string): number | null { + this._assertActive('ttl'); return this.l1.ttl(this.nk(cacheKey)); } @@ -2928,6 +3063,7 @@ export class CacheService { * Bloom-filter fast path — no fetch, no disk or Redis round-trip. */ has(cacheKey: string): boolean { + this._assertActive('has'); return this.l1.has(this.nk(cacheKey)); } @@ -2940,6 +3076,7 @@ export class CacheService { * for (const key of cache.keys()) console.log(key); */ *keys(): Generator { + this._assertActive('keys'); const prefix = this._namespace ? this._namespace + ':' : ''; for (const key of this.l1.liveKeys()) { if (prefix && !key.startsWith(prefix)) continue; @@ -2955,6 +3092,7 @@ export class CacheService { * for (const val of cache.values()) console.log(val.id); */ *values(): Generator { + this._assertActive('values'); const prefix = this._namespace ? this._namespace + ':' : ''; if (!prefix) { yield* this.l1.liveValues() as Generator; @@ -2974,6 +3112,7 @@ export class CacheService { * for (const [key, val] of cache.entries()) console.log(key, val.id); */ *entries(): Generator<[string, T]> { + this._assertActive('entries'); const prefix = this._namespace ? this._namespace + ':' : ''; for (const [key, entry] of this.l1.liveEntries()) { if (prefix && !key.startsWith(prefix)) continue; @@ -3002,6 +3141,7 @@ export class CacheService { * `offset` — `rawKey.slice(offset)` gives the bare key without namespace. */ scan(fn: (rawKey: string, value: T, offset: number) => void): void { + this._assertActive('scan'); const prefixLen = this._namespace ? this._namespace.length + 1 : 0; this.l1.scan((key, entry, pfx) => { const value = (entry.value !== undefined ? entry.value : entry.data) as T; @@ -3016,6 +3156,7 @@ export class CacheService { * @param newTtlSeconds - The new TTL from now, in seconds. */ async touch(cacheKey: string, newTtlSeconds: number): Promise { + this._assertActive('touch'); const k = this.nk(cacheKey); const hit = this.l1.touch(k, newTtlSeconds * 1_000); if (hit && !this._redisDisabled) { @@ -3036,6 +3177,7 @@ export class CacheService { * if (fresh !== null) return fresh; // serve from L1, no network hop */ getIfFresh(cacheKey: string): T | null { + this._assertActive('getIfFresh'); const k = this.nk(cacheKey); const entry = this.l1.getEntry(k); if (!entry) return null; @@ -3054,6 +3196,7 @@ export class CacheService { * const val = await cache.peek('user:123'); */ async peek(cacheKey: string): Promise { + this._assertActive('peek'); const k = this.nk(cacheKey); // 1. L1 RAM @@ -3069,7 +3212,7 @@ export class CacheService { } // 2. L1.5 Disk - if (!this._diskDisabled && this.watchdog.isDiskAllowed()) { + if (!this._diskDisabled && !this._pendingDiskDeletes.has(k) && this.watchdog.isDiskAllowed()) { const diskStart = performance.now(); const diskHit = this.disk.load(k); const diskElapsed = performance.now() - diskStart; @@ -3139,6 +3282,7 @@ export class CacheService { ttl: number | ((key: string) => number) = 300, priority?: CachePriority, ): Promise<(T | undefined)[]> { + this._assertActive('mget'); const span = this._startSpan('tricache.mget'); if (this.opts.tracer) { span.setAttribute('cache.batch.size', keys.length); @@ -3214,8 +3358,9 @@ export class CacheService { // ── Tier 1.5: disk spill (evicted L1 entries) ── if (!this._diskDisabled && missKeys.length > 0) { for (let j = missKeys.length - 1; j >= 0; j--) { - if (!this.watchdog.isDiskAllowed()) continue; const k = this.nk(missKeys[j]); + if (this._pendingDiskDeletes.has(k)) continue; + if (!this.watchdog.isDiskAllowed()) continue; const diskStart = performance.now(); const diskHit = this.disk.load(k); const diskElapsed = performance.now() - diskStart; @@ -3303,6 +3448,7 @@ export class CacheService { async mset( entries: Record, ): Promise { + this._assertActive('mset'); const span = this._startSpan('tricache.mset'); const keys = Object.keys(entries); if (this.opts.tracer) { @@ -3331,6 +3477,7 @@ export class CacheService { * await cache.mdel(['user:1', 'user:2', 'user:3']); */ async mdel(keys: string[]): Promise { + this._assertActive('mdel'); const span = this._startSpan('tricache.mdel'); if (this.opts.tracer) { span.setAttribute('cache.batch.size', keys.length); @@ -3359,6 +3506,7 @@ export class CacheService { * console.log(`Warmed ${loaded} keys from Redis`); */ async warmFromL2(pattern: string, opts?: { priority?: CachePriority }): Promise { + this._assertActive('warmFromL2'); if (this._redisDisabled) return 0; try { const client = await this.getRedis(); @@ -3412,6 +3560,7 @@ export class CacheService { * await cache.invalidateTag('catalog'); // clears product:1 and any other tagged entries */ async invalidateTag(tag: string): Promise { + this._assertActive('invalidateTag'); const span = this._startSpan('tricache.invalidate_tag'); if (this.opts.tracer) { span.setAttribute('cache.tag', tag); @@ -3434,7 +3583,11 @@ export class CacheService { newVer = current + 1; } this._setLocalTagVersion(tag, newVer, Date.now()); - void this.publishInvalidation('tag_incr', tag, newVer, false, true); + if (this.opts.awaitInvalidationBackplane) { + await this.publishInvalidation('tag_incr', tag, newVer, false, true); + } else { + void this.publishInvalidation('tag_incr', tag, newVer, false, true); + } return; } @@ -3444,7 +3597,15 @@ export class CacheService { // Remove from L1 + disk for (const k of members) { this.l1.delete(k); - if (!this._diskDisabled) this.disk.delete(k); + this._bumpKeyMutation(k); + this._pendingDiskDeletes.add(k); + setImmediate(() => { + try { + if (!this._diskDisabled) this.disk.delete(k); + } finally { + this._pendingDiskDeletes.delete(k); + } + }); } this.tagIndex.delete(tagKey); @@ -3484,6 +3645,7 @@ export class CacheService { * await cache.invalidateTags(['case:acme', 'org:acme', 'ai-chat:acme']); */ async invalidateTags(tags: string[]): Promise { + this._assertActive('invalidateTags'); if (tags.length === 0) return; if (tags.length === 1) { await this.invalidateTag(tags[0]); return; } @@ -3499,7 +3661,11 @@ export class CacheService { const [err, newVer] = results[i] ?? [null, null]; const ver = (!err && typeof newVer === 'number') ? newVer : (this.tagVersions.get(tags[i])?.version ?? 0) + 1; this._setLocalTagVersion(tags[i], ver, now); - void this.publishInvalidation('tag_incr', tags[i], ver, false, true); + if (this.opts.awaitInvalidationBackplane) { + await this.publishInvalidation('tag_incr', tags[i], ver, false, true); + } else { + void this.publishInvalidation('tag_incr', tags[i], ver, false, true); + } } return; } catch (err) { @@ -3509,7 +3675,11 @@ export class CacheService { for (const tag of tags) { const ver = (this.tagVersions.get(tag)?.version ?? 0) + 1; this._setLocalTagVersion(tag, ver, now); - void this.publishInvalidation('tag_incr', tag, ver, false, true); + if (this.opts.awaitInvalidationBackplane) { + await this.publishInvalidation('tag_incr', tag, ver, false, true); + } else { + void this.publishInvalidation('tag_incr', tag, ver, false, true); + } } return; } @@ -3526,7 +3696,15 @@ export class CacheService { } for (const k of allMembers) { this.l1.delete(k); - this.disk.delete(k); + this._bumpKeyMutation(k); + this._pendingDiskDeletes.add(k); + setImmediate(() => { + try { + if (!this._diskDisabled) this.disk.delete(k); + } finally { + this._pendingDiskDeletes.delete(k); + } + }); } if (!this._redisDisabled) { @@ -3568,6 +3746,7 @@ export class CacheService { * `l2` is `null` when Redis is disabled. */ async ping(): Promise { + this._assertActive('ping'); // L1: measure a has() call const t0 = Date.now(); this.l1.has('__ping__'); @@ -3657,6 +3836,7 @@ export class CacheService { * Returns the number of keys written. */ async drainToL2(): Promise { + this._assertActive('drainToL2'); if (this._redisDisabled) return 0; try { const client = await this.getRedis(); @@ -3705,6 +3885,7 @@ export class CacheService { * if (!claimed) return res.status(409).json({ error: 'duplicate request' }); */ async setIfAbsent(cacheKey: string, value: T, ttlSeconds = 300, priority?: CachePriority): Promise { + this._assertActive('setIfAbsent'); const k = this.nk(cacheKey); // Fast path: L1 check (process-local, no network hop) @@ -3729,6 +3910,8 @@ export class CacheService { const ttlMs = this._jitterTtl(ttlSeconds * 1_000); const p = priority ?? inferPriority(cacheKey); + this._bumpKeyMutation(k); + this._pendingDiskDeletes.delete(k); this.l1.set(k, value, ttlMs, p); this.counters.sets++; return true; @@ -3753,6 +3936,7 @@ export class CacheService { fn: () => Promise, options?: LockOptions, ): Promise { + this._assertActive('lock'); const ttlSeconds = Math.max(1, options?.ttl ?? 30); const acquireTimeout = Math.max(0, options?.acquireTimeout ?? 5_000); const retryInterval = Math.max(10, options?.retryInterval ?? 100); @@ -4162,6 +4346,7 @@ export class CacheService { * the previous key for seamless fallback decryption of existing cache entries. */ async rotateEncryptionKey(newKeyBase64: string, newMode?: EncryptionMode): Promise { + this._assertActive('rotateEncryptionKey'); this.enc.rotateKey(newKeyBase64, newMode); if (this._workerPool) { await this._workerPool.drainAndReinit(this.enc.toWorkerInit()); @@ -4172,6 +4357,8 @@ export class CacheService { /** Close Redis connections and stop all background timers. */ async destroy(): Promise { this._destroyed = true; + this._pendingDiskDeletes.clear(); + this._keyMutationEpochs.clear(); const g = globalThis as Record; const key = CacheService.globalKey(this.opts); if (g[key] === this) { diff --git a/src/types.ts b/src/types.ts index 068ca17..07965c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -675,6 +675,16 @@ export interface CacheOptions { */ useShardedPubSub?: boolean; + /** + * If true, await backplane invalidation broadcasts during delete(), set(), + * clear(), and invalidateTag() operations rather than firing them in the background. + * Guarantees that remote cluster nodes receive the invalidation before the + * operation resolves to the caller. + * + * Default: `false` (asynchronous fire-and-forget for minimal caller latency). + */ + awaitInvalidationBackplane?: boolean; + // ── Multi-Region / Cross-Cluster Invalidation Relay ───────────────────── /** * Optional cross-region invalidation relay configuration for geo-distributed deployments. diff --git a/tests/cache-service.test.ts b/tests/cache-service.test.ts index c91d493..ed309be 100644 --- a/tests/cache-service.test.ts +++ b/tests/cache-service.test.ts @@ -705,8 +705,8 @@ describe('invalidateTags() batch invalidation', () => { it('is equivalent to calling invalidateTag() for each tag individually', async () => { const dir1 = tempDir(); const dir2 = tempDir(); - const s1 = CacheService.reset({ disableRedis: true, diskCacheDir: dir1 }); - const s2 = CacheService.reset({ disableRedis: true, diskCacheDir: dir2 }); + const s1 = CacheService.reset({ namespace: 'tag_batch_1', disableRedis: true, diskCacheDir: dir1 }); + const s2 = CacheService.reset({ namespace: 'tag_batch_2', disableRedis: true, diskCacheDir: dir2 }); try { for (const s of [s1, s2]) { await s.set('p:1', 'v', 60, undefined, { tags: ['alpha'] }); diff --git a/tests/systemic-cracks.test.ts b/tests/systemic-cracks.test.ts new file mode 100644 index 0000000..8896302 --- /dev/null +++ b/tests/systemic-cracks.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CacheService } from '../src/cache-service.js'; +import { SmartCacheEntry } from '../src/types.js'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; + +function tempDir(): string { + const d = path.join(os.tmpdir(), `tricache-crack-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(d, { recursive: true }); + return d; +} + +function makeTestEntry(val: unknown, ttlMs = 60_000): SmartCacheEntry { + const data = Buffer.from(JSON.stringify(val)); + return { + value: val, + data, + isCompressed: false, + expiresAt: Date.now() + ttlMs, + size: data.byteLength, + hits: 1, + lastAccess: Date.now(), + priority: 1, + }; +} + +describe('Systemic Invariant & Crack Hardening Audit Tests', () => { + let diskDir: string; + + beforeEach(() => { + diskDir = tempDir(); + }); + + afterEach(() => { + try { + fs.rmSync(diskDir, { recursive: true, force: true }); + } catch { /* ok */ } + }); + + describe('Failure Path 1: SWR & SingleFlight Invalidation Resurrection Zombie', () => { + it('aborts commit if key is deleted while fetchFn is in-flight (SingleFlight)', async () => { + const cache = CacheService.reset({ + namespace: 'crack_sf_del', + disableRedis: true, + diskCacheDir: diskDir, + }); + + let resolveFetch!: (val: string) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + // Start in-flight get + const getPromise = cache.get('user:101', () => fetchPromise, 60); + + // Concurrent delete arrives while fetch is pending + await cache.delete('user:101'); + + // Now fetch finishes with stale data + resolveFetch('old-user-data'); + const result = await getPromise; + + expect(result).toBe('old-user-data'); + // But the cache must NOT have committed it! + expect(cache.has('user:101')).toBe(false); + expect(cache.getIfFresh('user:101')).toBeNull(); + + await cache.destroy(); + }); + + it('aborts commit if key is updated via set() while fetchFn is in-flight (avoids mutation overwrite)', async () => { + const cache = CacheService.reset({ + namespace: 'crack_sf_mutate', + disableRedis: true, + diskCacheDir: diskDir, + }); + + let resolveFetch!: (val: string) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + const getPromise = cache.get('user:102', () => fetchPromise, 60); + + // Concurrent mutation arrives with newer value + await cache.set('user:102', 'new-updated-data', 60); + + // Old fetch completes + resolveFetch('stale-data-from-slow-query'); + await getPromise; + + // The cache must retain the newer mutated data, NOT the stale fetch result! + expect(cache.getIfFresh('user:102')).toBe('new-updated-data'); + + await cache.destroy(); + }); + + it('aborts SWR revalidation commit if key is deleted while revalidation is in-flight', async () => { + const cache = CacheService.reset({ + namespace: 'crack_swr_del', + disableRedis: true, + diskCacheDir: diskDir, + }); + + // Populate initial entry + await cache.set('profile:1', 'initial-profile', 1); + + // Artificially age the entry into SWR grace period + const nk = (cache as any).nk('profile:1'); + const entry = (cache as any).l1.getEntry(nk); + if (entry) { + entry.staleAt = Date.now() - 10; + entry.expiresAt = Date.now() + 10_000; + } + + let resolveRevalidate!: (val: string) => void; + const revalidatePromise = new Promise((resolve) => { + resolveRevalidate = resolve; + }); + + // Trigger SWR background revalidation by calling get() + const staleResult = await cache.get( + 'profile:1', + () => revalidatePromise, + 60, + { swr: 30 }, + ); + expect(staleResult).toBe('initial-profile'); + + // Key is explicitly deleted during background revalidation + await cache.delete('profile:1'); + expect(cache.has('profile:1')).toBe(false); + + // SWR fetch finishes + resolveRevalidate('resurrected-profile'); + await new Promise(r => setTimeout(r, 50)); // allow background microtasks to finish + + // Key must NOT be resurrected by SWR! + expect(cache.has('profile:1')).toBe(false); + expect(cache.getIfFresh('profile:1')).toBeNull(); + + await cache.destroy(); + }); + + it('aborts SWR revalidation commit if generational tag version was bumped during revalidation', async () => { + const cache = CacheService.reset({ + namespace: 'crack_swr_tag', + disableRedis: true, + diskCacheDir: diskDir, + tagStrategy: 'generational', + }); + + await cache.set('product:99', 'initial-product', 1, undefined, { tags: ['catalog'] }); + + // Age entry into SWR grace period + const nk = (cache as any).nk('product:99'); + const entry = (cache as any).l1.getEntry(nk); + if (entry) { + entry.staleAt = Date.now() - 10; + entry.expiresAt = Date.now() + 10_000; + } + + let resolveRevalidate!: (val: string) => void; + const revalidatePromise = new Promise((resolve) => { + resolveRevalidate = resolve; + }); + + await cache.get( + 'product:99', + () => revalidatePromise, + 60, + { swr: 30, tags: ['catalog'] }, + ); + + // Tag is invalidated while revalidation fetch is in-flight! + await cache.invalidateTag('catalog'); + + // Now revalidation fetch finishes with old product state + resolveRevalidate('stale-product-after-catalog-update'); + await new Promise(r => setTimeout(r, 50)); + + // Key must not have been committed under the new tag version with stale data! + // When get() is called, it must detect tag version mismatch and fetch fresh data: + let freshFetched = false; + const val = await cache.get('product:99', async () => { + freshFetched = true; + return 'fresh-product'; + }, 60); + + expect(freshFetched).toBe(true); + expect(val).toBe('fresh-product'); + + await cache.destroy(); + }); + }); + + describe('Failure Path 2: Deferred Disk Unlink Inversion', () => { + it('same-tick read immediately after delete does not load pending disk file into L1', async () => { + const cache = CacheService.reset({ + namespace: 'crack_disk_defer', + disableRedis: true, + diskCacheDir: diskDir, + }); + + // Write directly to disk tier + const k = (cache as any).nk('doc:file1'); + const testEntry = makeTestEntry({ title: 'important secret document' }); + (cache as any).disk.save(k, testEntry); + + // Also ensure it is in L1 + (cache as any).l1.set(k, testEntry.value, 60_000, 1); + expect(cache.has('doc:file1')).toBe(true); + + // Delete the key — this unlinks from L1 and schedules setImmediate for disk unlink + await cache.delete('doc:file1'); + + // Immediately peek in the SAME event-loop tick before setImmediate fires: + // Without pendingDiskDeletes guard, peek() would check disk, find the file, and resurrect it! + const peeked = await cache.peek('doc:file1'); + expect(peeked).toBeNull(); + expect(cache.has('doc:file1')).toBe(false); + + // Same check with get() in the same tick: + let fetchRan = false; + const fetched = await cache.get('doc:file1', async () => { + fetchRan = true; + return { title: 'new freshly fetched doc' }; + }); + + expect(fetchRan).toBe(true); + expect(fetched).toEqual({ title: 'new freshly fetched doc' }); + + await cache.destroy(); + }); + + it('remote backplane del invalidation also marks pending disk delete', async () => { + const cache = CacheService.reset({ + namespace: 'crack_remote_disk', + disableRedis: true, + diskCacheDir: diskDir, + }); + + const k = (cache as any).nk('remote:key1'); + const testEntry = makeTestEntry('remote-value'); + (cache as any).disk.save(k, testEntry); + + // Simulate remote backplane invalidation arrival + cache._handleBackplaneMessage(JSON.stringify({ + op: 'del', + key: k, + src: 'other-node-42', + })); + + // Immediately peek before setImmediate disk unlink runs + const peeked = await cache.peek('remote:key1'); + expect(peeked).toBeNull(); + + await cache.destroy(); + }); + }); + + describe('Failure Path 3: LRU-Eviction Tag Stripping Trap during SWR', () => { + it('retains generational tag versions when L1 entry is evicted during in-flight SWR fetch', async () => { + const cache = CacheService.reset({ + namespace: 'crack_lru_tags', + disableRedis: true, + diskCacheDir: diskDir, + tagStrategy: 'generational', + }); + + await cache.set('item:42', { name: 'Widget' }, 1, undefined, { tags: ['inventory'] }); + + // Age into SWR grace period + const nk = (cache as any).nk('item:42'); + const entry = (cache as any).l1.getEntry(nk); + if (entry) { + entry.staleAt = Date.now() - 10; + entry.expiresAt = Date.now() + 10_000; + } + + let resolveFetch!: (val: { name: string }) => void; + const fetchPromise = new Promise<{ name: string }>((resolve) => { + resolveFetch = resolve; + }); + + // Start SWR get + await cache.get( + 'item:42', + () => fetchPromise, + 60, + { swr: 30, tags: ['inventory'] }, + ); + + // Simulating extreme L1 memory churn while fetch is pending: + // The L1 entry is evicted by LRU/OOM! + (cache as any).l1.delete(nk); + expect((cache as any).l1.get(nk)).toBeNull(); + + // Now SWR fetch resolves + resolveFetch({ name: 'Updated Widget' }); + await new Promise(r => setTimeout(r, 50)); + + // The new entry must be in L1 AND MUST RETAIN the inventory tag versions! + const revalidated = (cache as any).l1.get(nk); + expect(revalidated).not.toBeNull(); + expect(revalidated.value).toEqual({ name: 'Updated Widget' }); + expect(revalidated.tagVersions).toBeDefined(); + expect(revalidated.tagVersions['inventory']).toBeDefined(); + + // When the tag is invalidated, calling get() must detect generational staleness, + // evict the entry, and trigger a fresh fetch: + await cache.invalidateTag('inventory'); + + let reFetchRan = false; + const val = await cache.get('item:42', async () => { + reFetchRan = true; + return { name: 'Fresh Post-Invalidation Widget' }; + }, 60); + + expect(reFetchRan).toBe(true); + expect(val).toEqual({ name: 'Fresh Post-Invalidation Widget' }); + + await cache.destroy(); + }); + }); + + describe('Failure Path 4: Destroyed Instance Zombie', () => { + it('throws TriCacheError immediately on public methods after destroy()', async () => { + const cache = CacheService.reset({ + namespace: 'crack_destroyed_guard', + disableRedis: true, + diskCacheDir: diskDir, + }); + + await cache.destroy(); + + await expect(cache.get('k', async () => 'v')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.set('k', 'v')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.delete('k')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.increment('k')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.clear()).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.peek('k')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.mget(['k'], async () => ({}))).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.mset({ k: { value: 'v' } })).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.mdel(['k'])).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.invalidateTag('t')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.invalidateTags(['t'])).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.setIfAbsent('k', 'v')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.lock('res', async () => 'ok')).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.touch('k', 60)).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.drainToL2()).rejects.toThrow(/destroyed CacheService instance/); + await expect(cache.ping()).rejects.toThrow(/destroyed CacheService instance/); + expect(() => cache.has('k')).toThrow(/destroyed CacheService instance/); + expect(() => cache.ttl('k')).toThrow(/destroyed CacheService instance/); + expect(() => cache.getIfFresh('k')).toThrow(/destroyed CacheService instance/); + expect(() => cache.rebalance()).toThrow(/destroyed CacheService instance/); + expect(() => [...cache.keys()]).toThrow(/destroyed CacheService instance/); + expect(() => [...cache.values()]).toThrow(/destroyed CacheService instance/); + expect(() => [...cache.entries()]).toThrow(/destroyed CacheService instance/); + expect(() => cache.scan(() => {})).toThrow(/destroyed CacheService instance/); + }); + }); + + describe('Failure Path 5: Silent Backplane Invalidation Message Loss & Guaranteed Delivery Option', () => { + it('propagates backplane publish errors when awaitInvalidationBackplane is true', async () => { + const mockRedisClient = { + publish: vi.fn().mockRejectedValue(new Error('Redis cluster connection severed')), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + const cache = CacheService.reset({ + namespace: 'crack_backplane_throw', + disableRedis: false, + invalidationBackplane: true, + awaitInvalidationBackplane: true, + redisClient: mockRedisClient as any, + diskCacheDir: diskDir, + }); + + // set() should throw because backplane publish failed + await expect(cache.set('key:sync', 'value')).rejects.toThrow('Redis cluster connection severed'); + + // delete() should throw because backplane publish failed + await expect(cache.delete('key:sync')).rejects.toThrow('Redis cluster connection severed'); + + // clear() should throw because backplane publish failed + await expect(cache.clear()).rejects.toThrow('Redis cluster connection severed'); + + await cache.destroy(); + }); + + it('swallows backplane publish errors and logs warning when awaitInvalidationBackplane is false (default)', async () => { + const warnSpy = vi.fn(); + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: warnSpy, + error: vi.fn(), + }; + + const mockRedisClient = { + publish: vi.fn().mockRejectedValue(new Error('Redis connection timeout')), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + const cache = CacheService.reset({ + namespace: 'crack_backplane_default', + disableRedis: false, + invalidationBackplane: true, + awaitInvalidationBackplane: false, // default fire-and-forget + logger: mockLogger, + redisClient: mockRedisClient as any, + diskCacheDir: diskDir, + }); + + // set() must NOT throw + await expect(cache.set('key:async', 'value')).resolves.not.toThrow(); + + // delete() must NOT throw + await expect(cache.delete('key:async')).resolves.not.toThrow(); + + // Warning should have been logged + expect(warnSpy).toHaveBeenCalledWith( + 'Backplane invalidation publish failed', + expect.objectContaining({ error: 'Redis connection timeout' }), + ); + + await cache.destroy(); + }); + }); +}); From 369b6a9dd3fe02135008d73db988c9fe378da724 Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 17:50:29 +0300 Subject: [PATCH 07/11] docs(changelog): document v0.9.0 systemic concurrency hardening and lifecycle resilience --- CHANGELOG.md | 8 ++++++++ docs/changelog.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64407f7..343e129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Prometheus Observability Recipe & Express API Demo** ([#30](https://github.com/Kareem411/TriCache/issues/30), [#31](https://github.com/Kareem411/TriCache/pull/31), [#25](https://github.com/Kareem411/TriCache/issues/25), [#35](https://github.com/Kareem411/TriCache/pull/35)): - Complete Prometheus `/metrics` scraping recipe with Grafana dashboard configuration (`docs/recipes/prometheus-metrics.md`). - Production Express microservice demo in `examples/express-api/` with weak ETags and 304 validation. +- **Configurable Backplane Delivery Assurance (`awaitInvalidationBackplane`) (`src/types.ts`, `src/cache-service.ts`)**: + - Introduced `awaitInvalidationBackplane?: boolean` in `CacheOptions`. + - When enabled, invalidation broadcasts (`set`, `delete`, `clear`, `invalidateTag`, `invalidateTags`) are awaited and propagate transport failures directly to callers rather than failing silently in the background, providing guaranteed delivery semantics for mission-critical write paths. ### Fixed +- **Core Concurrency Hardening & Mutation Epoch Fencing (`src/cache-service.ts`)**: + - **Mutation Epoch Fencing**: Added per-key monotonic mutation epoch tracking (`_keyMutationEpochs`). Both SingleFlight coalesced `get()` calls and SWR background revalidations (`_revalidate`) snapshot the key's mutation epoch before executing `fetchFn()`. If a concurrent `delete()`, `set()`, or `clear()` mutates the key while the fetch is in-flight, the stale commit to L1 and Redis is aborted, preventing zombie resurrection and race-condition data overwrites. + - **Disk Deletion Tombstones**: Introduced `_pendingDiskDeletes` synchronous tombstoning to guard against deferred `setImmediate(() => this.disk.delete(k))` unlink races. Immediate same-tick reads (`peek`, `get`, `mget`) after `delete()` or remote backplane `del` are prevented from reading or resurrecting the stale disk file into L1. + - **Tag Retention & Invalidation Abortion during SWR**: `get()` snapshots and propagates `knownTagVersions` into `_revalidate()`. Even under severe L1 memory churn or OOM evictions where the entry is purged during a slow query, generational tag metadata is preserved when re-populating L1 and Redis. Furthermore, if a generational tag version was bumped during the in-flight revalidation fetch, the commit is aborted to prevent resurrecting invalidated tag state. + - **Strict Lifecycle Guards**: Hardened all public API and internal methods (`get`, `set`, `delete`, `peek`, `mget`, `mset`, `mdel`, `clear`, `increment`, `lock`, `touch`, `getIfFresh`, `has`, `ttl`, `scan`, `keys`, `values`, `entries`, `ping`, `drainToL2`, `rotateEncryptionKey`, `getRedis`) with `_assertActive()`. Operations invoked on destroyed `CacheService` instances immediately fast-fail with `TriCacheError` rather than leaking zombie state or reconnecting orphaned Redis clients. - **SWR Background Revalidation & Concurrency Cache Poisoning (`src/hono/index.ts`, `src/edge/hono.ts`)**: Fixed vulnerability where non-2xx responses (e.g. 500/404) during background SWR revalidation or singleflight request coalescing could overwrite valid cached data in L1 memory and L2 Redis. Non-2xx responses now throw `NonCacheableHonoResponseError` / `NonCacheableEdgeResponseError` before storage, preventing cache corruption and allowing coalesced callers to fall back gracefully to their own `next()`. - **Server-Sent Events (SSE) Infinite Buffering Guard (`src/hono/index.ts`, `src/edge/hono.ts`)**: diff --git a/docs/changelog.md b/docs/changelog.md index 64407f7..343e129 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -32,8 +32,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Prometheus Observability Recipe & Express API Demo** ([#30](https://github.com/Kareem411/TriCache/issues/30), [#31](https://github.com/Kareem411/TriCache/pull/31), [#25](https://github.com/Kareem411/TriCache/issues/25), [#35](https://github.com/Kareem411/TriCache/pull/35)): - Complete Prometheus `/metrics` scraping recipe with Grafana dashboard configuration (`docs/recipes/prometheus-metrics.md`). - Production Express microservice demo in `examples/express-api/` with weak ETags and 304 validation. +- **Configurable Backplane Delivery Assurance (`awaitInvalidationBackplane`) (`src/types.ts`, `src/cache-service.ts`)**: + - Introduced `awaitInvalidationBackplane?: boolean` in `CacheOptions`. + - When enabled, invalidation broadcasts (`set`, `delete`, `clear`, `invalidateTag`, `invalidateTags`) are awaited and propagate transport failures directly to callers rather than failing silently in the background, providing guaranteed delivery semantics for mission-critical write paths. ### Fixed +- **Core Concurrency Hardening & Mutation Epoch Fencing (`src/cache-service.ts`)**: + - **Mutation Epoch Fencing**: Added per-key monotonic mutation epoch tracking (`_keyMutationEpochs`). Both SingleFlight coalesced `get()` calls and SWR background revalidations (`_revalidate`) snapshot the key's mutation epoch before executing `fetchFn()`. If a concurrent `delete()`, `set()`, or `clear()` mutates the key while the fetch is in-flight, the stale commit to L1 and Redis is aborted, preventing zombie resurrection and race-condition data overwrites. + - **Disk Deletion Tombstones**: Introduced `_pendingDiskDeletes` synchronous tombstoning to guard against deferred `setImmediate(() => this.disk.delete(k))` unlink races. Immediate same-tick reads (`peek`, `get`, `mget`) after `delete()` or remote backplane `del` are prevented from reading or resurrecting the stale disk file into L1. + - **Tag Retention & Invalidation Abortion during SWR**: `get()` snapshots and propagates `knownTagVersions` into `_revalidate()`. Even under severe L1 memory churn or OOM evictions where the entry is purged during a slow query, generational tag metadata is preserved when re-populating L1 and Redis. Furthermore, if a generational tag version was bumped during the in-flight revalidation fetch, the commit is aborted to prevent resurrecting invalidated tag state. + - **Strict Lifecycle Guards**: Hardened all public API and internal methods (`get`, `set`, `delete`, `peek`, `mget`, `mset`, `mdel`, `clear`, `increment`, `lock`, `touch`, `getIfFresh`, `has`, `ttl`, `scan`, `keys`, `values`, `entries`, `ping`, `drainToL2`, `rotateEncryptionKey`, `getRedis`) with `_assertActive()`. Operations invoked on destroyed `CacheService` instances immediately fast-fail with `TriCacheError` rather than leaking zombie state or reconnecting orphaned Redis clients. - **SWR Background Revalidation & Concurrency Cache Poisoning (`src/hono/index.ts`, `src/edge/hono.ts`)**: Fixed vulnerability where non-2xx responses (e.g. 500/404) during background SWR revalidation or singleflight request coalescing could overwrite valid cached data in L1 memory and L2 Redis. Non-2xx responses now throw `NonCacheableHonoResponseError` / `NonCacheableEdgeResponseError` before storage, preventing cache corruption and allowing coalesced callers to fall back gracefully to their own `next()`. - **Server-Sent Events (SSE) Infinite Buffering Guard (`src/hono/index.ts`, `src/edge/hono.ts`)**: From f37e306e572e7115240bc2929a8b8e042878ec63 Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 18:19:33 +0300 Subject: [PATCH 08/11] fix(core): harden systemic cracks in inflight coalescence, clock domains, lock fallback, and disk tombstones --- CHANGELOG.md | 5 + docs/changelog.md | 5 + src/cache-service.ts | 102 +++++++++++-- src/types.ts | 15 ++ tests/systemic-cracks.test.ts | 267 ++++++++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 343e129..2cf6e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Core Concurrency Hardening & Mutation Epoch Fencing (`src/cache-service.ts`)**: - **Mutation Epoch Fencing**: Added per-key monotonic mutation epoch tracking (`_keyMutationEpochs`). Both SingleFlight coalesced `get()` calls and SWR background revalidations (`_revalidate`) snapshot the key's mutation epoch before executing `fetchFn()`. If a concurrent `delete()`, `set()`, or `clear()` mutates the key while the fetch is in-flight, the stale commit to L1 and Redis is aborted, preventing zombie resurrection and race-condition data overwrites. + - **SingleFlight In-Flight Eviction on Mutations**: `set()`, `delete()`, `clear()`, and backplane invalidations synchronously evict entries from `this.inflight`, preventing subsequent reads from coalescing onto stale pre-mutation in-flight fetches. Additionally, in-flight reads that detect an epoch mismatch check L1 for fresher committed data before falling back. + - **Clock Domain Unification for Generational Tags**: Replaced `Date.now()` Unix epoch timestamps with monotonic `performance.now()` in `_setLocalTagVersion`, `invalidateTag`, `invalidateTags`, and backplane `tag_incr`. Eliminates an epoch inversion where negative deltas caused local tag versions to be treated as permanently fresh and never re-polled from Redis. + - **Global Epoch Monotonicity on Cache Flush**: Added a global monotonic epoch counter `_globalEpoch` incremented on `cache.clear()`. Ensures in-flight reads initiated prior to `clear()` detect the epoch divergence and abort committing purged keys back into L1 and Redis. + - **Distributed Lock Split-Brain Prevention (`failClosedOnRedisError` / `lockFailClosed`)**: Introduced configurable fail-closed locking for `cache.lock()`. When Redis encounters network partitions, disconnects, or errors, `lock()` rejects with an error rather than silently degrading to an in-process local mutex that would permit concurrent split-brain execution across cluster pods. + - **Disk Tier Pattern Tombstones**: Added in-memory pattern tombstones (`_patternTombstones`) checked during Tier 1.5 disk lookups (`get`, `peek`, `mget`). Prevents wildcard pattern deletions (`delete('prefix:*')`) from leaving unindexed SHA-256 hashed files on disk that could resurrect into L1 on subsequent reads. - **Disk Deletion Tombstones**: Introduced `_pendingDiskDeletes` synchronous tombstoning to guard against deferred `setImmediate(() => this.disk.delete(k))` unlink races. Immediate same-tick reads (`peek`, `get`, `mget`) after `delete()` or remote backplane `del` are prevented from reading or resurrecting the stale disk file into L1. - **Tag Retention & Invalidation Abortion during SWR**: `get()` snapshots and propagates `knownTagVersions` into `_revalidate()`. Even under severe L1 memory churn or OOM evictions where the entry is purged during a slow query, generational tag metadata is preserved when re-populating L1 and Redis. Furthermore, if a generational tag version was bumped during the in-flight revalidation fetch, the commit is aborted to prevent resurrecting invalidated tag state. - **Strict Lifecycle Guards**: Hardened all public API and internal methods (`get`, `set`, `delete`, `peek`, `mget`, `mset`, `mdel`, `clear`, `increment`, `lock`, `touch`, `getIfFresh`, `has`, `ttl`, `scan`, `keys`, `values`, `entries`, `ping`, `drainToL2`, `rotateEncryptionKey`, `getRedis`) with `_assertActive()`. Operations invoked on destroyed `CacheService` instances immediately fast-fail with `TriCacheError` rather than leaking zombie state or reconnecting orphaned Redis clients. diff --git a/docs/changelog.md b/docs/changelog.md index 343e129..2cf6e17 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -39,6 +39,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Core Concurrency Hardening & Mutation Epoch Fencing (`src/cache-service.ts`)**: - **Mutation Epoch Fencing**: Added per-key monotonic mutation epoch tracking (`_keyMutationEpochs`). Both SingleFlight coalesced `get()` calls and SWR background revalidations (`_revalidate`) snapshot the key's mutation epoch before executing `fetchFn()`. If a concurrent `delete()`, `set()`, or `clear()` mutates the key while the fetch is in-flight, the stale commit to L1 and Redis is aborted, preventing zombie resurrection and race-condition data overwrites. + - **SingleFlight In-Flight Eviction on Mutations**: `set()`, `delete()`, `clear()`, and backplane invalidations synchronously evict entries from `this.inflight`, preventing subsequent reads from coalescing onto stale pre-mutation in-flight fetches. Additionally, in-flight reads that detect an epoch mismatch check L1 for fresher committed data before falling back. + - **Clock Domain Unification for Generational Tags**: Replaced `Date.now()` Unix epoch timestamps with monotonic `performance.now()` in `_setLocalTagVersion`, `invalidateTag`, `invalidateTags`, and backplane `tag_incr`. Eliminates an epoch inversion where negative deltas caused local tag versions to be treated as permanently fresh and never re-polled from Redis. + - **Global Epoch Monotonicity on Cache Flush**: Added a global monotonic epoch counter `_globalEpoch` incremented on `cache.clear()`. Ensures in-flight reads initiated prior to `clear()` detect the epoch divergence and abort committing purged keys back into L1 and Redis. + - **Distributed Lock Split-Brain Prevention (`failClosedOnRedisError` / `lockFailClosed`)**: Introduced configurable fail-closed locking for `cache.lock()`. When Redis encounters network partitions, disconnects, or errors, `lock()` rejects with an error rather than silently degrading to an in-process local mutex that would permit concurrent split-brain execution across cluster pods. + - **Disk Tier Pattern Tombstones**: Added in-memory pattern tombstones (`_patternTombstones`) checked during Tier 1.5 disk lookups (`get`, `peek`, `mget`). Prevents wildcard pattern deletions (`delete('prefix:*')`) from leaving unindexed SHA-256 hashed files on disk that could resurrect into L1 on subsequent reads. - **Disk Deletion Tombstones**: Introduced `_pendingDiskDeletes` synchronous tombstoning to guard against deferred `setImmediate(() => this.disk.delete(k))` unlink races. Immediate same-tick reads (`peek`, `get`, `mget`) after `delete()` or remote backplane `del` are prevented from reading or resurrecting the stale disk file into L1. - **Tag Retention & Invalidation Abortion during SWR**: `get()` snapshots and propagates `knownTagVersions` into `_revalidate()`. Even under severe L1 memory churn or OOM evictions where the entry is purged during a slow query, generational tag metadata is preserved when re-populating L1 and Redis. Furthermore, if a generational tag version was bumped during the in-flight revalidation fetch, the commit is aborted to prevent resurrecting invalidated tag state. - **Strict Lifecycle Guards**: Hardened all public API and internal methods (`get`, `set`, `delete`, `peek`, `mget`, `mset`, `mdel`, `clear`, `increment`, `lock`, `touch`, `getIfFresh`, `has`, `ttl`, `scan`, `keys`, `values`, `entries`, `ping`, `drainToL2`, `rotateEncryptionKey`, `getRedis`) with `_assertActive()`. Operations invoked on destroyed `CacheService` instances immediately fast-fail with `TriCacheError` rather than leaking zombie state or reconnecting orphaned Redis clients. diff --git a/src/cache-service.ts b/src/cache-service.ts index 8ecf98d..ef9667a 100644 --- a/src/cache-service.ts +++ b/src/cache-service.ts @@ -470,6 +470,7 @@ export class CacheService { encryptionKey: string | undefined; encryptionMode: 'aes-256-gcm' | 'aes-128-gcm' | 'aes-128-ctr' | 'xor' | undefined; snapshotPath: string; snapshotMaxAgeMs: number; invalidationBackplane: boolean; awaitInvalidationBackplane: boolean; + lockFailClosed: boolean; oomProtection: boolean; oomHeapThreshold: number; oomCheckIntervalMs: number; oomEvictPercent: number; onMetrics: ((m: CacheMetrics) => void) | undefined; @@ -575,7 +576,36 @@ export class CacheService { private _lastStreamId = '$'; private _destroyed = false; private readonly _pendingDiskDeletes = new Set(); + private _globalEpoch = 0; private readonly _keyMutationEpochs = new Map(); + /** + * Pattern tombstones for disk tier. + * Files in the disk tier are keyed by SHA-256 of namespacedKey. + * Wildcard pattern deletes cannot scan the filesystem synchronously, so we maintain + * pattern tombstones in memory to intercept subsequent disk reads and evict matching files. + */ + private readonly _patternTombstones: Array<{ prefix: string; createdAt: number }> = []; + + private _addPatternTombstone(prefix: string): void { + const now = Date.now(); + while (this._patternTombstones.length > 0 && (now - this._patternTombstones[0].createdAt > 3_600_000)) { + this._patternTombstones.shift(); + } + if (this._patternTombstones.length >= 2_000) { + this._patternTombstones.shift(); + } + this._patternTombstones.push({ prefix, createdAt: now }); + } + + private _isTombstonedByPattern(namespacedKey: string): boolean { + if (this._patternTombstones.length === 0) return false; + for (let i = this._patternTombstones.length - 1; i >= 0; i--) { + if (namespacedKey.startsWith(this._patternTombstones[i].prefix)) { + return true; + } + } + return false; + } private _assertActive(operation: string): void { if (this._destroyed) { @@ -594,7 +624,7 @@ export class CacheService { } private _getKeyMutationEpoch(namespacedKey: string): number { - return this._keyMutationEpochs.get(namespacedKey) ?? 0; + return this._globalEpoch + (this._keyMutationEpochs.get(namespacedKey) ?? 0); } private _ipcServer?: IpcTelemetryServer; /** Timestamp (Date.now()) when the backplane subscriber last lost its connection. */ @@ -703,6 +733,7 @@ export class CacheService { snapshotMaxAgeMs: options.snapshotMaxAgeMs ?? DEFAULT_SNAPSHOT_MAX_AGE, invalidationBackplane: options.invalidationBackplane ?? true, awaitInvalidationBackplane: options.awaitInvalidationBackplane ?? false, + lockFailClosed: options.lockFailClosed ?? false, oomProtection: options.oomProtection ?? true, oomHeapThreshold: options.oomHeapThreshold ?? 0.85, oomCheckIntervalMs: options.oomCheckIntervalMs ?? 10_000, @@ -1106,7 +1137,7 @@ export class CacheService { 'redisHost', 'redisPort', 'redisTls', 'disableRedis', 'redisClusterNodes', 'redisSentinel', 'encryptionKey', 'encryptionMode', 'l1MaxBytes', 'l1MaxEntries', 'namespace', 'frozen', 'adaptiveTtl', - 'l2WriteMode', 'instanceName', 'invalidationBackplane', 'awaitInvalidationBackplane', + 'l2WriteMode', 'instanceName', 'invalidationBackplane', 'awaitInvalidationBackplane', 'lockFailClosed', ]; /** Returns the names of options that differ between `a` and the live `b`. */ @@ -1154,6 +1185,7 @@ export class CacheService { adaptiveTtl: o.adaptiveTtl, invalidationBackplane: o.invalidationBackplane, awaitInvalidationBackplane: o.awaitInvalidationBackplane, + lockFailClosed: o.lockFailClosed, strictSingleton: o.strictSingleton, remoteSnapshot: o.remoteSnapshot, crossRegion: o.crossRegion, @@ -1473,6 +1505,7 @@ export class CacheService { if (op === 'del') { this.l1.delete(key); this._bumpKeyMutation(key); + this.inflight.delete(key); this._pendingDiskDeletes.add(key); setImmediate(() => { try { @@ -1484,17 +1517,23 @@ export class CacheService { this._cascadeDependencies(key); } else if (op === 'del-glob') { this.l1.deletePattern(key); - // Invalidate in-flight mutations for any known live keys matching pattern const pfx = key.endsWith('*') ? key.slice(0, -1) : key; for (const liveKey of this.l1.liveKeys()) { if (liveKey.startsWith(pfx)) { this._bumpKeyMutation(liveKey); } } + for (const inflightKey of Array.from(this.inflight.keys())) { + if (inflightKey.startsWith(pfx)) { + this._bumpKeyMutation(inflightKey); + this.inflight.delete(inflightKey); + } + } + this._addPatternTombstone(pfx); } else if (op === 'tag_incr') { const tag = key; const ver = typeof tagVersion === 'number' ? tagVersion : ((this.tagVersions.get(tag)?.version ?? 0) + 1); - this._setLocalTagVersion(tag, ver, Date.now()); + this._setLocalTagVersion(tag, ver, performance.now()); } this.logger.debug('Backplane: invalidation applied', { op, key: key.slice(0, 60) }); } @@ -2327,8 +2366,11 @@ export class CacheService { try { // ── Tier 1.5: disk tier (evicted L1 entries) — protected by latency watchdog ── if (!this._diskDisabled && !this._pendingDiskDeletes.has(k)) { - const isDiskAllowed = this.watchdog.isDiskAllowed(); - if (isDiskAllowed) { + if (this._isTombstonedByPattern(k)) { + try { this.disk.delete(k); } catch { /* ok */ } + } else { + const isDiskAllowed = this.watchdog.isDiskAllowed(); + if (isDiskAllowed) { const diskStart = performance.now(); const diskHit = this.disk.load(k); const diskElapsed = performance.now() - diskStart; @@ -2374,6 +2416,7 @@ export class CacheService { } } } + } // ── Tier 2: Redis (distributed, production-only by default) ── if (!this._redisDisabled) { @@ -2499,6 +2542,14 @@ export class CacheService { if (this._getKeyMutationEpoch(k) !== mutationEpochAtStart) { this.logger.debug('Aborting in-flight cache commit — key was mutated or deleted during fetch', { cacheKey }); span.setAttribute('cache.aborted_mutation_race', true); + const current = this.l1.get(k); + if (current && !current.isStale && current.value !== undefined) { + const freshVal = current.value as T; + if (this.opts.frozen) deepFreeze(freshVal); + return (this.opts.cloneStrategy === 'structuredClone' && freshVal != null && typeof freshVal === 'object') + ? structuredClone(freshVal) + : freshVal; + } if (this.opts.frozen) deepFreeze(data); return (this.opts.cloneStrategy === 'structuredClone' && data != null && typeof data === 'object') ? structuredClone(data) @@ -2741,6 +2792,7 @@ export class CacheService { const p = priority ?? inferPriority(cacheKey); const k = this.nk(cacheKey); this._bumpKeyMutation(k); + this.inflight.delete(k); this._pendingDiskDeletes.delete(k); this.counters.sets++; @@ -2863,9 +2915,17 @@ export class CacheService { this._bumpKeyMutation(liveKey); } } + for (const inflightKey of Array.from(this.inflight.keys())) { + if (inflightKey.startsWith(pfx)) { + this._bumpKeyMutation(inflightKey); + this.inflight.delete(inflightKey); + } + } + this._addPatternTombstone(pfx); this.l1.deletePattern(k); } else { this._bumpKeyMutation(k); + this.inflight.delete(k); this.l1.delete(k); this._pendingDiskDeletes.add(k); setImmediate(() => { @@ -2996,10 +3056,18 @@ export class CacheService { this._bumpKeyMutation(liveKey); } } + for (const inflightKey of Array.from(this.inflight.keys())) { + if (inflightKey.startsWith(pfx)) { + this._bumpKeyMutation(inflightKey); + this.inflight.delete(inflightKey); + } + } + this._addPatternTombstone(pfx); this.l1.deletePattern(k); - // Disk-tier pattern delete is not supported (files are keyed by SHA-256 hash); - // prefix-scoped clears only evict from L1, matching existing delete('glob*') semantics. } else { + this._globalEpoch++; + this.inflight.clear(); + this._patternTombstones.length = 0; this.l1.clear(); if (!this._diskDisabled) this.disk.clear(); this._l1Counters.clear(); @@ -3212,7 +3280,10 @@ export class CacheService { } // 2. L1.5 Disk - if (!this._diskDisabled && !this._pendingDiskDeletes.has(k) && this.watchdog.isDiskAllowed()) { + if (!this._diskDisabled && !this._pendingDiskDeletes.has(k)) { + if (this._isTombstonedByPattern(k)) { + try { this.disk.delete(k); } catch { /* ok */ } + } else if (this.watchdog.isDiskAllowed()) { const diskStart = performance.now(); const diskHit = this.disk.load(k); const diskElapsed = performance.now() - diskStart; @@ -3238,6 +3309,7 @@ export class CacheService { } } } + } // 3. L2 Redis if (!this._redisDisabled && !this.cb.isOpen) { @@ -3360,6 +3432,10 @@ export class CacheService { for (let j = missKeys.length - 1; j >= 0; j--) { const k = this.nk(missKeys[j]); if (this._pendingDiskDeletes.has(k)) continue; + if (this._isTombstonedByPattern(k)) { + try { this.disk.delete(k); } catch { /* ok */ } + continue; + } if (!this.watchdog.isDiskAllowed()) continue; const diskStart = performance.now(); const diskHit = this.disk.load(k); @@ -3582,7 +3658,7 @@ export class CacheService { const current = this.tagVersions.get(tag)?.version ?? 0; newVer = current + 1; } - this._setLocalTagVersion(tag, newVer, Date.now()); + this._setLocalTagVersion(tag, newVer, performance.now()); if (this.opts.awaitInvalidationBackplane) { await this.publishInvalidation('tag_incr', tag, newVer, false, true); } else { @@ -3650,7 +3726,7 @@ export class CacheService { if (tags.length === 1) { await this.invalidateTag(tags[0]); return; } if (this.opts.tagStrategy === 'generational') { - const now = Date.now(); + const now = performance.now(); if (!this._redisDisabled) { try { const client = await this.getRedis(); @@ -3972,6 +4048,10 @@ export class CacheService { } } catch (err) { if ((err as Error).message.startsWith('Failed to acquire lock')) throw err; + const failClosed = options?.failClosedOnRedisError ?? this.opts.lockFailClosed; + if (failClosed) { + throw new Error(`Distributed lock acquisition failed for resource "${resourceKey}" due to Redis error: ${(err as Error).message}`); + } this.logger.debug('Distributed lock: Redis error, falling back to in-process mutex', { resourceKey, error: (err as Error).message, }); diff --git a/src/types.ts b/src/types.ts index 07965c5..3be3b09 100644 --- a/src/types.ts +++ b/src/types.ts @@ -685,6 +685,15 @@ export interface CacheOptions { */ awaitInvalidationBackplane?: boolean; + /** + * If `true`, `cache.lock()` operations fail closed (throw an Error) if Redis + * encounters a connection or runtime error during lock acquisition, preventing + * split-brain concurrent execution across multiple cluster nodes. + * + * Default: `false` (falls back to local in-process mutex with a debug log). + */ + lockFailClosed?: boolean; + // ── Multi-Region / Cross-Cluster Invalidation Relay ───────────────────── /** * Optional cross-region invalidation relay configuration for geo-distributed deployments. @@ -1107,6 +1116,12 @@ export interface LockOptions { * Default: `100` ms. */ retryInterval?: number; + /** + * If `true`, lock acquisition will throw an error immediately if Redis encounters a + * connection or operational error rather than falling back to an in-process mutex. + * Overrides `CacheOptions.lockFailClosed`. + */ + failClosedOnRedisError?: boolean; } /** diff --git a/tests/systemic-cracks.test.ts b/tests/systemic-cracks.test.ts index 8896302..1017171 100644 --- a/tests/systemic-cracks.test.ts +++ b/tests/systemic-cracks.test.ts @@ -429,4 +429,271 @@ describe('Systemic Invariant & Crack Hardening Audit Tests', () => { await cache.destroy(); }); }); + + describe('Failure Path 6: SingleFlight Zombie Coalescence & Post-Mutation Isolation', () => { + it('prevents post-delete reads from coalescing onto pre-delete in-flight fetch', async () => { + const cache = CacheService.reset({ + namespace: 'sf_zombie_prevent', + disableRedis: true, + diskCacheDir: diskDir, + }); + + let resolveSlowFetch!: (val: string) => void; + const slowFetchPromise = new Promise((resolve) => { + resolveSlowFetch = resolve; + }); + + // 1. Client A starts slow fetch + const clientAPromise = cache.get('order:100', () => slowFetchPromise, 60); + + // 2. Client B deletes the key while slow fetch is still running + await cache.delete('order:100'); + + // 3. Client C calls get('order:100') AFTER delete has completed + let clientCFetchCalled = false; + const clientCPromise = cache.get('order:100', async () => { + clientCFetchCalled = true; + return 'fresh-order-data-from-client-c'; + }, 60); + + // 4. Now Client A's slow fetch completes + resolveSlowFetch('stale-pre-delete-order-data'); + + const [clientAResult, clientCResult] = await Promise.all([clientAPromise, clientCPromise]); + + // Because Client C committed fresh data to L1, Client A also receives the fresher L1 data rather than stale data + expect(clientAResult).toBe('fresh-order-data-from-client-c'); + // Client C MUST NOT have coalesced onto Client A's pre-delete fetch! + expect(clientCFetchCalled).toBe(true); + expect(clientCResult).toBe('fresh-order-data-from-client-c'); + expect(cache.getIfFresh('order:100')).toBe('fresh-order-data-from-client-c'); + + await cache.destroy(); + }); + + it('returns fresher L1 value if set() occurred during in-flight fetch', async () => { + const cache = CacheService.reset({ + namespace: 'sf_fresher_l1', + disableRedis: true, + diskCacheDir: diskDir, + }); + + let resolveSlowFetch!: (val: string) => void; + const slowFetchPromise = new Promise((resolve) => { + resolveSlowFetch = resolve; + }); + + const clientAPromise = cache.get('item:99', () => slowFetchPromise, 60); + + // set() occurs mid-flight + await cache.set('item:99', 'fresher-item-data', 60); + + resolveSlowFetch('stale-item-data'); + const clientAResult = await clientAPromise; + + // Client A receives the newer data rather than stale fetch data + expect(clientAResult).toBe('fresher-item-data'); + expect(cache.getIfFresh('item:99')).toBe('fresher-item-data'); + + await cache.destroy(); + }); + }); + + describe('Failure Path 7: Clock Domain Inversion in Generational Tags', () => { + it('sets lastSyncedAt in monotonic performance.now() domain, expiring after tagVersionTtlMs', async () => { + let redisGetCount = 0; + let redisTagVer = 1; + + const mockRedisClient = { + incr: vi.fn().mockImplementation(async () => ++redisTagVer), + get: vi.fn().mockImplementation(async () => String(redisTagVer)), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + const cache = CacheService.reset({ + namespace: 'tag_clock_domain', + disableRedis: false, + tagStrategy: 'generational', + tagVersionTtlMs: 25, // 25ms TTL + redisClient: mockRedisClient as any, + diskCacheDir: diskDir, + }); + + // Invalidate tag locally + await cache.invalidateTag('users'); + + // Read tag version internally + const v1 = await (cache as any)._getTagVersion('users'); + expect(v1).toBe(2); + + // Verify that local tag entry lastSyncedAt is in performance.now() domain (not ~1.76e12 epoch) + const localEntry = (cache as any).tagVersions.get('users'); + expect(localEntry).toBeDefined(); + expect(localEntry.lastSyncedAt).toBeLessThan(1_000_000_000); // monotonic uptime, not Unix epoch! + + // Immediately reading again hits local cache without calling Redis + mockRedisClient.get.mockClear(); + const v2 = await (cache as any)._getTagVersion('users'); + expect(v2).toBe(2); + expect(mockRedisClient.get).not.toHaveBeenCalled(); + + // Wait 35ms (exceeds 25ms TTL) + await new Promise(r => setTimeout(r, 35)); + + // After TTL expires, it MUST query Redis again because time elapsed properly! + redisTagVer = 5; // simulated remote bump + const v3 = await (cache as any)._getTagVersion('users'); + expect(mockRedisClient.get).toHaveBeenCalledWith(expect.stringContaining('tag_ver:users')); + expect(v3).toBe(5); + + await cache.destroy(); + }); + }); + + describe('Failure Path 8: Global State Purge Monotonicity (clear() Epoch Fence)', () => { + it('aborts committing in-flight fetch that started before cache.clear()', async () => { + const cache = CacheService.reset({ + namespace: 'clear_epoch_fence', + disableRedis: true, + diskCacheDir: diskDir, + }); + + let resolveSlowFetch!: (val: string) => void; + const slowFetchPromise = new Promise((resolve) => { + resolveSlowFetch = resolve; + }); + + // Key has never been written, epoch is 0 at start + const getPromise = cache.get('theme:dark', () => slowFetchPromise, 60); + + // Global clear() is invoked + await cache.clear(); + + // Slow DB query finishes after clear() + resolveSlowFetch('dark-theme-config'); + const result = await getPromise; + + expect(result).toBe('dark-theme-config'); + // The key MUST NOT have been committed back to L1! + expect(cache.has('theme:dark')).toBe(false); + expect(cache.getIfFresh('theme:dark')).toBeNull(); + + await cache.destroy(); + }); + }); + + describe('Failure Path 9: Distributed Lock Fail-Closed Under Redis Outages', () => { + it('throws error when failClosedOnRedisError is true and Redis encounters an error', async () => { + const mockRedisClient = { + set: vi.fn().mockRejectedValue(new Error('Redis connection ETIMEDOUT')), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + const cache = CacheService.reset({ + namespace: 'lock_fail_closed', + disableRedis: false, + redisClient: mockRedisClient as any, + diskCacheDir: diskDir, + }); + + let criticalSectionExecuted = false; + await expect( + cache.lock('payout:batch_1', async () => { + criticalSectionExecuted = true; + return 'done'; + }, { failClosedOnRedisError: true, acquireTimeout: 100 }), + ).rejects.toThrow('Distributed lock acquisition failed for resource "payout:batch_1" due to Redis error: Redis connection ETIMEDOUT'); + + expect(criticalSectionExecuted).toBe(false); + + await cache.destroy(); + }); + + it('respects global lockFailClosed option on CacheOptions', async () => { + const mockRedisClient = { + set: vi.fn().mockRejectedValue(new Error('ECONNRESET')), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + const cache = CacheService.reset({ + namespace: 'lock_global_fail_closed', + disableRedis: false, + lockFailClosed: true, + redisClient: mockRedisClient as any, + diskCacheDir: diskDir, + }); + + let criticalSectionExecuted = false; + await expect( + cache.lock('invoice:999', async () => { + criticalSectionExecuted = true; + return 'paid'; + }, { acquireTimeout: 100 }), + ).rejects.toThrow('Distributed lock acquisition failed for resource "invoice:999" due to Redis error: ECONNRESET'); + + expect(criticalSectionExecuted).toBe(false); + + await cache.destroy(); + }); + + it('falls back to in-process mutex when failClosed is false (default backwards compatibility)', async () => { + const mockRedisClient = { + set: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')), + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + const cache = CacheService.reset({ + namespace: 'lock_fallback_default', + disableRedis: false, + lockFailClosed: false, + redisClient: mockRedisClient as any, + diskCacheDir: diskDir, + }); + + let executed = false; + const res = await cache.lock('local:job', async () => { + executed = true; + return 'success'; + }, { acquireTimeout: 100 }); + + expect(executed).toBe(true); + expect(res).toBe('success'); + + await cache.destroy(); + }); + }); + + describe('Failure Path 10: Disk Tier Pattern Tombstone Invalidation', () => { + it('prevents reviving deleted keys from disk cache after pattern delete', async () => { + const cache = CacheService.reset({ + namespace: 'disk_pat_tombstone', + disableRedis: true, + diskCacheDir: diskDir, + }); + + // 1. Manually write an entry to disk as if it was evicted from L1 + const namespacedKey = (cache as any).nk('account:55:settings'); + (cache as any).disk.save(namespacedKey, makeTestEntry({ theme: 'blue', notifications: true })); + + // 2. Ensure L1 does NOT have the key + (cache as any).l1.delete(namespacedKey); + expect((cache as any).l1.get(namespacedKey)).toBeNull(); + + // 3. Perform a wildcard pattern delete + await cache.delete('account:55:*'); + + // 4. Client attempts to read 'account:55:settings' + let fetchCalled = false; + const result = await cache.get('account:55:settings', async () => { + fetchCalled = true; + return { theme: 'default', notifications: false }; + }); + + // It must NOT hit disk and revive the pre-deletion settings! + expect(fetchCalled).toBe(true); + expect(result).toEqual({ theme: 'default', notifications: false }); + + await cache.destroy(); + }); + }); }); From f46fb0dcbc5d0a6a6c3a26f495d0a1824c5c92f5 Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 20:21:36 +0300 Subject: [PATCH 09/11] docs: update test count badge to 862 passing --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index abdec90..5d7cedc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Docs](https://img.shields.io/badge/docs-VitePress-blue.svg)](https://kareem411.github.io/TriCache/) [![npm version](https://img.shields.io/npm/v/tricache.svg)](https://www.npmjs.com/package/tricache) [![npm downloads](https://img.shields.io/npm/dm/tricache.svg)](https://www.npmjs.com/package/tricache) -[![Tests](https://img.shields.io/badge/tests-854%20passing-brightgreen)](tests) +[![Tests](https://img.shields.io/badge/tests-862%20passing-brightgreen)](tests) [![Code Quality](https://img.shields.io/badge/oxlint-0%20warnings-brightgreen)](src) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js ≥ 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) From ddce8df5e97bc0374c9253475ec38e688d8ecc1c Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 20:28:37 +0300 Subject: [PATCH 10/11] docs(api): document awaitInvalidationBackplane and lockFailClosed in api-reference --- docs/api-reference.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 42acf54..5874672 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -161,9 +161,10 @@ async lock( **`LockOptions` Schema:** ```typescript interface LockOptions { - ttl?: number; // Lock lease duration in seconds (default: 30) - acquireTimeout?: number; // Max wait time in ms before aborting (default: 5000) - retryInterval?: number; // Polling interval in ms (default: 100) + ttl?: number; // Lock lease duration in seconds (default: 30) + acquireTimeout?: number; // Max wait time in ms before aborting (default: 5000) + retryInterval?: number; // Polling interval in ms (default: 100) + failClosedOnRedisError?: boolean; // Throws immediately on Redis errors instead of in-process fallback (prevents split-brain) } ``` @@ -438,6 +439,8 @@ Complete schema of options passed to `CacheService.create(options)`: | `adaptiveTtl` | `boolean` | `false` | Autonomous p95 fetch latency TTL tuning | | `autoPipeline` | `boolean` | `false` | Zero-latency microtask Redis command batching | | `maxPipelineBatchSize` | `number` | `100` | Immediate flush batch size threshold | +| `awaitInvalidationBackplane` | `boolean` | `false` | Await backplane broadcast on mutations (`set`, `delete`, `clear`, `invalidateTag`) | +| `lockFailClosed` | `boolean` | `false` | Fails closed (throws) on Redis errors during `cache.lock()` (prevents split-brain) | | `enableIpc` | `boolean` | `false` | Enables local IPC bridge for `tricache top` | | `ipcSocketPath` | `string` | `undefined` | Custom Unix domain socket or Windows named pipe | | `crossRegion` | `CrossRegionRelayOptions` | `undefined` | Multi-cluster cross-region invalidation relay | From b219efb6032ce2452b3b5e78799ae706bbd1d1a3 Mon Sep 17 00:00:00 2001 From: Kareem Date: Fri, 2 Oct 2026 20:32:27 +0300 Subject: [PATCH 11/11] docs: bind navbar version dynamically to package.json v0.9.0 --- docs/.vitepress/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 48652c3..a1be6c9 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitepress'; +import pkg from '../../package.json' with { type: 'json' }; export default defineConfig({ title: 'TriCache', @@ -82,7 +83,7 @@ export default defineConfig({ { text: 'API', link: '/api-reference' }, { text: 'Benchmarks', link: '/benchmarks' }, { - text: 'v0.8.0', + text: `v${pkg.version}`, items: [ { text: 'Changelog', link: '/changelog' }, { text: 'Contributing Guide', link: '/contributing' },