Skip to content

[Custom InLocalStorage] Add storage.wrapper option for InLocal storage #424

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: inlocalstorage_baseline
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4411c5d
Implement basic storageAdapter to support async storages with getItem…
EmilianoSanchez May 27, 2025
6d0f274
Merge branch 'inlocalstorage_wrapper' into inlocalstorage_storageAdapter
EmilianoSanchez May 30, 2025
b550042
Tests
EmilianoSanchez May 30, 2025
4b0988d
Update types
EmilianoSanchez Jun 23, 2025
299585b
refactor: rename Storage interface to StorageWrapper for clarity and …
EmilianoSanchez Jul 18, 2025
2bcf4d4
Merge branch 'inlocalstorage_wrapper' into inlocalstorage_storageAdapter
EmilianoSanchez Jul 30, 2025
70ea6d4
Revert "Break the PR into smaller PRs"
EmilianoSanchez Jul 30, 2025
71258da
Merge branch 'inlocalstorage_wrapper' into inlocalstorage_reorder_ope…
EmilianoSanchez Jul 30, 2025
47a8cc2
Merge branch 'inlocalstorage_reorder_operations' into inlocalstorage_…
EmilianoSanchez Jul 30, 2025
60aa25d
refactor: move storageAdapter to dedicated file and update imports
EmilianoSanchez Jul 31, 2025
cb22017
Add unit test
EmilianoSanchez Aug 1, 2025
6bb2930
optimize StorageAdapter's length and key methods by using arrays inst…
EmilianoSanchez Aug 1, 2025
a7d3246
refactor: rename save method to whenSaved
EmilianoSanchez Aug 2, 2025
c594a6a
optimize StorageAdapter using an internal keys array and cache object
EmilianoSanchez Aug 4, 2025
a1c091b
refactor: split StorageWrapper into sync and async interfaces for bet…
EmilianoSanchez Aug 4, 2025
1c09903
Revert commit 70ea6d424d08928e81b496ce6ffed3e262b19cf6.
EmilianoSanchez Aug 11, 2025
afdf656
Add and use StorageAdapter::save method
EmilianoSanchez Aug 11, 2025
1cc876b
refactor: call storage::save in updaters and remove from cache classes
EmilianoSanchez Aug 12, 2025
a52eab3
Merge branch 'inlocalstorage_baseline' into inlocalstorage_storageAda…
EmilianoSanchez Aug 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal';
import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../../KeyBuilderCS';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
import { storages, PREFIX } from './wrapper.mock';
import { IMySegmentsResponse } from '../../../dtos/types';

test('SEGMENT CACHE / in LocalStorage', () => {
test.each(storages)('SEGMENT CACHE / in LocalStorage', (storage) => {
const caches = [
new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user'), localStorage),
new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder('SPLITIO', 'user'), localStorage)
new MySegmentsCacheInLocal(loggerMock, new KeyBuilderCS(PREFIX, 'user'), storage),
new MySegmentsCacheInLocal(loggerMock, myLargeSegmentsKeyBuilder(PREFIX, 'user'), storage)
];

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

expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment-2')).toBe('1');
expect(localStorage.getItem('SPLITIO.user.segment.mocked-segment')).toBe(null);
expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment-2')).toBe('1');
expect(localStorage.getItem('SPLITIO.user.largeSegment.mocked-segment')).toBe(null);
expect(storage.getItem(PREFIX + '.user.segment.mocked-segment-2')).toBe('1');
expect(storage.getItem(PREFIX + '.user.segment.mocked-segment')).toBe(null);
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment-2')).toBe('1');
expect(storage.getItem(PREFIX + '.user.largeSegment.mocked-segment')).toBe(null);
});
323 changes: 163 additions & 160 deletions src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts

Large diffs are not rendered by default.

24 changes: 17 additions & 7 deletions src/storages/inLocalStorage/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,29 @@ describe('IN LOCAL STORAGE', () => {
fakeInMemoryStorageFactory.mockClear();
});

test('calls InMemoryStorage factory if LocalStorage API is not available', () => {

test('calls InMemoryStorage factory if LocalStorage API is not available or the provided storage wrapper is invalid', () => {
// Delete global localStorage property
const originalLocalStorage = Object.getOwnPropertyDescriptor(global, 'localStorage');
Object.defineProperty(global, 'localStorage', {}); // delete global localStorage property

const storageFactory = InLocalStorage({ prefix: 'prefix' });
const storage = storageFactory(internalSdkParams);
Object.defineProperty(global, 'localStorage', {});

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

Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor); // restore original localStorage
// @ts-expect-error Provided storage is invalid
storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: {} });
storage = storageFactory(internalSdkParams);
expect(storage).toBe(fakeInMemoryStorage);

// Provided storage is valid
storageFactory = InLocalStorage({ prefix: 'prefix', wrapper: { getItem: () => Promise.resolve(null), setItem: () => Promise.resolve(), removeItem: () => Promise.resolve() } });
storage = storageFactory(internalSdkParams);
expect(storage).not.toBe(fakeInMemoryStorage);

// Restore original localStorage
Object.defineProperty(global, 'localStorage', originalLocalStorage as PropertyDescriptor);
});

