@ti-engine/core 1.12.2 → 1.13.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
@@ -16,63 +16,36 @@
16
16
  */
17
17
 
18
18
  const ConnectionObserver = require( "#connection-observer" );
19
+ const RedisCacheProvider = require( "#redis-cache-provider" );
19
20
  const _ = require( "lodash" );
20
21
  const config = require( "#config" );
21
- const tools = require( "#tools" );
22
- const redis = require( "#redis-integration" );
23
22
  const exceptions = require( "#exceptions" );
23
+ const { cacheCapability } = require( "#cache-capability" );
24
24
 
25
25
  /**
26
- * Decodes one entry of a `multi(...).exec()` result into the value it carries.
26
+ * Determines which of the required capabilities a backend does not provide.
27
27
  * <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.
28
+ * NOTE: This lives outside the class, and is exported, for the same reason the Redis decoders are: the cache singleton
29
+ * builds its own backend in its constructor, so the reconciliation cannot be driven without a live server. This is the
30
+ * pure half of it, and it is the half that decides whether an instance starts.
35
31
  *
36
32
  * @method
37
- * @param {Array} [result] One `[ error, value ]` entry.
38
- * @returns {*} The parsed value, or `undefined` when there is none.
39
- * @private
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;
45
- }
46
-
47
- /**
48
- * Maps a set of requested keys onto the values a `multi(...).exec()` returned for them, using `null` for a miss.
49
- * <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.
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.
56
- *
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
33
+ * @param {string[]} [required] Capabilities the application declared it needs.
34
+ * @param {string[]} [available] Capabilities the backend reports it provides.
35
+ * @returns {string[]} The required capabilities that are absent, in the order they were required.
36
+ * @public
62
37
  */
63
- function mapCommandValues( keys, rawResults ) {
64
- let values = Object.create( null );
65
-
66
- _.forEach( keys, ( key, idx ) => {
67
- let decoded = decodeCommandValue( rawResults ? rawResults[ idx ] : undefined );
68
- values[ key ] = ( decoded === undefined ) ? null : decoded;
69
- } );
70
-
71
- return values;
38
+ function findMissingCapabilities( required, available ) {
39
+ let provided = new Set( Array.isArray( available ) ? available : [] );
40
+ return _.filter( Array.isArray( required ) ? required : [], ( capability ) => provided.has( capability ) === false );
72
41
  }
73
42
 
74
43
  /**
75
44
  * Used to create and/or return a Common Memory Cache singleton instance.
45
+ * <br/>
46
+ * NOTE: This owns the cache's operational state and the connection observation around it; where the values actually
47
+ * live is the {@link CacheProvider}'s business. Every method here checks that the cache is usable and then delegates,
48
+ * which is why no provider repeats that check.
76
49
  *
77
50
  * @class CommonMemoryCache
78
51
  * @extends ConnectionObserver
@@ -82,7 +55,8 @@ function mapCommandValues( keys, rawResults ) {
82
55
  class CommonMemoryCache extends ConnectionObserver {
83
56
 
84
57
  static #instance = null;
85
- #redisClient = null;
58
+ /** @type CacheProvider */
59
+ #provider = null;
86
60
  #isOperational = false;
87
61
  #connectionIdentifier = "system-cache";
88
62
 
@@ -94,8 +68,8 @@ class CommonMemoryCache extends ConnectionObserver {
94
68
  super();
95
69
 
96
70
  if ( !CommonMemoryCache.#instance ) {
97
- this.#redisClient = redis.createRedisClient( this.#connectionIdentifier );
98
- this.#redisClient.addConnectionObserver( this );
71
+ this.#provider = new RedisCacheProvider( this.#connectionIdentifier );
72
+ this.#provider.addConnectionObserver( this );
99
73
 
100
74
  CommonMemoryCache.#instance = this;
101
75
  }
@@ -126,21 +100,56 @@ class CommonMemoryCache extends ConnectionObserver {
126
100
  return this.#connectionIdentifier;
127
101
  }
128
102
 
103
+ /**
104
+ * Property returning the optional behaviors the configured backend provides.
105
+ * <br/>
106
+ * NOTE: Accurate only once {@link CommonMemoryCache#initialize} has resolved — some capabilities cannot be
107
+ * established until the backend has connected.
108
+ *
109
+ * @property
110
+ * @returns {string[]} Values drawn from {@link TiCacheCapability}.
111
+ * @public
112
+ */
113
+ get capabilities() {
114
+ return this.#provider.capabilities;
115
+ }
116
+
129
117
  /**
130
118
  * Used to initialize the cache service.
119
+ * <br/>
120
+ * NOTE: Once the backend is connected, the capabilities it reports are reconciled against the
121
+ * 'memoryCache.requiredCapabilities' setting, and startup fails if any of them is missing. That is deliberate: a
122
+ * backend silently lacking a behavior the application depends on is otherwise discovered from inside a request,
123
+ * long after the deployment that introduced it.
124
+ * <br/>
125
+ * NOTE: A failed reconciliation rolls the cache back to non-operational and shuts the backend down before it
126
+ * rejects, so a refused startup never leaves a usable cache behind.
131
127
  *
132
128
  * @method
133
129
  * @returns {Promise}
130
+ * @throws {TiException.E_GEN_FEATURE_UNSUPPORTED} If the backend does not provide every required capability.
134
131
  * @public
135
132
  */
136
133
  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 );
134
+ return this.#provider.initialize().then( () => {
135
+ try {
136
+ this.#verifyRequiredCapabilities();
137
+ } catch ( error ) {
138
+ // The backend is already connected and this cache already operational by the time the check runs:
139
+ // the Redis client notifies its connection observers from inside its "ready" handler, before
140
+ // `initialize()` resolves, and `onConnectionRecovered` sets `#isOperational` to true. Rejecting
141
+ // without undoing that would leave the singleton reporting an operational cache over a live
142
+ // connection while its caller has been told that startup failed - `ServiceInstance.onStart` only
143
+ // propagates the rejection, and `shutDown()` is reached from `stop()`, which a failed start never
144
+ // gets to. So the rollback belongs here, where the failure is raised.
145
+ this.#isOperational = false;
146
+ return this.#provider.shutDown().catch( () => {
147
+ // A backend that cannot close cleanly must not replace the reason startup was refused.
148
+ } ).then( () => {
149
+ throw error;
150
+ } );
151
+ }
152
+ } );
144
153
  }
