@hile/cache 2.1.1 → 3.0.2

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/AI.md ADDED
@@ -0,0 +1,232 @@
1
+ # AI Guide For @hile/cache
2
+
3
+
4
+
5
+ <!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
6
+
7
+
8
+
9
+ Purpose: Build typed Redis read-through caches with TTL, tags, fieldable hashes, negative/stale options, and singleflight refreshes.
10
+
11
+
12
+
13
+ Use this file when an AI agent installs the npm package and needs package-local examples, package selection rules, boundaries, and verification steps.
14
+
15
+
16
+
17
+ ## Package Selection
18
+
19
+
20
+
21
+ | User asks for | Use | Also read |
22
+ |---|---|---|
23
+ | Add read-through Redis cache | `@hile/cache` | `packages/infrastructure.md`, `recipes/redis-cache-singleflight.md` |
24
+
25
+
26
+
27
+ # Infrastructure Helpers
28
+
29
+ Packages: `@hile/typeorm`, `@hile/ioredis`, `@hile/logger`, `@hile/cache`, `@hile/schedule`, `@hile/loader`.
30
+
31
+ ## Use When
32
+
33
+ Use these packages for database connections, Redis connections, structured logging, typed Redis caches, scheduled jobs, and file-system loaders.
34
+
35
+ ## Do Not Use When
36
+
37
+ - Do not use default TypeORM or Redis services for multiple connections.
38
+ - Do not use `@hile/cache` without returning `new Cache(...)` from the loader function.
39
+ - Do not use `@hile/schedule` distributed mode without Redis lock TTLs sized for job duration.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pnpm add @hile/typeorm @hile/ioredis @hile/logger @hile/cache @hile/schedule @hile/loader
45
+ ```
46
+
47
+ ## Imports
48
+
49
+ ```ts
50
+ import typeormService, { transaction } from '@hile/typeorm'
51
+ import redisService from '@hile/ioredis'
52
+ import { createLogger } from '@hile/logger'
53
+ import { Cache, defineCache, RedisCache } from '@hile/cache'
54
+ import { Scheduler, defineJob } from '@hile/schedule'
55
+ import { scanDirectory, compileRoutePath, toRouterPath, normalizePath, Loader } from '@hile/loader'
56
+ ```
57
+
58
+ ## Copy-Paste Example
59
+
60
+ ```ts
61
+ import { loadService } from '@hile/core'
62
+ import redisService from '@hile/ioredis'
63
+ import { Cache, defineCache, RedisCache } from '@hile/cache'
64
+
65
+ const userProfile = defineCache('user:{id:string}:profile', async ({ id }) => {
66
+ const user = await loadUser(id)
67
+ return new Cache(user).setExpire(300)
68
+ })
69
+
70
+ const redis = await loadService(redisService)
71
+ const cache = new RedisCache('app:', redis)
72
+ const users = await cache.loadCache(userProfile)
73
+ const user = await users.read({ id: 'u1' })
74
+ ```
75
+
76
+ ## More Examples
77
+
78
+ TypeORM transaction with compensation:
79
+
80
+ ```ts
81
+ await transaction(ds, async (runner, rollback) => {
82
+ const user = await runner.manager.save(User, input)
83
+ rollback(() => redis.del(`user:${user.id}`))
84
+ await runner.manager.save(AuditLog, { userId: user.id, action: 'create' })
85
+ return user
86
+ })
87
+ ```
88
+
89
+ Distributed scheduled job:
90
+
91
+ ```ts
92
+ const scheduler = new Scheduler()
93
+ scheduler.add('daily-report', '0 8 * * *', async () => {
94
+ await sendDailyReport()
95
+ }, {
96
+ distributed: {
97
+ redis,
98
+ ttl: 60_000,
99
+ namespace: 'reports',
100
+ policy: 'skip-if-locked',
101
+ },
102
+ })
103
+ ```
104
+
105
+ ## Compose With
106
+
107
+ - Use `RedisCache` with `@hile/ioredis`.
108
+ - Use `defineCache(..., { singleflight: true })` to reduce stampedes; internally it uses Redis locks.
109
+ - Use scheduler distributed mode with `@hile/redis-lock`.
110
+ - Use `Loader` only when building a new file-system based convention.
111
+
112
+ ## Runtime And Lifecycle Notes
113
+
114
+ - `Cache(undefined)` removes the key unless negative caching is configured.
115
+ - `Cache#setExpire(seconds)` uses seconds.
116
+ - `defineCache` typed placeholders support `string`, `number`, and `boolean`.
117
+ - `RedisCache.loadCache()` returns `read`, `write`, `remove`, `has`, and `multi`.
118
+ - `RedisCache.removeTag(tag)` removes tagged cache entries.
119
+ - `fieldable` caches use Redis hashes and cannot combine with stale or negative cache options.
120
+ - `Scheduler.add()` supports cron strings and `{ delay }`.
121
+ - `Scheduler.load()` reads default exports from `*.schedule.*` files produced by `defineJob()`.
122
+ - `scanDirectory()` matches `.ts`, `.js`, `.tsx`, `.jsx`, and `.mjs`.
123
+
124
+ ## Anti-Patterns
125
+
126
+ - Returning raw values from `defineCache` handlers instead of `new Cache(value)`.
127
+ - Treating cache as source of truth.
128
+ - Forgetting to destroy manually created Redis or TypeORM clients.
129
+ - Scheduling jobs without idempotency when side effects can repeat.
130
+
131
+ ## Verification Checklist
132
+
133
+ - Manual Redis clients call `disconnect()` during cleanup.
134
+ - Manual TypeORM data sources call `destroy()` during cleanup.
135
+ - Cache keys include app/tenant prefixes when shared Redis is used.
136
+ - Scheduled jobs have clear duplicate-run policy.
137
+
138
+
139
+
140
+ # Related Recipes
141
+
142
+
143
+
144
+ # Redis Cache With Singleflight
145
+
146
+ ## Complete Example
147
+
148
+ ```ts
149
+ import { loadService } from '@hile/core'
150
+ import redisService from '@hile/ioredis'
151
+ import { Cache, defineCache, RedisCache } from '@hile/cache'
152
+
153
+ const userProfileCache = defineCache(
154
+ 'user:{id:string}:profile',
155
+ async ({ id }) => {
156
+ const profile = await loadProfileFromDatabase(id)
157
+ if (!profile) return new Cache(undefined)
158
+ return new Cache(profile).setExpire(300)
159
+ },
160
+ {
161
+ singleflight: { ttl: 10_000, wait: 10_000 },
162
+ stale: { ttl: 60 },
163
+ negative: { ttl: 30 },
164
+ tags: (params, data) => data ? [`user:${params.id}`] : [],
165
+ },
166
+ )
167
+
168
+ const redis = await loadService(redisService)
169
+ const cache = new RedisCache('app:', redis)
170
+ const userProfiles = await cache.loadCache(userProfileCache)
171
+
172
+ const profile = await userProfiles.read({ id: 'u1' })
173
+ await userProfiles.remove({ id: 'u1' })
174
+ await cache.removeTag('user:u1')
175
+ ```
176
+
177
+ ## File Layout
178
+
179
+ ```text
180
+ src/
181
+ caches/user-profile.cache.ts
182
+ models/users/get-profile.model.ts
183
+ ```
184
+
185
+ ## User Intent
186
+
187
+ Use this recipe when reads are expensive and Redis should provide read-through caching with stampede protection.
188
+
189
+ ## Packages To Use
190
+
191
+ - `@hile/cache`
192
+ - `@hile/ioredis`
193
+ - `@hile/redis-lock` indirectly through cache singleflight
194
+
195
+ ## Implementation Steps
196
+
197
+ 1. Define a typed key with placeholders.
198
+ 2. Return `new Cache(value)` from the loader function.
199
+ 3. Add TTL with `setExpire(seconds)`.
200
+ 4. Enable `singleflight` for expensive reads.
201
+ 5. Remove cache entries after writes.
202
+
203
+ ## Failure And Cleanup Behavior
204
+
205
+ - Stale cache serves previous values while refresh runs.
206
+ - Negative cache stores missing values only when configured.
207
+ - Cache is not source of truth; database writes should remove or refresh related keys.
208
+
209
+ ## Verification Checklist
210
+
211
+ - Cache handler returns `Cache`.
212
+ - Key params match placeholders.
213
+ - Redis prefix is app-specific.
214
+ - Update flows call `remove()` or `removeTag()`.
215
+
216
+
217
+
218
+ # Global Guardrails
219
+
220
+
221
+
222
+ ## Never Generate These Patterns
223
+
224
+ - Do not call `loadService()` at module top level; it starts resources during import.
225
+ - Do not default-export plain functions from `*.boot.*` files; `hile start` expects a Hile service.
226
+ - Do not set `ctx.body` and also return a controller value.
227
+ - Do not assume `@hile/http` Zod validation mutates or coerces `ctx.query`, `ctx.params`, or `ctx.request.body`.
228
+ - Do not put reusable business logic only in controllers, pages, queue workers, or message handlers.
229
+ - Do not use old message examples that append a secondary response getter; current request APIs return promises directly.
230
+ - Do not claim exactly-once delivery or execution from Redis locks, queues, idempotency, or rate limits.
231
+ - Do not use queue `jobId` as the only side-effect idempotency boundary.
232
+ - Do not log the entire async context by default.
package/README.md CHANGED
@@ -1,201 +1,59 @@
1
1
  # @hile/cache
