@jintianxiayu/cache-decorator 0.1.3 → 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 (86) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +270 -131
  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 -11
  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/key-builder.ts +33 -31
  58. package/src/core/native-cache.ts +55 -53
  59. package/src/core/pending-cache.ts +29 -26
  60. package/src/core/redis-cache-client.ts +160 -0
  61. package/src/core/redis-cache.ts +104 -56
  62. package/src/decorators/cache-evict.ts +129 -69
  63. package/src/decorators/cache.ts +203 -125
  64. package/src/index.ts +11 -8
  65. package/test/cache-evict-logging.test.ts +362 -0
  66. package/test/cache-logger.integration.test.ts +129 -0
  67. package/test/cache-logger.test.ts +153 -0
  68. package/test/cache-logging.test.ts +544 -0
  69. package/test/cache.test.ts +255 -256
  70. package/test/fixtures/cache-logger-child.mjs +108 -0
  71. package/test/helpers/legacy-redis-cache.ts +22 -0
  72. package/test/helpers/package-consumer.ts +231 -0
  73. package/test/helpers/redis-fixture.ts +142 -0
  74. package/test/ioredis-cache-client.test.ts +143 -0
  75. package/test/native-cache.test.ts +77 -71
  76. package/test/node-redis-cache-client.test.ts +149 -0
  77. package/test/pending-cache.test.ts +69 -58
  78. package/test/redis-cache-client-lifecycle.test.ts +112 -0
  79. package/test/redis-cache-client-types.test.ts +184 -0
  80. package/test/redis-cache-client.integration.test.ts +327 -0
  81. package/test/redis-cache-decorator.test.ts +201 -0
  82. package/test/redis-cache-package.integration.test.ts +328 -0
  83. package/test/redis-cache-provider.test.ts +269 -0
  84. package/test/type-contract/contract.ts +64 -0
  85. package/test/type-contract/tsconfig.json +12 -0
  86. package/tsconfig.json +8 -8
