@ti-engine/core 1.12.3 → 1.14.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/utils/cache.js CHANGED
@@ -15,64 +15,99 @@
15
15
  * limitations under the License.
16
16
  */
17
17
 
18
+ const CacheProvider = require( "#cache-provider" );
18
19
  const ConnectionObserver = require( "#connection-observer" );
20
+ const RedisCacheProvider = require( "#redis-cache-provider" );
19
21
  const _ = require( "lodash" );
20
22
  const config = require( "#config" );
21
- const tools = require( "#tools" );
22
- const redis = require( "#redis-integration" );
23
23
  const exceptions = require( "#exceptions" );
24
+ const path = require( "path" );
25
+ const { cacheCapability } = require( "#cache-capability" );
24
26
 
25
27
  /**
26
- * Decodes one entry of a `multi(...).exec()` result into the value it carries.
28
+ * Determines whether a value is a class extending {@link CacheProvider}, without constructing it.
27
29
  * <br/>
28
- * Each entry is an ioredis `[ error, value ]` pair, so the value sits at index 1 and is a string when the key existed.
29
- * Returns `undefined` for a miss, an error entry, or a malformed entry.
30
- * <br/>
31
- * This lives outside the class, and is shared by {@link CommonMemoryCache#getValue} and
32
- * {@link CommonMemoryCache#getValues}, because it previously existed as two near-identical inline expressions and one
33
- * of them drifted: `getValues` inspected its own accumulator instead of the per-key entry, so `.length` was
34
- * `undefined`, the comparison was always false, and **every key resolved to `null`** whatever Redis returned.
30
+ * NOTE: A `typeof === "function"` test is not enough, and constructing first to ask `instanceof` afterwards is worse.
31
+ * An arrow function passes the `typeof` test but is not a constructor, so `new` on it throws a raw TypeError instead
32
+ * of the documented exception; and an unrelated class would have its constructor RUN - arbitrary code from a
33
+ * misconfigured path - before anything rejected it. Walking the prototype chain answers the question without
34
+ * executing anything.
35
35
  *
36
36
  * @method
37
- * @param {Array} [result] One `[ error, value ]` entry.
38
- * @returns {*} The parsed value, or `undefined` when there is none.
39
- * @private
37
+ * @param {*} candidate The value exported by the configured provider module.
38
+ * @returns {boolean}
39
+ * @public
40
40
  */