2
2
 
3
- 基于 Redis 的类型安全读穿透缓存层。依赖 `ioredis`,构造时注入已连接的 `Redis` 实例(可与 `@hile/ioredis` 配合使用)。
3
+ <!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
4
4
 
5
- ## 安装
5
+ Build typed Redis read-through caches with TTL, tags, fieldable hashes, negative/stale options, and singleflight refreshes.
6
6
 
7
- ```bash
8
- pnpm add @hile/cache
9
- ```
10
-
11
- 运行时需提供 `ioredis` 的 `Redis` 实例;在 Hile 应用中通常通过 `@hile/ioredis` 的 `createRedis()` 或 `loadService(ioredisService)` 获取。
12
-
13
- ## 快速开始
14
-
15
- ### 1. 定义缓存
16
-
17
- 使用 `defineCache` 定义一条缓存:指定 key 模板和回源函数。
18
-
19
- ```typescript
20
- import { defineCache, Cache } from '@hile/cache';
21
-
22
- const userCache = defineCache('user:{id:string}:info', async (params) => {
23
- // params 的类型自动推导为 { id: string }
24
- const data = await fetchUserFromDB(params.id);
25
- return new Cache(data).setExpire(300); // 5 分钟 TTL
26
- });
27
- ```
28
-
29
- ### 2. 使用缓存
30
-
31
- ```typescript
32
- import { loadService } from '@hile/core';
33
- import redisService from '@hile/ioredis';
34
- import { RedisCache } from '@hile/cache';
35
-
36
- const redis = await loadService(redisService);
37
- const cache = new RedisCache('myapp:', redis); // 前缀 + Redis 实例
38
-
39
- const { read, write, remove, has } = await cache.loadCache(userCache);
40
-
41
- // 读穿透:miss 时自动回源并写入
42
- const user = await read({ id: 'u-001' });
43
-
44
- // 强制刷新
45
- await write({ id: 'u-001' });
46
-
47
- // 判断是否存在
48
- const exists = await has({ id: 'u-001' });
49
-
50
- // 删除
51
- await remove({ id: 'u-001' });
52
- ```
53
-
54
- ---
55
-
56
- ## 核心概念
57
-
58
- ### 类型安全 key 模板
59
-
60
- key 模板使用 `{name:type}` 占位符,编译期自动推导参数类型:
61
-
62
- ```typescript
63
- defineCache('user:{id:string}:posts:{page:number}:{verified:boolean}', ...)
64
- // params → { id: string; page: number; verified: boolean }
65
- ```
66
-
67
- 支持的类型:`string`、`number`、`boolean`。
68
-
69
- ### Cache 类
70
-
71
- `Cache<R>` 包装回源数据,提供 TTL 控制:
7
+ This README is intentionally short and example-first. The complete AI-facing guide ships in `AI.md` in this package.
72
8
 
