@expo/entity-cache-adapter-redis 0.62.0 → 0.64.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/README.md +2 -8
- package/build/src/GenericRedisCacher.d.ts +8 -3
- package/build/src/GenericRedisCacher.js +34 -6
- package/package.json +19 -19
- package/src/GenericRedisCacher.ts +53 -14
- package/src/__integration-tests__/BatchedRedisCacheAdapter-integration-test.ts +1 -7
- package/src/__integration-tests__/GenericRedisCacher-full-integration-test.ts +1 -7
- package/src/__integration-tests__/GenericRedisCacher-integration-test.ts +1 -7
- package/src/__integration-tests__/errors-test.ts +1 -7
- package/src/__tests__/GenericRedisCacher-test.ts +139 -18
package/README.md
CHANGED
|
@@ -13,13 +13,7 @@ import Redis from 'ioredis';
|
|
|
13
13
|
|
|
14
14
|
const genericRedisCacherContext = {
|
|
15
15
|
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
|
16
|
-
|
|
17
|
-
const delimiter = ':';
|
|
18
|
-
const escapedParts = parts.map((part) =>
|
|
19
|
-
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`)
|
|
20
|
-
);
|
|
21
|
-
return escapedParts.join(delimiter);
|
|
22
|
-
},
|
|
16
|
+
cacheKeyDelimiter: ':',
|
|
23
17
|
cacheKeyPrefix: 'ent-',
|
|
24
18
|
ttlSecondsPositive: 86400, // 1 day
|
|
25
19
|
ttlSecondsNegative: 600, // 10 minutes
|
|
@@ -40,4 +34,4 @@ export const createDefaultEntityCompanionProvider = (
|
|
|
40
34
|
}
|
|
41
35
|
);
|
|
42
36
|
};
|
|
43
|
-
```
|
|
37
|
+
```
|
|
@@ -55,10 +55,15 @@ export interface GenericRedisCacheContext {
|
|
|
55
55
|
*/
|
|
56
56
|
redisClient: IRedis;
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
58
|
+
* Delimiter used to join the parts of a Redis cache key (cache key prefix,
|
|
59
|
+
* versions, entity name, field values, etc). Typically `:`.
|
|
60
|
+
*
|
|
61
|
+
* The cacher escapes occurrences of this delimiter (and the escape character `\`)
|
|
62
|
+
* within each part before joining, so the encoding is injective regardless of
|
|
63
|
+
* what the parts contain. This prevents a value that happens to contain the
|
|
64
|
+
* delimiter from colliding with a different (key, value) pair.
|
|
60
65
|
*/
|
|
61
|
-
|
|
66
|
+
cacheKeyDelimiter: string;
|
|
62
67
|
/**
|
|
63
68
|
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
|
|
64
69
|
* distintion for entity keys, e.g. `ent-`
|
|
@@ -27,12 +27,27 @@ export var RedisCacheInvalidationStrategy;
|
|
|
27
27
|
*/
|
|
28
28
|
RedisCacheInvalidationStrategy["CUSTOM"] = "custom";
|
|
29
29
|
})(RedisCacheInvalidationStrategy || (RedisCacheInvalidationStrategy = {}));
|
|
30
|
+
function validateGenericRedisCacheContext(context) {
|
|
31
|
+
if (context.cacheKeyDelimiter.length === 0) {
|
|
32
|
+
throw new Error('GenericRedisCacheContext.cacheKeyDelimiter must be a non-empty string');
|
|
33
|
+
}
|
|
34
|
+
if (context.cacheKeyDelimiter.includes('\\')) {
|
|
35
|
+
throw new Error(`GenericRedisCacheContext.cacheKeyDelimiter must not contain the escape character "\\" (got ${JSON.stringify(context.cacheKeyDelimiter)})`);
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isInteger(context.ttlSecondsPositive) || context.ttlSecondsPositive <= 0) {
|
|
38
|
+
throw new Error(`GenericRedisCacheContext.ttlSecondsPositive must be a positive integer (got ${context.ttlSecondsPositive})`);
|
|
39
|
+
}
|
|
40
|
+
if (!Number.isInteger(context.ttlSecondsNegative) || context.ttlSecondsNegative <= 0) {
|
|
41
|
+
throw new Error(`GenericRedisCacheContext.ttlSecondsNegative must be a positive integer (got ${context.ttlSecondsNegative})`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
30
44
|
export class GenericRedisCacher {
|
|
31
45
|
context;
|
|
32
46
|
entityConfiguration;
|
|
33
47
|
constructor(context, entityConfiguration) {
|
|
34
48
|
this.context = context;
|
|
35
49
|
this.entityConfiguration = entityConfiguration;
|
|
50
|
+
validateGenericRedisCacheContext(context);
|
|
36
51
|
}
|
|
37
52
|
async loadManyAsync(keys) {
|
|
38
53
|
if (keys.length === 0) {
|
|
@@ -90,8 +105,18 @@ export class GenericRedisCacher {
|
|
|
90
105
|
}
|
|
91
106
|
makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion) {
|
|
92
107
|
const cacheKeyType = key.getLoadMethodType();
|
|
93
|
-
const
|
|
94
|
-
|
|
108
|
+
const keyAndValueParts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
|
|
109
|
+
const allParts = [
|
|
110
|
+
this.context.cacheKeyPrefix,
|
|
111
|
+
cacheKeyType,
|
|
112
|
+
this.entityConfiguration.tableName,
|
|
113
|
+
`v4.${cacheKeyVersion}`,
|
|
114
|
+
...keyAndValueParts,
|
|
115
|
+
];
|
|
116
|
+
const delimiter = this.context.cacheKeyDelimiter;
|
|
117
|
+
return allParts
|
|
118
|
+
.map((part) => part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`))
|
|
119
|
+
.join(delimiter);
|
|
95
120
|
}
|
|
96
121
|
makeCacheKeyForStorage(key, value) {
|
|
97
122
|
return this.makeCacheKeyForCacheKeyVersion(key, value, this.entityConfiguration.cacheKeyVersion);
|
|
@@ -104,10 +129,13 @@ export class GenericRedisCacher {
|
|
|
104
129
|
];
|
|
105
130
|
case RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS:
|
|
106
131
|
return getSurroundingCacheKeyVersionsForInvalidation(this.entityConfiguration.cacheKeyVersion).map((cacheKeyVersion) => this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion));
|
|
107
|
-
case RedisCacheInvalidationStrategy.CUSTOM:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.
|
|
132
|
+
case RedisCacheInvalidationStrategy.CUSTOM: {
|
|
133
|
+
const cacheKeyVersions = this.context.invalidationConfig.cacheKeyVersionsToInvalidateFn(this.entityConfiguration.cacheKeyVersion);
|
|
134
|
+
if (cacheKeyVersions.length === 0) {
|
|
135
|
+
throw new Error(`GenericRedisCacheContext.invalidationConfig.cacheKeyVersionsToInvalidateFn returned an empty list for cacheKeyVersion ${this.entityConfiguration.cacheKeyVersion}; this would silently disable invalidation`);
|
|
136
|
+
}
|
|
137
|
+
return cacheKeyVersions.map((cacheKeyVersion) => this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion));
|
|
138
|
+
}
|
|
111
139
|
}
|
|
112
140
|
}
|
|
113
141
|
}
|
package/package.json
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/entity-cache-adapter-redis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
4
4
|
"description": "Redis cache adapter for @expo/entity",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"entity"
|
|
7
|
+
],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "Expo",
|
|
5
10
|
"files": [
|
|
6
11
|
"build",
|
|
7
12
|
"!*.tsbuildinfo",
|
|
8
13
|
"!__*",
|
|
9
14
|
"src"
|
|
10
15
|
],
|
|
16
|
+
"type": "module",
|
|
11
17
|
"main": "build/src/index.js",
|
|
12
18
|
"types": "build/src/index.d.ts",
|
|
13
19
|
"scripts": {
|
|
14
20
|
"build": "tsc --build",
|
|
15
21
|
"prepack": "rm -rf build && yarn build",
|
|
16
22
|
"clean": "yarn build --clean",
|
|
17
|
-
"lint": "yarn run --top-level
|
|
23
|
+
"lint": "yarn run --top-level oxlint --type-aware src",
|
|
18
24
|
"lint-fix": "yarn lint --fix",
|
|
19
25
|
"test": "yarn test:all --rootDir $(pwd)",
|
|
20
26
|
"integration": "yarn integration:all --rootDir $(pwd)"
|
|
21
27
|
},
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=18"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"entity"
|
|
27
|
-
],
|
|
28
|
-
"author": "Expo",
|
|
29
|
-
"license": "MIT",
|
|
30
|
-
"type": "module",
|
|
31
28
|
"dependencies": {
|
|
32
|
-
"@expo/entity": "^0.
|
|
33
|
-
},
|
|
34
|
-
"peerDependencies": {
|
|
35
|
-
"ioredis": ">=5"
|
|
29
|
+
"@expo/entity": "^0.64.0"
|
|
36
30
|
},
|
|
37
31
|
"devDependencies": {
|
|
38
32
|
"@expo/batcher": "1.0.0",
|
|
39
|
-
"@expo/entity-testing-utils": "^0.
|
|
33
|
+
"@expo/entity-testing-utils": "^0.64.0",
|
|
40
34
|
"@jest/globals": "30.3.0",
|
|
41
|
-
"ioredis": "5.10.
|
|
35
|
+
"ioredis": "5.10.1",
|
|
42
36
|
"ts-mockito": "2.6.1",
|
|
43
|
-
"typescript": "
|
|
37
|
+
"typescript": "6.0.3"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"ioredis": ">=5"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "3f10a9e70eab45ae95acdae133055d8a3ec04ce8"
|
|
46
46
|
}
|
|
@@ -79,6 +79,27 @@ export type GenericRedisCacheInvalidationConfig =
|
|
|
79
79
|
cacheKeyVersionsToInvalidateFn: (cacheKeyVersion: number) => readonly number[];
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
function validateGenericRedisCacheContext(context: GenericRedisCacheContext): void {
|
|
83
|
+
if (context.cacheKeyDelimiter.length === 0) {
|
|
84
|
+
throw new Error('GenericRedisCacheContext.cacheKeyDelimiter must be a non-empty string');
|
|
85
|
+
}
|
|
86
|
+
if (context.cacheKeyDelimiter.includes('\\')) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`GenericRedisCacheContext.cacheKeyDelimiter must not contain the escape character "\\" (got ${JSON.stringify(context.cacheKeyDelimiter)})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(context.ttlSecondsPositive) || context.ttlSecondsPositive <= 0) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`GenericRedisCacheContext.ttlSecondsPositive must be a positive integer (got ${context.ttlSecondsPositive})`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!Number.isInteger(context.ttlSecondsNegative) || context.ttlSecondsNegative <= 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`GenericRedisCacheContext.ttlSecondsNegative must be a positive integer (got ${context.ttlSecondsNegative})`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
export interface GenericRedisCacheContext {
|
|
83
104
|
/**
|
|
84
105
|
* Instance of ioredis.Redis
|
|
@@ -86,10 +107,15 @@ export interface GenericRedisCacheContext {
|
|
|
86
107
|
redisClient: IRedis;
|
|
87
108
|
|
|
88
109
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
110
|
+
* Delimiter used to join the parts of a Redis cache key (cache key prefix,
|
|
111
|
+
* versions, entity name, field values, etc). Typically `:`.
|
|
112
|
+
*
|
|
113
|
+
* The cacher escapes occurrences of this delimiter (and the escape character `\`)
|
|
114
|
+
* within each part before joining, so the encoding is injective regardless of
|
|
115
|
+
* what the parts contain. This prevents a value that happens to contain the
|
|
116
|
+
* delimiter from colliding with a different (key, value) pair.
|
|
91
117
|
*/
|
|
92
|
-
|
|
118
|
+
cacheKeyDelimiter: string;
|
|
93
119
|
|
|
94
120
|
/**
|
|
95
121
|
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
|
|
@@ -122,7 +148,9 @@ export class GenericRedisCacher<
|
|
|
122
148
|
constructor(
|
|
123
149
|
private readonly context: GenericRedisCacheContext,
|
|
124
150
|
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
|
125
|
-
) {
|
|
151
|
+
) {
|
|
152
|
+
validateGenericRedisCacheContext(context);
|
|
153
|
+
}
|
|
126
154
|
|
|
127
155
|
public async loadManyAsync(
|
|
128
156
|
keys: readonly string[],
|
|
@@ -212,14 +240,18 @@ export class GenericRedisCacher<
|
|
|
212
240
|
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
|
|
213
241
|
>(key: TLoadKey, value: TLoadValue, cacheKeyVersion: number): string {
|
|
214
242
|
const cacheKeyType = key.getLoadMethodType();
|
|
215
|
-
const
|
|
216
|
-
|
|
243
|
+
const keyAndValueParts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
|
|
244
|
+
const allParts = [
|
|
217
245
|
this.context.cacheKeyPrefix,
|
|
218
246
|
cacheKeyType,
|
|
219
247
|
this.entityConfiguration.tableName,
|
|
220
|
-
`
|
|
221
|
-
...
|
|
222
|
-
|
|
248
|
+
`v4.${cacheKeyVersion}`,
|
|
249
|
+
...keyAndValueParts,
|
|
250
|
+
];
|
|
251
|
+
const delimiter = this.context.cacheKeyDelimiter;
|
|
252
|
+
return allParts
|
|
253
|
+
.map((part) => part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`))
|
|
254
|
+
.join(delimiter);
|
|
223
255
|
}
|
|
224
256
|
|
|
225
257
|
public makeCacheKeyForStorage<
|
|
@@ -250,12 +282,19 @@ export class GenericRedisCacher<
|
|
|
250
282
|
).map((cacheKeyVersion) =>
|
|
251
283
|
this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
|
|
252
284
|
);
|
|
253
|
-
case RedisCacheInvalidationStrategy.CUSTOM:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
285
|
+
case RedisCacheInvalidationStrategy.CUSTOM: {
|
|
286
|
+
const cacheKeyVersions = this.context.invalidationConfig.cacheKeyVersionsToInvalidateFn(
|
|
287
|
+
this.entityConfiguration.cacheKeyVersion,
|
|
288
|
+
);
|
|
289
|
+
if (cacheKeyVersions.length === 0) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`GenericRedisCacheContext.invalidationConfig.cacheKeyVersionsToInvalidateFn returned an empty list for cacheKeyVersion ${this.entityConfiguration.cacheKeyVersion}; this would silently disable invalidation`,
|
|
258
292
|
);
|
|
293
|
+
}
|
|
294
|
+
return cacheKeyVersions.map((cacheKeyVersion) =>
|
|
295
|
+
this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
259
298
|
}
|
|
260
299
|
}
|
|
261
300
|
}
|
|
@@ -68,13 +68,7 @@ describe(GenericRedisCacher, () => {
|
|
|
68
68
|
beforeAll(() => {
|
|
69
69
|
genericRedisCacheContext = {
|
|
70
70
|
redisClient,
|
|
71
|
-
|
|
72
|
-
const delimiter = ':';
|
|
73
|
-
const escapedParts = parts.map((part) =>
|
|
74
|
-
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
|
75
|
-
);
|
|
76
|
-
return escapedParts.join(delimiter);
|
|
77
|
-
},
|
|
71
|
+
cacheKeyDelimiter: ':',
|
|
78
72
|
cacheKeyPrefix: 'test-',
|
|
79
73
|
ttlSecondsPositive: 86400, // 1 day
|
|
80
74
|
ttlSecondsNegative: 600, // 10 minutes
|
|
@@ -26,13 +26,7 @@ describe(GenericRedisCacher, () => {
|
|
|
26
26
|
beforeAll(() => {
|
|
27
27
|
genericRedisCacheContext = {
|
|
28
28
|
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
|
29
|
-
|
|
30
|
-
const delimiter = ':';
|
|
31
|
-
const escapedParts = parts.map((part) =>
|
|
32
|
-
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
|
33
|
-
);
|
|
34
|
-
return escapedParts.join(delimiter);
|
|
35
|
-
},
|
|
29
|
+
cacheKeyDelimiter: ':',
|
|
36
30
|
cacheKeyPrefix: 'test-',
|
|
37
31
|
ttlSecondsPositive: 86400, // 1 day
|
|
38
32
|
ttlSecondsNegative: 600, // 10 minutes
|
|
@@ -20,13 +20,7 @@ describe(GenericRedisCacher, () => {
|
|
|
20
20
|
beforeAll(() => {
|
|
21
21
|
genericRedisCacheContext = {
|
|
22
22
|
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
|
23
|
-
|
|
24
|
-
const delimiter = ':';
|
|
25
|
-
const escapedParts = parts.map((part) =>
|
|
26
|
-
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
|
27
|
-
);
|
|
28
|
-
return escapedParts.join(delimiter);
|
|
29
|
-
},
|
|
23
|
+
cacheKeyDelimiter: ':',
|
|
30
24
|
cacheKeyPrefix: 'test-',
|
|
31
25
|
ttlSecondsPositive: 86400, // 1 day
|
|
32
26
|
ttlSecondsNegative: 600, // 10 minutes
|
|
@@ -17,13 +17,7 @@ describe(GenericRedisCacher, () => {
|
|
|
17
17
|
beforeAll(() => {
|
|
18
18
|
genericRedisCacheContext = {
|
|
19
19
|
redisClient,
|
|
20
|
-
|
|
21
|
-
const delimiter = ':';
|
|
22
|
-
const escapedParts = parts.map((part) =>
|
|
23
|
-
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
|
24
|
-
);
|
|
25
|
-
return escapedParts.join(delimiter);
|
|
26
|
-
},
|
|
20
|
+
cacheKeyDelimiter: ':',
|
|
27
21
|
cacheKeyPrefix: 'test-',
|
|
28
22
|
ttlSecondsPositive: 86400, // 1 day
|
|
29
23
|
ttlSecondsNegative: 600, // 10 minutes
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CacheStatus,
|
|
3
|
+
CompositeFieldHolder,
|
|
4
|
+
CompositeFieldValueHolder,
|
|
3
5
|
EntityConfiguration,
|
|
4
6
|
SingleFieldHolder,
|
|
5
7
|
SingleFieldValueHolder,
|
|
8
|
+
StringField,
|
|
6
9
|
UUIDField,
|
|
7
10
|
} from '@expo/entity';
|
|
8
11
|
import { describe, expect, it } from '@jest/globals';
|
|
@@ -13,6 +16,8 @@ import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRe
|
|
|
13
16
|
|
|
14
17
|
type BlahFields = {
|
|
15
18
|
id: string;
|
|
19
|
+
fieldA: string;
|
|
20
|
+
fieldB: string;
|
|
16
21
|
};
|
|
17
22
|
|
|
18
23
|
const entityConfiguration = new EntityConfiguration<BlahFields, 'id'>({
|
|
@@ -21,6 +26,8 @@ const entityConfiguration = new EntityConfiguration<BlahFields, 'id'>({
|
|
|
21
26
|
cacheKeyVersion: 2,
|
|
22
27
|
schema: {
|
|
23
28
|
id: new UUIDField({ columnName: 'id', cache: true }),
|
|
29
|
+
fieldA: new StringField({ columnName: 'field_a', cache: true }),
|
|
30
|
+
fieldB: new StringField({ columnName: 'field_b', cache: true }),
|
|
24
31
|
},
|
|
25
32
|
databaseAdapterFlavor: 'postgres',
|
|
26
33
|
cacheAdapterFlavor: 'redis',
|
|
@@ -41,7 +48,7 @@ describe(GenericRedisCacher, () => {
|
|
|
41
48
|
const genericCacher = new GenericRedisCacher(
|
|
42
49
|
{
|
|
43
50
|
redisClient: instance(mockRedisClient),
|
|
44
|
-
|
|
51
|
+
cacheKeyDelimiter: ':',
|
|
45
52
|
cacheKeyPrefix: 'hello-',
|
|
46
53
|
ttlSecondsPositive: 1,
|
|
47
54
|
ttlSecondsNegative: 2,
|
|
@@ -83,7 +90,7 @@ describe(GenericRedisCacher, () => {
|
|
|
83
90
|
const genericCacher = new GenericRedisCacher(
|
|
84
91
|
{
|
|
85
92
|
redisClient: instance(mock<Redis>()),
|
|
86
|
-
|
|
93
|
+
cacheKeyDelimiter: ':',
|
|
87
94
|
cacheKeyPrefix: 'hello-',
|
|
88
95
|
ttlSecondsPositive: 1,
|
|
89
96
|
ttlSecondsNegative: 2,
|
|
@@ -118,7 +125,7 @@ describe(GenericRedisCacher, () => {
|
|
|
118
125
|
const genericCacher = new GenericRedisCacher(
|
|
119
126
|
{
|
|
120
127
|
redisClient: instance(mockRedisClient),
|
|
121
|
-
|
|
128
|
+
cacheKeyDelimiter: ':',
|
|
122
129
|
cacheKeyPrefix: 'hello-',
|
|
123
130
|
ttlSecondsPositive: 1,
|
|
124
131
|
ttlSecondsNegative: 2,
|
|
@@ -134,10 +141,12 @@ describe(GenericRedisCacher, () => {
|
|
|
134
141
|
new SingleFieldValueHolder('wat'),
|
|
135
142
|
);
|
|
136
143
|
|
|
137
|
-
|
|
144
|
+
const value = { id: 'wat', fieldA: 'a', fieldB: 'b' };
|
|
145
|
+
|
|
146
|
+
await genericCacher.cacheManyAsync(new Map([[cacheKey, value]]));
|
|
138
147
|
|
|
139
148
|
expect(redisResults.get(cacheKey)).toMatchObject({
|
|
140
|
-
value: JSON.stringify(
|
|
149
|
+
value: JSON.stringify(value),
|
|
141
150
|
code: 'EX',
|
|
142
151
|
ttl: 1,
|
|
143
152
|
});
|
|
@@ -163,7 +172,7 @@ describe(GenericRedisCacher, () => {
|
|
|
163
172
|
const genericCacher = new GenericRedisCacher(
|
|
164
173
|
{
|
|
165
174
|
redisClient: instance(mockRedisClient),
|
|
166
|
-
|
|
175
|
+
cacheKeyDelimiter: ':',
|
|
167
176
|
cacheKeyPrefix: 'hello-',
|
|
168
177
|
ttlSecondsPositive: 1,
|
|
169
178
|
ttlSecondsNegative: 2,
|
|
@@ -197,7 +206,7 @@ describe(GenericRedisCacher, () => {
|
|
|
197
206
|
const genericCacher = new GenericRedisCacher(
|
|
198
207
|
{
|
|
199
208
|
redisClient: instance(mockRedisClient),
|
|
200
|
-
|
|
209
|
+
cacheKeyDelimiter: ':',
|
|
201
210
|
cacheKeyPrefix: 'hello-',
|
|
202
211
|
ttlSecondsPositive: 1,
|
|
203
212
|
ttlSecondsNegative: 2,
|
|
@@ -212,12 +221,40 @@ describe(GenericRedisCacher, () => {
|
|
|
212
221
|
new SingleFieldValueHolder('wat'),
|
|
213
222
|
);
|
|
214
223
|
expect(cacheKeys).toHaveLength(1);
|
|
215
|
-
expect(cacheKeys[0]).toBe('hello-:single:blah:
|
|
224
|
+
expect(cacheKeys[0]).toBe('hello-:single:blah:v4.2:id:wat');
|
|
216
225
|
|
|
217
226
|
await genericCacher.invalidateManyAsync(cacheKeys);
|
|
218
227
|
verify(mockRedisClient.del(...cacheKeys)).once();
|
|
219
228
|
});
|
|
220
229
|
|
|
230
|
+
it('does not collapse composite cache keys with delimiter-bearing parts', () => {
|
|
231
|
+
const genericCacher = new GenericRedisCacher(
|
|
232
|
+
{
|
|
233
|
+
redisClient: instance(mock<Redis>()),
|
|
234
|
+
cacheKeyPrefix: 'hello-',
|
|
235
|
+
cacheKeyDelimiter: ':',
|
|
236
|
+
ttlSecondsPositive: 1,
|
|
237
|
+
ttlSecondsNegative: 2,
|
|
238
|
+
invalidationConfig: {
|
|
239
|
+
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
entityConfiguration,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const compositeFieldHolder = new CompositeFieldHolder<BlahFields, 'id'>(['fieldA', 'fieldB']);
|
|
246
|
+
const cacheKey1 = genericCacher['makeCacheKeyForStorage'](
|
|
247
|
+
compositeFieldHolder,
|
|
248
|
+
new CompositeFieldValueHolder({ fieldA: 'a:b', fieldB: 'c' }),
|
|
249
|
+
);
|
|
250
|
+
const cacheKey2 = genericCacher['makeCacheKeyForStorage'](
|
|
251
|
+
compositeFieldHolder,
|
|
252
|
+
new CompositeFieldValueHolder({ fieldA: 'a', fieldB: 'b:c' }),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
expect(cacheKey1).not.toEqual(cacheKey2);
|
|
256
|
+
});
|
|
257
|
+
|
|
221
258
|
it('invalidates correctly with RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS', async () => {
|
|
222
259
|
const mockRedisClient = mock<Redis>();
|
|
223
260
|
when(mockRedisClient.del()).thenResolve(1);
|
|
@@ -225,7 +262,7 @@ describe(GenericRedisCacher, () => {
|
|
|
225
262
|
const genericCacher = new GenericRedisCacher(
|
|
226
263
|
{
|
|
227
264
|
redisClient: instance(mockRedisClient),
|
|
228
|
-
|
|
265
|
+
cacheKeyDelimiter: ':',
|
|
229
266
|
cacheKeyPrefix: 'hello-',
|
|
230
267
|
ttlSecondsPositive: 1,
|
|
231
268
|
ttlSecondsNegative: 2,
|
|
@@ -240,9 +277,9 @@ describe(GenericRedisCacher, () => {
|
|
|
240
277
|
new SingleFieldValueHolder('wat'),
|
|
241
278
|
);
|
|
242
279
|
expect(cacheKeys).toHaveLength(3);
|
|
243
|
-
expect(cacheKeys[0]).toBe('hello-:single:blah:
|
|
244
|
-
expect(cacheKeys[1]).toBe('hello-:single:blah:
|
|
245
|
-
expect(cacheKeys[2]).toBe('hello-:single:blah:
|
|
280
|
+
expect(cacheKeys[0]).toBe('hello-:single:blah:v4.1:id:wat');
|
|
281
|
+
expect(cacheKeys[1]).toBe('hello-:single:blah:v4.2:id:wat');
|
|
282
|
+
expect(cacheKeys[2]).toBe('hello-:single:blah:v4.3:id:wat');
|
|
246
283
|
|
|
247
284
|
await genericCacher.invalidateManyAsync(cacheKeys);
|
|
248
285
|
verify(mockRedisClient.del(...cacheKeys)).once();
|
|
@@ -255,7 +292,7 @@ describe(GenericRedisCacher, () => {
|
|
|
255
292
|
const genericCacher = new GenericRedisCacher(
|
|
256
293
|
{
|
|
257
294
|
redisClient: instance(mockRedisClient),
|
|
258
|
-
|
|
295
|
+
cacheKeyDelimiter: ':',
|
|
259
296
|
cacheKeyPrefix: 'hello-',
|
|
260
297
|
ttlSecondsPositive: 1,
|
|
261
298
|
ttlSecondsNegative: 2,
|
|
@@ -278,10 +315,10 @@ describe(GenericRedisCacher, () => {
|
|
|
278
315
|
new SingleFieldValueHolder('wat'),
|
|
279
316
|
);
|
|
280
317
|
expect(cacheKeys).toHaveLength(4);
|
|
281
|
-
expect(cacheKeys[0]).toBe('hello-:single:blah:
|
|
282
|
-
expect(cacheKeys[1]).toBe('hello-:single:blah:
|
|
283
|
-
expect(cacheKeys[2]).toBe('hello-:single:blah:
|
|
284
|
-
expect(cacheKeys[3]).toBe('hello-:single:blah:
|
|
318
|
+
expect(cacheKeys[0]).toBe('hello-:single:blah:v4.2:id:wat');
|
|
319
|
+
expect(cacheKeys[1]).toBe('hello-:single:blah:v4.3:id:wat');
|
|
320
|
+
expect(cacheKeys[2]).toBe('hello-:single:blah:v4.4:id:wat');
|
|
321
|
+
expect(cacheKeys[3]).toBe('hello-:single:blah:v4.5:id:wat');
|
|
285
322
|
|
|
286
323
|
await genericCacher.invalidateManyAsync(cacheKeys);
|
|
287
324
|
verify(mockRedisClient.del(...cacheKeys)).once();
|
|
@@ -291,7 +328,7 @@ describe(GenericRedisCacher, () => {
|
|
|
291
328
|
const genericCacher = new GenericRedisCacher(
|
|
292
329
|
{
|
|
293
330
|
redisClient: instance(mock<Redis>()),
|
|
294
|
-
|
|
331
|
+
cacheKeyDelimiter: ':',
|
|
295
332
|
cacheKeyPrefix: 'hello-',
|
|
296
333
|
ttlSecondsPositive: 1,
|
|
297
334
|
ttlSecondsNegative: 2,
|
|
@@ -303,5 +340,89 @@ describe(GenericRedisCacher, () => {
|
|
|
303
340
|
);
|
|
304
341
|
await genericCacher.invalidateManyAsync([]);
|
|
305
342
|
});
|
|
343
|
+
|
|
344
|
+
it('throws when CUSTOM strategy returns an empty cacheKeyVersions list', () => {
|
|
345
|
+
const genericCacher = new GenericRedisCacher(
|
|
346
|
+
{
|
|
347
|
+
redisClient: instance(mock<Redis>()),
|
|
348
|
+
cacheKeyDelimiter: ':',
|
|
349
|
+
cacheKeyPrefix: 'hello-',
|
|
350
|
+
ttlSecondsPositive: 1,
|
|
351
|
+
ttlSecondsNegative: 2,
|
|
352
|
+
invalidationConfig: {
|
|
353
|
+
invalidationStrategy: RedisCacheInvalidationStrategy.CUSTOM,
|
|
354
|
+
cacheKeyVersionsToInvalidateFn: () => [],
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
entityConfiguration,
|
|
358
|
+
);
|
|
359
|
+
expect(() =>
|
|
360
|
+
genericCacher['makeCacheKeysForInvalidation'](
|
|
361
|
+
new SingleFieldHolder('id'),
|
|
362
|
+
new SingleFieldValueHolder('wat'),
|
|
363
|
+
),
|
|
364
|
+
).toThrow(/empty list/);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe('GenericRedisCacheContext validation', () => {
|
|
369
|
+
const validContext = {
|
|
370
|
+
redisClient: instance(mock<Redis>()),
|
|
371
|
+
cacheKeyDelimiter: ':',
|
|
372
|
+
cacheKeyPrefix: 'hello-',
|
|
373
|
+
ttlSecondsPositive: 1,
|
|
374
|
+
ttlSecondsNegative: 2,
|
|
375
|
+
invalidationConfig: {
|
|
376
|
+
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION as const,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
it('throws when cacheKeyDelimiter is empty', () => {
|
|
381
|
+
expect(
|
|
382
|
+
() =>
|
|
383
|
+
new GenericRedisCacher({ ...validContext, cacheKeyDelimiter: '' }, entityConfiguration),
|
|
384
|
+
).toThrow(/cacheKeyDelimiter must be a non-empty string/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('throws when cacheKeyDelimiter contains the escape character', () => {
|
|
388
|
+
expect(
|
|
389
|
+
() =>
|
|
390
|
+
new GenericRedisCacher({ ...validContext, cacheKeyDelimiter: '\\' }, entityConfiguration),
|
|
391
|
+
).toThrow(/must not contain the escape character/);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('throws when ttlSecondsPositive is not a positive integer', () => {
|
|
395
|
+
expect(
|
|
396
|
+
() =>
|
|
397
|
+
new GenericRedisCacher({ ...validContext, ttlSecondsPositive: 0 }, entityConfiguration),
|
|
398
|
+
).toThrow(/ttlSecondsPositive must be a positive integer/);
|
|
399
|
+
expect(
|
|
400
|
+
() =>
|
|
401
|
+
new GenericRedisCacher({ ...validContext, ttlSecondsPositive: -1 }, entityConfiguration),
|
|
402
|
+
).toThrow(/ttlSecondsPositive must be a positive integer/);
|
|
403
|
+
expect(
|
|
404
|
+
() =>
|
|
405
|
+
new GenericRedisCacher({ ...validContext, ttlSecondsPositive: 1.5 }, entityConfiguration),
|
|
406
|
+
).toThrow(/ttlSecondsPositive must be a positive integer/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('throws when ttlSecondsNegative is not a positive integer', () => {
|
|
410
|
+
expect(
|
|
411
|
+
() =>
|
|
412
|
+
new GenericRedisCacher({ ...validContext, ttlSecondsNegative: 0 }, entityConfiguration),
|
|
413
|
+
).toThrow(/ttlSecondsNegative must be a positive integer/);
|
|
414
|
+
expect(
|
|
415
|
+
() =>
|
|
416
|
+
new GenericRedisCacher({ ...validContext, ttlSecondsNegative: -1 }, entityConfiguration),
|
|
417
|
+
).toThrow(/ttlSecondsNegative must be a positive integer/);
|
|
418
|
+
expect(
|
|
419
|
+
() =>
|
|
420
|
+
new GenericRedisCacher({ ...validContext, ttlSecondsNegative: 1.5 }, entityConfiguration),
|
|
421
|
+
).toThrow(/ttlSecondsNegative must be a positive integer/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('accepts a valid context', () => {
|
|
425
|
+
expect(() => new GenericRedisCacher(validContext, entityConfiguration)).not.toThrow();
|
|
426
|
+
});
|
|
306
427
|
});
|
|
307
428
|
});
|