41
- function decodeCommandValue( result ) {
42
- // `Array.isArray` rather than a bare truthy-and-length test: a string also has a `length` and an indexable
43
- // character at 1, so a malformed non-array entry would otherwise be parsed as if it were a value.
44
- return ( Array.isArray( result ) && result.length > 1 && _.isString( result[ 1 ] ) ) ? tools.parseJSON( result[ 1 ] ) : undefined;
41
+ function isCacheProviderClass( candidate ) {
42
+ return typeof candidate === "function" && candidate.prototype instanceof CacheProvider;
45
43
  }
46
44
 
47
45
  /**
48
- * Maps a set of requested keys onto the values a `multi(...).exec()` returned for them, using `null` for a miss.
46
+ * Creates the cache backend named by the 'memoryCache.provider' setting.
49
47
  * <br/>
50
- * Iterates the requested `keys` rather than the raw results, so a short or absent response still yields one entry per
51
- * requested key instead of silently omitting some the caller's map always has the shape it asked for.
48
+ * NOTE: The built-in name "redis" selects {@link RedisCacheProvider}. Any other value is treated as a module path
49
+ * resolved against the process working directory, much as 'TI_INSTANCE_CLASS' already is, and must export a class
50
+ * extending {@link CacheProvider}. Resolution uses `path.resolve` rather than `path.join` so that an absolute path is
51
+ * taken as given instead of being appended to the working directory.
52
52
  * <br/>
53
- * The accumulator has no prototype on purpose: the key names come from the caller, and a cache key named `__proto__`
54
- * written by bracket assignment onto an ordinary `{}` would repoint the accumulator's prototype instead of creating
55
- * the entry. Same class as the `decycle` defect fixed in `tools.js`; see the 1.11.0 changelog entry.
53
+ * NOTE: This runs while the singleton is being constructed, which is to say at require time. A bad provider name
54
+ * therefore fails the process immediately rather than at the first cache call - which is the point: a deployment
55
+ * pointed at a backend that does not exist should not reach the code that assumes one.
56
56
  *
57
57
  * @method
58
- * @param {string[]} keys The keys that were requested, in command order.
59
- * @param {Array} [rawResults] The `multi(...).exec()` result.
60
- * @returns {Object} A null-prototype map of key to value, `null` where the key was absent.
61
- * @private
58
+ * @param {string} connectionIdentifier The identifier under which the backend's connection is observed.
59
+ * @returns {CacheProvider}
60
+ * @throws {TiException.E_GEN_INVALID_ARGUMENT_TYPE} If the configured module does not export a {@link CacheProvider}.
61
+ * @public
62
62
  */
63
- function mapCommandValues( keys, rawResults ) {
64
- let values = Object.create( null );
63
+ function createConfiguredProvider( connectionIdentifier ) {
64
+ let selected = config.getSetting( config.setting.MEMORY_CACHE_PROVIDER, "redis" );
65
+
66
+ if ( selected === "redis" ) {
67
+ return new RedisCacheProvider( connectionIdentifier );
68
+ }
69
+
70
+ let ProviderClass;
71
+ try {
72
+ ProviderClass = require( path.resolve( process.cwd(), selected ) );
73
+ } catch ( error ) {
74
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, {
75
+ details: `Could not load the cache provider configured as '${ selected }': ${ error.message }`
76
+ } );
77
+ }
65
78
 
66
- _.forEach( keys, ( key, idx ) => {
67
- let decoded = decodeCommandValue( rawResults ? rawResults[ idx ] : undefined );
68
- values[ key ] = ( decoded === undefined ) ? null : decoded;
69
- } );
79
+ if ( isCacheProviderClass( ProviderClass ) === false ) {
80
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, {
81
+ details: `The cache provider configured as '${ selected }' does not export a class extending CacheProvider.`
82
+ } );
83
+ }
70
84
 
71
- return values;
85
+ return new ProviderClass( connectionIdentifier );
86
+ }
87
+
88
+ /**
89
+ * Determines which of the required capabilities a backend does not provide.
90
+ * <br/>
91
+ * NOTE: This lives outside the class, and is exported, for the same reason the Redis decoders are: it is the pure half
92
+ * of the reconciliation, and the half that decides whether an instance starts.
93
+ *
94
+ * @method
95
+ * @param {string[]} [required] Capabilities the application declared it needs.
96
+ * @param {string[]} [available] Capabilities the backend reports it provides.
97
+ * @returns {string[]} The required capabilities that are absent, in the order they were required.
98
+ * @public
99
+ */
100
+ function findMissingCapabilities( required, available ) {
101
+ let provided = new Set( Array.isArray( available ) ? available : [] );
102
+ return _.filter( Array.isArray( required ) ? required : [], ( capability ) => provided.has( capability ) === false );
72
103
  }
73
104
 
74
105
  /**
75
106
  * Used to create and/or return a Common Memory Cache singleton instance.
107
+ * <br/>
108
+ * NOTE: This owns the cache's operational state and the connection observation around it; where the values actually
109
+ * live is the {@link CacheProvider}'s business. Every method here checks that the cache is usable and then delegates,
110
+ * which is why no provider repeats that check.
76
111
  *
77
112
  * @class CommonMemoryCache
78
113
  * @extends ConnectionObserver
@@ -82,7 +117,8 @@ function mapCommandValues( keys, rawResults ) {
82
117
  class CommonMemoryCache extends ConnectionObserver {
83
118
 
84
119
  static #instance = null;
85
- #redisClient = null;
120
+ /** @type CacheProvider */
121
+ #provider = null;
86
122
  #isOperational = false;
87
123
  #connectionIdentifier = "system-cache";
88
124
 