73
- | 方法 | 说明 |
74
- |------|------|
75
- | `new Cache(data)` | 创建缓存数据,expire=0 永不过期 |
76
- | `.setExpire(seconds)` | 设置 TTL(秒) |
9
+ ## When To Use
77
10
 
78
- ### 读穿透(Read-Through)
11
+ Use these packages for database connections, Redis connections, structured logging, typed Redis caches, scheduled jobs, and file-system loaders.
79
12
 
80
- `RedisCache._read` 的调用链路:
13
+ ## Install
81
14
 
82
- ```
83
- read(params)
84
- → EXISTS key
85
- ├─ true → GET key → JSON.parse → 返回
86
- └─ false → write(params) → handler 回源 → SET/SETEX → 返回
87
- ```
88
-
89
- ---
90
-
91
- ## API 参考
92
-
93
- ### defineCache
94
-
95
- ```typescript
96
- function defineCache<T extends string = string, R = any>(
97
- key: T,
98
- fn: (params: ExtractParams<T>) => Promise<Cache<R>>
99
- ): DefineCacheResult<T, R>;
100
- ```
101
-
102
- ### RedisCache
103
-
104
- ```typescript
105
- class RedisCache {
106
- constructor(prefix: string, redis: Redis);
107
-
108
- loadCache<T extends string, R>(
109
- target: DefineCacheResult<T, R>
110
- ): Promise<{
111
- write(params: ExtractParams<T>): Promise<R | undefined>;
112
- read(params: ExtractParams<T>): Promise<R | undefined>;
113
- remove(params: ExtractParams<T>): Promise<number>;
114
- has(params: ExtractParams<T>): Promise<boolean>;
115
- }>;
116
- }
117
- ```
118
-
119
- | 方法 | 返回值 | 说明 |
120
- |------|--------|------|
121
- | `read` | `R \| undefined` | 读穿透:EXISTS → GET / miss 则回源写入 |
122
- | `write` | `R \| undefined` | 强制执行回源并写入 Redis(data=undefined 则删除 key) |
123
- | `remove` | `number` | 删除缓存,返回 0/1 |
124
- | `has` | `boolean` | 检查 key 是否存在 |
125
-
126
- ### Redis 中的 key 结构
127
-
128
- ```
129
- {prefix}{key模板渲染结果}
130
- ```
131
-
132
- 例如 `prefix = "myapp:"`、key 模板为 `user:{id:string}:info`、params 为 `{ id: "u-001" }` 时:
133
- ```
134
- myapp:user:u-001:info
15
+ ```bash
16
+ pnpm add @hile/cache
135
17
  ```
136
18
 
137
- ---
19
+ ## Copy-Paste Example
138
20
 
139
- ## 与 @hile/cli 集成
140
-
141
- `package.json` 中配置自动加载 Redis,在 boot 或服务工厂里注入客户端:
142
-
143
- ```json
144
- {
145
- "hile": {
146
- "auto_load_packages": ["@hile/ioredis"]
147
- }
148
- }
149
- ```
21
+ ```ts
22
+ import { loadService } from '@hile/core'
23
+ import redisService from '@hile/ioredis'
24
+ import { Cache, defineCache, RedisCache } from '@hile/cache'
150
25
 
151
- ```typescript
152
- import { loadService } from '@hile/core';
153
- import redisService from '@hile/ioredis';
154
- import { defineCache, Cache, RedisCache } from '@hile/cache';
26
+ const userProfile = defineCache('user:{id:string}:profile', async ({ id }) => {
27
+ const user = await loadUser(id)
28
+ return new Cache(user).setExpire(300)
29
+ })
155
30
 
156
- // defineService 工厂内
157
- const redis = await loadService(redisService);
158
- const cache = new RedisCache('myapp:', redis);
31
+ const redis = await loadService(redisService)
32
+ const cache = new RedisCache('app:', redis)
33
+ const users = await cache.loadCache(userProfile)
34
+ const user = await users.read({ id: 'u1' })
159
35
  ```
160
36
 
161
- ---
37
+ ## Boundaries
162
38
 
163
- ## 完整示例
39
+ - Do not use default TypeORM or Redis services for multiple connections.
40
+ - Do not use `@hile/cache` without returning `new Cache(...)` from the loader function.
41
+ - Do not use `@hile/schedule` distributed mode without Redis lock TTLs sized for job duration.
164
42
 
165
- ```typescript
166
- import { loadService } from '@hile/core';
167
- import redisService from '@hile/ioredis';
168
- import { defineCache, Cache, RedisCache } from '@hile/cache';
43
+ - Returning raw values from `defineCache` handlers instead of `new Cache(value)`.
44
+ - Treating cache as source of truth.
45
+ - Forgetting to destroy manually created Redis or TypeORM clients.
46
+ - Scheduling jobs without idempotency when side effects can repeat.
169
47
 
170
- const redis = await loadService(redisService);
171
-
172
- // 定义多条缓存
173
- const userCache = defineCache('user:{id:string}:info', async ({ id }) => {
174
- const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
175
- if (!user) return new Cache(undefined); // 不写入 Redis
176
- return new Cache(user).setExpire(600);
177
- });
178
-
179
- const postCache = defineCache('post:{id:string}:detail', async ({ id }) => {
180
- const post = await db.query('SELECT * FROM posts WHERE id = $1', [id]);
181
- return new Cache(post).setExpire(3600);
182
- });
183
-
184
- const cache = new RedisCache('myapp:', redis);
185
-
186
- // 批量加载
187
- const [userOps, postOps] = await Promise.all([
188
- cache.loadCache(userCache),
189
- cache.loadCache(postCache),
190
- ]);
191
-
192
- // 使用
193
- const user = await userOps.read({ id: 'u-001' });
194
- const post = await postOps.read({ id: 'p-001' });
195
- ```
48
+ ## Verify
196
49
 
