@hile/cache 2.0.7 → 3.0.1

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  基于 Redis 的类型安全读穿透缓存层。依赖 `ioredis`,构造时注入已连接的 `Redis` 实例(可与 `@hile/ioredis` 配合使用)。
4
4
 
5
+ 从 3.0.0 开始,新增或重构的 Hile 架构包统一进入 3.x 版本线,2.x 时代结束。
6
+
5
7
  ## 安装
6
8
 
7
9
  ```bash
@@ -23,6 +25,11 @@ const userCache = defineCache('user:{id:string}:info', async (params) => {
23
25
  // params 的类型自动推导为 { id: string }
24
26
  const data = await fetchUserFromDB(params.id);
25
27
  return new Cache(data).setExpire(300); // 5 分钟 TTL
28
+ }, {
29
+ singleflight: true,
30
+ stale: { ttl: 60 },
31
+ negative: { ttl: 30 },
32
+ tags: ({ id }) => ['users', `user:${id}`],
26
33
  });
27
34
  ```
28
35
 
@@ -49,6 +56,9 @@ const exists = await has({ id: 'u-001' });
49
56
 
50
57
  // 删除
51
58
  await remove({ id: 'u-001' });
59
+
60
+ // 按 tag 批量失效
61
+ await cache.removeTag('users');
52
62
  ```
53
63
 
54
64
  ---
@@ -86,6 +96,31 @@ read(params)
86
96
  └─ false → write(params) → handler 回源 → SET/SETEX → 返回
87
97
  ```
88
98
 
99
+ ### Singleflight
100
+
101
+ `singleflight` 使用 `@hile/redis-lock` 合并同一个 key 的并发 miss,避免热点 key 同时打到数据库。
102
+
103
+ ```typescript
104
+ const userCache = defineCache('user:{id:string}', fetchUser, {
105
+ singleflight: {
106
+ ttl: 10_000,
107
+ wait: 10_000,
108
+ },
109
+ });
110
+ ```
111
+
112
+ ### Stale Cache
113
+
114
+ `stale: { ttl }` 会在数据过了 `Cache#setExpire()` 的 fresh 时间后继续保留一段 stale 时间。读取 stale 数据时立即返回旧值,并在后台刷新。
115
+
116
+ ### Negative Cache
117
+
118
+ 默认 `new Cache(undefined)` 不写 Redis。配置 `negative: { ttl }` 后会写入一个短 TTL 哨兵,避免不存在的数据反复回源。
119
+
120
+ ### Tags
121
+
122
+ `tags` 会把缓存 key 记录到 Redis set,之后可用 `cache.removeTag(tag)` 批量删除。
123
+
89
124
  ---
90
125
 
91
126
  ## API 参考
@@ -95,15 +130,20 @@ read(params)
95
130
  ```typescript
96
131
  function defineCache<T extends string = string, R = any>(
97
132
  key: T,
98
- fn: (params: ExtractParams<T>) => Promise<Cache<R>>
133
+ fn: (params: ExtractParams<T>) => Promise<Cache<R>>,
134
+ options?: boolean | DefineCacheOptions<T, R>
99
135
  ): DefineCacheResult<T, R>;
100
136
  ```
101
137
 
138
+ 第三个参数仍兼容旧的 `boolean` fieldable 写法;新能力使用 options 对象。
139
+
140
+ > `fieldable` 使用 Redis Hash 存储,不兼容 `stale` 和 `negative` 这类依赖字符串 payload 的策略;组合使用会在 `defineCache()` 阶段直接抛出配置错误。
141
+
102
142
  ### RedisCache
103
143
 
104
144
  ```typescript
105
145
  class RedisCache {
106
- constructor(prefix: string, redis: Redis);
146
+ constructor(prefix: string, redis: Redis, options?: { locks?: RedisLock });
107
147
 
108
148
  loadCache<T extends string, R>(
109
149
  target: DefineCacheResult<T, R>
@@ -113,6 +153,8 @@ class RedisCache {
113
153
  remove(params: ExtractParams<T>): Promise<number>;
114
154
  has(params: ExtractParams<T>): Promise<boolean>;
115
155
  }>;
156
+
157
+ removeTag(tag: string): Promise<number>;
116
158
  }
117
159
  ```
118
160
 
@@ -122,6 +164,7 @@ class RedisCache {
122
164
  | `write` | `R \| undefined` | 强制执行回源并写入 Redis(data=undefined 则删除 key) |
123
165
  | `remove` | `number` | 删除缓存,返回 0/1 |
124
166
  | `has` | `boolean` | 检查 key 是否存在 |
167
+ | `removeTag` | `number` | 删除某个 tag 下记录的所有缓存 key |
125
168
 
126
169
  ### Redis 中的 key 结构
127
170
 
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,12 +1,12 @@
1
1
  {
2
2
  "name": "@hile/cache",
3
- "version": "2.0.7",
3
+ "version": "3.0.1",
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",
@@ -21,7 +21,8 @@
21
21
  "vitest": "^4.0.18"
22
22
  },
23
23
  "dependencies": {
24
+ "@hile/redis-lock": "^3.0.1",
24
25
  "ioredis": "^5.11.0"
25
26
  },
26
- "gitHead": "a7615000fcb87e6bc0e573af760c814ec935bab2"
27
+ "gitHead": "88f52fb95743f86761778776aff23631fcf9d821"
27
28
  }