@jintianxiayu/cache-decorator 0.1.2 → 1.0.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +270 -133
  3. package/dist/adapters/ioredis-cache-client.d.ts +9 -0
  4. package/dist/adapters/ioredis-cache-client.d.ts.map +1 -0
  5. package/dist/adapters/ioredis-cache-client.js +73 -0
  6. package/dist/adapters/ioredis-cache-client.js.map +1 -0
  7. package/dist/adapters/node-redis-cache-client.d.ts +10 -0
  8. package/dist/adapters/node-redis-cache-client.d.ts.map +1 -0
  9. package/dist/adapters/node-redis-cache-client.js +81 -0
  10. package/dist/adapters/node-redis-cache-client.js.map +1 -0
  11. package/dist/adapters/redis-key-prefix.d.ts +22 -0
  12. package/dist/adapters/redis-key-prefix.d.ts.map +1 -0
  13. package/dist/adapters/redis-key-prefix.js +41 -0
  14. package/dist/adapters/redis-key-prefix.js.map +1 -0
  15. package/dist/core/cache-logger.d.ts +28 -0
  16. package/dist/core/cache-logger.d.ts.map +1 -0
  17. package/dist/core/cache-logger.js +70 -0
  18. package/dist/core/cache-logger.js.map +1 -0
  19. package/dist/core/cache-provider-registry.d.ts.map +1 -1
  20. package/dist/core/cache-provider-registry.js.map +1 -1
  21. package/dist/core/cache-provider.d.ts.map +1 -1
  22. package/dist/core/key-builder.d.ts.map +1 -1
  23. package/dist/core/key-builder.js +3 -2
  24. package/dist/core/key-builder.js.map +1 -1
  25. package/dist/core/native-cache.d.ts.map +1 -1
  26. package/dist/core/native-cache.js +2 -1
  27. package/dist/core/native-cache.js.map +1 -1
  28. package/dist/core/pending-cache.d.ts.map +1 -1
  29. package/dist/core/pending-cache.js +6 -3
  30. package/dist/core/pending-cache.js.map +1 -1
  31. package/dist/core/redis-cache-client.d.ts +156 -0
  32. package/dist/core/redis-cache-client.d.ts.map +1 -0
  33. package/dist/core/redis-cache-client.js +3 -0
  34. package/dist/core/redis-cache-client.js.map +1 -0
  35. package/dist/core/redis-cache.d.ts +39 -7
  36. package/dist/core/redis-cache.d.ts.map +1 -1
  37. package/dist/core/redis-cache.js +63 -22
  38. package/dist/core/redis-cache.js.map +1 -1
  39. package/dist/decorators/cache-evict.d.ts +1 -1
  40. package/dist/decorators/cache-evict.d.ts.map +1 -1
  41. package/dist/decorators/cache-evict.js +68 -14
  42. package/dist/decorators/cache-evict.js.map +1 -1
  43. package/dist/decorators/cache.d.ts +1 -1
  44. package/dist/decorators/cache.d.ts.map +1 -1
  45. package/dist/decorators/cache.js +85 -23
  46. package/dist/decorators/cache.js.map +1 -1
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +6 -0
  50. package/dist/index.js.map +1 -1
  51. package/jest.config.js +11 -14
  52. package/package.json +26 -29
  53. package/src/adapters/ioredis-cache-client.ts +89 -0
  54. package/src/adapters/node-redis-cache-client.ts +95 -0
  55. package/src/adapters/redis-key-prefix.ts +38 -0
  56. package/src/core/cache-logger.ts +105 -0
  57. package/src/core/cache-provider-registry.ts +39 -39
  58. package/src/core/cache-provider.ts +28 -28
  59. package/src/core/key-builder.ts +33 -31
  60. package/src/core/native-cache.ts +55 -53
  61. package/src/core/pending-cache.ts +29 -26
  62. package/src/core/redis-cache-client.ts +160 -0
  63. package/src/core/redis-cache.ts +104 -62
  64. package/src/decorators/cache-evict.ts +129 -73
  65. package/src/decorators/cache.ts +203 -129
  66. package/src/index.ts +11 -9
  67. package/test/cache-evict-logging.test.ts +362 -0
  68. package/test/cache-evict.test.ts +165 -165
  69. package/test/cache-logger.integration.test.ts +129 -0
  70. package/test/cache-logger.test.ts +153 -0
  71. package/test/cache-logging.test.ts +544 -0
  72. package/test/cache-provider-registry.test.ts +24 -24
  73. package/test/cache.test.ts +255 -256
  74. package/test/fixtures/cache-logger-child.mjs +108 -0
  75. package/test/helpers/legacy-redis-cache.ts +22 -0
  76. package/test/helpers/package-consumer.ts +231 -0
  77. package/test/helpers/redis-fixture.ts +142 -0
  78. package/test/ioredis-cache-client.test.ts +143 -0
  79. package/test/key-builder.test.ts +28 -28
  80. package/test/native-cache.test.ts +77 -71
  81. package/test/node-redis-cache-client.test.ts +149 -0
  82. package/test/pending-cache.test.ts +69 -56
  83. package/test/redis-cache-client-lifecycle.test.ts +112 -0
  84. package/test/redis-cache-client-types.test.ts +184 -0
  85. package/test/redis-cache-client.integration.test.ts +327 -0
  86. package/test/redis-cache-decorator.test.ts +201 -0
  87. package/test/redis-cache-package.integration.test.ts +328 -0
  88. package/test/redis-cache-provider.test.ts +269 -0
  89. package/test/type-contract/contract.ts +64 -0
  90. package/test/type-contract/tsconfig.json +12 -0
  91. package/tsconfig.json +8 -8