197
- ---
50
+ - Manual Redis clients call `disconnect()` during cleanup.
51
+ - Manual TypeORM data sources call `destroy()` during cleanup.
52
+ - Cache keys include app/tenant prefixes when shared Redis is used.
53
+ - Scheduled jobs have clear duplicate-run policy.
198
54
 
199
- ## License
55
+ ## More Context
200
56
 
201
- MIT
57
+ - `AI.md` in this package: full package-local AI guide.
58
+ - Root `llms-full.txt`: full monorepo AI context.
59
+ - Root `references/`: source files copied from `docs/ai`.
package/dist/define.d.ts CHANGED
@@ -11,9 +11,30 @@ export type ExtractParams<Template extends string> = string extends Template ? R
11
11
  [K in Key]: Type extends "string" ? string : Type extends "number" ? number : Type extends "boolean" ? boolean : never;
12
12
  } & ExtractParams<Rest> : {};
13
13
  export type DefineCacheHandler<T extends string = string, R = any> = (opts: ExtractParams<T>) => Promise<Cache<R>>;
14
+ export type CacheTagResolver<T extends string = string, R = any> = string[] | ((params: ExtractParams<T>, data: R | undefined) => string[]);
15
+ export type CacheSingleflightOptions = {
16
+ ttl?: number;
17
+ wait?: number;
18
+ pollInterval?: number;
19
+ maxPollInterval?: number;
20
+ };
21
+ export type CacheStaleOptions = {
22
+ ttl: number;
23
+ };
24
+ export type CacheNegativeOptions = {
25
+ ttl: number;
26
+ };
27
+ export type DefineCacheOptions<T extends string = string, R = any> = {
28
+ fieldable?: boolean;
29
+ singleflight?: boolean | CacheSingleflightOptions;
30
+ stale?: CacheStaleOptions;
31
+ negative?: CacheNegativeOptions;
32
+ tags?: CacheTagResolver<T, R>;
33
+ };
14
34
  export type DefineCacheResult<T extends string = string, R = any> = {
15
35
  fn: DefineCacheHandler<T, R>;
16
- key: string;
36
+ key: T;
17
37
  fieldable: boolean;
38
+ options: DefineCacheOptions<T, R>;
18
39
  };
19
- export declare function defineCache<T extends string = string, R = any>(key: T, fn: DefineCacheHandler<T, R>, fieldable?: boolean): DefineCacheResult<T, R>;
40
+ export declare function defineCache<T extends string = string, R = any>(key: T, fn: DefineCacheHandler<T, R>, options?: boolean | DefineCacheOptions<T, R>): DefineCacheResult<T, R>;
package/dist/define.js CHANGED
@@ -13,11 +13,26 @@ export class Cache {
13
13
  return this._expire;
14
14
  }
15
15
  }
