@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 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
- makeKeyFn(...parts: string[]): string {
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
- * Create a key string for key parts (cache key prefix, versions, entity name, etc).
59
- * Most commonly a simple `parts.join(':')`. See integration test for example.
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
- makeKeyFn: (...parts: string[]) => string;
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 parts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
94
- return this.context.makeKeyFn(this.context.cacheKeyPrefix, cacheKeyType, this.entityConfiguration.tableName, `v3.${cacheKeyVersion}`, ...parts);
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
- return this.context.invalidationConfig
109
- .cacheKeyVersionsToInvalidateFn(this.entityConfiguration.cacheKeyVersion)
110
- .map((cacheKeyVersion) => this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion));
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.62.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 eslint src",
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.62.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.62.0",
33
+ "@expo/entity-testing-utils": "^0.64.0",
40
34
  "@jest/globals": "30.3.0",
41
- "ioredis": "5.10.0",
35
+ "ioredis": "5.10.1",
42
36
  "ts-mockito": "2.6.1",
43
- "typescript": "5.9.3"
37
+ "typescript": "6.0.3"
38
+ },
39
+ "peerDependencies": {
40
+ "ioredis": ">=5"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
44
  },
45
- "gitHead": "4965cc238882982e6315beca48a68679ed45456b"
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
- * Create a key string for key parts (cache key prefix, versions, entity name, etc).
90
- * Most commonly a simple `parts.join(':')`. See integration test for example.
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
- makeKeyFn: (...parts: string[]) => string;
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 parts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
216
- return this.context.makeKeyFn(
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
- `v3.${cacheKeyVersion}`,
221
- ...parts,
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
- return this.context.invalidationConfig
255
- .cacheKeyVersionsToInvalidateFn(this.entityConfiguration.cacheKeyVersion)
256
- .map((cacheKeyVersion) =>
257
- this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
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
- makeKeyFn(...parts: string[]): string {
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
- makeKeyFn(...parts: string[]): string {
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
- makeKeyFn(...parts: string[]): string {
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
- makeKeyFn(...parts: string[]): string {
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
- makeKeyFn: (...parts) => parts.join(':'),
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
- makeKeyFn: (...parts) => parts.join(':'),
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
- makeKeyFn: (...parts) => parts.join(':'),
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
- await genericCacher.cacheManyAsync(new Map([[cacheKey, { id: 'wat' }]]));
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({ id: 'wat' }),
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
- makeKeyFn: (...parts) => parts.join(':'),
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
- makeKeyFn: (...parts) => parts.join(':'),
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:v3.2:id:wat');
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
- makeKeyFn: (...parts) => parts.join(':'),
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:v3.1:id:wat');
244
- expect(cacheKeys[1]).toBe('hello-:single:blah:v3.2:id:wat');
245
- expect(cacheKeys[2]).toBe('hello-:single:blah:v3.3:id:wat');
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
- makeKeyFn: (...parts) => parts.join(':'),
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:v3.2:id:wat');
282
- expect(cacheKeys[1]).toBe('hello-:single:blah:v3.3:id:wat');
283
- expect(cacheKeys[2]).toBe('hello-:single:blah:v3.4:id:wat');
284
- expect(cacheKeys[3]).toBe('hello-:single:blah:v3.5:id:wat');
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
- makeKeyFn: (...parts) => parts.join(':'),
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
  });