Skip to content
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

1931 local storage migration #1996

Merged
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a17f510
(WIP) migration hook
Da-Colon Jun 3, 2024
bb3ab2f
migrate only favorites;
Da-Colon Jun 10, 2024
774ddab
remove old cache keys
Da-Colon Jun 12, 2024
5f1d82e
extract to a testable function;
Da-Colon Jun 13, 2024
2ef4685
add unit test for migration func
Da-Colon Jun 13, 2024
382d53f
target `fract_` more precisely
Da-Colon Jun 13, 2024
fd4e1d8
run pretty/lint
Da-Colon Jun 13, 2024
e9d7792
rename -> useMigrate
Da-Colon Jun 13, 2024
cc402b3
add migration version
Da-Colon Jun 13, 2024
62e37d9
remove old conditional for SSR
Da-Colon Jun 14, 2024
6a710b2
set new migration version to track migrations
Da-Colon Jun 14, 2024
d148f04
remove mocking and fix tests for checking migration number
Da-Colon Jun 14, 2024
92bc295
run pretty/lint
Da-Colon Jun 14, 2024
2499cf5
move cache version check to useEffect
Da-Colon Jun 14, 2024
04a80e4
Infinity migrations
adamgall Jun 14, 2024
6e81487
fix tests
Da-Colon Jun 14, 2024
8a21e7e
add logger
Da-Colon Jun 14, 2024
c78e39e
rename test
Da-Colon Jun 14, 2024
1d4dec8
add new runMigration test
Da-Colon Jun 14, 2024
fc0f1b9
run pretty/lint
Da-Colon Jun 14, 2024
3d08bcc
remove hardcoded migration version
Da-Colon Jun 15, 2024
2783209
run pretty/lint
Da-Colon Jun 15, 2024
06fc419
move import into try/catch
Da-Colon Jun 16, 2024
f866df0
remove use of `fs` API
Da-Colon Jun 16, 2024
6fb2f02
update test for malformed filenames
Da-Colon Jun 16, 2024
8843b83
properly set version number based index of loop
Da-Colon Jun 17, 2024
242361f
add new test for success migration update.
Da-Colon Jun 17, 2024
492ebff
can delete
Da-Colon Jun 17, 2024
3b9191c
remove this early exit
Da-Colon Jun 17, 2024
82d3cf4
early exit if migration is count is same as actualCacheVersion
Da-Colon Jun 17, 2024
18a47e4
add new expect to test before test is run
Da-Colon Jun 17, 2024
b495b31
add never expire param
Da-Colon Jun 17, 2024
d9307e5
run pretty/lint
Da-Colon Jun 17, 2024
5626da1
Small code tidying up
adamgall Jun 17, 2024
d2e616e
add file extension
Da-Colon Jun 17, 2024
3cdce28
use glob import directly
Da-Colon Jun 17, 2024
5c10fa7
update tests for new setup
Da-Colon Jun 17, 2024
9bb0ff2
run pretty/lint
Da-Colon Jun 17, 2024
367dbd6
remove console log
Da-Colon Jun 17, 2024
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
13 changes: 2 additions & 11 deletions src/hooks/utils/cache/cacheDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,12 @@ export enum CacheKeys {
MASTER_COPY = 'Master Copy',
AVERAGE_BLOCK_TIME = 'Average Block Time',
PROPOSAL_CACHE = 'Proposal',
MIGRATION = 'Migration',
// indexDB keys
DECODED_TRANSACTION_PREFIX = 'decode_trans_',
MULTISIG_METADATA_PREFIX = 'm_m_',
}

export enum CacheKeysV0 {
FAVORITES = 'favorites',
MASTER_COPY_PREFIX = 'master_copy_of_',
// wasn't a originally part of the cache keys but was used
AVERAGE_BLOCK_TIME = 'averageBlockTime',
// these were not used for local storage
// DECODED_TRANSACTION_PREFIX = 'decode_trans_',
// MULTISIG_METADATA_PREFIX = 'm_m_',
PROPOSAL_PREFIX = 'proposal',
}