test('calls its own storage factory if LocalStorage API is available', () => {
Expand Down
62 changes: 62 additions & 0 deletions src/storages/inLocalStorage/__tests__/storageAdapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { storageAdapter } from '../storageAdapter';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';


const syncWrapper = {
getItem: jest.fn(() => JSON.stringify({ key1: 'value1' })),
setItem: jest.fn(),
removeItem: jest.fn(),
};

const asyncWrapper = {
getItem: jest.fn(() => Promise.resolve(JSON.stringify({ key1: 'value1' }))),
setItem: jest.fn(() => Promise.resolve()),
removeItem: jest.fn(() => Promise.resolve()),
};

test.each([
[syncWrapper],
[asyncWrapper],
])('storageAdapter', async (wrapper) => {

const storage = storageAdapter(loggerMock, 'prefix', wrapper);

expect(storage.length).toBe(0);

// Load cache from storage wrapper
await storage.load();

expect(wrapper.getItem).toHaveBeenCalledWith('prefix');
expect(storage.length).toBe(1);
expect(storage.key(0)).toBe('key1');
expect(storage.getItem('key1')).toBe('value1');

// Set item
storage.setItem('key2', 'value2');
expect(storage.getItem('key2')).toBe('value2');
expect(storage.length).toBe(2);

// Remove item
storage.removeItem('key1');
expect(storage.getItem('key1')).toBe(null);
expect(storage.length).toBe(1);

// Until `save` is called, changes should not be saved/persisted
await storage.whenSaved();
expect(wrapper.setItem).not.toHaveBeenCalled();

storage.setItem('.till', '1');
expect(storage.length).toBe(2);
expect(storage.key(0)).toBe('key2');
expect(storage.key(1)).toBe('.till');

// When `save` is called, changes should be saved/persisted immediately
storage.save();
await storage.whenSaved();
expect(wrapper.setItem).toHaveBeenCalledWith('prefix', JSON.stringify({ key2: 'value2', '.till': '1' }));

expect(wrapper.setItem).toHaveBeenCalledTimes(1);

await storage.whenSaved();
expect(wrapper.setItem).toHaveBeenCalledTimes(1);
});
75 changes: 38 additions & 37 deletions src/storages/inLocalStorage/__tests__/validateCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ import { SplitsCacheInLocal } from '../SplitsCacheInLocal';
import { nearlyEqual } from '../../../__tests__/testUtils';
import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal';
import { RBSegmentsCacheInLocal } from '../RBSegmentsCacheInLocal';
import { storages, PREFIX } from './wrapper.mock';

const FULL_SETTINGS_HASH = 'dc1f9817';

describe('validateCache', () => {
const keys = new KeyBuilderCS('SPLITIO', 'user');
describe.each(storages)('validateCache', (storage) => {
const keys = new KeyBuilderCS(PREFIX, 'user');
const logSpy = jest.spyOn(fullSettings.log, 'info');
const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage);
const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, localStorage);
const splits = new SplitsCacheInLocal(fullSettings, keys, localStorage);
const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, localStorage);
const segments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage);
const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys, storage);
const splits = new SplitsCacheInLocal(fullSettings, keys, storage);
const rbSegments = new RBSegmentsCacheInLocal(fullSettings, keys, storage);

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

beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
for (let i = 0; i < storage.length; i++) storage.removeItem(storage.key(i) as string);
});

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

