Skip to content

Commit f45dcc7

Browse files
Merge pull request #424 from splitio/inlocalstorage_storageAdapter
[Custom InLocalStorage] Add `storage.wrapper` option for InLocal storage
2 parents ad6857a + d04eb63 commit f45dcc7

14 files changed

+445
-218
lines changed

src/storages/inLocalStorage/__tests__/MySegmentsCacheInLocal.spec.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal';
22
import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../../KeyBuilderCS';
33
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
4+
import { storages, PREFIX } from './wrapper.mock';
45
import { IMySegmentsResponse } from '../../../dtos/types';
56

6-
test('SEGMENT CACHE / in LocalStorage', () => {
7+
test.each(storages)('SEGMENT CACHE / in LocalStorage', (storage) => {
78
const caches = [
8-
new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user'), localStorage),
9-
new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder('SPLITIO', 'user'), localStorage)
9+
new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS(PREFIX, 'user'), storage),
10+
new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder(PREFIX, 'user'), storage)
1011
];
1112

1213
caches.forEach(cache => {
@@ -33,8 +34,8 @@ test('SEGMENT CACHE / in LocalStorage', () => {
3334
expect(cache.getKeysCount()).toBe(1);
3435
});
3536

36-
expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment-2')).toBe('1');
37-
expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment')).toBe(null);
38-
expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment-2')).toBe('1');
39-
expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment')).toBe(null);
37+
expect(storage.getItem(PREFIX + '.user.segment.mocked-segment-2')).toBe('1');
38+
expect(storage.getItem(PREFIX + '.user.segment.mocked-segment')).toBe(null);
39+
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment-2')).toBe('1');
40+
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment')).toBe(null);
4041
});

src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts

Lines changed: 163 additions & 160 deletions
Large diffs are not rendered by default.

src/storages/inLocalStorage/__tests__/index.spec.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,29 @@ describe('IN LOCAL STORAGE', () => {
2323
fakeInMemoryStorageFactory.mockClear();
2424
});
2525

26-
test('calls InMemoryStorage factory if LocalStorage API is not available', () => {
27-
26+
test('calls InMemoryStorage factory if LocalStorage API is not available or the provided storage wrapper is invalid', () => {
27+
// Delete global localStorage property
2828
const originalLocalStorage = Object.getOwnPropertyDescriptor(global, 'localStorage');
29-
Object.defineProperty(global, 'localStorage', {}); // delete global localStorage property
30-
31-
const storageFactory = InLocalStorage({ prefix: 'prefix' });
32-
const storage = storageFactory(internalSdkParams);
29+
Object.defineProperty(global, 'localStorage', {});
3330

31+
// LocalStorage API is not available
32+
let storageFactory = InLocalStorage({ prefix: 'prefix' });
33+
let storage = storageFactory(internalSdkParams);
3434
expect(fakeInMemoryStorageFactory).toBeCalledWith(internalSdkParams); // calls InMemoryStorage factory
3535
expect(storage).toBe(fakeInMemoryStorage);
3636

37-
Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); // restore original localStorage
37+
// @ts-expect-error Provided storage is invalid
38+
storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: {} });
39+
storage = storageFactory(internalSdkParams);
40+
expect(storage).toBe(fakeInMemoryStorage);
41+
42+
// Provided storage is valid
43+
storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } });
44+
storage = storageFactory(internalSdkParams);
45+
expect(storage).not.toBe(fakeInMemoryStorage);
3846

47+
// Restore original localStorage
48+
Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor);
3949
});
4050

