@jintianxiayu/cache-decorator 0.1.3 → 1.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +306 -79
  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-error.d.ts +75 -0
  16. package/dist/core/cache-error.d.ts.map +1 -0
  17. package/dist/core/cache-error.js +168 -0
  18. package/dist/core/cache-error.js.map +1 -0
  19. package/dist/core/cache-logger.d.ts +29 -0
  20. package/dist/core/cache-logger.d.ts.map +1 -0
  21. package/dist/core/cache-logger.js +72 -0
  22. package/dist/core/cache-logger.js.map +1 -0
  23. package/dist/core/cache-provider-registry.d.ts.map +1 -1
  24. package/dist/core/cache-provider-registry.js.map +1 -1
  25. package/dist/core/cache-provider.d.ts.map +1 -1
  26. package/dist/core/key-builder.d.ts.map +1 -1
  27. package/dist/core/key-builder.js +3 -2
  28. package/dist/core/key-builder.js.map +1 -1
  29. package/dist/core/native-cache.d.ts.map +1 -1
  30. package/dist/core/native-cache.js +2 -1
  31. package/dist/core/native-cache.js.map +1 -1
  32. package/dist/core/pending-cache.d.ts.map +1 -1
  33. package/dist/core/pending-cache.js +6 -3
  34. package/dist/core/pending-cache.js.map +1 -1
  35. package/dist/core/redis-cache-client.d.ts +156 -0
  36. package/dist/core/redis-cache-client.d.ts.map +1 -0
  37. package/dist/core/redis-cache-client.js +3 -0
  38. package/dist/core/redis-cache-client.js.map +1 -0
  39. package/dist/core/redis-cache.d.ts +39 -7
  40. package/dist/core/redis-cache.d.ts.map +1 -1
  41. package/dist/core/redis-cache.js +63 -22
  42. package/dist/core/redis-cache.js.map +1 -1
  43. package/dist/decorators/cache-evict.d.ts +1 -1
  44. package/dist/decorators/cache-evict.d.ts.map +1 -1
  45. package/dist/decorators/cache-evict.js +68 -14
  46. package/dist/decorators/cache-evict.js.map +1 -1
  47. package/dist/decorators/cache.d.ts +7 -1
  48. package/dist/decorators/cache.d.ts.map +1 -1
  49. package/dist/decorators/cache.js +169 -24
  50. package/dist/decorators/cache.js.map +1 -1
  51. package/dist/index.d.ts +3 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -0
  54. package/dist/index.js.map +1 -1
  55. package/jest.config.js +1 -1
  56. package/package.json +26 -29
  57. package/src/adapters/ioredis-cache-client.ts +89 -0
  58. package/src/adapters/node-redis-cache-client.ts +95 -0
  59. package/src/adapters/redis-key-prefix.ts +38 -0
  60. package/src/core/cache-error.ts +233 -0
  61. package/src/core/cache-logger.ts +116 -0
  62. package/src/core/key-builder.ts +3 -1
  63. package/src/core/native-cache.ts +3 -1
  64. package/src/core/pending-cache.ts +6 -3
  65. package/src/core/redis-cache-client.ts +160 -0
  66. package/src/core/redis-cache.ts +68 -20
  67. package/src/decorators/cache-evict.ts +76 -16
  68. package/src/decorators/cache.ts +220 -26
  69. package/src/index.ts +3 -0
  70. package/test/cache-evict-logging.test.ts +362 -0
  71. package/test/cache-logger.integration.test.ts +129 -0
  72. package/test/cache-logger.test.ts +156 -0
  73. package/test/cache-logging.test.ts +852 -0
  74. package/test/cache.test.ts +717 -34
  75. package/test/fixtures/cache-logger-child.mjs +108 -0
  76. package/test/helpers/legacy-redis-cache.ts +41 -0
  77. package/test/helpers/package-consumer.ts +231 -0
  78. package/test/helpers/redis-fixture.ts +142 -0
  79. package/test/ioredis-cache-client.test.ts +143 -0
  80. package/test/legacy-redis-cache.test.ts +51 -0
  81. package/test/native-cache.test.ts +9 -3
  82. package/test/node-redis-cache-client.test.ts +149 -0
  83. package/test/pending-cache.test.ts +11 -0
  84. package/test/redis-cache-client-lifecycle.test.ts +112 -0
  85. package/test/redis-cache-client-types.test.ts +184 -0
  86. package/test/redis-cache-client.integration.test.ts +355 -0
  87. package/test/redis-cache-decorator.test.ts +540 -0
  88. package/test/redis-cache-package.integration.test.ts +328 -0
  89. package/test/redis-cache-provider.test.ts +269 -0
  90. package/test/type-contract/contract.ts +119 -0
  91. package/test/type-contract/tsconfig.json +12 -0
  92. package/tsconfig.json +1 -1
@@ -1,18 +1,90 @@
1
- import { Cache } from '../src/decorators/cache';
1
+ import type { CacheProvider } from '../src/core/cache-provider';
2
2
  import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