@@ -94,8 +130,8 @@ class CommonMemoryCache extends ConnectionObserver {
94
130
  super();
95
131
 
96
132
  if ( !CommonMemoryCache.#instance ) {
97
- this.#redisClient = redis.createRedisClient( this.#connectionIdentifier );
98
- this.#redisClient.addConnectionObserver( this );
133
+ this.#provider = createConfiguredProvider( this.#connectionIdentifier );
134
+ this.#provider.addConnectionObserver( this );
99
135
 
100
136
  CommonMemoryCache.#instance = this;
101
137
  }
@@ -126,21 +162,56 @@ class CommonMemoryCache extends ConnectionObserver {
126
162
  return this.#connectionIdentifier;
127
163
  }
128
164
 
165
+ /**
166
+ * Property returning the optional behaviors the configured backend provides.
167
+ * <br/>
168
+ * NOTE: Accurate only once {@link CommonMemoryCache#initialize} has resolved — some capabilities cannot be
169
+ * established until the backend has connected.
170
+ *
171
+ * @property
172
+ * @returns {string[]} Values drawn from {@link TiCacheCapability}.
173
+ * @public
174
+ */
175
+ get capabilities() {
176
+ return this.#provider.capabilities;
177
+ }
178
+
129
179
  /**
130
180
  * Used to initialize the cache service.
181
+ * <br/>
182
+ * NOTE: Once the backend is connected, the capabilities it reports are reconciled against the
183
+ * 'memoryCache.requiredCapabilities' setting, and startup fails if any of them is missing. That is deliberate: a
184
+ * backend silently lacking a behavior the application depends on is otherwise discovered from inside a request,
185
+ * long after the deployment that introduced it.
186
+ * <br/>
187
+ * NOTE: A failed reconciliation rolls the cache back to non-operational and shuts the backend down before it
188
+ * rejects, so a refused startup never leaves a usable cache behind.
131
189
  *
132
190
  * @method
133
191
  * @returns {Promise}
192
+ * @throws {TiException.E_GEN_FEATURE_UNSUPPORTED} If the backend does not provide every required capability.
134
193
  * @public
135
194
  */
136
195
  initialize() {
137
- let host = config.getSetting( config.setting.MEMORY_CACHE_REDIS_HOST );
138
- let port = config.getSetting( config.setting.MEMORY_CACHE_REDIS_PORT );
139
- let db = config.getSetting( config.setting.MEMORY_CACHE_REDIS_DB );
140
- let authKey = config.getSetting( config.setting.MEMORY_CACHE_AUTH_KEY );
141
- let user = config.getSetting( config.setting.MEMORY_CACHE_USER );
142
-
143
- return this.#redisClient.initialize( host, port, authKey, user, db );
196
+ return this.#provider.initialize().then( () => {
197
+ try {
198
+ this.#verifyRequiredCapabilities();
199
+ } catch ( error ) {
200
+ // The backend is already connected and this cache already operational by the time the check runs:
201
+ // the Redis client notifies its connection observers from inside its "ready" handler, before
202
+ // `initialize()` resolves, and `onConnectionRecovered` sets `#isOperational` to true. Rejecting
203
+ // without undoing that would leave the singleton reporting an operational cache over a live
204
+ // connection while its caller has been told that startup failed - `ServiceInstance.onStart` only
205
+ // propagates the rejection, and `shutDown()` is reached from `stop()`, which a failed start never
206
+ // gets to. So the rollback belongs here, where the failure is raised.
207
+ this.#isOperational = false;
208
+ return this.#provider.shutDown().catch( () => {
209
+ // A backend that cannot close cleanly must not replace the reason startup was refused.
210
+ } ).then( () => {
211
+ throw error;
212
+ } );
213
+ }
214
+ } );
144
215
  }
145
216
 
146
217
  /**
@@ -151,7 +222,7 @@ class CommonMemoryCache extends ConnectionObserver {
151
222
  * @public
152
223
  */
153
224
  shutDown() {
154
- return this.#redisClient.shutDown( 250 );
225
+ return this.#provider.shutDown();
155
226
  }
156
227
 
157
228
  /**
@@ -200,14 +271,14 @@ class CommonMemoryCache extends ConnectionObserver {
200
271
  }
201
272
 
202
273
  /**
203
- * Used to register a new {@link ConnectionObserver} for events related to the underlying Redis connection state.
274
+ * Used to register a new {@link ConnectionObserver} for events related to the underlying backend connection state.
204
275
  *
205
276
  * @method
206
277
  * @param {ConnectionObserver} connectionObserver The {@link ConnectionObserver} that will be notified of any changes.
207
278
  * @public
208
279
  */
209
280
  addConnectionObserver( connectionObserver ) {
210
- this.#redisClient.addConnectionObserver( connectionObserver );
281
+ this.#provider.addConnectionObserver( connectionObserver );
211
282
  }
212
283
 
213
284
  /**
@@ -219,19 +290,7 @@ class CommonMemoryCache extends ConnectionObserver {
219
290
  * @public
220
291
  */
221
292
  matchKeys( pattern ) {
222
- return new Promise( ( resolve, reject ) => {
223
- if ( this.#isOperational === true ) {
224
- let commandKeys = [ redis.cacheCommands.KEYS, pattern ];
225
- this.#redisClient.executeCommands( [ commandKeys ] ).then( ( results ) => {
226
- results = results[ 0 ];
227
- resolve( ( results && results.length > 1 ) ? results[ 1 ] : [] );
228
- } ).catch( ( error ) => {
229
- reject( error );
230
- } );
231
- } else {
232
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
233
- }
234
- } );
293
+ return this.#guarded( () => this.#provider.matchKeys( pattern ) );
235
294
  }
236
295
 
