From b9011e66fb49aebec8e012053632df8d32231bcd Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 22 May 2025 16:31:58 -0300 Subject: [PATCH 01/32] Refactor InLocalStorage to use the storage from options --- .../__tests__/RBSegmentsCacheSync.spec.ts | 3 +- .../inLocalStorage/MySegmentsCacheInLocal.ts | 23 ++++---- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 33 ++++++----- .../inLocalStorage/SplitsCacheInLocal.ts | 55 ++++++++++--------- .../__tests__/MySegmentsCacheInLocal.spec.ts | 4 +- .../__tests__/SplitsCacheInLocal.spec.ts | 16 +++--- .../__tests__/validateCache.spec.ts | 22 ++++---- src/storages/inLocalStorage/index.ts | 37 ++++++++----- src/storages/inLocalStorage/validateCache.ts | 15 ++--- src/utils/env/isLocalStorageAvailable.ts | 15 ++++- types/splitio.d.ts | 54 ++++++++++++++++++ 11 files changed, 180 insertions(+), 97 deletions(-) diff --git a/src/storages/__tests__/RBSegmentsCacheSync.spec.ts b/src/storages/__tests__/RBSegmentsCacheSync.spec.ts index 03579351..0946bacf 100644 --- a/src/storages/__tests__/RBSegmentsCacheSync.spec.ts +++ b/src/storages/__tests__/RBSegmentsCacheSync.spec.ts @@ -6,7 +6,8 @@ import { IRBSegmentsCacheSync } from '../types'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; const cacheInMemory = new RBSegmentsCacheInMemory(); -const cacheInLocal = new RBSegmentsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); +// eslint-disable-next-line no-undef +const cacheInLocal = new RBSegmentsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); describe.each([cacheInMemory, cacheInLocal])('Rule-based segments cache sync (Memory & LocalStorage)', (cache: IRBSegmentsCacheSync) => { diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index e3b250b5..4d565531 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -3,16 +3,19 @@ import { isNaNNumber } from '../../utils/lang'; import { AbstractMySegmentsCacheSync } from '../AbstractMySegmentsCacheSync'; import type { MySegmentsKeyBuilder } from '../KeyBuilderCS'; import { LOG_PREFIX, DEFINED } from './constants'; +import SplitIO from '../../../types/splitio'; export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { private readonly keys: MySegmentsKeyBuilder; private readonly log: ILogger; + private readonly localStorage: SplitIO.Storage; - constructor(log: ILogger, keys: MySegmentsKeyBuilder) { + constructor(log: ILogger, keys: MySegmentsKeyBuilder, localStorage: SplitIO.Storage) { super(); this.log = log; this.keys = keys; + this.localStorage = localStorage; // There is not need to flush segments cache like splits cache, since resetSegments receives the up-to-date list of active segments } @@ -20,8 +23,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { const segmentKey = this.keys.buildSegmentNameKey(name); try { - if (localStorage.getItem(segmentKey) === DEFINED) return false; - localStorage.setItem(segmentKey, DEFINED); + if (this.localStorage.getItem(segmentKey) === DEFINED) return false; + this.localStorage.setItem(segmentKey, DEFINED); return true; } catch (e) { this.log.error(LOG_PREFIX + e); @@ -33,8 +36,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { const segmentKey = this.keys.buildSegmentNameKey(name); try { - if (localStorage.getItem(segmentKey) !== DEFINED) return false; - localStorage.removeItem(segmentKey); + if (this.localStorage.getItem(segmentKey) !== DEFINED) return false; + this.localStorage.removeItem(segmentKey); return true; } catch (e) { this.log.error(LOG_PREFIX + e); @@ -43,12 +46,12 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { } isInSegment(name: string): boolean { - return localStorage.getItem(this.keys.buildSegmentNameKey(name)) === DEFINED; + return this.localStorage.getItem(this.keys.buildSegmentNameKey(name)) === DEFINED; } getRegisteredSegments(): string[] { // Scan current values from localStorage - return Object.keys(localStorage).reduce((accum, key) => { + return Object.keys(this.localStorage).reduce((accum, key) => { let segmentName = this.keys.extractSegmentName(key); if (segmentName) accum.push(segmentName); @@ -63,8 +66,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { protected setChangeNumber(changeNumber?: number) { try { - if (changeNumber) localStorage.setItem(this.keys.buildTillKey(), changeNumber + ''); - else localStorage.removeItem(this.keys.buildTillKey()); + if (changeNumber) this.localStorage.setItem(this.keys.buildTillKey(), changeNumber + ''); + else this.localStorage.removeItem(this.keys.buildTillKey()); } catch (e) { this.log.error(e); } @@ -72,7 +75,7 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { getChangeNumber() { const n = -1; - let value: string | number | null = localStorage.getItem(this.keys.buildTillKey()); + let value: string | number | null = this.localStorage.getItem(this.keys.buildTillKey()); if (value !== null) { value = parseInt(value, 10); diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index 37f6ad8e..e4b4e492 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -7,20 +7,23 @@ import { usesSegments } from '../AbstractSplitsCacheSync'; import { KeyBuilderCS } from '../KeyBuilderCS'; import { IRBSegmentsCacheSync } from '../types'; import { LOG_PREFIX } from './constants'; +import SplitIO from '../../../types/splitio'; export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private readonly keys: KeyBuilderCS; private readonly log: ILogger; + private readonly localStorage: SplitIO.Storage; - constructor(settings: ISettings, keys: KeyBuilderCS) { + constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: SplitIO.Storage) { this.keys = keys; this.log = settings.log; + this.localStorage = localStorage; } clear() { this.getNames().forEach(name => this.remove(name)); - localStorage.removeItem(this.keys.buildRBSegmentsTillKey()); + this.localStorage.removeItem(this.keys.buildRBSegmentsTillKey()); } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { @@ -31,8 +34,8 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private setChangeNumber(changeNumber: number) { try { - localStorage.setItem(this.keys.buildRBSegmentsTillKey(), changeNumber + ''); - localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); + this.localStorage.setItem(this.keys.buildRBSegmentsTillKey(), changeNumber + ''); + this.localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); } catch (e) { this.log.error(LOG_PREFIX + e); } @@ -40,20 +43,20 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private updateSegmentCount(diff: number) { const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); - const count = toNumber(localStorage.getItem(segmentsCountKey)) + diff; + const count = toNumber(this.localStorage.getItem(segmentsCountKey)) + diff; // @ts-expect-error - if (count > 0) localStorage.setItem(segmentsCountKey, count); - else localStorage.removeItem(segmentsCountKey); + if (count > 0) this.localStorage.setItem(segmentsCountKey, count); + else this.localStorage.removeItem(segmentsCountKey); } private add(rbSegment: IRBSegment): boolean { try { const name = rbSegment.name; const rbSegmentKey = this.keys.buildRBSegmentKey(name); - const rbSegmentFromLocalStorage = localStorage.getItem(rbSegmentKey); + const rbSegmentFromLocalStorage = this.localStorage.getItem(rbSegmentKey); const previous = rbSegmentFromLocalStorage ? JSON.parse(rbSegmentFromLocalStorage) : null; - localStorage.setItem(rbSegmentKey, JSON.stringify(rbSegment)); + this.localStorage.setItem(rbSegmentKey, JSON.stringify(rbSegment)); let usesSegmentsDiff = 0; if (previous && usesSegments(previous)) usesSegmentsDiff--; @@ -72,7 +75,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { const rbSegment = this.get(name); if (!rbSegment) return false; - localStorage.removeItem(this.keys.buildRBSegmentKey(name)); + this.localStorage.removeItem(this.keys.buildRBSegmentKey(name)); if (usesSegments(rbSegment)) this.updateSegmentCount(-1); @@ -84,13 +87,13 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } private getNames(): string[] { - const len = localStorage.length; + const len = this.localStorage.length; const accum = []; let cur = 0; while (cur < len) { - const key = localStorage.key(cur); + const key = this.localStorage.key(cur); if (key != null && this.keys.isRBSegmentKey(key)) accum.push(this.keys.extractKey(key)); @@ -101,7 +104,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } get(name: string): IRBSegment | null { - const item = localStorage.getItem(this.keys.buildRBSegmentKey(name)); + const item = this.localStorage.getItem(this.keys.buildRBSegmentKey(name)); return item && JSON.parse(item); } @@ -113,7 +116,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { getChangeNumber(): number { const n = -1; - let value: string | number | null = localStorage.getItem(this.keys.buildRBSegmentsTillKey()); + let value: string | number | null = this.localStorage.getItem(this.keys.buildRBSegmentsTillKey()); if (value !== null) { value = parseInt(value, 10); @@ -125,7 +128,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } usesSegments(): boolean { - const storedCount = localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); + const storedCount = this.localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); const splitsWithSegmentsCount = storedCount === null ? 0 : toNumber(storedCount); return isFiniteNumber(splitsWithSegmentsCount) ? diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index 2fb6183c..da411048 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -6,6 +6,7 @@ import { ILogger } from '../../logger/types'; import { LOG_PREFIX } from './constants'; import { ISettings } from '../../types'; import { setToArray } from '../../utils/lang/sets'; +import SplitIO from '../../../types/splitio'; /** * ISplitsCacheSync implementation that stores split definitions in browser LocalStorage. @@ -16,19 +17,21 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private readonly log: ILogger; private readonly flagSetsFilter: string[]; private hasSync?: boolean; + private readonly localStorage: SplitIO.Storage; - constructor(settings: ISettings, keys: KeyBuilderCS) { + constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: SplitIO.Storage) { super(); this.keys = keys; this.log = settings.log; this.flagSetsFilter = settings.sync.__splitFiltersValidation.groupedFilters.bySet; + this.localStorage = localStorage; } private _decrementCount(key: string) { - const count = toNumber(localStorage.getItem(key)) - 1; + const count = toNumber(this.localStorage.getItem(key)) - 1; // @ts-expect-error - if (count > 0) localStorage.setItem(key, count); - else localStorage.removeItem(key); + if (count > 0) this.localStorage.setItem(key, count); + else this.localStorage.removeItem(key); } private _decrementCounts(split: ISplit) { @@ -49,12 +52,12 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { try { const ttKey = this.keys.buildTrafficTypeKey(split.trafficTypeName); // @ts-expect-error - localStorage.setItem(ttKey, toNumber(localStorage.getItem(ttKey)) + 1); + this.localStorage.setItem(ttKey, toNumber(this.localStorage.getItem(ttKey)) + 1); if (usesSegments(split)) { const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); // @ts-expect-error - localStorage.setItem(segmentsCountKey, toNumber(localStorage.getItem(segmentsCountKey)) + 1); + this.localStorage.setItem(segmentsCountKey, toNumber(this.localStorage.getItem(segmentsCountKey)) + 1); } } catch (e) { this.log.error(LOG_PREFIX + e); @@ -68,15 +71,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { */ clear() { // collect item keys - const len = localStorage.length; + const len = this.localStorage.length; const accum = []; for (let cur = 0; cur < len; cur++) { - const key = localStorage.key(cur); + const key = this.localStorage.key(cur); if (key != null && this.keys.isSplitsCacheKey(key)) accum.push(key); } // remove items accum.forEach(key => { - localStorage.removeItem(key); + this.localStorage.removeItem(key); }); this.hasSync = false; @@ -86,7 +89,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { try { const name = split.name; const splitKey = this.keys.buildSplitKey(name); - const splitFromLocalStorage = localStorage.getItem(splitKey); + const splitFromLocalStorage = this.localStorage.getItem(splitKey); const previousSplit = splitFromLocalStorage ? JSON.parse(splitFromLocalStorage) : null; if (previousSplit) { @@ -94,7 +97,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { this.removeFromFlagSets(previousSplit.name, previousSplit.sets); } - localStorage.setItem(splitKey, JSON.stringify(split)); + this.localStorage.setItem(splitKey, JSON.stringify(split)); this._incrementCounts(split); this.addToFlagSets(split); @@ -111,7 +114,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { const split = this.getSplit(name); if (!split) return false; - localStorage.removeItem(this.keys.buildSplitKey(name)); + this.localStorage.removeItem(this.keys.buildSplitKey(name)); this._decrementCounts(split); this.removeFromFlagSets(split.name, split.sets); @@ -124,15 +127,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } getSplit(name: string): ISplit | null { - const item = localStorage.getItem(this.keys.buildSplitKey(name)); + const item = this.localStorage.getItem(this.keys.buildSplitKey(name)); return item && JSON.parse(item); } setChangeNumber(changeNumber: number): boolean { try { - localStorage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); + this.localStorage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); // update "last updated" timestamp with current time - localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); + this.localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); this.hasSync = true; return true; } catch (e) { @@ -143,7 +146,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { getChangeNumber(): number { const n = -1; - let value: string | number | null = localStorage.getItem(this.keys.buildSplitsTillKey()); + let value: string | number | null = this.localStorage.getItem(this.keys.buildSplitsTillKey()); if (value !== null) { value = parseInt(value, 10); @@ -155,13 +158,13 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } getSplitNames(): string[] { - const len = localStorage.length; + const len = this.localStorage.length; const accum = []; let cur = 0; while (cur < len) { - const key = localStorage.key(cur); + const key = this.localStorage.key(cur); if (key != null && this.keys.isSplitKey(key)) accum.push(this.keys.extractKey(key)); @@ -172,7 +175,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } trafficTypeExists(trafficType: string): boolean { - const ttCount = toNumber(localStorage.getItem(this.keys.buildTrafficTypeKey(trafficType))); + const ttCount = toNumber(this.localStorage.getItem(this.keys.buildTrafficTypeKey(trafficType))); return isFiniteNumber(ttCount) && ttCount > 0; } @@ -180,7 +183,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { // If cache hasn't been synchronized with the cloud, assume we need them. if (!this.hasSync) return true; - const storedCount = localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); + const storedCount = this.localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); const splitsWithSegmentsCount = storedCount === null ? 0 : toNumber(storedCount); return isFiniteNumber(splitsWithSegmentsCount) ? @@ -191,7 +194,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { getNamesByFlagSets(flagSets: string[]): Set[] { return flagSets.map(flagSet => { const flagSetKey = this.keys.buildFlagSetKey(flagSet); - const flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + const flagSetFromLocalStorage = this.localStorage.getItem(flagSetKey); return new Set(flagSetFromLocalStorage ? JSON.parse(flagSetFromLocalStorage) : []); }); @@ -206,12 +209,12 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { const flagSetKey = this.keys.buildFlagSetKey(featureFlagSet); - const flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + const flagSetFromLocalStorage = this.localStorage.getItem(flagSetKey); const flagSetCache = new Set(flagSetFromLocalStorage ? JSON.parse(flagSetFromLocalStorage) : []); flagSetCache.add(featureFlag.name); - localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + this.localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); }); } @@ -226,7 +229,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private removeNames(flagSetName: string, featureFlagName: string) { const flagSetKey = this.keys.buildFlagSetKey(flagSetName); - const flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + const flagSetFromLocalStorage = this.localStorage.getItem(flagSetKey); if (!flagSetFromLocalStorage) return; @@ -234,11 +237,11 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { flagSetCache.delete(featureFlagName); if (flagSetCache.size === 0) { - localStorage.removeItem(flagSetKey); + this.localStorage.removeItem(flagSetKey); return; } - localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + this.localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); } } diff --git a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts index bb38fe10..a3246dab 100644 --- a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts @@ -4,8 +4,8 @@ import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; test('SEGMENT CACHE / in LocalStorage', () => { const caches = [ - new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user')), - new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder('SPLITIO', 'user')) + new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user'), localStorage), + new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder('SPLITIO', 'user'), localStorage) ]; caches.forEach(cache => { diff --git a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts index 913d6a3b..c8f938bd 100644 --- a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts @@ -6,7 +6,7 @@ import { fullSettings } from '../../../utils/settingsValidation/__tests__/settin test('SPLITS CACHE / LocalStorage', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); cache.clear(); @@ -37,7 +37,7 @@ test('SPLITS CACHE / LocalStorage', () => { }); test('SPLITS CACHE / LocalStorage / Get Keys', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); cache.update([something, somethingElse], [], 1); @@ -48,7 +48,7 @@ test('SPLITS CACHE / LocalStorage / Get Keys', () => { }); test('SPLITS CACHE / LocalStorage / Update Splits', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); cache.update([something, somethingElse], [], 1); @@ -59,7 +59,7 @@ test('SPLITS CACHE / LocalStorage / Update Splits', () => { }); test('SPLITS CACHE / LocalStorage / trafficTypeExists and ttcache tests', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); cache.update([ { ...splitWithUserTT, name: 'split1' }, @@ -98,7 +98,7 @@ test('SPLITS CACHE / LocalStorage / trafficTypeExists and ttcache tests', () => }); test('SPLITS CACHE / LocalStorage / killLocally', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); cache.addSplit(something); cache.addSplit(somethingElse); @@ -131,7 +131,7 @@ test('SPLITS CACHE / LocalStorage / killLocally', () => { }); test('SPLITS CACHE / LocalStorage / usesSegments', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); expect(cache.usesSegments()).toBe(true); // true initially, until data is synchronized cache.setChangeNumber(1); // to indicate that data has been synced. @@ -162,7 +162,7 @@ test('SPLITS CACHE / LocalStorage / flag set cache tests', () => { queryString: '&sets=e,n,o,x', } } - }, new KeyBuilderCS('SPLITIO', 'user')); + }, new KeyBuilderCS('SPLITIO', 'user'), localStorage); const emptySet = new Set([]); @@ -203,7 +203,7 @@ test('SPLITS CACHE / LocalStorage / flag set cache tests', () => { // if FlagSets are not defined, it should store all FlagSets in memory. test('SPLIT CACHE / LocalStorage / flag set cache tests without filters', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user')); + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); const emptySet = new Set([]); diff --git a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts index b87fa67b..27ad2159 100644 --- a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts +++ b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts @@ -12,10 +12,10 @@ const FULL_SETTINGS_HASH = 'dc1f9817'; describe('validateCache', () => { const keys = new KeyBuilderCS('SPLITIO', 'user'); const logSpy = jest.spyOn(fullSettings.log, 'info'); - const segments = new MySegmentsCacheInLocal(fullSettings.log, keys); - const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys); - const splits = new SplitsCacheInLocal(fullSettings, keys); - const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys); + const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage); + const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage); + const splits = new SplitsCacheInLocal(fullSettings, keys, localStorage); + const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, localStorage); jest.spyOn(splits, 'getChangeNumber'); jest.spyOn(splits, 'clear'); @@ -29,7 +29,7 @@ describe('validateCache', () => { }); test('if there is no cache, it should return false', () => { - expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(validateCache({ storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).not.toHaveBeenCalled(); @@ -47,7 +47,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(validateCache({ storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); @@ -66,7 +66,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago - expect(validateCache({ expirationDays: 1 }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(validateCache({ expirationDays: 1, storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache'); @@ -83,7 +83,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({}, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(validateCache({ storage: localStorage }, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache'); @@ -101,7 +101,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(validateCache({ clearOnInit: true, storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); @@ -117,13 +117,13 @@ describe('validateCache', () => { // If cache is cleared, it should not clear again until a day has passed logSpy.mockClear(); localStorage.setItem(keys.buildSplitsTillKey(), '1'); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(validateCache({ clearOnInit: true, storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed // If a day has passed, it should clear again localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + ''); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(validateCache({ clearOnInit: true, storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); expect(splits.clear).toHaveBeenCalledTimes(2); expect(rbSegments.clear).toHaveBeenCalledTimes(2); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 8924b84d..c4274b1a 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -4,7 +4,7 @@ import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory'; import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory } from '../types'; import { validatePrefix } from '../KeyBuilder'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS'; -import { isLocalStorageAvailable } from '../../utils/env/isLocalStorageAvailable'; +import { isLocalStorageAvailable, isStorageValid } from '../../utils/env/isLocalStorageAvailable'; import { SplitsCacheInLocal } from './SplitsCacheInLocal'; import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; @@ -15,8 +15,20 @@ import { shouldRecordTelemetry, TelemetryCacheInMemory } from '../inMemory/Telem import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS'; import { getMatching } from '../../utils/key'; import { validateCache } from './validateCache'; +import { ILogger } from '../../logger/types'; import SplitIO from '../../../types/splitio'; +function validateStorage(log: ILogger, storage?: SplitIO.Storage) { + if (storage) { + if (isStorageValid(storage)) return storage; + log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); + } + + if (isLocalStorageAvailable()) return localStorage; + + log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage'); +} + /** * InLocal storage factory for standalone client-side SplitFactory */ @@ -25,21 +37,18 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt const prefix = validatePrefix(options.prefix); function InLocalStorageCSFactory(params: IStorageFactoryParams): IStorageSync { + const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params; - // Fallback to InMemoryStorage if LocalStorage API is not available - if (!isLocalStorageAvailable()) { - params.settings.log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage'); - return InMemoryStorageCSFactory(params); - } + const storage = validateStorage(log, options.storage); + if (!storage) return InMemoryStorageCSFactory(params); - const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params; const matchingKey = getMatching(settings.core.key); const keys = new KeyBuilderCS(prefix, matchingKey); - const splits = new SplitsCacheInLocal(settings, keys); - const rbSegments = new RBSegmentsCacheInLocal(settings, keys); - const segments = new MySegmentsCacheInLocal(log, keys); - const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey)); + const splits = new SplitsCacheInLocal(settings, keys, storage); + const rbSegments = new RBSegmentsCacheInLocal(settings, keys, storage); + const segments = new MySegmentsCacheInLocal(log, keys, storage); + const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey), storage); return { splits, @@ -53,7 +62,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt uniqueKeys: new UniqueKeysCacheInMemoryCS(), validateCache() { - return validateCache(options, settings, keys, splits, rbSegments, segments, largeSegments); + return validateCache({ ...options, storage }, settings, keys, splits, rbSegments, segments, largeSegments); }, destroy() { }, @@ -64,8 +73,8 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt return { splits: this.splits, rbSegments: this.rbSegments, - segments: new MySegmentsCacheInLocal(log, new KeyBuilderCS(prefix, matchingKey)), - largeSegments: new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey)), + segments: new MySegmentsCacheInLocal(log, new KeyBuilderCS(prefix, matchingKey), storage), + largeSegments: new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey), storage), impressions: this.impressions, impressionCounts: this.impressionCounts, events: this.events, diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 93d3144c..e5aead24 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -16,11 +16,12 @@ const MILLIS_IN_A_DAY = 86400000; * * @returns `true` if cache should be cleared, `false` otherwise */ -function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { +function validateExpiration(options: SplitIO.InLocalStorageOptions & { storage: SplitIO.Storage }, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { const { log } = settings; + const { storage } = options; // Check expiration - const lastUpdatedTimestamp = parseInt(localStorage.getItem(keys.buildLastUpdatedKey()) as string, 10); + const lastUpdatedTimestamp = parseInt(storage.getItem(keys.buildLastUpdatedKey()) as string, 10); if (!isNaNNumber(lastUpdatedTimestamp)) { const cacheExpirationInDays = isFiniteNumber(options.expirationDays) && options.expirationDays >= 1 ? options.expirationDays : DEFAULT_CACHE_EXPIRATION_IN_DAYS; const expirationTimestamp = currentTimestamp - MILLIS_IN_A_DAY * cacheExpirationInDays; @@ -32,12 +33,12 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS // Check hash const storageHashKey = keys.buildHashKey(); - const storageHash = localStorage.getItem(storageHashKey); + const storageHash = storage.getItem(storageHashKey); const currentStorageHash = getStorageHash(settings); if (storageHash !== currentStorageHash) { try { - localStorage.setItem(storageHashKey, currentStorageHash); + storage.setItem(storageHashKey, currentStorageHash); } catch (e) { log.error(LOG_PREFIX + e); } @@ -50,7 +51,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS // Clear on init if (options.clearOnInit) { - const lastClearTimestamp = parseInt(localStorage.getItem(keys.buildLastClear()) as string, 10); + const lastClearTimestamp = parseInt(storage.getItem(keys.buildLastClear()) as string, 10); if (isNaNNumber(lastClearTimestamp) || lastClearTimestamp < currentTimestamp - MILLIS_IN_A_DAY) { log.info(LOG_PREFIX + 'clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); @@ -67,7 +68,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS * * @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache) */ -export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): boolean { +export function validateCache(options: SplitIO.InLocalStorageOptions & { storage: SplitIO.Storage }, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): boolean { const currentTimestamp = Date.now(); const isThereCache = splits.getChangeNumber() > -1; @@ -80,7 +81,7 @@ export function validateCache(options: SplitIO.InLocalStorageOptions, settings: // Update last clear timestamp try { - localStorage.setItem(keys.buildLastClear(), currentTimestamp + ''); + options.storage.setItem(keys.buildLastClear(), currentTimestamp + ''); } catch (e) { settings.log.error(LOG_PREFIX + e); } diff --git a/src/utils/env/isLocalStorageAvailable.ts b/src/utils/env/isLocalStorageAvailable.ts index e062b57d..5e98b87d 100644 --- a/src/utils/env/isLocalStorageAvailable.ts +++ b/src/utils/env/isLocalStorageAvailable.ts @@ -1,9 +1,18 @@ -/* eslint-disable no-undef */ export function isLocalStorageAvailable(): boolean { + try { + // eslint-disable-next-line no-undef + return isStorageValid(localStorage); + } catch (e) { + return false; + } +} + +export function isStorageValid(storage: any): boolean { var mod = '__SPLITSOFTWARE__'; try { - localStorage.setItem(mod, mod); - localStorage.removeItem(mod); + storage.setItem(mod, mod); + storage.getItem(mod); + storage.removeItem(mod); return true; } catch (e) { return false; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index ad8644b2..c7126e6e 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -449,6 +449,54 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync */ declare namespace SplitIO { + interface Storage { + /** + * Returns the number of key/value pairs. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/length) + */ + readonly length: number; + /** + * Removes all key/value pairs, if there are any. + * + * Dispatches a storage event on Window objects holding an equivalent Storage object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/clear) + */ + clear(): void; + /** + * Returns the current value associated with the given key, or null if the given key does not exist. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem) + */ + getItem(key: string): string | null; + /** + * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/key) + */ + key(index: number): string | null; + /** + * Removes the key/value pair with the given key, if a key/value pair with the given key exists. + * + * Dispatches a storage event on Window objects holding an equivalent Storage object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/removeItem) + */ + removeItem(key: string): void; + /** + * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously. + * + * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.) + * + * Dispatches a storage event on Window objects holding an equivalent Storage object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem) + */ + setItem(key: string, value: string): void; + [name: string]: any; + } + /** * EventEmitter interface based on a subset of the Node.js EventEmitter methods. */ @@ -963,6 +1011,12 @@ declare namespace SplitIO { * @defaultValue `false` */ clearOnInit?: boolean; + /** + * Optional storage API to use. If not provided, the SDK will use the default localStorage Web API. + * + * @defaultValue `window.localStorage` + */ + storage?: Storage; } /** * Storage for asynchronous (consumer) SDK. From 61911ec772635ac3d39f49d70e76ae3d5e814e57 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 23 May 2025 11:30:40 -0300 Subject: [PATCH 02/32] Make validateCache async to support async storages --- .../__tests__/validateCache.spec.ts | 24 ++++----- src/storages/inLocalStorage/index.ts | 8 ++- src/storages/inLocalStorage/validateCache.ts | 37 +++++++------- src/storages/types.ts | 3 +- src/sync/__tests__/syncManagerOnline.spec.ts | 24 ++++----- .../offline/syncTasks/fromObjectSyncTask.ts | 3 +- src/sync/syncManagerOnline.ts | 49 ++++++++++--------- .../settingsValidation/storage/storageCS.ts | 2 +- 8 files changed, 80 insertions(+), 70 deletions(-) diff --git a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts index b87fa67b..1d502ad1 100644 --- a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts +++ b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts @@ -28,8 +28,8 @@ describe('validateCache', () => { localStorage.clear(); }); - test('if there is no cache, it should return false', () => { - expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + test('if there is no cache, it should return false', async () => { + expect(await validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).not.toHaveBeenCalled(); @@ -43,11 +43,11 @@ describe('validateCache', () => { expect(localStorage.getItem(keys.buildLastClear())).toBeNull(); }); - test('if there is cache and it must not be cleared, it should return true', () => { + test('if there is cache and it must not be cleared, it should return true', async () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(await validateCache({}, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); @@ -61,12 +61,12 @@ describe('validateCache', () => { expect(localStorage.getItem(keys.buildLastClear())).toBeNull(); }); - test('if there is cache and it has expired, it should clear cache and return false', () => { + test('if there is cache and it has expired, it should clear cache and return false', async () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago - expect(validateCache({ expirationDays: 1 }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ expirationDays: 1 }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache'); @@ -79,11 +79,11 @@ describe('validateCache', () => { expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); - test('if there is cache and its hash has changed, it should clear cache and return false', () => { + test('if there is cache and its hash has changed, it should clear cache and return false', async () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({}, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({}, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache'); @@ -96,12 +96,12 @@ describe('validateCache', () => { expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); - test('if there is cache and clearOnInit is true, it should clear cache and return false', () => { + test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => { // Older cache version (without last clear) localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); @@ -117,13 +117,13 @@ describe('validateCache', () => { // If cache is cleared, it should not clear again until a day has passed logSpy.mockClear(); localStorage.setItem(keys.buildSplitsTillKey(), '1'); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(await validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed // If a day has passed, it should clear again localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + ''); - expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); expect(splits.clear).toHaveBeenCalledTimes(2); expect(rbSegments.clear).toHaveBeenCalledTimes(2); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 8924b84d..544289a3 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -40,6 +40,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt const rbSegments = new RBSegmentsCacheInLocal(settings, keys); const segments = new MySegmentsCacheInLocal(log, keys); const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey)); + let validateCachePromise: Promise | undefined; return { splits, @@ -53,10 +54,13 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt uniqueKeys: new UniqueKeysCacheInMemoryCS(), validateCache() { - return validateCache(options, settings, keys, splits, rbSegments, segments, largeSegments); + return validateCachePromise || (validateCachePromise = validateCache(options, settings, keys, splits, rbSegments, segments, largeSegments)); }, - destroy() { }, + destroy() { + // @TODO return `storageWrapper.disconnect()` + return Promise.resolve(); + }, // When using shared instantiation with MEMORY we reuse everything but segments (they are customer per key). shared(matchingKey: string) { diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 93d3144c..5888dd6e 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -67,27 +67,28 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS * * @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache) */ -export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): boolean { +export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { + return new Promise((resolve) => { + const currentTimestamp = Date.now(); + const isThereCache = splits.getChangeNumber() > -1; - const currentTimestamp = Date.now(); - const isThereCache = splits.getChangeNumber() > -1; + if (validateExpiration(options, settings, keys, currentTimestamp, isThereCache)) { + splits.clear(); + rbSegments.clear(); + segments.clear(); + largeSegments.clear(); - if (validateExpiration(options, settings, keys, currentTimestamp, isThereCache)) { - splits.clear(); - rbSegments.clear(); - segments.clear(); - largeSegments.clear(); + // Update last clear timestamp + try { + localStorage.setItem(keys.buildLastClear(), currentTimestamp + ''); + } catch (e) { + settings.log.error(LOG_PREFIX + e); + } - // Update last clear timestamp - try { - localStorage.setItem(keys.buildLastClear(), currentTimestamp + ''); - } catch (e) { - settings.log.error(LOG_PREFIX + e); + resolve(false); } - return false; - } - - // Check if ready from cache - return isThereCache; + // Check if ready from cache + resolve(isThereCache); + }); } diff --git a/src/storages/types.ts b/src/storages/types.ts index 8e93daca..30952342 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -479,7 +479,8 @@ export interface IStorageSync extends IStorageBase< IUniqueKeysCacheSync > { // Defined in client-side - validateCache?: () => boolean, // @TODO support async + // https://github.com/splitio/javascript-commons/pull/352 + validateCache?: () => Promise, // or connect? largeSegments?: ISegmentsCacheSync, } diff --git a/src/sync/__tests__/syncManagerOnline.spec.ts b/src/sync/__tests__/syncManagerOnline.spec.ts index c7dba96e..fc6cefb6 100644 --- a/src/sync/__tests__/syncManagerOnline.spec.ts +++ b/src/sync/__tests__/syncManagerOnline.spec.ts @@ -43,7 +43,7 @@ const pushManagerMock = { // Mocked pushManager const pushManagerFactoryMock = jest.fn(() => pushManagerMock); -test('syncManagerOnline should start or not the submitter depending on user consent status', () => { +test('syncManagerOnline should start or not the submitter depending on user consent status', async () => { const settings = { ...fullSettings }; const syncManager = syncManagerOnlineFactory()({ @@ -52,14 +52,14 @@ test('syncManagerOnline should start or not the submitter depending on user cons }); const submitterManager = syncManager.submitterManager!; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(1); expect(submitterManager.start).lastCalledWith(false); // SubmitterManager should start all submitters, if userConsent is undefined syncManager.stop(); expect(submitterManager.stop).toBeCalledTimes(1); settings.userConsent = 'UNKNOWN'; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(2); expect(submitterManager.start).lastCalledWith(true); // SubmitterManager should start only telemetry submitter, if userConsent is unknown syncManager.stop(); @@ -69,7 +69,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons expect(submitterManager.execute).lastCalledWith(true); // SubmitterManager should flush only telemetry, if userConsent is unknown settings.userConsent = 'GRANTED'; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(3); expect(submitterManager.start).lastCalledWith(false); // SubmitterManager should start all submitters, if userConsent is granted syncManager.stop(); @@ -79,7 +79,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons expect(submitterManager.execute).lastCalledWith(false); // SubmitterManager should flush all submitters, if userConsent is granted settings.userConsent = 'DECLINED'; - syncManager.start(); + await syncManager.start(); expect(submitterManager.start).toBeCalledTimes(4); expect(submitterManager.start).lastCalledWith(true); // SubmitterManager should start only telemetry submitter, if userConsent is declined syncManager.stop(); @@ -90,7 +90,7 @@ test('syncManagerOnline should start or not the submitter depending on user cons }); -test('syncManagerOnline should syncAll a single time when sync is disabled', () => { +test('syncManagerOnline should syncAll a single time when sync is disabled', async () => { const settings = { ...fullSettings }; // disable sync @@ -106,19 +106,19 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', () expect(pushManagerFactoryMock).not.toBeCalled(); // Test pollingManager for Main client - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); expect(pollingManagerMock.syncAll).toBeCalledTimes(1); syncManager.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); expect(pollingManagerMock.syncAll).toBeCalledTimes(1); syncManager.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); expect(pollingManagerMock.syncAll).toBeCalledTimes(1); @@ -139,12 +139,12 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', () pollingSyncManagerShared.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); syncManager.stop(); - syncManager.start(); + await syncManager.start(); expect(pollingManagerMock.start).not.toBeCalled(); @@ -175,7 +175,7 @@ test('syncManagerOnline should syncAll a single time when sync is disabled', () expect(pushManagerFactoryMock).toBeCalled(); // Test pollingManager for Main client - testSyncManager.start(); + await testSyncManager.start(); expect(pushManagerMock.start).toBeCalled(); diff --git a/src/sync/offline/syncTasks/fromObjectSyncTask.ts b/src/sync/offline/syncTasks/fromObjectSyncTask.ts index acbb5f52..96bc8384 100644 --- a/src/sync/offline/syncTasks/fromObjectSyncTask.ts +++ b/src/sync/offline/syncTasks/fromObjectSyncTask.ts @@ -59,8 +59,7 @@ export function fromObjectUpdaterFactory( if (startingUp) { startingUp = false; - const isCacheLoaded = storage.validateCache ? storage.validateCache() : false; - Promise.resolve().then(() => { + Promise.resolve(storage.validateCache ? storage.validateCache() : false).then((isCacheLoaded) => { // Emits SDK_READY_FROM_CACHE if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); // Emits SDK_READY diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index 21bf81e7..ca530950 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -88,36 +88,41 @@ export function syncManagerOnlineFactory( start() { running = true; - if (startFirstTime) { - const isCacheLoaded = storage.validateCache ? storage.validateCache() : false; - if (isCacheLoaded) Promise.resolve().then(() => { readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); }); - } + // @TODO call after `validateCache` promise once there are InLocal versions for event, impression and telemetry storages + submitterManager.start(!isConsentGranted(settings)); - // start syncing splits and segments - if (pollingManager) { + return Promise.resolve(storage.validateCache ? storage.validateCache() : false).then((isCacheLoaded) => { + if (!running) return; - // If synchronization is disabled pushManager and pollingManager should not start - if (syncEnabled) { - if (pushManager) { - // Doesn't call `syncAll` when the syncManager is resuming + if (startFirstTime) { + // Emits SDK_READY_FROM_CACHE + if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); + + } + + // start syncing splits and segments + if (pollingManager) { + + // If synchronization is disabled pushManager and pollingManager should not start + if (syncEnabled) { + if (pushManager) { + // Doesn't call `syncAll` when the syncManager is resuming + if (startFirstTime) { + pollingManager.syncAll(); + } + pushManager.start(); + } else { + pollingManager.start(); + } + } else { if (startFirstTime) { pollingManager.syncAll(); } - pushManager.start(); - } else { - pollingManager.start(); - } - } else { - if (startFirstTime) { - pollingManager.syncAll(); } } - } - - // start periodic data recording (events, impressions, telemetry). - submitterManager.start(!isConsentGranted(settings)); - startFirstTime = false; + startFirstTime = false; + }); }, /** diff --git a/src/utils/settingsValidation/storage/storageCS.ts b/src/utils/settingsValidation/storage/storageCS.ts index 7d58af3d..ef78ad84 100644 --- a/src/utils/settingsValidation/storage/storageCS.ts +++ b/src/utils/settingsValidation/storage/storageCS.ts @@ -8,7 +8,7 @@ import { IStorageFactoryParams, IStorageSync } from '../../../storages/types'; export function __InLocalStorageMockFactory(params: IStorageFactoryParams): IStorageSync { const result = InMemoryStorageCSFactory(params); - result.validateCache = () => true; // to emit SDK_READY_FROM_CACHE + result.validateCache = () => Promise.resolve(true); // to emit SDK_READY_FROM_CACHE return result; } __InLocalStorageMockFactory.type = STORAGE_MEMORY; From 358a6b7921dc8439940331761380ace50c323f68 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 27 May 2025 11:25:52 -0300 Subject: [PATCH 03/32] Place 'setChangeNumber' call as last operation inside storage 'update' methods, to signal transaction commit --- src/storages/AbstractMySegmentsCacheSync.ts | 46 +++++++++++-------- src/storages/AbstractSplitsCacheSync.ts | 5 +- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 5 +- .../__tests__/validateCache.spec.ts | 14 +++--- src/storages/inLocalStorage/index.ts | 2 +- src/storages/inLocalStorage/validateCache.ts | 16 +++---- types/splitio.d.ts | 2 +- 7 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/storages/AbstractMySegmentsCacheSync.ts b/src/storages/AbstractMySegmentsCacheSync.ts index 7d3dc304..5b72aaf9 100644 --- a/src/storages/AbstractMySegmentsCacheSync.ts +++ b/src/storages/AbstractMySegmentsCacheSync.ts @@ -49,12 +49,10 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync * For client-side synchronizer: it resets or updates the cache. */ resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse): boolean { - this.setChangeNumber(segmentsData.cn); - const { added, removed } = segmentsData as MySegmentsData; + let isDiff = false; if (added && removed) { - let isDiff = false; added.forEach(segment => { isDiff = this.addSegment(segment) || isDiff; @@ -63,32 +61,40 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync removed.forEach(segment => { isDiff = this.removeSegment(segment) || isDiff; }); + } else { - return isDiff; - } + const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); + const storedSegmentKeys = this.getRegisteredSegments().sort(); - const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); - const storedSegmentKeys = this.getRegisteredSegments().sort(); + // Extreme fast => everything is empty + if (!names.length && !storedSegmentKeys.length) { + isDiff = false; + } else { - // Extreme fast => everything is empty - if (!names.length && !storedSegmentKeys.length) return false; + let index = 0; - let index = 0; + while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; - while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; + // Quick path => no changes + if (index === names.length && index === storedSegmentKeys.length) { + isDiff = false; + } else { - // Quick path => no changes - if (index === names.length && index === storedSegmentKeys.length) return false; + // Slowest path => add and/or remove segments + for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { + this.removeSegment(storedSegmentKeys[removeIndex]); + } - // Slowest path => add and/or remove segments - for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { - this.removeSegment(storedSegmentKeys[removeIndex]); - } + for (let addIndex = index; addIndex < names.length; addIndex++) { + this.addSegment(names[addIndex]); + } - for (let addIndex = index; addIndex < names.length; addIndex++) { - this.addSegment(names[addIndex]); + isDiff = true; + } + } } - return true; + this.setChangeNumber(segmentsData.cn); + return isDiff; } } diff --git a/src/storages/AbstractSplitsCacheSync.ts b/src/storages/AbstractSplitsCacheSync.ts index 761c5cb9..a56c2a86 100644 --- a/src/storages/AbstractSplitsCacheSync.ts +++ b/src/storages/AbstractSplitsCacheSync.ts @@ -14,9 +14,10 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync { protected abstract setChangeNumber(changeNumber: number): boolean | void update(toAdd: ISplit[], toRemove: ISplit[], changeNumber: number): boolean { + let updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); + updated = toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - const updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); - return toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; + return updated; } abstract getSplit(name: string): ISplit | null diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index e4b4e492..a9744912 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -27,9 +27,10 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { + let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); - return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; + return updated; } private setChangeNumber(changeNumber: number) { diff --git a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts index d8d24374..2699d184 100644 --- a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts +++ b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts @@ -29,7 +29,7 @@ describe('validateCache', () => { }); test('if there is no cache, it should return false', async () => { - expect(await validateCache({ storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).not.toHaveBeenCalled(); @@ -47,7 +47,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(await validateCache({ storage: localStorage }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); @@ -66,7 +66,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago - expect(await validateCache({ storage: localStorage, expirationDays: 1 }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ expirationDays: 1 }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache'); @@ -83,7 +83,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(await validateCache({ storage: localStorage }, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({}, localStorage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache'); @@ -101,7 +101,7 @@ describe('validateCache', () => { localStorage.setItem(keys.buildSplitsTillKey(), '1'); localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(await validateCache({ storage: localStorage, clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); @@ -117,13 +117,13 @@ describe('validateCache', () => { // If cache is cleared, it should not clear again until a day has passed logSpy.mockClear(); localStorage.setItem(keys.buildSplitsTillKey(), '1'); - expect(await validateCache({ storage: localStorage, clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed // If a day has passed, it should clear again localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + ''); - expect(await validateCache({ storage: localStorage, clearOnInit: true }, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); expect(splits.clear).toHaveBeenCalledTimes(2); expect(rbSegments.clear).toHaveBeenCalledTimes(2); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 22ff8400..5872adaa 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -63,7 +63,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt uniqueKeys: new UniqueKeysCacheInMemoryCS(), validateCache() { - return validateCachePromise || (validateCachePromise = validateCache({ ...options, storage }, settings, keys, splits, rbSegments, segments, largeSegments)); + return validateCachePromise || (validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments)); }, destroy() { diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 7a641e11..8aec3814 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -16,9 +16,8 @@ const MILLIS_IN_A_DAY = 86400000; * * @returns `true` if cache should be cleared, `false` otherwise */ -function validateExpiration(options: SplitIO.InLocalStorageOptions & { storage: SplitIO.Storage }, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { +function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: SplitIO.Storage, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { const { log } = settings; - const { storage } = options; // Check expiration const lastUpdatedTimestamp = parseInt(storage.getItem(keys.buildLastUpdatedKey()) as string, 10); @@ -68,12 +67,13 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions & { storage: * * @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache) */ -export function validateCache(options: SplitIO.InLocalStorageOptions & { storage: SplitIO.Storage }, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { - return new Promise((resolve) => { +export function validateCache(options: SplitIO.InLocalStorageOptions, storage: SplitIO.Storage, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { + + return Promise.resolve().then(() => { const currentTimestamp = Date.now(); const isThereCache = splits.getChangeNumber() > -1; - if (validateExpiration(options, settings, keys, currentTimestamp, isThereCache)) { + if (validateExpiration(options, storage, settings, keys, currentTimestamp, isThereCache)) { splits.clear(); rbSegments.clear(); segments.clear(); @@ -81,15 +81,15 @@ export function validateCache(options: SplitIO.InLocalStorageOptions & { storage // Update last clear timestamp try { - options.storage.setItem(keys.buildLastClear(), currentTimestamp + ''); + storage.setItem(keys.buildLastClear(), currentTimestamp + ''); } catch (e) { settings.log.error(LOG_PREFIX + e); } - resolve(false); + return false; } // Check if ready from cache - resolve(isThereCache); + return isThereCache; }); } diff --git a/types/splitio.d.ts b/types/splitio.d.ts index c7126e6e..3ae98912 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1012,7 +1012,7 @@ declare namespace SplitIO { */ clearOnInit?: boolean; /** - * Optional storage API to use. If not provided, the SDK will use the default localStorage Web API. + * Optional storage API to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. * * @defaultValue `window.localStorage` */ From 4411c5d81b72096421089ffb08bccc265ab83d3d Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 27 May 2025 15:42:02 -0300 Subject: [PATCH 04/32] Implement basic storageAdapter to support async storages with getItem and setItem methods --- .../inLocalStorage/MySegmentsCacheInLocal.ts | 6 +- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 6 +- .../inLocalStorage/SplitsCacheInLocal.ts | 6 +- src/storages/inLocalStorage/index.ts | 67 +++++++++++++++++-- src/storages/inLocalStorage/validateCache.ts | 7 +- types/splitio.d.ts | 49 +++----------- 6 files changed, 83 insertions(+), 58 deletions(-) diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 4d565531..0b296531 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -1,17 +1,17 @@ +import { StorageAdapter } from '.'; import { ILogger } from '../../logger/types'; import { isNaNNumber } from '../../utils/lang'; import { AbstractMySegmentsCacheSync } from '../AbstractMySegmentsCacheSync'; import type { MySegmentsKeyBuilder } from '../KeyBuilderCS'; import { LOG_PREFIX, DEFINED } from './constants'; -import SplitIO from '../../../types/splitio'; export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { private readonly keys: MySegmentsKeyBuilder; private readonly log: ILogger; - private readonly localStorage: SplitIO.Storage; + private readonly localStorage: StorageAdapter; - constructor(log: ILogger, keys: MySegmentsKeyBuilder, localStorage: SplitIO.Storage) { + constructor(log: ILogger, keys: MySegmentsKeyBuilder, localStorage: StorageAdapter) { super(); this.log = log; this.keys = keys; diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index a9744912..d68e17b2 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -7,15 +7,15 @@ import { usesSegments } from '../AbstractSplitsCacheSync'; import { KeyBuilderCS } from '../KeyBuilderCS'; import { IRBSegmentsCacheSync } from '../types'; import { LOG_PREFIX } from './constants'; -import SplitIO from '../../../types/splitio'; +import { StorageAdapter } from '.'; export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private readonly keys: KeyBuilderCS; private readonly log: ILogger; - private readonly localStorage: SplitIO.Storage; + private readonly localStorage: StorageAdapter; - constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: SplitIO.Storage) { + constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: StorageAdapter) { this.keys = keys; this.log = settings.log; this.localStorage = localStorage; diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index da411048..2d0dec55 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -6,7 +6,7 @@ import { ILogger } from '../../logger/types'; import { LOG_PREFIX } from './constants'; import { ISettings } from '../../types'; import { setToArray } from '../../utils/lang/sets'; -import SplitIO from '../../../types/splitio'; +import { StorageAdapter } from '.'; /** * ISplitsCacheSync implementation that stores split definitions in browser LocalStorage. @@ -17,9 +17,9 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private readonly log: ILogger; private readonly flagSetsFilter: string[]; private hasSync?: boolean; - private readonly localStorage: SplitIO.Storage; + private readonly localStorage: StorageAdapter; - constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: SplitIO.Storage) { + constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: StorageAdapter) { super(); this.keys = keys; this.log = settings.log; diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 5872adaa..7c04440f 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -18,9 +18,67 @@ import { validateCache } from './validateCache'; import { ILogger } from '../../logger/types'; import SplitIO from '../../../types/splitio'; -function validateStorage(log: ILogger, storage?: SplitIO.Storage) { +export interface StorageAdapter { + // Methods to support async storages + load?: () => Promise; + save?: () => Promise; + // Methods based on https://developer.mozilla.org/en-US/docs/Web/API/Storage + readonly length: number; + getItem(key: string): string | null; + key(index: number): string | null; + removeItem(key: string): void; + setItem(key: string, value: string): void; +} + +function isTillKey(key: string) { + return key.endsWith('.till'); +} + +function storageAdapter(log: ILogger, prefix: string, storage: SplitIO.Storage): StorageAdapter { + let cache: Record = {}; + + let connectPromise: Promise | undefined; + let disconnectPromise = Promise.resolve(); + + return { + load() { + return connectPromise || (connectPromise = storage.getItem(prefix).then((storedCache) => { + cache = JSON.parse(storedCache || '{}'); + }).catch((e) => { + log.error(LOG_PREFIX + 'Rejected promise calling storage getItem, with error: ' + e); + })); + }, + save() { + return disconnectPromise = disconnectPromise.then(() => { + return storage.setItem(prefix, JSON.stringify(cache)).catch((e) => { + log.error(LOG_PREFIX + 'Rejected promise calling storage setItem, with error: ' + e); + }); + }); + }, + + get length() { + return Object.keys(cache).length; + }, + getItem(key: string) { + return cache[key] || null; + }, + key(index: number) { + return Object.keys(cache)[index] || null; + }, + removeItem(key: string) { + delete cache[key]; + if (isTillKey(key)) this.save!(); + }, + setItem(key: string, value: string) { + cache[key] = value; + if (isTillKey(key)) this.save!(); + } + }; +} + +function validateStorage(log: ILogger, prefix: string, storage?: SplitIO.Storage): StorageAdapter | undefined { if (storage) { - if (isStorageValid(storage)) return storage; + if (isStorageValid(storage)) return storageAdapter(log, prefix, storage); log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); } @@ -39,7 +97,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt function InLocalStorageCSFactory(params: IStorageFactoryParams): IStorageSync { const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params; - const storage = validateStorage(log, options.storage); + const storage = validateStorage(log, prefix, options.storage); if (!storage) return InMemoryStorageCSFactory(params); const matchingKey = getMatching(settings.core.key); @@ -67,8 +125,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt }, destroy() { - // @TODO return `storageWrapper.disconnect()` - return Promise.resolve(); + return storage.save && storage.save(); }, // When using shared instantiation with MEMORY we reuse everything but segments (they are customer per key). diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 8aec3814..d25f9f3e 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -7,6 +7,7 @@ import type { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import type { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; import { KeyBuilderCS } from '../KeyBuilderCS'; import SplitIO from '../../../types/splitio'; +import { StorageAdapter } from '.'; const DEFAULT_CACHE_EXPIRATION_IN_DAYS = 10; const MILLIS_IN_A_DAY = 86400000; @@ -16,7 +17,7 @@ const MILLIS_IN_A_DAY = 86400000; * * @returns `true` if cache should be cleared, `false` otherwise */ -function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: SplitIO.Storage, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { +function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { const { log } = settings; // Check expiration @@ -67,9 +68,9 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: Spl * * @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache) */ -export function validateCache(options: SplitIO.InLocalStorageOptions, storage: SplitIO.Storage, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { +export function validateCache(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { - return Promise.resolve().then(() => { + return Promise.resolve(storage.load && storage.load()).then(() => { const currentTimestamp = Date.now(); const isThereCache = splits.getChangeNumber() > -1; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 3ae98912..c9e8fbf9 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -451,50 +451,17 @@ declare namespace SplitIO { interface Storage { /** - * Returns the number of key/value pairs. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/length) - */ - readonly length: number; - /** - * Removes all key/value pairs, if there are any. - * - * Dispatches a storage event on Window objects holding an equivalent Storage object. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/clear) - */ - clear(): void; - /** - * Returns the current value associated with the given key, or null if the given key does not exist. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem) - */ - getItem(key: string): string | null; - /** - * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/key) + * Returns a promise that resolves to the current value associated with the given key, or null if the given key does not exist. */ - key(index: number): string | null; + getItem(key: string): Promise; /** - * Removes the key/value pair with the given key, if a key/value pair with the given key exists. - * - * Dispatches a storage event on Window objects holding an equivalent Storage object. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/removeItem) - */ - removeItem(key: string): void; - /** - * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously. - * - * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.) - * - * Dispatches a storage event on Window objects holding an equivalent Storage object. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem) + * Returns a promise that resolves when the value of the pair identified by key is set to value, creating a new key/value pair if none existed for key previously. */ - setItem(key: string, value: string): void; - [name: string]: any; + setItem(key: string, value: string): Promise; + // /** + // * Returns a promise that resolves when the key/value pair with the given key is removed, if a key/value pair with the given key exists. + // */ + // removeItem(key: string): Promise; } /** From b550042c5490dede290b2e2f0274ac21c5640065 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 30 May 2025 12:49:42 -0300 Subject: [PATCH 05/32] Tests --- .../__tests__/MySegmentsCacheInLocal.spec.ts | 15 +- .../__tests__/SplitsCacheInLocal.spec.ts | 319 +++++++++--------- .../inLocalStorage/__tests__/index.spec.ts | 24 +- .../__tests__/validateCache.spec.ts | 75 ++-- .../inLocalStorage/__tests__/wrapper.mock.ts | 27 ++ src/storages/inLocalStorage/index.ts | 2 +- types/splitio.d.ts | 10 +- 7 files changed, 257 insertions(+), 215 deletions(-) create mode 100644 src/storages/inLocalStorage/__tests__/wrapper.mock.ts diff --git a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts index a3246dab..dfb98522 100644 --- a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts @@ -1,11 +1,12 @@ import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../../KeyBuilderCS'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { storages, PREFIX } from './wrapper.mock'; -test('SEGMENT CACHE / in LocalStorage', () => { +test.each(storages)('SEGMENT CACHE / in LocalStorage', (storage) => { const caches = [ - new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user'), localStorage), - new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder('SPLITIO', 'user'), localStorage) + new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS(PREFIX, 'user'), storage), + new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder(PREFIX, 'user'), storage) ]; caches.forEach(cache => { @@ -33,8 +34,8 @@ test('SEGMENT CACHE / in LocalStorage', () => { expect(cache.getKeysCount()).toBe(1); }); - expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment-2')).toBe('1'); - expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment')).toBe(null); - expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment-2')).toBe('1'); - expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment')).toBe(null); + expect(storage.getItem(PREFIX + '.user.segment.mocked-segment-2')).toBe('1'); + expect(storage.getItem(PREFIX + '.user.segment.mocked-segment')).toBe(null); + expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment-2')).toBe('1'); + expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment')).toBe(null); }); diff --git a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts index c8f938bd..8f561542 100644 --- a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts @@ -3,225 +3,228 @@ import { KeyBuilderCS } from '../../KeyBuilderCS'; import { splitWithUserTT, splitWithAccountTT, splitWithAccountTTAndUsesSegments, something, somethingElse, featureFlagOne, featureFlagTwo, featureFlagThree, featureFlagWithEmptyFS, featureFlagWithoutFS } from '../../__tests__/testUtils'; import { ISplit } from '../../../dtos/types'; import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { storages, PREFIX } from './wrapper.mock'; -test('SPLITS CACHE / LocalStorage', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); +describe.each(storages)('SPLITS CACHE', (storage) => { + test('LocalStorage', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.clear(); + cache.clear(); - cache.update([something, somethingElse], [], -1); + cache.update([something, somethingElse], [], -1); - let values = cache.getAll(); + let values = cache.getAll(); - expect(values).toEqual([something, somethingElse]); + expect(values).toEqual([something, somethingElse]); - cache.removeSplit(something.name); + cache.removeSplit(something.name); - const splits = cache.getSplits([something.name, somethingElse.name]); - expect(splits[something.name]).toEqual(null); - expect(splits[somethingElse.name]).toEqual(somethingElse); + const splits = cache.getSplits([something.name, somethingElse.name]); + expect(splits[something.name]).toEqual(null); + expect(splits[somethingElse.name]).toEqual(somethingElse); - values = cache.getAll(); + values = cache.getAll(); - expect(values).toEqual([somethingElse]); + expect(values).toEqual([somethingElse]); - expect(cache.getSplit(something.name)).toEqual(null); - expect(cache.getSplit(somethingElse.name)).toEqual(somethingElse); + expect(cache.getSplit(something.name)).toEqual(null); + expect(cache.getSplit(somethingElse.name)).toEqual(somethingElse); - expect(cache.getChangeNumber()).toBe(-1); + expect(cache.getChangeNumber()).toBe(-1); - cache.setChangeNumber(123); + cache.setChangeNumber(123); - expect(cache.getChangeNumber()).toBe(123); -}); + expect(cache.getChangeNumber()).toBe(123); + }); -test('SPLITS CACHE / LocalStorage / Get Keys', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); + test('LocalStorage / Get Keys', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.update([something, somethingElse], [], 1); + cache.update([something, somethingElse], [], 1); - const keys = cache.getSplitNames(); + const keys = cache.getSplitNames(); - expect(keys.indexOf(something.name) !== -1).toBe(true); - expect(keys.indexOf(somethingElse.name) !== -1).toBe(true); -}); + expect(keys.indexOf(something.name) !== -1).toBe(true); + expect(keys.indexOf(somethingElse.name) !== -1).toBe(true); + }); -test('SPLITS CACHE / LocalStorage / Update Splits', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); + test('LocalStorage / Update Splits', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.update([something, somethingElse], [], 1); + cache.update([something, somethingElse], [], 1); - cache.update([], [something, somethingElse], 1); + cache.update([], [something, somethingElse], 1); - expect(cache.getSplit(something.name)).toBe(null); - expect(cache.getSplit(somethingElse.name)).toBe(null); -}); + expect(cache.getSplit(something.name)).toBe(null); + expect(cache.getSplit(somethingElse.name)).toBe(null); + }); -test('SPLITS CACHE / LocalStorage / trafficTypeExists and ttcache tests', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); + test('LocalStorage / trafficTypeExists and ttcache tests', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.update([ - { ...splitWithUserTT, name: 'split1' }, - { ...splitWithAccountTT, name: 'split2' }, - { ...splitWithUserTT, name: 'split3' }, - ], [], 1); - cache.addSplit({ ...splitWithUserTT, name: 'split4' }); + cache.update([ + { ...splitWithUserTT, name: 'split1' }, + { ...splitWithAccountTT, name: 'split2' }, + { ...splitWithUserTT, name: 'split3' }, + ], [], 1); + cache.addSplit({ ...splitWithUserTT, name: 'split4' }); - expect(cache.trafficTypeExists('user_tt')).toBe(true); - expect(cache.trafficTypeExists('account_tt')).toBe(true); - expect(cache.trafficTypeExists('not_existent_tt')).toBe(false); + expect(cache.trafficTypeExists('user_tt')).toBe(true); + expect(cache.trafficTypeExists('account_tt')).toBe(true); + expect(cache.trafficTypeExists('not_existent_tt')).toBe(false); - cache.removeSplit('split4'); + cache.removeSplit('split4'); - expect(cache.trafficTypeExists('user_tt')).toBe(true); - expect(cache.trafficTypeExists('account_tt')).toBe(true); + expect(cache.trafficTypeExists('user_tt')).toBe(true); + expect(cache.trafficTypeExists('account_tt')).toBe(true); - cache.removeSplit('split3'); - cache.removeSplit('split2'); + cache.removeSplit('split3'); + cache.removeSplit('split2'); - expect(cache.trafficTypeExists('user_tt')).toBe(true); - expect(cache.trafficTypeExists('account_tt')).toBe(false); + expect(cache.trafficTypeExists('user_tt')).toBe(true); + expect(cache.trafficTypeExists('account_tt')).toBe(false); - cache.removeSplit('split1'); + cache.removeSplit('split1'); - expect(cache.trafficTypeExists('user_tt')).toBe(false); - expect(cache.trafficTypeExists('account_tt')).toBe(false); + expect(cache.trafficTypeExists('user_tt')).toBe(false); + expect(cache.trafficTypeExists('account_tt')).toBe(false); - cache.addSplit({ ...splitWithUserTT, name: 'split1' }); - expect(cache.trafficTypeExists('user_tt')).toBe(true); + cache.addSplit({ ...splitWithUserTT, name: 'split1' }); + expect(cache.trafficTypeExists('user_tt')).toBe(true); - cache.addSplit({ ...splitWithAccountTT, name: 'split1' }); - expect(cache.trafficTypeExists('account_tt')).toBe(true); - expect(cache.trafficTypeExists('user_tt')).toBe(false); + cache.addSplit({ ...splitWithAccountTT, name: 'split1' }); + expect(cache.trafficTypeExists('account_tt')).toBe(true); + expect(cache.trafficTypeExists('user_tt')).toBe(false); -}); + }); -test('SPLITS CACHE / LocalStorage / killLocally', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); + test('LocalStorage / killLocally', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - cache.addSplit(something); - cache.addSplit(somethingElse); - const initialChangeNumber = cache.getChangeNumber(); + cache.addSplit(something); + cache.addSplit(somethingElse); + const initialChangeNumber = cache.getChangeNumber(); - // kill an non-existent split - let updated = cache.killLocally('nonexistent_split', 'other_treatment', 101); - const nonexistentSplit = cache.getSplit('nonexistent_split'); + // kill an non-existent split + let updated = cache.killLocally('nonexistent_split', 'other_treatment', 101); + const nonexistentSplit = cache.getSplit('nonexistent_split'); - expect(updated).toBe(false); // killLocally resolves without update if split doesn't exist - expect(nonexistentSplit).toBe(null); // non-existent split keeps being non-existent + expect(updated).toBe(false); // killLocally resolves without update if split doesn't exist + expect(nonexistentSplit).toBe(null); // non-existent split keeps being non-existent - // kill an existent split - updated = cache.killLocally(something.name, 'some_treatment', 100); - let lol1Split = cache.getSplit(something.name) as ISplit; + // kill an existent split + updated = cache.killLocally(something.name, 'some_treatment', 100); + let lol1Split = cache.getSplit(something.name) as ISplit; - expect(updated).toBe(true); // killLocally resolves with update if split is changed - expect(lol1Split.killed).toBe(true); // existing split must be killed - expect(lol1Split.defaultTreatment).toBe('some_treatment'); // existing split must have new default treatment - expect(lol1Split.changeNumber).toBe(100); // existing split must have the given change number - expect(cache.getChangeNumber()).toBe(initialChangeNumber); // cache changeNumber is not changed + expect(updated).toBe(true); // killLocally resolves with update if split is changed + expect(lol1Split.killed).toBe(true); // existing split must be killed + expect(lol1Split.defaultTreatment).toBe('some_treatment'); // existing split must have new default treatment + expect(lol1Split.changeNumber).toBe(100); // existing split must have the given change number + expect(cache.getChangeNumber()).toBe(initialChangeNumber); // cache changeNumber is not changed - // not update if changeNumber is old - updated = cache.killLocally(something.name, 'some_treatment_2', 90); - lol1Split = cache.getSplit(something.name) as ISplit; + // not update if changeNumber is old + updated = cache.killLocally(something.name, 'some_treatment_2', 90); + lol1Split = cache.getSplit(something.name) as ISplit; - expect(updated).toBe(false); // killLocally resolves without update if changeNumber is old - expect(lol1Split.defaultTreatment).not.toBe('some_treatment_2'); // existing split is not updated if given changeNumber is older + expect(updated).toBe(false); // killLocally resolves without update if changeNumber is old + expect(lol1Split.defaultTreatment).not.toBe('some_treatment_2'); // existing split is not updated if given changeNumber is older -}); + }); -test('SPLITS CACHE / LocalStorage / usesSegments', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); + test('LocalStorage / usesSegments', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); - expect(cache.usesSegments()).toBe(true); // true initially, until data is synchronized - cache.setChangeNumber(1); // to indicate that data has been synced. + expect(cache.usesSegments()).toBe(true); // true initially, until data is synchronized + cache.setChangeNumber(1); // to indicate that data has been synced. - cache.update([splitWithUserTT, splitWithAccountTT], [], 1); - expect(cache.usesSegments()).toBe(false); // 0 splits using segments + cache.update([splitWithUserTT, splitWithAccountTT], [], 1); + expect(cache.usesSegments()).toBe(false); // 0 splits using segments - cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split3' }); - expect(cache.usesSegments()).toBe(true); // 1 split using segments + cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split3' }); + expect(cache.usesSegments()).toBe(true); // 1 split using segments - cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split4' }); - expect(cache.usesSegments()).toBe(true); // 2 splits using segments + cache.addSplit({ ...splitWithAccountTTAndUsesSegments, name: 'split4' }); + expect(cache.usesSegments()).toBe(true); // 2 splits using segments - cache.removeSplit('split3'); - expect(cache.usesSegments()).toBe(true); // 1 split using segments + cache.removeSplit('split3'); + expect(cache.usesSegments()).toBe(true); // 1 split using segments - cache.removeSplit('split4'); - expect(cache.usesSegments()).toBe(false); // 0 splits using segments -}); + cache.removeSplit('split4'); + expect(cache.usesSegments()).toBe(false); // 0 splits using segments + }); -test('SPLITS CACHE / LocalStorage / flag set cache tests', () => { - // @ts-ignore - const cache = new SplitsCacheInLocal({ - ...fullSettings, - sync: { // @ts-expect-error - __splitFiltersValidation: { - groupedFilters: { bySet: ['o', 'n', 'e', 'x'], byName: [], byPrefix: [] }, - queryString: '&sets=e,n,o,x', + test('LocalStorage / flag set cache tests', () => { + // @ts-ignore + const cache = new SplitsCacheInLocal({ + ...fullSettings, + sync: { // @ts-expect-error + __splitFiltersValidation: { + groupedFilters: { bySet: ['o', 'n', 'e', 'x'], byName: [], byPrefix: [] }, + queryString: '&sets=e,n,o,x', + } } - } - }, new KeyBuilderCS('SPLITIO', 'user'), localStorage); + }, new KeyBuilderCS(PREFIX, 'user'), storage); - const emptySet = new Set([]); + const emptySet = new Set([]); - cache.update([ - featureFlagOne, - featureFlagTwo, - featureFlagThree, - ], [], -1); - cache.addSplit(featureFlagWithEmptyFS); + cache.update([ + featureFlagOne, + featureFlagTwo, + featureFlagThree, + ], [], -1); + cache.addSplit(featureFlagWithEmptyFS); - expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); - expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); - expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); - expect(cache.getNamesByFlagSets(['t'])).toEqual([emptySet]); // 't' not in filter - expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); + expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); + expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['t'])).toEqual([emptySet]); // 't' not in filter + expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); - cache.addSplit({ ...featureFlagOne, sets: ['1'] }); + cache.addSplit({ ...featureFlagOne, sets: ['1'] }); - expect(cache.getNamesByFlagSets(['1'])).toEqual([emptySet]); // '1' not in filter - expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_two'])]); - expect(cache.getNamesByFlagSets(['n'])).toEqual([emptySet]); + expect(cache.getNamesByFlagSets(['1'])).toEqual([emptySet]); // '1' not in filter + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_two'])]); + expect(cache.getNamesByFlagSets(['n'])).toEqual([emptySet]); - cache.addSplit({ ...featureFlagOne, sets: ['x'] }); - expect(cache.getNamesByFlagSets(['x'])).toEqual([new Set(['ff_one'])]); - expect(cache.getNamesByFlagSets(['o', 'e', 'x'])).toEqual([new Set(['ff_two']), new Set(['ff_three']), new Set(['ff_one'])]); + cache.addSplit({ ...featureFlagOne, sets: ['x'] }); + expect(cache.getNamesByFlagSets(['x'])).toEqual([new Set(['ff_one'])]); + expect(cache.getNamesByFlagSets(['o', 'e', 'x'])).toEqual([new Set(['ff_two']), new Set(['ff_three']), new Set(['ff_one'])]); - cache.removeSplit(featureFlagOne.name); - expect(cache.getNamesByFlagSets(['x'])).toEqual([emptySet]); + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['x'])).toEqual([emptySet]); - cache.removeSplit(featureFlagOne.name); - expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); // 'y' not in filter - expect(cache.getNamesByFlagSets([])).toEqual([]); + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); // 'y' not in filter + expect(cache.getNamesByFlagSets([])).toEqual([]); - cache.addSplit(featureFlagWithoutFS); - expect(cache.getNamesByFlagSets([])).toEqual([]); -}); + cache.addSplit(featureFlagWithoutFS); + expect(cache.getNamesByFlagSets([])).toEqual([]); + }); + + // if FlagSets are not defined, it should store all FlagSets in memory. + test('LocalStorage / flag set cache tests without filters', () => { + const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS(PREFIX, 'user'), storage); + + const emptySet = new Set([]); + + cache.update([ + featureFlagOne, + featureFlagTwo, + featureFlagThree, + ], [], -1); + cache.addSplit(featureFlagWithEmptyFS); + + expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); + expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); + expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['t'])).toEqual([new Set(['ff_two', 'ff_three'])]); + expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); + expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); -// if FlagSets are not defined, it should store all FlagSets in memory. -test('SPLIT CACHE / LocalStorage / flag set cache tests without filters', () => { - const cache = new SplitsCacheInLocal(fullSettings, new KeyBuilderCS('SPLITIO', 'user'), localStorage); - - const emptySet = new Set([]); - - cache.update([ - featureFlagOne, - featureFlagTwo, - featureFlagThree, - ], [], -1); - cache.addSplit(featureFlagWithEmptyFS); - - expect(cache.getNamesByFlagSets(['o'])).toEqual([new Set(['ff_one', 'ff_two'])]); - expect(cache.getNamesByFlagSets(['n'])).toEqual([new Set(['ff_one'])]); - expect(cache.getNamesByFlagSets(['e'])).toEqual([new Set(['ff_one', 'ff_three'])]); - expect(cache.getNamesByFlagSets(['t'])).toEqual([new Set(['ff_two', 'ff_three'])]); - expect(cache.getNamesByFlagSets(['y'])).toEqual([emptySet]); - expect(cache.getNamesByFlagSets(['o', 'n', 'e'])).toEqual([new Set(['ff_one', 'ff_two']), new Set(['ff_one']), new Set(['ff_one', 'ff_three'])]); - - // Validate that the feature flag cache is cleared when calling `clear` method - cache.clear(); - expect(localStorage.length).toBe(0); + // Validate that the feature flag cache is cleared when calling `clear` method + cache.clear(); + expect(storage.length).toBe(0); + }); }); diff --git a/src/storages/inLocalStorage/__tests__/index.spec.ts b/src/storages/inLocalStorage/__tests__/index.spec.ts index b9d0fc1d..5d11f514 100644 --- a/src/storages/inLocalStorage/__tests__/index.spec.ts +++ b/src/storages/inLocalStorage/__tests__/index.spec.ts @@ -23,19 +23,29 @@ describe('IN LOCAL STORAGE', () => { fakeInMemoryStorageFactory.mockClear(); }); - test('calls InMemoryStorage factory if LocalStorage API is not available', () => { - + test('calls InMemoryStorage factory if LocalStorage API is not available or the provided storage wrapper is invalid', () => { + // Delete global localStorage property const originalLocalStorage = Object.getOwnPropertyDescriptor(global, 'localStorage'); - Object.defineProperty(global, 'localStorage', {}); // delete global localStorage property - - const storageFactory = InLocalStorage({ prefix: 'prefix' }); - const storage = storageFactory(internalSdkParams); + Object.defineProperty(global, 'localStorage', {}); + // LocalStorage API is not available + let storageFactory = InLocalStorage({ prefix: 'prefix' }); + let storage = storageFactory(internalSdkParams); expect(fakeInMemoryStorageFactory).toBeCalledWith(internalSdkParams); // calls InMemoryStorage factory expect(storage).toBe(fakeInMemoryStorage); - Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); // restore original localStorage + // @ts-expect-error Provided storage is invalid + storageFactory = InLocalStorage({ prefix: 'prefix', storage: {} }); + storage = storageFactory(internalSdkParams); + expect(storage).toBe(fakeInMemoryStorage); + + // Provided storage is valid + storageFactory = InLocalStorage({ prefix: 'prefix', storage: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } }); + storage = storageFactory(internalSdkParams); + expect(storage).not.toBe(fakeInMemoryStorage); + // Restore original localStorage + Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); }); test('calls its own storage factory if LocalStorage API is available', () => { diff --git a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts index 2699d184..102444ba 100644 --- a/src/storages/inLocalStorage/__tests__/validateCache.spec.ts +++ b/src/storages/inLocalStorage/__tests__/validateCache.spec.ts @@ -6,16 +6,17 @@ import { SplitsCacheInLocal } from '../SplitsCacheInLocal'; import { nearlyEqual } from '../../../__tests__/testUtils'; import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal'; import { RBSegmentsCacheInLocal } from '../RBSegmentsCacheInLocal'; +import { storages, PREFIX } from './wrapper.mock'; const FULL_SETTINGS_HASH = 'dc1f9817'; -describe('validateCache', () => { - const keys = new KeyBuilderCS('SPLITIO', 'user'); +describe.each(storages)('validateCache', (storage) => { + const keys = new KeyBuilderCS(PREFIX, 'user'); const logSpy = jest.spyOn(fullSettings.log, 'info'); - const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage); - const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage); - const splits = new SplitsCacheInLocal(fullSettings, keys, localStorage); - const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, localStorage); + const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage); + const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage); + const splits = new SplitsCacheInLocal(fullSettings, keys, storage); + const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, storage); jest.spyOn(splits, 'getChangeNumber'); jest.spyOn(splits, 'clear'); @@ -25,11 +26,11 @@ describe('validateCache', () => { beforeEach(() => { jest.clearAllMocks(); - localStorage.clear(); + for (let i = 0; i < storage.length; i++) storage.removeItem(storage.key(i) as string); }); test('if there is no cache, it should return false', async () => { - expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).not.toHaveBeenCalled(); @@ -39,15 +40,15 @@ describe('validateCache', () => { expect(largeSegments.clear).not.toHaveBeenCalled(); expect(splits.getChangeNumber).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - expect(localStorage.getItem(keys.buildLastClear())).toBeNull(); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + expect(storage.getItem(keys.buildLastClear())).toBeNull(); }); test('if there is cache and it must not be cleared, it should return true', async () => { - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); @@ -57,16 +58,16 @@ describe('validateCache', () => { expect(largeSegments.clear).not.toHaveBeenCalled(); expect(splits.getChangeNumber).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - expect(localStorage.getItem(keys.buildLastClear())).toBeNull(); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + expect(storage.getItem(keys.buildLastClear())).toBeNull(); }); test('if there is cache and it has expired, it should clear cache and return false', async () => { - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago - expect(await validateCache({ expirationDays: 1 }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache'); @@ -75,15 +76,15 @@ describe('validateCache', () => { expect(segments.clear).toHaveBeenCalledTimes(1); expect(largeSegments.clear).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); test('if there is cache and its hash has changed, it should clear cache and return false', async () => { - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(await validateCache({}, localStorage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache'); @@ -92,16 +93,16 @@ describe('validateCache', () => { expect(segments.clear).toHaveBeenCalledTimes(1); expect(largeSegments.clear).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe('45c6ba5d'); - expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); + expect(storage.getItem(keys.buildHashKey())).toBe('45c6ba5d'); + expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => { // Older cache version (without last clear) - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); + storage.setItem(keys.buildSplitsTillKey(), '1'); + storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH); - expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); @@ -110,25 +111,25 @@ describe('validateCache', () => { expect(segments.clear).toHaveBeenCalledTimes(1); expect(largeSegments.clear).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); - const lastClear = localStorage.getItem(keys.buildLastClear()); + expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH); + const lastClear = storage.getItem(keys.buildLastClear()); expect(nearlyEqual(parseInt(lastClear as string), Date.now())).toBe(true); // If cache is cleared, it should not clear again until a day has passed logSpy.mockClear(); - localStorage.setItem(keys.buildSplitsTillKey(), '1'); - expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); + storage.setItem(keys.buildSplitsTillKey(), '1'); + expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true); expect(logSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed + expect(storage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed // If a day has passed, it should clear again - localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + ''); - expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); + storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + ''); + expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false); expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache'); expect(splits.clear).toHaveBeenCalledTimes(2); expect(rbSegments.clear).toHaveBeenCalledTimes(2); expect(segments.clear).toHaveBeenCalledTimes(2); expect(largeSegments.clear).toHaveBeenCalledTimes(2); - expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); + expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true); }); }); diff --git a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts new file mode 100644 index 00000000..f1d3b90c --- /dev/null +++ b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts @@ -0,0 +1,27 @@ +import { storageAdapter } from '..'; +import SplitIO from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + +export const PREFIX = 'SPLITIO'; + +export function createMemoryStorage(): SplitIO.Storage { + let cache: Record = {}; + return { + getItem(key: string) { + return Promise.resolve(cache[key] || null); + }, + setItem(key: string, value: string) { + cache[key] = value; + return Promise.resolve(); + }, + removeItem(key: string) { + delete cache[key]; + return Promise.resolve(); + } + }; +} + +export const storages = [ + localStorage, + storageAdapter(loggerMock, PREFIX, createMemoryStorage()) +]; diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 7c04440f..b1e94254 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -34,7 +34,7 @@ function isTillKey(key: string) { return key.endsWith('.till'); } -function storageAdapter(log: ILogger, prefix: string, storage: SplitIO.Storage): StorageAdapter { +export function storageAdapter(log: ILogger, prefix: string, storage: SplitIO.Storage): StorageAdapter { let cache: Record = {}; let connectPromise: Promise | undefined; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index c9e8fbf9..3600e682 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -457,11 +457,11 @@ declare namespace SplitIO { /** * Returns a promise that resolves when the value of the pair identified by key is set to value, creating a new key/value pair if none existed for key previously. */ - setItem(key: string, value: string): Promise; - // /** - // * Returns a promise that resolves when the key/value pair with the given key is removed, if a key/value pair with the given key exists. - // */ - // removeItem(key: string): Promise; + setItem(key: string, value: string): Promise; + /** + * Returns a promise that resolves when the key/value pair with the given key is removed, if a key/value pair with the given key exists. + */ + removeItem(key: string): Promise; } /** From 4b0988df659c060e636bb3ace587939ed53fc543 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 23 Jun 2025 17:51:59 -0300 Subject: [PATCH 06/32] Update types --- types/splitio.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 3600e682..9f979e7a 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1324,6 +1324,12 @@ declare namespace SplitIO { * @defaultValue `false` */ clearOnInit?: boolean; + /** + * Optional storage API to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. + * + * @defaultValue `window.localStorage` + */ + storage?: Storage; }; } /** From 299585b18880038c125c0a1c78d80262f7981f79 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 18 Jul 2025 15:07:16 -0300 Subject: [PATCH 07/32] refactor: rename Storage interface to StorageWrapper for clarity and consistency --- .../inLocalStorage/__tests__/index.spec.ts | 4 ++-- .../inLocalStorage/__tests__/wrapper.mock.ts | 2 +- src/storages/inLocalStorage/index.ts | 16 ++++++++-------- src/utils/env/isLocalStorageAvailable.ts | 10 +++++----- types/splitio.d.ts | 10 +++++----- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/storages/inLocalStorage/__tests__/index.spec.ts b/src/storages/inLocalStorage/__tests__/index.spec.ts index 5d11f514..545c532f 100644 --- a/src/storages/inLocalStorage/__tests__/index.spec.ts +++ b/src/storages/inLocalStorage/__tests__/index.spec.ts @@ -35,12 +35,12 @@ describe('IN LOCAL STORAGE', () => { expect(storage).toBe(fakeInMemoryStorage); // @ts-expect-error Provided storage is invalid - storageFactory = InLocalStorage({ prefix: 'prefix', storage: {} }); + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: {} }); storage = storageFactory(internalSdkParams); expect(storage).toBe(fakeInMemoryStorage); // Provided storage is valid - storageFactory = InLocalStorage({ prefix: 'prefix', storage: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } }); + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } }); storage = storageFactory(internalSdkParams); expect(storage).not.toBe(fakeInMemoryStorage); diff --git a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts index f1d3b90c..dbfcbf6a 100644 --- a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts +++ b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts @@ -4,7 +4,7 @@ import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; export const PREFIX = 'SPLITIO'; -export function createMemoryStorage(): SplitIO.Storage { +export function createMemoryStorage(): SplitIO.StorageWrapper { let cache: Record = {}; return { getItem(key: string) { diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index b1e94254..6e8c41d3 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -4,7 +4,7 @@ import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory'; import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory } from '../types'; import { validatePrefix } from '../KeyBuilder'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS'; -import { isLocalStorageAvailable, isStorageValid } from '../../utils/env/isLocalStorageAvailable'; +import { isLocalStorageAvailable, isStorageWrapperValid } from '../../utils/env/isLocalStorageAvailable'; import { SplitsCacheInLocal } from './SplitsCacheInLocal'; import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; @@ -34,7 +34,7 @@ function isTillKey(key: string) { return key.endsWith('.till'); } -export function storageAdapter(log: ILogger, prefix: string, storage: SplitIO.Storage): StorageAdapter { +export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): StorageAdapter { let cache: Record = {}; let connectPromise: Promise | undefined; @@ -42,7 +42,7 @@ export function storageAdapter(log: ILogger, prefix: string, storage: SplitIO.St return { load() { - return connectPromise || (connectPromise = storage.getItem(prefix).then((storedCache) => { + return connectPromise || (connectPromise = wrapper.getItem(prefix).then((storedCache) => { cache = JSON.parse(storedCache || '{}'); }).catch((e) => { log.error(LOG_PREFIX + 'Rejected promise calling storage getItem, with error: ' + e); @@ -50,7 +50,7 @@ export function storageAdapter(log: ILogger, prefix: string, storage: SplitIO.St }, save() { return disconnectPromise = disconnectPromise.then(() => { - return storage.setItem(prefix, JSON.stringify(cache)).catch((e) => { + return wrapper.setItem(prefix, JSON.stringify(cache)).catch((e) => { log.error(LOG_PREFIX + 'Rejected promise calling storage setItem, with error: ' + e); }); }); @@ -76,9 +76,9 @@ export function storageAdapter(log: ILogger, prefix: string, storage: SplitIO.St }; } -function validateStorage(log: ILogger, prefix: string, storage?: SplitIO.Storage): StorageAdapter | undefined { - if (storage) { - if (isStorageValid(storage)) return storageAdapter(log, prefix, storage); +function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined { + if (wrapper) { + if (isStorageWrapperValid(wrapper)) return storageAdapter(log, prefix, wrapper); log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); } @@ -97,7 +97,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt function InLocalStorageCSFactory(params: IStorageFactoryParams): IStorageSync { const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params; - const storage = validateStorage(log, prefix, options.storage); + const storage = validateStorage(log, prefix, options.wrapper); if (!storage) return InMemoryStorageCSFactory(params); const matchingKey = getMatching(settings.core.key); diff --git a/src/utils/env/isLocalStorageAvailable.ts b/src/utils/env/isLocalStorageAvailable.ts index 5e98b87d..b01efec0 100644 --- a/src/utils/env/isLocalStorageAvailable.ts +++ b/src/utils/env/isLocalStorageAvailable.ts @@ -1,18 +1,18 @@ export function isLocalStorageAvailable(): boolean { try { // eslint-disable-next-line no-undef - return isStorageValid(localStorage); + return isStorageWrapperValid(localStorage); } catch (e) { return false; } } -export function isStorageValid(storage: any): boolean { +export function isStorageWrapperValid(wrapper: any): boolean { var mod = '__SPLITSOFTWARE__'; try { - storage.setItem(mod, mod); - storage.getItem(mod); - storage.removeItem(mod); + wrapper.setItem(mod, mod); + wrapper.getItem(mod); + wrapper.removeItem(mod); return true; } catch (e) { return false; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 9f979e7a..6b71c204 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -449,7 +449,7 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync */ declare namespace SplitIO { - interface Storage { + interface StorageWrapper { /** * Returns a promise that resolves to the current value associated with the given key, or null if the given key does not exist. */ @@ -979,11 +979,11 @@ declare namespace SplitIO { */ clearOnInit?: boolean; /** - * Optional storage API to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. + * Optional storage wrapper to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. * * @defaultValue `window.localStorage` */ - storage?: Storage; + wrapper?: StorageWrapper; } /** * Storage for asynchronous (consumer) SDK. @@ -1325,11 +1325,11 @@ declare namespace SplitIO { */ clearOnInit?: boolean; /** - * Optional storage API to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. + * Optional storage wrapper to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. * * @defaultValue `window.localStorage` */ - storage?: Storage; + wrapper?: StorageWrapper; }; } /** From 6b5eaca1545a78d462d9819949b2f4ff6c403d61 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 30 Jul 2025 11:12:05 -0300 Subject: [PATCH 08/32] Polishing --- CHANGES.txt | 9 +++++++++ src/storages/inLocalStorage/index.ts | 1 - src/storages/types.ts | 3 +-- src/sync/syncManagerOnline.ts | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index d07f2bc1..4a18088b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -47,6 +47,15 @@ - Removed internal ponyfills for `Map` and `Set` global objects, dropping support for IE and other outdated browsers. The SDK now requires the runtime environment to support these features natively or to provide a polyfill. - Removed the `sync.localhostMode` configuration option to plug the LocalhostMode module. +1.17.1 (July 25, 2025) + - Updated the Redis storage to avoid lazy require of the `ioredis` dependency when the SDK is initialized. + - Updated some transitive dependencies for vulnerability fixes. + - Bugfix - Enhanced HTTP client module to implement timeouts for failing requests that might otherwise remain pending indefinitely on some Fetch API implementations, pausing the SDK synchronization process. + - Bugfix - Properly handle rejected promises when using targeting rules with segment matchers in consumer modes (e.g., Redis and Pluggable storages). + - Bugfix - Sanitize the `SplitSDKMachineName` header value to avoid exceptions on HTTP/S requests when it contains non ISO-8859-1 characters (Related to issue https://github.com/splitio/javascript-client/issues/847). + - Bugfix - Fixed an issue with the SDK_UPDATE event on server-side, where it was not being emitted if there was an empty segment and the SDK received a feature flag update notification. + - Bugfix - Fixed an issue with the server-side polling manager that caused dangling timers when the SDK was destroyed before it was ready. + 1.17.0 (September 6, 2024) - Added `sync.requestOptions.getHeaderOverrides` configuration option to enhance SDK HTTP request Headers for Authorization Frameworks. - Added `isTimedout` and `lastUpdate` properties to IStatusInterface to keep track of the timestamp of the last SDK event, used on React and Redux SDKs. diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 544289a3..c86b1008 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -58,7 +58,6 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt }, destroy() { - // @TODO return `storageWrapper.disconnect()` return Promise.resolve(); }, diff --git a/src/storages/types.ts b/src/storages/types.ts index 30952342..0e9c3140 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -479,8 +479,7 @@ export interface IStorageSync extends IStorageBase< IUniqueKeysCacheSync > { // Defined in client-side - // https://github.com/splitio/javascript-commons/pull/352 - validateCache?: () => Promise, // or connect? + validateCache?: () => Promise, largeSegments?: ISegmentsCacheSync, } diff --git a/src/sync/syncManagerOnline.ts b/src/sync/syncManagerOnline.ts index 69c78abc..92dbc28f 100644 --- a/src/sync/syncManagerOnline.ts +++ b/src/sync/syncManagerOnline.ts @@ -89,7 +89,7 @@ export function syncManagerOnlineFactory( start() { running = true; - // @TODO call after `validateCache` promise once there are InLocal versions for event, impression and telemetry storages + // @TODO once event, impression and telemetry storages support persistence, call when `validateCache` promise is resolved submitterManager.start(!isConsentGranted(settings)); return Promise.resolve(storage.validateCache ? storage.validateCache() : false).then((isCacheLoaded) => { From c567f2ecb6e567d58ebca731cf914e9cd5d39f22 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 30 Jul 2025 16:24:33 -0300 Subject: [PATCH 09/32] Polishing --- .../inLocalStorage/MySegmentsCacheInLocal.ts | 28 +++---- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 40 +++++----- .../inLocalStorage/SplitsCacheInLocal.ts | 74 +++++++++---------- src/storages/inLocalStorage/index.ts | 11 +-- src/storages/inLocalStorage/validateCache.ts | 5 +- src/storages/types.ts | 12 +++ src/utils/env/isLocalStorageAvailable.ts | 15 +--- types/splitio.d.ts | 54 -------------- 8 files changed, 88 insertions(+), 151 deletions(-) diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 8865a4e7..5fc176cb 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -3,19 +3,19 @@ import { isNaNNumber } from '../../utils/lang'; import { AbstractMySegmentsCacheSync } from '../AbstractMySegmentsCacheSync'; import type { MySegmentsKeyBuilder } from '../KeyBuilderCS'; import { LOG_PREFIX, DEFINED } from './constants'; -import SplitIO from '../../../types/splitio'; +import { StorageAdapter } from '../types'; export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { private readonly keys: MySegmentsKeyBuilder; private readonly log: ILogger; - private readonly localStorage: SplitIO.Storage; + private readonly storage: StorageAdapter; - constructor(log: ILogger, keys: MySegmentsKeyBuilder, localStorage: SplitIO.Storage) { + constructor(log: ILogger, keys: MySegmentsKeyBuilder, storage: StorageAdapter) { super(); this.log = log; this.keys = keys; - this.localStorage = localStorage; + this.storage = storage; // There is not need to flush segments cache like splits cache, since resetSegments receives the up-to-date list of active segments } @@ -23,8 +23,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { const segmentKey = this.keys.buildSegmentNameKey(name); try { - if (this.localStorage.getItem(segmentKey) === DEFINED) return false; - this.localStorage.setItem(segmentKey, DEFINED); + if (this.storage.getItem(segmentKey) === DEFINED) return false; + this.storage.setItem(segmentKey, DEFINED); return true; } catch (e) { this.log.error(LOG_PREFIX + e); @@ -36,8 +36,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { const segmentKey = this.keys.buildSegmentNameKey(name); try { - if (this.localStorage.getItem(segmentKey) !== DEFINED) return false; - this.localStorage.removeItem(segmentKey); + if (this.storage.getItem(segmentKey) !== DEFINED) return false; + this.storage.removeItem(segmentKey); return true; } catch (e) { this.log.error(LOG_PREFIX + e); @@ -46,13 +46,13 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { } isInSegment(name: string): boolean { - return this.localStorage.getItem(this.keys.buildSegmentNameKey(name)) === DEFINED; + return this.storage.getItem(this.keys.buildSegmentNameKey(name)) === DEFINED; } getRegisteredSegments(): string[] { const registeredSegments: string[] = []; - for (let i = 0; i < this.localStorage.length; i++) { - const segmentName = this.keys.extractSegmentName(this.localStorage.key(i)!); + for (let i = 0; i < this.storage.length; i++) { + const segmentName = this.keys.extractSegmentName(this.storage.key(i)!); if (segmentName) registeredSegments.push(segmentName); } return registeredSegments; @@ -64,8 +64,8 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { protected setChangeNumber(changeNumber?: number) { try { - if (changeNumber) this.localStorage.setItem(this.keys.buildTillKey(), changeNumber + ''); - else this.localStorage.removeItem(this.keys.buildTillKey()); + if (changeNumber) this.storage.setItem(this.keys.buildTillKey(), changeNumber + ''); + else this.storage.removeItem(this.keys.buildTillKey()); } catch (e) { this.log.error(e); } @@ -73,7 +73,7 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { getChangeNumber() { const n = -1; - let value: string | number | null = this.localStorage.getItem(this.keys.buildTillKey()); + let value: string | number | null = this.storage.getItem(this.keys.buildTillKey()); if (value !== null) { value = parseInt(value, 10); diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index a9744912..b0b2aba5 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -5,25 +5,24 @@ import { isFiniteNumber, isNaNNumber, toNumber } from '../../utils/lang'; import { setToArray } from '../../utils/lang/sets'; import { usesSegments } from '../AbstractSplitsCacheSync'; import { KeyBuilderCS } from '../KeyBuilderCS'; -import { IRBSegmentsCacheSync } from '../types'; +import { IRBSegmentsCacheSync, StorageAdapter } from '../types'; import { LOG_PREFIX } from './constants'; -import SplitIO from '../../../types/splitio'; export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private readonly keys: KeyBuilderCS; private readonly log: ILogger; - private readonly localStorage: SplitIO.Storage; + private readonly storage: StorageAdapter; - constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: SplitIO.Storage) { + constructor(settings: ISettings, keys: KeyBuilderCS, storage: StorageAdapter) { this.keys = keys; this.log = settings.log; - this.localStorage = localStorage; + this.storage = storage; } clear() { this.getNames().forEach(name => this.remove(name)); - this.localStorage.removeItem(this.keys.buildRBSegmentsTillKey()); + this.storage.removeItem(this.keys.buildRBSegmentsTillKey()); } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { @@ -35,8 +34,8 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private setChangeNumber(changeNumber: number) { try { - this.localStorage.setItem(this.keys.buildRBSegmentsTillKey(), changeNumber + ''); - this.localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); + this.storage.setItem(this.keys.buildRBSegmentsTillKey(), changeNumber + ''); + this.storage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); } catch (e) { this.log.error(LOG_PREFIX + e); } @@ -44,20 +43,19 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { private updateSegmentCount(diff: number) { const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); - const count = toNumber(this.localStorage.getItem(segmentsCountKey)) + diff; - // @ts-expect-error - if (count > 0) this.localStorage.setItem(segmentsCountKey, count); - else this.localStorage.removeItem(segmentsCountKey); + const count = toNumber(this.storage.getItem(segmentsCountKey)) + diff; + if (count > 0) this.storage.setItem(segmentsCountKey, count + ''); + else this.storage.removeItem(segmentsCountKey); } private add(rbSegment: IRBSegment): boolean { try { const name = rbSegment.name; const rbSegmentKey = this.keys.buildRBSegmentKey(name); - const rbSegmentFromLocalStorage = this.localStorage.getItem(rbSegmentKey); - const previous = rbSegmentFromLocalStorage ? JSON.parse(rbSegmentFromLocalStorage) : null; + const rbSegmentFromStorage = this.storage.getItem(rbSegmentKey); + const previous = rbSegmentFromStorage ? JSON.parse(rbSegmentFromStorage) : null; - this.localStorage.setItem(rbSegmentKey, JSON.stringify(rbSegment)); + this.storage.setItem(rbSegmentKey, JSON.stringify(rbSegment)); let usesSegmentsDiff = 0; if (previous && usesSegments(previous)) usesSegmentsDiff--; @@ -76,7 +74,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { const rbSegment = this.get(name); if (!rbSegment) return false; - this.localStorage.removeItem(this.keys.buildRBSegmentKey(name)); + this.storage.removeItem(this.keys.buildRBSegmentKey(name)); if (usesSegments(rbSegment)) this.updateSegmentCount(-1); @@ -88,13 +86,13 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } private getNames(): string[] { - const len = this.localStorage.length; + const len = this.storage.length; const accum = []; let cur = 0; while (cur < len) { - const key = this.localStorage.key(cur); + const key = this.storage.key(cur); if (key != null && this.keys.isRBSegmentKey(key)) accum.push(this.keys.extractKey(key)); @@ -105,7 +103,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } get(name: string): IRBSegment | null { - const item = this.localStorage.getItem(this.keys.buildRBSegmentKey(name)); + const item = this.storage.getItem(this.keys.buildRBSegmentKey(name)); return item && JSON.parse(item); } @@ -117,7 +115,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { getChangeNumber(): number { const n = -1; - let value: string | number | null = this.localStorage.getItem(this.keys.buildRBSegmentsTillKey()); + let value: string | number | null = this.storage.getItem(this.keys.buildRBSegmentsTillKey()); if (value !== null) { value = parseInt(value, 10); @@ -129,7 +127,7 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } usesSegments(): boolean { - const storedCount = this.localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); + const storedCount = this.storage.getItem(this.keys.buildSplitsWithSegmentCountKey()); const splitsWithSegmentsCount = storedCount === null ? 0 : toNumber(storedCount); return isFiniteNumber(splitsWithSegmentsCount) ? diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index da411048..3aa08452 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -6,32 +6,28 @@ import { ILogger } from '../../logger/types'; import { LOG_PREFIX } from './constants'; import { ISettings } from '../../types'; import { setToArray } from '../../utils/lang/sets'; -import SplitIO from '../../../types/splitio'; +import { StorageAdapter } from '../types'; -/** - * ISplitsCacheSync implementation that stores split definitions in browser LocalStorage. - */ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private readonly keys: KeyBuilderCS; private readonly log: ILogger; private readonly flagSetsFilter: string[]; private hasSync?: boolean; - private readonly localStorage: SplitIO.Storage; + private readonly storage: StorageAdapter; - constructor(settings: ISettings, keys: KeyBuilderCS, localStorage: SplitIO.Storage) { + constructor(settings: ISettings, keys: KeyBuilderCS, storage: StorageAdapter) { super(); this.keys = keys; this.log = settings.log; this.flagSetsFilter = settings.sync.__splitFiltersValidation.groupedFilters.bySet; - this.localStorage = localStorage; + this.storage = storage; } private _decrementCount(key: string) { - const count = toNumber(this.localStorage.getItem(key)) - 1; - // @ts-expect-error - if (count > 0) this.localStorage.setItem(key, count); - else this.localStorage.removeItem(key); + const count = toNumber(this.storage.getItem(key)) - 1; + if (count > 0) this.storage.setItem(key, count + ''); + else this.storage.removeItem(key); } private _decrementCounts(split: ISplit) { @@ -51,13 +47,11 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private _incrementCounts(split: ISplit) { try { const ttKey = this.keys.buildTrafficTypeKey(split.trafficTypeName); - // @ts-expect-error - this.localStorage.setItem(ttKey, toNumber(this.localStorage.getItem(ttKey)) + 1); + this.storage.setItem(ttKey, (toNumber(this.storage.getItem(ttKey)) + 1) + ''); if (usesSegments(split)) { const segmentsCountKey = this.keys.buildSplitsWithSegmentCountKey(); - // @ts-expect-error - this.localStorage.setItem(segmentsCountKey, toNumber(this.localStorage.getItem(segmentsCountKey)) + 1); + this.storage.setItem(segmentsCountKey, (toNumber(this.storage.getItem(segmentsCountKey)) + 1) + ''); } } catch (e) { this.log.error(LOG_PREFIX + e); @@ -71,15 +65,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { */ clear() { // collect item keys - const len = this.localStorage.length; + const len = this.storage.length; const accum = []; for (let cur = 0; cur < len; cur++) { - const key = this.localStorage.key(cur); + const key = this.storage.key(cur); if (key != null && this.keys.isSplitsCacheKey(key)) accum.push(key); } // remove items accum.forEach(key => { - this.localStorage.removeItem(key); + this.storage.removeItem(key); }); this.hasSync = false; @@ -89,15 +83,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { try { const name = split.name; const splitKey = this.keys.buildSplitKey(name); - const splitFromLocalStorage = this.localStorage.getItem(splitKey); - const previousSplit = splitFromLocalStorage ? JSON.parse(splitFromLocalStorage) : null; + const splitFromStorage = this.storage.getItem(splitKey); + const previousSplit = splitFromStorage ? JSON.parse(splitFromStorage) : null; if (previousSplit) { this._decrementCounts(previousSplit); this.removeFromFlagSets(previousSplit.name, previousSplit.sets); } - this.localStorage.setItem(splitKey, JSON.stringify(split)); + this.storage.setItem(splitKey, JSON.stringify(split)); this._incrementCounts(split); this.addToFlagSets(split); @@ -114,7 +108,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { const split = this.getSplit(name); if (!split) return false; - this.localStorage.removeItem(this.keys.buildSplitKey(name)); + this.storage.removeItem(this.keys.buildSplitKey(name)); this._decrementCounts(split); this.removeFromFlagSets(split.name, split.sets); @@ -127,15 +121,15 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } getSplit(name: string): ISplit | null { - const item = this.localStorage.getItem(this.keys.buildSplitKey(name)); + const item = this.storage.getItem(this.keys.buildSplitKey(name)); return item && JSON.parse(item); } setChangeNumber(changeNumber: number): boolean { try { - this.localStorage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); + this.storage.setItem(this.keys.buildSplitsTillKey(), changeNumber + ''); // update "last updated" timestamp with current time - this.localStorage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); + this.storage.setItem(this.keys.buildLastUpdatedKey(), Date.now() + ''); this.hasSync = true; return true; } catch (e) { @@ -146,7 +140,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { getChangeNumber(): number { const n = -1; - let value: string | number | null = this.localStorage.getItem(this.keys.buildSplitsTillKey()); + let value: string | number | null = this.storage.getItem(this.keys.buildSplitsTillKey()); if (value !== null) { value = parseInt(value, 10); @@ -158,13 +152,13 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } getSplitNames(): string[] { - const len = this.localStorage.length; + const len = this.storage.length; const accum = []; let cur = 0; while (cur < len) { - const key = this.localStorage.key(cur); + const key = this.storage.key(cur); if (key != null && this.keys.isSplitKey(key)) accum.push(this.keys.extractKey(key)); @@ -175,7 +169,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } trafficTypeExists(trafficType: string): boolean { - const ttCount = toNumber(this.localStorage.getItem(this.keys.buildTrafficTypeKey(trafficType))); + const ttCount = toNumber(this.storage.getItem(this.keys.buildTrafficTypeKey(trafficType))); return isFiniteNumber(ttCount) && ttCount > 0; } @@ -183,7 +177,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { // If cache hasn't been synchronized with the cloud, assume we need them. if (!this.hasSync) return true; - const storedCount = this.localStorage.getItem(this.keys.buildSplitsWithSegmentCountKey()); + const storedCount = this.storage.getItem(this.keys.buildSplitsWithSegmentCountKey()); const splitsWithSegmentsCount = storedCount === null ? 0 : toNumber(storedCount); return isFiniteNumber(splitsWithSegmentsCount) ? @@ -194,9 +188,9 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { getNamesByFlagSets(flagSets: string[]): Set[] { return flagSets.map(flagSet => { const flagSetKey = this.keys.buildFlagSetKey(flagSet); - const flagSetFromLocalStorage = this.localStorage.getItem(flagSetKey); + const flagSetFromStorage = this.storage.getItem(flagSetKey); - return new Set(flagSetFromLocalStorage ? JSON.parse(flagSetFromLocalStorage) : []); + return new Set(flagSetFromStorage ? JSON.parse(flagSetFromStorage) : []); }); } @@ -209,12 +203,12 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { const flagSetKey = this.keys.buildFlagSetKey(featureFlagSet); - const flagSetFromLocalStorage = this.localStorage.getItem(flagSetKey); + const flagSetFromStorage = this.storage.getItem(flagSetKey); - const flagSetCache = new Set(flagSetFromLocalStorage ? JSON.parse(flagSetFromLocalStorage) : []); + const flagSetCache = new Set(flagSetFromStorage ? JSON.parse(flagSetFromStorage) : []); flagSetCache.add(featureFlag.name); - this.localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + this.storage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); }); } @@ -229,19 +223,19 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private removeNames(flagSetName: string, featureFlagName: string) { const flagSetKey = this.keys.buildFlagSetKey(flagSetName); - const flagSetFromLocalStorage = this.localStorage.getItem(flagSetKey); + const flagSetFromStorage = this.storage.getItem(flagSetKey); - if (!flagSetFromLocalStorage) return; + if (!flagSetFromStorage) return; - const flagSetCache = new Set(JSON.parse(flagSetFromLocalStorage)); + const flagSetCache = new Set(JSON.parse(flagSetFromStorage)); flagSetCache.delete(featureFlagName); if (flagSetCache.size === 0) { - this.localStorage.removeItem(flagSetKey); + this.storage.removeItem(flagSetKey); return; } - this.localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + this.storage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); } } diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index a9636f33..736d1f7b 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -4,7 +4,7 @@ import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory'; import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory } from '../types'; import { validatePrefix } from '../KeyBuilder'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS'; -import { isLocalStorageAvailable, isStorageValid } from '../../utils/env/isLocalStorageAvailable'; +import { isLocalStorageAvailable } from '../../utils/env/isLocalStorageAvailable'; import { SplitsCacheInLocal } from './SplitsCacheInLocal'; import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; @@ -18,12 +18,7 @@ import { validateCache } from './validateCache'; import { ILogger } from '../../logger/types'; import SplitIO from '../../../types/splitio'; -function validateStorage(log: ILogger, storage?: SplitIO.Storage) { - if (storage) { - if (isStorageValid(storage)) return storage; - log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); - } - +function validateStorage(log: ILogger) { if (isLocalStorageAvailable()) return localStorage; log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage'); @@ -39,7 +34,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt function InLocalStorageCSFactory(params: IStorageFactoryParams): IStorageSync { const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params; - const storage = validateStorage(log, options.storage); + const storage = validateStorage(log); if (!storage) return InMemoryStorageCSFactory(params); const matchingKey = getMatching(settings.core.key); diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 8aec3814..c5adf199 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -7,6 +7,7 @@ import type { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import type { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; import { KeyBuilderCS } from '../KeyBuilderCS'; import SplitIO from '../../../types/splitio'; +import { StorageAdapter } from '../types'; const DEFAULT_CACHE_EXPIRATION_IN_DAYS = 10; const MILLIS_IN_A_DAY = 86400000; @@ -16,7 +17,7 @@ const MILLIS_IN_A_DAY = 86400000; * * @returns `true` if cache should be cleared, `false` otherwise */ -function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: SplitIO.Storage, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { +function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { const { log } = settings; // Check expiration @@ -67,7 +68,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: Spl * * @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache) */ -export function validateCache(options: SplitIO.InLocalStorageOptions, storage: SplitIO.Storage, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { +export function validateCache(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { return Promise.resolve().then(() => { const currentTimestamp = Date.now(); diff --git a/src/storages/types.ts b/src/storages/types.ts index 0e9c3140..75b1e292 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -4,6 +4,18 @@ import { MySegmentsData } from '../sync/polling/types'; import { EventDataType, HttpErrors, HttpLatencies, ImpressionDataType, LastSync, Method, MethodExceptions, MethodLatencies, MultiMethodExceptions, MultiMethodLatencies, MultiConfigs, OperationType, StoredEventWithMetadata, StoredImpressionWithMetadata, StreamingEvent, UniqueKeysPayloadCs, UniqueKeysPayloadSs, TelemetryUsageStatsPayload, UpdatesFromSSEEnum } from '../sync/submitters/types'; import { ISettings } from '../types'; +/** + * Internal interface based on a subset of the Web Storage API interface + * (https://developer.mozilla.org/en-US/docs/Web/API/Storage) used by the SDK + */ +export interface StorageAdapter { + readonly length: number; + getItem(key: string): string | null; + key(index: number): string | null; + removeItem(key: string): void; + setItem(key: string, value: string): void; +} + /** * Interface of a pluggable storage wrapper. */ diff --git a/src/utils/env/isLocalStorageAvailable.ts b/src/utils/env/isLocalStorageAvailable.ts index 5e98b87d..e062b57d 100644 --- a/src/utils/env/isLocalStorageAvailable.ts +++ b/src/utils/env/isLocalStorageAvailable.ts @@ -1,18 +1,9 @@ +/* eslint-disable no-undef */ export function isLocalStorageAvailable(): boolean { - try { - // eslint-disable-next-line no-undef - return isStorageValid(localStorage); - } catch (e) { - return false; - } -} - -export function isStorageValid(storage: any): boolean { var mod = '__SPLITSOFTWARE__'; try { - storage.setItem(mod, mod); - storage.getItem(mod); - storage.removeItem(mod); + localStorage.setItem(mod, mod); + localStorage.removeItem(mod); return true; } catch (e) { return false; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 4c6ec911..e85ab01b 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -458,54 +458,6 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync */ declare namespace SplitIO { - interface Storage { - /** - * Returns the number of key/value pairs. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/length) - */ - readonly length: number; - /** - * Removes all key/value pairs, if there are any. - * - * Dispatches a storage event on Window objects holding an equivalent Storage object. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/clear) - */ - clear(): void; - /** - * Returns the current value associated with the given key, or null if the given key does not exist. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem) - */ - getItem(key: string): string | null; - /** - * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/key) - */ - key(index: number): string | null; - /** - * Removes the key/value pair with the given key, if a key/value pair with the given key exists. - * - * Dispatches a storage event on Window objects holding an equivalent Storage object. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/removeItem) - */ - removeItem(key: string): void; - /** - * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously. - * - * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.) - * - * Dispatches a storage event on Window objects holding an equivalent Storage object. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem) - */ - setItem(key: string, value: string): void; - [name: string]: any; - } - /** * EventEmitter interface based on a subset of the Node.js EventEmitter methods. */ @@ -1020,12 +972,6 @@ declare namespace SplitIO { * @defaultValue `false` */ clearOnInit?: boolean; - /** - * Optional storage API to persist rollout plan related data. If not provided, the SDK will use the default localStorage Web API. - * - * @defaultValue `window.localStorage` - */ - storage?: Storage; } /** * Storage for asynchronous (consumer) SDK. From 96eaea06d2f68fa59f9fc383bdb830140cd0305b Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 30 Jul 2025 16:29:51 -0300 Subject: [PATCH 10/32] Break the PR into smaller PRs --- src/storages/AbstractMySegmentsCacheSync.ts | 46 ++++++++----------- src/storages/AbstractSplitsCacheSync.ts | 5 +- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 5 +- 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/storages/AbstractMySegmentsCacheSync.ts b/src/storages/AbstractMySegmentsCacheSync.ts index 5b72aaf9..7d3dc304 100644 --- a/src/storages/AbstractMySegmentsCacheSync.ts +++ b/src/storages/AbstractMySegmentsCacheSync.ts @@ -49,10 +49,12 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync * For client-side synchronizer: it resets or updates the cache. */ resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse): boolean { + this.setChangeNumber(segmentsData.cn); + const { added, removed } = segmentsData as MySegmentsData; - let isDiff = false; if (added && removed) { + let isDiff = false; added.forEach(segment => { isDiff = this.addSegment(segment) || isDiff; @@ -61,40 +63,32 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync removed.forEach(segment => { isDiff = this.removeSegment(segment) || isDiff; }); - } else { - const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); - const storedSegmentKeys = this.getRegisteredSegments().sort(); + return isDiff; + } - // Extreme fast => everything is empty - if (!names.length && !storedSegmentKeys.length) { - isDiff = false; - } else { + const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); + const storedSegmentKeys = this.getRegisteredSegments().sort(); - let index = 0; + // Extreme fast => everything is empty + if (!names.length && !storedSegmentKeys.length) return false; - while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; + let index = 0; - // Quick path => no changes - if (index === names.length && index === storedSegmentKeys.length) { - isDiff = false; - } else { + while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; - // Slowest path => add and/or remove segments - for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { - this.removeSegment(storedSegmentKeys[removeIndex]); - } + // Quick path => no changes + if (index === names.length && index === storedSegmentKeys.length) return false; - for (let addIndex = index; addIndex < names.length; addIndex++) { - this.addSegment(names[addIndex]); - } + // Slowest path => add and/or remove segments + for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { + this.removeSegment(storedSegmentKeys[removeIndex]); + } - isDiff = true; - } - } + for (let addIndex = index; addIndex < names.length; addIndex++) { + this.addSegment(names[addIndex]); } - this.setChangeNumber(segmentsData.cn); - return isDiff; + return true; } } diff --git a/src/storages/AbstractSplitsCacheSync.ts b/src/storages/AbstractSplitsCacheSync.ts index 64194561..2a4b9b78 100644 --- a/src/storages/AbstractSplitsCacheSync.ts +++ b/src/storages/AbstractSplitsCacheSync.ts @@ -14,10 +14,9 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync { protected abstract setChangeNumber(changeNumber: number): boolean | void update(toAdd: ISplit[], toRemove: ISplit[], changeNumber: number): boolean { - let updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); - updated = toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - return updated; + const updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); + return toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; } abstract getSplit(name: string): ISplit | null diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index b0b2aba5..312787bc 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -26,10 +26,9 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { - let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); - updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - return updated; + const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; } private setChangeNumber(changeNumber: number) { From 70ea6d424d08928e81b496ce6ffed3e262b19cf6 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 30 Jul 2025 16:32:46 -0300 Subject: [PATCH 11/32] Revert "Break the PR into smaller PRs" This reverts commit 96eaea06d2f68fa59f9fc383bdb830140cd0305b. --- src/storages/AbstractMySegmentsCacheSync.ts | 46 +++++++++++-------- src/storages/AbstractSplitsCacheSync.ts | 5 +- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 5 +- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/storages/AbstractMySegmentsCacheSync.ts b/src/storages/AbstractMySegmentsCacheSync.ts index 7d3dc304..5b72aaf9 100644 --- a/src/storages/AbstractMySegmentsCacheSync.ts +++ b/src/storages/AbstractMySegmentsCacheSync.ts @@ -49,12 +49,10 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync * For client-side synchronizer: it resets or updates the cache. */ resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse): boolean { - this.setChangeNumber(segmentsData.cn); - const { added, removed } = segmentsData as MySegmentsData; + let isDiff = false; if (added && removed) { - let isDiff = false; added.forEach(segment => { isDiff = this.addSegment(segment) || isDiff; @@ -63,32 +61,40 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync removed.forEach(segment => { isDiff = this.removeSegment(segment) || isDiff; }); + } else { - return isDiff; - } + const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); + const storedSegmentKeys = this.getRegisteredSegments().sort(); - const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); - const storedSegmentKeys = this.getRegisteredSegments().sort(); + // Extreme fast => everything is empty + if (!names.length && !storedSegmentKeys.length) { + isDiff = false; + } else { - // Extreme fast => everything is empty - if (!names.length && !storedSegmentKeys.length) return false; + let index = 0; - let index = 0; + while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; - while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; + // Quick path => no changes + if (index === names.length && index === storedSegmentKeys.length) { + isDiff = false; + } else { - // Quick path => no changes - if (index === names.length && index === storedSegmentKeys.length) return false; + // Slowest path => add and/or remove segments + for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { + this.removeSegment(storedSegmentKeys[removeIndex]); + } - // Slowest path => add and/or remove segments - for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { - this.removeSegment(storedSegmentKeys[removeIndex]); - } + for (let addIndex = index; addIndex < names.length; addIndex++) { + this.addSegment(names[addIndex]); + } - for (let addIndex = index; addIndex < names.length; addIndex++) { - this.addSegment(names[addIndex]); + isDiff = true; + } + } } - return true; + this.setChangeNumber(segmentsData.cn); + return isDiff; } } diff --git a/src/storages/AbstractSplitsCacheSync.ts b/src/storages/AbstractSplitsCacheSync.ts index 2a4b9b78..64194561 100644 --- a/src/storages/AbstractSplitsCacheSync.ts +++ b/src/storages/AbstractSplitsCacheSync.ts @@ -14,9 +14,10 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync { protected abstract setChangeNumber(changeNumber: number): boolean | void update(toAdd: ISplit[], toRemove: ISplit[], changeNumber: number): boolean { + let updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); + updated = toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - const updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); - return toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; + return updated; } abstract getSplit(name: string): ISplit | null diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index 312787bc..b0b2aba5 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -26,9 +26,10 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { + let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); - return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; + return updated; } private setChangeNumber(changeNumber: number) { From 3d3578be74e04bb5bb95d630e21f4e1a3fffeb97 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 30 Jul 2025 16:37:59 -0300 Subject: [PATCH 12/32] Polishing --- src/storages/inLocalStorage/validateCache.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 5888dd6e..07d87c79 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -68,7 +68,8 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS * @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache) */ export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise { - return new Promise((resolve) => { + + return Promise.resolve().then(() => { const currentTimestamp = Date.now(); const isThereCache = splits.getChangeNumber() > -1; @@ -85,10 +86,10 @@ export function validateCache(options: SplitIO.InLocalStorageOptions, settings: settings.log.error(LOG_PREFIX + e); } - resolve(false); + return false; } // Check if ready from cache - resolve(isThereCache); + return isThereCache; }); } From e0ef9cd0ba1821317cca7c5aa7f1a2bfd6e6128f Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 30 Jul 2025 17:51:43 -0300 Subject: [PATCH 13/32] feat: add WebStorage type check and optimize storage adapter usage --- src/storages/inLocalStorage/index.ts | 8 ++++++-- src/utils/env/isLocalStorageAvailable.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index d6b0691f..c83abc59 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -4,7 +4,7 @@ import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory'; import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory, StorageAdapter } from '../types'; import { validatePrefix } from '../KeyBuilder'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS'; -import { isLocalStorageAvailable, isValidStorageWrapper } from '../../utils/env/isLocalStorageAvailable'; +import { isLocalStorageAvailable, isValidStorageWrapper, isWebStorage } from '../../utils/env/isLocalStorageAvailable'; import { SplitsCacheInLocal } from './SplitsCacheInLocal'; import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal'; import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal'; @@ -66,7 +66,11 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined { if (wrapper) { - if (isValidStorageWrapper(wrapper)) return storageAdapter(log, prefix, wrapper); + if (isValidStorageWrapper(wrapper)) { + return isWebStorage(wrapper) ? + wrapper as StorageAdapter: // localStorage and sessionStorage don't need adapter + storageAdapter(log, prefix, wrapper); + } log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); } diff --git a/src/utils/env/isLocalStorageAvailable.ts b/src/utils/env/isLocalStorageAvailable.ts index d1bdc8e2..10a7dba8 100644 --- a/src/utils/env/isLocalStorageAvailable.ts +++ b/src/utils/env/isLocalStorageAvailable.ts @@ -18,3 +18,15 @@ export function isValidStorageWrapper(wrapper: any): boolean { return false; } } + +export function isWebStorage(wrapper: any): boolean { + if (typeof wrapper.length === 'number') { + try { + wrapper.key(0); + return true; + } catch (e) { + return false; + } + } + return false; +} From 60aa25d72ceb8a537dcc586ee5d83e9707f0c0c7 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 31 Jul 2025 12:01:36 -0300 Subject: [PATCH 14/32] refactor: move storageAdapter to dedicated file and update imports --- .../inLocalStorage/__tests__/wrapper.mock.ts | 2 +- src/storages/inLocalStorage/index.ts | 47 +---------------- src/storages/inLocalStorage/storageAdapter.ts | 50 +++++++++++++++++++ 3 files changed, 52 insertions(+), 47 deletions(-) create mode 100644 src/storages/inLocalStorage/storageAdapter.ts diff --git a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts index dbfcbf6a..9cbcbf02 100644 --- a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts +++ b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts @@ -1,4 +1,4 @@ -import { storageAdapter } from '..'; +import { storageAdapter } from '../storageAdapter'; import SplitIO from '../../../../types/splitio'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index d6b0691f..68f5fe79 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -17,52 +17,7 @@ import { getMatching } from '../../utils/key'; import { validateCache } from './validateCache'; import { ILogger } from '../../logger/types'; import SplitIO from '../../../types/splitio'; - -function isTillKey(key: string) { - return key.endsWith('.till'); -} - -export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): StorageAdapter { - let cache: Record = {}; - - let connectPromise: Promise | undefined; - let disconnectPromise = Promise.resolve(); - - return { - load() { - return connectPromise || (connectPromise = Promise.resolve(wrapper.getItem(prefix)).then((storedCache) => { - cache = JSON.parse(storedCache || '{}'); - }).catch((e) => { - log.error(LOG_PREFIX + 'Rejected promise calling storage getItem, with error: ' + e); - })); - }, - save() { - return disconnectPromise = disconnectPromise.then(() => { - return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))).catch((e) => { - log.error(LOG_PREFIX + 'Rejected promise calling storage setItem, with error: ' + e); - }); - }); - }, - - get length() { - return Object.keys(cache).length; - }, - getItem(key: string) { - return cache[key] || null; - }, - key(index: number) { - return Object.keys(cache)[index] || null; - }, - removeItem(key: string) { - delete cache[key]; - if (isTillKey(key)) this.save!(); - }, - setItem(key: string, value: string) { - cache[key] = value; - if (isTillKey(key)) this.save!(); - } - }; -} +import { storageAdapter } from './storageAdapter'; function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined { if (wrapper) { diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts new file mode 100644 index 00000000..f0fbd21a --- /dev/null +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -0,0 +1,50 @@ +import { ILogger } from '../../logger/types'; +import SplitIO from '../../../types/splitio'; +import { LOG_PREFIX } from './constants'; +import { StorageAdapter } from '../types'; + +function isTillKey(key: string) { + return key.endsWith('.till'); +} + +export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): StorageAdapter { + let cache: Record = {}; + + let connectPromise: Promise | undefined; + let disconnectPromise = Promise.resolve(); + + return { + load() { + return connectPromise || (connectPromise = Promise.resolve(wrapper.getItem(prefix)).then((storedCache) => { + cache = JSON.parse(storedCache || '{}'); + }).catch((e) => { + log.error(LOG_PREFIX + 'Rejected promise calling storage getItem, with error: ' + e); + })); + }, + save() { + return disconnectPromise = disconnectPromise.then(() => { + return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))).catch((e) => { + log.error(LOG_PREFIX + 'Rejected promise calling storage setItem, with error: ' + e); + }); + }); + }, + + get length() { + return Object.keys(cache).length; + }, + getItem(key: string) { + return cache[key] || null; + }, + key(index: number) { + return Object.keys(cache)[index] || null; + }, + removeItem(key: string) { + delete cache[key]; + if (isTillKey(key)) this.save!(); + }, + setItem(key: string, value: string) { + cache[key] = value; + if (isTillKey(key)) this.save!(); + } + }; +} From 1cae832d95dc6c15ae1798a6501f23e8fe1b7d61 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 31 Jul 2025 12:11:50 -0300 Subject: [PATCH 15/32] unit tests --- .../inLocalStorage/__tests__/index.spec.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/storages/inLocalStorage/__tests__/index.spec.ts b/src/storages/inLocalStorage/__tests__/index.spec.ts index 545c532f..cc45c99c 100644 --- a/src/storages/inLocalStorage/__tests__/index.spec.ts +++ b/src/storages/inLocalStorage/__tests__/index.spec.ts @@ -10,6 +10,10 @@ jest.mock('../../inMemory/InMemoryStorageCS', () => { import { IStorageFactoryParams } from '../../types'; import { assertStorageInterface } from '../../__tests__/testUtils'; import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks'; +import { createMemoryStorage } from './wrapper.mock'; +import * as storageAdapter from '../storageAdapter'; + +const storageAdapterSpy = jest.spyOn(storageAdapter, 'storageAdapter'); // Test target import { InLocalStorage } from '../index'; @@ -40,7 +44,7 @@ describe('IN LOCAL STORAGE', () => { expect(storage).toBe(fakeInMemoryStorage); // Provided storage is valid - storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } }); + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: createMemoryStorage() }); storage = storageFactory(internalSdkParams); expect(storage).not.toBe(fakeInMemoryStorage); @@ -55,7 +59,31 @@ describe('IN LOCAL STORAGE', () => { assertStorageInterface(storage); // the instance must implement the storage interface expect(fakeInMemoryStorageFactory).not.toBeCalled(); // doesn't call InMemoryStorage factory + }); + + test('calls its own storage factory if the provided storage wrapper is valid', () => { + storageAdapterSpy.mockClear(); + + // Web Storages should not use the storageAdapter + let storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: localStorage }); + let storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).not.toBeCalled(); + + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: sessionStorage }); + storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).not.toBeCalled(); + + // Non Web Storages should use the storageAdapter + storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: createMemoryStorage() }); + storage = storageFactory(internalSdkParams); + assertStorageInterface(storage); + expect(fakeInMemoryStorageFactory).not.toBeCalled(); + expect(storageAdapterSpy).toBeCalled(); }); }); From f1f5bf5b5ab69074bc27f2cc910ec360123994a4 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 31 Jul 2025 12:19:21 -0300 Subject: [PATCH 16/32] rc --- CHANGES.txt | 3 +++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 4a18088b..cacc2904 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +2.5.0 (August XX, 2025) + - Added `storage.wrapper` configuration option to allow the SDK to use a custom storage wrapper for the storage type `LOCALSTORAGE`. Default value is `window.localStorage`. + 2.4.1 (June 3, 2025) - Bugfix - Improved the Proxy fallback to flag spec version 1.2 to handle cases where the Proxy does not return an end-of-stream marker in 400 status code responses. diff --git a/package-lock.json b/package-lock.json index aa7cf6d8..661ab4c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.4.2-rc.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.4.2-rc.2", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 27b15da2..d46d7735 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.4.2-rc.2", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From cb22017d84d46b8817247c20bc88cde4e50991dc Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 1 Aug 2025 18:56:02 -0300 Subject: [PATCH 17/32] Add unit test --- .../inLocalStorage/MySegmentsCacheInLocal.ts | 2 +- .../__tests__/storageAdapter.spec.ts | 58 +++++++++++++++++++ src/storages/inLocalStorage/storageAdapter.ts | 28 +++++---- 3 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 5fc176cb..fd038a07 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -51,7 +51,7 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { getRegisteredSegments(): string[] { const registeredSegments: string[] = []; - for (let i = 0; i < this.storage.length; i++) { + for (let i = 0, len = this.storage.length; i < len; i++) { const segmentName = this.keys.extractSegmentName(this.storage.key(i)!); if (segmentName) registeredSegments.push(segmentName); } diff --git a/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts new file mode 100644 index 00000000..5b1924c4 --- /dev/null +++ b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts @@ -0,0 +1,58 @@ +import { storageAdapter } from '../storageAdapter'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + + +const syncWrapper = { + getItem: jest.fn(() => JSON.stringify({ key1: 'value1' })), + setItem: jest.fn(), + removeItem: jest.fn(), +}; + +const asyncWrapper = { + getItem: jest.fn(() => Promise.resolve(JSON.stringify({ key1: 'value1' }))), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), +}; + +test.each([ + [syncWrapper], + [asyncWrapper], +])('storageAdapter', async (wrapper) => { + + const storage = storageAdapter(loggerMock, 'prefix', wrapper); + + await storage.load!(); + + expect(wrapper.getItem).toHaveBeenCalledWith('prefix'); + + expect(storage.length).toBe(1); + expect(storage.key(0)).toBe('key1'); + expect(storage.getItem('key1')).toBe('value1'); + + storage.setItem('key2', 'value2'); + expect(storage.getItem('key2')).toBe('value2'); + expect(storage.length).toBe(2); + + storage.removeItem('key1'); + expect(storage.getItem('key1')).toBe(null); + expect(storage.length).toBe(1); + + await storage.save!(); + expect(wrapper.setItem).not.toHaveBeenCalled(); + + storage.setItem('.till', '1'); + expect(storage.length).toBe(2); + expect(storage.key(0)).toBe('key2'); + expect(storage.key(1)).toBe('.till'); + + await storage.save!(); + expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2', '.till': '1' })); + + storage.removeItem('.till'); + + await storage.save!(); + expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2' })); + + await storage.save!(); + expect(wrapper.setItem).toHaveBeenCalledTimes(2); +}); diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index f0fbd21a..970d1812 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -10,23 +10,29 @@ function isTillKey(key: string) { export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): StorageAdapter { let cache: Record = {}; - let connectPromise: Promise | undefined; - let disconnectPromise = Promise.resolve(); + let loadPromise: Promise | undefined; + let savePromise = Promise.resolve(); + + function _save() { + return savePromise = savePromise.then(() => { + return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))); + }).catch((e) => { + log.error(LOG_PREFIX + 'Rejected promise calling wrapper `setItem` method, with error: ' + e); + }); + } return { load() { - return connectPromise || (connectPromise = Promise.resolve(wrapper.getItem(prefix)).then((storedCache) => { + return loadPromise || (loadPromise = Promise.resolve().then(() => { + return wrapper.getItem(prefix); + }).then((storedCache) => { cache = JSON.parse(storedCache || '{}'); }).catch((e) => { - log.error(LOG_PREFIX + 'Rejected promise calling storage getItem, with error: ' + e); + log.error(LOG_PREFIX + 'Rejected promise calling wrapper `getItem` method, with error: ' + e); })); }, save() { - return disconnectPromise = disconnectPromise.then(() => { - return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))).catch((e) => { - log.error(LOG_PREFIX + 'Rejected promise calling storage setItem, with error: ' + e); - }); - }); + return savePromise; }, get length() { @@ -40,11 +46,11 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St }, removeItem(key: string) { delete cache[key]; - if (isTillKey(key)) this.save!(); + if (isTillKey(key)) _save(); }, setItem(key: string, value: string) { cache[key] = value; - if (isTillKey(key)) this.save!(); + if (isTillKey(key)) _save(); } }; } From 6bb29305af36d5c73e639cfae60703b5977e3c0d Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 1 Aug 2025 19:13:42 -0300 Subject: [PATCH 18/32] optimize StorageAdapter's length and key methods by using arrays instead of object cache --- src/storages/inLocalStorage/storageAdapter.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index 970d1812..9f10cf3f 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -8,13 +8,18 @@ function isTillKey(key: string) { } export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): StorageAdapter { - let cache: Record = {}; + let keys: string[] = []; + let values: string[] = []; let loadPromise: Promise | undefined; let savePromise = Promise.resolve(); function _save() { return savePromise = savePromise.then(() => { + const cache = keys.reduce((acc, key, index) => { + acc[key] = values[index]; + return acc; + }, {} as Record); return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))); }).catch((e) => { log.error(LOG_PREFIX + 'Rejected promise calling wrapper `setItem` method, with error: ' + e); @@ -26,7 +31,9 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St return loadPromise || (loadPromise = Promise.resolve().then(() => { return wrapper.getItem(prefix); }).then((storedCache) => { - cache = JSON.parse(storedCache || '{}'); + const cache = JSON.parse(storedCache || '{}'); + keys = Object.keys(cache); + values = keys.map(key => cache[key]); }).catch((e) => { log.error(LOG_PREFIX + 'Rejected promise calling wrapper `getItem` method, with error: ' + e); })); @@ -36,20 +43,29 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St }, get length() { - return Object.keys(cache).length; + return keys.length; }, getItem(key: string) { - return cache[key] || null; + const index = keys.indexOf(key); + if (index === -1) return null; + return values[index]; }, key(index: number) { - return Object.keys(cache)[index] || null; + return keys[index] || null; }, removeItem(key: string) { - delete cache[key]; + const index = keys.indexOf(key); + if (index === -1) return; + keys.splice(index, 1); + values.splice(index, 1); + if (isTillKey(key)) _save(); }, setItem(key: string, value: string) { - cache[key] = value; + let index = keys.indexOf(key); + if (index === -1) index = keys.push(key) - 1; + values[index] = value; + if (isTillKey(key)) _save(); } }; From a7d3246b73811b6e214c639db7397a3a96cc3d49 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Sat, 2 Aug 2025 10:43:35 -0300 Subject: [PATCH 19/32] refactor: rename save method to whenSaved --- .../__tests__/storageAdapter.spec.ts | 19 +++++++++++++------ src/storages/inLocalStorage/index.ts | 2 +- src/storages/inLocalStorage/storageAdapter.ts | 4 ++-- src/storages/types.ts | 4 ++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts index 5b1924c4..0112e592 100644 --- a/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts +++ b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts @@ -21,38 +21,45 @@ test.each([ const storage = storageAdapter(loggerMock, 'prefix', wrapper); - await storage.load!(); + expect(storage.length).toBe(0); - expect(wrapper.getItem).toHaveBeenCalledWith('prefix'); + // Load cache from storage wrapper + await storage.load(); + expect(wrapper.getItem).toHaveBeenCalledWith('prefix'); expect(storage.length).toBe(1); expect(storage.key(0)).toBe('key1'); expect(storage.getItem('key1')).toBe('value1'); + // Set item storage.setItem('key2', 'value2'); expect(storage.getItem('key2')).toBe('value2'); expect(storage.length).toBe(2); + // Remove item storage.removeItem('key1'); expect(storage.getItem('key1')).toBe(null); expect(storage.length).toBe(1); - await storage.save!(); + // Until a till key is set/removed, changes should not be saved/persisted + await storage.whenSaved(); expect(wrapper.setItem).not.toHaveBeenCalled(); + // When setting a till key, changes should be saved/persisted immediately storage.setItem('.till', '1'); expect(storage.length).toBe(2); expect(storage.key(0)).toBe('key2'); expect(storage.key(1)).toBe('.till'); - await storage.save!(); + await storage.whenSaved(); expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2', '.till': '1' })); + // When removing a till key, changes should be saved/persisted immediately storage.removeItem('.till'); - await storage.save!(); + await storage.whenSaved(); expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2' })); - await storage.save!(); + await storage.whenSaved(); expect(wrapper.setItem).toHaveBeenCalledTimes(2); }); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 68f5fe79..9cf1ae0c 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -68,7 +68,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt }, destroy() { - return storage.save && storage.save(); + return storage.whenSaved && storage.whenSaved(); }, // When using shared instantiation with MEMORY we reuse everything but segments (they are customer per key). diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index 9f10cf3f..9eec6ec1 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -7,7 +7,7 @@ function isTillKey(key: string) { return key.endsWith('.till'); } -export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): StorageAdapter { +export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): Required { let keys: string[] = []; let values: string[] = []; @@ -38,7 +38,7 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St log.error(LOG_PREFIX + 'Rejected promise calling wrapper `getItem` method, with error: ' + e); })); }, - save() { + whenSaved() { return savePromise; }, diff --git a/src/storages/types.ts b/src/storages/types.ts index d91a0cab..c59950d7 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -11,11 +11,11 @@ import { ISettings } from '../types'; export interface StorageAdapter { // Methods to support async storages load?: () => Promise; - save?: () => Promise; + whenSaved?: () => Promise; // Methods based on https://developer.mozilla.org/en-US/docs/Web/API/Storage readonly length: number; - getItem(key: string): string | null; key(index: number): string | null; + getItem(key: string): string | null; removeItem(key: string): void; setItem(key: string, value: string): void; } From c594a6a223ab0c9a27bf8ee96028972f7ad2a04d Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 4 Aug 2025 15:47:27 -0300 Subject: [PATCH 20/32] optimize StorageAdapter using an internal keys array and cache object --- src/storages/inLocalStorage/storageAdapter.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index 9eec6ec1..5e068590 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -9,17 +9,13 @@ function isTillKey(key: string) { export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): Required { let keys: string[] = []; - let values: string[] = []; + let cache: Record = {}; let loadPromise: Promise | undefined; let savePromise = Promise.resolve(); function _save() { return savePromise = savePromise.then(() => { - const cache = keys.reduce((acc, key, index) => { - acc[key] = values[index]; - return acc; - }, {} as Record); return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))); }).catch((e) => { log.error(LOG_PREFIX + 'Rejected promise calling wrapper `setItem` method, with error: ' + e); @@ -31,9 +27,8 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St return loadPromise || (loadPromise = Promise.resolve().then(() => { return wrapper.getItem(prefix); }).then((storedCache) => { - const cache = JSON.parse(storedCache || '{}'); + cache = JSON.parse(storedCache || '{}'); keys = Object.keys(cache); - values = keys.map(key => cache[key]); }).catch((e) => { log.error(LOG_PREFIX + 'Rejected promise calling wrapper `getItem` method, with error: ' + e); })); @@ -46,9 +41,7 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St return keys.length; }, getItem(key: string) { - const index = keys.indexOf(key); - if (index === -1) return null; - return values[index]; + return cache[key] || null; }, key(index: number) { return keys[index] || null; @@ -57,14 +50,13 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St const index = keys.indexOf(key); if (index === -1) return; keys.splice(index, 1); - values.splice(index, 1); + delete cache[key]; if (isTillKey(key)) _save(); }, setItem(key: string, value: string) { - let index = keys.indexOf(key); - if (index === -1) index = keys.push(key) - 1; - values[index] = value; + if (keys.indexOf(key) === -1) keys.push(key); + cache[key] = value; if (isTillKey(key)) _save(); } From a1c091bef65b8b12caeec047126ce46aa387a4b1 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 4 Aug 2025 15:59:10 -0300 Subject: [PATCH 21/32] refactor: split StorageWrapper into sync and async interfaces for better type safety --- .../inLocalStorage/__tests__/wrapper.mock.ts | 2 +- src/storages/inLocalStorage/index.ts | 2 +- src/storages/inLocalStorage/storageAdapter.ts | 2 +- types/splitio.d.ts | 33 ++++++++++++++----- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts index 9cbcbf02..897c13a0 100644 --- a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts +++ b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts @@ -4,7 +4,7 @@ import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; export const PREFIX = 'SPLITIO'; -export function createMemoryStorage(): SplitIO.StorageWrapper { +export function createMemoryStorage(): SplitIO.AsyncStorageWrapper { let cache: Record = {}; return { getItem(key: string) { diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 9cf1ae0c..ed9bb1a2 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -19,7 +19,7 @@ import { ILogger } from '../../logger/types'; import SplitIO from '../../../types/splitio'; import { storageAdapter } from './storageAdapter'; -function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined { +function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.SyncStorageWrapper | SplitIO.AsyncStorageWrapper): StorageAdapter | undefined { if (wrapper) { if (isValidStorageWrapper(wrapper)) return storageAdapter(log, prefix, wrapper); log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index 5e068590..7c508de1 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -7,7 +7,7 @@ function isTillKey(key: string) { return key.endsWith('.till'); } -export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): Required { +export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.SyncStorageWrapper | SplitIO.AsyncStorageWrapper): Required { let keys: string[] = []; let cache: Record = {}; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index ed832258..93635db4 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -458,19 +458,34 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync */ declare namespace SplitIO { - interface StorageWrapper { + interface SyncStorageWrapper { /** - * Returns a promise that resolves to the current value associated with the given key, or null if the given key does not exist. + * Returns the value associated with the given key, or null if the key does not exist. */ - getItem(key: string): Promise | string | null; + getItem(key: string): string | null; /** - * Returns a promise that resolves when the value of the pair identified by key is set to value, creating a new key/value pair if none existed for key previously. + * Sets the value for the given key, creating a new key/value pair if key does not exist. */ - setItem(key: string, value: string): Promise | void; + setItem(key: string, value: string): void; /** - * Returns a promise that resolves when the key/value pair with the given key is removed, if a key/value pair with the given key exists. + * Removes the key/value pair for the given key, if the key exists. */ - removeItem(key: string): Promise | void; + removeItem(key: string): void; + } + + interface AsyncStorageWrapper { + /** + * Returns a promise that resolves to the value associated with the given key, or null if the key does not exist. + */ + getItem(key: string): Promise; + /** + * Returns a promise that resolves when the value of the pair identified by key is set to value, creating a new key/value pair if key does not exist. + */ + setItem(key: string, value: string): Promise; + /** + * Returns a promise that resolves when the key/value pair for the given key is removed, if the key exists. + */ + removeItem(key: string): Promise; } /** @@ -992,7 +1007,7 @@ declare namespace SplitIO { * * @defaultValue `window.localStorage` */ - wrapper?: StorageWrapper; + wrapper?: SyncStorageWrapper | AsyncStorageWrapper; } /** * Storage for asynchronous (consumer) SDK. @@ -1338,7 +1353,7 @@ declare namespace SplitIO { * * @defaultValue `window.localStorage` */ - wrapper?: StorageWrapper; + wrapper?: SyncStorageWrapper | AsyncStorageWrapper; }; } /** From 1c09903f8acd6a86bbe8d2d1a62c08910303aac9 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 11 Aug 2025 20:32:25 -0300 Subject: [PATCH 22/32] Revert commit 70ea6d424d08928e81b496ce6ffed3e262b19cf6. --- src/storages/AbstractMySegmentsCacheSync.ts | 46 ++++++++----------- src/storages/AbstractSplitsCacheSync.ts | 5 +- .../inLocalStorage/RBSegmentsCacheInLocal.ts | 5 +- 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/storages/AbstractMySegmentsCacheSync.ts b/src/storages/AbstractMySegmentsCacheSync.ts index 5b72aaf9..7d3dc304 100644 --- a/src/storages/AbstractMySegmentsCacheSync.ts +++ b/src/storages/AbstractMySegmentsCacheSync.ts @@ -49,10 +49,12 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync * For client-side synchronizer: it resets or updates the cache. */ resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse): boolean { + this.setChangeNumber(segmentsData.cn); + const { added, removed } = segmentsData as MySegmentsData; - let isDiff = false; if (added && removed) { + let isDiff = false; added.forEach(segment => { isDiff = this.addSegment(segment) || isDiff; @@ -61,40 +63,32 @@ export abstract class AbstractMySegmentsCacheSync implements ISegmentsCacheSync removed.forEach(segment => { isDiff = this.removeSegment(segment) || isDiff; }); - } else { - const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); - const storedSegmentKeys = this.getRegisteredSegments().sort(); + return isDiff; + } - // Extreme fast => everything is empty - if (!names.length && !storedSegmentKeys.length) { - isDiff = false; - } else { + const names = ((segmentsData as IMySegmentsResponse).k || []).map(s => s.n).sort(); + const storedSegmentKeys = this.getRegisteredSegments().sort(); - let index = 0; + // Extreme fast => everything is empty + if (!names.length && !storedSegmentKeys.length) return false; - while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; + let index = 0; - // Quick path => no changes - if (index === names.length && index === storedSegmentKeys.length) { - isDiff = false; - } else { + while (index < names.length && index < storedSegmentKeys.length && names[index] === storedSegmentKeys[index]) index++; - // Slowest path => add and/or remove segments - for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { - this.removeSegment(storedSegmentKeys[removeIndex]); - } + // Quick path => no changes + if (index === names.length && index === storedSegmentKeys.length) return false; - for (let addIndex = index; addIndex < names.length; addIndex++) { - this.addSegment(names[addIndex]); - } + // Slowest path => add and/or remove segments + for (let removeIndex = index; removeIndex < storedSegmentKeys.length; removeIndex++) { + this.removeSegment(storedSegmentKeys[removeIndex]); + } - isDiff = true; - } - } + for (let addIndex = index; addIndex < names.length; addIndex++) { + this.addSegment(names[addIndex]); } - this.setChangeNumber(segmentsData.cn); - return isDiff; + return true; } } diff --git a/src/storages/AbstractSplitsCacheSync.ts b/src/storages/AbstractSplitsCacheSync.ts index 64194561..2a4b9b78 100644 --- a/src/storages/AbstractSplitsCacheSync.ts +++ b/src/storages/AbstractSplitsCacheSync.ts @@ -14,10 +14,9 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync { protected abstract setChangeNumber(changeNumber: number): boolean | void update(toAdd: ISplit[], toRemove: ISplit[], changeNumber: number): boolean { - let updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); - updated = toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - return updated; + const updated = toAdd.map(addedFF => this.addSplit(addedFF)).some(result => result); + return toRemove.map(removedFF => this.removeSplit(removedFF.name)).some(result => result) || updated; } abstract getSplit(name: string): ISplit | null diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index b0b2aba5..312787bc 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -26,10 +26,9 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { } update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { - let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); - updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; this.setChangeNumber(changeNumber); - return updated; + const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; } private setChangeNumber(changeNumber: number) { From afdf656e30fd1af162450b9ac46031a90f0935f9 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 11 Aug 2025 20:34:45 -0300 Subject: [PATCH 23/32] Add and use StorageAdapter::save method --- .../inLocalStorage/MySegmentsCacheInLocal.ts | 8 ++++++ .../inLocalStorage/RBSegmentsCacheInLocal.ts | 6 ++-- .../inLocalStorage/SplitsCacheInLocal.ts | 6 ++++ .../__tests__/storageAdapter.spec.ts | 13 ++++----- src/storages/inLocalStorage/storageAdapter.ts | 28 +++++++++---------- src/storages/inLocalStorage/validateCache.ts | 3 ++ src/storages/types.ts | 1 + 7 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index fd038a07..361ca61f 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -4,6 +4,8 @@ import { AbstractMySegmentsCacheSync } from '../AbstractMySegmentsCacheSync'; import type { MySegmentsKeyBuilder } from '../KeyBuilderCS'; import { LOG_PREFIX, DEFINED } from './constants'; import { StorageAdapter } from '../types'; +import { IMySegmentsResponse } from '../../dtos/types'; +import { MySegmentsData } from '../../sync/polling/types'; export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { @@ -19,6 +21,12 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { // There is not need to flush segments cache like splits cache, since resetSegments receives the up-to-date list of active segments } + resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse): boolean { + const result = super.resetSegments(segmentsData); + if (this.storage.save) this.storage.save(); + return result; + } + protected addSegment(name: string): boolean { const segmentKey = this.keys.buildSegmentNameKey(name); diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index 312787bc..c22d790e 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -27,8 +27,10 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { this.setChangeNumber(changeNumber); - const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); - return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; + let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; + if (this.storage.save) this.storage.save(); + return updated; } private setChangeNumber(changeNumber: number) { diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index 3aa08452..25fa21a8 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -79,6 +79,12 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { this.hasSync = false; } + update(toAdd: ISplit[], toRemove: ISplit[], changeNumber: number): boolean { + const result = super.update(toAdd, toRemove, changeNumber); + if (this.storage.save) this.storage.save(); + return result; + } + addSplit(split: ISplit) { try { const name = split.name; diff --git a/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts index 0112e592..84be924a 100644 --- a/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts +++ b/src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts @@ -41,25 +41,22 @@ test.each([ expect(storage.getItem('key1')).toBe(null); expect(storage.length).toBe(1); - // Until a till key is set/removed, changes should not be saved/persisted + // Until `save` is called, changes should not be saved/persisted await storage.whenSaved(); expect(wrapper.setItem).not.toHaveBeenCalled(); - // When setting a till key, changes should be saved/persisted immediately storage.setItem('.till', '1'); expect(storage.length).toBe(2); expect(storage.key(0)).toBe('key2'); expect(storage.key(1)).toBe('.till'); + // When `save` is called, changes should be saved/persisted immediately + storage.save(); await storage.whenSaved(); expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2', '.till': '1' })); - // When removing a till key, changes should be saved/persisted immediately - storage.removeItem('.till'); + expect(wrapper.setItem).toHaveBeenCalledTimes(1); await storage.whenSaved(); - expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2' })); - - await storage.whenSaved(); - expect(wrapper.setItem).toHaveBeenCalledTimes(2); + expect(wrapper.setItem).toHaveBeenCalledTimes(1); }); diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index 7c508de1..af92df22 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -3,9 +3,6 @@ import SplitIO from '../../../types/splitio'; import { LOG_PREFIX } from './constants'; import { StorageAdapter } from '../types'; -function isTillKey(key: string) { - return key.endsWith('.till'); -} export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.SyncStorageWrapper | SplitIO.AsyncStorageWrapper): Required { let keys: string[] = []; @@ -14,14 +11,6 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.Sy let loadPromise: Promise | undefined; let savePromise = Promise.resolve(); - function _save() { - return savePromise = savePromise.then(() => { - return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))); - }).catch((e) => { - log.error(LOG_PREFIX + 'Rejected promise calling wrapper `setItem` method, with error: ' + e); - }); - } - return { load() { return loadPromise || (loadPromise = Promise.resolve().then(() => { @@ -33,6 +22,15 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.Sy log.error(LOG_PREFIX + 'Rejected promise calling wrapper `getItem` method, with error: ' + e); })); }, + + save() { + return savePromise = savePromise.then(() => { + return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))); + }).catch((e) => { + log.error(LOG_PREFIX + 'Rejected promise calling wrapper `setItem` method, with error: ' + e); + }); + }, + whenSaved() { return savePromise; }, @@ -40,25 +38,25 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.Sy get length() { return keys.length; }, + getItem(key: string) { return cache[key] || null; }, + key(index: number) { return keys[index] || null; }, + removeItem(key: string) { const index = keys.indexOf(key); if (index === -1) return; keys.splice(index, 1); delete cache[key]; - - if (isTillKey(key)) _save(); }, + setItem(key: string, value: string) { if (keys.indexOf(key) === -1) keys.push(key); cache[key] = value; - - if (isTillKey(key)) _save(); } }; } diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 9973a76e..38df9899 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -87,6 +87,9 @@ export function validateCache(options: SplitIO.InLocalStorageOptions, storage: S settings.log.error(LOG_PREFIX + e); } + // Persist clear + if (storage.save) storage.save(); + return false; } diff --git a/src/storages/types.ts b/src/storages/types.ts index c59950d7..54533169 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -11,6 +11,7 @@ import { ISettings } from '../types'; export interface StorageAdapter { // Methods to support async storages load?: () => Promise; + save?: () => Promise; whenSaved?: () => Promise; // Methods based on https://developer.mozilla.org/en-US/docs/Web/API/Storage readonly length: number; From 1cc876b4091971f7e5c8be3665d67f29b35e6c1a Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 12 Aug 2025 12:31:06 -0300 Subject: [PATCH 24/32] refactor: call storage::save in updaters and remove from cache classes --- src/storages/inLocalStorage/MySegmentsCacheInLocal.ts | 8 -------- src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts | 6 ++---- src/storages/inLocalStorage/SplitsCacheInLocal.ts | 6 ------ src/storages/inLocalStorage/index.ts | 4 ++++ src/storages/types.ts | 1 + src/sync/polling/updaters/mySegmentsUpdater.ts | 2 ++ src/sync/polling/updaters/splitChangesUpdater.ts | 4 +++- 7 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 361ca61f..fd038a07 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -4,8 +4,6 @@ import { AbstractMySegmentsCacheSync } from '../AbstractMySegmentsCacheSync'; import type { MySegmentsKeyBuilder } from '../KeyBuilderCS'; import { LOG_PREFIX, DEFINED } from './constants'; import { StorageAdapter } from '../types'; -import { IMySegmentsResponse } from '../../dtos/types'; -import { MySegmentsData } from '../../sync/polling/types'; export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { @@ -21,12 +19,6 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { // There is not need to flush segments cache like splits cache, since resetSegments receives the up-to-date list of active segments } - resetSegments(segmentsData: MySegmentsData | IMySegmentsResponse): boolean { - const result = super.resetSegments(segmentsData); - if (this.storage.save) this.storage.save(); - return result; - } - protected addSegment(name: string): boolean { const segmentKey = this.keys.buildSegmentNameKey(name); diff --git a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts index c22d790e..312787bc 100644 --- a/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/RBSegmentsCacheInLocal.ts @@ -27,10 +27,8 @@ export class RBSegmentsCacheInLocal implements IRBSegmentsCacheSync { update(toAdd: IRBSegment[], toRemove: IRBSegment[], changeNumber: number): boolean { this.setChangeNumber(changeNumber); - let updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); - updated = toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; - if (this.storage.save) this.storage.save(); - return updated; + const updated = toAdd.map(toAdd => this.add(toAdd)).some(result => result); + return toRemove.map(toRemove => this.remove(toRemove.name)).some(result => result) || updated; } private setChangeNumber(changeNumber: number) { diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index 25fa21a8..3aa08452 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -79,12 +79,6 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { this.hasSync = false; } - update(toAdd: ISplit[], toRemove: ISplit[], changeNumber: number): boolean { - const result = super.update(toAdd, toRemove, changeNumber); - if (this.storage.save) this.storage.save(); - return result; - } - addSplit(split: ISplit) { try { const name = split.name; diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index ed9bb1a2..ae77bb41 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -67,6 +67,10 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt return validateCachePromise || (validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments)); }, + save() { + return storage.save && storage.save(); + }, + destroy() { return storage.whenSaved && storage.whenSaved(); }, diff --git a/src/storages/types.ts b/src/storages/types.ts index 54533169..307fee3e 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -483,6 +483,7 @@ export interface IStorageBase< uniqueKeys: TUniqueKeysCache, destroy(): void | Promise, shared?: (matchingKey: string, onReadyCb: (error?: any) => void) => this + save?: () => void | Promise, } export interface IStorageSync extends IStorageBase< diff --git a/src/sync/polling/updaters/mySegmentsUpdater.ts b/src/sync/polling/updaters/mySegmentsUpdater.ts index 5de512fa..4b6038c5 100644 --- a/src/sync/polling/updaters/mySegmentsUpdater.ts +++ b/src/sync/polling/updaters/mySegmentsUpdater.ts @@ -51,6 +51,8 @@ export function mySegmentsUpdaterFactory( shouldNotifyUpdate = largeSegments!.resetSegments((segmentsData as IMembershipsResponse).ls || {}) || shouldNotifyUpdate; } + if (storage.save) storage.save(); + // Notify update if required if (usesSegmentsSync(storage) && (shouldNotifyUpdate || readyOnAlreadyExistentState)) { readyOnAlreadyExistentState = false; diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 0331bc43..6c6371e3 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -117,7 +117,7 @@ export function computeMutation(rules: Array, export function splitChangesUpdaterFactory( log: ILogger, splitChangesFetcher: ISplitChangesFetcher, - storage: Pick, + storage: Pick, splitFiltersValidation: ISplitFiltersValidation, splitsEventEmitter?: ISplitsEventEmitter, requestTimeoutBeforeReady: number = 0, @@ -185,6 +185,8 @@ export function splitChangesUpdaterFactory( // @TODO if at least 1 segment fetch fails due to 404 and other segments are updated in the storage, SDK_UPDATE is not emitted segments.registerSegments(setToArray(usedSegments)) ]).then(([ffChanged, rbsChanged]) => { + if (storage.save) storage.save(); + if (splitsEventEmitter) { // To emit SDK_SPLITS_ARRIVED for server-side SDK, we must check that all registered segments have been fetched return Promise.resolve(!splitsEventEmitter.splitsArrived || ((ffChanged || rbsChanged) && (isClientSide || checkAllSegmentsExist(segments)))) From 07dedd70e0564ca7581d1ac5046264ce3a15bc93 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 12 Aug 2025 14:54:17 -0300 Subject: [PATCH 25/32] Polishing --- src/storages/inLocalStorage/MySegmentsCacheInLocal.ts | 2 +- .../inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts | 4 ++-- src/storages/types.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts index 5fc176cb..fd038a07 100644 --- a/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts +++ b/src/storages/inLocalStorage/MySegmentsCacheInLocal.ts @@ -51,7 +51,7 @@ export class MySegmentsCacheInLocal extends AbstractMySegmentsCacheSync { getRegisteredSegments(): string[] { const registeredSegments: string[] = []; - for (let i = 0; i < this.storage.length; i++) { + for (let i = 0, len = this.storage.length; i < len; i++) { const segmentName = this.keys.extractSegmentName(this.storage.key(i)!); if (segmentName) registeredSegments.push(segmentName); } diff --git a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts index a3246dab..25c44637 100644 --- a/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts @@ -1,6 +1,7 @@ import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal'; import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../../KeyBuilderCS'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { IMySegmentsResponse } from '../../../dtos/types'; test('SEGMENT CACHE / in LocalStorage', () => { const caches = [ @@ -22,11 +23,10 @@ test('SEGMENT CACHE / in LocalStorage', () => { }); caches.forEach(cache => { - // @ts-expect-error cache.resetSegments({ added: [], removed: ['mocked-segment'] - }); + } as IMySegmentsResponse); expect(cache.isInSegment('mocked-segment')).toBe(false); expect(cache.getRegisteredSegments()).toEqual(['mocked-segment-2']); diff --git a/src/storages/types.ts b/src/storages/types.ts index 75b1e292..8de14402 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -10,8 +10,8 @@ import { ISettings } from '../types'; */ export interface StorageAdapter { readonly length: number; - getItem(key: string): string | null; key(index: number): string | null; + getItem(key: string): string | null; removeItem(key: string): void; setItem(key: string, value: string): void; } From a7edc8dd68b3adecae16a56c28faee3c5144e034 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 12 Aug 2025 17:29:57 -0300 Subject: [PATCH 26/32] rc --- package-lock.json | 4 ++-- package.json | 2 +- src/storages/inLocalStorage/__tests__/index.spec.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 661ab4c6..c9900fcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.2-rc.2", + "version": "2.4.2-rc.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.4.2-rc.2", + "version": "2.4.2-rc.3", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index d46d7735..027c1597 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.2-rc.2", + "version": "2.4.2-rc.3", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/storages/inLocalStorage/__tests__/index.spec.ts b/src/storages/inLocalStorage/__tests__/index.spec.ts index cc45c99c..3341871d 100644 --- a/src/storages/inLocalStorage/__tests__/index.spec.ts +++ b/src/storages/inLocalStorage/__tests__/index.spec.ts @@ -27,7 +27,7 @@ describe('IN LOCAL STORAGE', () => { fakeInMemoryStorageFactory.mockClear(); }); - test('calls InMemoryStorage factory if LocalStorage API is not available or the provided storage wrapper is invalid', () => { + test('fallback to InMemoryStorage if LocalStorage API is not available or the provided storage wrapper is invalid', () => { // Delete global localStorage property const originalLocalStorage = Object.getOwnPropertyDescriptor(global, 'localStorage'); Object.defineProperty(global, 'localStorage', {}); @@ -52,7 +52,7 @@ describe('IN LOCAL STORAGE', () => { Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); }); - test('calls its own storage factory if LocalStorage API is available', () => { + test('calls InLocalStorage if LocalStorage API is available', () => { const storageFactory = InLocalStorage({ prefix: 'prefix' }); const storage = storageFactory(internalSdkParams); @@ -61,7 +61,7 @@ describe('IN LOCAL STORAGE', () => { expect(fakeInMemoryStorageFactory).not.toBeCalled(); // doesn't call InMemoryStorage factory }); - test('calls its own storage factory if the provided storage wrapper is valid', () => { + test('calls InLocalStorage if the provided storage wrapper is valid', () => { storageAdapterSpy.mockClear(); // Web Storages should not use the storageAdapter From 4e4df833bd04b09e095fb4831f695f5de2e948dd Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 16 Sep 2025 14:32:36 -0300 Subject: [PATCH 27/32] Avoid using wrapper for validation --- src/utils/env/isLocalStorageAvailable.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/utils/env/isLocalStorageAvailable.ts b/src/utils/env/isLocalStorageAvailable.ts index d1bdc8e2..9f5fef87 100644 --- a/src/utils/env/isLocalStorageAvailable.ts +++ b/src/utils/env/isLocalStorageAvailable.ts @@ -1,20 +1,19 @@ +/* eslint-disable no-undef */ export function isLocalStorageAvailable(): boolean { + var mod = '__SPLITSOFTWARE__'; try { - // eslint-disable-next-line no-undef - return isValidStorageWrapper(localStorage); + localStorage.setItem(mod, mod); + localStorage.removeItem(mod); + return true; } catch (e) { return false; } } export function isValidStorageWrapper(wrapper: any): boolean { - var mod = '__SPLITSOFTWARE__'; - try { - wrapper.setItem(mod, mod); - wrapper.getItem(mod); - wrapper.removeItem(mod); - return true; - } catch (e) { - return false; - } + return wrapper !== null && + typeof wrapper === 'object' && + typeof wrapper.setItem === 'function' && + typeof wrapper.getItem === 'function' && + typeof wrapper.removeItem === 'function'; } From d04eb639ea8ac1ed997593a1771655f2c337e4cf Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 16 Sep 2025 14:47:13 -0300 Subject: [PATCH 28/32] Simplify StorageWrapper interface --- .../inLocalStorage/__tests__/wrapper.mock.ts | 2 +- src/storages/inLocalStorage/index.ts | 2 +- src/storages/inLocalStorage/storageAdapter.ts | 2 +- types/splitio.d.ts | 30 ++++++------------- 4 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts index 897c13a0..9cbcbf02 100644 --- a/src/storages/inLocalStorage/__tests__/wrapper.mock.ts +++ b/src/storages/inLocalStorage/__tests__/wrapper.mock.ts @@ -4,7 +4,7 @@ import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; export const PREFIX = 'SPLITIO'; -export function createMemoryStorage(): SplitIO.AsyncStorageWrapper { +export function createMemoryStorage(): SplitIO.StorageWrapper { let cache: Record = {}; return { getItem(key: string) { diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index ae77bb41..46e824b8 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -19,7 +19,7 @@ import { ILogger } from '../../logger/types'; import SplitIO from '../../../types/splitio'; import { storageAdapter } from './storageAdapter'; -function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.SyncStorageWrapper | SplitIO.AsyncStorageWrapper): StorageAdapter | undefined { +function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined { if (wrapper) { if (isValidStorageWrapper(wrapper)) return storageAdapter(log, prefix, wrapper); log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API'); diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index af92df22..03fa68f2 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -4,7 +4,7 @@ import { LOG_PREFIX } from './constants'; import { StorageAdapter } from '../types'; -export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.SyncStorageWrapper | SplitIO.AsyncStorageWrapper): Required { +export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.StorageWrapper): Required { let keys: string[] = []; let cache: Record = {}; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 56f9119d..63708344 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -463,34 +463,22 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync */ declare namespace SplitIO { - interface SyncStorageWrapper { + interface StorageWrapper { /** * Returns the value associated with the given key, or null if the key does not exist. + * If the operation is asynchronous, returns a Promise. */ - getItem(key: string): string | null; + getItem(key: string): string | null | Promise; /** * Sets the value for the given key, creating a new key/value pair if key does not exist. + * If the operation is asynchronous, returns a Promise. */ - setItem(key: string, value: string): void; + setItem(key: string, value: string): void | Promise; /** * Removes the key/value pair for the given key, if the key exists. + * If the operation is asynchronous, returns a Promise. */ - removeItem(key: string): void; - } - - interface AsyncStorageWrapper { - /** - * Returns a promise that resolves to the value associated with the given key, or null if the key does not exist. - */ - getItem(key: string): Promise; - /** - * Returns a promise that resolves when the value of the pair identified by key is set to value, creating a new key/value pair if key does not exist. - */ - setItem(key: string, value: string): Promise; - /** - * Returns a promise that resolves when the key/value pair for the given key is removed, if the key exists. - */ - removeItem(key: string): Promise; + removeItem(key: string): void | Promise; } /** @@ -1013,7 +1001,7 @@ declare namespace SplitIO { * * @defaultValue `window.localStorage` */ - wrapper?: SyncStorageWrapper | AsyncStorageWrapper; + wrapper?: StorageWrapper; } /** * Storage for asynchronous (consumer) SDK. @@ -1380,7 +1368,7 @@ declare namespace SplitIO { * * @defaultValue `window.localStorage` */ - wrapper?: SyncStorageWrapper | AsyncStorageWrapper; + wrapper?: StorageWrapper; }; } /** From c705834228eeea62c792675832fdc3246eb97ff9 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 16 Sep 2025 17:31:13 -0300 Subject: [PATCH 29/32] rc --- package-lock.json | 4 ++-- package.json | 2 +- src/storages/inLocalStorage/storageAdapter.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14125ac1..03f82b41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.5.0", + "version": "2.5.1-rc.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.5.0", + "version": "2.5.1-rc.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 155f650a..2bc41ac3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.5.0", + "version": "2.5.1-rc.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/storages/inLocalStorage/storageAdapter.ts b/src/storages/inLocalStorage/storageAdapter.ts index 03fa68f2..5b9db838 100644 --- a/src/storages/inLocalStorage/storageAdapter.ts +++ b/src/storages/inLocalStorage/storageAdapter.ts @@ -19,7 +19,7 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St cache = JSON.parse(storedCache || '{}'); keys = Object.keys(cache); }).catch((e) => { - log.error(LOG_PREFIX + 'Rejected promise calling wrapper `getItem` method, with error: ' + e); + log.error(LOG_PREFIX + 'Error calling wrapper `getItem` method: ' + e); })); }, @@ -27,7 +27,7 @@ export function storageAdapter(log: ILogger, prefix: string, wrapper: SplitIO.St return savePromise = savePromise.then(() => { return Promise.resolve(wrapper.setItem(prefix, JSON.stringify(cache))); }).catch((e) => { - log.error(LOG_PREFIX + 'Rejected promise calling wrapper `setItem` method, with error: ' + e); + log.error(LOG_PREFIX + 'Error calling wrapper `setItem` method: ' + e); }); }, From 987e8c5bec29e65c3b6f9c8173da8ab6e1fac740 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 18 Sep 2025 12:16:22 -0300 Subject: [PATCH 30/32] Update links to public docs --- CHANGES.txt | 2 +- README.md | 36 ++++++++++++++++++------------------ types/splitio.d.ts | 44 ++++++++++++++++++++++---------------------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index e985763c..da38cdb1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,4 @@ -2.6.0 (September 19, 2025) +2.6.0 (September 18, 2025) - Added `storage.wrapper` configuration option to allow the SDK to use a custom storage wrapper for the storage type `LOCALSTORAGE`. Default value is `window.localStorage`. 2.5.0 (September 10, 2025) diff --git a/README.md b/README.md index 85f791cd..36f809b5 100644 --- a/README.md +++ b/README.md @@ -24,24 +24,24 @@ To learn more about Split, contact hello@split.io, or get started with feature f Split has built and maintains SDKs for: -* .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://help.split.io/hc/en-us/articles/360020240172--NET-SDK) -* Android [Github](https://github.com/splitio/android-client) [Docs](https://help.split.io/hc/en-us/articles/360020343291-Android-SDK) -* Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/6495326064397-Angular-utilities) -* Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://help.split.io/hc/en-us/articles/26988707417869-Elixir-Thin-Client-SDK) -* Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/8096158017165-Flutter-plugin) -* GO [Github](https://github.com/splitio/go-client) [Docs](https://help.split.io/hc/en-us/articles/360020093652-Go-SDK) -* iOS [Github](https://github.com/splitio/ios-client) [Docs](https://help.split.io/hc/en-us/articles/360020401491-iOS-SDK) -* Java [Github](https://github.com/splitio/java-client) [Docs](https://help.split.io/hc/en-us/articles/360020405151-Java-SDK) -* JavaScript [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK) -* JavaScript for Browser [Github](https://github.com/splitio/javascript-browser-client) [Docs](https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK) -* Node.js [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK) -* PHP [Github](https://github.com/splitio/php-client) [Docs](https://help.split.io/hc/en-us/articles/360020350372-PHP-SDK) -* PHP thin-client [Github](https://github.com/splitio/php-thin-client) [Docs](https://help.split.io/hc/en-us/articles/18305128673933-PHP-Thin-Client-SDK) -* Python [Github](https://github.com/splitio/python-client) [Docs](https://help.split.io/hc/en-us/articles/360020359652-Python-SDK) -* React [Github](https://github.com/splitio/react-client) [Docs](https://help.split.io/hc/en-us/articles/360038825091-React-SDK) -* React Native [Github](https://github.com/splitio/react-native-client) [Docs](https://help.split.io/hc/en-us/articles/4406066357901-React-Native-SDK) -* Redux [Github](https://github.com/splitio/redux-client) [Docs](https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK) -* Ruby [Github](https://github.com/splitio/ruby-client) [Docs](https://help.split.io/hc/en-us/articles/360020673251-Ruby-SDK) +* .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/net-sdk/) +* Android [Github](https://github.com/splitio/android-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/android-sdk/) +* Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/angular-utilities/) +* Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/elixir-thin-client-sdk/) +* Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/flutter-plugin/) +* GO [Github](https://github.com/splitio/go-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/go-sdk/) +* iOS [Github](https://github.com/splitio/ios-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/ios-sdk/) +* Java [Github](https://github.com/splitio/java-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/java-sdk/) +* JavaScript [Github](https://github.com/splitio/javascript-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/) +* JavaScript for Browser [Github](https://github.com/splitio/javascript-browser-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/) +* Node.js [Github](https://github.com/splitio/javascript-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/) +* PHP [Github](https://github.com/splitio/php-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/php-sdk/) +* PHP thin-client [Github](https://github.com/splitio/php-thin-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/php-thin-client-sdk/) +* Python [Github](https://github.com/splitio/python-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/python-sdk/) +* React [Github](https://github.com/splitio/react-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-sdk/) +* React Native [Github](https://github.com/splitio/react-native-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-native-sdk/) +* Redux [Github](https://github.com/splitio/redux-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/redux-sdk/) +* Ruby [Github](https://github.com/splitio/ruby-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/ruby-sdk/) For a comprehensive list of open source projects visit our [Github page](https://github.com/splitio?utf8=%E2%9C%93&query=%20only%3Apublic%20). diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 63708344..eaa490f3 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -132,7 +132,7 @@ interface IPluggableSharedSettings { /** * Boolean value to indicate whether the logger should be enabled or disabled by default, or a log level string or a Logger object. * Passing a logger object is required to get descriptive log messages. Otherwise most logs will print with message codes. - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#logging}. + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#logging}. * * Examples: * ``` @@ -179,7 +179,7 @@ interface IServerSideSharedSettings { /** * Your SDK key. * - * @see {@link https://help.split.io/hc/en-us/articles/360019916211-API-keys} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/account-settings/api-keys/} */ authorizationKey: string; /** @@ -282,7 +282,7 @@ interface IServerSideSharedSettings { eventsQueueSize?: number; /** * For mocking/testing only. The SDK will refresh the features mocked data when mode is set to "localhost" by defining the key. - * For more information see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#localhost-mode} + * For more information see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#localhost-mode} * * @defaultValue `15` */ @@ -297,7 +297,7 @@ interface IServerSideSharedSettings { }; /** * Mocked features file path. For testing purposes only. For using this you should specify "localhost" as authorizationKey on core settings. - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#localhost-mode} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#localhost-mode} * * @defaultValue `'$HOME/.split'` */ @@ -314,13 +314,13 @@ interface IClientSideSharedSettings { /** * Your SDK key. * - * @see {@link https://help.split.io/hc/en-us/articles/360019916211-API-keys} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/account-settings/api-keys/} */ authorizationKey: string; /** * Customer identifier. Whatever this means to you. * - * @see {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} */ key: SplitIO.SplitKey; /** @@ -347,7 +347,7 @@ interface IClientSideSharedSettings { interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISyncSharedSettings { /** * Mocked features map. For testing purposes only. For using this you should specify "localhost" as authorizationKey on core settings. - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#localhost-mode} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#localhost-mode} */ features?: SplitIO.MockedFeaturesMap; /** @@ -442,7 +442,7 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync eventsQueueSize?: number; /** * For mocking/testing only. The SDK will refresh the features mocked data when mode is set to "localhost" by defining the key. - * For more information see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#localhost-mode} + * For more information see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#localhost-mode} * * @defaultValue `15` */ @@ -794,7 +794,7 @@ declare namespace SplitIO { /** * Attributes should be on object with values of type string, boolean, number (dates should be sent as millis since epoch) or array of strings or numbers. * - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#attribute-syntax} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#attribute-syntax} */ type Attributes = { [attributeName: string]: AttributeType; @@ -806,7 +806,7 @@ declare namespace SplitIO { /** * Properties should be an object with values of type string, number, boolean or null. Size limit of ~31kb. * - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#track} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#track} */ type Properties = { [propertyName: string]: string | number | boolean | null; @@ -1076,7 +1076,7 @@ declare namespace SplitIO { * Impression listener interface. This is the interface that needs to be implemented * by the element you provide to the SDK as impression listener. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#listener} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#listener} */ interface IImpressionListener { logImpression(data: ImpressionData): void; @@ -1211,7 +1211,7 @@ declare namespace SplitIO { /** * Settings interface for Browser SDK instances created with client-side API and synchronous storage (e.g., in-memory or local storage). * - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#configuration} */ interface IClientSideSettings extends IClientSideSyncSharedSettings, IPluggableSharedSettings { /** @@ -1232,20 +1232,20 @@ declare namespace SplitIO { /** * Settings interface for React Native SDK instances, with client-side API and synchronous storage. * - * @see {@link https://help.split.io/hc/en-us/articles/4406066357901-React-Native-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-native-sdk/#configuration} */ interface IReactNativeSettings extends IClientSideSettings { } /** * Settings interface for Browser SDK instances created with client-side API and asynchronous storage (e.g., serverless environments with a persistent storage). * If your storage is synchronous (by default we use memory, which is sync) use SplitIO.IClientSideSettings instead. * - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#sharing-state-with-a-pluggable-storage} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#sharing-state-with-a-pluggable-storage} */ interface IClientSideAsyncSettings extends IClientSideSharedSettings, ISharedSettings, IPluggableSharedSettings { /** * The SDK mode. When using `PluggableStorage` as storage, the possible values are "consumer" and "consumer_partial". * - * @see {@link https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK#sharing-state-with-a-pluggable-storage} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/#sharing-state-with-a-pluggable-storage} */ mode: 'consumer' | 'consumer_partial'; /** @@ -1331,7 +1331,7 @@ declare namespace SplitIO { /** * Settings interface for JavaScript SDK instances created on the browser, with client-side API and synchronous storage (e.g., in-memory or local storage). * - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#configuration} */ interface IBrowserSettings extends IClientSideSyncSharedSettings, INonPluggableSharedSettings { /** @@ -1375,7 +1375,7 @@ declare namespace SplitIO { * Settings interface for JavaScript SDK instances created on Node.js, with server-side API and synchronous in-memory storage. * If your storage is asynchronous (Redis for example) use SplitIO.INodeAsyncSettings instead. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#configuration} */ interface INodeSettings extends IServerSideSharedSettings, ISyncSharedSettings, INonPluggableSharedSettings { /** @@ -1462,13 +1462,13 @@ declare namespace SplitIO { * Settings interface for JavaScript SDK instances created on Node.js, with asynchronous storage like Redis. * If your storage is synchronous (by default we use memory, which is sync) use SplitIO.INodeSettings instead. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#configuration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#configuration} */ interface INodeAsyncSettings extends IServerSideSharedSettings, ISharedSettings, INonPluggableSharedSettings { /** * The SDK mode. When using 'REDIS' storage type, the only possible value is "consumer", which is required. * - * @see {@link https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK#state-sharing-redis-integration} + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/#state-sharing-redis-integration} */ mode: 'consumer'; /** @@ -1742,7 +1742,7 @@ declare namespace SplitIO { * Tracks an event to be fed to the results product on Split user interface. * * @param key - The key that identifies the entity related to this event. - * @param trafficType - The traffic type of the entity related to this event. See {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @param trafficType - The traffic type of the entity related to this event. See {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} * @param eventType - The event type corresponding to this event. * @param value - The value of this event. * @param properties - The properties of this event. Values can be string, number, boolean or null. @@ -1840,7 +1840,7 @@ declare namespace SplitIO { * Tracks an event to be fed to the results product on Split user interface, and returns a promise to signal when the event was successfully queued (or not). * * @param key - The key that identifies the entity related to this event. - * @param trafficType - The traffic type of the entity related to this event. See {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @param trafficType - The traffic type of the entity related to this event. See {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} * @param eventType - The event type corresponding to this event. * @param value - The value of this event. * @param properties - The properties of this event. Values can be string, number, boolean or null. @@ -1970,7 +1970,7 @@ declare namespace SplitIO { /** * Tracks an event to be fed to the results product on Split user interface. * - * @param trafficType - The traffic type of the entity related to this event. See {@link https://help.split.io/hc/en-us/articles/360019916311-Traffic-type} + * @param trafficType - The traffic type of the entity related to this event. See {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/fme-settings/traffic-types/} * @param eventType - The event type corresponding to this event. * @param value - The value of this event. * @param properties - The properties of this event. Values can be string, number, boolean or null. From 1cb908588a6565676cbea62272472a6e77bfdb33 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 18 Sep 2025 12:16:33 -0300 Subject: [PATCH 31/32] stable version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03f82b41..d40871e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.5.1-rc.0", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.5.1-rc.0", + "version": "2.6.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 2bc41ac3..9301b5bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.5.1-rc.0", + "version": "2.6.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From 240fec6e2f89dfbce079469d72ca3494dc9accd4 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 18 Sep 2025 12:48:39 -0300 Subject: [PATCH 32/32] Log sdk version --- src/logger/messages/info.ts | 2 +- src/sdkFactory/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/logger/messages/info.ts b/src/logger/messages/info.ts index 94333bf6..fc688224 100644 --- a/src/logger/messages/info.ts +++ b/src/logger/messages/info.ts @@ -11,7 +11,7 @@ export const codesInfo: [number, string][] = codesWarn.concat([ [c.IMPRESSION, c.LOG_PREFIX_IMPRESSIONS_TRACKER +'Feature flag: %s. Key: %s. Evaluation: %s. Label: %s'], [c.IMPRESSION_QUEUEING, c.LOG_PREFIX_IMPRESSIONS_TRACKER +'Queueing corresponding impression.'], [c.NEW_SHARED_CLIENT, 'New shared client instance created.'], - [c.NEW_FACTORY, 'New Split SDK instance created.'], + [c.NEW_FACTORY, 'New Split SDK instance created. %s'], [c.EVENTS_TRACKER_SUCCESS, c.LOG_PREFIX_EVENTS_TRACKER + 'Successfully queued %s'], [c.IMPRESSIONS_TRACKER_SUCCESS, c.LOG_PREFIX_IMPRESSIONS_TRACKER + 'Successfully stored %s impression(s).'], [c.USER_CONSENT_UPDATED, 'UserConsent: consent status changed from %s to %s.'], diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index d1dcac43..eba01028 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -113,7 +113,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA initCallbacks.length = 0; } - log.info(NEW_FACTORY); + log.info(NEW_FACTORY, [settings.version]); // @ts-ignore return objectAssign({