145
154
 
146
155
  /**
@@ -151,7 +160,7 @@ class CommonMemoryCache extends ConnectionObserver {
151
160
  * @public
152
161
  */
153
162
  shutDown() {
154
- return this.#redisClient.shutDown( 250 );
163
+ return this.#provider.shutDown();
155
164
  }
156
165
 
157
166
  /**
@@ -200,14 +209,14 @@ class CommonMemoryCache extends ConnectionObserver {
200
209
  }
201
210
 
202
211
  /**
203
- * Used to register a new {@link ConnectionObserver} for events related to the underlying Redis connection state.
212
+ * Used to register a new {@link ConnectionObserver} for events related to the underlying backend connection state.
204
213
  *
205
214
  * @method
206
215
  * @param {ConnectionObserver} connectionObserver The {@link ConnectionObserver} that will be notified of any changes.
207
216
  * @public
208
217
  */
209
218
  addConnectionObserver( connectionObserver ) {
210
- this.#redisClient.addConnectionObserver( connectionObserver );
219
+ this.#provider.addConnectionObserver( connectionObserver );
211
220
  }
212
221
 
213
222
  /**
@@ -219,19 +228,7 @@ class CommonMemoryCache extends ConnectionObserver {
219
228
  * @public
220
229
  */
221
230
  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
- } );
231
+ return this.#guarded( () => this.#provider.matchKeys( pattern ) );
235
232
  }
236
233
 
237
234
  /**
@@ -245,26 +242,7 @@ class CommonMemoryCache extends ConnectionObserver {
245
242
  * @public
246
243
  */
247
244
  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
- } );
245
+ return this.#guarded( () => this.#provider.setValue( key, value, expiration ) );
268
246
  }
269
247
 
270
248
  /**
@@ -278,31 +256,7 @@ class CommonMemoryCache extends ConnectionObserver {
278
256
  * @public
279
257
  */
280
258
  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
- } );
259
+ return this.#guarded( () => this.#provider.setValues( keyValues, prefix, expiration ) );
306
260
  }
307
261
 
308
262
  /**
@@ -314,18 +268,7 @@ class CommonMemoryCache extends ConnectionObserver {
314
268
  * @public
315
269
  */