3
+ import { KeyBuilder } from '../src/core/key-builder';
3
4
  import { MemoryCacheProvider } from '../src/core/native-cache';
4
-
5
- describe('@Cache 装饰器', () => {
6
- beforeAll(() => {
7
- CacheProviderRegistry.register('memory', new MemoryCacheProvider());
8
- CacheProviderRegistry.setDefault('memory');
5
+ import { Cache, type CacheErrorCodec, type CacheErrorPolicy, type CacheOptions } from '../src/decorators/cache';
6
+
7
+ interface ProviderHarness {
8
+ readonly provider: CacheProvider;
9
+ readonly get: jest.Mock<unknown, [string]>;
10
+ readonly set: jest.Mock<void, [string, unknown, number?]>;
11
+ readonly values: Map<string, unknown>;
12
+ }
13
+
14
+ interface RejectionDeferred {
15
+ readonly promise: Promise<never>;
16
+ readonly reject: (reason: unknown) => void;
17
+ }
18
+
19
+ /** 创建同步读写且记录调用参数的可插拔 Provider。 */
20
+ function createProvider(): ProviderHarness {
21
+ const values = new Map<string, unknown>();
22
+ const get = jest.fn<unknown, [string]>((key) => values.get(key));
23
+ const set = jest.fn<void, [string, unknown, number?]>((key, value) => {
24
+ values.set(key, value);
9
25
  });
10
-
11
- beforeEach(() => {
12
- const provider = CacheProviderRegistry.get();
13
- provider.clear();
26
+ const provider: CacheProvider = {
27
+ get: <Value>(key: string): Value | undefined => get(key) as Value | undefined,
28
+ set: <Value>(key: string, value: Value, ttl?: number): void => set(key, value, ttl),
29
+ delete: (key: string): void => {
30
+ values.delete(key);
31
+ },
32
+ clear: (): void => {
33
+ values.clear();
34
+ },
35
+ deleteByPattern: (): void => {
36
+ values.clear();
37
+ },
38
+ };
39
+ return { provider, get, set, values };
40
+ }
41
+
42
+ /** 创建由测试显式拒绝的 Promise,用于观察 pending 身份与清理。 */
43
+ function createRejectionDeferred(): RejectionDeferred {
44
+ let rejectPromise: ((reason: unknown) => void) | undefined;
45
+ const promise = new Promise<never>((_resolve, reject) => {
46
+ rejectPromise = reject;
14
47
  });
48
+ if (!rejectPromise) {
49
+ throw new Error('Deferred rejecter was not initialized');
50
+ }
51
+ return { promise, reject: rejectPromise };
52
+ }
53
+
54
+ /** 等待并返回 Promise 的 rejection 值,包括 undefined 等非 Error 值。 */
55
+ async function captureRejection(promise: Promise<unknown>): Promise<unknown> {
56
+ try {
57
+ await promise;
58
+ } catch (error) {
59
+ return error;
60
+ }
61
+ throw new Error('Expected promise to reject');
62
+ }
63
+
64
+ /** 使用公开协议形状构造当前版本异常条目,避免测试依赖内部 helper。 */
65
+ function currentErrorEntry(payload: unknown): unknown {
66
+ return {
67
+ error: {
68
+ kind: '@jintianxiayu/cache-decorator/error',
69
+ version: 1,
70
+ payload,
71
+ },
72
+ };
73
+ }
74
+
75
+ beforeEach(() => {
76
+ CacheProviderRegistry.clear();
77
+ CacheProviderRegistry.register('memory', new MemoryCacheProvider());
78
+ CacheProviderRegistry.setDefault('memory');
79
+ });
15
80
 
81
+ afterEach(() => {
82
+ jest.useRealTimers();
83
+ jest.restoreAllMocks();
84
+ CacheProviderRegistry.clear();
85
+ });
86
+
87
+ describe('@Cache 装饰器', () => {
16
88
  describe('基础缓存', () => {
17
89
  it('应缓存方法结果', async () => {
18
90
  class UserService {
@@ -58,8 +130,11 @@ describe('@Cache 装饰器', () => {
58
130
  jest.useFakeTimers();
59
131
 
60
132
  class UserService {
133
+ callCount = 0;
134
+
61
135
  @Cache('user-cache', { ttl: 60 })
62
136
  async getUser(id: number) {
137
+ this.callCount++;
63
138
  return { id };
64
139
  }
65
140
  }
@@ -72,8 +147,7 @@ describe('@Cache 装饰器', () => {
72
147
 
73
148
  const result2 = await service.getUser(1);
74
149
  expect(result2).toEqual({ id: 1 });
75
-
76
- jest.useRealTimers();
150
+ expect(service.callCount).toBe(2);
77
151
  });
78
152
  });
79
153
 
@@ -101,45 +175,38 @@ describe('@Cache 装饰器', () => {
101
175
  });
102
176
 
103
177
  describe('错误结果缓存', () => {
104
- it.skip('应缓存错误结果', async () => {
178
+ it('省略策略时不持久化业务异常并允许顺序重试', async () => {
105
179
  class UserService {
106
180
  callCount = 0;
107
181
 
108
182
  @Cache('user-cache')
109
183
  async getUser(_id: number) {
110
184
  this.callCount++;
111
- throw new Error('user not found');
185
+ throw new Error(`user not found ${this.callCount}`);
112
186
  }
113
187
  }
114
188
 
115
189
  const service = new UserService();
116
190
 
117
- try {
118
- await service.getUser(1);
119
- } catch (e) {
120
- // expected
121
- }
122
- try {
123
- await service.getUser(1);
124
- } catch (e) {
125
- // expected
126
- }
127
- expect(service.callCount).toBe(1);
191
+ await expect(service.getUser(1)).rejects.toThrow('user not found 1');
192
+ await expect(service.getUser(1)).rejects.toThrow('user not found 2');
193
+ expect(service.callCount).toBe(2);
128
194
  });
129
195
  });
130
196
 
131
197
  describe('请求合并', () => {
132
- it.skip('并发请求应返回同一 Promise', async () => {
198
+ it('并发请求应返回同一 Promise', async () => {
133
199
  let resolvePromise: ((value: number) => void) | undefined;
134
200
  let callCount = 0;
201
+ const resultPromise = new Promise<number>((resolve) => {
202
+ resolvePromise = resolve;
203
+ });
135
204
 
136
205
  class UserService {
137
206
  @Cache('user-cache')
138
207
  async getUser(_id: number): Promise<number> {
139
208
  callCount++;
140
- return new Promise((resolve) => {
141
- resolvePromise = resolve;
142
- });
209
+ return resultPromise;
143
210
  }
144
211
  }
145
212
 
@@ -147,14 +214,12 @@ describe('@Cache 装饰器', () => {
147
214
  const promise1 = service.getUser(1);
148
215
  const promise2 = service.getUser(1);
149
216
 
150
- await new Promise((resolve) => setTimeout(resolve, 10));
151
-
152
- expect(callCount).toBe(1);
153
- expect(promise1).toBe(promise2);
154
-
217
+ await Promise.resolve();
155
218
  resolvePromise!(42);
156
219
 
157
220
  const [result1, result2] = await Promise.all([promise1, promise2]);
221
+ expect(callCount).toBe(1);
222
+ expect(promise1).toBe(promise2);
158
223
  expect(result1).toBe(42);
159
224
  expect(result2).toBe(42);
160
225
  expect(callCount).toBe(1);
@@ -254,3 +319,621 @@ describe('@Cache 装饰器', () => {
254
319
  });
255
320
  });
256
321
  });
322
+
323
+ describe('异常策略配置边界', () => {
324
+ it('非法异常 TTL 在 decorator 求值时抛出 RangeError 且不访问 Provider 或业务方法', () => {
325
+ const providerGet = jest.spyOn(CacheProviderRegistry, 'get');
326
+ const businessMethod = jest.fn();
327
+ const invalidTtls = [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
328
+
329
+ for (const ttl of invalidTtls) {
330
+ expect(() => Cache('invalid-error-ttl', { errorCache: { ttl } })).toThrow(RangeError);
331
+ }
332
+
333
+ expect(providerGet).not.toHaveBeenCalled();
334
+ expect(businessMethod).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it('运行期畸形筛选器和 codec 在 decorator 求值时抛出 TypeError', () => {
338
+ const invalidPolicies: readonly unknown[] = [
339
+ null,
340
+ { ttl: 5, shouldCache: 'yes' },
341
+ { ttl: 5, codec: null },
342
+ { ttl: 5, codec: { encode: (): unknown => ({}) } },
343
+ { ttl: 5, codec: { decode: (): unknown => new Error('decoded') } },
344
+ ];
345
+
346
+ for (const policy of invalidPolicies) {
347
+ expect(() => Cache('invalid-error-policy', { errorCache: policy as CacheErrorPolicy })).toThrow(TypeError);
348
+ }
349
+ });
350
+ });
351
+
352
+ describe('异常策略编排', () => {
353
+ it('Provider 读取失败不调用异常筛选器、codec 或业务方法', async () => {
354
+ const provider = createProvider();
355
+ const readError = new Error('redis unavailable');
356
+ provider.get.mockImplementation(() => {
357
+ throw readError;
358
+ });
359
+ CacheProviderRegistry.register('failed-read', provider.provider);
360
+ const shouldCache = jest.fn(() => true);
361
+ const encode = jest.fn((error: unknown): unknown => error);
362
+ const decode = jest.fn((payload: unknown): unknown => payload);
363
+ const businessMethod = jest.fn();
364
+
365
+ class UserService {
366
+ @Cache('failed-read-users', {
367
+ providerName: 'failed-read',
368
+ errorCache: { ttl: 5, shouldCache, codec: { encode, decode } },
369
+ })
370
+ getUser(): unknown {
371
+ return businessMethod();
372
+ }
373
+ }
374
+
375
+ await expect(new UserService().getUser()).rejects.toBe(readError);
376
+ expect(businessMethod).not.toHaveBeenCalled();
377
+ expect(shouldCache).not.toHaveBeenCalled();
378
+ expect(encode).not.toHaveBeenCalled();
379
+ expect(decode).not.toHaveBeenCalled();
380
+ expect(provider.set).not.toHaveBeenCalled();
381
+ });
382
+
383
+ it('key resolver 失败后的业务成功只写入正常结果', async () => {
384
+ const provider = createProvider();
385
+ CacheProviderRegistry.register('key-fallback', provider.provider);
386
+ const shouldCache = jest.fn(() => true);
387
+ const codec: CacheErrorCodec = {
388
+ encode: jest.fn((error: unknown): unknown => error),
389
+ decode: jest.fn((payload: unknown): unknown => payload),
390
+ };
391
+
392
+ class UserService {
393
+ @Cache('key-fallback-users', {
394
+ providerName: 'key-fallback',
395
+ key: () => {
396
+ throw new Error('resolver failed');
397
+ },
398
+ errorCache: { ttl: 5, shouldCache, codec },
399
+ })
400
+ async getUser(id: number): Promise<{ id: number }> {
401
+ return { id };
402
+ }
403
+ }
404
+
405
+ await expect(new UserService().getUser(7)).resolves.toEqual({ id: 7 });
406
+ expect(shouldCache).not.toHaveBeenCalled();
407
+ expect(codec.encode).not.toHaveBeenCalled();
408
+ expect(provider.set).toHaveBeenCalledWith(expect.any(String), { value: { id: 7 } }, undefined);
409
+ });
410
+
411
+ it('省略筛选器时接受异常,命中使用默认 codec 的新 Error', async () => {
412
+ const businessError = new Error('stable not found');
413
+
414
+ class UserService {
415
+ callCount = 0;
416
+
417
+ @Cache('default-error-policy', { errorCache: { ttl: 5 } })
418
+ async getUser(): Promise<never> {
419
+ this.callCount += 1;
420
+ throw businessError;
421
+ }
422
+ }
423
+
424
+ const service = new UserService();
425
+ expect(await captureRejection(service.getUser())).toBe(businessError);
426
+ const cachedError = await captureRejection(service.getUser());
427
+ expect(cachedError).toBeInstanceOf(Error);
428
+ expect(cachedError).not.toBe(businessError);
429
+ expect((cachedError as Error).message).toBe('stable not found');
430
+ expect(service.callCount).toBe(1);
431
+ });
432
+
433
+ it('筛选器返回 true 时只求值和 encode 一次并使用异常 TTL', async () => {
434
+ const provider = createProvider();
435
+ CacheProviderRegistry.register('accepted-error', provider.provider);
436
+ const businessError = new Error('accepted');
437
+ const shouldCache = jest.fn((_error: unknown): boolean => true);
438
+ const encode = jest.fn((_error: unknown): unknown => ({ code: 'ACCEPTED' }));
439
+ const codec: CacheErrorCodec = {
440
+ encode,
441
+ decode: (payload: unknown): unknown => payload,
442
+ };
443
+
444
+ class UserService {
445
+ @Cache('accepted-error-users', {
446
+ providerName: 'accepted-error',
447
+ errorCache: { ttl: 9, shouldCache, codec },
448
+ })
449
+ async getUser(): Promise<never> {
450
+ throw businessError;
451
+ }
452
+ }
453
+
454
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
455
+ expect(shouldCache).toHaveBeenCalledTimes(1);
456
+ expect(shouldCache).toHaveBeenCalledWith(businessError);
457
+ expect(encode).toHaveBeenCalledTimes(1);
458
+ expect(provider.set).toHaveBeenCalledTimes(1);
459
+ expect(provider.set.mock.calls[0]?.[2]).toBe(9);
460
+ });
461
+
462
+ it('筛选器返回 false 时跳过 codec 和写入并允许后续重试', async () => {
463
+ const provider = createProvider();
464
+ CacheProviderRegistry.register('rejected-error', provider.provider);
465
+ const shouldCache = jest.fn((_error: unknown): boolean => false);
466
+ const codec: CacheErrorCodec = {
467
+ encode: jest.fn((error: unknown): unknown => error),
468
+ decode: jest.fn((payload: unknown): unknown => payload),
469
+ };
470
+ let callCount = 0;
471
+
472
+ class UserService {
473
+ @Cache('rejected-error-users', {
474
+ providerName: 'rejected-error',
475
+ errorCache: { ttl: 9, shouldCache, codec },
476
+ })
477
+ async getUser(): Promise<never> {
478
+ callCount += 1;
479
+ throw new Error(`transient ${callCount}`);
480
+ }
481
+ }
482
+
483
+ const service = new UserService();
484
+ await expect(service.getUser()).rejects.toThrow('transient 1');
485
+ await expect(service.getUser()).rejects.toThrow('transient 2');
486
+ expect(shouldCache).toHaveBeenCalledTimes(2);
487
+ expect(codec.encode).not.toHaveBeenCalled();
488
+ expect(provider.set).not.toHaveBeenCalled();
489
+ expect(callCount).toBe(2);
490
+ });
491
+
492
+ it('筛选器抛错时 fail-closed 且不遮蔽原业务异常', async () => {
493
+ const provider = createProvider();
494
+ CacheProviderRegistry.register('failed-predicate', provider.provider);
495
+ const businessError = new Error('original business failure');
496
+ const shouldCache = jest.fn(() => {
497
+ throw new Error('predicate secret');
498
+ });
499
+ const codec: CacheErrorCodec = {
500
+ encode: jest.fn((error: unknown): unknown => error),
501
+ decode: jest.fn((payload: unknown): unknown => payload),
502
+ };
503
+
504
+ class UserService {
505
+ @Cache('failed-predicate-users', {
506
+ providerName: 'failed-predicate',
507
+ errorCache: { ttl: 9, shouldCache, codec },
508
+ })
509
+ async getUser(): Promise<never> {
510
+ throw businessError;
511
+ }
512
+ }
513
+
514
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
515
+ expect(shouldCache).toHaveBeenCalledTimes(1);
516
+ expect(codec.encode).not.toHaveBeenCalled();
517
+ expect(provider.set).not.toHaveBeenCalled();
518
+ });
519
+
520
+ it('正常结果和异常分别使用正常 TTL 与异常 TTL', async () => {
521
+ const provider = createProvider();
522
+ CacheProviderRegistry.register('separate-ttl', provider.provider);
523
+ const businessError = new Error('negative result');
524
+
525
+ class UserService {
526
+ @Cache('normal-ttl-users', {
527
+ providerName: 'separate-ttl',
528
+ ttl: 300,
529
+ errorCache: { ttl: 10 },
530
+ })
531
+ async getUser(): Promise<string> {
532
+ return 'value';
533
+ }
534
+
535
+ @Cache('error-ttl-users', {
536
+ providerName: 'separate-ttl',
537
+ ttl: 300,
538
+ errorCache: { ttl: 10 },
539
+ })
540
+ async getMissingUser(): Promise<never> {
541
+ throw businessError;
542
+ }
543
+
544
+ @Cache('omitted-normal-ttl', { providerName: 'separate-ttl', errorCache: { ttl: 10 } })
545
+ async getConfig(): Promise<string> {
546
+ return 'config';
547
+ }
548
+ }
549
+
550
+ const service = new UserService();
551
+ await expect(service.getUser()).resolves.toBe('value');
552
+ await expect(service.getMissingUser()).rejects.toBe(businessError);
553
+ await expect(service.getConfig()).resolves.toBe('config');
554
+
555
+ expect(provider.set.mock.calls.map((call) => call[2])).toEqual([300, 10, undefined]);
556
+ });
557
+
558
+ it('Memory Provider 中异常 TTL 到期后重新执行业务方法', async () => {
559
+ jest.useFakeTimers({ now: 0 });
560
+
561
+ class UserService {
562
+ callCount = 0;
563
+
564
+ @Cache('expiring-errors', { errorCache: { ttl: 10 } })
565
+ async getUser(): Promise<never> {
566
+ this.callCount += 1;
567
+ throw new Error(`failure ${this.callCount}`);
568
+ }
569
+ }
570
+
571
+ const service = new UserService();
572
+ await expect(service.getUser()).rejects.toThrow('failure 1');
573
+ await expect(service.getUser()).rejects.toThrow('failure 1');
574
+ expect(service.callCount).toBe(1);
575
+
576
+ jest.advanceTimersByTime(10_001);
577
+ await expect(service.getUser()).rejects.toThrow('failure 2');
578
+ expect(service.callCount).toBe(2);
579
+ });
580
+
581
+ it('异常写入同步失败仍传播原业务异常', async () => {
582
+ const provider = createProvider();
583
+ const writeError = new Error('synchronous cache write failed');
584
+ const businessError = new Error('business failure');
585
+ provider.set.mockImplementation(() => {
586
+ throw writeError;
587
+ });
588
+ CacheProviderRegistry.register('failed-error-write', provider.provider);
589
+
590
+ class UserService {
591
+ @Cache('failed-error-write-users', {
592
+ providerName: 'failed-error-write',
593
+ errorCache: { ttl: 5 },
594
+ })
595
+ async getUser(): Promise<never> {
596
+ throw businessError;
597
+ }
598
+ }
599
+
600
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
601
+ expect(provider.set).toHaveBeenCalledTimes(1);
602
+ });
603
+
604
+ it('正常写入同步失败不进入异常缓存策略', async () => {
605
+ const provider = createProvider();
606
+ const writeError = new Error('normal write failed');
607
+ const shouldCache = jest.fn((_error: unknown): boolean => true);
608
+ provider.set.mockImplementation(() => {
609
+ throw writeError;
610
+ });
611
+ CacheProviderRegistry.register('failed-normal-write', provider.provider);
612
+
613
+ class UserService {
614
+ @Cache('failed-normal-write-users', {
615
+ providerName: 'failed-normal-write',
616
+ errorCache: { ttl: 5, shouldCache },
617
+ })
618
+ async getUser(): Promise<string> {
619
+ return 'value';
620
+ }
621
+ }
622
+
623
+ await expect(new UserService().getUser()).rejects.toBe(writeError);
624
+ expect(provider.set).toHaveBeenCalledTimes(1);
625
+ expect(shouldCache).not.toHaveBeenCalled();
626
+ });
627
+ });
628
+
629
+ describe('异常表示与命中分类', () => {
630
+ it('默认 codec 仅持久化标准 Error 的 name/message 并在命中时创建新 Error', async () => {
631
+ const provider = createProvider();
632
+ CacheProviderRegistry.register('default-codec', provider.provider);
633
+ const businessError = new Error('user 13800138000 not found');
634
+ businessError.name = 'NotFoundError';
635
+ businessError.stack = 'sensitive stack';
636
+ Object.assign(businessError, { internalCode: 'SECRET_CODE' });
637
+ let callCount = 0;
638
+
639
+ class UserService {
640
+ @Cache('default-codec-users', { providerName: 'default-codec', errorCache: { ttl: 5 } })
641
+ async getUser(): Promise<never> {
642
+ callCount += 1;
643
+ throw businessError;
644
+ }
645
+ }
646
+
647
+ const service = new UserService();
648
+ expect(await captureRejection(service.getUser())).toBe(businessError);
649
+ const storedEntry = [...provider.values.values()][0];
650
+ expect(storedEntry).toEqual(
651
+ currentErrorEntry({ type: 'error', name: 'NotFoundError', message: 'user 13800138000 not found' })
652
+ );
653
+ expect(JSON.stringify(storedEntry)).not.toContain('sensitive stack');
654
+ expect(JSON.stringify(storedEntry)).not.toContain('SECRET_CODE');
655
+
656
+ const cachedError = await captureRejection(service.getUser());
657
+ expect(cachedError).toBeInstanceOf(Error);
658
+ expect(cachedError).not.toBe(businessError);
659
+ expect((cachedError as Error).name).toBe('NotFoundError');
660
+ expect((cachedError as Error).message).toBe('user 13800138000 not found');
661
+ expect((cachedError as { internalCode?: string }).internalCode).toBeUndefined();
662
+ expect(callCount).toBe(1);
663
+ });
664
+
665
+ it('默认 codec 让 JSON 兼容的非 Error 值按 JSON 表示往返', async () => {
666
+ const cases: ReadonlyArray<readonly [string, unknown]> = [
667
+ ['string', 'NOT_FOUND'],
668
+ ['number', 404],
669
+ ['boolean', false],
670
+ ['null', null],
671
+ ['object', { code: 'NOT_FOUND', details: [1, true, null] }],
672
+ ];
673
+
674
+ for (const [label, thrownValue] of cases) {
675
+ const provider = createProvider();
676
+ const providerName = `json-error-${label}`;
677
+ CacheProviderRegistry.register(providerName, provider.provider);
678
+ let callCount = 0;
679
+
680
+ class UserService {
681
+ @Cache(`json-error-${label}`, { providerName, errorCache: { ttl: 5 } })
682
+ async getUser(): Promise<never> {
683
+ callCount += 1;
684
+ throw thrownValue;
685
+ }
686
+ }
687
+
688
+ const service = new UserService();
689
+ expect(await captureRejection(service.getUser())).toBe(thrownValue);
690
+ expect(await captureRejection(service.getUser())).toEqual(thrownValue);
691
+ expect(callCount).toBe(1);
692
+ }
693
+ });
694
+
695
+ it('默认 codec 对不可 JSON 往返的值跳过写入并保留原异常', async () => {
696
+ const cyclic: Record<string, unknown> = {};
697
+ cyclic.self = cyclic;
698
+ const invalidValues: ReadonlyArray<readonly [string, unknown]> = [
699
+ ['undefined', undefined],
700
+ ['function', (): void => undefined],
701
+ ['symbol', Symbol('secret')],
702
+ ['cyclic', cyclic],
703
+ ['non-finite', Number.NaN],
704
+ ['bigint', BigInt(1)],
705
+ ];
706
+
707
+ for (const [label, thrownValue] of invalidValues) {
708
+ const provider = createProvider();
709
+ const providerName = `invalid-error-${label}`;
710
+ CacheProviderRegistry.register(providerName, provider.provider);
711
+ let callCount = 0;
712
+
713
+ class UserService {
714
+ @Cache(`invalid-error-${label}`, { providerName, key: 'fixed', errorCache: { ttl: 5 } })
715
+ async getUser(): Promise<never> {
716
+ callCount += 1;
717
+ throw thrownValue;
718
+ }
719
+ }
720
+
721
+ const service = new UserService();
722
+ expect(await captureRejection(service.getUser())).toBe(thrownValue);
723
+ expect(await captureRejection(service.getUser())).toBe(thrownValue);
724
+ expect(provider.set).not.toHaveBeenCalled();
725
+ expect(callCount).toBe(2);
726
+ }
727
+ });
728
+
729
+ it('自定义 codec 在 Memory 与自定义 Provider 中得到同一规范化 payload', async () => {
730
+ class DomainError extends Error {
731
+ constructor(readonly code: string) {
732
+ super(`domain:${code}`);
733
+ this.name = 'DomainError';
734
+ }
735
+ }
736
+
737
+ const encodedPayload = { code: 'NOT_FOUND' };
738
+ const encode = jest.fn((_error: unknown): unknown => encodedPayload);
739
+ const decode = jest.fn((payload: unknown): unknown => {
740
+ if (typeof payload !== 'object' || payload === null || !('code' in payload)) {
741
+ throw new TypeError('invalid domain payload');
742
+ }
743
+ return new DomainError(String(payload.code));
744
+ });
745
+ const codec: CacheErrorCodec = { encode, decode };
746
+ const customProvider = createProvider();
747
+ CacheProviderRegistry.register('custom-codec-provider', customProvider.provider);
748
+ let memoryCalls = 0;
749
+ let customCalls = 0;
750
+
751
+ class UserService {
752
+ @Cache('custom-codec-memory', { providerName: 'memory', errorCache: { ttl: 5, codec } })
753
+ async getFromMemory(): Promise<never> {
754
+ memoryCalls += 1;
755
+ throw new DomainError('NOT_FOUND');
756
+ }
757
+
758
+ @Cache('custom-codec-provider', {
759
+ providerName: 'custom-codec-provider',
760
+ errorCache: { ttl: 5, codec },
761
+ })
762
+ async getFromCustomProvider(): Promise<never> {
763
+ customCalls += 1;
764
+ throw new DomainError('NOT_FOUND');
765
+ }
766
+ }
767
+
768
+ const service = new UserService();
769
+ await captureRejection(service.getFromMemory());
770
+ await captureRejection(service.getFromCustomProvider());
771
+ const memoryEntry = await CacheProviderRegistry.get('memory').get(KeyBuilder.build('custom-codec-memory', []));
772
+ const customEntry = [...customProvider.values.values()][0];
773
+ expect(memoryEntry).toEqual(customEntry);
774
+ expect((memoryEntry as { error: { payload: unknown } }).error.payload).toEqual(encodedPayload);
775
+ expect((memoryEntry as { error: { payload: unknown } }).error.payload).not.toBe(encodedPayload);
776
+
777
+ const memoryError = await captureRejection(service.getFromMemory());
778
+ const customError = await captureRejection(service.getFromCustomProvider());
779
+ expect(memoryError).toBeInstanceOf(DomainError);
780
+ expect(customError).toBeInstanceOf(DomainError);
781
+ expect(encode).toHaveBeenCalledTimes(2);
782
+ expect(decode).toHaveBeenCalledTimes(2);
783
+ expect(memoryCalls).toBe(1);
784
+ expect(customCalls).toBe(1);
785
+ });
786
+
787
+ it('自定义 encode 抛错时跳过写入并保留业务异常', async () => {
788
+ const provider = createProvider();
789
+ CacheProviderRegistry.register('failed-encode', provider.provider);
790
+ const businessError = new Error('business failure');
791
+ const codecError = new Error('codec secret');
792
+ const codec: CacheErrorCodec = {
793
+ encode: (): never => {
794
+ throw codecError;
795
+ },
796
+ decode: (payload: unknown): unknown => payload,
797
+ };
798
+
799
+ class UserService {
800
+ @Cache('failed-encode-users', { providerName: 'failed-encode', errorCache: { ttl: 5, codec } })
801
+ async getUser(): Promise<never> {
802
+ throw businessError;
803
+ }
804
+ }
805
+
806
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
807
+ expect(provider.set).not.toHaveBeenCalled();
808
+ });
809
+
810
+ it('共享 key 的 codec 不匹配或 decode 返回非法结果时旁路为 miss', async () => {
811
+ const cases: ReadonlyArray<readonly [string, (payload: unknown) => unknown]> = [
812
+ [
813
+ 'mismatch',
814
+ (): never => {
815
+ throw new TypeError('codec mismatch');
816
+ },
817
+ ],
818
+ ['invalid-result', (): unknown => (): void => undefined],
819
+ ];
820
+
821
+ for (const [label, decode] of cases) {
822
+ const provider = createProvider();
823
+ const providerName = `decode-${label}`;
824
+ const cacheName = `decode-${label}-users`;
825
+ provider.values.set(KeyBuilder.build(cacheName, []), currentErrorEntry({ code: 'OTHER_CODEC' }));
826
+ CacheProviderRegistry.register(providerName, provider.provider);
827
+ const businessMethod = jest.fn(() => 'fresh');
828
+
829
+ class UserService {
830
+ @Cache(cacheName, {
831
+ providerName,
832
+ errorCache: { ttl: 5, codec: { encode: (error: unknown): unknown => error, decode } },
833
+ })
834
+ async getUser(): Promise<string> {
835
+ return businessMethod();
836
+ }
837
+ }
838
+
839
+ await expect(new UserService().getUser()).resolves.toBe('fresh');
840
+ expect(businessMethod).toHaveBeenCalledTimes(1);
841
+ expect(provider.set).toHaveBeenCalledWith(expect.any(String), { value: 'fresh' }, undefined);
842
+ }
843
+ });
844
+
845
+ it('禁用策略时旁路版本化异常并用成功结果覆盖', async () => {
846
+ const provider = createProvider();
847
+ const cacheName = 'disabled-versioned-error';
848
+ const cacheKey = KeyBuilder.build(cacheName, []);
849
+ provider.values.set(cacheKey, currentErrorEntry({ type: 'error', name: 'Error', message: 'cached' }));
850
+ CacheProviderRegistry.register('disabled-versioned', provider.provider);
851
+ const businessMethod = jest.fn(() => 'fresh');
852
+
853
+ class UserService {
854
+ @Cache(cacheName, { providerName: 'disabled-versioned' })
855
+ async getUser(): Promise<string> {
856
+ return businessMethod();
857
+ }
858
+ }
859
+
860
+ await expect(new UserService().getUser()).resolves.toBe('fresh');
861
+ expect(businessMethod).toHaveBeenCalledTimes(1);
862
+ expect(provider.values.get(cacheKey)).toEqual({ value: 'fresh' });
863
+ });
864
+
865
+ it('legacy error 条目始终旁路,业务失败后按当前策略写入新 envelope', async () => {
866
+ const provider = createProvider();
867
+ const cacheName = 'legacy-error-entry';
868
+ const cacheKey = KeyBuilder.build(cacheName, []);
869
+ provider.values.set(cacheKey, { error: new Error('legacy') });
870
+ CacheProviderRegistry.register('legacy-error', provider.provider);
871
+ const businessError = new Error('current business failure');
872
+ let callCount = 0;
873
+
874
+ class UserService {
875
+ @Cache(cacheName, { providerName: 'legacy-error', errorCache: { ttl: 5 } })
876
+ async getUser(): Promise<never> {
877
+ callCount += 1;
878
+ throw businessError;
879
+ }
880
+ }
881
+
882
+ const service = new UserService();
883
+ await expect(service.getUser()).rejects.toBe(businessError);
884
+ expect(provider.values.get(cacheKey)).toEqual(
885
+ currentErrorEntry({ type: 'error', name: 'Error', message: 'current business failure' })
886
+ );
887
+ const cachedError = await captureRejection(service.getUser());
888
+ expect((cachedError as Error).message).toBe('current business failure');
889
+ expect(callCount).toBe(1);
890
+ });
891
+ });
892
+
893
+ describe('异常路径 pending 请求合并', () => {
894
+ const pendingCases: ReadonlyArray<{
895
+ readonly label: string;
896
+ readonly error: unknown;
897
+ readonly options: CacheOptions;
898
+ }> = [
899
+ { label: 'disabled', error: new Error('disabled'), options: {} },
900
+ {
901
+ label: 'predicate-rejected',
902
+ error: new Error('predicate rejected'),
903
+ options: { errorCache: { ttl: 5, shouldCache: (): boolean => false } },
904
+ },
905
+ { label: 'encode-failed', error: (): void => undefined, options: { errorCache: { ttl: 5 } } },
906
+ ];
907
+
908
+ it.each(pendingCases)('$label 时并发 rejection 复用同一 Promise,settled 后允许重试', async (testCase) => {
909
+ const provider = createProvider();
910
+ const providerName = `pending-${testCase.label}`;
911
+ const cacheName = `pending-${testCase.label}-users`;
912
+ CacheProviderRegistry.register(providerName, provider.provider);
913
+ const deferred = createRejectionDeferred();
914
+ const businessMethod = jest.fn(() => deferred.promise);
915
+
916
+ class UserService {
917
+ @Cache(cacheName, { ...testCase.options, providerName })
918
+ getUser(): Promise<never> {
919
+ return businessMethod();
920
+ }
921
+ }
922
+
923
+ const service = new UserService();
924
+ const first = service.getUser();
925
+ const second = service.getUser();
926
+ expect(second).toBe(first);
927
+ await Promise.resolve();
928
+ expect(businessMethod).toHaveBeenCalledTimes(1);
929
+
930
+ deferred.reject(testCase.error);
931
+ expect(await captureRejection(first)).toBe(testCase.error);
932
+ expect(await captureRejection(second)).toBe(testCase.error);
933
+ const retry = service.getUser();
934
+ expect(await captureRejection(retry)).toBe(testCase.error);
935
+ expect(businessMethod).toHaveBeenCalledTimes(2);
936
+ expect(provider.get).toHaveBeenCalledTimes(2);
937
+ expect(provider.set).not.toHaveBeenCalled();
938
+ });
939
+ });