16
- export function defineCache(key, fn, fieldable = false) {
16
+ function normalizeDefineCacheOptions(options) {
17
+ return typeof options === 'boolean'
18
+ ? { fieldable: options }
19
+ : options;
20
+ }
21
+ function assertCompatibleCacheOptions(options) {
22
+ if (!options.fieldable)
23
+ return;
24
+ if (options.negative || options.stale) {
25
+ throw new TypeError('fieldable cache cannot be combined with negative or stale cache options');
26
+ }
27
+ }
28
+ export function defineCache(key, fn, options = false) {
29
+ const normalized = normalizeDefineCacheOptions(options);
30
+ assertCompatibleCacheOptions(normalized);
17
31
  return {
18
32
  fn,
19
33
  key,
20
- fieldable,
34
+ fieldable: normalized.fieldable ?? false,
35
+ options: normalized,
21
36
  };
22
37
  }
23
38
  // defineCache('user:{id:string}:ddd:{x:number}:idsaf:{y:boolean}', async (params) => {
package/dist/index.d.ts CHANGED
@@ -1,16 +1,34 @@
1
+ import { RedisLock } from '@hile/redis-lock';
1
2
  import { DefineCacheResult, ExtractParams } from './define.js';
2
3
  import { ChainableCommander, Redis } from 'ioredis';
3
4
  export * from './define.js';
5
+ export * from './options.js';
6
+ export * from './payload.js';
7
+ export * from './tags.js';
8
+ export type RedisCacheOptions = {
9
+ locks?: RedisLock;
10
+ };
4
11
  export declare class RedisCache {
5
12
  private readonly prefix;
6
13
  private readonly redis;
7
14
  private readonly _regexp;
8
- constructor(prefix: string, redis: Redis);
15
+ private readonly locks;
16
+ private readonly tags;
17
+ constructor(prefix: string, redis: Redis, options?: RedisCacheOptions);
9
18
  private makeKey;
10
19
  private _multi;
11
20
  private _write;
21
+ private _writeWithKey;
22
+ private _storeCacheResult;
23
+ private rememberTags;
12
24
  private _read;
25
+ private _readExisting;
26
+ private _refreshStale;
13
27
  private _remove;
28
+ private withKeyTagMutationLock;
29
+ private withMutationLocks;
30
+ private makeKeyMutationLockKey;
31
+ private makeTagMutationLockKey;
14
32
  private _has;
15
33
  loadCache<T extends string, R>(target: DefineCacheResult<T, R>): Promise<{
16
34
  write: (params: ExtractParams<T>) => Promise<R | undefined>;
@@ -19,4 +37,5 @@ export declare class RedisCache {
19
37
  has: (params: ExtractParams<T>) => Promise<boolean>;
20
38
  multi: (params: ExtractParams<T>, callback: (multi: ChainableCommander, key: string) => unknown) => Promise<[error: Error | null, result: unknown][] | null>;
21
39
  }>;
40
+ removeTag(tag: string): Promise<number>;
22
41
  }
package/dist/index.js CHANGED
@@ -1,11 +1,26 @@
1
+ import { RedisLock } from '@hile/redis-lock';
2
+ import { decodeCacheValue, encodeCacheValue, encodeNegative, resolveNegativeTtl, resolveRedisTtl, } from './payload.js';
3
+ import { resolveSingleflightOptions } from './options.js';
4
+ import { CacheTagIndex } from './tags.js';
1
5
  export * from './define.js';
6
+ export * from './options.js';
7
+ export * from './payload.js';
8
+ export * from './tags.js';
9
+ const TAG_MUTATION_LOCK_OPTIONS = {
10
+ ttl: 10_000,
11
+ wait: 10_000,
12
+ };
2
13
  export class RedisCache {
3
14
  prefix;
4
15
  redis;
5
16
  _regexp = /\{([^\:]+):[^\}]+\}/g;
6
- constructor(prefix, redis) {
17
+ locks;
18
+ tags;
19
+ constructor(prefix, redis, options = {}) {
7
20
  this.prefix = prefix;
8
21
  this.redis = redis;
22
+ this.locks = options.locks ?? new RedisLock(redis);
23
+ this.tags = new CacheTagIndex(prefix, redis);
9
24
  }
10
25
  makeKey(key, options) {
11
26
  return this.prefix + key.replace(this._regexp, (_, key) => String(options[key]));
@@ -15,23 +30,44 @@ export class RedisCache {
15
30
  const redis = this.redis;
16
31
  const exists = await redis.exists(key);
17
32
  if (!exists)
18
- await this._write(target, params);
33
+ await this._writeWithKey(target, params, key);
19
34
  const multi = redis.multi();
20
35
  callback(multi, key);
21
36
  return await multi.exec();
22
37
  }
23
38
  async _write(target, params) {
24
39
  const key = this.makeKey(target.key, params);
40
+ return this._writeWithKey(target, params, key);
41
+ }
42
+ async _writeWithKey(target, params, key) {
25
43
  const cache = await target.fn(params);
26
- if (cache.__$flag__ !== 'cache') {
44
+ if (!cache || cache.__$flag__ !== 'cache') {
27
45
  throw new Error('Cache result must be an instance of Cache');
28
46
  }
47
+ if (!target.options.tags) {
48
+ return this._storeCacheResult(target, params, key, cache);
49
+ }
50
+ const nextTags = cache.data === undefined && resolveNegativeTtl(target.options) === undefined
51
+ ? []
52
+ : this.tags.resolveTags(target, params, cache.data);
53
+ return this.withKeyTagMutationLock(target, params, key, nextTags, () => {
54
+ return this._storeCacheResult(target, params, key, cache, nextTags);
55
+ });
56
+ }
57
+ async _storeCacheResult(target, params, key, cache, resolvedTags) {
29
58
  const redis = this.redis;
30
59
  const exists = await redis.exists(key);
31
60
  if (cache.data === undefined) {
32
- if (exists) {
33
- await redis.del(key);
61
+ const negativeTtl = resolveNegativeTtl(target.options);
62
+ if (negativeTtl !== undefined) {
63
+ await redis.setex(key, negativeTtl, encodeNegative());
64
+ await this.rememberTags(target, params, undefined, key, resolvedTags);
65
+ return;
34
66
  }
67
+ if (exists)
68
+ await redis.del(key);
69
+ if (target.options.tags)
70
+ await this.tags.forget(target, params, key);
35
71
  return;
36
72
  }
37
73
  if (target.fieldable) {
@@ -39,40 +75,126 @@ export class RedisCache {
39
75
  if (cache.expire > 0) {
40
76
  await redis.expire(key, cache.expire);
41
77
  }
78
+ else {
79
+ await redis.persist(key);
80
+ }
81
+ await this.rememberTags(target, params, cache.data, key, resolvedTags);
42
82
  return cache.data;
43
83
  }
44
- const payload = JSON.stringify(cache.data);
45
- if (cache.expire > 0) {
46
- await redis.setex(key, cache.expire, payload);
84
+ const payload = encodeCacheValue(cache.data, cache.expire, target.options);
85
+ const ttl = resolveRedisTtl(cache.expire, target.options);
86
+ if (ttl > 0) {
87
+ await redis.setex(key, ttl, payload);
47
88
  }
48
89
  else {
49
90
  await redis.set(key, payload);
50
91
  }
92
+ await this.rememberTags(target, params, cache.data, key, resolvedTags);
51
93
  return cache.data;
52
94
  }
95
+ async rememberTags(target, params, data, key, resolvedTags) {
96
+ if (!target.options.tags)
97
+ return;
98
+ if (resolvedTags) {
99
+ await this.tags.rememberTags(key, resolvedTags);
100
+ return;
101
+ }
102
+ await this.tags.remember(target, params, data, key);
103
+ }
53
104
  async _read(target, params) {
54
105
  const key = this.makeKey(target.key, params);
55
- const redis = this.redis;
106
+ const cached = await this._readExisting(target, key);
107
+ if (cached.hit) {
108
+ if (cached.stale) {
109
+ void this._refreshStale(target, params, key);
110
+ }
111
+ return cached.value;
112
+ }
113
+ const singleflight = resolveSingleflightOptions(target.options);
114
+ if (singleflight) {
115
+ return this.locks.withLock(`${key}:lock`, singleflight, async () => {
116
+ const current = await this._readExisting(target, key);
117
+ if (current.hit)
118
+ return current.value;
119
+ return this._writeWithKey(target, params, key);
120
+ });
121
+ }
122
+ return await this._writeWithKey(target, params, key);
123
+ }
124
+ async _readExisting(target, key) {
56
125
  if (target.fieldable) {
57
- const fields = await redis.hgetall(key);
58
- if (!fields)
59
- return await this._write(target, params);
60
- if (Object.keys(fields).length === 0)
61
- return await this._write(target, params);
62
- return fields;
126
+ const fields = await this.redis.hgetall(key);
127
+ if (!fields || Object.keys(fields).length === 0)
128
+ return { hit: false };
129
+ return { hit: true, value: fields, stale: false };
63
130
  }
64
- const text = await redis.get(key);
131
+ const text = await this.redis.get(key);
65
132
  if (!text)
66
- return await this._write(target, params);
67
- return JSON.parse(text);
133
+ return { hit: false };
134
+ return decodeCacheValue(text, target.options);
135
+ }
136
+ async _refreshStale(target, params, key) {
137
+ try {
138
+ const singleflight = resolveSingleflightOptions(target.options);
139
+ if (singleflight) {
140
+ await this.locks.withLock(`${key}:lock`, {
141
+ ...singleflight,
142
+ wait: 0,
143
+ }, async () => {
144
+ await this._writeWithKey(target, params, key);
145
+ });
146
+ return;
147
+ }
148
+ await this._writeWithKey(target, params, key);
149
+ }
150
+ catch {
151
+ // stale cache should keep serving the previous value when refresh fails
152
+ }
68
153
  }
69
154
  async _remove(target, params) {
70
155
  const key = this.makeKey(target.key, params);
156
+ if (target.options.tags) {
157
+ return this.withKeyTagMutationLock(target, params, key, [], async () => {
158
+ const removed = await this.redis.del(key);
159
+ await this.tags.forget(target, params, key);
160
+ return removed;
161
+ });
162
+ }
71
163
  const redis = this.redis;
72
164
  const exists = await redis.exists(key);
73
165
  if (!exists)
74
166
  return 0;
75
- return await redis.del(key);
167
+ const removed = await redis.del(key);
168
+ if (removed > 0)
169
+ await this.tags.forget(target, params, key);
170
+ return removed;
171
+ }
172
+ async withKeyTagMutationLock(target, params, key, nextTags, callback) {
173
+ return this.withMutationLocks([this.makeKeyMutationLockKey(key)], async () => {
174
+ const previousTags = await this.tags.readKeyTags(key);
175
+ const legacyTags = previousTags.length === 0
176
+ ? this.tags.resolveTags(target, params, undefined)
177
+ : [];
178
+ const tagLockKeys = [...previousTags, ...legacyTags, ...nextTags]
179
+ .map(tag => this.makeTagMutationLockKey(tag));
180
+ return this.withMutationLocks(tagLockKeys, callback);
181
+ });
182
+ }
183
+ async withMutationLocks(lockKeys, callback) {
184
+ const keys = [...new Set(lockKeys)].sort();
185
+ const run = async (index) => {
186
+ if (index >= keys.length)
187
+ return callback();
188
+ const lockKey = keys[index];
189
+ return this.locks.withLock(lockKey, TAG_MUTATION_LOCK_OPTIONS, async () => run(index + 1));
190
+ };
191
+ return run(0);
192
+ }
193
+ makeKeyMutationLockKey(key) {
194
+ return `${key}:hile-cache:mutation-lock`;
195
+ }
196
+ makeTagMutationLockKey(tag) {
197
+ return `${this.prefix}tag:${tag}:mutation-lock`;
76
198
  }
77
199
  async _has(target, params) {
78
200
  const key = this.makeKey(target.key, params);
@@ -99,4 +221,9 @@ export class RedisCache {
99
221
  }
100
222
  };
101
223
  }
224
+ async removeTag(tag) {
225
+ return this.withMutationLocks([this.makeTagMutationLockKey(tag)], () => {
226
+ return this.tags.removeTag(tag);
227
+ });
228
+ }
102
229
  }
@@ -0,0 +1,8 @@
1
+ import type { DefineCacheOptions } from './define';
2
+ export type ResolvedSingleflightOptions = {
3
+ ttl: number;
4
+ wait: number;
5
+ pollInterval?: number;
6
+ maxPollInterval?: number;
7
+ };
8
+ export declare function resolveSingleflightOptions<T extends string, R>(options: DefineCacheOptions<T, R>): ResolvedSingleflightOptions | undefined;
@@ -0,0 +1,12 @@
1
+ export function resolveSingleflightOptions(options) {
2
+ if (!options.singleflight)
3
+ return undefined;
4
+ const singleflight = options.singleflight === true ? {} : options.singleflight;
5
+ const ttl = singleflight.ttl ?? 10_000;
6
+ return {
7
+ ttl,
8
+ wait: singleflight.wait ?? ttl,
9
+ pollInterval: singleflight.pollInterval,
10
+ maxPollInterval: singleflight.maxPollInterval,
11
+ };
12
+ }
@@ -0,0 +1,13 @@
1
+ import type { DefineCacheOptions } from './define';
2
+ export type CacheReadResult<R> = {
3
+ hit: false;
4
+ } | {
5
+ hit: true;
6
+ value: R | undefined;
7
+ stale: boolean;
8
+ };
9
+ export declare function resolveNegativeTtl<T extends string, R>(options: DefineCacheOptions<T, R>): number | undefined;
10
+ export declare function resolveRedisTtl<T extends string, R>(expire: number, options: DefineCacheOptions<T, R>): number;
11
+ export declare function encodeCacheValue<T extends string, R>(data: R, expire: number, options: DefineCacheOptions<T, R>): string;
12
+ export declare function encodeNegative(): string;
13
+ export declare function decodeCacheValue<R, T extends string = string>(text: string, options?: DefineCacheOptions<T, R>): CacheReadResult<R>;
@@ -0,0 +1,79 @@
1
+ const PAYLOAD_FLAG = '__$hile_cache';
2
+ const PAYLOAD_PREFIX = '\x1fhile-cache:v1:';
3
+ function getPayloadFlag(value) {
4
+ if (typeof value !== 'object' || value === null)
5
+ return undefined;
6
+ return value[PAYLOAD_FLAG];
7
+ }
8
+ function isStoredValue(value) {
9
+ if (getPayloadFlag(value) !== 'value')
10
+ return false;
11
+ const record = value;
12
+ const keys = Object.keys(record);
13
+ return 'data' in record
14
+ && typeof record.freshUntil === 'number'
15
+ && keys.every(key => key === PAYLOAD_FLAG || key === 'data' || key === 'freshUntil');
16
+ }
17
+ function isStoredNegative(value) {
18
+ if (getPayloadFlag(value) !== 'negative')
19
+ return false;
20
+ return Object.keys(value).length === 1;
21
+ }
22
+ function encodePayload(value) {
23
+ return `${PAYLOAD_PREFIX}${JSON.stringify(value)}`;
24
+ }
25
+ function decodePrefixedPayload(text) {
26
+ if (!text.startsWith(PAYLOAD_PREFIX))
27
+ return undefined;
28
+ const stored = JSON.parse(text.slice(PAYLOAD_PREFIX.length));
29
+ if (stored.type === 'negative') {
30
+ return { hit: true, value: undefined, stale: false };
31
+ }
32
+ if (stored.type !== 'value') {
33
+ throw new Error('Invalid cache payload');
34
+ }
35
+ const freshUntil = typeof stored.freshUntil === 'number' ? stored.freshUntil : undefined;
36
+ return {
37
+ hit: true,
38
+ value: stored.data,
39
+ stale: freshUntil !== undefined && Date.now() > freshUntil,
40
+ };
41
+ }
42
+ export function resolveNegativeTtl(options) {
43
+ return options.negative?.ttl;
44
+ }
45
+ export function resolveRedisTtl(expire, options) {
46
+ if (expire <= 0)
47
+ return 0;
48
+ return expire + (options.stale?.ttl ?? 0);
49
+ }
50
+ export function encodeCacheValue(data, expire, options) {
51
+ if (!options.stale || expire <= 0)
52
+ return JSON.stringify(data);
53
+ return encodePayload({
54
+ type: 'value',
55
+ data,
56
+ freshUntil: Date.now() + expire * 1000,
57
+ });
58
+ }
59
+ export function encodeNegative() {
60
+ return encodePayload({ type: 'negative' });
61
+ }
62
+ export function decodeCacheValue(text, options = {}) {
63
+ const prefixed = decodePrefixedPayload(text);
64
+ if (prefixed)
65
+ return prefixed;
66
+ const parsed = JSON.parse(text);
67
+ if (options.negative && isStoredNegative(parsed)) {
68
+ return { hit: true, value: undefined, stale: false };
69
+ }
70
+ if (!options.stale || !isStoredValue(parsed)) {
71
+ return { hit: true, value: parsed, stale: false };
72
+ }
73
+ const freshUntil = typeof parsed.freshUntil === 'number' ? parsed.freshUntil : undefined;
74
+ return {
75
+ hit: true,
76
+ value: parsed.data,
77
+ stale: freshUntil !== undefined && Date.now() > freshUntil,
78
+ };
79
+ }
@@ -0,0 +1,3 @@
1
+ export declare const REPLACE_CACHE_TAGS = "\n-- REPLACE_CACHE_TAGS\nlocal cacheKey = ARGV[1]\nlocal tagPrefix = ARGV[2]\nlocal next = {}\n\nfor i = 3, #ARGV do\n next[ARGV[i]] = true\nend\n\nlocal previous = redis.call('SMEMBERS', KEYS[1])\nfor _, tag in ipairs(previous) do\n if not next[tag] then\n redis.call('SREM', tagPrefix .. tag, cacheKey)\n end\nend\n\nredis.call('DEL', KEYS[1])\nfor i = 3, #ARGV do\n redis.call('SADD', tagPrefix .. ARGV[i], cacheKey)\n redis.call('SADD', KEYS[1], ARGV[i])\nend\n\nreturn #ARGV - 2\n";
2
+ export declare const FORGET_CACHE_TAGS = "\n-- FORGET_CACHE_TAGS\nlocal cacheKey = ARGV[1]\nlocal tagPrefix = ARGV[2]\nlocal tags = redis.call('SMEMBERS', KEYS[1])\n\nfor _, tag in ipairs(tags) do\n redis.call('SREM', tagPrefix .. tag, cacheKey)\nend\nredis.call('DEL', KEYS[1])\n\nreturn #tags\n";
3
+ export declare const REMOVE_CACHE_TAG = "\n-- REMOVE_CACHE_TAG\nlocal tagPrefix = ARGV[1]\nlocal indexPrefix = ARGV[2]\nlocal cacheKeys = redis.call('SMEMBERS', KEYS[1])\nlocal removed = 0\n\nfor _, cacheKey in ipairs(cacheKeys) do\n local indexKey = indexPrefix .. cacheKey\n local tags = redis.call('SMEMBERS', indexKey)\n\n for _, tag in ipairs(tags) do\n redis.call('SREM', tagPrefix .. tag, cacheKey)\n end\n\n redis.call('DEL', indexKey)\n removed = removed + redis.call('DEL', cacheKey)\nend\n\nredis.call('DEL', KEYS[1])\n\nreturn removed\n";
@@ -0,0 +1,61 @@
1
+ export const REPLACE_CACHE_TAGS = `
2
+ -- REPLACE_CACHE_TAGS
3
+ local cacheKey = ARGV[1]
4
+ local tagPrefix = ARGV[2]
5
+ local next = {}
6
+
7
+ for i = 3, #ARGV do
8
+ next[ARGV[i]] = true
9
+ end
10
+
11
+ local previous = redis.call('SMEMBERS', KEYS[1])
12
+ for _, tag in ipairs(previous) do
13
+ if not next[tag] then
14
+ redis.call('SREM', tagPrefix .. tag, cacheKey)
15
+ end
16
+ end
17
+
18
+ redis.call('DEL', KEYS[1])
19
+ for i = 3, #ARGV do
20
+ redis.call('SADD', tagPrefix .. ARGV[i], cacheKey)
21
+ redis.call('SADD', KEYS[1], ARGV[i])
22
+ end
23
+
24
+ return #ARGV - 2
25
+ `;
26
+ export const FORGET_CACHE_TAGS = `
27
+ -- FORGET_CACHE_TAGS
28
+ local cacheKey = ARGV[1]
29
+ local tagPrefix = ARGV[2]
30
+ local tags = redis.call('SMEMBERS', KEYS[1])
31
+
32
+ for _, tag in ipairs(tags) do
33
+ redis.call('SREM', tagPrefix .. tag, cacheKey)
34
+ end
35
+ redis.call('DEL', KEYS[1])
36
+
37
+ return #tags
38
+ `;
39
+ export const REMOVE_CACHE_TAG = `
40
+ -- REMOVE_CACHE_TAG
41
+ local tagPrefix = ARGV[1]
42
+ local indexPrefix = ARGV[2]
43
+ local cacheKeys = redis.call('SMEMBERS', KEYS[1])
44
+ local removed = 0
45
+
46
+ for _, cacheKey in ipairs(cacheKeys) do
47
+ local indexKey = indexPrefix .. cacheKey
48
+ local tags = redis.call('SMEMBERS', indexKey)
49
+
50
+ for _, tag in ipairs(tags) do
51
+ redis.call('SREM', tagPrefix .. tag, cacheKey)
52
+ end
53
+
54
+ redis.call('DEL', indexKey)
55
+ removed = removed + redis.call('DEL', cacheKey)
56
+ end
57
+
58
+ redis.call('DEL', KEYS[1])
59
+
60
+ return removed
61
+ `;
package/dist/tags.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Redis } from 'ioredis';
2
+ import type { DefineCacheResult, ExtractParams } from './define';
3
+ export declare class CacheTagIndex {
4
+ private readonly prefix;
5
+ private readonly redis;
6
+ constructor(prefix: string, redis: Redis);
7
+ remember<T extends string, R>(target: DefineCacheResult<T, R>, params: ExtractParams<T>, data: R | undefined, key: string): Promise<void>;
8
+ forget<T extends string, R>(target: DefineCacheResult<T, R>, params: ExtractParams<T>, key: string): Promise<void>;
9
+ removeTag(tag: string): Promise<number>;
10
+ readKeyTags(key: string): Promise<string[]>;
11
+ rememberTags(key: string, tags: string[]): Promise<void>;
12
+ resolveTags<T extends string, R>(target: DefineCacheResult<T, R>, params: ExtractParams<T>, data: R | undefined): string[];
13
+ private makeTagKey;
14
+ private makeTagPrefix;
15
+ private makeKeyTagsKey;
16
+ private makeKeyTagsPrefix;
17
+ private replaceKeyTags;
18
+ private forgetKeyTags;
19
+ }
package/dist/tags.js ADDED
@@ -0,0 +1,58 @@
1
+ import { FORGET_CACHE_TAGS, REMOVE_CACHE_TAG, REPLACE_CACHE_TAGS } from './tag-scripts.js';
2
+ export class CacheTagIndex {
3
+ prefix;
4
+ redis;
5
+ constructor(prefix, redis) {
6
+ this.prefix = prefix;
7
+ this.redis = redis;
8
+ }
9
+ async remember(target, params, data, key) {
10
+ const tags = this.resolveTags(target, params, data);
11
+ await this.rememberTags(key, tags);
12
+ }
13
+ async forget(target, params, key) {
14
+ const forgotten = await this.forgetKeyTags(key);
15
+ if (forgotten > 0)
16
+ return;
17
+ const legacyTags = this.resolveTags(target, params, undefined);
18
+ if (legacyTags.length === 0)
19
+ return;
20
+ await Promise.all(legacyTags.map(tag => this.redis.srem(this.makeTagKey(tag), key)));
21
+ }
22
+ async removeTag(tag) {
23
+ const removed = await this.redis.eval(REMOVE_CACHE_TAG, 1, this.makeTagKey(tag), this.makeTagPrefix(), this.makeKeyTagsPrefix());
24
+ return Number(removed);
25
+ }
26
+ async readKeyTags(key) {
27
+ return this.redis.smembers(this.makeKeyTagsKey(key));
28
+ }
29
+ async rememberTags(key, tags) {
30
+ await this.replaceKeyTags(key, tags);
31
+ }
32
+ resolveTags(target, params, data) {
33
+ const tags = target.options.tags;
34
+ if (!tags)
35
+ return [];
36
+ const resolved = typeof tags === 'function' ? tags(params, data) : tags;
37
+ return [...new Set(resolved)];
38
+ }
39
+ makeTagKey(tag) {
40
+ return `${this.makeTagPrefix()}${tag}`;
41
+ }
42
+ makeTagPrefix() {
43
+ return `${this.prefix}tag:`;
44
+ }
45
+ makeKeyTagsKey(key) {
46
+ return `${this.makeKeyTagsPrefix()}${key}`;
47
+ }
48
+ makeKeyTagsPrefix() {
49
+ return `${this.prefix}tag-index:`;
50
+ }
51
+ async replaceKeyTags(key, nextTags) {
52
+ await this.redis.eval(REPLACE_CACHE_TAGS, 1, this.makeKeyTagsKey(key), key, this.makeTagPrefix(), ...nextTags);
53
+ }
54
+ async forgetKeyTags(key) {
55
+ const removed = await this.redis.eval(FORGET_CACHE_TAGS, 1, this.makeKeyTagsKey(key), key, this.makeTagPrefix());
56
+ return Number(removed);
57
+ }
58
+ }
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@hile/cache",
3
- "version": "2.1.1",
3
+ "version": "3.0.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
7
7
  "build": "tsc -b && fix-esm-import-path --preserve-import-type ./dist",
8
8
  "dev": "tsc -b --watch",
9
- "test": "vitest run"
9
+ "test": "vitest run && tsc -p tsconfig.typecheck.json"
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
- "README.md"
13
+ "README.md",
14
+ "AI.md"
14
15
  ],
15
16
  "license": "MIT",
16
17
  "publishConfig": {
@@ -21,7 +22,8 @@
21
22
  "vitest": "^4.0.18"
22
23
  },
23
24
  "dependencies": {
25
+ "@hile/redis-lock": "^3.0.2",
24
26
  "ioredis": "^5.11.0"
25
27
  },
26
- "gitHead": "7903ae989bd001d1ed1437cb90c9e828a1909061"
28
+ "gitHead": "0985b6f8abc1f4de0a36324063585fdc3ac1375b"
27
29
  }