@@ -0,0 +1,269 @@
1
+ import { RedisCacheClient } from '../src/core/redis-cache-client';
2
+ import { RedisCacheProvider } from '../src/core/redis-cache';
3
+
4
+ function createClient(): jest.Mocked<RedisCacheClient> {
5
+ const client = {
6
+ get: jest.fn<Promise<string | null>, Parameters<RedisCacheClient['get']>>(),
7
+ set: jest.fn<Promise<void>, Parameters<RedisCacheClient['set']>>(),
8
+ deleteMany: jest.fn<Promise<void>, Parameters<RedisCacheClient['deleteMany']>>(),
9
+ scan: jest.fn<ReturnType<RedisCacheClient['scan']>, Parameters<RedisCacheClient['scan']>>(),
10
+ flushDatabase: jest.fn<Promise<void>, Parameters<RedisCacheClient['flushDatabase']>>(),
11
+ } satisfies jest.Mocked<RedisCacheClient>;
12
+ client.get.mockResolvedValue(null);
13
+ client.set.mockResolvedValue(undefined);
14
+ client.deleteMany.mockResolvedValue(undefined);
15
+ client.scan.mockResolvedValue({ cursor: '0', keys: [] });
16
+ client.flushDatabase.mockResolvedValue(undefined);
17
+ return client;
18
+ }
19
+
20
+ function useStringStorage(client: jest.Mocked<RedisCacheClient>): Map<string, string> {
21
+ const values = new Map<string, string>();
22
+ client.set.mockImplementation(({ key, value }): Promise<void> => {
23
+ values.set(key, value);
24
+ return Promise.resolve();
25
+ });
26
+ client.get.mockImplementation((key): Promise<string | null> => Promise.resolve(values.get(key) ?? null));
27
+ return values;
28
+ }
29
+
30
+ it('redis-cache-client/V01 不存在的 key 返回 cache miss', async () => {
31
+ const client = createClient();
32
+ const provider = new RedisCacheProvider(client);
33
+
34
+ await expect(provider.get('missing')).resolves.toBeUndefined();
35
+
36
+ expect(client.get).toHaveBeenCalledWith('missing');
37
+ expect(client.set).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('redis-cache-client/V03 非 JSON 字符串和空字符串保持原值', async () => {
41
+ const client = createClient();
42
+ const values = useStringStorage(client);
43
+ const provider = new RedisCacheProvider(client);
44
+
45
+ await provider.set('plain', 'not-json');
46
+ await provider.set('empty', '');
47
+
48
+ expect(values).toEqual(
49
+ new Map([
50
+ ['plain', 'not-json'],
51
+ ['empty', ''],
52
+ ])
53
+ );
54
+ await expect(provider.get('plain')).resolves.toBe('not-json');
55
+ await expect(provider.get('empty')).resolves.toBe('');
56
+ });
57
+
58
+ it('redis-cache-client/V04 JSON 文本字符串沿用解析行为', async () => {
59
+ const client = createClient();
60
+ const values = useStringStorage(client);
61
+ const provider = new RedisCacheProvider(client);
62
+ const cases: ReadonlyArray<readonly [string, unknown]> = [
63
+ ['123', 123],
64
+ ['true', true],
65
+ ['null', null],
66
+ ['[1,2]', [1, 2]],
67
+ ['{"status":"open"}', { status: 'open' }],
68
+ ];
69
+
70
+ for (const [source, expected] of cases) {
71
+ await provider.set(source, source);
72
+ expect(values.get(source)).toBe(source);
73
+ await expect(provider.get(source)).resolves.toEqual(expected);
74
+ }
75
+ });
76
+
77
+ it('redis-cache-client/V06 缺省 TTL 与零 TTL 永不过期', async () => {
78
+ const client = createClient();
79
+ const provider = new RedisCacheProvider(client);
80
+
81
+ await provider.set('default-ttl', 'value');
82
+ await provider.set('zero-ttl', 'value', 0);
83
+
84
+ expect(client.set).toHaveBeenNthCalledWith(1, { key: 'default-ttl', value: 'value' });
85
+ expect(client.set).toHaveBeenNthCalledWith(2, { key: 'zero-ttl', value: 'value' });
86
+ });
87
+
88
+ it('redis-cache-client/V07 序列化失败不写入', async () => {
89
+ const client = createClient();
90
+ const provider = new RedisCacheProvider(client);
91
+ const cyclic: { self?: unknown } = {};
92
+ cyclic.self = cyclic;
93
+ const values: readonly unknown[] = [cyclic, undefined, (): void => undefined, Symbol('cache')];
94
+
95
+ for (const value of values) {
96
+ await expect(provider.set('key', value)).rejects.toThrow(TypeError);
97
+ }
98
+ expect(client.set).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('redis-cache-client/V08 key 边界不被 Provider 改写', async () => {
102
+ const client = createClient();
103
+ const provider = new RedisCacheProvider(client);
104
+ const keys = ['', '订单:缓存[*]?'];
105
+ client.get.mockResolvedValue('value');
106
+
107
+ for (const key of keys) {
108
+ await provider.set(key, 'value');
109
+ await provider.get(key);
110
+ await provider.delete(key);
111
+ }
112
+
113
+ expect(client.set.mock.calls.map(([request]) => request.key)).toEqual(keys);
114
+ expect(client.get.mock.calls.map(([key]) => key)).toEqual(keys);
115
+ expect(client.deleteMany.mock.calls.map(([deletedKeys]) => deletedKeys)).toEqual(keys.map((key) => [key]));
116
+ });
117
+
118
+ it('redis-cache-client/E01 读取异常不是 cache miss', async () => {
119
+ const client = createClient();
120
+ const provider = new RedisCacheProvider(client);
121
+ const error = new Error('GET unavailable');
122
+ client.get.mockRejectedValue(error);
123
+
124
+ await expect(provider.get('key')).rejects.toBe(error);
125
+ expect(client.set).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it('redis-cache-client/E02 写入异常传播', async () => {
129
+ const client = createClient();
130
+ const provider = new RedisCacheProvider(client);
131
+ const error = new Error('SET unavailable');
132
+ client.set.mockRejectedValue(error);
133
+
134
+ await expect(provider.set('key', { value: true })).rejects.toBe(error);
135
+ expect(client.set).toHaveBeenCalledWith({ key: 'key', value: '{"value":true}' });
136
+ });
137
+
138
+ it('redis-cache-client/E04 非法 TTL 在写入前被拒绝', async () => {
139
+ const client = createClient();
140
+ const provider = new RedisCacheProvider(client);
141
+ const invalidTtls = [-1, -0.5, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
142
+
143
+ for (const ttl of invalidTtls) {
144
+ await expect(provider.set('key', 'value', ttl)).rejects.toThrow(RangeError);
145
+ }
146
+ expect(client.set).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('redis-cache-client/R01 重复删除保持幂等', async () => {
150
+ const client = createClient();
151
+ const provider = new RedisCacheProvider(client);
152
+
153
+ await expect(provider.delete('key')).resolves.toBeUndefined();
154
+ await expect(provider.delete('key')).resolves.toBeUndefined();
155
+
156
+ expect(client.deleteMany).toHaveBeenCalledTimes(2);
157
+ expect(client.deleteMany).toHaveBeenNthCalledWith(1, ['key']);
158
+ expect(client.deleteMany).toHaveBeenNthCalledWith(2, ['key']);
159
+ });
160
+
161
+ it('redis-cache-client/R02 重复写入由后完成的命令覆盖', async () => {
162
+ const client = createClient();
163
+ useStringStorage(client);
164
+ const provider = new RedisCacheProvider(client);
165
+
166
+ await provider.set('key', { version: 1 }, 60);
167
+ await provider.set('key', { version: 2 }, 120);
168
+
169
+ await expect(provider.get<{ version: number }>('key')).resolves.toEqual({ version: 2 });
170
+ expect(client.set).toHaveBeenNthCalledWith(1, {
171
+ key: 'key',
172
+ value: '{"version":1}',
173
+ ttlSeconds: 60,
174
+ });
175
+ expect(client.set).toHaveBeenNthCalledWith(2, {
176
+ key: 'key',
177
+ value: '{"version":2}',
178
+ ttlSeconds: 120,
179
+ });
180
+ });
181
+
182
+ it('redis-cache-client/E03 删除或清库异常传播', async () => {
183
+ const deleteClient = createClient();
184
+ const deleteProvider = new RedisCacheProvider(deleteClient);
185
+ const deleteError = new Error('DEL unavailable');
186
+ deleteClient.deleteMany.mockRejectedValue(deleteError);
187
+ const clearClient = createClient();
188
+ const clearProvider = new RedisCacheProvider(clearClient);
189
+ const clearError = new Error('FLUSHDB unavailable');
190
+ clearClient.flushDatabase.mockRejectedValue(clearError);
191
+
192
+ await expect(deleteProvider.delete('key')).rejects.toBe(deleteError);
193
+ await expect(clearProvider.clear()).rejects.toBe(clearError);
194
+ });
195
+
196
+ it('cache-evict-allentries-prefix/pattern 末尾 * 作为前缀匹配', async () => {
197
+ const client = createClient();
198
+ const provider = new RedisCacheProvider(client);
199
+ const existingKeys = new Set(['name:1', 'name:2', 'order:1']);
200
+ client.scan.mockResolvedValue({ cursor: '0', keys: ['name:1', 'name:2'] });
201
+ client.deleteMany.mockImplementation((keys): Promise<void> => {
202
+ for (const key of keys) {
203
+ existingKeys.delete(key);
204
+ }
205
+ return Promise.resolve();
206
+ });
207
+
208
+ await provider.deleteByPattern('name*');
209
+
210
+ expect(client.scan).toHaveBeenCalledWith({ cursor: '0', pattern: 'name*', count: 100 });
211
+ expect(client.deleteMany).toHaveBeenCalledWith(['name:1', 'name:2']);
212
+ expect(existingKeys).toEqual(new Set(['order:1']));
213
+ });
214
+
215
+ it('cache-evict-allentries-prefix/RedisCacheProvider 使用 SCAN 迭代删除', async () => {
216
+ const client = createClient();
217
+ const provider = new RedisCacheProvider(client);
218
+ client.scan
219
+ .mockResolvedValueOnce({ cursor: '7', keys: ['name:1'] })
220
+ .mockResolvedValueOnce({ cursor: '0', keys: ['name:2'] });
221
+
222
+ await provider.deleteByPattern('name*');
223
+
224
+ expect(client.scan.mock.calls).toEqual([
225
+ [{ cursor: '0', pattern: 'name*', count: 100 }],
226
+ [{ cursor: '7', pattern: 'name*', count: 100 }],
227
+ ]);
228
+ expect(client.deleteMany.mock.calls).toEqual([[['name:1']], [['name:2']]]);
229
+ });
230
+
231
+ it('cache-evict-allentries-prefix/F04 多页、空页与重复 key 均能完成扫描', async () => {
232
+ const client = createClient();
233
+ const provider = new RedisCacheProvider(client);
234
+ client.scan
235
+ .mockResolvedValueOnce({ cursor: '4', keys: ['name:1'] })
236
+ .mockResolvedValueOnce({ cursor: '8', keys: [] })
237
+ .mockResolvedValueOnce({ cursor: '0', keys: ['name:1', 'name:2'] });
238
+
239
+ await provider.deleteByPattern('name*');
240
+
241
+ expect(client.scan.mock.calls).toEqual([
242
+ [{ cursor: '0', pattern: 'name*', count: 100 }],
243
+ [{ cursor: '4', pattern: 'name*', count: 100 }],
244
+ [{ cursor: '8', pattern: 'name*', count: 100 }],
245
+ ]);
246
+ expect(client.deleteMany.mock.calls).toEqual([[['name:1']], [['name:1', 'name:2']]]);
247
+ });
248
+
249
+ it('cache-evict-allentries-prefix/F09 扫描或批量删除失败时停止并传播', async () => {
250
+ const scanClient = createClient();
251
+ const scanProvider = new RedisCacheProvider(scanClient);
252
+ const scanError = new Error('SCAN unavailable');
253
+ scanClient.scan.mockRejectedValue(scanError);
254
+
255
+ await expect(scanProvider.deleteByPattern('name*')).rejects.toBe(scanError);
256
+ expect(scanClient.deleteMany).not.toHaveBeenCalled();
257
+
258
+ const deleteClient = createClient();
259
+ const deleteProvider = new RedisCacheProvider(deleteClient);
260
+ const deleteError = new Error('second page DEL unavailable');
261
+ deleteClient.scan
262
+ .mockResolvedValueOnce({ cursor: '5', keys: ['name:1'] })
263
+ .mockResolvedValueOnce({ cursor: '9', keys: ['name:2'] });
264
+ deleteClient.deleteMany.mockResolvedValueOnce(undefined).mockRejectedValueOnce(deleteError);
265
+
266
+ await expect(deleteProvider.deleteByPattern('name*')).rejects.toBe(deleteError);
267
+ expect(deleteClient.scan).toHaveBeenCalledTimes(2);
268
+ expect(deleteClient.deleteMany.mock.calls).toEqual([[['name:1']], [['name:2']]]);
269
+ });
@@ -0,0 +1,64 @@
1
+ import {
2
+ Cache,
3
+ CacheEvict,
4
+ type CacheEvictOptions,
5
+ type CacheKeyResolver,
6
+ type CacheOptions,
7
+ type CacheProvider,
8
+ } from '@jintianxiayu/cache-decorator';
9
+
10
+ type Equal<Left, Right> =
11
+ (<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2 ? true : false;
12
+ type Assert<Condition extends true> = Condition;
13
+ type LegacyMethodDecorator = (_target: object, _propertyKey: string, descriptor: PropertyDescriptor) => void;
14
+
15
+ export type CacheOptionKeysMatch = Assert<Equal<keyof CacheOptions, 'ttl' | 'providerName' | 'key'>>;
16
+ export type CacheEvictOptionKeysMatch = Assert<Equal<keyof CacheEvictOptions, 'key' | 'allEntries' | 'providerName'>>;
17
+ export type CacheKeyResolverMatch = Assert<Equal<CacheKeyResolver, null | string | ((...args: unknown[]) => string)>>;
18
+
19
+ const provider: CacheProvider = {
20
+ get: <Value>(_key: string): Value | undefined => undefined,
21
+ set: <Value>(_key: string, _value: Value, _ttl?: number): void => {
22
+ return;
23
+ },
24
+ delete: (_key: string): void => {
25
+ return;
26
+ },
27
+ clear: (): void => {
28
+ return;
29
+ },
30
+ deleteByPattern: (_pattern: string): void => {
31
+ return;
32
+ },
33
+ };
34
+
35
+ const cacheDecorator: LegacyMethodDecorator = Cache('contract-cache', {
36
+ ttl: 60,
37
+ providerName: 'memory',
38
+ key: (...args: unknown[]) => String(args[0]),
39
+ });
40
+ const evictDecorator: LegacyMethodDecorator = CacheEvict('contract-cache', {
41
+ providerName: 'memory',
42
+ key: null,
43
+ allEntries: false,
44
+ });
45
+
46
+ const invalidLoggingOption: CacheOptions = {
47
+ // @ts-expect-error CacheOptions 不提供第二套日志开关。
48
+ logging: true,
49
+ };
50
+ const invalidDebugOption: CacheOptions = {
51
+ // @ts-expect-error CacheOptions 不提供 debug 开关。
52
+ debug: true,
53
+ };
54
+ const invalidLoggerOption: CacheEvictOptions = {
55
+ // @ts-expect-error CacheEvictOptions 不接受 Logger 实例或回调。
56
+ logger: undefined,
57
+ };
58
+
59
+ void provider;
60
+ void cacheDecorator;
61
+ void evictDecorator;
62
+ void invalidLoggingOption;
63
+ void invalidDebugOption;
64
+ void invalidLoggerOption;
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "skipLibCheck": false,
6
+ "paths": {
7
+ "@jintianxiayu/cache-decorator": ["../../dist/index.d.ts"]
8
+ },
9
+ "types": ["node"]
10
+ },
11
+ "include": ["contract.ts"]
12
+ }
package/tsconfig.json CHANGED
@@ -1,8 +1,8 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src/**/*"]
8
- }
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }