import { promisify } from 'util' ; import { Flattener , IFlattener } from './flattener' ; import type { IResult , RecursivePartial } from '../interfaces' ; import { TYPE } from '../utils/type' ; import type { IOptions , ISetOptions , IDelOptions , IMultiCommands , IRedisClient , Transaction } from './jsonCache.types' ; import Config from './config' ; interface IJSONCache < T > { set ( key , obj , options ) : Promise < any > ; get ( key ) : Promise < T | undefined > ; get ( key , ... fields ) : Promise < Partial < T > | undefined > ; rewrite ( key , obj , options ? ) : Promise < any > ; clearAll ( ) : Promise < any > ; del ( key , options ? ) : Promise < any > ; incr ( key , obj , options ? ) : Promise < any > ; } /**
 * JSONCache eases the difficulties in storing a JSON in redis.
 *
 *  It stores the JSON in hashset for simpler get and set of required
 * fields. It also allows you to override/set specific fields in
 * the JSON without rewriting the whole JSON tree. Which means that it
 * is literally possible to `Object.deepAssign()`.
 *
 *   Everytime you store an object, JSONCache would store two hashset
 * in Redis, one for data and the other for type information. This helps
 * during retrieval of data, to restore the type of data which was originally
 * provided. All these workaround are needed because Redis DOES NOT support
 * any other data type apart from String.
 *
 * Well the easiest way is to store an object in Redis is
 * JSON.stringify(obj) and store the stringified result.
 * But this can cause issue when the obj is
 * too huge or when you would want to retrieve only specific fields
 * from the JSON but do not want to parse the whole JSON.
 *   Also note that this method would end up in returing all the
 * fields as strings and you would have no clue to identify the type of
 * field.
 */ export default class JSONCache < T = any > implements IJSONCache < T > { private redisClientInt : IRedisClient ; private flattener : IFlattener ; param redisClient param options constructor ( redisClient , private options = { } ) { this . options . prefix = typeof options . prefix === 'string' ? options . prefix : 'jc:' ; this . redisClientInt = { hmset : promisify ( redisClient . hmset ) . bind ( redisClient ) , hmget : promisify ( redisClient . hmget ) . bind ( redisClient ) , hgetall : promisify ( redisClient . hgetall ) . bind ( redisClient ) , expire : promisify ( redisClient . expire ) . bind ( redisClient ) , del : promisify ( redisClient . del ) . bind ( redisClient ) , scan : promisify ( redisClient . scan ) . bind ( redisClient ) , hincrbyfloat : promisify ( redisClient . hincrbyfloat ) . bind ( redisClient ) , multi : ( commands ) => { return new Promise ( ( resolve , reject ) => { redisClient . multi ( commands ) . exec ( ( err , results ) => { if ( err ) reject ( err ) ; else resolve ( results ) ; } ) ; } ) ; } , } ; this . flattener = new Flattener ( options . stringifier , options . parser ) ; } param key param obj param options public async set ( key , obj , options = { } ) { const flattened = this . flattener . flatten ( obj ) ; const commands = await this . getKeysToBeRemoved ( key , flattened ) ; this . addSetCommands ( key , flattened , commands , options . expire ) ; await this . execCommand ( commands , options . transaction ) ; } param key param fields returns public async get ( key ) ; public async get ( key , ... fields ) ; public async get ( key , ... fields ) { const [ data , typeInfo ] = await Promise . all ( [ this . redisClientInt . hgetall ( this . getKey ( key ) ) , this . redisClientInt . hgetall ( this . getTypeKey ( key ) ) , ] ) ; if ( ! ( data && typeInfo ) ) { return undefined ; } const dataKeysLen = Object . keys ( data ) . length ; const typeInfoKeysLen = Object . keys ( typeInfo ) . length ; if ( dataKeysLen !== typeInfoKeysLen || dataKeysLen === 0 ) return undefined ; let result ; if ( fields . length > 0 ) { let dataKeys ; result = fields . reduce ( ( res , field ) => { if ( field in data ) { res . data [ field ] = data [ field ] ; res . typeInfo [ field ] = typeInfo [ field ] ; } else { const searchKey = `${ field }.` ; ( dataKeys || ( dataKeys = Object . keys ( data ) ) ) . forEach ( flattenedKey => { if ( flattenedKey . startsWith ( searchKey ) ) { res . data [ flattenedKey ] = data [ flattenedKey ] ; res . typeInfo [ flattenedKey ] = typeInfo [ flattenedKey ] ; } } ) ; } return res ; } , { data : { } , typeInfo : { } } ) as IResult ; } else { result = { data , typeInfo , arrayInfo : { } } ; } return this . flattener . unflatten ( result ) as T ; } param key param obj public async rewrite ( key , obj , options = { } ) { const commands = [ [ 'del' , this . getKey ( key ) ] , [ 'del' , this . getTypeKey ( key ) ] , ] ; const flattened = this . flattener . flatten ( obj ) ; this . addSetCommands ( key , flattened , commands , options . expire ) ; await this . execCommand ( commands , options . transaction ) ; } /**
     * Removes/deletes all the keys in the JSON Cache,
     * having the prefix.
     */ public async clearAll ( ) { let cursor = '0' ; let keys ; do { [ cursor , keys ] = await this . redisClientInt . scan ( cursor , 'MATCH' , `${ this . options . prefix }*` , 'COUNT' , Config . SCAN_COUNT ) ; if ( keys . length > 0 ) { await this . redisClientInt . del ( ... keys ) ; } } while ( cursor !== '0' ) ; } param key public async del ( key , options = { } ) { const commands = [ [ 'del' , this . getKey ( key ) ] , [ 'del' , this . getTypeKey ( key ) ] , ] ; await this . execCommand ( commands , options . transaction ) ; } example param key param obj public async incr ( key , obj , options = { } ) { const flattened = this . flattener . flatten ( obj ) ; const commands = [ ] ; Object . entries ( flattened . data ) . forEach ( ( [ path , incrVal ] ) => { if ( flattened . typeInfo [ path ] !== TYPE . NUMBER ) { return ; } commands . push ( [ 'hincrbyfloat' , this . getKey ( key ) , path , incrVal ] ) ; } ) ; await this . execCommand ( commands , options . transaction ) ; } /******************
     * PRIVATE METHODS
     ******************/ private async getKeysToBeRemoved ( key , flattened ) { const commands = [ ] ; if ( Object . keys ( flattened . arrayInfo ) . length > 0 ) { const currentObj = await this . get ( key ) ; if ( currentObj ) { const currrentObjFlattened = this . flattener . flatten ( currentObj ) . data ; const keysToBeRemoved = [ ] ; Object . keys ( flattened . arrayInfo ) . forEach ( path => { Object . keys ( currrentObjFlattened ) . forEach ( objPath => { if ( objPath . startsWith ( path ) ) { keysToBeRemoved . push ( objPath ) ; } } ) ; } ) ; if ( keysToBeRemoved . length > 0 ) { commands . push ( [ 'hdel' , this . getKey ( key ) , ... keysToBeRemoved ] ) ; commands . push ( [ 'hdel' , this . getTypeKey ( key ) , ... keysToBeRemoved ] ) ; } } } return commands ; } param key private getKey ( key ) { return `${ this . options . prefix }${ key }` ; } param key private getTypeKey ( key ) { return `${ this . options . prefix }${ key }_t` ; } param key param flattened param commands param expire private addSetCommands ( key , flattened , commands , expire ? ) { commands . push ( [ 'hmset' , this . getKey ( key ) , flattened . data ] ) ; commands . push ( [ 'hmset' , this . getTypeKey ( key ) , flattened . typeInfo ] ) ; if ( expire ) { commands . push ( [ 'expire' , this . getKey ( key ) , expire ] ) ; commands . push ( [ 'expire' , this . getTypeKey ( key ) , expire ] ) ; } return commands ; } private execTransactionCommands ( commands , transaction ) { commands . forEach ( command => { const [ action , ... args ] = command ; transaction [ action ] ( ... args ) ; } ) ; } private async execCommand ( commands , transaction ? ) { if ( transaction ) { this . execTransactionCommands ( commands , transaction ) ; return transaction ; } else { const result = await this . redisClientInt . multi ( commands ) ; return result ; } } }