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.
This commit is contained in:
Claude
2026-01-13 17:16:26 +00:00
committed by Samuel Newman
parent 8bb99bebc5
commit de8d8c6ee0
5 changed files with 118 additions and 90 deletions
+2 -2
View File
@@ -6,7 +6,6 @@ import {
AtpAgent, AtpAgent,
getAgeAssuranceRegionConfig, getAgeAssuranceRegionConfig,
} from '@atproto/api' } from '@atproto/api'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client' import {persistQueryClient} from '@tanstack/react-query-persist-client'
@@ -14,6 +13,7 @@ import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import { import {
hasSnoozedBirthdateUpdateForDid, hasSnoozedBirthdateUpdateForDid,
@@ -45,7 +45,7 @@ const qc = new QueryClient({
}, },
}) })
const persister = createAsyncStoragePersister({ const persister = createAsyncStoragePersister({
storage: AsyncStorage, storage: createPersistedQueryStorage('age_assurance'),
key: 'age-assurance-query-client', key: 'age-assurance-query-client',
}) })
const [, cacheHydrationPromise] = persistQueryClient({ const [, cacheHydrationPromise] = persistQueryClient({
@@ -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', () => { describe('createPersistedQueryStorage', () => {
beforeEach(async () => { it('should create isolated storage instances', async () => {
// Clear storage between tests const storage1 = createPersistedQueryStorage('store1')
await persistedQueryStorage.removeItem('test-key') 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 () => { describe('storage operations', () => {
const result = await persistedQueryStorage.getItem('non-existent-key') let storage: ReturnType<typeof createPersistedQueryStorage>
expect(result).toBeNull()
})
it('should store and retrieve a value', async () => { beforeEach(() => {
const testValue = JSON.stringify({data: 'test'}) storage = createPersistedQueryStorage('test_store')
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(),
}) })
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 () => { it('should return null for non-existent keys', async () => {
await persistedQueryStorage.setItem('test-key', 'value1') const result = await storage.getItem('non-existent-key')
await persistedQueryStorage.setItem('test-key', 'value2') expect(result).toBeNull()
const result = await persistedQueryStorage.getItem('test-key') })
expect(result).toBe('value2')
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')
})
}) })
}) })
+18 -13
View File
@@ -15,20 +15,25 @@ export interface PersistedQueryStorage {
removeItem: (key: string) => Promise<void> removeItem: (key: string) => Promise<void>
} }
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. * 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 = { export function createPersistedQueryStorage(id: string): PersistedQueryStorage {
getItem: async (key: string): Promise<string | null> => { const mmkv = new MMKV({id})
return mmkv.getString(key) ?? null
}, return {
setItem: async (key: string, value: string): Promise<void> => { getItem: async (key: string): Promise<string | null> => {
mmkv.set(key, value) return mmkv.getString(key) ?? null
}, },
removeItem: async (key: string): Promise<void> => { setItem: async (key: string, value: string): Promise<void> => {
mmkv.delete(key) mmkv.set(key, value)
}, },
removeItem: async (key: string): Promise<void> => {
mmkv.delete(key)
},
}
} }
+40 -32
View File
@@ -22,53 +22,61 @@ interface PersistedQueryDB extends DBSchema {
} }
} }
const DB_NAME = 'bsky_persisted_queries'
const DB_VERSION = 1 const DB_VERSION = 1
const STORE_NAME = 'queries' const STORE_NAME = 'queries'
let dbPromise: Promise<IDBPDatabase<PersistedQueryDB>> | null = null const dbCache = new Map<string, Promise<IDBPDatabase<PersistedQueryDB>>>()
function getDB(): Promise<IDBPDatabase<PersistedQueryDB>> { function getDB(dbName: string): Promise<IDBPDatabase<PersistedQueryDB>> {
let dbPromise = dbCache.get(dbName)
if (!dbPromise) { if (!dbPromise) {
dbPromise = openDB<PersistedQueryDB>(DB_NAME, DB_VERSION, { dbPromise = openDB<PersistedQueryDB>(dbName, DB_VERSION, {
upgrade(db) { upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) { if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME) db.createObjectStore(STORE_NAME)
} }
}, },
}) })
dbCache.set(dbName, dbPromise)
} }
return 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 = { export function createPersistedQueryStorage(id: string): PersistedQueryStorage {
getItem: async (key: string): Promise<string | null> => { const dbName = `bsky_${id}`
try {
const db = await getDB() return {
const value = await db.get(STORE_NAME, key) getItem: async (key: string): Promise<string | null> => {
return value ?? null try {
} catch (e) { const db = await getDB(dbName)
console.error('Failed to get item from IndexedDB:', e) const value = await db.get(STORE_NAME, key)
return null return value ?? null
} } catch (e) {
}, console.error('Failed to get item from IndexedDB:', e)
setItem: async (key: string, value: string): Promise<void> => { return null
try { }
const db = await getDB() },
await db.put(STORE_NAME, value, key) setItem: async (key: string, value: string): Promise<void> => {
} catch (e) { try {
console.error('Failed to set item in IndexedDB:', e) const db = await getDB(dbName)
} await db.put(STORE_NAME, value, key)
}, } catch (e) {
removeItem: async (key: string): Promise<void> => { console.error('Failed to set item in IndexedDB:', e)
try { }
const db = await getDB() },
await db.delete(STORE_NAME, key) removeItem: async (key: string): Promise<void> => {
} catch (e) { try {
console.error('Failed to remove item from IndexedDB:', e) const db = await getDB(dbName)
} await db.delete(STORE_NAME, key)
}, } catch (e) {
console.error('Failed to remove item from IndexedDB:', e)
}
},
}
} }
+3 -2
View File
@@ -8,7 +8,7 @@ import {
} from '@tanstack/react-query-persist-client' } from '@tanstack/react-query-persist-client'
import {SuperJSON} from 'superjson' import {SuperJSON} from 'superjson'
import {persistedQueryStorage} from '#/lib/persisted-query-storage' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
@@ -181,8 +181,9 @@ function QueryProviderInner({
// Do not move the query client creation outside of this component. // Do not move the query client creation outside of this component.
const [queryClient, _setQueryClient] = useState(() => createQueryClient()) const [queryClient, _setQueryClient] = useState(() => createQueryClient())
const [persistOptions, _setPersistOptions] = useState(() => { const [persistOptions, _setPersistOptions] = useState(() => {
const storage = createPersistedQueryStorage('persisted_queries')
const asyncPersister = createAsyncStoragePersister({ const asyncPersister = createAsyncStoragePersister({
storage: persistedQueryStorage, storage,
key: key:
'queryClient-' + (currentDid ?? 'logged-out') + `-v${PERSIST_VERSION}`, 'queryClient-' + (currentDid ?? 'logged-out') + `-v${PERSIST_VERSION}`,
serialize: SuperJSON.stringify, serialize: SuperJSON.stringify,