316
270
  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
- } );
271
+ return this.#guarded( () => this.#provider.getValue( key ) );
329
272
  }
330
273
 
331
274
  /**
@@ -338,21 +281,7 @@ class CommonMemoryCache extends ConnectionObserver {
338
281
  * @public
339
282
  */
340
283
  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
- } );
284
+ return this.#guarded( () => this.#provider.getValues( keys, prefix ) );
356
285
  }
357
286
 
358
287
  /**
@@ -364,19 +293,7 @@ class CommonMemoryCache extends ConnectionObserver {
364
293
  * @public
365
294
  */
366
295
  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
- } );
296
+ return this.#guarded( () => this.#provider.deleteValue( key ) );
380
297
  }
381
298
 
382
299
  /**
@@ -392,18 +309,7 @@ class CommonMemoryCache extends ConnectionObserver {
392
309
  * @public
393
310
  */
394
311
  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
- } );
312
+ return this.#guarded( () => this.#provider.expireValue( key, seconds, name ) );
407
313
  }
408
314
 
409
315
  /**
@@ -416,24 +322,7 @@ class CommonMemoryCache extends ConnectionObserver {
416
322
  * @public
417
323
  */
418
324
  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
- } );
325
+ return this.#guarded( () => this.#provider.listPushValue( listName, values ) );
437
326
  }
438
327
 
439
328
  /**
@@ -446,18 +335,7 @@ class CommonMemoryCache extends ConnectionObserver {
446
335
  * @public
447
336
  */
448
337
  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
- } );
338
+ return this.#guarded( () => this.#provider.addToSet( key, value ) );
461
339
  }
462
340
 
463
341
  /**
@@ -472,21 +350,7 @@ class CommonMemoryCache extends ConnectionObserver {
472
350
  * @public
473
351
  */
474
352
  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
- } );
353
+ return this.#guarded( () => this.#provider.addToSetMulti( keys, values ) );
490
354
  }
491
355
 
492
356
  /**
@@ -499,20 +363,7 @@ class CommonMemoryCache extends ConnectionObserver {
499
363
  * @public
500
364
  */
501
365
  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
- } );
366
+ return this.#guarded( () => this.#provider.isSetMember( setName, value ) );
516
367
  }
517
368
 
518
369
  /**
@@ -524,20 +375,7 @@ class CommonMemoryCache extends ConnectionObserver {
524
375
  * @public
525
376
  */
526
377
  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
- } );
378
+ return this.#guarded( () => this.#provider.membersOfSet( key ) );
541
379
  }
542
380
 
543
381
  /**
@@ -549,20 +387,7 @@ class CommonMemoryCache extends ConnectionObserver {
549
387
  * @public
550
388
  */
551
389
  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
- } );
390
+ return this.#guarded( () => this.#provider.unionOfSets( keys ) );
566
391
  }
567
392
 
568
393
  /**
@@ -577,18 +402,7 @@ class CommonMemoryCache extends ConnectionObserver {
577
402
  * @public
578
403
  */
579
404
  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
- } );
405
+ return this.#guarded( () => this.#provider.hashSetField( key, name, value ) );
592
406
  }
593
407
 
594
408
  /**
@@ -604,22 +418,7 @@ class CommonMemoryCache extends ConnectionObserver {
604
418
  * @public
605
419
  */
606
420
  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
- } );
421
+ return this.#guarded( () => this.#provider.hashSetFields( key, fields ) );
623
422
  }
624
423
 
625
424
  /**
@@ -632,19 +431,7 @@ class CommonMemoryCache extends ConnectionObserver {
632
431
  * @public
633
432
  */
634
433
  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
- } );
434
+ return this.#guarded( () => this.#provider.hashGetField( key, field ) );
648
435
  }
649
436
 
650
437
  /**
@@ -657,19 +444,7 @@ class CommonMemoryCache extends ConnectionObserver {
657
444
  * @public
658
445
  */
659
446
  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
- } );
447
+ return this.#guarded( () => this.#provider.hashDeleteField( key, field ) );
673
448
  }
674
449
 
675
450
  /**
@@ -687,25 +462,7 @@ class CommonMemoryCache extends ConnectionObserver {
687
462
  * @public
688
463
  */
689
464
  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