export type CacheKey = {
cacheName: CacheKeys;
version: number;
Expand Down Expand Up @@ -90,6 +80,7 @@ type CacheKeyToValueMap = {
[CacheKeys.MASTER_COPY]: Address;
[CacheKeys.PROPOSAL_CACHE]: AzoriusProposal;
[CacheKeys.AVERAGE_BLOCK_TIME]: number;
[CacheKeys.MIGRATION]: number;
};

export type CacheValueType<T extends CacheKeyType> = T extends { cacheName: infer U }
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/utils/cache/migrations/1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { addNetworkPrefix } from '../../../../utils/url';
// This should be a temporary hook to migrate the old local storage to the new one
// and should be removed after a few months

import { CacheKeys } from '../cacheDefaults';
import { setValue } from '../useLocalStorage';

//@dev for testing seperated the function from the hook and export
export default function v1MigrateFavorites() {
// Migrate old cache keys to new format
const keys = Object.keys(localStorage);
const fractKeys = keys.filter(key => key.startsWith('fract_'));
if (!fractKeys.length) {
return;
}
// Get All Network Favorites
const favoritesCache = fractKeys.filter(key => key.endsWith('favorites'));
const newFavorites: string[] = [];

// loop through all favorites
for (const favorite of favoritesCache) {
// Get ChainId from favorite key
const [, chainId] = favorite.split('_');
if (Number.isNaN(Number(chainId))) {
continue;
}
const favoritesValue = localStorage.getItem(favorite);
if (favoritesValue) {
// Parse favorites value and add network prefix
const parsedValue: { v: string[] } = JSON.parse(favoritesValue);
parsedValue.v.forEach((value: string) => {
newFavorites.push(addNetworkPrefix(value, Number(chainId)));
});
}
}
if (newFavorites.length) {
// Set new Favorites cache
setValue({ cacheName: CacheKeys.FAVORITES }, newFavorites);
}

// delete old cache
fractKeys.forEach(key => {
localStorage.removeItem(key);
});
}
45 changes: 20 additions & 25 deletions src/hooks/utils/cache/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,31 @@ export const setValue = (
value: any,
expirationMinutes: number = CacheExpiry.ONE_WEEK,
): void => {
if (typeof window !== 'undefined') {
const val: CacheValue = {
v: value,
e:
expirationMinutes === CacheExpiry.NEVER
? CacheExpiry.NEVER
: Date.now() + expirationMinutes * 60000,
};
localStorage.setItem(
JSON.stringify({ ...key, version: CACHE_VERSIONS[key.cacheName] }),
JSON.stringify(val, bigintReplacer),
);
}
const val: CacheValue = {
v: value,
e:
expirationMinutes === CacheExpiry.NEVER
? CacheExpiry.NEVER
: Date.now() + expirationMinutes * 60000,
};
localStorage.setItem(
JSON.stringify({ ...key, version: CACHE_VERSIONS[key.cacheName] }),
JSON.stringify(val, bigintReplacer),
);
};

export const getValue = <T extends CacheKeyType>(key: T): CacheValueType<T> | null => {
if (typeof window !== 'undefined') {
const version = CACHE_VERSIONS[key.cacheName];
const rawVal = localStorage.getItem(JSON.stringify({ ...key, version }));
if (rawVal) {
const parsed: CacheValue = JSON.parse(rawVal, proposalObjectReviver);
if (parsed.e === CacheExpiry.NEVER || parsed.e >= Date.now()) {
return parsed.v as CacheValueType<T>;
} else {
localStorage.removeItem(JSON.stringify({ ...key, version }));
return null;
}
const version = CACHE_VERSIONS[key.cacheName];
const rawVal = localStorage.getItem(JSON.stringify({ ...key, version }));
if (rawVal) {
const parsed: CacheValue = JSON.parse(rawVal, proposalObjectReviver);
if (parsed.e === CacheExpiry.NEVER || parsed.e >= Date.now()) {
return parsed.v as CacheValueType<T>;
} else {
localStorage.removeItem(JSON.stringify({ ...key, version }));
return null;
}
} else {
return null;
}
return null;
};
39 changes: 39 additions & 0 deletions src/hooks/utils/cache/useMigrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import { logError } from '../../../helpers/errorLogging';
import { CacheKeys } from './cacheDefaults';
import { getValue, setValue } from './useLocalStorage';

const migrations = import.meta.glob('./migrations/*');

export const runMigrations = async (
// @dev import.meta.glob can not be mocked in tests, so we pass the count as an argument
migrationCount: number = Object.keys(migrations).length,
) => {
const cacheVersion = getValue({ cacheName: CacheKeys.MIGRATION });

const actualCacheVersion = cacheVersion || 0;
let newVersion = actualCacheVersion;
// loop through each pending migration and run in turn
for (let i = actualCacheVersion + 1; i <= migrationCount; i++) {
try {
const migration: { default: () => void } = await import(`./migrations/${i}`);
migration.default();
newVersion = i;
} catch (e) {
logError(e);
newVersion = i - 1;
}
}
setValue({ cacheName: CacheKeys.MIGRATION }, newVersion);
};

export const useMigrate = () => {
const isMounted = useRef(false);

useEffect(() => {
// prevent multiple calls
if (isMounted.current) return;
runMigrations();
isMounted.current = true;
}, []);
};
2 changes: 2 additions & 0 deletions src/providers/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import { theme } from '../assets/theme';
import { ErrorBoundary } from '../components/ui/utils/ErrorBoundary';
import { TopErrorFallback } from '../components/ui/utils/TopErrorFallback';
import graphQLClient from '../graphql';
import { useMigrate } from '../hooks/utils/cache/useMigrate';
import { AppProvider } from './App/AppProvider';
import EthersContextProvider from './Ethers';
import { NetworkConfigProvider } from './NetworkConfig/NetworkConfigProvider';
import { wagmiConfig, queryClient } from './NetworkConfig/web3-modal.config';

export default function Providers({ children }: { children: ReactNode }) {
useMigrate();
return (
<ChakraProvider
theme={theme}
Expand Down
161 changes: 161 additions & 0 deletions test/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as logging from '../src/helpers/errorLogging';
import { CacheKeys } from '../src/hooks/utils/cache/cacheDefaults';
import migrateCacheToV1 from '../src/hooks/utils/cache/migrations/1';
import { getValue } from '../src/hooks/utils/cache/useLocalStorage';
import { runMigrations } from '../src/hooks/utils/cache/useMigrate';

describe('func migrateCacheToV1', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it('should correctly migrate a single favorite', () => {
const oldKey = 'fract_11155111_favorites';
const oldValue = JSON.stringify({
v: ['0xd418E98a11B9189fCc05cddfbB10F4Cee996C749'],
e: -1,
});
localStorage.setItem(oldKey, oldValue);

const expectedNewValue = ['sep:0xd418E98a11B9189fCc05cddfbB10F4Cee996C749'];

migrateCacheToV1();
const favoriteCache = getValue({ cacheName: CacheKeys.FAVORITES });
if (!favoriteCache) {
throw new Error('Favorites cache not found');
}
expect(favoriteCache).toStrictEqual(expectedNewValue);
expect(localStorage.getItem(oldKey)).toBeNull();
});

it('should correctly migrate multiple favorites from different networks', () => {
const oldKey1 = 'fract_11155111_favorites';
const oldValue1 = JSON.stringify({
v: ['0xd418E98a11B9189fCc05cddfbB10F4Cee996C749'],
e: -1,
});

const oldKey2 = 'fract_1_favorites';
const oldValue2 = JSON.stringify({
v: ['0xabcdE98a11B9189fCc05cddfbB10F4Cee996C999'],
e: -1,
});

localStorage.setItem(oldKey1, oldValue1);
localStorage.setItem(oldKey2, oldValue2);

const expectedNewValue = [
'sep:0xd418E98a11B9189fCc05cddfbB10F4Cee996C749',
'eth:0xabcdE98a11B9189fCc05cddfbB10F4Cee996C999',
];

migrateCacheToV1();
const favoriteCache = getValue({ cacheName: CacheKeys.FAVORITES });
if (!favoriteCache) {
throw new Error('Favorites cache not found');
}
expect(favoriteCache).toStrictEqual(expectedNewValue);
expect(localStorage.getItem(oldKey1)).toBeNull();
expect(localStorage.getItem(oldKey2)).toBeNull();
});

it('should handle an empty localStorage without errors', () => {
migrateCacheToV1();
});

it('should handle localStorage without relevant keys', () => {
localStorage.setItem('unrelatedKey', 'someValue');
migrateCacheToV1();

expect(localStorage.getItem('unrelatedKey')).toBe('someValue');
});

it('should handle favorites without chain IDs correctly', () => {
const oldKey = 'fract_favorites';
const oldValue = JSON.stringify({
v: ['0x1234abcd5678efgh91011ijklmno1234'],
e: -1,
});
localStorage.setItem(oldKey, oldValue);

migrateCacheToV1();

expect(localStorage.getItem(oldKey)).toBeNull();
});
});

describe('func runMigrations (gap imports)', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.resetModules();
localStorage.clear();
vi.spyOn(logging, 'logError').mockImplementation(() => {});
vi.doMock('./migrations/1', () => ({ default: vi.fn() }));
vi.doMock('./migrations/2', () => ({ default: vi.fn() }));
vi.doMock('./migrations/4', () => ({ default: vi.fn() }));
});

it('should stop migration at first gap and log an error', async () => {
await runMigrations(3);
expect(logging.logError).toHaveBeenCalledOnce();
const migrationCache = getValue({ cacheName: CacheKeys.MIGRATION });
if (!migrationCache) {
throw new Error('Migration cache not found');
}
expect(migrationCache).toBe(2);
});

afterEach(() => {
vi.restoreAllMocks();
});
});

describe('func runMigrations (invalid filename)', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.resetModules();
localStorage.clear();
vi.spyOn(logging, 'logError').mockImplementation(() => {});
vi.doMock('./migrations/1', () => ({ default: vi.fn() }));
vi.doMock('./migrations/2', () => ({ default: vi.fn() }));
vi.doMock('./migrations/foo', () => ({ default: vi.fn() }));
});

it('should stop migration at malformed file name and log an error', async () => {
await runMigrations(3);
expect(logging.logError).toHaveBeenCalledOnce();
const migrationCache = getValue({ cacheName: CacheKeys.MIGRATION });
if (!migrationCache) {
throw new Error('Migration cache not found');
}
expect(migrationCache).toBe(2);
});

afterEach(() => {
vi.restoreAllMocks();
});
});

describe('func runMigrations (successfully migrate to lastest)', () => {
beforeEach(() => {
vi.resetAllMocks();
localStorage.clear();
vi.doMock('./migrations/1', () => ({ default: () => {} }));
vi.doMock('./migrations/2', () => ({ default: () => {} }));
vi.doMock('./migrations/3', () => ({ default: () => {} }));
});

it('should successfully migrate to the latest version', async () => {
await runMigrations(3);
const migrationCache = getValue({ cacheName: CacheKeys.MIGRATION });
if (!migrationCache) {
throw new Error('Migration cache not found');
}
expect(migrationCache).toBe(3);
});

afterEach(() => {
vi.restoreAllMocks();
});
});