@heinhtet37/express-route-cache 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,31 +1,138 @@
1
- # @mvs/express-route-cache
1
+ # @heinhtet37/express-route-cache
2
2
 
3
- Reusable Express middleware factories for route-level response caching and cache invalidation.
3
+ Express route caching helpers for low-level control and practical resource-level DX.
4
4
 
5
- This package is intended to be published independently from this repo. You can:
5
+ The original API still works:
6
6
 
7
- 1. `cd express-route-cache`
8
- 2. `npm install`
9
- 3. `npm publish`
7
+ - `createRouteCache`
8
+ - `createRedisStore`
9
+ - `createMemoryStore`
10
+ - `check`
11
+ - `capture`
12
+ - `invalidate`
13
+
14
+ This version adds:
15
+
16
+ - `resource()` bundles for list/detail/variant routes
17
+ - optional cache key `namespace`
18
+ - optional tag-based invalidation
19
+ - deterministic resource keys
20
+ - lightweight TTL parsing like `"5m"` and `"1h"`
21
+ - lightweight lifecycle events
10
22
 
11
23
  ## Install
12
24
 
13
25
  ```bash
14
- npm install @mvs/express-route-cache
26
+ npm install @heinhtet37/express-route-cache
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```ts
32
+ import { createRedisStore, createRouteCache } from "@heinhtet37/express-route-cache";
33
+ import redis from "./redis";
34
+
35
+ const cache = createRouteCache({
36
+ store: createRedisStore({
37
+ client: redis,
38
+ defaultTtlSeconds: "5m",
39
+ }),
40
+ namespace: "mm-smart-pos",
41
+ });
42
+
43
+ const rolesCache = cache.resource({
44
+ basePath: "/api/v1/roles",
45
+ idParam: "id",
46
+ ttlSeconds: "5m",
47
+ variants: ["names"],
48
+ });
49
+
50
+ router.get("/", ...rolesCache.list(), async (_req, res) => {
51
+ res.json(await roleService.getAll());
52
+ });
53
+
54
+ router.get("/names", ...rolesCache.variant("names"), async (_req, res) => {
55
+ res.json(await roleService.getAllNames());
56
+ });
57
+
58
+ router.get("/:id", ...rolesCache.detail(), async (req, res) => {
59
+ res.json(await roleService.getById(req.params.id));
60
+ });
61
+
62
+ router.post("/", rolesCache.invalidateOnWrite(), async (req, res) => {
63
+ res.json(await roleService.create(req.body));
64
+ });
65
+
66
+ router.patch("/:id", rolesCache.invalidateOnWrite(), async (req, res) => {
67
+ res.json(await roleService.update(req.params.id, req.body));
68
+ });
69
+
70
+ router.delete("/:id", rolesCache.invalidateOnWrite(), async (req, res) => {
71
+ res.json(await roleService.remove(req.params.id));
72
+ });
15
73
  ```
16
74
 
17
- ## What it provides
75
+ ## Resource API
76
+
77
+ `resource()` is the high-level DX API for common REST-style routes.
78
+
79
+ ```ts
80
+ const usersCache = cache.resource({
81
+ basePath: "/api/users",
82
+ idParam: "id",
83
+ ttlSeconds: 300,
84
+ variants: ["names", "summary"],
85
+ });
86
+ ```
18
87
 
19
- - `createRouteCache()` to create middleware factories
20
- - `createRedisStore()` for Redis-backed caching
21
- - `createMemoryStore()` for local development and tests
88
+ ### Read Bundles
22
89
 
23
- ## Basic usage
90
+ `list()` returns `[check, capture]` for the collection route.
24
91
 
25
92
  ```ts
