From de8d8c6ee0dc3506b9bf49999a671f479d3e7dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 17:16:26 +0000 Subject: [PATCH] Refactor storage abstraction to use factory pattern Change createPersistedQueryStorage to a factory function that accepts a storage ID, allowing multiple isolated storage instances: - Native: Each instance gets its own MMKV store - Web: Each instance gets its own IndexedDB database Adopt the factory pattern in: - react-query.tsx: Uses 'persisted_queries' storage - ageAssurance/data.tsx: Uses 'age_assurance' storage This provides better separation between different query client caches and allows each to be managed independently. --- src/ageAssurance/data.tsx | 4 +- .../__tests__/persisted-query-storage.test.ts | 96 +++++++++++-------- src/lib/persisted-query-storage.ts | 31 +++--- src/lib/persisted-query-storage.web.ts | 72 +++++++------- src/lib/react-query.tsx | 5 +- 5 files changed, 118 insertions(+), 90 deletions(-) diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index f6353a899b..ad431e6c9b 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -6,7 +6,6 @@ import { AtpAgent, getAgeAssuranceRegionConfig, } from '@atproto/api' -import AsyncStorage from '@react-native-async-storage/async-storage' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {persistQueryClient} from '@tanstack/react-query-persist-client' @@ -14,6 +13,7 @@ import debounce from 'lodash.debounce' import {networkRetry} from '#/lib/async/retry' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {getAge} from '#/lib/strings/time' import { hasSnoozedBirthdateUpdateForDid, @@ -45,7 +45,7 @@ const qc = new QueryClient({ }, }) const persister = createAsyncStoragePersister({ - storage: AsyncStorage, + storage: createPersistedQueryStorage('age_assurance'), key: 'age-assurance-query-client', }) const [, cacheHydrationPromise] = persistQueryClient({ diff --git a/src/lib/__tests__/persisted-query-storage.test.ts b/src/lib/__tests__/persisted-query-storage.test.ts index 1943af30e3..fe6a61884b 100644 --- a/src/lib/__tests__/persisted-query-storage.test.ts +++ b/src/lib/__tests__/persisted-query-storage.test.ts @@ -22,52 +22,66 @@ jest.mock('@bsky.app/react-native-mmkv', () => ({ }, })) -import {persistedQueryStorage} from '../persisted-query-storage' +import {createPersistedQueryStorage} from '../persisted-query-storage' -describe('persistedQueryStorage', () => { - beforeEach(async () => { - // Clear storage between tests - await persistedQueryStorage.removeItem('test-key') +describe('createPersistedQueryStorage', () => { + it('should create isolated storage instances', async () => { + const storage1 = createPersistedQueryStorage('store1') + const storage2 = createPersistedQueryStorage('store2') + + await storage1.setItem('key', 'value1') + await storage2.setItem('key', 'value2') + + expect(await storage1.getItem('key')).toBe('value1') + expect(await storage2.getItem('key')).toBe('value2') }) - it('should return null for non-existent keys', async () => { - const result = await persistedQueryStorage.getItem('non-existent-key') - expect(result).toBeNull() - }) + describe('storage operations', () => { + let storage: ReturnType - it('should store and retrieve a value', async () => { - const testValue = JSON.stringify({data: 'test'}) - await persistedQueryStorage.setItem('test-key', testValue) - const result = await persistedQueryStorage.getItem('test-key') - expect(result).toBe(testValue) - }) - - it('should remove a value', async () => { - const testValue = JSON.stringify({data: 'test'}) - await persistedQueryStorage.setItem('test-key', testValue) - await persistedQueryStorage.removeItem('test-key') - const result = await persistedQueryStorage.getItem('test-key') - expect(result).toBeNull() - }) - - it('should handle complex JSON data', async () => { - const complexData = JSON.stringify({ - queries: [ - {key: 'query1', data: {nested: {value: 123}}}, - {key: 'query2', data: {array: [1, 2, 3]}}, - ], - timestamp: Date.now(), + beforeEach(() => { + storage = createPersistedQueryStorage('test_store') }) - await persistedQueryStorage.setItem('complex-key', complexData) - const result = await persistedQueryStorage.getItem('complex-key') - expect(result).toBe(complexData) - expect(JSON.parse(result!)).toEqual(JSON.parse(complexData)) - }) - it('should overwrite existing values', async () => { - await persistedQueryStorage.setItem('test-key', 'value1') - await persistedQueryStorage.setItem('test-key', 'value2') - const result = await persistedQueryStorage.getItem('test-key') - expect(result).toBe('value2') + it('should return null for non-existent keys', async () => { + const result = await storage.getItem('non-existent-key') + expect(result).toBeNull() + }) + + it('should store and retrieve a value', async () => { + const testValue = JSON.stringify({data: 'test'}) + await storage.setItem('test-key', testValue) + const result = await storage.getItem('test-key') + expect(result).toBe(testValue) + }) + + it('should remove a value', async () => { + const testValue = JSON.stringify({data: 'test'}) + await storage.setItem('test-key', testValue) + await storage.removeItem('test-key') + const result = await storage.getItem('test-key') + expect(result).toBeNull() + }) + + it('should handle complex JSON data', async () => { + const complexData = JSON.stringify({ + queries: [ + {key: 'query1', data: {nested: {value: 123}}}, + {key: 'query2', data: {array: [1, 2, 3]}}, + ], + timestamp: Date.now(), + }) + await storage.setItem('complex-key', complexData) + const result = await storage.getItem('complex-key') + expect(result).toBe(complexData) + expect(JSON.parse(result!)).toEqual(JSON.parse(complexData)) + }) + + it('should overwrite existing values', async () => { + await storage.setItem('test-key', 'value1') + await storage.setItem('test-key', 'value2') + const result = await storage.getItem('test-key') + expect(result).toBe('value2') + }) }) }) diff --git a/src/lib/persisted-query-storage.ts b/src/lib/persisted-query-storage.ts index eec81a60c3..916f6d17df 100644 --- a/src/lib/persisted-query-storage.ts +++ b/src/lib/persisted-query-storage.ts @@ -15,20 +15,25 @@ export interface PersistedQueryStorage { removeItem: (key: string) => Promise } -const mmkv = new MMKV({id: 'bsky_persisted_queries'}) - /** - * MMKV-based storage adapter for persisting react-query cache on native platforms. + * Creates an MMKV-based storage adapter for persisting react-query cache on native platforms. + * Each storage instance uses a separate MMKV store identified by the provided id. * MMKV provides synchronous access but we wrap it in Promises for API compatibility. + * + * @param id - Unique identifier for this storage instance (used as MMKV store id) */ -export const persistedQueryStorage: PersistedQueryStorage = { - getItem: async (key: string): Promise => { - return mmkv.getString(key) ?? null - }, - setItem: async (key: string, value: string): Promise => { - mmkv.set(key, value) - }, - removeItem: async (key: string): Promise => { - mmkv.delete(key) - }, +export function createPersistedQueryStorage(id: string): PersistedQueryStorage { + const mmkv = new MMKV({id}) + + return { + getItem: async (key: string): Promise => { + return mmkv.getString(key) ?? null + }, + setItem: async (key: string, value: string): Promise => { + mmkv.set(key, value) + }, + removeItem: async (key: string): Promise => { + mmkv.delete(key) + }, + } } diff --git a/src/lib/persisted-query-storage.web.ts b/src/lib/persisted-query-storage.web.ts index 90b093bd0d..520d867167 100644 --- a/src/lib/persisted-query-storage.web.ts +++ b/src/lib/persisted-query-storage.web.ts @@ -22,53 +22,61 @@ interface PersistedQueryDB extends DBSchema { } } -const DB_NAME = 'bsky_persisted_queries' const DB_VERSION = 1 const STORE_NAME = 'queries' -let dbPromise: Promise> | null = null +const dbCache = new Map>>() -function getDB(): Promise> { +function getDB(dbName: string): Promise> { + let dbPromise = dbCache.get(dbName) if (!dbPromise) { - dbPromise = openDB(DB_NAME, DB_VERSION, { + dbPromise = openDB(dbName, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME) } }, }) + dbCache.set(dbName, dbPromise) } return dbPromise } /** - * IndexedDB-based storage adapter for persisting react-query cache on web platforms. + * Creates an IndexedDB-based storage adapter for persisting react-query cache on web platforms. + * Each storage instance uses a separate IndexedDB database identified by the provided id. + * + * @param id - Unique identifier for this storage instance (used as database name) */ -export const persistedQueryStorage: PersistedQueryStorage = { - getItem: async (key: string): Promise => { - try { - const db = await getDB() - const value = await db.get(STORE_NAME, key) - return value ?? null - } catch (e) { - console.error('Failed to get item from IndexedDB:', e) - return null - } - }, - setItem: async (key: string, value: string): Promise => { - try { - const db = await getDB() - await db.put(STORE_NAME, value, key) - } catch (e) { - console.error('Failed to set item in IndexedDB:', e) - } - }, - removeItem: async (key: string): Promise => { - try { - const db = await getDB() - await db.delete(STORE_NAME, key) - } catch (e) { - console.error('Failed to remove item from IndexedDB:', e) - } - }, +export function createPersistedQueryStorage(id: string): PersistedQueryStorage { + const dbName = `bsky_${id}` + + return { + getItem: async (key: string): Promise => { + try { + const db = await getDB(dbName) + const value = await db.get(STORE_NAME, key) + return value ?? null + } catch (e) { + console.error('Failed to get item from IndexedDB:', e) + return null + } + }, + setItem: async (key: string, value: string): Promise => { + try { + const db = await getDB(dbName) + await db.put(STORE_NAME, value, key) + } catch (e) { + console.error('Failed to set item in IndexedDB:', e) + } + }, + removeItem: async (key: string): Promise => { + try { + const db = await getDB(dbName) + await db.delete(STORE_NAME, key) + } catch (e) { + console.error('Failed to remove item from IndexedDB:', e) + } + }, + } } diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index 43a61ef8c5..3ad7b7525a 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -8,7 +8,7 @@ import { } from '@tanstack/react-query-persist-client' import {SuperJSON} from 'superjson' -import {persistedQueryStorage} from '#/lib/persisted-query-storage' +import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {isNative} from '#/platform/detection' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' @@ -181,8 +181,9 @@ function QueryProviderInner({ // Do not move the query client creation outside of this component. const [queryClient, _setQueryClient] = useState(() => createQueryClient()) const [persistOptions, _setPersistOptions] = useState(() => { + const storage = createPersistedQueryStorage('persisted_queries') const asyncPersister = createAsyncStoragePersister({ - storage: persistedQueryStorage, + storage, key: 'queryClient-' + (currentDid ?? 'logged-out') + `-v${PERSIST_VERSION}`, serialize: SuperJSON.stringify,