Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"jszip": "^3.10.1",
"jszip-utils": "^0.1.0",
"keycode": "^2.2.1",
"localforage": "1.10.0",
"lodash": "^4.17.21",
"mathjs": "7.6.0",
"microsoft-cognitiveservices-speech-sdk": "^1.43.0",
Expand Down
106 changes: 104 additions & 2 deletions src/__test__/reducers.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,109 @@
import createReducer from '../reducers';
import createReducer, { createMigratingStorage } from '../reducers';

describe('reducers', () => {
it('should create Reducer', () => {
const red = createReducer();
expect(red).toBeDefined;
expect(red).toBeDefined();
});
});

const createMockStorage = () => ({
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn()
});

describe('createMigratingStorage', () => {
let oldStorage, newStorage, storage;

beforeEach(() => {
oldStorage = createMockStorage();
newStorage = createMockStorage();
storage = createMigratingStorage(oldStorage, newStorage);
});

describe('getItem', () => {
it('returns value from new storage when it exists, without touching old storage', async () => {
newStorage.getItem.mockResolvedValue('new-data');

const result = await storage.getItem('persist:root');

expect(result).toBe('new-data');
expect(oldStorage.getItem).not.toHaveBeenCalled();
expect(oldStorage.removeItem).not.toHaveBeenCalled();
});

it('migrates from old to new storage when only old has the key', async () => {
newStorage.getItem.mockResolvedValue(null);
oldStorage.getItem.mockResolvedValue('legacy-data');
newStorage.setItem.mockResolvedValue(undefined);
oldStorage.removeItem.mockResolvedValue(undefined);

const result = await storage.getItem('persist:root');

expect(result).toBe('legacy-data');
expect(newStorage.setItem).toHaveBeenCalledWith(
'persist:root',
'legacy-data'
);
expect(oldStorage.removeItem).toHaveBeenCalledWith('persist:root');
});

it('returns null when neither storage has the key', async () => {
newStorage.getItem.mockResolvedValue(null);
oldStorage.getItem.mockResolvedValue(null);

const result = await storage.getItem('persist:root');

expect(result).toBeNull();
});

it('falls back to old storage when new storage read fails', async () => {
newStorage.getItem.mockRejectedValue(new Error('IndexedDB error'));
oldStorage.getItem.mockResolvedValue('legacy-data');
newStorage.setItem.mockResolvedValue(undefined);
oldStorage.removeItem.mockResolvedValue(undefined);

const result = await storage.getItem('persist:root');

expect(result).toBe('legacy-data');
expect(newStorage.setItem).toHaveBeenCalledWith(
'persist:root',
'legacy-data'
);
});

it('returns old value but does not remove from old storage when migration write fails', async () => {
newStorage.getItem.mockResolvedValue(null);
oldStorage.getItem.mockResolvedValue('legacy-data');
newStorage.setItem.mockRejectedValue(new Error('write failed'));

const result = await storage.getItem('persist:root');

expect(result).toBe('legacy-data');
expect(oldStorage.removeItem).not.toHaveBeenCalled();
});
});

describe('setItem', () => {
it('delegates to new storage only', async () => {
newStorage.setItem.mockResolvedValue(undefined);

await storage.setItem('persist:root', 'data');

expect(newStorage.setItem).toHaveBeenCalledWith('persist:root', 'data');
expect(oldStorage.setItem).not.toHaveBeenCalled();
});
});

describe('removeItem', () => {
it('delegates to new storage only', async () => {
newStorage.removeItem.mockResolvedValue(undefined);

await storage.removeItem('persist:root');

expect(newStorage.removeItem).toHaveBeenCalledWith('persist:root');
expect(oldStorage.removeItem).not.toHaveBeenCalled();
});
});
});
102 changes: 99 additions & 3 deletions src/reducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
createMigrate
} from 'redux-persist';

import localForage from 'localforage';
import localStorage from 'redux-persist/lib/storage';
import { appInsights } from './appInsights';