- } );
465
+ return this.#guarded( () => this.#provider.setJSON( key, value, path, overrideMode ) );
709
466
  }
710
467
 
711
468
  /**
@@ -720,22 +477,7 @@ class CommonMemoryCache extends ConnectionObserver {
720
477
  * @public
721
478
  */
722
479
  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
- } );
480
+ return this.#guarded( () => this.#provider.getJSON( key, path ) );
739
481
  }
740
482
 
741
483
  /**
@@ -751,22 +493,7 @@ class CommonMemoryCache extends ConnectionObserver {
751
493
  * @public
752
494
  */
753
495
  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
- } );
496
+ return this.#guarded( () => this.#provider.editJSON( key, value, path ) );
770
497
  }
771
498
 
772
499
  /**
@@ -782,41 +509,45 @@ class CommonMemoryCache extends ConnectionObserver {
782
509
  * @public
783
510
  */
784
511
  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
- } );
512
+ return this.#guarded( () => this.#provider.arrayAppendJSON( key, value, path ) );
801
513
  }
802
514
 
803
515
  /* Private interface */
804
516
 
805
517
  /**
806
- * Used to normalize a JSON path.
518
+ * Used to reject an operation when the cache is not usable, and to delegate it to the backend when it is.
807
519
  * <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.
520
+ * NOTE: This exists so the check lives in exactly one place. It previously stood at the head of all twenty-one
521
+ * data methods, which is twenty-one chances for a new method to be added without it.
810
522
  *
811
523
  * @method
812
- * @param {string|string[]} path
813
- * @returns {string}
524
+ * @param {function(): Promise} operation
525
+ * @returns {Promise}
526
+ */
527
+ #guarded( operation ) {
528
+ return ( this.#isOperational === true )
529
+ ? operation()
530
+ : Promise.reject( exceptions.raise( exceptions.exceptionCode.E_GEN_SYSTEM_CACHE_UNAVAILABLE ) );
531
+ }
532
+
533
+ /**
534
+ * Used to reconcile what the application requires against what the backend reports it can do.
535
+ *
536
+ * @method
537
+ * @throws {TiException.E_GEN_FEATURE_UNSUPPORTED} If any required capability is absent.
814
538
  */
815
- #normalizeJSONPath( path ) {
816
- if ( Array.isArray( path ) ) {
817
- return "$" + path.map( ( segment ) => `["${ String( segment ).replace( /\\/g, "\\\\" ).replace( /"/g, '\\"' ) }"]` ).join( "" );
539
+ #verifyRequiredCapabilities() {
540
+ let required = config.getSetting( config.setting.MEMORY_CACHE_REQUIRED_CAPABILITIES, [] );
541
+ if ( _.isEmpty( required ) === true ) {
542
+ return;
543
+ }
544
+
545
+ let missing = findMissingCapabilities( required, this.#provider.capabilities );
546
+ if ( missing.length > 0 ) {
547
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_FEATURE_UNSUPPORTED, {
548
+ details: `The configured cache backend '${ this.#provider.constructor.name }' does not provide the required ${ ( missing.length === 1 ) ? "capability" : "capabilities" }: ${ missing.join( ", " ) }.`
549
+ } );
818
550
  }
819
- return ( path.startsWith( "$" ) === false ) ? ( "$." + path ) : path;
820
551
  }
821
552
 
822
553
  }
@@ -824,7 +555,14 @@ class CommonMemoryCache extends ConnectionObserver {
824
555
  const instance = new CommonMemoryCache();
825
556
  module.exports.instance = Object.freeze( instance );
826
557
 
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;
558
+ // Re-exported from the Redis backend, where these now live along with the rest of the Redis-specific decoding. They
559
+ // stay on this module because it is the published entry point and removing them would break any consumer that reaches
560
+ // for them - including this package's own `cache-get-values` suite.
561
+ module.exports.decodeCommandValue = RedisCacheProvider.decodeCommandValue;
562
+ module.exports.mapCommandValues = RedisCacheProvider.mapCommandValues;
563
+
564
+ // Re-exported so a consumer can name a capability without reaching past this module's exports map.
565
+ module.exports.cacheCapability = cacheCapability;
566
+
567
+ // Exported for testing, per the note on the function itself.
568
+ module.exports.findMissingCapabilities = findMissingCapabilities;