4151
test('calls its own storage factory if LocalStorage API is available', () => {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { storageAdapter } from '../storageAdapter';
2+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
3+
4+
5+
const syncWrapper = {
6+
getItem: jest.fn(() => JSON.stringify({ key1: 'value1' })),
7+
setItem: jest.fn(),
8+
removeItem: jest.fn(),
9+
};
10+
11+
const asyncWrapper = {
12+
getItem: jest.fn(() => Promise.resolve(JSON.stringify({ key1: 'value1' }))),
13+
setItem: jest.fn(() => Promise.resolve()),
14+
removeItem: jest.fn(() => Promise.resolve()),
15+
};
16+
17+
test.each([
18+
[syncWrapper],
19+
[asyncWrapper],
20+
])('storageAdapter', async (wrapper) => {
21+
22+
const storage = storageAdapter(loggerMock, 'prefix', wrapper);
23+
24+
expect(storage.length).toBe(0);
25+
26+
// Load cache from storage wrapper
27+
await storage.load();
28+
29+
expect(wrapper.getItem).toHaveBeenCalledWith('prefix');
30+
expect(storage.length).toBe(1);
31+
expect(storage.key(0)).toBe('key1');
32+
expect(storage.getItem('key1')).toBe('value1');
33+
34+
// Set item
35+
storage.setItem('key2', 'value2');
36+
expect(storage.getItem('key2')).toBe('value2');
37+
expect(storage.length).toBe(2);
38+
39+
// Remove item
40+
storage.removeItem('key1');
41+
expect(storage.getItem('key1')).toBe(null);
42+
expect(storage.length).toBe(1);
43+
44+
// Until `save` is called, changes should not be saved/persisted
45+
await storage.whenSaved();
46+
expect(wrapper.setItem).not.toHaveBeenCalled();
47+
48+
storage.setItem('.till', '1');
49+
expect(storage.length).toBe(2);
50+
expect(storage.key(0)).toBe('key2');
51+
expect(storage.key(1)).toBe('.till');
52+
53+
// When `save` is called, changes should be saved/persisted immediately
54+
storage.save();
55+
await storage.whenSaved();
56+
expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2', '.till': '1' }));
57+
58+
expect(wrapper.setItem).toHaveBeenCalledTimes(1);
59+
60+
await storage.whenSaved();
61+
expect(wrapper.setItem).toHaveBeenCalledTimes(1);
62+
});

src/storages/inLocalStorage/__tests__/validateCache.spec.ts

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,17 @@ import { SplitsCacheInLocal } from '../SplitsCacheInLocal';
66
import { nearlyEqual } from '../../../__tests__/testUtils';
77
import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal';
88
import { RBSegmentsCacheInLocal } from '../RBSegmentsCacheInLocal';
9+
import { storages, PREFIX } from './wrapper.mock';
910

1011
const FULL_SETTINGS_HASH = 'dc1f9817';
1112

12-
describe('validateCache', () => {
13-
const keys = new KeyBuilderCS('SPLITIO', 'user');
13+
describe.each(storages)('validateCache', (storage) => {
14+
const keys = new KeyBuilderCS(PREFIX, 'user');
1415
const logSpy = jest.spyOn(fullSettings.log, 'info');
15-
const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage);
16-
const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage);
17-
const splits = new SplitsCacheInLocal(fullSettings, keys, localStorage);
18-
const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, localStorage);
16+
const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage);
17+
const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage);
18+
const splits = new SplitsCacheInLocal(fullSettings, keys, storage);
19+
const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, storage);
1920