import appReducer from './components/App/App.reducer';
import languageProviderReducer from './providers/LanguageProvider/LanguageProvider.reducer';
import scannerProviderReducer from './providers/ScannerProvider/ScannerProvider.reducer';
Expand All @@ -12,9 +16,101 @@ import boardReducer from './components/Board/Board.reducer';
import communicatorReducer from './components/Communicator/Communicator.reducer';
import notificationsReducer from './components/Notifications/Notifications.reducer';
import subscriptionProviderReducer from './providers/SubscriptionProvider/SubscriptionProvider.reducer';
import storage from 'redux-persist/lib/storage';
import { DEFAULT_BOARDS } from '../src/helpers';

localForage.config({
name: 'cboard',
storeName: 'cboard_store'
});

/**
* Creates a storage wrapper that migrates data from old storage to new storage.
*
* @param {Object} oldStorage - The legacy storage engine (localStorage)
* @param {Object} newStorage - The new storage engine (localForage/IndexedDB)
* @returns {Object} A storage engine compatible with redux-persist
*/
export const createMigratingStorage = (oldStorage, newStorage) => ({
/**
* Retrieves a value from storage, migrating from old to new if necessary.
* Called by redux-persist on app initialization.
*/
async getItem(key) {
try {
const newValue = await newStorage.getItem(key);
if (newValue !== null && newValue !== undefined) {
return newValue;
}
} catch (err) {
console.warn('Cboard: IndexedDB read failed', err);
appInsights.trackException({
exception: err,
properties: { key, step: 'indexeddb_read' }
});
}

try {
const oldValue = await oldStorage.getItem(key);
if (oldValue !== null && oldValue !== undefined) {
console.log(
`Cboard: Migrating ${key} from localStorage to IndexedDB...`
);
appInsights.trackEvent({
name: 'StorageMigration_Started',
properties: { key }
});
try {
await newStorage.setItem(key, oldValue);
console.log(`Cboard: Successfully migrated ${key}`);
appInsights.trackEvent({
name: 'StorageMigration_Success',
properties: { key }
});
await oldStorage.removeItem(key);
console.log(`Cboard: Cleaned up ${key} from localStorage`);
appInsights.trackEvent({
name: 'StorageMigration_Cleanup',
properties: { key }
});
} catch (writeErr) {
console.warn('Cboard: Migration write failed', writeErr);
appInsights.trackException({
exception: writeErr,
properties: { key, step: 'migration_write' }
});
}
return oldValue;
}
} catch (err) {
console.warn('Cboard: localStorage read failed', err);
appInsights.trackException({
exception: err,
properties: { key, step: 'localstorage_read' }
});
}

return null;
},

/**
* Saves a value to storage.
* Called by redux-persist whenever Redux state changes.
*/
async setItem(key, value) {
return await newStorage.setItem(key, value);
},

/**
* Removes a value from storage.
* Called by redux-persist when purging state.
*/
async removeItem(key) {
return await newStorage.removeItem(key);
}
});

const migratingStorage = createMigratingStorage(localStorage, localForage);

const boardMigrations = {
0: state => {
return {
Expand All @@ -29,15 +125,15 @@ const boardMigrations = {

const config = {
key: 'root',
storage,
storage: migratingStorage,
blacklist: ['language'],
version: 0,
migrate: createMigrate(boardMigrations, { debug: false })
};

const languagePersistConfig = {
key: 'language',
storage: storage,
storage: migratingStorage,
blacklist: ['langsFetched']
};

Expand Down
14 changes: 14 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9613,6 +9613,13 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"

lie@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
integrity sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==
dependencies:
immediate "~3.0.5"

lie@~3.3.0:
version "3.3.0"
resolved "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz"
Expand Down Expand Up @@ -9700,6 +9707,13 @@ loader-utils@^3.2.0:
resolved "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz"
integrity sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==

localforage@^1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4"
integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==
dependencies:
lie "3.1.1"

locate-path@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz"
Expand Down
Loading