@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.
- package/CHANGELOG.md +18 -0
- package/README.md +270 -131
- package/dist/adapters/ioredis-cache-client.d.ts +9 -0
- package/dist/adapters/ioredis-cache-client.d.ts.map +1 -0
- package/dist/adapters/ioredis-cache-client.js +73 -0
- package/dist/adapters/ioredis-cache-client.js.map +1 -0
- package/dist/adapters/node-redis-cache-client.d.ts +10 -0
- package/dist/adapters/node-redis-cache-client.d.ts.map +1 -0
- package/dist/adapters/node-redis-cache-client.js +81 -0
- package/dist/adapters/node-redis-cache-client.js.map +1 -0
- package/dist/adapters/redis-key-prefix.d.ts +22 -0
- package/dist/adapters/redis-key-prefix.d.ts.map +1 -0
- package/dist/adapters/redis-key-prefix.js +41 -0
- package/dist/adapters/redis-key-prefix.js.map +1 -0
- package/dist/core/cache-logger.d.ts +28 -0
- package/dist/core/cache-logger.d.ts.map +1 -0
- package/dist/core/cache-logger.js +70 -0
- package/dist/core/cache-logger.js.map +1 -0
- package/dist/core/cache-provider-registry.d.ts.map +1 -1
- package/dist/core/cache-provider-registry.js.map +1 -1
- package/dist/core/cache-provider.d.ts.map +1 -1
- package/dist/core/key-builder.d.ts.map +1 -1
- package/dist/core/key-builder.js +3 -2
- package/dist/core/key-builder.js.map +1 -1
- package/dist/core/native-cache.d.ts.map +1 -1
- package/dist/core/native-cache.js +2 -1
- package/dist/core/native-cache.js.map +1 -1
- package/dist/core/pending-cache.d.ts.map +1 -1
- package/dist/core/pending-cache.js +6 -3
- package/dist/core/pending-cache.js.map +1 -1
- package/dist/core/redis-cache-client.d.ts +156 -0
- package/dist/core/redis-cache-client.d.ts.map +1 -0
- package/dist/core/redis-cache-client.js +3 -0
- package/dist/core/redis-cache-client.js.map +1 -0
- package/dist/core/redis-cache.d.ts +39 -7
- package/dist/core/redis-cache.d.ts.map +1 -1
- package/dist/core/redis-cache.js +63 -22
- package/dist/core/redis-cache.js.map +1 -1
- package/dist/decorators/cache-evict.d.ts +1 -1
- package/dist/decorators/cache-evict.d.ts.map +1 -1
- package/dist/decorators/cache-evict.js +68 -14
- package/dist/decorators/cache-evict.js.map +1 -1
- package/dist/decorators/cache.d.ts +1 -1
- package/dist/decorators/cache.d.ts.map +1 -1
- package/dist/decorators/cache.js +85 -23
- package/dist/decorators/cache.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/jest.config.js +11 -11
- package/package.json +26 -29
- package/src/adapters/ioredis-cache-client.ts +89 -0
- package/src/adapters/node-redis-cache-client.ts +95 -0
- package/src/adapters/redis-key-prefix.ts +38 -0
- package/src/core/cache-logger.ts +105 -0
- package/src/core/key-builder.ts +33 -31
- package/src/core/native-cache.ts +55 -53
- package/src/core/pending-cache.ts +29 -26
- package/src/core/redis-cache-client.ts +160 -0
- package/src/core/redis-cache.ts +104 -56
- package/src/decorators/cache-evict.ts +129 -69
- package/src/decorators/cache.ts +203 -125
- package/src/index.ts +11 -8
- package/test/cache-evict-logging.test.ts +362 -0
- package/test/cache-logger.integration.test.ts +129 -0
- package/test/cache-logger.test.ts +153 -0
- package/test/cache-logging.test.ts +544 -0
- package/test/cache.test.ts +255 -256
- package/test/fixtures/cache-logger-child.mjs +108 -0
- package/test/helpers/legacy-redis-cache.ts +22 -0
- package/test/helpers/package-consumer.ts +231 -0
- package/test/helpers/redis-fixture.ts +142 -0
- package/test/ioredis-cache-client.test.ts +143 -0
- package/test/native-cache.test.ts +77 -71
- package/test/node-redis-cache-client.test.ts +149 -0
- package/test/pending-cache.test.ts +69 -58
- package/test/redis-cache-client-lifecycle.test.ts +112 -0
- package/test/redis-cache-client-types.test.ts +184 -0
- package/test/redis-cache-client.integration.test.ts +327 -0
- package/test/redis-cache-decorator.test.ts +201 -0
- package/test/redis-cache-package.integration.test.ts +328 -0
- package/test/redis-cache-provider.test.ts +269 -0
- package/test/type-contract/contract.ts +64 -0
- package/test/type-contract/tsconfig.json +12 -0
- package/tsconfig.json +8 -8
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
createPackageTestRoot,
|
|
6
|
+
installConsumer,
|
|
7
|
+
PackageConsumer,
|
|
8
|
+
packCurrentPackage,
|
|
9
|
+
packLoggerPackage,
|
|
10
|
+
packageRoot,
|
|
11
|
+
readReadmeExample,
|
|
12
|
+
removePackageTestRoot,
|
|
13
|
+
runCommand,
|
|
14
|
+
} from './helpers/package-consumer';
|
|
15
|
+
|
|
16
|
+
jest.setTimeout(180000);
|
|
17
|
+
|
|
18
|
+
const sources = {
|
|
19
|
+
none: `
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { LoggerFactory } from '@jintianxiayu/logger';
|
|
22
|
+
import {
|
|
23
|
+
Cache, CacheProviderRegistry, MemoryCacheProvider, RedisCacheProvider, createIoredisCacheClient,
|
|
24
|
+
createNodeRedisCacheClient, type CacheProvider, type RedisCacheClient,
|
|
25
|
+
type IoredisCacheClientSource, type NodeRedisCacheClientSource, type NodeRedisCacheClientOptions,
|
|
26
|
+
} from '@jintianxiayu/cache-decorator';
|
|
27
|
+
const values = new Map<string, string>();
|
|
28
|
+
const client: RedisCacheClient = {
|
|
29
|
+
get: async (key) => values.get(key) ?? null,
|
|
30
|
+
set: async ({ key, value }) => { values.set(key, value); },
|
|
31
|
+
deleteMany: async (keys) => { for (const key of keys) { values.delete(key); } },
|
|
32
|
+
scan: async () => ({ cursor: '0', keys: [...values.keys()] }),
|
|
33
|
+
flushDatabase: async () => { values.clear(); },
|
|
34
|
+
};
|
|
35
|
+
const custom: CacheProvider = {
|
|
36
|
+
get: async () => undefined, set: async () => undefined, delete: async () => undefined,
|
|
37
|
+
clear: async () => undefined, deleteByPattern: async () => undefined,
|
|
38
|
+
};
|
|
39
|
+
export type Sources = IoredisCacheClientSource | NodeRedisCacheClientSource;
|
|
40
|
+
export type Options = NodeRedisCacheClientOptions;
|
|
41
|
+
async function main(): Promise<void> {
|
|
42
|
+
LoggerFactory.init({
|
|
43
|
+
root: { console: { enabled: false }, file: { enabled: false } },
|
|
44
|
+
processErrors: { uncaughtException: false, unhandledRejection: false, exitOnError: false },
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
assert.equal(typeof createIoredisCacheClient, 'function');
|
|
48
|
+
assert.equal(typeof createNodeRedisCacheClient, 'function');
|
|
49
|
+
const memory = new MemoryCacheProvider();
|
|
50
|
+
await memory.set('memory-key', { source: 'memory' });
|
|
51
|
+
assert.deepEqual(await memory.get('memory-key'), { source: 'memory' });
|
|
52
|
+
CacheProviderRegistry.register('memory', memory);
|
|
53
|
+
let calls = 0;
|
|
54
|
+
class Service {
|
|
55
|
+
@Cache('external-memory', { providerName: 'memory' })
|
|
56
|
+
getValue(id: number): { id: number } {
|
|
57
|
+
calls += 1;
|
|
58
|
+
return { id };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const service = new Service();
|
|
62
|
+
assert.deepEqual(await service.getValue(1), { id: 1 });
|
|
63
|
+
assert.deepEqual(await service.getValue(1), { id: 1 });
|
|
64
|
+
assert.equal(calls, 1);
|
|
65
|
+
|
|
66
|
+
const redis = new RedisCacheProvider(client);
|
|
67
|
+
await redis.set('redis-key', { source: 'custom-client' });
|
|
68
|
+
assert.deepEqual(await redis.get('redis-key'), { source: 'custom-client' });
|
|
69
|
+
CacheProviderRegistry.register('custom', custom);
|
|
70
|
+
CacheProviderRegistry.setDefault('custom');
|
|
71
|
+
assert.equal(await CacheProviderRegistry.get().get('key'), undefined);
|
|
72
|
+
} finally {
|
|
73
|
+
CacheProviderRegistry.clear();
|
|
74
|
+
await LoggerFactory.shutdown({ timeout: 2_000 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
main().catch((error: unknown) => { console.error(error); process.exitCode = 1; });
|
|
78
|
+
`,
|
|
79
|
+
redis: `
|
|
80
|
+
import assert from 'node:assert/strict';
|
|
81
|
+
import { randomUUID } from 'node:crypto';
|
|
82
|
+
import { createClient } from 'redis';
|
|
83
|
+
import { RedisCacheProvider, createNodeRedisCacheClient } from '@jintianxiayu/cache-decorator';
|
|
84
|
+
const redis = createClient();
|
|
85
|
+
const provider = new RedisCacheProvider(createNodeRedisCacheClient(redis));
|
|
86
|
+
assert.ok(provider instanceof RedisCacheProvider);
|
|
87
|
+
assert.equal(redis.isOpen, false);
|
|
88
|
+
async function runCache(): Promise<void> {
|
|
89
|
+
const url = process.env.CACHE_DECORATOR_TEST_REDIS_URL;
|
|
90
|
+
assert.ok(url);
|
|
91
|
+
const connection = createClient({
|
|
92
|
+
url, disableOfflineQueue: true, socket: { connectTimeout: 1000, reconnectStrategy: false },
|
|
93
|
+
});
|
|
94
|
+
const errors: Error[] = [];
|
|
95
|
+
connection.on('error', (error: Error) => errors.push(error));
|
|
96
|
+
const key = 'cache-consumer:' + randomUUID();
|
|
97
|
+
try {
|
|
98
|
+
await connection.connect();
|
|
99
|
+
const cache = new RedisCacheProvider(createNodeRedisCacheClient(connection));
|
|
100
|
+
await cache.set(key, { source: 'node-redis' }, 30);
|
|
101
|
+
assert.deepEqual(await cache.get(key), { source: 'node-redis' });
|
|
102
|
+
await cache.delete(key);
|
|
103
|
+
assert.equal(await cache.get(key), undefined);
|
|
104
|
+
assert.equal(await connection.ping(), 'PONG');
|
|
105
|
+
assert.deepEqual(errors, []);
|
|
106
|
+
} finally {
|
|
107
|
+
if (connection.isOpen) {
|
|
108
|
+
try { await connection.del(key); }
|
|
109
|
+
finally { connection.destroy(); }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (process.argv[2] === 'cache') {
|
|
114
|
+
runCache().catch((error: unknown) => { console.error(error); process.exitCode = 1; });
|
|
115
|
+
}
|
|
116
|
+
`,
|
|
117
|
+
ioredis: `
|
|
118
|
+
import assert from 'node:assert/strict';
|
|
119
|
+
import { randomUUID } from 'node:crypto';
|
|
120
|
+
import Redis from 'ioredis';
|
|
121
|
+
import { RedisCacheProvider, createIoredisCacheClient } from '@jintianxiayu/cache-decorator';
|
|
122
|
+
const redis = new Redis({ lazyConnect: true });
|
|
123
|
+
const provider = new RedisCacheProvider(createIoredisCacheClient(redis));
|
|
124
|
+
assert.ok(provider instanceof RedisCacheProvider);
|
|
125
|
+
assert.equal(redis.status, 'wait');
|
|
126
|
+
async function runCache(): Promise<void> {
|
|
127
|
+
const url = process.env.CACHE_DECORATOR_TEST_REDIS_URL;
|
|
128
|
+
assert.ok(url);
|
|
129
|
+
const connection = new Redis(url, {
|
|
130
|
+
lazyConnect: true, enableOfflineQueue: false, connectTimeout: 1000, commandTimeout: 1000,
|
|
131
|
+
retryStrategy: () => null,
|
|
132
|
+
});
|
|
133
|
+
const errors: Error[] = [];
|
|
134
|
+
connection.on('error', (error: Error) => errors.push(error));
|
|
135
|
+
const key = 'cache-consumer:' + randomUUID();
|
|
136
|
+
try {
|
|
137
|
+
await connection.connect();
|
|
138
|
+
const cache = new RedisCacheProvider(createIoredisCacheClient(connection));
|
|
139
|
+
await cache.set(key, { source: 'ioredis' }, 30);
|
|
140
|
+
assert.deepEqual(await cache.get(key), { source: 'ioredis' });
|
|
141
|
+
await cache.delete(key);
|
|
142
|
+
assert.equal(await cache.get(key), undefined);
|
|
143
|
+
assert.equal(await connection.ping(), 'PONG');
|
|
144
|
+
assert.deepEqual(errors, []);
|
|
145
|
+
} finally {
|
|
146
|
+
try { if (connection.status === 'ready') { await connection.del(key); } }
|
|
147
|
+
finally { connection.disconnect(); }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (process.argv[2] === 'cache') {
|
|
151
|
+
runCache().catch((error: unknown) => { console.error(error); process.exitCode = 1; });
|
|
152
|
+
}
|
|
153
|
+
`,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let root: string;
|
|
157
|
+
let consumers: Record<'none' | 'redis' | 'ioredis', PackageConsumer>;
|
|
158
|
+
|
|
159
|
+
/** 在干净 Node 子进程内检查依赖解析,避免 Jest 启动脚本的 NODE_PATH 干扰。 */
|
|
160
|
+
function clientResolution(consumer: PackageConsumer): Record<string, string | null> {
|
|
161
|
+
return JSON.parse(
|
|
162
|
+
runCommand({
|
|
163
|
+
executable: process.execPath,
|
|
164
|
+
cwd: consumer.directory,
|
|
165
|
+
args: [
|
|
166
|
+
'-e',
|
|
167
|
+
`
|
|
168
|
+
const results = {};
|
|
169
|
+
for (const name of ['redis', 'ioredis']) {
|
|
170
|
+
try { results[name] = require.resolve(name); }
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== 'MODULE_NOT_FOUND') { throw error; }
|
|
173
|
+
results[name] = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
process.stdout.write(JSON.stringify(results));
|
|
177
|
+
`,
|
|
178
|
+
],
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
beforeAll(() => {
|
|
184
|
+
root = createPackageTestRoot();
|
|
185
|
+
const archive = packCurrentPackage(root);
|
|
186
|
+
const loggerArchive = packLoggerPackage(root);
|
|
187
|
+
consumers = {
|
|
188
|
+
none: installConsumer({
|
|
189
|
+
root,
|
|
190
|
+
archive,
|
|
191
|
+
loggerArchive,
|
|
192
|
+
client: 'none',
|
|
193
|
+
source: sources.none,
|
|
194
|
+
readmeSource: readReadmeExample('自定义 RedisCacheClient'),
|
|
195
|
+
quickStartSource: readReadmeExample('快速开始', 2),
|
|
196
|
+
}),
|
|
197
|
+
redis: installConsumer({
|
|
198
|
+
root,
|
|
199
|
+
archive,
|
|
200
|
+
loggerArchive,
|
|
201
|
+
client: 'redis',
|
|
202
|
+
source: sources.redis,
|
|
203
|
+
readmeSource: readReadmeExample('node-redis'),
|
|
204
|
+
quickStartSource: readReadmeExample('快速开始', 2),
|
|
205
|
+
}),
|
|
206
|
+
ioredis: installConsumer({
|
|
207
|
+
root,
|
|
208
|
+
archive,
|
|
209
|
+
loggerArchive,
|
|
210
|
+
client: 'ioredis',
|
|
211
|
+
source: sources.ioredis,
|
|
212
|
+
readmeSource: readReadmeExample('ioredis'),
|
|
213
|
+
quickStartSource: readReadmeExample('快速开始', 2),
|
|
214
|
+
}),
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('cache-operation-logging/P01 外部严格消费项目显式提供并共享唯一 Logger peer', () => {
|
|
219
|
+
for (const consumer of Object.values(consumers)) {
|
|
220
|
+
const requireFromConsumer = createRequire(join(consumer.directory, 'package.json'));
|
|
221
|
+
const requireFromCache = createRequire(join(consumer.installedPackage, 'package.json'));
|
|
222
|
+
expect(realpathSync(requireFromCache.resolve('@jintianxiayu/logger'))).toBe(
|
|
223
|
+
realpathSync(requireFromConsumer.resolve('@jintianxiayu/logger'))
|
|
224
|
+
);
|
|
225
|
+
expect(consumer.dependencyTree).toContain('"@jintianxiayu/logger"');
|
|
226
|
+
expect(consumer.dependencyTree).toContain('"reflect-metadata"');
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('cache-operation-logging/P02 发布 manifest 将 Logger workspace peer 转为实际 semver', () => {
|
|
231
|
+
const manifest = JSON.parse(readFileSync(join(consumers.none.installedPackage, 'package.json'), 'utf8')) as {
|
|
232
|
+
readonly dependencies?: Record<string, string>;
|
|
233
|
+
readonly peerDependencies?: Record<string, string>;
|
|
234
|
+
};
|
|
235
|
+
const loggerManifest = JSON.parse(readFileSync(join(packageRoot, '../logger/package.json'), 'utf8')) as {
|
|
236
|
+
readonly version: string;
|
|
237
|
+
};
|
|
238
|
+
expect(manifest.peerDependencies?.['@jintianxiayu/logger']).toBe(`^${loggerManifest.version}`);
|
|
239
|
+
expect(manifest.peerDependencies?.['@jintianxiayu/logger']).not.toContain('workspace:');
|
|
240
|
+
expect(manifest.dependencies?.['@jintianxiayu/logger']).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
afterAll(() => {
|
|
244
|
+
if (root) {
|
|
245
|
+
removePackageTestRoot(root);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('redis-cache-client/P01 不安装 Redis 客户端也能使用非 Redis Provider', () => {
|
|
250
|
+
const consumer = consumers.none;
|
|
251
|
+
expect(clientResolution(consumer)).toEqual({ ioredis: null, redis: null });
|
|
252
|
+
expect(consumer.dependencyTree).not.toContain('"ioredis":');
|
|
253
|
+
expect(consumer.dependencyTree).not.toContain('"redis":');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('redis-cache-client/P02 仅安装 node-redis 的消费项目', () => {
|
|
257
|
+
const consumer = consumers.redis;
|
|
258
|
+
const resolution = clientResolution(consumer);
|
|
259
|
+
expect(resolution.redis?.startsWith(consumer.directory)).toBe(true);
|
|
260
|
+
expect(resolution.ioredis).toBeNull();
|
|
261
|
+
expect(consumer.dependencyTree).toContain('"redis":');
|
|
262
|
+
expect(consumer.dependencyTree).not.toContain('"ioredis":');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('redis-cache-client/P03 仅安装 ioredis 的消费项目', () => {
|
|
266
|
+
const consumer = consumers.ioredis;
|
|
267
|
+
const resolution = clientResolution(consumer);
|
|
268
|
+
expect(resolution.ioredis?.startsWith(consumer.directory)).toBe(true);
|
|
269
|
+
expect(resolution.redis).toBeNull();
|
|
270
|
+
expect(consumer.dependencyTree).toContain('"ioredis":');
|
|
271
|
+
expect(consumer.dependencyTree).not.toContain('"redis":');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const redisTest = process.env.CACHE_DECORATOR_TEST_REDIS_URL ? it : it.skip;
|
|
275
|
+
|
|
276
|
+
redisTest('redis-cache-client/P02 仅 node-redis 的消费项目执行真实缓存操作', () => {
|
|
277
|
+
const consumer = consumers.redis;
|
|
278
|
+
expect(
|
|
279
|
+
runCommand({
|
|
280
|
+
executable: process.execPath,
|
|
281
|
+
args: [join(consumer.directory, 'out/consumer.js'), 'cache'],
|
|
282
|
+
cwd: consumer.directory,
|
|
283
|
+
})
|
|
284
|
+
).toBe('');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
redisTest('redis-cache-client/P03 仅 ioredis 的消费项目执行真实缓存操作', () => {
|
|
288
|
+
const consumer = consumers.ioredis;
|
|
289
|
+
expect(
|
|
290
|
+
runCommand({
|
|
291
|
+
executable: process.execPath,
|
|
292
|
+
args: [join(consumer.directory, 'out/consumer.js'), 'cache'],
|
|
293
|
+
cwd: consumer.directory,
|
|
294
|
+
})
|
|
295
|
+
).toBe('');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('redis-cache-client/P04 公共入口与声明不泄漏第三方客户端', () => {
|
|
299
|
+
const consumer = consumers.none;
|
|
300
|
+
const requireFromConsumer = createRequire(join(consumer.directory, 'package.json'));
|
|
301
|
+
const api: Record<string, unknown> = requireFromConsumer('@jintianxiayu/cache-decorator');
|
|
302
|
+
expect(Object.keys(api).sort()).toEqual(
|
|
303
|
+
[
|
|
304
|
+
'Cache',
|
|
305
|
+
'CacheEvict',
|
|
306
|
+
'CacheProviderRegistry',
|
|
307
|
+
'KeyBuilder',
|
|
308
|
+
'MemoryCacheProvider',
|
|
309
|
+
'PendingCache',
|
|
310
|
+
'RedisCacheProvider',
|
|
311
|
+
'createIoredisCacheClient',
|
|
312
|
+
'createNodeRedisCacheClient',
|
|
313
|
+
].sort()
|
|
314
|
+
);
|
|
315
|
+
const manifest = JSON.parse(readFileSync(join(consumer.installedPackage, 'package.json'), 'utf8'));
|
|
316
|
+
for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
317
|
+
expect(manifest[field]?.ioredis).toBeUndefined();
|
|
318
|
+
expect(manifest[field]?.redis).toBeUndefined();
|
|
319
|
+
}
|
|
320
|
+
const dist = join(consumer.installedPackage, 'dist');
|
|
321
|
+
const externalClient = /(?:from\s*|require\s*\(\s*|import\s*\(\s*)['"](?:ioredis|redis)(?:\/|['"])/;
|
|
322
|
+
for (const entry of readdirSync(dist, { recursive: true })) {
|
|
323
|
+
const name = String(entry);
|
|
324
|
+
if (name.endsWith('.js') || name.endsWith('.d.ts')) {
|
|
325
|
+
expect(readFileSync(join(dist, name), 'utf8')).not.toMatch(externalClient);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
@@ -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;
|