237
296
  /**
@@ -245,26 +304,7 @@ class CommonMemoryCache extends ConnectionObserver {
245
304
  * @public
246
305
  */
247
306
  setValue( key, value, expiration ) {
248
- return new Promise( ( resolve, reject ) => {
249
- if ( this.#isOperational === true ) {
250
- if ( value ) {
251
- let commandSetValue = [ redis.cacheCommands.SET_VALUE, key, tools.stringifyJSON( value ) ];
252
- if ( expiration ) {
253
- commandSetValue.push( "EX" );
254
- commandSetValue.push( expiration );
255
- }
256
- this.#redisClient.executeCommands( [ commandSetValue ] ).then( () => {
257
- resolve( value );
258
- } ).catch( ( error ) => {
259
- reject( error );
260
- } );
261
- } else {
262
- resolve( value );
263
- }
264
- } else {
265
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
266
- }
267
- } );
307
+ return this.#guarded( () => this.#provider.setValue( key, value, expiration ) );
268
308
  }
269
309
 
270
310
  /**
@@ -278,31 +318,7 @@ class CommonMemoryCache extends ConnectionObserver {
278
318
  * @public
279
319
  */
280
320
  setValues( keyValues, prefix, expiration ) {
281
- return new Promise( ( resolve, reject ) => {
282
- if ( this.#isOperational === true ) {
283
- if ( keyValues ) {
284
- let commands = [];
285
- _.forEach( keyValues, ( value, key ) => {
286
- let commandSetValue = [ redis.cacheCommands.SET_VALUE, ( ( prefix ) ? prefix : "" ) + key, tools.stringifyJSON( value ) ];
287
- if ( expiration ) {
288
- commandSetValue.push( "EX" );
289
- commandSetValue.push( expiration );
290
- }
291
- commands.push( commandSetValue );
292
- } );
293
-
294
- this.#redisClient.executeCommands( commands ).then( () => {
295
- resolve( keyValues );
296
- } ).catch( ( error ) => {
297
- reject( error );
298
- } );
299
- } else {
300
- resolve( keyValues );
301
- }
302
- } else {
303
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
304
- }
305
- } );
321
+ return this.#guarded( () => this.#provider.setValues( keyValues, prefix, expiration ) );
306
322
  }
307
323
 
308
324
  /**
@@ -314,18 +330,7 @@ class CommonMemoryCache extends ConnectionObserver {
314
330
  * @public
315
331
  */
316
332
  getValue( key ) {
317
- return new Promise( ( resolve, reject ) => {
318
- if ( this.#isOperational === true ) {
319
- let commandGetValue = [ redis.cacheCommands.GET_VALUE, key ];
320
- this.#redisClient.executeCommands( [ commandGetValue ] ).then( ( results ) => {
321
- resolve( decodeCommandValue( results ? results[ 0 ] : undefined ) );
322
- } ).catch( ( error ) => {
323
- reject( error );
324
- } );
325
- } else {
326
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
327
- }
328
- } );
333
+ return this.#guarded( () => this.#provider.getValue( key ) );
329
334
  }
330
335
 
331
336
  /**
@@ -338,21 +343,7 @@ class CommonMemoryCache extends ConnectionObserver {
338
343
  * @public
339
344
  */
340
345
  getValues( keys, prefix ) {
341
- return new Promise( ( resolve, reject ) => {
342
- if ( this.#isOperational === true ) {
343
- let commands = [];
344
- _.forEach( keys, ( key ) => {
345
- commands.push( [ redis.cacheCommands.GET_VALUE, ( ( prefix ) ? prefix : "" ) + key ] );
346
- } );
347
- this.#redisClient.executeCommands( commands ).then( ( rawResults ) => {
348
- resolve( mapCommandValues( keys, rawResults ) );
349
- } ).catch( ( error ) => {
350
- reject( error );
351
- } );
352
- } else {
353
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
354
- }
355
- } );
346
+ return this.#guarded( () => this.#provider.getValues( keys, prefix ) );
356
347
  }
357
348
 
358
349
  /**
@@ -364,19 +355,7 @@ class CommonMemoryCache extends ConnectionObserver {
364
355
  * @public
365
356
  */
366
357
  deleteValue( key ) {
367
- return new Promise( ( resolve, reject ) => {
368
- if ( this.#isOperational === true ) {
369
- let commandDeleteValue = [ redis.cacheCommands.DELETE_VALUE, key ];
370
- this.#redisClient.executeCommands( [ commandDeleteValue ] ).then( ( results ) => {
371
- results = results[ 0 ];
372
- resolve( ( results && results.length > 1 ) ? results[ 1 ] : undefined );
373
- } ).catch( ( error ) => {
374
- reject( error );
375
- } );
376
- } else {
377
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
378
- }
379
- } );
358
+ return this.#guarded( () => this.#provider.deleteValue( key ) );
380
359
  }
