diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 020f823a..12289470 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -1,7 +1,7 @@ import { ILogger } from '../../logger/types'; import AbstractSegmentsCacheSync from '../AbstractSegmentsCacheSync'; import KeyBuilderCS from '../KeyBuilderCS'; -import { logPrefix, DEFINED } from './constants'; +import { LOG_PREFIX, DEFINED } from './constants'; export default class MySegmentsCacheInLocal extends AbstractSegmentsCacheSync { @@ -20,7 +20,7 @@ export default class MySegmentsCacheInLocal extends AbstractSegmentsCacheSync { * @NOTE this method is not being used at the moment. */ clear() { - this.log.info(logPrefix + 'Flushing MySegments data from localStorage'); + this.log.info(LOG_PREFIX + 'Flushing MySegments data from localStorage'); // We cannot simply call `localStorage.clear()` since that implies removing user items from the storage // We could optimize next sentence, since it implies iterating over all localStorage items @@ -34,7 +34,7 @@ export default class MySegmentsCacheInLocal extends AbstractSegmentsCacheSync { localStorage.setItem(segmentKey, DEFINED); return true; } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); return false; } } @@ -46,7 +46,7 @@ export default class MySegmentsCacheInLocal extends AbstractSegmentsCacheSync { localStorage.removeItem(segmentKey); return true; } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); return false; } } diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index 56135e57..68565b55 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -3,7 +3,7 @@ import AbstractSplitsCacheSync, { usesSegments } from '../AbstractSplitsCacheSyn import { isFiniteNumber, toNumber, isNaNNumber } from '../../utils/lang'; import KeyBuilderCS from '../KeyBuilderCS'; import { ILogger } from '../../logger/types'; -import { logPrefix } from './constants'; +import { LOG_PREFIX } from './constants'; /** * ISplitsCacheSync implementation that stores split definitions in browser LocalStorage. @@ -52,7 +52,7 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { } } } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); } } @@ -72,7 +72,7 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { } } } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); } } @@ -82,7 +82,7 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { * We cannot simply call `localStorage.clear()` since that implies removing user items from the storage. */ clear() { - this.log.info(logPrefix + 'Flushing Splits data from localStorage'); + this.log.info(LOG_PREFIX + 'Flushing Splits data from localStorage'); // collect item keys const len = localStorage.length; @@ -114,7 +114,7 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { return true; } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); return false; } } @@ -129,7 +129,7 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { return true; } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); return false; } } @@ -147,14 +147,14 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { // when using a new split query, we must update it at the store if (this.updateNewFilter) { - this.log.info(logPrefix + 'Split filter query was modified. Updating cache.'); + this.log.info(LOG_PREFIX + 'Split filter query was modified. Updating cache.'); const queryKey = this.keys.buildSplitsFilterQueryKey(); const queryString = this.splitFiltersValidation.queryString; try { if (queryString) localStorage.setItem(queryKey, queryString); else localStorage.removeItem(queryKey); } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); } this.updateNewFilter = false; } @@ -166,7 +166,7 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { this.hasSync = true; return true; } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); return false; } } @@ -273,7 +273,7 @@ export default class SplitsCacheInLocal extends AbstractSplitsCacheSync { }); } } catch (e) { - this.log.error(logPrefix + e); + this.log.error(LOG_PREFIX + e); } } // if the filter didn't change, nothing is done diff --git a/src/storages/inLocalStorage/constants.ts b/src/storages/inLocalStorage/constants.ts index 74624310..8dcb6c3e 100644 --- a/src/storages/inLocalStorage/constants.ts +++ b/src/storages/inLocalStorage/constants.ts @@ -1,2 +1,2 @@ -export const logPrefix = 'storage:localstorage: '; +export const LOG_PREFIX = 'storage:localstorage: '; export const DEFINED = '1'; diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 226d771c..a26587d1 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -10,7 +10,7 @@ import MySegmentsCacheInMemory from '../inMemory/MySegmentsCacheInMemory'; import SplitsCacheInMemory from '../inMemory/SplitsCacheInMemory'; import { DEFAULT_CACHE_EXPIRATION_IN_MILLIS } from '../../utils/constants/browser'; import { InMemoryStorageCSFactory } from '../inMemory/InMemoryStorageCS'; -import { logPrefix } from './constants'; +import { LOG_PREFIX } from './constants'; export interface InLocalStorageOptions { prefix?: string @@ -27,7 +27,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}) { // Fallback to InMemoryStorage if LocalStorage API is not available if (!isLocalStorageAvailable()) { - params.log.warn(logPrefix + 'LocalStorage API is unavailable. Fallbacking into default MEMORY storage'); + params.log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Fallbacking into default MEMORY storage'); return InMemoryStorageCSFactory(params); } diff --git a/src/storages/inRedis/EventsCacheInRedis.ts b/src/storages/inRedis/EventsCacheInRedis.ts index 5a8caab5..97f7d13f 100644 --- a/src/storages/inRedis/EventsCacheInRedis.ts +++ b/src/storages/inRedis/EventsCacheInRedis.ts @@ -4,8 +4,7 @@ import KeyBuilderSS from '../KeyBuilderSS'; import { Redis } from 'ioredis'; import { SplitIO } from '../../types'; import { ILogger } from '../../logger/types'; - -const logPrefix = 'storage:redis: '; +import { LOG_PREFIX } from './constants'; export default class EventsCacheInRedis implements IEventsCacheAsync { @@ -33,7 +32,7 @@ export default class EventsCacheInRedis implements IEventsCacheAsync { // We use boolean values to signal successful queueing .then(() => true) .catch(err => { - this.log.error(logPrefix + `Error adding event to queue: ${err}.`); + this.log.error(LOG_PREFIX + `Error adding event to queue: ${err}.`); return false; }); } diff --git a/src/storages/inRedis/RedisAdapter.ts b/src/storages/inRedis/RedisAdapter.ts index 7d0636bc..de0abee3 100644 --- a/src/storages/inRedis/RedisAdapter.ts +++ b/src/storages/inRedis/RedisAdapter.ts @@ -5,7 +5,7 @@ import { _Set, setToArray, ISet } from '../../utils/lang/sets'; import thenable from '../../utils/promise/thenable'; import timeout from '../../utils/promise/timeout'; -const logPrefix = 'storage:redis-adapter: '; +const LOG_PREFIX = 'storage:redis-adapter: '; // If we ever decide to fully wrap every method, there's a Commander.getBuiltinCommands from ioredis. const METHODS_TO_PROMISE_WRAP = ['set', 'exec', 'del', 'get', 'keys', 'sadd', 'srem', 'sismember', 'smembers', 'incr', 'rpush', 'pipeline', 'expire', 'mget']; @@ -55,16 +55,16 @@ export default class RedisAdapter extends ioredis { _listenToEvents() { this.once('ready', () => { const commandsCount = this._notReadyCommandsQueue ? this._notReadyCommandsQueue.length : 0; - this.log.info(logPrefix + `Redis connection established. Queued commands: ${commandsCount}.`); + this.log.info(LOG_PREFIX + `Redis connection established. Queued commands: ${commandsCount}.`); this._notReadyCommandsQueue && this._notReadyCommandsQueue.forEach(queued => { - this.log.info(logPrefix + `Executing queued ${queued.name} command.`); + this.log.info(LOG_PREFIX + `Executing queued ${queued.name} command.`); queued.command().then(queued.resolve).catch(queued.reject); }); // After the SDK is ready for the first time we'll stop queueing commands. This is just so we can keep handling BUR for them. this._notReadyCommandsQueue = undefined; }); this.once('close', () => { - this.log.info(logPrefix + 'Redis connection closed.'); + this.log.info(LOG_PREFIX + 'Redis connection closed.'); }); } @@ -78,7 +78,7 @@ export default class RedisAdapter extends ioredis { const params = arguments; function commandWrapper() { - instance.log.debug(logPrefix + `Executing ${method}.`); + instance.log.debug(LOG_PREFIX + `Executing ${method}.`); // Return original method const result = originalMethod.apply(instance, params); @@ -93,7 +93,7 @@ export default class RedisAdapter extends ioredis { result.then(cleanUpRunningCommandsCb, cleanUpRunningCommandsCb); return timeout(instance._options.operationTimeout, result).catch(err => { - instance.log.error(logPrefix + `${method} operation threw an error or exceeded configured timeout of ${instance._options.operationTimeout}ms. Message: ${err}`); + instance.log.error(LOG_PREFIX + `${method} operation threw an error or exceeded configured timeout of ${instance._options.operationTimeout}ms. Message: ${err}`); // Handling is not the adapter responsibility. throw err; }); @@ -126,19 +126,19 @@ export default class RedisAdapter extends ioredis { setTimeout(function deferedDisconnect() { if (instance._runningCommands.size > 0) { - instance.log.info(logPrefix + `Attempting to disconnect but there are ${instance._runningCommands.size} commands still waiting for resolution. Defering disconnection until those finish.`); + instance.log.info(LOG_PREFIX + `Attempting to disconnect but there are ${instance._runningCommands.size} commands still waiting for resolution. Defering disconnection until those finish.`); Promise.all(setToArray(instance._runningCommands)) .then(() => { - instance.log.debug(logPrefix + 'Pending commands finished successfully, disconnecting.'); + instance.log.debug(LOG_PREFIX + 'Pending commands finished successfully, disconnecting.'); originalMethod.apply(instance, params); }) .catch(e => { - instance.log.warn(logPrefix + `Pending commands finished with error: ${e}. Proceeding with disconnection.`); + instance.log.warn(LOG_PREFIX + `Pending commands finished with error: ${e}. Proceeding with disconnection.`); originalMethod.apply(instance, params); }); } else { - instance.log.debug(logPrefix + 'No commands pending execution, disconnect.'); + instance.log.debug(LOG_PREFIX + 'No commands pending execution, disconnect.'); // Nothing pending, just proceed. originalMethod.apply(instance, params); } diff --git a/src/storages/inRedis/SplitsCacheInRedis.ts b/src/storages/inRedis/SplitsCacheInRedis.ts index 726ec0a6..d3c8d082 100644 --- a/src/storages/inRedis/SplitsCacheInRedis.ts +++ b/src/storages/inRedis/SplitsCacheInRedis.ts @@ -3,9 +3,7 @@ import KeyBuilderSS from '../KeyBuilderSS'; import { ISplitsCacheAsync } from '../types'; import { Redis } from 'ioredis'; import { ILogger } from '../../logger/types'; -import { SplitError } from '../../utils/lang/errors'; - -const logPrefix = 'storage:redis: '; +import { LOG_PREFIX } from './constants'; /** * Discard errors for an answer of multiple operations. @@ -90,11 +88,11 @@ export default class SplitsCacheInRedis implements ISplitsCacheAsync { /** * Get split definition or null if it's not defined. - * Returned promise is Rejected with an SplitError if redis operation fails. + * Returned promise is rejected if redis operation fails. */ getSplit(name: string): Promise { if (this.redisError) { - this.log.error(logPrefix + this.redisError); + this.log.error(LOG_PREFIX + this.redisError); return Promise.reject(this.redisError); // no need to wrap as an SplitError } @@ -150,14 +148,14 @@ export default class SplitsCacheInRedis implements ISplitsCacheAsync { ttCount = parseInt(ttCount as string, 10); if (!isFiniteNumber(ttCount) || ttCount < 0) { - this.log.info(logPrefix + `Could not validate traffic type existence of ${trafficType} due to data corruption of some sorts.`); + this.log.info(LOG_PREFIX + `Could not validate traffic type existance of ${trafficType} due to data corruption of some sorts.`); return false; } return ttCount > 0; }) .catch(e => { - this.log.error(logPrefix + `Could not validate traffic type existence of ${trafficType} due to an error: ${e}.`); + this.log.error(LOG_PREFIX + `Could not validate traffic type existance of ${trafficType} due to an error: ${e}.`); // If there is an error, bypass the validation so the event can get tracked. return true; }); @@ -179,11 +177,11 @@ export default class SplitsCacheInRedis implements ISplitsCacheAsync { /** * Fetches multiple splits definitions. - * Returned promise is Rejected with an SplitError if redis operation fails. + * Returned promise is rejected if redis operation fails. */ getSplits(names: string[]): Promise> { if (this.redisError) { - this.log.error(logPrefix + this.redisError); + this.log.error(LOG_PREFIX + this.redisError); return Promise.reject(this.redisError); // no need to wrap as an SplitError } @@ -198,8 +196,8 @@ export default class SplitsCacheInRedis implements ISplitsCacheAsync { return Promise.resolve(splits); }) .catch(e => { - this.log.error(logPrefix + `Could not grab splits due to an error: ${e}.`); - return Promise.reject(new SplitError(e)); + this.log.error(LOG_PREFIX + `Could not grab splits due to an error: ${e}.`); + return Promise.reject(e); }); } diff --git a/src/storages/inRedis/__tests__/RedisAdapter.spec.ts b/src/storages/inRedis/__tests__/RedisAdapter.spec.ts index ba369d8c..9557162d 100644 --- a/src/storages/inRedis/__tests__/RedisAdapter.spec.ts +++ b/src/storages/inRedis/__tests__/RedisAdapter.spec.ts @@ -6,7 +6,7 @@ import { _Set, setToArray } from '../../../utils/lang/sets'; // Mocking sdkLogger import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; -const logPrefix = 'storage:redis-adapter: '; +const LOG_PREFIX = 'storage:redis-adapter: '; // Mocking ioredis @@ -188,7 +188,7 @@ describe('STORAGE Redis Adapter', () => { secondCallArgs[1](); // Execute the callback for "close" expect(loggerMock.info).toBeCalledTimes(1); // The callback for the "close" event will only log info to the user about what is going on. - expect(loggerMock.info.mock.calls[0]).toEqual([logPrefix + 'Redis connection closed.']); // The callback for the "close" event will only log info to the user about what is going on. + expect(loggerMock.info.mock.calls[0]).toEqual([LOG_PREFIX + 'Redis connection closed.']); // The callback for the "close" event will only log info to the user about what is going on. loggerMock.info.mockClear(); expect(loggerMock.info).not.toBeCalled(); // Control assertion @@ -198,7 +198,7 @@ describe('STORAGE Redis Adapter', () => { firstCallArgs[1](); expect(loggerMock.info).toBeCalledTimes(1); // The callback for the "ready" event will inform the user about the trigger. - expect(loggerMock.info).toBeCalledWith(logPrefix + 'Redis connection established. Queued commands: 0.'); // The callback for the "ready" event will inform the user about the trigger. + expect(loggerMock.info).toBeCalledWith(LOG_PREFIX + 'Redis connection established. Queued commands: 0.'); // The callback for the "ready" event will inform the user about the trigger. expect(instance._notReadyCommandsQueue).toBe(undefined); // After the DB is ready, it will clean up the offline commands queue so we do not queue commands anymore. // Don't do this at home @@ -222,9 +222,9 @@ describe('STORAGE Redis Adapter', () => { expect(loggerMock.info).toBeCalledTimes(3); // If we had queued commands, it will log the event (1 call) as well as each executed command (n calls). expect(loggerMock.info.mock.calls).toEqual([ - [logPrefix + 'Redis connection established. Queued commands: 2.'], // The callback for the "ready" event will inform the user about the trigger and the amount of queued commands. - [logPrefix + 'Executing queued GET command.'], // If we had queued, it will log the event as well as each executed command. - [logPrefix + 'Executing queued SET command.'] // If we had queued commands, it will log the event as well as each executed command. + [LOG_PREFIX + 'Redis connection established. Queued commands: 2.'], // The callback for the "ready" event will inform the user about the trigger and the amount of queued commands. + [LOG_PREFIX + 'Executing queued GET command.'], // If we had queued, it will log the event as well as each executed command. + [LOG_PREFIX + 'Executing queued SET command.'] // If we had queued commands, it will log the event as well as each executed command. ]); expect(queuedGetCommand.command).toBeCalledTimes(1); // It will execute each queued command. @@ -287,7 +287,7 @@ describe('STORAGE Redis Adapter', () => { commandTimeoutResolver.rej('test'); setTimeout(() => { // Allow the promises to tick. expect(instance._runningCommands.has(commandTimeoutResolver.originalPromise)).toBe(false); // After a command finishes with error, it's promise is removed from the instance._runningCommands queue. - expect(loggerMock.error.mock.calls[index]).toEqual([`${logPrefix}${methodName} operation threw an error or exceeded configured timeout of 5000ms. Message: test`]); // The log error method should be called with the corresponding messages, depending on the method, error and operationTimeout. + expect(loggerMock.error.mock.calls[index]).toEqual([`${LOG_PREFIX}${methodName} operation threw an error or exceeded configured timeout of 5000ms. Message: test`]); // The log error method should be called with the corresponding messages, depending on the method, error and operationTimeout. }, 0); }); @@ -334,7 +334,7 @@ describe('STORAGE Redis Adapter', () => { expect(ioredisMock.disconnect).not.toBeCalled(); // Original method should not be called right away. setTimeout(() => { // o queued commands timeout wrapper. - expect(loggerMock.debug.mock.calls).toEqual([[logPrefix + 'No commands pending execution, disconnect.']]); + expect(loggerMock.debug.mock.calls).toEqual([[LOG_PREFIX + 'No commands pending execution, disconnect.']]); expect(ioredisMock.disconnect).toBeCalledTimes(1); // Original method should have been called once, asynchronously loggerMock.debug.mockClear(); ioredisMock.disconnect.mockClear(); @@ -347,11 +347,11 @@ describe('STORAGE Redis Adapter', () => { rejectedPromise.catch(() => { }); // Swallow the unhandled to avoid unhandledRejection warns setTimeout(() => { // queued with rejection timeout wrapper - expect(loggerMock.info.mock.calls).toEqual([[logPrefix + 'Attempting to disconnect but there are 2 commands still waiting for resolution. Defering disconnection until those finish.']]); + expect(loggerMock.info.mock.calls).toEqual([[LOG_PREFIX + 'Attempting to disconnect but there are 2 commands still waiting for resolution. Defering disconnection until those finish.']]); Promise.all(setToArray(instance._runningCommands)).catch(e => { setImmediate(() => { // Allow the callback to execute before checking. - expect(loggerMock.warn.mock.calls[0]).toEqual([`${logPrefix}Pending commands finished with error: ${e}. Proceeding with disconnection.`]); // Should warn about the error but tell user that will disconnect anyways. + expect(loggerMock.warn.mock.calls[0]).toEqual([`${LOG_PREFIX}Pending commands finished with error: ${e}. Proceeding with disconnection.`]); // Should warn about the error but tell user that will disconnect anyways. expect(ioredisMock.disconnect).toBeCalledTimes(1); // Original method should have been called once, asynchronously loggerMock.info.mockClear(); @@ -367,11 +367,11 @@ describe('STORAGE Redis Adapter', () => { instance._runningCommands.add(Promise.resolve()); setTimeout(() => { - expect(loggerMock.info.mock.calls).toEqual([[logPrefix + 'Attempting to disconnect but there are 4 commands still waiting for resolution. Defering disconnection until those finish.']]); + expect(loggerMock.info.mock.calls).toEqual([[LOG_PREFIX + 'Attempting to disconnect but there are 4 commands still waiting for resolution. Defering disconnection until those finish.']]); Promise.all(setToArray(instance._runningCommands)).then(() => { // This one will go through success path setImmediate(() => { - expect(loggerMock.debug.mock.calls).toEqual([[logPrefix + 'Pending commands finished successfully, disconnecting.']]); + expect(loggerMock.debug.mock.calls).toEqual([[LOG_PREFIX + 'Pending commands finished successfully, disconnecting.']]); expect(ioredisMock.disconnect).toBeCalledTimes(1); // Original method should have been called once, asynchronously }); }); diff --git a/src/storages/inRedis/constants.ts b/src/storages/inRedis/constants.ts new file mode 100644 index 00000000..a3856844 --- /dev/null +++ b/src/storages/inRedis/constants.ts @@ -0,0 +1 @@ +export const LOG_PREFIX = 'storage:redis: '; diff --git a/src/storages/pluggable/EventsCachePluggable.ts b/src/storages/pluggable/EventsCachePluggable.ts new file mode 100644 index 00000000..2af117d7 --- /dev/null +++ b/src/storages/pluggable/EventsCachePluggable.ts @@ -0,0 +1,48 @@ +import { ICustomStorageWrapper, IEventsCacheAsync } from '../types'; +import { IRedisMetadata } from '../../dtos/types'; +import KeyBuilderSS from '../KeyBuilderSS'; +import { SplitIO } from '../../types'; +import { ILogger } from '../../logger/types'; +import { LOG_PREFIX } from './constants'; + +export class EventsCachePluggable implements IEventsCacheAsync { + + private readonly log: ILogger; + private readonly wrapper: ICustomStorageWrapper; + private readonly keys: KeyBuilderSS; + private readonly metadata: IRedisMetadata; + + constructor(log: ILogger, keys: KeyBuilderSS, wrapper: ICustomStorageWrapper, metadata: IRedisMetadata) { + this.log = log; + this.keys = keys; + this.wrapper = wrapper; + this.metadata = metadata; + } + + /** + * Push given event to the storage. + * @param eventData Event item to push. + * @returns A promise that is resolved with a boolean value indicating if the push operation succeeded or failed. + * The promise will never be rejected. + */ + track(eventData: SplitIO.EventData): Promise { + return this.wrapper.pushItems( + this.keys.buildEventsKey(), + [this._toJSON(eventData)] + ) + // We use boolean values to signal successful queueing + .then(() => true) + .catch(e => { + this.log.error(LOG_PREFIX + ` Error adding event to queue: ${e}.`); + return false; + }); + } + + private _toJSON(eventData: SplitIO.EventData): string { + return JSON.stringify({ + m: this.metadata, + e: eventData + }); + } + +} diff --git a/src/storages/pluggable/ImpressionsCachePluggable.ts b/src/storages/pluggable/ImpressionsCachePluggable.ts new file mode 100644 index 00000000..31436c43 --- /dev/null +++ b/src/storages/pluggable/ImpressionsCachePluggable.ts @@ -0,0 +1,64 @@ +import { ICustomStorageWrapper, IImpressionsCacheAsync } from '../types'; +import { IRedisMetadata } from '../../dtos/types'; +import { ImpressionDTO } from '../../types'; +import KeyBuilderSS from '../KeyBuilderSS'; +import { ILogger } from '../../logger/types'; +import { LOG_PREFIX } from './constants'; + +export class ImpressionsCachePluggable implements IImpressionsCacheAsync { + + private readonly log: ILogger; + private readonly keys: KeyBuilderSS; + private readonly wrapper: ICustomStorageWrapper; + private readonly metadata: IRedisMetadata; + + constructor(log: ILogger, keys: KeyBuilderSS, wrapper: ICustomStorageWrapper, metadata: IRedisMetadata) { + this.log = log; + this.keys = keys; + this.wrapper = wrapper; + this.metadata = metadata; + } + + /** + * Push given impressions to the storage. + * @param impressions List of impresions to push. + * @returns A promise that is resolved with a boolean value indicating if the push operation succeeded or failed. + * The promise will never be rejected. + */ + track(impressions: ImpressionDTO[]): Promise { + return this.wrapper.pushItems( + this.keys.buildImpressionsKey(), + this._toJSON(impressions) + ) + // We use boolean values to signal successful queueing + .then(() => true) + .catch((e) => { + this.log.error(LOG_PREFIX + ` Error adding event to queue: ${e}.`); + return false; + }); + } + + private _toJSON(impressions: ImpressionDTO[]): string[] { + return impressions.map(impression => { + const { + keyName, bucketingKey, feature, treatment, label, time, changeNumber + } = impression; + + return JSON.stringify({ + m: this.metadata, + i: { + k: keyName, + b: bucketingKey, + f: feature, + t: treatment, + r: label, + c: changeNumber, + m: time + } + }); + }); + } + + // @TODO implement producer methods + +} diff --git a/src/storages/pluggable/SegmentsCachePluggable.ts b/src/storages/pluggable/SegmentsCachePluggable.ts new file mode 100644 index 00000000..612b5ff0 --- /dev/null +++ b/src/storages/pluggable/SegmentsCachePluggable.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-vars */ +import { isNaNNumber } from '../../utils/lang'; +import KeyBuilder from '../KeyBuilder'; +import { ICustomStorageWrapper, ISegmentsCacheAsync } from '../types'; +import { ILogger } from '../../logger/types'; +import { LOG_PREFIX } from './constants'; + +/** + * ISegmentsCacheAsync implementation for pluggable storages. + */ +export class SegmentsCachePluggable implements ISegmentsCacheAsync { + + private readonly log: ILogger; + private readonly keys: KeyBuilder; + private readonly wrapper: ICustomStorageWrapper; + + constructor(log: ILogger, keys: KeyBuilder, wrapper: ICustomStorageWrapper) { + this.log = log; + this.keys = keys; + this.wrapper = wrapper; + } + + /** + * Returns if `key` is part of `name` segment. + */ + isInSegment(name: string, key: string) { + return this.wrapper.itemContains(this.keys.buildSegmentNameKey(name), key); + } + + /** + * Set till number for the given segment `name`. + * The returned promise is resolved when the operation success, + * or rejected with an SplitError if it fails (e.g., wrapper operation fails). + */ + setChangeNumber(name: string, changeNumber: number) { + return this.wrapper.set( + this.keys.buildSegmentTillKey(name), changeNumber + '' + ); + } + + /** + * Get till number or -1 if it's not defined. + * The returned promise is resolved with the changeNumber or -1 if it doesn't exist or a wrapper operation fails. + * The promise will never be rejected. + */ + getChangeNumber(name: string) { + return this.wrapper.get(this.keys.buildSegmentTillKey(name)).then((value: string | null) => { + const i = parseInt(value as string, 10); + return isNaNNumber(i) ? -1 : i; + }).catch((e) => { + this.log.error(LOG_PREFIX + 'Could not retrieve changeNumber from segments storage. Error: ' + e); + return -1; + }); + } + + /** + * Add a list of `segmentKeys` to the given segment `name`. + * The returned promise is resolved when the operation success + * or rejected with an SplitError if it fails (e.g., wrapper operation fails, JSON parsing error) + * + * @TODO implement for DataLoader/Producer mode + */ + addToSegment(name: string, segmentKeys: string[]) { + return Promise.resolve(true); + } + + /** + * Remove a list of `segmentKeys` from the given segment `name`. + * The returned promise is resolved when the operation success + * or rejected with an SplitError if it fails (e.g., wrapper operation fails, JSON parsing error) + * + * @TODO implement for DataLoader/Producer mode + */ + removeFromSegment(name: string, segmentKeys: string[]) { + return Promise.resolve(true); + } + + /** @TODO implement for DataLoader/Producer mode */ + registerSegments(names: string[]) { + return Promise.resolve(true); + } + + /** @TODO implement for DataLoader/Producer mode */ + getRegisteredSegments() { + return Promise.resolve([]); + } + + /** @TODO implement for DataLoader/Producer mode */ + clear(): Promise { + return Promise.resolve(true); + } +} diff --git a/src/storages/pluggable/SplitsCachePluggable.ts b/src/storages/pluggable/SplitsCachePluggable.ts index cc035cea..9362aaab 100644 --- a/src/storages/pluggable/SplitsCachePluggable.ts +++ b/src/storages/pluggable/SplitsCachePluggable.ts @@ -4,7 +4,7 @@ import { ICustomStorageWrapper, ISplitsCacheAsync } from '../types'; import { ILogger } from '../../logger/types'; import { usesSegments } from '../AbstractSplitsCacheSync'; import { ISplit } from '../../dtos/types'; -import { logPrefix } from './constants'; +import { LOG_PREFIX } from './constants'; import { SplitError } from '../../utils/lang/errors'; /** @@ -180,13 +180,13 @@ export class SplitsCachePluggable implements ISplitsCacheAsync { ttCount = parseInt(ttCount as string, 10); if (!isFiniteNumber(ttCount) || ttCount < 0) { - this.log.info(logPrefix + `Could not validate traffic type existence of ${trafficType} due to data corruption of some sorts.`); + this.log.info(LOG_PREFIX + `Could not validate traffic type existence of ${trafficType} due to data corruption of some sorts.`); return false; } return ttCount > 0; }).catch(e => { - this.log.error(logPrefix + `Could not validate traffic type existence of ${trafficType} due to an error: ${e}.`); + this.log.error(LOG_PREFIX + `Could not validate traffic type existence of ${trafficType} due to an error: ${e}.`); // If there is an error, bypass the validation so the event can get tracked. return true; }); @@ -212,7 +212,7 @@ export class SplitsCachePluggable implements ISplitsCacheAsync { return isNaNNumber(i) ? -1 : i; }).catch((e) => { - this.log.error(logPrefix + 'Could not retrieve changeNumber from storage. Error: ' + e); + this.log.error(LOG_PREFIX + 'Could not retrieve changeNumber from storage. Error: ' + e); return -1; }); } diff --git a/src/storages/pluggable/__tests__/EventsCachePluggable.spec.ts b/src/storages/pluggable/__tests__/EventsCachePluggable.spec.ts new file mode 100644 index 00000000..9bb8cef0 --- /dev/null +++ b/src/storages/pluggable/__tests__/EventsCachePluggable.spec.ts @@ -0,0 +1,77 @@ +import find from 'lodash/find'; +import isEqual from 'lodash/isEqual'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import KeyBuilderSS from '../../KeyBuilderSS'; +import { EventsCachePluggable } from '../EventsCachePluggable'; +import { metadataBuilder } from '../../inRedis'; +import { wrapperMockFactory } from './wrapper.mock'; +import { wrapperAdapter } from '../wrapperAdapter'; + +const prefix = 'events_cache_ut'; +const metadata = { version: 'js_someversion', ip: 'some_ip', hostname: 'some_hostname' }; +const keys = new KeyBuilderSS(prefix, metadata); +const key = keys.buildEventsKey(); + +const fakeRedisMetadata = metadataBuilder(metadata); +const fakeEvent1 = { event: 1 }; +const fakeEvent2 = { event: '2' }; +const fakeEvent3 = { event: null }; + +describe('PLUGGABLE EVENTS CACHE', () => { + + test('`track` method should push values into the pluggable storage', async () => { + const wrapperMock = wrapperMockFactory(); + const cache = new EventsCachePluggable(loggerMock, keys, wrapperMock, fakeRedisMetadata); + + // @ts-expect-error + expect(await cache.track(fakeEvent1)).toBe(true); // If the queueing operation was successful, it should resolve the returned promise with "true" + // @ts-expect-error + expect(await cache.track(fakeEvent2)).toBe(true); // If the queueing operation was successful, it should resolve the returned promise with "true" + // @ts-expect-error + expect(await cache.track(fakeEvent3)).toBe(true); // If the queueing operation was successful, it should resolve the returned promise with "true" + + const values = wrapperMock._cache[key]; + + expect(values.length).toBe(3); // After pushing we should have as many events as we have stored. + expect(typeof values[0]).toBe('string'); // All elements should be strings since those are stringified JSONs. + expect(typeof values[1]).toBe('string'); // All elements should be strings since those are stringified JSONs. + expect(typeof values[2]).toBe('string'); // All elements should be strings since those are stringified JSONs. + + const findMatchingElem = (event: any) => { + return find(values, elem => { + const parsedElem = JSON.parse(elem); + return isEqual(parsedElem.e, event) && isEqual(parsedElem.m, fakeRedisMetadata); + }); + }; + + /* If the elements are found, then the values are correct. */ + const foundEv1 = findMatchingElem(fakeEvent1); + const foundEv2 = findMatchingElem(fakeEvent2); + const foundEv3 = findMatchingElem(fakeEvent3); + expect(foundEv1).not.toBe(undefined); // stored events matched the values we are expecting. + expect(foundEv2).not.toBe(undefined); // stored events matched the values we are expecting. + expect(foundEv3).not.toBe(undefined); // stored events matched the values we are expecting. + + wrapperMock.mockClear(); + }); + + test('`track` method result should not reject if wrapper operation fails', async () => { + const wrapperMock = wrapperMockFactory(); + // @ts-ignore. I'll use a "bad" queue to force an exception with the pushItems wrapper operation. + wrapperMock._cache['non-list-key'] = 10; + // @ts-expect-error. wrapperMock is adapted this time to properly handle unexpected exceptions + const faultyCache = new EventsCachePluggable(loggerMock, { + buildEventsKey: () => 'non-list-key' + }, wrapperAdapter(loggerMock, wrapperMock), fakeRedisMetadata); + + // @ts-expect-error + expect(await faultyCache.track(fakeEvent1)).toBe(false); // If the queueing operation was NOT successful, it should resolve the returned promise with "false" instead of rejecting it. + // @ts-expect-error + expect(await faultyCache.track(fakeEvent2)).toBe(false); // If the queueing operation was NOT successful, it should resolve the returned promise with "false" instead of rejecting it. + // @ts-expect-error + expect(await faultyCache.track(fakeEvent3)).toBe(false); // If the queueing operation was NOT successful, it should resolve the returned promise with "false" instead of rejecting it. + + wrapperMock.mockClear(); + }); + +}); diff --git a/src/storages/pluggable/__tests__/ImpressionsCachePluggable.spec.ts b/src/storages/pluggable/__tests__/ImpressionsCachePluggable.spec.ts new file mode 100644 index 00000000..00a88d95 --- /dev/null +++ b/src/storages/pluggable/__tests__/ImpressionsCachePluggable.spec.ts @@ -0,0 +1,84 @@ + +import KeyBuilderSS from '../../KeyBuilderSS'; +import { ImpressionsCachePluggable } from '../ImpressionsCachePluggable'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { wrapperMock } from './wrapper.mock'; +import { IRedisMetadata } from '../../../dtos/types'; + +const prefix = 'impr_cache_ut'; +const impressionsKey = `${prefix}.impressions`; +const testMeta: IRedisMetadata = { i: 'some_ip', n: 'some_host', s: 'some_sdk_version' }; // @ts-ignore +const keys = new KeyBuilderSS(prefix, testMeta); + +const o1 = { + feature: 'test1', + keyName: 'facundo@split.io', + treatment: 'on', + time: Date.now(), + label: 'default rule', + changeNumber: 1 +}; + +const o2 = { + feature: 'test2', + keyName: 'pepep@split.io', + treatment: 'A', + time: Date.now(), + bucketingKey: '1234-5678', + label: 'is in segment', + changeNumber: 1 +}; + +const o3 = { + feature: 'test3', + keyName: 'pipiip@split.io', + treatment: 'B', + time: Date.now(), + label: 'default rule', + changeNumber: 1 +}; + +describe('PLUGGABLE IMPRESSIONS CACHE', () => { + + afterEach(() => { + loggerMock.mockClear(); + wrapperMock.mockClear(); + }); + + test('`track` method should push values into the pluggable storage', async () => { + const cache = new ImpressionsCachePluggable(loggerMock, keys, wrapperMock, testMeta); + + expect(await cache.track([o1])).toBe(true); // result should resolve to true + + let state = wrapperMock._cache[impressionsKey]; + expect(state.length).toBe(1); // impression should be stored + + expect(await cache.track([o2, o3])).toBe(true); + + state = wrapperMock._cache[impressionsKey]; + expect(state.length).toBe(3); + // This is testing both the track and the toJSON method. + expect(state[0]).toBe(JSON.stringify({ + m: testMeta, + i: { k: o1.keyName, f: o1.feature, t: o1.treatment, r: o1.label, c: o1.changeNumber, m: o1.time } + })); + expect(state[1]).toBe(JSON.stringify({ + m: testMeta, + i: { k: o2.keyName, b: o2.bucketingKey, f: o2.feature, t: o2.treatment, r: o2.label, c: o2.changeNumber, m: o2.time } + })); + expect(state[2]).toBe(JSON.stringify({ + m: testMeta, + i: { k: o3.keyName, f: o3.feature, t: o3.treatment, r: o3.label, c: o3.changeNumber, m: o3.time } + })); + + }); + + test('`track` method result should not reject if wrapper operation fails', async () => { + // make wrapper operation fail + wrapperMock.pushItems.mockImplementation(() => Promise.reject()); + const cache = new ImpressionsCachePluggable(loggerMock, keys, wrapperMock, testMeta); + + expect(await cache.track([o1])).toBe(false); // result should resolve to false + }); + +}); diff --git a/src/storages/pluggable/__tests__/SegmentsCachePluggable.spec.ts b/src/storages/pluggable/__tests__/SegmentsCachePluggable.spec.ts new file mode 100644 index 00000000..ad95f3ff --- /dev/null +++ b/src/storages/pluggable/__tests__/SegmentsCachePluggable.spec.ts @@ -0,0 +1,36 @@ +import { SegmentsCachePluggable } from '../SegmentsCachePluggable'; +import KeyBuilder from '../../KeyBuilder'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { wrapperMock } from './wrapper.mock'; + +const keyBuilder = new KeyBuilder(); + +describe('SEGMENTS CACHE PLUGGABLE', () => { + + afterEach(() => { + loggerMock.mockClear(); + wrapperMock.mockClear(); + }); + + test('isInSegment, set/getChangeNumber', async () => { + const cache = new SegmentsCachePluggable(loggerMock, keyBuilder, wrapperMock); + + // assert setChangeNumber and getChangeNumber + expect(await cache.setChangeNumber('mocked-segment', 100)).toBe(false); + expect(await cache.getChangeNumber('mocked-segment')).toBe(100); + expect(await cache.setChangeNumber('mocked-segment', 200)).toBe(true); + expect(await cache.getChangeNumber('mocked-segment')).toBe(200); + expect(await cache.getChangeNumber('inexistent-segment')).toBe(-1); // -1 if the segment doesn't exist + + // mock segment keys + wrapperMock._cache[keyBuilder.buildSegmentNameKey('mocked-segment')] = ['b', 'd']; + + expect(await cache.isInSegment('mocked-segment', 'a')).toBe(false); + expect(await cache.isInSegment('mocked-segment', 'b')).toBe(true); + expect(await cache.isInSegment('mocked-segment', 'c')).toBe(false); + expect(await cache.isInSegment('mocked-segment', 'd')).toBe(true); + + expect(await cache.isInSegment('inexistent-segment', 'a')).toBe(false); + }); + +}); diff --git a/src/storages/pluggable/__tests__/wrapper.mock.ts b/src/storages/pluggable/__tests__/wrapper.mock.ts index 1f8af875..60e8b772 100644 --- a/src/storages/pluggable/__tests__/wrapper.mock.ts +++ b/src/storages/pluggable/__tests__/wrapper.mock.ts @@ -1,90 +1,106 @@ import { startsWith, toNumber } from '../../../utils/lang'; -let _cache: Record = {}; -let _queues: Record = {}; +// Creates an in memory ICustomStorageWrapper implementation with Jest mocks +export function wrapperMockFactory() { -// An in memory ICustomStorageWrapper implementation with Jest mocks -export const wrapperMock = { + /** Holds items and list of items */ + let _cache: Record = {}; - _cache, - _queues, + return { + _cache, - get: jest.fn((key => { - return Promise.resolve(key in _cache ? _cache[key] : null); - })), - set: jest.fn((key: string, value: string) => { - const result = key in _cache; - _cache[key] = value; - return Promise.resolve(result); - }), - getAndSet: jest.fn((key: string, value: string) => { - const result = key in _cache ? _cache[key] : null; - _cache[key] = value; - return Promise.resolve(result); - }), - del: jest.fn((key: string) => { - const result = key in _cache; - delete _cache[key]; - return Promise.resolve(result); - }), - getKeysByPrefix: jest.fn((prefix: string) => { - return Promise.resolve(Object.keys(_cache).filter(key => startsWith(key, prefix))); - }), - getByPrefix: jest.fn((prefix: string) => { - return Promise.resolve(Object.keys(_cache).filter(key => startsWith(key, prefix)).map(key => _cache[key])); - }), - incr: jest.fn((key: string) => { - if (key in _cache) { - const count = toNumber(_cache[key]) + 1; - if (isNaN(count)) return Promise.resolve(false); - _cache[key] = count + ''; - } else { - _cache[key] = '1'; - } - return Promise.resolve(true); - }), - decr: jest.fn((key: string) => { - if (key in _cache) { - const count = toNumber(_cache[key]) - 1; - if (isNaN(count)) return Promise.resolve(false); - _cache[key] = count + ''; - } else { - _cache[key] = '-1'; - } - return Promise.resolve(true); - }), - getMany: jest.fn((keys: string[]) => { - return Promise.resolve(keys.map(key => _cache[key] ? _cache[key] : null)); - }), - pushItems: jest.fn((key: string, items: string[]) => { - if (!(key in _queues)) _queues[key] = []; - _queues[key].push(...items); - return Promise.resolve(true); - }), - popItems: jest.fn((key: string, count: number) => { - return Promise.resolve(key in _queues ? _queues[key].splice(0, count) : []); - }), - getItemsCount: jest.fn((key: string) => { - return Promise.resolve(key in _queues ? _queues[key].length : 0); - }), - itemContains: jest.fn((key: string, item: string) => { - return Promise.resolve(key in _queues && _queues[key].indexOf(item) > -1 ? true : false); - }), + get: jest.fn((key => { + return Promise.resolve(key in _cache ? _cache[key] as string : null); + })), + set: jest.fn((key: string, value: string) => { + const result = key in _cache; + _cache[key] = value; + return Promise.resolve(result); + }), + getAndSet: jest.fn((key: string, value: string) => { + const result = key in _cache ? _cache[key] as string : null; + _cache[key] = value; + return Promise.resolve(result); + }), + del: jest.fn((key: string) => { + const result = key in _cache; + delete _cache[key]; + return Promise.resolve(result); + }), + getKeysByPrefix: jest.fn((prefix: string) => { + return Promise.resolve(Object.keys(_cache).filter(key => startsWith(key, prefix))); + }), + getByPrefix: jest.fn((prefix: string) => { + return Promise.resolve(Object.keys(_cache).filter(key => startsWith(key, prefix)).map(key => _cache[key] as string)); + }), + incr: jest.fn((key: string) => { + if (key in _cache) { + const count = toNumber(_cache[key]) + 1; + if (isNaN(count)) return Promise.resolve(false); + _cache[key] = count + ''; + } else { + _cache[key] = '1'; + } + return Promise.resolve(true); + }), + decr: jest.fn((key: string) => { + if (key in _cache) { + const count = toNumber(_cache[key]) - 1; + if (isNaN(count)) return Promise.resolve(false); + _cache[key] = count + ''; + } else { + _cache[key] = '-1'; + } + return Promise.resolve(true); + }), + getMany: jest.fn((keys: string[]) => { + return Promise.resolve(keys.map(key => _cache[key] ? _cache[key] as string : null)); + }), + pushItems: jest.fn((key: string, items: string[]) => { // @ts-ignore + if (!(key in _cache)) _cache[key] = []; + const list = _cache[key]; + if (Array.isArray(list)) { + list.push(...items); + return Promise.resolve(); + } + return Promise.reject('key is not a list'); + }), + popItems: jest.fn((key: string, count: number) => { + const list = _cache[key]; + return Promise.resolve(Array.isArray(list) ? list.splice(0, count) : []); + }), + getItemsCount: jest.fn((key: string) => { + const list = _cache[key]; + return Promise.resolve(Array.isArray(list) ? list.length : 0); + }), + itemContains: jest.fn((key: string, item: string) => { + const list = _cache[key]; + return Promise.resolve(Array.isArray(list) && list.indexOf(item) > -1 ? true : false); + }), - // always connects and close - connect: jest.fn(() => Promise.resolve(true)), - close: jest.fn(() => Promise.resolve()), + // always connects and close + connect: jest.fn(() => Promise.resolve(true)), + close: jest.fn(() => Promise.resolve()), + + mockClear() { + this._cache = {}; + this.get.mockClear(); + this.set.mockClear(); + this.del.mockClear(); + this.getKeysByPrefix.mockClear(); + this.incr.mockClear(); + this.decr.mockClear(); + this.getMany.mockClear(); + this.connect.mockClear(); + this.close.mockClear(); + this.getAndSet.mockClear(); + this.getByPrefix.mockClear(); + this.pushItems.mockClear(); + this.popItems.mockClear(); + this.getItemsCount.mockClear(); + this.itemContains.mockClear(); + } + }; +} - mockClear() { - _cache = {}; - this.get.mockClear(); - this.set.mockClear(); - this.del.mockClear(); - this.getKeysByPrefix.mockClear(); - this.incr.mockClear(); - this.decr.mockClear(); - this.getMany.mockClear(); - this.connect.mockClear(); - this.close.mockClear(); - } -}; +export const wrapperMock = wrapperMockFactory(); diff --git a/src/storages/pluggable/__tests__/wrapperAdapter.spec.ts b/src/storages/pluggable/__tests__/wrapperAdapter.spec.ts index ea0f2c22..430c3663 100644 --- a/src/storages/pluggable/__tests__/wrapperAdapter.spec.ts +++ b/src/storages/pluggable/__tests__/wrapperAdapter.spec.ts @@ -6,7 +6,7 @@ import { SplitError } from '../../../utils/lang/errors'; /** Mocks */ -import { wrapperMock } from './wrapper.mock'; // Well implemented wrapper +import { wrapperMock } from './wrapper.mock'; function throwsException() { throw new Error('some error'); @@ -49,7 +49,6 @@ export const wrapperWithValuesToSanitize = { connect: () => Promise.resolve(1), getAndSet: () => Promise.resolve(true), getByPrefix: () => Promise.resolve(['1', null, false, true, '2', null]), - pushItems: () => Promise.resolve('false'), popItems: () => Promise.resolve('invalid array'), getItemsCount: () => Promise.resolve('10'), itemContains: () => Promise.resolve('true'), @@ -66,7 +65,6 @@ const SANITIZED_RESULTS = { connect: false, getAndSet: 'true', getByPrefix: ['1', '2'], - pushItems: false, popItems: [], getItemsCount: 10, itemContains: true, @@ -84,7 +82,7 @@ const VALID_METHOD_CALLS = { 'close': [], 'getAndSet': ['some_key', 'some_value'], 'getByPrefix': ['some_prefix'], - 'pushItems': ['some_key', ['item1', 'item2']], + 'pushItems': ['some_key_list', ['item1', 'item2']], 'popItems': ['some_key'], 'getItemsCount': ['some_key'], 'itemContains': ['some_key', 'some_value'], diff --git a/src/storages/pluggable/constants.ts b/src/storages/pluggable/constants.ts index dbac131e..1f845f45 100644 --- a/src/storages/pluggable/constants.ts +++ b/src/storages/pluggable/constants.ts @@ -1 +1 @@ -export const logPrefix = 'storage:pluggable:'; +export const LOG_PREFIX = 'storage:pluggable:'; diff --git a/src/storages/pluggable/wrapperAdapter.ts b/src/storages/pluggable/wrapperAdapter.ts index 37fa666d..a7049d9e 100644 --- a/src/storages/pluggable/wrapperAdapter.ts +++ b/src/storages/pluggable/wrapperAdapter.ts @@ -3,9 +3,10 @@ import { sanitizeBoolean as sBoolean } from '../../evaluator/value/sanitize'; import { ILogger } from '../../logger/types'; import { SplitError } from '../../utils/lang/errors'; import { ICustomStorageWrapper } from '../types'; -import { logPrefix } from './constants'; +import { LOG_PREFIX } from './constants'; // Sanitizers return the given value if it is of the expected type, or a new sanitized one if invalid. +// @TODO review or remove sanitizers. might be expensive for producer methods function sanitizeBoolean(val: any): boolean { return sBoolean(val) || false; @@ -17,41 +18,42 @@ function sanitizeNumber(val: any): number { } sanitizeNumber.type = 'number'; -function sanitizeArray(val: any): string[] { +function sanitizeArrayOfStrings(val: any): string[] { if (!Array.isArray(val)) return []; // if not an array, returns a new empty one if (val.every(isString)) return val; // if all items are valid, return the given array - return val.filter(isString); // otherwise, return a new array filtering the invalid items + return val.map(toString); // otherwise, return a new array with items sanitized } -sanitizeArray.type = 'Array'; +sanitizeArrayOfStrings.type = 'Array'; function sanitizeNullableString(val: any): string | null { if (val === null) return val; - return toString(val); + return toString(val); // if not null, sanitize value as an string } sanitizeNullableString.type = 'string | null'; -function sanitizeArrayOfNullableString(val: any): (string | null)[] { - const isStringOrNull = (v: any) => v === null || isString(v); +function sanitizeArrayOfNullableStrings(val: any): (string | null)[] { if (!Array.isArray(val)) return []; - if (val.every(isStringOrNull)) return val; - return val.filter(isStringOrNull); + if (val.every(v => v === null || isString(v))) return val; // if all items are string or null, return the given array + return val.map(v => v !== null ? toString(v) : null); // otherwise, return a new array with items sanitized } -sanitizeArrayOfNullableString.type = 'Array'; +sanitizeArrayOfNullableStrings.type = 'Array'; const METHODS_TO_PROMISE_WRAP: [string, undefined | { (val: any): any, type: string }][] = [ ['get', sanitizeNullableString], ['set', sanitizeBoolean], + ['getAndSet', sanitizeNullableString], ['del', sanitizeBoolean], - ['getKeysByPrefix', sanitizeArray], + ['getKeysByPrefix', sanitizeArrayOfStrings], + ['getByPrefix', sanitizeArrayOfStrings], ['incr', sanitizeBoolean], ['decr', sanitizeBoolean], - ['getMany', sanitizeArrayOfNullableString], + ['getMany', sanitizeArrayOfNullableStrings], ['connect', sanitizeBoolean], ['close', undefined], - ['getAndSet', sanitizeNullableString], - ['getByPrefix', sanitizeArray], - ['pushItems', sanitizeBoolean], - ['popItems', sanitizeArray], + + + ['pushItems', undefined], + ['popItems', sanitizeArrayOfStrings], ['getItemsCount', sanitizeNumber], ['itemContains', sanitizeBoolean], ]; @@ -70,9 +72,9 @@ export function wrapperAdapter(log: ILogger, wrapper: ICustomStorageWrapper): IC METHODS_TO_PROMISE_WRAP.forEach(([method, sanitizer]) => { - // Logs error and wraps it into an SplitError object + // Logs error and wraps it into an SplitError object (required to handle user callback errors in SDK readiness events) function handleError(e: any) { - log.error(`${logPrefix} Wrapper '${method}' operation threw an error. Message: ${e}`); + log.error(`${LOG_PREFIX} Wrapper '${method}' operation threw an error. Message: ${e}`); return Promise.reject(new SplitError(e)); } @@ -84,7 +86,7 @@ export function wrapperAdapter(log: ILogger, wrapper: ICustomStorageWrapper): IC const sanitizedValue = sanitizer(value); // if value had to be sanitized, log a warning - if (sanitizedValue !== value) log.warn(`${logPrefix} Attempted to sanitize return value [${value}] of wrapper '${method}' operation which should be of type [${sanitizer.type}]. Sanitized and processed value => [${sanitizedValue}]`); + if (sanitizedValue !== value) log.warn(`${LOG_PREFIX} Attempted to sanitize return value [${value}] of wrapper '${method}' operation which should be of type [${sanitizer.type}]. Sanitized and processed value => [${sanitizedValue}]`); return sanitizedValue; })).catch(handleError); diff --git a/src/storages/types.ts b/src/storages/types.ts index 7d45ec9a..81024db1 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -61,20 +61,24 @@ export interface ICustomStorageWrapper { */ getMany: (keys: string[]) => Promise<(string | null)[]> /** - * Push given `items` to `key` queue. + * Push given `items` to `key` list. If key does not exist, an empty list is created for the key before pushing the items. + * Return a promise that resolves if the operation success, + * or rejects if the operation fails, for example, if there is a connectin error or the key holds a value that is not a list. */ - pushItems: (key: string, items: string[]) => Promise + pushItems: (key: string, items: string[]) => Promise /** * Pop `count` number of items from `key` queue. - * Return a promise that resolves with the list of removed items the removed members, or an empty array when key does not exist. + * Return a promise that resolves with the list of removed items the removed members, + * or an empty array when key does not exist or is not a list. */ popItems: (key: string, count: number) => Promise /** - * Return a promise that resolves with the number of items at the `key` queue, or 0 when key does not exist. + * Return a promise that resolves with the number of items at the `key` queue, or 0 when key does not exist or is not a list. */ getItemsCount: (key: string) => Promise /** - * Return a promise that resolves with true boolean value if `item` is a member of the set stored at `key`, or false otherwise. + * Return a promise that resolves with true boolean value if `item` is a member of the list stored at `key`, + * or false if it is not a member or key does not exist or is not a list. */ itemContains: (key: string, item: string) => Promise