From b9011e66fb49aebec8e012053632df8d32231bcd Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 22 May 2025 16:31:58 -0300 Subject: [PATCH 1/5] 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 358a6b7921dc8439940331761380ace50c323f68 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 27 May 2025 11:25:52 -0300 Subject: [PATCH 2/5] 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 c567f2ecb6e567d58ebca731cf914e9cd5d39f22 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 30 Jul 2025 16:24:33 -0300 Subject: [PATCH 3/5] 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 4/5] 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 07dedd70e0564ca7581d1ac5046264ce3a15bc93 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 12 Aug 2025 14:54:17 -0300 Subject: [PATCH 5/5] 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; }