381
360
 
382
361
  /**
@@ -392,18 +371,7 @@ class CommonMemoryCache extends ConnectionObserver {
392
371
  * @public
393
372
  */
394
373
  expireValue( key, seconds, name ) {
395
- return new Promise( ( resolve, reject ) => {
396
- if ( this.#isOperational === true ) {
397
- let commandExpire = ( name ) ? [ redis.cacheCommands.HASH_EXPIRE, name, seconds, "FIELDS", 1, key ] : [ redis.cacheCommands.EXPIRE, key, seconds ];
398
- this.#redisClient.executeCommands( [ commandExpire ] ).then( () => {
399
- resolve( seconds );
400
- } ).catch( ( error ) => {
401
- reject( error );
402
- } );
403
- } else {
404
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
405
- }
406
- } );
374
+ return this.#guarded( () => this.#provider.expireValue( key, seconds, name ) );
407
375
  }
408
376
 
409
377
  /**
@@ -416,24 +384,7 @@ class CommonMemoryCache extends ConnectionObserver {
416
384
  * @public
417
385
  */
418
386
  listPushValue( listName, values ) {
419
- return new Promise( ( resolve, reject ) => {
420
- if ( this.#isOperational === true ) {
421
- let commandPushValues = [ redis.cacheCommands.LIST_PUSH, listName ];
422
- _.forEach( values, ( value ) => {
423
- if ( value ) {
424
- commandPushValues.push( tools.stringifyJSON( value ) );
425
- }
426
- } );
427
- this.#redisClient.executeCommands( [ commandPushValues ] ).then( ( results ) => {
428
- results = results[ 0 ];
429
- resolve( ( results && results.length > 1 ) ? results[ 1 ] : undefined );
430
- } ).catch( ( error ) => {
431
- reject( error );
432
- } );
433
- } else {
434
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
435
- }
436
- } );
387
+ return this.#guarded( () => this.#provider.listPushValue( listName, values ) );
437
388
  }
438
389
 
439
390
  /**
@@ -446,18 +397,7 @@ class CommonMemoryCache extends ConnectionObserver {
446
397
  * @public
447
398
  */
448
399
  addToSet( key, value ) {
449
- return new Promise( ( resolve, reject ) => {
450
- if ( this.#isOperational === true ) {
451
- let commandAddToSet = [ redis.cacheCommands.ADD_TO_SET, key, tools.stringifyJSON( value ) ];
452
- this.#redisClient.executeCommands( [ commandAddToSet ] ).then( () => {
453
- resolve();
454
- } ).catch( ( error ) => {
455
- reject( error );
456
- } );
457
- } else {
458
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
459
- }
460
- } );
400
+ return this.#guarded( () => this.#provider.addToSet( key, value ) );
461
401
  }
462
402
 
463
403
  /**
@@ -472,21 +412,7 @@ class CommonMemoryCache extends ConnectionObserver {
472
412
  * @public
473
413
  */
474
414
  addToSetMulti( keys, values ) {
475
- return new Promise( ( resolve, reject ) => {
476
- if ( this.#isOperational === true ) {
477
- let commands = [];
478
- _.forEach( keys, ( key, idx ) => {
479
- commands.push( [ redis.cacheCommands.ADD_TO_SET, key, tools.stringifyJSON( values[ idx ] ) ] );
480
- } );
481
- this.#redisClient.executeCommands( commands ).then( () => {
482
- resolve();
483
- } ).catch( ( error ) => {
484
- reject( error );
485
- } );
486
- } else {
487
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
488
- }
489
- } );
415
+ return this.#guarded( () => this.#provider.addToSetMulti( keys, values ) );
490
416
  }
491
417
 
492
418
  /**
@@ -499,20 +425,7 @@ class CommonMemoryCache extends ConnectionObserver {
499
425
  * @public
500
426
  */
501
427
  isSetMember( setName, value ) {
502
- return new Promise( ( resolve, reject ) => {
503
- if ( this.#isOperational === true ) {
504
- let commandIsSetMember = [ redis.cacheCommands.IS_SET_MEMBER, setName, value ];
505
- this.#redisClient.executeCommands( [ commandIsSetMember ] ).then( ( results ) => {
506
- results = results[ 0 ];
507
- let result = !!( results && results.length > 1 && results[ 1 ] === 1 );
508
- resolve( result );
509
- } ).catch( ( error ) => {
510
- reject( error );
511
- } );
512
- } else {
513
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
514
- }
515
- } );
428
+ return this.#guarded( () => this.#provider.isSetMember( setName, value ) );
516
429
  }