26
- import { createRedisStore, createRouteCache } from "@mvs/express-route-cache";
27
- import redis from "./redis";
93
+ router.get("/", ...usersCache.list(), handler);
94
+ ```
95
+
96
+ `detail()` returns `[check, capture]` for `/:id`.
97
+
98
+ ```ts
99
+ router.get("/:id", ...usersCache.detail(), handler);
100
+ ```
101
+
102
+ `variant(name)` returns `[check, capture]` for custom read routes like `/names`.
103
+
104
+ ```ts
105
+ router.get("/names", ...usersCache.variant("names"), handler);
106
+ ```
107
+
108
+ ### Write Invalidation
109
+
110
+ `invalidateOnWrite()` invalidates the resource collection, query variants, configured variants, and the matching detail route when the write route has an `id` param.
111
+
112
+ ```ts
113
+ router.post("/", usersCache.invalidateOnWrite(), handler);
114
+ router.patch("/:id", usersCache.invalidateOnWrite(), handler);
115
+ router.delete("/:id", usersCache.invalidateOnWrite(), handler);
116
+ ```
117
+
118
+ You can extend invalidation with extra patterns or tags:
119
+
120
+ ```ts
121
+ router.patch(
122
+ "/:id",
123
+ usersCache.invalidateOnWrite({
124
+ additionalPatterns: ["/api/dashboard*", "/api/summary*"],
125
+ additionalTags: ["dashboard"],
126
+ }),
127
+ handler
128
+ );
129
+ ```
130
+
131
+ ## Low-Level API
28
132
 
133
+ The original low-level API remains available for custom routes.
134
+
135
+ ```ts
29
136
  const cache = createRouteCache({
30
137
  store: createRedisStore({
31
138
  client: redis,
@@ -33,13 +140,15 @@ const cache = createRouteCache({
33
140
  }),
34
141
  });
35
142
 
36
- export const checkUsersCache = cache.check({
143
+ const checkUsersCache = cache.check({
37
144
  varyHeaders: ["accept-language"],
38
145
  });
39
146
 
40
- export const setUsersCache = cache.capture();
147
+ const captureUsersCache = cache.capture({
148
+ ttlSeconds: "5m",
149
+ });
41
150
 
42
- export const invalidateUsersCache = cache.invalidate({
151
+ const invalidateUsersCache = cache.invalidate({
43
152
  patterns: (req) => [
44
153
  "/api/users",
45
154
  "/api/users?*",
@@ -48,83 +157,138 @@ export const invalidateUsersCache = cache.invalidate({
48
157
  });
49
158
  ```
50
159
 
51
- Then use it in your routes:
160
+ ## Namespace Support
161
+
162
+ `namespace` prefixes internal cache keys and tag identifiers without forcing consumers to rewrite their patterns.
52
163
 
53
164
  ```ts
54
- router.get(
55
- "/users",
56
- checkUsersCache,
57
- setUsersCache,
58
- async (req, res) => {
59
- res.json(await userService.getAll());
60
- }
61
- );
165
+ const cache = createRouteCache({
166
+ store,
167
+ namespace: "mm-smart-pos",
168
+ });
62
169
 
63
- router.patch(
64
- "/users/:id",
65
- invalidateUsersCache,
66
- async (req, res) => {
67
- res.json(await userService.update(req.params.id, req.body));
68
- }
69
- );
170
+ await cache.set("/roles", { ok: true }, 300);
171
+ await cache.deleteByPatterns(["/roles"]);
172
+ ```
173
+
174
+ With a namespace configured, `"/roles"` becomes an internal key like `"mm-smart-pos::/roles"`, but callers can still use the simple public key.
175
+
176
+ ## Tag Support
177
+
178
+ Tag invalidation is optional.
179
+
180
+ - `createMemoryStore()` supports tags out of the box.
181
+ - `createRedisStore()` supports tags when the Redis client provides `sadd`, `srem`, `smembers`, and `expire`.
182
+ - Pattern invalidation continues to work either way.
183
+
184
+ Low-level tag example:
185
+
186
+ ```ts
187
+ const checkUsersCache = cache.check({ key: "/api/users" });
188
+
189
+ const captureUsersCache = cache.capture({
190
+ tags: ["users", "admin-dashboard"],
191
+ });
192
+
193
+ const invalidateUsersByTag = cache.invalidate({
194
+ tags: ["users"],
195
+ });
70
196
  ```
71
197
 
72
- ## API
198
+ Resource bundles automatically attach resource-level tags, and `invalidateOnWrite()` uses both patterns and tags when available.
73
199
 
74
- ### `createRouteCache({ store, logger, defaultTtlSeconds })`
200
+ ## TTL Values
75
201
 
76
- Returns:
202
+ TTL values can be numbers or simple strings:
77
203
 
78
- - `check(options)`
79
- - `capture(options)`
80
- - `invalidate(options)`
81
- - `get(key)`
82
- - `set(key, value, ttlSeconds?)`
83
- - `deleteByPatterns(patterns)`
204
+ - `300`
205
+ - `"300"`
206
+ - `"5m"`
207
+ - `"1h"`
208
+ - `"1d"`
84
209
 
85
- ### `check(options)`
210
+ This works with:
86
211
 
87
- Options:
212
+ - `defaultTtlSeconds`
213
+ - `capture({ ttlSeconds })`
214
+ - `resource({ ttlSeconds })`
215
+ - `set(key, value, ttl)`
88
216
 
89
- - `key`: custom cache key or `(req) => string`
90
- - `varyHeaders`: append selected request headers into the cache key
91
- - `shouldBypass`: skip lookup for selected requests
92
- - `hitHeader`: response header name for cache hit or miss, defaults to `x-cache`
217
+ ## Events
93
218
 
94
- ### `capture(options)`
219
+ You can subscribe to lightweight lifecycle events:
95
220
 
96
- Options:
221
+ - `hit`
222
+ - `miss`
223
+ - `set`
224
+ - `invalidate`
225
+ - `error`
97
226
 
98
- - `ttlSeconds`: number or `(req, res, body) => number | undefined`
99
- - `shouldCache`: predicate to decide whether a response body should be cached
227
+ ```ts
228
+ const cache = createRouteCache({
229
+ store,
230
+ events: {
231
+ hit: ({ key }) => console.log("cache hit", key),
232
+ miss: ({ key }) => console.log("cache miss", key),
233
+ set: ({ key, ttlSeconds }) => console.log("cache set", key, ttlSeconds),
234
+ invalidate: ({ patterns, tags }) => console.log("cache invalidate", patterns, tags),
235
+ error: ({ operation, error }) => console.error("cache error", operation, error),
236
+ },
237
+ });
238
+ ```
100
239
 
101
- ### `invalidate(options)`
240
+ ## Redis Store
102
241
 
103
- Options:
242
+ ```ts
243
+ import Redis from "ioredis";
244
+ import { createRedisStore } from "@heinhtet37/express-route-cache";
104
245
 
105
- - `patterns`: array or `(req, res, body) => string[]`
106
- - `shouldInvalidate`: predicate to control invalidation
107
- - `onInvalidate`: async hook for audit logging or side effects
246
+ const client = new Redis();
108
247
 
109
- ### `createRedisStore({ client, defaultTtlSeconds, logger, scanCount })`
248
+ const store = createRedisStore({
249
+ client,
250
+ defaultTtlSeconds: "5m",
251
+ });
252
+ ```
110
253
 
111
- The Redis client should expose:
254
+ For pattern invalidation, the Redis client must provide:
112
255
 
113
256
  - `get`
114
257
  - `del`
115
258
  - one of `scan` or `keys`
116
259
  - one of `setex` or `set(..., "EX", ttl)`
117
260
 
118
- ### `createMemoryStore({ defaultTtlSeconds })`
261
+ For tag invalidation, the Redis client should also provide:
262
+
263
+ - `sadd`
264
+ - `srem`
265
+ - `smembers`
266
+ - `expire`
267
+
268
+ ## Memory Store
269
+
270
+ ```ts
271
+ import { createMemoryStore } from "@heinhtet37/express-route-cache";
272
+
273
+ const store = createMemoryStore({
274
+ defaultTtlSeconds: "1m",
275
+ });
276
+ ```
277
+
278
+ The memory store is useful for local development and tests. It also supports tag invalidation.
279
+
280
+ ## Notes And Tradeoffs
119
281
 
120
- Simple in-memory cache store for testing or local usage.
282
+ - The old API is still supported and unchanged for normal usage.
283
+ - The new `resource()` API is intentionally opinionated around list/detail/variant patterns.
284
+ - Resource invalidation automatically covers configured variants only. If you use extra variant names, either include them in `variants` or add `additionalPatterns`.
285
+ - Tag invalidation is additive. It does not replace pattern invalidation.
286
+ - Redis tag invalidation uses extra metadata keys to track tag membership. This keeps the public API simple at the cost of slightly more write work per cached entry.
121
287
 
122
- ## Publish
288
+ ## Tests
123
289
 
124
290
  ```bash
125
- cd express-route-cache
126
- npm install
127
- npm publish
291
+ npm test
128
292
  ```
129
293
 
130
- Because `publishConfig.access` is already set to `public`, npm will publish it as a public package.
294
+ That runs a package build and then the Node test suite in `tests/`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Request, RequestHandler, Response } from "express";
2
+ export type TtlValue = number | string;
2
3
  export interface CacheStore {
3
4
  get<T>(key: string): Promise<T | null>;
4
5
  set<T>(key: string, value: T, options?: {
@@ -6,11 +7,48 @@ export interface CacheStore {
6
7
  }): Promise<void>;
7
8
  deleteByPatterns(patterns: string[]): Promise<number | void>;
8
9
  }
10
+ export interface TaggableCacheStore extends CacheStore {
11
+ addTags?(key: string, tags: string[], options?: {
12
+ ttlSeconds?: number;
13
+ }): Promise<void>;
14
+ deleteByTags?(tags: string[]): Promise<number | void>;
15
+ }
9
16
  export interface CacheLogger {
10
17
  error?(message: string, meta?: unknown): void;
11
18
  warn?(message: string, meta?: unknown): void;
12
19
  info?(message: string, meta?: unknown): void;
13
20
  }
21
+ export interface CacheEvents {
22
+ hit?(event: {
23
+ key: string;
24
+ req: Request;
25
+ }): void;
26
+ miss?(event: {
27
+ key: string;
28
+ req: Request;
29
+ }): void;
30
+ set?(event: {
31
+ key: string;
32
+ ttlSeconds?: number;
33
+ req: Request;
34
+ body: unknown;
35
+ tags?: string[];
36
+ }): void;
37
+ invalidate?(event: {
38
+ req?: Request;
39
+ patterns?: string[];
40
+ tags?: string[];
41
+ deletedCount?: number;
42
+ }): void;
43
+ error?(event: {
44
+ req?: Request;
45
+ key?: string;
46
+ patterns?: string[];
47
+ tags?: string[];
48
+ operation: "get" | "set" | "invalidate" | "hook" | "tag";
49
+ error: unknown;
50
+ }): void;
51
+ }
14
52
  type ValueResolver<T> = T | ((req: Request, res: Response, body?: unknown) => T);
15
53
  type MaybePromise<T> = T | Promise<T>;
16
54
  export interface CacheCheckOptions {
@@ -20,26 +58,68 @@ export interface CacheCheckOptions {
20
58
  hitHeader?: string | false;
21
59
  }
22
60
  export interface CacheCaptureOptions {
23
- ttlSeconds?: ValueResolver<number | undefined>;
61
+ ttlSeconds?: ValueResolver<TtlValue | undefined>;
24
62
  shouldCache?: (req: Request, res: Response, body: unknown) => boolean;
63
+ tags?: ValueResolver<string[] | undefined>;
25
64
  }
26
65
  export interface CacheInvalidateOptions {
27
- patterns: string[] | ((req: Request, res: Response, body: unknown) => string[]);
66
+ patterns?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
67
+ tags?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
68
+ shouldInvalidate?: (req: Request, res: Response, body: unknown) => boolean;
69
+ onInvalidate?: (req: Request, res: Response, body: unknown, details: {
70
+ patterns: string[];
71
+ tags: string[];
72
+ }) => MaybePromise<void>;
73
+ }
74
+ export interface ResourceReadOptions {
75
+ ttlSeconds?: TtlValue;
76
+ varyHeaders?: string[];
77
+ shouldBypass?: (req: Request) => boolean;
78
+ hitHeader?: string | false;
79
+ shouldCache?: (req: Request, res: Response, body: unknown) => boolean;
80
+ tags?: ValueResolver<string[] | undefined>;
81
+ }
82
+ export interface ResourceInvalidateOptions {
83
+ additionalPatterns?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
84
+ additionalTags?: string[] | ((req: Request, res: Response, body: unknown) => string[]);
28
85
  shouldInvalidate?: (req: Request, res: Response, body: unknown) => boolean;
29
- onInvalidate?: (req: Request, res: Response, body: unknown, patterns: string[]) => MaybePromise<void>;
86
+ onInvalidate?: (req: Request, res: Response, body: unknown, details: {
87
+ patterns: string[];
88
+ tags: string[];
89
+ }) => MaybePromise<void>;
90
+ }
91
+ export interface RouteCacheResourceOptions {
92
+ basePath: string;
93
+ idParam?: string;
94
+ ttlSeconds?: TtlValue;
95
+ variants?: string[];
96
+ varyHeaders?: string[];
97
+ hitHeader?: string | false;
98
+ shouldBypass?: (req: Request) => boolean;
99
+ shouldCache?: (req: Request, res: Response, body: unknown) => boolean;
100
+ }
101
+ export interface RouteCacheResource {
102
+ list(options?: ResourceReadOptions): [RequestHandler, RequestHandler];
103
+ detail(options?: ResourceReadOptions): [RequestHandler, RequestHandler];
104
+ variant(name: string, options?: ResourceReadOptions): [RequestHandler, RequestHandler];
105
+ invalidateOnWrite(options?: ResourceInvalidateOptions): RequestHandler;
30
106
  }
31
107
  export interface CreateRouteCacheOptions {
32
108
  store: CacheStore;
33
109
  logger?: CacheLogger;
34
- defaultTtlSeconds?: number;
110
+ defaultTtlSeconds?: TtlValue;
111
+ namespace?: string;
112
+ events?: CacheEvents;
35
113
  }
36
114
  export declare function createRouteCache(options: CreateRouteCacheOptions): {
37
115
  check(checkOptions?: CacheCheckOptions): RequestHandler;
38
116
  capture(captureOptions?: CacheCaptureOptions): RequestHandler;
39
117
  invalidate(invalidateOptions: CacheInvalidateOptions): RequestHandler;
118
+ resource(resourceOptions: RouteCacheResourceOptions): RouteCacheResource;
40
119
  get<T>(key: string): Promise<T | null>;
41
- set<T>(key: string, value: T, ttlSeconds?: number | undefined): Promise<void>;
120
+ set<T>(key: string, value: T, ttlSeconds?: TtlValue | undefined): Promise<void>;
42
121
  deleteByPatterns(patterns: string[]): Promise<void>;
122
+ deleteByTags(tags: string[]): Promise<void>;
43
123
  };
44
124
  export interface RedisLikeClient {
45
125
  get(key: string): Promise<string | null>;
@@ -48,18 +128,22 @@ export interface RedisLikeClient {
48
128
  keys?(pattern: string): Promise<string[]>;
49
129
  scan?(cursor: string, tokenOne: "MATCH", pattern: string, tokenTwo: "COUNT", count: number): Promise<[string, string[]]>;
50
130
  del(...keys: string[]): Promise<number>;
131
+ sadd?(key: string, ...members: string[]): Promise<number>;
132
+ srem?(key: string, ...members: string[]): Promise<number>;
133
+ smembers?(key: string): Promise<string[]>;
134
+ expire?(key: string, seconds: number): Promise<number>;
51
135
  }
52
136
  export interface CreateRedisStoreOptions {
53
137
  client: RedisLikeClient;
54
- defaultTtlSeconds?: number;
138
+ defaultTtlSeconds?: TtlValue;
55
139
  logger?: CacheLogger;
56
140
  scanCount?: number;
57
141
  deserialize?: <T>(value: string) => T;
58
142
  serialize?: (value: unknown) => string;
59
143
  }
60
- export declare function createRedisStore(options: CreateRedisStoreOptions): CacheStore;
144
+ export declare function createRedisStore(options: CreateRedisStoreOptions): TaggableCacheStore;
61
145
  export interface CreateMemoryStoreOptions {
62
- defaultTtlSeconds?: number;
146
+ defaultTtlSeconds?: TtlValue;
63
147
  }
64
- export declare function createMemoryStore(options?: CreateMemoryStoreOptions): CacheStore;
148
+ export declare function createMemoryStore(options?: CreateMemoryStoreOptions): TaggableCacheStore;
65
149
  export {};
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ exports.createRouteCache = createRouteCache;
4
4
  exports.createRedisStore = createRedisStore;
5
5
  exports.createMemoryStore = createMemoryStore;
6
6
  const ROUTE_CACHE_CONTEXT_KEY = "__routeCache";
7
+ const REDIS_TAG_PREFIX = "__route_cache_tag__:";
8
+ const REDIS_KEY_TAG_PREFIX = "__route_cache_key_tags__:";
7
9
  function getCacheContext(res) {
8
10
  return res.locals?.[ROUTE_CACHE_CONTEXT_KEY];
9
11
  }
@@ -22,6 +24,55 @@ function escapeForRegex(input) {
22
24
  function wildcardToRegExp(pattern) {
23
25
  return new RegExp("^" + pattern.split("*").map(escapeForRegex).join(".*") + "$");
24
26
  }
27
+ function normalizePath(input) {
28
+ if (!input) {
29
+ return "/";
30
+ }
31
+ if (input === "/") {
32
+ return "/";
33
+ }
34
+ return input.endsWith("/") ? input.slice(0, -1) : input;
35
+ }
36
+ function stableSerialize(value) {
37
+ if (Array.isArray(value)) {
38
+ return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
39
+ }
40
+ if (value && typeof value === "object") {
41
+ const entries = Object.entries(value)
42
+ .filter(([, entryValue]) => entryValue !== undefined)
43
+ .sort(([left], [right]) => left.localeCompare(right));
44
+ return `{${entries
45
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`)
46
+ .join(",")}}`;
47
+ }
48
+ return JSON.stringify(value);
49
+ }
50
+ function buildDeterministicQuerySuffix(query) {
51
+ if (!query ||
52
+ typeof query !== "object" ||
53
+ Array.isArray(query) ||
54
+ Object.keys(query).length === 0) {
55
+ return "";
56
+ }
57
+ return `?${stableSerialize(query)}`;
58
+ }
59
+ function buildHeaderSuffix(req, varyHeaders = []) {
60
+ if (varyHeaders.length === 0) {
61
+ return "";
62
+ }
63
+ const serializedHeaders = varyHeaders
64
+ .map((headerName) => headerName.toLowerCase())
65
+ .sort((left, right) => left.localeCompare(right))
66
+ .map((headerName) => [
67
+ headerName,
68
+ normalizeHeaderValue(req.headers[headerName]),
69
+ ])
70
+ .filter(([, value]) => value !== "");
71
+ if (serializedHeaders.length === 0) {
72
+ return "";
73
+ }
74
+ return `#headers=${stableSerialize(Object.fromEntries(serializedHeaders))}`;
75
+ }
25
76
  function buildCacheKey(req, varyHeaders = []) {
26
77
  const suffix = varyHeaders
27
78
  .map((headerName) => {
@@ -37,32 +88,260 @@ function buildCacheKey(req, varyHeaders = []) {
37
88
  const separator = req.originalUrl.includes("?") ? "&" : "?";
38
89
  return `${req.originalUrl}${separator}${suffix}`;
39
90
  }
91
+ function buildResourceKey(kind, req, options) {
92
+ const { basePath, detailId, variantName, varyHeaders = [] } = options;
93
+ let routePath = normalizePath(basePath);
94
+ let routeLabel = `resource:${kind}`;
95
+ if (kind === "detail") {
96
+ routePath = `${routePath}/${detailId ?? ""}`;
97
+ }
98
+ if (kind === "variant") {
99
+ routePath = `${routePath}/${variantName ?? ""}`;
100
+ routeLabel = `${routeLabel}:${variantName ?? "unknown"}`;
101
+ }
102
+ return `${routeLabel}:${routePath}${buildDeterministicQuerySuffix(req.query)}${buildHeaderSuffix(req, varyHeaders)}`;
103
+ }
104
+ function parseTtl(ttlSeconds) {
105
+ if (ttlSeconds === undefined) {
106
+ return undefined;
107
+ }
108
+ if (typeof ttlSeconds === "number") {
109
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds < 0) {
110
+ throw new Error("TTL must be a finite non-negative number.");
111
+ }
112
+ return ttlSeconds;
113
+ }
114
+ const normalized = ttlSeconds.trim().toLowerCase();
115
+ if (/^\d+$/.test(normalized)) {
116
+ return Number(normalized);
117
+ }
118
+ const matched = normalized.match(/^(\d+)(s|m|h|d)$/);
119
+ if (!matched) {
120
+ throw new Error(`Invalid TTL value "${ttlSeconds}". Use numbers or strings like "5m" and "1h".`);
121
+ }
122
+ const amount = Number(matched[1]);
123
+ const unit = matched[2];
124
+ switch (unit) {
125
+ case "s":
126
+ return amount;
127
+ case "m":
128
+ return amount * 60;
129
+ case "h":
130
+ return amount * 60 * 60;
131
+ case "d":
132
+ return amount * 60 * 60 * 24;
133
+ default:
134
+ return amount;
135
+ }
136
+ }
40
137
  function isSuccessfulStatus(statusCode) {
41
138
  return statusCode >= 200 && statusCode < 300;
42
139
  }
140
+ function getRouteParamValue(value) {
141
+ if (Array.isArray(value)) {
142
+ return value[0] ?? "";
143
+ }
144
+ return value ?? "";
145
+ }
146
+ function normalizeNamespace(namespace) {
147
+ return namespace?.trim() ? namespace.trim() : undefined;
148
+ }
149
+ function hasNamespacePrefix(value, namespace) {
150
+ return Boolean(namespace) && value.startsWith(`${namespace}::`);
151
+ }
152
+ function applyNamespace(value, namespace) {
153
+ if (!namespace || hasNamespacePrefix(value, namespace)) {
154
+ return value;
155
+ }
156
+ return `${namespace}::${value}`;
157
+ }
158
+ function normalizePatterns(patterns, namespace) {
159
+ return patterns.map((pattern) => applyNamespace(pattern, namespace));
160
+ }
161
+ function normalizeTags(tags, namespace) {
162
+ return tags.map((tag) => applyNamespace(`tag:${tag}`, namespace));
163
+ }
164
+ function uniqueValues(values) {
165
+ return [...new Set(values.filter((value) => value.length > 0))];
166
+ }
167
+ function safeEmitError(events, logger, event) {
168
+ try {
169
+ events?.error?.(event);
170
+ }
171
+ catch (hookError) {
172
+ logger?.error?.("Cache event hook failed", hookError);
173
+ }
174
+ }
175
+ function safeEmit(emitter, logger, eventName, payload, events) {
176
+ if (!emitter) {
177
+ return;
178
+ }
179
+ try {
180
+ emitter(payload);
181
+ }
182
+ catch (error) {
183
+ logger?.error?.(`Cache ${eventName} hook failed`, error);
184
+ safeEmitError(events, logger, {
185
+ operation: "hook",
186
+ error,
187
+ });
188
+ }
189
+ }
43
190
  async function deletePatterns(store, patterns, logger) {
44
191
  if (patterns.length === 0) {
45
- return;
192
+ return 0;
46
193
  }
47
194
  try {
48
- await store.deleteByPatterns(patterns);
195
+ const deletedCount = await store.deleteByPatterns(patterns);
196
+ return deletedCount ?? 0;
49
197
  }
50
198
  catch (error) {
51
199
  logger?.error?.("Failed to invalidate cache patterns", error);
200
+ throw error;
52
201
  }
53
202
  }
54
- function createRouteCache(options) {
55
- const { defaultTtlSeconds, logger, store } = options;
203
+ async function deleteTags(store, tags, logger) {
204
+ const tagStore = store;
205
+ if (tags.length === 0) {
206
+ return 0;
207
+ }
208
+ if (!tagStore.deleteByTags) {
209
+ logger?.warn?.("Cache store does not support tag invalidation. Falling back to pattern invalidation only.");
210
+ return 0;
211
+ }
212
+ try {
213
+ const deletedCount = await tagStore.deleteByTags(tags);
214
+ return deletedCount ?? 0;
215
+ }
216
+ catch (error) {
217
+ logger?.error?.("Failed to invalidate cache tags", error);
218
+ throw error;
219
+ }
220
+ }
221
+ async function deleteCacheEntries(store, options, logger) {
222
+ const patterns = uniqueValues(options.patterns ?? []);
223
+ const tags = uniqueValues(options.tags ?? []);
224
+ const patternDeletes = await deletePatterns(store, patterns, logger);
225
+ const tagDeletes = await deleteTags(store, tags, logger);
56
226
  return {
227
+ deletedCount: patternDeletes + tagDeletes,
228
+ patterns,
229
+ tags,
230
+ };
231
+ }
232
+ function buildResourceBaseTag(basePath) {
233
+ return `resource:${normalizePath(basePath)}`;
234
+ }
235
+ function resolveResourceTags(basePath, kind, input) {
236
+ const resourceBaseTag = buildResourceBaseTag(basePath);
237
+ if (kind === "list") {
238
+ return [resourceBaseTag, `${resourceBaseTag}:list`];
239
+ }
240
+ if (kind === "detail") {
241
+ return [
242
+ resourceBaseTag,
243
+ `${resourceBaseTag}:detail:${input.detailId ?? ""}`,
244
+ ];
245
+ }
246
+ return [
247
+ resourceBaseTag,
248
+ `${resourceBaseTag}:variant:${input.variantName ?? "unknown"}`,
249
+ ];
250
+ }
251
+ function resolveMaybeValue(value, req, res, body) {
252
+ return typeof value === "function"
253
+ ? value(req, res, body)
254
+ : value;
255
+ }
256
+ function buildResourceInvalidationPatterns(options, req) {
257
+ const normalizedBasePath = normalizePath(options.basePath);
258
+ const patterns = [
259
+ `resource:list:${normalizedBasePath}`,
260
+ `resource:list:${normalizedBasePath}?*`,
261
+ `resource:list:${normalizedBasePath}#*`,
262
+ `resource:list:${normalizedBasePath}?*#*`,
263
+ ];
264
+ for (const variantName of options.variants) {
265
+ const variantPath = `${normalizedBasePath}/${variantName}`;
266
+ patterns.push(`resource:variant:${variantName}:${variantPath}`, `resource:variant:${variantName}:${variantPath}?*`, `resource:variant:${variantName}:${variantPath}#*`, `resource:variant:${variantName}:${variantPath}?*#*`);
267
+ }
268
+ const detailId = getRouteParamValue(req.params?.[options.idParam]);
269
+ if (detailId) {
270
+ const detailPath = `${normalizedBasePath}/${detailId}`;
271
+ patterns.push(`resource:detail:${detailPath}`, `resource:detail:${detailPath}?*`, `resource:detail:${detailPath}#*`, `resource:detail:${detailPath}?*#*`);
272
+ }
273
+ return uniqueValues(patterns);
274
+ }
275
+ function buildResourceInvalidationTags(options, req) {
276
+ const resourceBaseTag = buildResourceBaseTag(options.basePath);
277
+ const tags = [resourceBaseTag, `${resourceBaseTag}:list`];
278
+ for (const variantName of options.variants) {
279
+ tags.push(`${resourceBaseTag}:variant:${variantName}`);
280
+ }
281
+ const detailId = getRouteParamValue(req.params?.[options.idParam]);
282
+ if (detailId) {
283
+ tags.push(`${resourceBaseTag}:detail:${detailId}`);
284
+ }
285
+ return uniqueValues(tags);
286
+ }
287
+ function createRouteCache(options) {
288
+ const { defaultTtlSeconds, events, logger, namespace: inputNamespace, store, } = options;
289
+ const namespace = normalizeNamespace(inputNamespace);
290
+ function buildNamespacedKey(value) {
291
+ return applyNamespace(value, namespace);
292
+ }
293
+ function buildNamespacedPatterns(patterns) {
294
+ return normalizePatterns(patterns, namespace);
295
+ }
296
+ function buildNamespacedTags(tags) {
297
+ return normalizeTags(tags, namespace);
298
+ }
299
+ function resolveTtl(ttlValue, fallback) {
300
+ return parseTtl(ttlValue ?? fallback);
301
+ }
302
+ function resolveInvalidatePayload(invalidateOptions, req, res, body) {
303
+ const resolvedPatterns = uniqueValues(invalidateOptions.patterns
304
+ ? resolveMaybeValue(invalidateOptions.patterns, req, res, body)
305
+ : []);
306
+ const resolvedTags = uniqueValues(invalidateOptions.tags
307
+ ? resolveMaybeValue(invalidateOptions.tags, req, res, body)
308
+ : []);
309
+ return {
310
+ patterns: buildNamespacedPatterns(resolvedPatterns),
311
+ tags: buildNamespacedTags(resolvedTags),
312
+ };
313
+ }
314
+ function createReadBundle(keyBuilder, defaultReadOptions, defaultTagsBuilder) {
315
+ const readOptions = defaultReadOptions ?? {};
316
+ return [
317
+ api.check({
318
+ key: (req) => buildNamespacedKey(keyBuilder(req, readOptions.varyHeaders ?? [])),
319
+ shouldBypass: readOptions.shouldBypass,
320
+ hitHeader: readOptions.hitHeader ?? "x-cache",
321
+ }),
322
+ api.capture({
323
+ ttlSeconds: readOptions.ttlSeconds ?? defaultTtlSeconds,
324
+ shouldCache: readOptions.shouldCache,
325
+ tags: (req, res, body) => uniqueValues([
326
+ ...defaultTagsBuilder(req),
327
+ ...(readOptions.tags
328
+ ? resolveMaybeValue(readOptions.tags, req, res, body) ?? []
329
+ : []),
330
+ ]),
331
+ }),
332
+ ];
333
+ }
334
+ const api = {
57
335
  check(checkOptions = {}) {
58
336
  const { hitHeader = "x-cache", key, shouldBypass, varyHeaders = [], } = checkOptions;
59
337
  return async (req, res, next) => {
60
338
  if (shouldBypass?.(req)) {
61
339
  return next();
62
340
  }
63
- const cacheKey = typeof key === "function"
341
+ const rawKey = typeof key === "function"
64
342
  ? key(req)
65
343
  : key ?? buildCacheKey(req, varyHeaders);
344
+ const cacheKey = buildNamespacedKey(rawKey);
66
345
  setCacheContext(res, { key: cacheKey });
67
346
  try {
68
347
  const cachedBody = await store.get(cacheKey);
@@ -70,22 +349,30 @@ function createRouteCache(options) {
70
349
  if (hitHeader) {
71
350
  res.setHeader(hitHeader, "HIT");
72
351
  }
352
+ safeEmit(events?.hit, logger, "hit", { key: cacheKey, req }, events);
73
353
  res.json(cachedBody);
74
354
  return;
75
355
  }
76
356
  if (hitHeader) {
77
357
  res.setHeader(hitHeader, "MISS");
78
358
  }
359
+ safeEmit(events?.miss, logger, "miss", { key: cacheKey, req }, events);
79
360
  next();
80
361
  }
81
362
  catch (error) {
82
363
  logger?.error?.("Failed to read from cache store", error);
364
+ safeEmitError(events, logger, {
365
+ req,
366
+ key: cacheKey,
367
+ operation: "get",
368
+ error,
369
+ });
83
370
  next();
84
371
  }
85
372
  };
86
373
  },
87
374
  capture(captureOptions = {}) {
88
- const { shouldCache, ttlSeconds } = captureOptions;
375
+ const { shouldCache, tags, ttlSeconds } = captureOptions;
89
376
  return (req, res, next) => {
90
377
  const originalJson = res.json.bind(res);
91
378
  res.json = ((body) => {
@@ -93,14 +380,45 @@ function createRouteCache(options) {
93
380
  if (context &&
94
381
  isSuccessfulStatus(res.statusCode) &&
95
382
  (shouldCache?.(req, res, body) ?? true)) {
96
- const resolvedTtl = typeof ttlSeconds === "function"
383
+ const resolvedTtl = resolveTtl(typeof ttlSeconds === "function"
97
384
  ? ttlSeconds(req, res, body)
98
- : ttlSeconds ?? defaultTtlSeconds;
99
- store
385
+ : ttlSeconds, defaultTtlSeconds);
386
+ const resolvedTags = uniqueValues(tags
387
+ ? resolveMaybeValue(tags, req, res, body) ?? []
388
+ : []);
389
+ const normalizedTags = buildNamespacedTags(resolvedTags);
390
+ void store
100
391
  .set(context.key, body, {
101
392
  ttlSeconds: resolvedTtl,
102
393
  })
103
- .catch((error) => logger?.error?.("Failed to write response into cache store", error));
394
+ .then(async () => {
395
+ const tagStore = store;
396
+ if (normalizedTags.length > 0 && tagStore.addTags) {
397
+ await tagStore.addTags(context.key, normalizedTags, {
398
+ ttlSeconds: resolvedTtl,
399
+ });
400
+ }
401
+ else if (normalizedTags.length > 0) {
402
+ logger?.warn?.("Cache store does not support tag indexing. Tags were ignored.");
403
+ }
404
+ safeEmit(events?.set, logger, "set", {
405
+ key: context.key,
406
+ ttlSeconds: resolvedTtl,
407
+ req,
408
+ body,
409
+ tags: normalizedTags,
410
+ }, events);
411
+ })
412
+ .catch((error) => {
413
+ logger?.error?.("Failed to write response into cache store", error);
414
+ safeEmitError(events, logger, {
415
+ req,
416
+ key: context.key,
417
+ tags: normalizedTags,
418
+ operation: normalizedTags.length > 0 ? "tag" : "set",
419
+ error,
420
+ });
421
+ });
104
422
  }
105
423
  return originalJson(body);
106
424
  });
@@ -108,34 +426,137 @@ function createRouteCache(options) {
108
426
  };
109
427
  },
110
428
  invalidate(invalidateOptions) {
111
- const { onInvalidate, patterns, shouldInvalidate } = invalidateOptions;
429
+ const { onInvalidate, shouldInvalidate } = invalidateOptions;
112
430
  return (req, res, next) => {
113
431
  const originalJson = res.json.bind(res);
114
432
  res.json = ((body) => {
115
- const resolvedPatterns = typeof patterns === "function"
116
- ? patterns(req, res, body)
117
- : patterns;
433
+ const resolvedInvalidation = resolveInvalidatePayload(invalidateOptions, req, res, body);
118
434
  if (isSuccessfulStatus(res.statusCode) &&
119
435
  (shouldInvalidate?.(req, res, body) ?? true)) {
120
- Promise.resolve(onInvalidate?.(req, res, body, resolvedPatterns))
121
- .then(() => deletePatterns(store, resolvedPatterns, logger))
122
- .catch((error) => logger?.error?.("Failed while running cache invalidation hook", error));
436
+ Promise.resolve(onInvalidate?.(req, res, body, {
437
+ patterns: resolvedInvalidation.patterns,
438
+ tags: resolvedInvalidation.tags,
439
+ }))
440
+ .then(() => deleteCacheEntries(store, resolvedInvalidation, logger))
441
+ .then((result) => {
442
+ safeEmit(events?.invalidate, logger, "invalidate", {
443
+ req,
444
+ patterns: result.patterns,
445
+ tags: result.tags,
446
+ deletedCount: result.deletedCount,
447
+ }, events);
448
+ })
449
+ .catch((error) => {
450
+ logger?.error?.("Failed while running cache invalidation hook", error);
451
+ safeEmitError(events, logger, {
452
+ req,
453
+ patterns: resolvedInvalidation.patterns,
454
+ tags: resolvedInvalidation.tags,
455
+ operation: "invalidate",
456
+ error,
457
+ });
458
+ });
123
459
  }
124
460
  return originalJson(body);
125
461
  });
126
462
  next();
127
463
  };
128
464
  },
465
+ resource(resourceOptions) {
466
+ const { basePath, hitHeader = "x-cache", idParam = "id", shouldBypass, shouldCache, ttlSeconds, variants = [], varyHeaders = [], } = resourceOptions;
467
+ function mergeReadOptions(readOptions) {
468
+ return {
469
+ ttlSeconds: readOptions?.ttlSeconds ?? ttlSeconds,
470
+ varyHeaders: readOptions?.varyHeaders ?? varyHeaders,
471
+ shouldBypass: readOptions?.shouldBypass ?? shouldBypass,
472
+ hitHeader: readOptions?.hitHeader ?? hitHeader,
473
+ shouldCache: readOptions?.shouldCache ?? shouldCache,
474
+ tags: readOptions?.tags,
475
+ };
476
+ }
477
+ return {
478
+ list(readOptions) {
479
+ const mergedOptions = mergeReadOptions(readOptions);
480
+ return createReadBundle((req, varyHeaderList) => buildResourceKey("list", req, {
481
+ basePath,
482
+ varyHeaders: varyHeaderList,
483
+ }), mergedOptions, () => resolveResourceTags(basePath, "list", {}));
484
+ },
485
+ detail(readOptions) {
486
+ const mergedOptions = mergeReadOptions(readOptions);
487
+ return createReadBundle((req, varyHeaderList) => buildResourceKey("detail", req, {
488
+ basePath,
489
+ detailId: getRouteParamValue(req.params?.[idParam]),
490
+ varyHeaders: varyHeaderList,
491
+ }), mergedOptions, (req) => resolveResourceTags(basePath, "detail", {
492
+ detailId: getRouteParamValue(req.params?.[idParam]),
493
+ }));
494
+ },
495
+ variant(name, readOptions) {
496
+ const mergedOptions = mergeReadOptions(readOptions);
497
+ return createReadBundle((req, varyHeaderList) => buildResourceKey("variant", req, {
498
+ basePath,
499
+ variantName: name,
500
+ varyHeaders: varyHeaderList,
501
+ }), mergedOptions, () => resolveResourceTags(basePath, "variant", {
502
+ variantName: name,
503
+ }));
504
+ },
505
+ invalidateOnWrite(invalidateOptions = {}) {
506
+ return api.invalidate({
507
+ patterns: (req, res, body) => [
508
+ ...buildResourceInvalidationPatterns({
509
+ basePath,
510
+ idParam,
511
+ variants,
512
+ }, req),
513
+ ...(invalidateOptions.additionalPatterns
514
+ ? resolveMaybeValue(invalidateOptions.additionalPatterns, req, res, body)
515
+ : []),
516
+ ],
517
+ tags: (req, res, body) => [
518
+ ...buildResourceInvalidationTags({
519
+ basePath,
520
+ idParam,
521
+ variants,
522
+ }, req),
523
+ ...(invalidateOptions.additionalTags
524
+ ? resolveMaybeValue(invalidateOptions.additionalTags, req, res, body)
525
+ : []),
526
+ ],
527
+ shouldInvalidate: invalidateOptions.shouldInvalidate,
528
+ onInvalidate: invalidateOptions.onInvalidate,
529
+ });
530
+ },
531
+ };
532
+ },
129
533
  async get(key) {
130
- return store.get(key);
534
+ return store.get(buildNamespacedKey(key));
131
535
  },
132
536
  async set(key, value, ttlSeconds = defaultTtlSeconds) {
133
- await store.set(key, value, { ttlSeconds });
537
+ const resolvedTtl = resolveTtl(ttlSeconds, defaultTtlSeconds);
538
+ await store.set(buildNamespacedKey(key), value, {
539
+ ttlSeconds: resolvedTtl,
540
+ });
134
541
  },
135
542
  async deleteByPatterns(patterns) {
136
- await deletePatterns(store, patterns, logger);
543
+ const result = await deleteCacheEntries(store, { patterns: buildNamespacedPatterns(patterns) }, logger);
544
+ safeEmit(events?.invalidate, logger, "invalidate", {
545
+ patterns: result.patterns,
546
+ tags: result.tags,
547
+ deletedCount: result.deletedCount,
548
+ }, events);
549
+ },
550
+ async deleteByTags(tags) {
551
+ const result = await deleteCacheEntries(store, { tags: buildNamespacedTags(tags) }, logger);
552
+ safeEmit(events?.invalidate, logger, "invalidate", {
553
+ patterns: result.patterns,
554
+ tags: result.tags,
555
+ deletedCount: result.deletedCount,
556
+ }, events);
137
557
  },
138
558
  };
559
+ return api;
139
560
  }
140
561
  async function collectKeysForPattern(client, pattern, scanCount) {
141
562
  if (client.scan) {
@@ -153,15 +574,40 @@ async function collectKeysForPattern(client, pattern, scanCount) {
153
574
  }
154
575
  throw new Error("Redis client must provide either scan() or keys() for pattern invalidation.");
155
576
  }
577
+ function createRedisTagSetKey(tag) {
578
+ return `${REDIS_TAG_PREFIX}${tag}`;
579
+ }
580
+ function createRedisKeyTagSetKey(key) {
581
+ return `${REDIS_KEY_TAG_PREFIX}${key}`;
582
+ }
583
+ function hasRedisTagSupport(client) {
584
+ return Boolean(client.sadd && client.srem && client.smembers && client.expire);
585
+ }
156
586
  function createRedisStore(options) {
157
587
  const { client, defaultTtlSeconds = 60, deserialize = JSON.parse, logger, scanCount = 200, serialize = JSON.stringify, } = options;
588
+ const resolvedDefaultTtl = parseTtl(defaultTtlSeconds) ?? 60;
589
+ async function cleanupRedisTagRefs(keys) {
590
+ if (!hasRedisTagSupport(client) || keys.length === 0) {
591
+ return;
592
+ }
593
+ for (const key of keys) {
594
+ const keyTagSetKey = createRedisKeyTagSetKey(key);
595
+ const tags = await client.smembers(keyTagSetKey);
596
+ if (tags.length > 0) {
597
+ for (const tag of tags) {
598
+ await client.srem(createRedisTagSetKey(tag), key);
599
+ }
600
+ }
601
+ await client.del(keyTagSetKey);
602
+ }
603
+ }
158
604
  return {
159
605
  async get(key) {
160
606
  const rawValue = await client.get(key);
161
607
  return rawValue ? deserialize(rawValue) : null;
162
608
  },
163
609
  async set(key, value, setOptions) {
164
- const ttlSeconds = setOptions?.ttlSeconds ?? defaultTtlSeconds;
610
+ const ttlSeconds = setOptions?.ttlSeconds ?? resolvedDefaultTtl;
165
611
  const payload = serialize(value);
166
612
  if (client.setex) {
167
613
  await client.setex(key, ttlSeconds, payload);
@@ -179,6 +625,7 @@ function createRedisStore(options) {
179
625
  if (uniqueKeys.length === 0) {
180
626
  return 0;
181
627
  }
628
+ await cleanupRedisTagRefs(uniqueKeys);
182
629
  const deletedCount = await client.del(...uniqueKeys);
183
630
  logger?.info?.("Deleted cache keys", {
184
631
  deletedCount,
@@ -186,11 +633,69 @@ function createRedisStore(options) {
186
633
  });
187
634
  return deletedCount;
188
635
  },
636
+ async addTags(key, tags, addTagOptions) {
637
+ if (!hasRedisTagSupport(client) || tags.length === 0) {
638
+ return;
639
+ }
640
+ const ttlSeconds = addTagOptions?.ttlSeconds ?? resolvedDefaultTtl;
641
+ const keyTagSetKey = createRedisKeyTagSetKey(key);
642
+ await client.sadd(keyTagSetKey, ...tags);
643
+ await client.expire(keyTagSetKey, ttlSeconds);
644
+ for (const tag of tags) {
645
+ const tagSetKey = createRedisTagSetKey(tag);
646
+ await client.sadd(tagSetKey, key);
647
+ await client.expire(tagSetKey, ttlSeconds);
648
+ }
649
+ },
650
+ async deleteByTags(tags) {
651
+ if (!hasRedisTagSupport(client) || tags.length === 0) {
652
+ return 0;
653
+ }
654
+ const keys = new Set();
655
+ for (const tag of tags) {
656
+ const matchedKeys = await client.smembers(createRedisTagSetKey(tag));
657
+ matchedKeys.forEach((key) => keys.add(key));
658
+ }
659
+ const uniqueKeys = [...keys];
660
+ if (uniqueKeys.length === 0) {
661
+ return 0;
662
+ }
663
+ await cleanupRedisTagRefs(uniqueKeys);
664
+ const deletedCount = await client.del(...uniqueKeys);
665
+ for (const tag of tags) {
666
+ await client.del(createRedisTagSetKey(tag));
667
+ }
668
+ return deletedCount;
669
+ },
189
670
  };
190
671
  }
191
672
  function createMemoryStore(options = {}) {
192
673
  const { defaultTtlSeconds = 60 } = options;
674
+ const resolvedDefaultTtl = parseTtl(defaultTtlSeconds) ?? 60;
193
675
  const entries = new Map();
676
+ const tagIndex = new Map();
677
+ const keyTags = new Map();
678
+ function removeKeyFromTagIndexes(key) {
679
+ const tags = keyTags.get(key);
680
+ if (!tags) {
681
+ return;
682
+ }
683
+ for (const tag of tags) {
684
+ const keys = tagIndex.get(tag);
685
+ if (!keys) {
686
+ continue;
687
+ }
688
+ keys.delete(key);
689
+ if (keys.size === 0) {
690
+ tagIndex.delete(tag);
691
+ }
692
+ }
693
+ keyTags.delete(key);
694
+ }
695
+ function deleteKey(key) {
696
+ entries.delete(key);
697
+ removeKeyFromTagIndexes(key);
698
+ }
194
699
  return {
195
700
  async get(key) {
196
701
  const entry = entries.get(key);
@@ -198,13 +703,13 @@ function createMemoryStore(options = {}) {
198
703
  return null;
199
704
  }
200
705
  if (Date.now() > entry.expiresAt) {
201
- entries.delete(key);
706
+ deleteKey(key);
202
707
  return null;
203
708
  }
204
709
  return entry.value;
205
710
  },
206
711
  async set(key, value, setOptions) {
207
- const ttlSeconds = setOptions?.ttlSeconds ?? defaultTtlSeconds;
712
+ const ttlSeconds = setOptions?.ttlSeconds ?? resolvedDefaultTtl;
208
713
  entries.set(key, {
209
714
  expiresAt: Date.now() + ttlSeconds * 1000,
210
715
  value,
@@ -213,13 +718,40 @@ function createMemoryStore(options = {}) {
213
718
  async deleteByPatterns(patterns) {
214
719
  const matchers = patterns.map(wildcardToRegExp);
215
720
  let deletedCount = 0;
216
- for (const key of entries.keys()) {
721
+ for (const key of [...entries.keys()]) {
217
722
  if (matchers.some((matcher) => matcher.test(key))) {
218
- entries.delete(key);
723
+ deleteKey(key);
219
724
  deletedCount += 1;
220
725
  }
221
726
  }
222
727
  return deletedCount;
223
728
  },
729
+ async addTags(key, tags) {
730
+ if (!entries.has(key) || tags.length === 0) {
731
+ return;
732
+ }
733
+ const nextTags = keyTags.get(key) ?? new Set();
734
+ for (const tag of tags) {
735
+ nextTags.add(tag);
736
+ const keysForTag = tagIndex.get(tag) ?? new Set();
737
+ keysForTag.add(key);
738
+ tagIndex.set(tag, keysForTag);
739
+ }
740
+ keyTags.set(key, nextTags);
741
+ },
742
+ async deleteByTags(tags) {
743
+ const keys = new Set();
744
+ for (const tag of tags) {
745
+ const taggedKeys = tagIndex.get(tag);
746
+ if (!taggedKeys) {
747
+ continue;
748
+ }
749
+ taggedKeys.forEach((key) => keys.add(key));
750
+ }
751
+ for (const key of keys) {
752
+ deleteKey(key);
753
+ }
754
+ return keys.size;
755
+ },
224
756
  };
225
757
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heinhtet37/express-route-cache",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Reusable Express route caching middleware for Node.js and TypeScript services.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -19,6 +19,7 @@
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
21
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
22
+ "test": "npm run build && node --test tests/**/*.test.js",
22
23
  "prepublishOnly": "npm run clean && npm run build"
23
24
  },
24
25
  "publishConfig": {