expect(logSpy).not.toHaveBeenCalled();

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

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

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

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

expect(logSpy).not.toHaveBeenCalled();

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

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

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

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

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

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

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

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

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

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

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

expect(localStorage.getItem(keys.buildHashKey())).toBe('45c6ba5d');
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
expect(storage.getItem(keys.buildHashKey())).toBe('45c6ba5d');
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

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

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

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

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

expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
const lastClear = localStorage.getItem(keys.buildLastClear());
expect(storage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
const lastClear = storage.getItem(keys.buildLastClear());
expect(nearlyEqual(parseInt(lastClear as string), Date.now())).toBe(true);

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

// If a day has passed, it should clear again
localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
expect(await validateCache({ clearOnInit: true }, localStorage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
expect(splits.clear).toHaveBeenCalledTimes(2);
expect(rbSegments.clear).toHaveBeenCalledTimes(2);
expect(segments.clear).toHaveBeenCalledTimes(2);
expect(largeSegments.clear).toHaveBeenCalledTimes(2);
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});
});
27 changes: 27 additions & 0 deletions src/storages/inLocalStorage/__tests__/wrapper.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { storageAdapter } from '../storageAdapter';
import SplitIO from '../../../../types/splitio';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';

export const PREFIX = 'SPLITIO';

export function createMemoryStorage(): SplitIO.AsyncStorageWrapper {
let cache: Record<string, string> = {};
return {
getItem(key: string) {
return Promise.resolve(cache[key] || null);
},
setItem(key: string, value: string) {
cache[key] = value;
return Promise.resolve();
},
removeItem(key: string) {
delete cache[key];
return Promise.resolve();
}
};
}

export const storages = [
localStorage,
storageAdapter(loggerMock, PREFIX, createMemoryStorage())
];
20 changes: 15 additions & 5 deletions src/storages/inLocalStorage/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ImpressionsCacheInMemory } from '../inMemory/ImpressionsCacheInMemory';
import { ImpressionCountsCacheInMemory } from '../inMemory/ImpressionCountsCacheInMemory';
import { EventsCacheInMemory } from '../inMemory/EventsCacheInMemory';
import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory } from '../types';
import { IStorageFactoryParams, IStorageSync, IStorageSyncFactory, StorageAdapter } from '../types';
import { validatePrefix } from '../KeyBuilder';
import { KeyBuilderCS, myLargeSegmentsKeyBuilder } from '../KeyBuilderCS';
import { isLocalStorageAvailable } from '../../utils/env/isLocalStorageAvailable';
import { isLocalStorageAvailable, isValidStorageWrapper } from '../../utils/env/isLocalStorageAvailable';
import { SplitsCacheInLocal } from './SplitsCacheInLocal';
import { RBSegmentsCacheInLocal } from './RBSegmentsCacheInLocal';
import { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal';
Expand All @@ -17,8 +17,14 @@ import { getMatching } from '../../utils/key';
import { validateCache } from './validateCache';
import { ILogger } from '../../logger/types';
import SplitIO from '../../../types/splitio';
import { storageAdapter } from './storageAdapter';

function validateStorage(log: ILogger, prefix: string, wrapper?: SplitIO.SyncStorageWrapper | SplitIO.AsyncStorageWrapper): StorageAdapter | undefined {
if (wrapper) {
if (isValidStorageWrapper(wrapper)) return storageAdapter(log, prefix, wrapper);
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');
Expand All @@ -34,7 +40,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);
const storage = validateStorage(log, prefix, options.wrapper);
if (!storage) return InMemoryStorageCSFactory(params);

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

save() {
return storage.save && storage.save();
},

destroy() {
return Promise.resolve();
return storage.whenSaved && storage.whenSaved();
},

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