517
430
 
518
431
  /**
@@ -524,20 +437,7 @@ class CommonMemoryCache extends ConnectionObserver {
524
437
  * @public
525
438
  */
526
439
  membersOfSet( key ) {
527
- return new Promise( ( resolve, reject ) => {
528
- if ( this.#isOperational === true ) {
529
- let commandMembersOfSet = [ redis.cacheCommands.GET_ALL_FROM_SET, key ];
530
- this.#redisClient.executeCommands( [ commandMembersOfSet ] ).then( ( results ) => {
531
- results = results[ 0 ];
532
- let parsedResults = ( results && results.length > 1 && results[ 1 ] ) ? results[ 1 ] : [];
533
- resolve( parsedResults );
534
- } ).catch( ( error ) => {
535
- reject( error );
536
- } );
537
- } else {
538
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
539
- }
540
- } );
440
+ return this.#guarded( () => this.#provider.membersOfSet( key ) );
541
441
  }
542
442
 
543
443
  /**
@@ -549,20 +449,7 @@ class CommonMemoryCache extends ConnectionObserver {
549
449
  * @public
550
450
  */
551
451
  unionOfSets( keys ) {
552
- return new Promise( ( resolve, reject ) => {
553
- if ( this.#isOperational === true ) {
554
- let commandUnionOfSets = _.concat( [ redis.cacheCommands.UNION_OF_SETS ], keys );
555
- this.#redisClient.executeCommands( [ commandUnionOfSets ] ).then( ( results ) => {
556
- results = results[ 0 ];
557
- let parsedResults = ( results && results.length > 1 && results[ 1 ] ) ? results[ 1 ] : [];
558
- resolve( parsedResults );
559
- } ).catch( ( error ) => {
560
- reject( error );
561
- } );
562
- } else {
563
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
564
- }
565
- } );
452
+ return this.#guarded( () => this.#provider.unionOfSets( keys ) );
566
453
  }
567
454
 
568
455
  /**
@@ -577,18 +464,7 @@ class CommonMemoryCache extends ConnectionObserver {
577
464
  * @public
578
465
  */
579
466
  hashSetField( key, name, value ) {
580
- return new Promise( ( resolve, reject ) => {
581
- if ( this.#isOperational === true ) {
582
- let commandHashSetField = [ redis.cacheCommands.HASH_SET, key, name, tools.stringifyJSON( value ) ];
583
- this.#redisClient.executeCommands( [ commandHashSetField ] ).then( () => {
584
- resolve();
585
- } ).catch( ( error ) => {
586
- reject( error );
587
- } );
588
- } else {
589
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
590
- }
591
- } );
467
+ return this.#guarded( () => this.#provider.hashSetField( key, name, value ) );
592
468
  }
593
469
 
594
470
  /**
@@ -604,22 +480,7 @@ class CommonMemoryCache extends ConnectionObserver {
604
480
  * @public
605
481
  */
606
482
  hashSetFields( key, fields ) {
607
- return new Promise( ( resolve, reject ) => {
608
- if ( this.#isOperational === true ) {
609
- let commandHashSetFields = [ redis.cacheCommands.HASH_SET, key ];
610
- _.forEach( fields, ( field ) => {
611
- commandHashSetFields.push( field.name );
612
- commandHashSetFields.push( tools.stringifyJSON( field.value ) );
613
- } );
614
- this.#redisClient.executeCommands( [ commandHashSetFields ] ).then( () => {
615
- resolve();
616
- } ).catch( ( error ) => {
617
- reject( error );
618
- } );
619
- } else {
620
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
621
- }
622
- } );
483
+ return this.#guarded( () => this.#provider.hashSetFields( key, fields ) );
623
484
  }
624
485
 
625
486
  /**
@@ -632,19 +493,7 @@ class CommonMemoryCache extends ConnectionObserver {
632
493
  * @public
633
494
  */
634
495
  hashGetField( key, field ) {
635
- return new Promise( ( resolve, reject ) => {
636
- if ( this.#isOperational === true ) {
637
- let commandHashGetField = [ redis.cacheCommands.HASH_GET, key, field ];
638
- this.#redisClient.executeCommands( [ commandHashGetField ] ).then( ( results ) => {
639
- results = results[ 0 ];
640
- resolve( ( results && results.length > 1 && _.isString( results[ 1 ] ) ) ? tools.parseJSON( results[ 1 ] ) : null );
641
- } ).catch( ( error ) => {
642
- reject( error );
643
- } );
644
- } else {
645
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
646
- }
647
- } );
496
+ return this.#guarded( () => this.#provider.hashGetField( key, field ) );
648
497
  }