@@ -0,0 +1,327 @@
1
+ import { once } from 'node:events';
2
+ import Redis from 'ioredis';
3
+ import { createIoredisCacheClient } from '../src/adapters/ioredis-cache-client';
4
+ import { createNodeRedisCacheClient } from '../src/adapters/node-redis-cache-client';
5
+ import { RedisCacheProvider } from '../src/core/redis-cache';
6
+ import { writeLegacyRedisCache } from './helpers/legacy-redis-cache';
7
+ import { RedisFixture, createIsolatedDatabaseRedisFixture, createRedisFixture } from './helpers/redis-fixture';
8
+
9
+ const redisUrl = process.env.CACHE_DECORATOR_TEST_REDIS_URL;
10
+ const redisTests = redisUrl ? describe : describe.skip;
11
+
12
+ interface TestKeyNamespace {
13
+ readonly keyPrefix: string;
14
+ physicalKey(logicalKey: string): string;
15
+ }
16
+
17
+ function createKeyNamespace(fixture: RedisFixture): TestKeyNamespace {
18
+ const keyPrefix = `${fixture.key()}:`;
19
+ return {
20
+ keyPrefix,
21
+ physicalKey(logicalKey: string): string {
22
+ return fixture.track(`${keyPrefix}${logicalKey}`);
23
+ },
24
+ };
25
+ }
26
+
27
+ redisTests('真实 Redis 夹具(需 CACHE_DECORATOR_TEST_REDIS_URL)', () => {
28
+ let fixture: RedisFixture | undefined;
29
+
30
+ beforeEach(async () => {
31
+ if (!redisUrl) {
32
+ throw new Error('CACHE_DECORATOR_TEST_REDIS_URL is required');
33
+ }
34
+ fixture = await createRedisFixture(redisUrl);
35
+ });
36
+
37
+ afterEach(async () => fixture?.close());
38
+
39
+ it('双客户端连接及夹具清理只影响自有唯一 key', async () => {
40
+ if (!redisUrl || !fixture) {
41
+ throw new Error('CACHE_DECORATOR_TEST_REDIS_URL is required');
42
+ }
43
+ const observer = await createRedisFixture(redisUrl);
44
+ const ownKey = fixture.key();
45
+ const otherKey = observer.key();
46
+ try {
47
+ await expect(fixture.io.ping()).resolves.toBe('PONG');
48
+ await expect(fixture.node.ping()).resolves.toBe('PONG');
49
+ await fixture.io.set(ownKey, 'owned');
50
+ await observer.node.set(otherKey, 'untouched');
51
+ await fixture.close();
52
+ await expect(observer.node.get(ownKey)).resolves.toBeNull();
53
+ await expect(observer.node.get(otherKey)).resolves.toBe('untouched');
54
+ expect(fixture.errors).toEqual([]);
55
+ } finally {
56
+ await observer.close();
57
+ }
58
+ });
59
+ });
60
+
61
+ redisTests('Redis 客户端数据与互操作协议', () => {
62
+ let fixture: RedisFixture | undefined;
63
+ let ioProvider: RedisCacheProvider;
64
+ let nodeProvider: RedisCacheProvider;
65
+
66
+ beforeEach(async () => {
67
+ if (!redisUrl) {
68
+ throw new Error('CACHE_DECORATOR_TEST_REDIS_URL is required');
69
+ }
70
+ fixture = await createRedisFixture(redisUrl);
71
+ ioProvider = new RedisCacheProvider(createIoredisCacheClient(fixture.io));
72
+ nodeProvider = new RedisCacheProvider(createNodeRedisCacheClient(fixture.node));
73
+ });
74
+
75
+ afterEach(async () => fixture?.close());
76
+
77
+ it('redis-cache-client/V02 JSON 值跨客户端读取', async () => {
78
+ if (!fixture) {
79
+ throw new Error('Redis fixture is required');
80
+ }
81
+ const values: readonly unknown[] = [{ status: 'open' }, [1, 'two'], true, 42, null];
82
+ for (const value of values) {
83
+ const ioKey = fixture.key();
84
+ const nodeKey = fixture.key();
85
+ await ioProvider.set(ioKey, value);
86
+ await expect(nodeProvider.get(ioKey)).resolves.toEqual(value);
87
+ await nodeProvider.set(nodeKey, value);
88
+ await expect(ioProvider.get(nodeKey)).resolves.toEqual(value);
89
+ }
90
+ });
91
+
92
+ it('redis-cache-client/V05 正整数 TTL 按秒传递', async () => {
93
+ if (!fixture) {
94
+ throw new Error('Redis fixture is required');
95
+ }
96
+ const ioKey = fixture.key();
97
+ const nodeKey = fixture.key();
98
+ await ioProvider.set(ioKey, 'io', 30);
99
+ await nodeProvider.set(nodeKey, 'node', 30);
100
+
101
+ const [ioTtl, nodeTtl] = await Promise.all([fixture.node.ttl(ioKey), fixture.io.ttl(nodeKey)]);
102
+ expect(ioTtl).toBeGreaterThan(20);
103
+ expect(ioTtl).toBeLessThanOrEqual(30);
104
+ expect(nodeTtl).toBeGreaterThan(20);
105
+ expect(nodeTtl).toBeLessThanOrEqual(30);
106
+ });
107
+
108
+ it('redis-cache-client/V06 缺省 TTL 与零 TTL 永不过期', async () => {
109
+ if (!fixture) {
110
+ throw new Error('Redis fixture is required');
111
+ }
112
+ const ioKey = fixture.key();
113
+ const nodeKey = fixture.key();
114
+ await ioProvider.set(ioKey, 'io');
115
+ await nodeProvider.set(nodeKey, 'node', 0);
116
+
117
+ await expect(fixture.node.ttl(ioKey)).resolves.toBe(-1);
118
+ await expect(fixture.io.ttl(nodeKey)).resolves.toBe(-1);
119
+ });
120
+
121
+ it('redis-cache-client/R03 并发写入不提供额外竞争控制', async () => {
122
+ if (!fixture) {
123
+ throw new Error('Redis fixture is required');
124
+ }
125
+ const key = fixture.key();
126
+ const ioSet = jest.spyOn(fixture.io, 'set');
127
+ const nodeSet = jest.spyOn(fixture.node, 'set');
128
+
129
+ await Promise.all([
130
+ ioProvider.set(key, { writer: 'ioredis' }),
131
+ nodeProvider.set(key, { writer: 'node-redis' }),
132
+ ]);
133
+
134
+ expect([{ writer: 'ioredis' }, { writer: 'node-redis' }]).toContainEqual(await ioProvider.get(key));
135
+ expect(ioSet).toHaveBeenCalledTimes(1);
136
+ expect(nodeSet).toHaveBeenCalledTimes(1);
137
+ ioSet.mockRestore();
138
+ nodeSet.mockRestore();
139
+ });
140
+
141
+ it('redis-cache-client/E05 真实连接不可用时不创建替代连接', async () => {
142
+ if (!redisUrl) {
143
+ throw new Error('CACHE_DECORATOR_TEST_REDIS_URL is required');
144
+ }
145
+ for (const kind of ['ioredis', 'node-redis'] as const) {
146
+ const unavailable = await createRedisFixture(redisUrl);
147
+ const provider = new RedisCacheProvider(
148
+ kind === 'ioredis'
149
+ ? createIoredisCacheClient(unavailable.io)
150
+ : createNodeRedisCacheClient(unavailable.node)
151
+ );
152
+ const ioConnect = jest.spyOn(unavailable.io, 'connect');
153
+ const nodeConnect = jest.spyOn(unavailable.node, 'connect');
154
+ try {
155
+ if (kind === 'ioredis') {
156
+ const ended = once(unavailable.io, 'end');
157
+ unavailable.io.disconnect();
158
+ await ended;
159
+ } else {
160
+ unavailable.node.destroy();
161
+ }
162
+ const started = Date.now();
163
+ await expect(provider.get(unavailable.key())).rejects.toBeInstanceOf(Error);
164
+ expect(Date.now() - started).toBeLessThan(1500);
165
+ expect(ioConnect).not.toHaveBeenCalled();
166
+ expect(nodeConnect).not.toHaveBeenCalled();
167
+ } finally {
168
+ ioConnect.mockRestore();
169
+ nodeConnect.mockRestore();
170
+ await unavailable.close();
171
+ }
172
+ }
173
+ });
174
+
175
+ it('redis-cache-client/I01 ioredis 写入后由 node-redis 读取', async () => {
176
+ if (!fixture) {
177
+ throw new Error('Redis fixture is required');
178
+ }
179
+ const key = fixture.key();
180
+ await ioProvider.set(key, { source: 'ioredis' }, 30);
181
+
182
+ await expect(nodeProvider.get(key)).resolves.toEqual({ source: 'ioredis' });
183
+ expect(await fixture.node.ttl(key)).toBeGreaterThan(20);
184
+ });
185
+
186
+ it('redis-cache-client/I02 node-redis 写入后由 ioredis 读取', async () => {
187
+ if (!fixture) {
188
+ throw new Error('Redis fixture is required');
189
+ }
190
+ const key = fixture.key();
191
+ await nodeProvider.set(key, { source: 'node-redis' }, 30);
192
+
193
+ await expect(ioProvider.get(key)).resolves.toEqual({ source: 'node-redis' });
194
+ expect(await fixture.io.ttl(key)).toBeGreaterThan(20);
195
+ });
196
+
197
+ it('cache-operation-logging/A03 redis-cache-client/I03 新版本读取旧版本缓存', async () => {
198
+ if (!fixture) {
199
+ throw new Error('Redis fixture is required');
200
+ }
201
+ const stringKey = fixture.key();
202
+ const objectKey = fixture.key();
203
+ await writeLegacyRedisCache(fixture.io, stringKey, 'legacy-string');
204
+ await writeLegacyRedisCache(fixture.io, objectKey, { legacy: true }, 30);
205
+
206
+ await expect(nodeProvider.get(stringKey)).resolves.toBe('legacy-string');
207
+ await expect(ioProvider.get(objectKey)).resolves.toEqual({ legacy: true });
208
+ });
209
+
210
+ it('cache-evict-allentries-prefix/RedisCacheProvider 使用 SCAN 迭代删除', async () => {
211
+ if (!fixture) {
212
+ throw new Error('Redis fixture is required');
213
+ }
214
+ const namespace = createKeyNamespace(fixture);
215
+ const provider = new RedisCacheProvider(
216
+ createNodeRedisCacheClient(fixture.node, { keyPrefix: namespace.keyPrefix })
217
+ );
218
+ const matchingKeys = Array.from({ length: 150 }, (_, index) => `name:${index}`);
219
+ const physicalMatchingKeys = matchingKeys.map((key) => namespace.physicalKey(key));
220
+ const otherKey = namespace.physicalKey('order:1');
221
+ await Promise.all([
222
+ ...physicalMatchingKeys.map((key) => fixture.node.set(key, 'value')),
223
+ fixture.node.set(otherKey, 'other'),
224
+ ]);
225
+ const scan = jest.spyOn(fixture.node, 'scan');
226
+
227
+ await provider.deleteByPattern('name*');
228
+
229
+ expect(scan).toHaveBeenCalled();
230
+ for (const call of scan.mock.calls) {
231
+ expect(call[1]).toEqual({ MATCH: `${namespace.keyPrefix}name*`, COUNT: 100 });
232
+ }
233
+ expect((await fixture.node.mGet(physicalMatchingKeys)).every((value) => value === null)).toBe(true);
234
+ await expect(fixture.node.get(otherKey)).resolves.toBe('other');
235
+ scan.mockRestore();
236
+ });
237
+
238
+ it('cache-evict-allentries-prefix/F05 ioredis keyPrefix 下删除逻辑 pattern', async () => {
239
+ if (!fixture) {
240
+ throw new Error('Redis fixture is required');
241
+ }
242
+ const namespace = createKeyNamespace(fixture);
243
+ const first = namespace.physicalKey('name:1');
244
+ const second = namespace.physicalKey('name:2');
245
+ const other = namespace.physicalKey('order:1');
246
+ const prefixed = new Redis({ ...fixture.io.options, keyPrefix: namespace.keyPrefix, lazyConnect: true });
247
+ prefixed.on('error', (error: Error) => fixture.errors.push(error));
248
+ try {
249
+ await prefixed.connect();
250
+ await fixture.node.mSet({ [first]: 'one', [second]: 'two', [other]: 'other' });
251
+ const provider = new RedisCacheProvider(createIoredisCacheClient(prefixed));
252
+
253
+ await provider.deleteByPattern('name*');
254
+
255
+ await expect(fixture.node.mGet([first, second])).resolves.toEqual([null, null]);
256
+ await expect(fixture.node.get(other)).resolves.toBe('other');
257
+ } finally {
258
+ if (prefixed.status !== 'end') {
259
+ const ended = once(prefixed, 'end');
260
+ prefixed.disconnect();
261
+ await ended;
262
+ }
263
+ }
264
+ });
265
+
266
+ it('cache-evict-allentries-prefix/F07 node-redis 缺省前缀不改写 key', async () => {
267
+ if (!fixture) {
268
+ throw new Error('Redis fixture is required');
269
+ }
270
+ const namespace = createKeyNamespace(fixture);
271
+ const first = namespace.physicalKey('name:1');
272
+ const second = namespace.physicalKey('name:2');
273
+ const other = namespace.physicalKey('order:1');
274
+ await fixture.node.mSet({ [first]: 'one', [second]: 'two', [other]: 'other' });
275
+
276
+ await nodeProvider.deleteByPattern(`${namespace.keyPrefix}name*`);
277
+
278
+ await expect(fixture.node.mGet([first, second])).resolves.toEqual([null, null]);
279
+ await expect(fixture.node.get(other)).resolves.toBe('other');
280
+ });
281
+
282
+ it('cache-evict-allentries-prefix/F08 node-redis 显式前缀访问同一命名空间', async () => {
283
+ if (!fixture) {
284
+ throw new Error('Redis fixture is required');
285
+ }
286
+ const namespace = createKeyNamespace(fixture);
287
+ const physicalKey = namespace.physicalKey('name:1');
288
+ const provider = new RedisCacheProvider(
289
+ createNodeRedisCacheClient(fixture.node, { keyPrefix: namespace.keyPrefix })
290
+ );
291
+
292
+ await provider.set('name:1', { prefixed: true });
293
+ await expect(fixture.io.get(physicalKey)).resolves.toBe('{"prefixed":true}');
294
+ await expect(provider.get('name:1')).resolves.toEqual({ prefixed: true });
295
+ await provider.deleteByPattern('name*');
296
+ await expect(fixture.io.get(physicalKey)).resolves.toBeNull();
297
+ });
298
+ });
299
+
300
+ redisTests('Redis 数据库级清理(要求 URL 显式选择非零数据库)', () => {
301
+ let fixture: RedisFixture | undefined;
302
+
303
+ beforeEach(async () => {
304
+ if (!redisUrl) {
305
+ throw new Error('CACHE_DECORATOR_TEST_REDIS_URL is required');
306
+ }
307
+ fixture = await createIsolatedDatabaseRedisFixture(redisUrl);
308
+ });
309
+
310
+ afterEach(async () => fixture?.close());
311
+
312
+ it('redis-cache-client/R04 重复清库保持数据库级语义', async () => {
313
+ if (!fixture) {
314
+ throw new Error('Redis fixture is required');
315
+ }
316
+ const cacheKey = fixture.key();
317
+ const businessKey = fixture.key();
318
+ const provider = new RedisCacheProvider(createNodeRedisCacheClient(fixture.node, { keyPrefix: 'ignored:' }));
319
+ await fixture.node.mSet({ [cacheKey]: 'cache', [businessKey]: 'business' });
320
+
321
+ await expect(provider.clear()).resolves.toBeUndefined();
322
+ await expect(provider.clear()).resolves.toBeUndefined();
323
+
324
+ await expect(fixture.node.mGet([cacheKey, businessKey])).resolves.toEqual([null, null]);
325
+ await expect(fixture.node.dbSize()).resolves.toBe(0);
326
+ });
327
+ });
@@ -0,0 +1,201 @@
1
+ import { createIoredisCacheClient } from '../src/adapters/ioredis-cache-client';
2
+ import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
3
+ import { IoredisCacheClientSource } from '../src/core/redis-cache-client';
4
+ import { KeyBuilder } from '../src/core/key-builder';
5
+ import { MemoryCacheProvider } from '../src/core/native-cache';
6
+ import { RedisCacheProvider } from '../src/core/redis-cache';
7
+ import { CacheEvict } from '../src/decorators/cache-evict';
8
+ import { Cache } from '../src/decorators/cache';
9
+
10
+ class FakeIoredisCacheClientSource implements IoredisCacheClientSource {
11
+ readonly options = Object.freeze({ keyPrefix: '' });
12
+ readonly writes: Array<{
13
+ readonly key: string;
14
+ readonly value: string;
15
+ readonly ttlSeconds: number | undefined;
16
+ }> = [];
17
+ private readonly values = new Map<string, string>();
18
+
19
+ constructor(private readonly events: string[]) {}
20
+
21
+ get(key: string): Promise<unknown> {
22
+ this.events.push(`get:${key}`);
23
+ return Promise.resolve(this.values.get(key) ?? null);
24
+ }
25
+
26
+ set(key: string, value: string): Promise<unknown> {
27
+ this.events.push(`set:${key}`);
28
+ this.writes.push({ key, value, ttlSeconds: undefined });
29
+ this.values.set(key, value);
30
+ return Promise.resolve('OK');
31
+ }
32
+
33
+ setex(key: string, ttlSeconds: number, value: string): Promise<unknown> {
34
+ this.events.push(`setex:${key}`);
35
+ this.writes.push({ key, value, ttlSeconds });
36
+ this.values.set(key, value);
37
+ return Promise.resolve('OK');
38
+ }
39
+
40
+ del(...keys: string[]): Promise<unknown> {
41
+ this.events.push(`del:${keys.join(',')}`);
42
+ let deleted = 0;
43
+ for (const key of keys) {
44
+ if (this.values.delete(key)) {
45
+ deleted += 1;
46
+ }
47
+ }
48
+ return Promise.resolve(deleted);
49
+ }
50
+
51
+ scan(..._args: Parameters<IoredisCacheClientSource['scan']>): Promise<unknown> {
52
+ return Promise.resolve(['0', []]);
53
+ }
54
+
55
+ flushdb(): Promise<unknown> {
56
+ this.values.clear();
57
+ return Promise.resolve('OK');
58
+ }
59
+
60
+ seed(key: string, value: string): void {
61
+ this.values.set(key, value);
62
+ }
63
+
64
+ raw(key: string): string | undefined {
65
+ return this.values.get(key);
66
+ }
67
+ }
68
+
69
+ beforeEach(() => CacheProviderRegistry.clear());
70
+ afterEach(() => CacheProviderRegistry.clear());
71
+
72
+ it('redis-cache-client/D01 默认 Redis Provider 保持装饰器调用方式', async () => {
73
+ const events: string[] = [];
74
+ const source = new FakeIoredisCacheClientSource(events);
75
+ const provider = new RedisCacheProvider(createIoredisCacheClient(source));
76
+ CacheProviderRegistry.register('redis', provider);
77
+ CacheProviderRegistry.setDefault('redis');
78
+ const cacheKey = KeyBuilder.build('d01-users', [1]);
79
+
80
+ class UserService {
81
+ @Cache('d01-users')
82
+ async getUser(id: number): Promise<{ id: number; version: number }> {
83
+ events.push(`business:get:${id}`);
84
+ return { id, version: events.filter((event) => event === `business:get:${id}`).length };
85
+ }
86
+
87
+ @CacheEvict('d01-users')
88
+ async updateUser(id: number): Promise<{ id: number; updated: boolean }> {
89
+ events.push(`business:update:${id}`);
90
+ return { id, updated: true };
91
+ }
92
+ }
93
+
94
+ const service = new UserService();
95
+ await expect(service.getUser(1)).resolves.toEqual({ id: 1, version: 1 });
96
+ await expect(service.getUser(1)).resolves.toEqual({ id: 1, version: 1 });
97
+ await expect(service.updateUser(1)).resolves.toEqual({ id: 1, updated: true });
98
+ await expect(service.getUser(1)).resolves.toEqual({ id: 1, version: 2 });
99
+
100
+ expect(events).toEqual([
101
+ `get:${cacheKey}`,
102
+ 'business:get:1',
103
+ `set:${cacheKey}`,
104
+ `get:${cacheKey}`,
105
+ 'business:update:1',
106
+ `del:${cacheKey}`,
107
+ `get:${cacheKey}`,
108
+ 'business:get:1',
109
+ `set:${cacheKey}`,
110
+ ]);
111
+ });
112
+
113
+ it('redis-cache-client/D02 指定 Provider 名称保持原行为', async () => {
114
+ const events: string[] = [];
115
+ const redisProvider = new RedisCacheProvider(createIoredisCacheClient(new FakeIoredisCacheClientSource(events)));
116
+ const memoryProvider = new MemoryCacheProvider();
117
+ CacheProviderRegistry.register('redis', redisProvider);
118
+ CacheProviderRegistry.register('memory', memoryProvider);
119
+ CacheProviderRegistry.setDefault('memory');
120
+ const redisKey = KeyBuilder.build('d02-orders', ['active']);
121
+
122
+ class OrderService {
123
+ redisCalls = 0;
124
+ memoryCalls = 0;
125
+
126
+ @Cache('d02-orders', { providerName: 'redis', key: 'active' })
127
+ async getRedisOrder(id: number): Promise<{ id: number; source: string }> {
128
+ this.redisCalls += 1;
129
+ return { id, source: 'redis' };
130
+ }
131
+
132
+ @Cache('d02-orders', { providerName: 'memory', key: 'active' })
133
+ async getMemoryOrder(id: number): Promise<{ id: number; source: string }> {
134
+ this.memoryCalls += 1;
135
+ return { id, source: 'memory' };
136
+ }
137
+
138
+ @CacheEvict('d02-orders', { providerName: 'redis', key: 'active' })
139
+ async evictRedisOrder(): Promise<string> {
140
+ return 'evicted';
141
+ }
142
+ }
143
+
144
+ const service = new OrderService();
145
+ await expect(service.getRedisOrder(1)).resolves.toEqual({ id: 1, source: 'redis' });
146
+ await expect(service.getRedisOrder(2)).resolves.toEqual({ id: 1, source: 'redis' });
147
+ await expect(service.getMemoryOrder(3)).resolves.toEqual({ id: 3, source: 'memory' });
148
+ await expect(service.getMemoryOrder(4)).resolves.toEqual({ id: 3, source: 'memory' });
149
+ await expect(service.evictRedisOrder()).resolves.toBe('evicted');
150
+ await expect(service.getRedisOrder(5)).resolves.toEqual({ id: 5, source: 'redis' });
151
+ await expect(service.getMemoryOrder(6)).resolves.toEqual({ id: 3, source: 'memory' });
152
+
153
+ expect(service.redisCalls).toBe(2);
154
+ expect(service.memoryCalls).toBe(1);
155
+ expect(events.filter((event) => event === `get:${redisKey}`)).toHaveLength(3);
156
+ expect(events).toContain(`del:${redisKey}`);
157
+ });
158
+
159
+ it('cache-operation-logging/A03 日志改造保持旧版 Redis key、entry、TTL 与淘汰 pattern', async () => {
160
+ const events: string[] = [];
161
+ const source = new FakeIoredisCacheClientSource(events);
162
+ const scan = jest.spyOn(source, 'scan');
163
+ const provider = new RedisCacheProvider(createIoredisCacheClient(source));
164
+ CacheProviderRegistry.register('compat-redis', provider);
165
+ const legacyKey = KeyBuilder.build('compat-users', ['legacy']);
166
+ const freshKey = KeyBuilder.build('compat-users', ['fresh']);
167
+ source.seed(legacyKey, '{"value":{"id":18,"source":"legacy-0.1.3"}}');
168
+ let legacyBusinessCalls = 0;
169
+
170
+ class UserService {
171
+ @Cache('compat-users', { providerName: 'compat-redis', key: 'legacy' })
172
+ async getLegacyUser(): Promise<{ id: number; source: string }> {
173
+ legacyBusinessCalls += 1;
174
+ return { id: 0, source: 'business' };
175
+ }
176
+
177
+ @Cache('compat-users', { providerName: 'compat-redis', key: 'fresh', ttl: 45 })
178
+ async getFreshUser(): Promise<{ id: number; source: string }> {
179
+ return { id: 19, source: 'current' };
180
+ }
181
+
182
+ @CacheEvict('compat-users', { providerName: 'compat-redis', allEntries: true })
183
+ async clearUsers(): Promise<boolean> {
184
+ return true;
185
+ }
186
+ }
187
+
188
+ const service = new UserService();
189
+ await expect(service.getLegacyUser()).resolves.toEqual({ id: 18, source: 'legacy-0.1.3' });
190
+ expect(legacyBusinessCalls).toBe(0);
191
+ await expect(service.getFreshUser()).resolves.toEqual({ id: 19, source: 'current' });
192
+ expect(source.raw(freshKey)).toBe('{"value":{"id":19,"source":"current"}}');
193
+ expect(source.writes).toContainEqual({
194
+ key: freshKey,
195
+ value: '{"value":{"id":19,"source":"current"}}',
196
+ ttlSeconds: 45,
197
+ });
198
+
199
+ await expect(service.clearUsers()).resolves.toBe(true);
200
+ expect(scan).toHaveBeenCalledWith('0', 'MATCH', 'compat-users*', 'COUNT', 100);
201
+ });