2021
jest.spyOn(splits, 'getChangeNumber');
2122
jest.spyOn(splits, 'clear');
@@ -25,11 +26,11 @@ describe('validateCache', () => {
2526

2627
beforeEach(() => {
2728
jest.clearAllMocks();
28-
localStorage.clear();
29+
for (let i = 0; i < storage.length; i++) storage.removeItem(storage.key(i) as string);
2930
});
3031

3132
test('if there is no cache, it should return false', async () => {
32-
expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
33+
expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
3334

3435
expect(logSpy).not.toHaveBeenCalled();
3536

@@ -39,15 +40,15 @@ describe('validateCache', () => {
3940
expect(largeSegments.clear).not.toHaveBeenCalled();
4041
expect(splits.getChangeNumber).toHaveBeenCalledTimes(1);
4142

42-
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
43-
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
43+
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
44+
expect(storage.getItem(keys.buildLastClear())).toBeNull();
4445
});
4546

4647
test('if there is cache and it must not be cleared, it should return true', async () => {
47-
localStorage.setItem(keys.buildSplitsTillKey(), '1');
48-
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
48+
storage.setItem(keys.buildSplitsTillKey(), '1');
49+
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
4950

50-
expect(await validateCache({}, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
51+
expect(await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
5152

5253
expect(logSpy).not.toHaveBeenCalled();
5354

@@ -57,16 +58,16 @@ describe('validateCache', () => {
5758
expect(largeSegments.clear).not.toHaveBeenCalled();
5859
expect(splits.getChangeNumber).toHaveBeenCalledTimes(1);
5960

60-
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
61-
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
61+
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
62+
expect(storage.getItem(keys.buildLastClear())).toBeNull();
6263
});
6364

6465
test('if there is cache and it has expired, it should clear cache and return false', async () => {
65-
localStorage.setItem(keys.buildSplitsTillKey(), '1');
66-
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
67-
localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
66+
storage.setItem(keys.buildSplitsTillKey(), '1');
67+
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
68+
storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
6869

69-
expect(await validateCache({ expirationDays: 1 }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
70+
expect(await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
7071

7172
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache');
7273

@@ -75,15 +76,15 @@ describe('validateCache', () => {
7576
expect(segments.clear).toHaveBeenCalledTimes(1);
7677
expect(largeSegments.clear).toHaveBeenCalledTimes(1);
7778

78-
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
79-
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
79+
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
80+
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
8081
});
8182

8283
test('if there is cache and its hash has changed, it should clear cache and return false', async () => {
83-
localStorage.setItem(keys.buildSplitsTillKey(), '1');
84-
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
84+
storage.setItem(keys.buildSplitsTillKey(), '1');
85+
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
8586

86-
expect(await validateCache({}, localStorage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
87+
expect(await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
8788

8889
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');
8990

@@ -92,16 +93,16 @@ describe('validateCache', () => {
9293
expect(segments.clear).toHaveBeenCalledTimes(1);
9394
expect(largeSegments.clear).toHaveBeenCalledTimes(1);
9495

95-
expect(localStorage.getItem(keys.buildHashKey())).toBe('45c6ba5d');
96-
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
96+
expect(storage.getItem(keys.buildHashKey())).toBe('45c6ba5d');
97+
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
9798
});
9899

99100
test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => {
100101
// Older cache version (without last clear)
101-
localStorage.setItem(keys.buildSplitsTillKey(), '1');
102-
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
102+
storage.setItem(keys.buildSplitsTillKey(), '1');
103+
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
103104

104-
expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
105+
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
105106

106107
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
107108

@@ -110,25 +111,25 @@ describe('validateCache', () => {
110111
expect(segments.clear).toHaveBeenCalledTimes(1);
111112
expect(largeSegments.clear).toHaveBeenCalledTimes(1);
112113

113-
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
114-
const lastClear = localStorage.getItem(keys.buildLastClear());
114+
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
115+
const lastClear = storage.getItem(keys.buildLastClear());
115116
expect(nearlyEqual(parseInt(lastClear as string), Date.now())).toBe(true);
116117

117118
// If cache is cleared, it should not clear again until a day has passed
118119
logSpy.mockClear();
119-
localStorage.setItem(keys.buildSplitsTillKey(), '1');
120-
expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
120+
storage.setItem(keys.buildSplitsTillKey(), '1');
121+
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
121122
expect(logSpy).not.toHaveBeenCalled();
122-
expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed
123+
expect(storage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed
123124

124125
// If a day has passed, it should clear again
125-
localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
126-
expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
126+
storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
127+
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
127128
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
128129
expect(splits.clear).toHaveBeenCalledTimes(2);
129130
expect(rbSegments.clear).toHaveBeenCalledTimes(2);
130131
expect(segments.clear).toHaveBeenCalledTimes(2);
131132
expect(largeSegments.clear).toHaveBeenCalledTimes(2);
132-
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
133+
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
133134
});
134135
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { storageAdapter } from '../storageAdapter';
2+
import SplitIO from '../../../../types/splitio';
3+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
4+
5+
export const PREFIX = 'SPLITIO';
6+
7+
export function createMemoryStorage(): SplitIO.StorageWrapper {
8+
let cache: Record<string, string> = {};
9+
return {
10+
getItem(key: string) {
11+
return Promise.resolve(cache[key] || null);
12+
},
13+
setItem(key: string, value: string) {
14+
cache[key] = value;
15+
return Promise.resolve();
16+
},
17+
removeItem(key: string) {
18+
delete cache[key];
19+
return Promise.resolve();
20+
}
21+
};
22+
}
23+
24+
export const storages = [
25+
localStorage,
26+
storageAdapter(loggerMock, PREFIX, createMemoryStorage())
27+
];

src/storages/inLocalStorage/index.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { ImpressionsCacheInMemory } from '../inMemory/ImpressionsCacheInMemory';
22
import { ImpressionCountsCacheInMemory } from '../inMemory/ImpressionCountsCacheInMemory';
33
import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory';
4-
import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory } from '../types';
4+
import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory, StorageAdapter } from '../types';
55
import { validatePrefix } from '../KeyBuilder';
66
import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS';
7-
import { isLocalStorageAvailable } from '../../utils/env/isLocalStorageAvailable';
7+
import { isLocalStorageAvailable, isValidStorageWrapper } from '../../utils/env/isLocalStorageAvailable';
88
import { SplitsCacheInLocal } from './SplitsCacheInLocal';
99
import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal';
1010
import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal';
@@ -17,8 +17,14 @@ import { getMatching } from '../../utils/key';
1717
import { validateCache } from './validateCache';
1818
import { ILogger } from '../../logger/types';
1919
import SplitIO from '../../../types/splitio';
20+
import { storageAdapter } from './storageAdapter';
21+
22+
function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.StorageWrapper): StorageAdapter | undefined {
23+
if (wrapper) {
24+
if (isValidStorageWrapper(wrapper)) return storageAdapter(log, prefix, wrapper);
25+
log.warn(LOG_PREFIX + 'Invalid storage provided. Falling back to LocalStorage API');
26+
}
2027

21-
function validateStorage(log: ILogger) {
2228
if (isLocalStorageAvailable()) return localStorage;
2329

2430
log.warn(LOG_PREFIX + 'LocalStorage API is unavailable. Falling back to default MEMORY storage');
@@ -34,7 +40,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
3440
function InLocalStorageCSFactory(params: IStorageFactoryParams): IStorageSync {
3541
const { settings, settings: { log, scheduler: { impressionsQueueSize, eventsQueueSize } } } = params;
3642

37-
const storage = validateStorage(log);
43+
const storage = validateStorage(log, prefix, options.wrapper);
3844
if (!storage) return InMemoryStorageCSFactory(params);
3945

4046
const matchingKey = getMatching(settings.core.key);
@@ -61,8 +67,12 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
6167
return validateCachePromise || (validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments));
6268
},
6369

70+
save() {
71+
return storage.save && storage.save();
72+
},
73+
6474
destroy() {
65-
return Promise.resolve();
75+
return storage.whenSaved && storage.whenSaved();
6676
},
6777

6878
// When using shared instantiation with MEMORY we reuse everything but segments (they are customer per key).

0 commit comments

Comments
 (0)