649
498
 
650
499
  /**
@@ -657,19 +506,7 @@ class CommonMemoryCache extends ConnectionObserver {
657
506
  * @public
658
507
  */
659
508
  hashDeleteField( key, field ) {
660
- return new Promise( ( resolve, reject ) => {
661
- if ( this.#isOperational === true ) {
662
- let commandHashGetField = [ redis.cacheCommands.HASH_REMOVE, key, field ];
663
- this.#redisClient.executeCommands( [ commandHashGetField ] ).then( ( results ) => {
664
- results = results[ 0 ];
665
- resolve( ( results && results.length > 1 ) ? tools.toBool( results[ 1 ] ) : false );
666
- } ).catch( ( error ) => {
667
- reject( error );
668
- } );
669
- } else {
670
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
671
- }
672
- } );
509
+ return this.#guarded( () => this.#provider.hashDeleteField( key, field ) );
673
510
  }
674
511
 
675
512
  /**
@@ -687,25 +524,7 @@ class CommonMemoryCache extends ConnectionObserver {
687
524
  * @public
688
525
  */
689
526
  setJSON( key, value, path = "$", overrideMode = 0 ) {
690
- return new Promise( ( resolve, reject ) => {
691
- if ( this.#isOperational === true ) {
692
- if ( this.#redisClient.isJSONSupported ) {
693
- let commandArguments = [ redis.cacheCommands.JSON_SET, key, this.#normalizeJSONPath( path ), tools.stringifyJSON( value ) ];
694
- if ( overrideMode !== 0 ) {
695
- commandArguments.push( overrideMode === 1 ? redis.cacheOverrideMode.NX : redis.cacheOverrideMode.XX );
696
- }
697
- this.#redisClient.callCommand( commandArguments ).then( () => {
698
- resolve();
699
- } ).catch( ( error ) => {
700
- reject( error );
701
- } );
702
- } else {
703
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_FEATURE_UNSUPPORTED, { details: "No RedisJSON module installed on server." } ) );
704
- }
705
- } else {
706
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
707
- }
708
- } );
527
+ return this.#guarded( () => this.#provider.setJSON( key, value, path, overrideMode ) );
709
528
  }
710
529
 
711
530
  /**
@@ -720,22 +539,7 @@ class CommonMemoryCache extends ConnectionObserver {
720
539
  * @public
721
540
  */
722
541
  getJSON( key, path = "$" ) {
723
- return new Promise( ( resolve, reject ) => {
724
- if ( this.#isOperational === true ) {
725
- if ( this.#redisClient.isJSONSupported ) {
726
- let commandArguments = [ redis.cacheCommands.JSON_GET, key, this.#normalizeJSONPath( path ) ];
727
- this.#redisClient.callCommand( commandArguments ).then( ( result ) => {
728
- resolve( result != null ? tools.parseJSON( String( result ) ) : null );
729
- } ).catch( ( error ) => {
730
- reject( error );
731
- } );
732
- } else {
733
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_FEATURE_UNSUPPORTED, { details: "No RedisJSON module installed on server." } ) );
734
- }
735
- } else {
736
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
737
- }
738
- } );
542
+ return this.#guarded( () => this.#provider.getJSON( key, path ) );
739
543
  }
740
544
 
741
545
  /**
@@ -751,22 +555,7 @@ class CommonMemoryCache extends ConnectionObserver {
751
555
  * @public
752
556
  */
753
557
  editJSON( key, value, path = "$" ) {
754
- return new Promise( ( resolve, reject ) => {
755
- if ( this.#isOperational === true ) {
756
- if ( this.#redisClient.isJSONSupported ) {
757
- let commandArguments = [ redis.cacheCommands.JSON_MERGE, key, this.#normalizeJSONPath( path ), tools.stringifyJSON( value ) ];
758
- this.#redisClient.callCommand( commandArguments ).then( () => {
759
- resolve();
760
- } ).catch( ( error ) => {
761
- reject( error );
762
- } );
763
- } else {
764
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_FEATURE_UNSUPPORTED, { details: "No RedisJSON module installed on server." } ) );
765
- }
766
- } else {
767
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
768
- }
769
- } );
558
+ return this.#guarded( () => this.#provider.editJSON( key, value, path ) );
770
559
  }
771
560
 
772
561
  /**
@@ -782,41 +571,45 @@ class CommonMemoryCache extends ConnectionObserver {
782
571
  * @public
783
572
  */
784
573
  arrayAppendJSON( key, value, path = "$" ) {
785
- return new Promise( ( resolve, reject ) => {
786
- if ( this.#isOperational === true ) {
787
- if ( this.#redisClient.isJSONSupported ) {
788
- let commandArguments = [ redis.cacheCommands.JSON_ARRAY_APPEND, key, this.#normalizeJSONPath( path ), tools.stringifyJSON( value ) ];
789
- this.#redisClient.callCommand( commandArguments ).then( () => {
790
- resolve();
791
- } ).catch( ( error ) => {
792
- reject( error );
793
- } );
794
- } else {
795
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_FEATURE_UNSUPPORTED, { details: "No RedisJSON module installed on server." } ) );
796
- }
797
- } else {
798
- reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
799
- }
800
- } );
574
+ return this.#guarded( () => this.#provider.arrayAppendJSON( key, value, path ) );
801
575
  }
802
576
 
803
577
  /* Private interface */
804
578
 
805
579
  /**
806
- * Used to normalize a JSON path.
580
+ * Used to reject an operation when the cache is not usable, and to delegate it to the backend when it is.
807
581
  * <br/>
808
- * NOTE: If "path" is an array, each element is treated as a literal key name and encoded with bracket notation,
809
- * which correctly handles key names that contain dots or other JSONPath special characters.
582
+ * NOTE: This exists so the check lives in exactly one place. It previously stood at the head of all twenty-one
583
+ * data methods, which is twenty-one chances for a new method to be added without it.
810
584
  *
811
585
  * @method
812
- * @param {string|string[]} path
813
- * @returns {string}
586
+ * @param {function(): Promise} operation
587
+ * @returns {Promise}
814
588
  */
815
- #normalizeJSONPath( path ) {
816
- if ( Array.isArray( path ) ) {
817
- return "$" + path.map( ( segment ) => `["${ String( segment ).replace( /\\/g, "\\\\" ).replace( /"/g, '\\"' ) }"]` ).join( "" );
589
+ #guarded( operation ) {
590
+ return ( this.#isOperational === true )
591
+ ? operation()
592
+ : Promise.reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
593
+ }
594
+
595
+ /**
596
+ * Used to reconcile what the application requires against what the backend reports it can do.
597
+ *
598
+ * @method
599
+ * @throws {TiException.E_GEN_FEATURE_UNSUPPORTED} If any required capability is absent.
600
+ */
601
+ #verifyRequiredCapabilities() {
602
+ let required = config.getSetting( config.setting.MEMORY_CACHE_REQUIRED_CAPABILITIES, [] );
603
+ if ( _.isEmpty( required ) === true ) {
604
+ return;
605
+ }
606
+
607
+ let missing = findMissingCapabilities( required, this.#provider.capabilities );
608
+ if ( missing.length > 0 ) {
609
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_FEATURE_UNSUPPORTED, {
610
+ details: `The configured cache backend '${ this.#provider.constructor.name }' does not provide the required ${ ( missing.length === 1 ) ? "capability" : "capabilities" }: ${ missing.join( ", " ) }.`
611
+ } );
818
612
  }
819
- return ( path.startsWith( "$" ) === false ) ? ( "$." + path ) : path;
820
613
  }
821
614
 
822
615
  }
@@ -824,7 +617,16 @@ class CommonMemoryCache extends ConnectionObserver {
824
617
  const instance = new CommonMemoryCache();
825
618
  module.exports.instance = Object.freeze( instance );
826
619
 
827
- // Exported for testing. The cache singleton builds its own Redis client in its constructor, so `getValues` cannot be
828
- // driven without a live server these are the pure halves of it, and they are where the defect was.
829
- module.exports.decodeCommandValue = decodeCommandValue;
830
- module.exports.mapCommandValues = mapCommandValues;
620
+ // Re-exported from the Redis backend, where these now live along with the rest of the Redis-specific decoding. They
621
+ // stay on this module because it is the published entry point and removing them would break any consumer that reaches
622
+ // for them - including this package's own `cache-get-values` suite.
623
+ module.exports.decodeCommandValue = RedisCacheProvider.decodeCommandValue;
624
+ module.exports.mapCommandValues = RedisCacheProvider.mapCommandValues;
625
+
626
+ // Re-exported so a consumer can name a capability without reaching past this module's exports map.
627
+ module.exports.cacheCapability = cacheCapability;
628
+
629
+ // Exported for testing, per the notes on the functions themselves.
630
+ module.exports.findMissingCapabilities = findMissingCapabilities;
631
+ module.exports.createConfiguredProvider = createConfiguredProvider;
632
+ module.exports.isCacheProviderClass = isCacheProviderClass;