Refactor to use archival storage

(cherry picked from commit a773b40e41c96f821cd32260919ce1437c0fc3ab)
This commit is contained in:
Eric Bailey
2026-01-26 14:37:15 -06:00
committed by Samuel Newman
parent 38177bc9a6
commit ab8d14a1f0
10 changed files with 66 additions and 137 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ const qc = new QueryClient({
},
})
const persister = createAsyncStoragePersister({
storage: createPersistedQueryStorage('age_assurance'),
storage: createPersistedQueryStorage('age_assurance_cache'),
key: 'age-assurance-query-client',
})
const [, cacheHydrationPromise] = persistQueryClient({
+5 -12
View File
@@ -1,10 +1,4 @@
/**
* Persisted Query Storage - Native Implementation (MMKV)
*
* This module provides a storage abstraction for persisting react-query cache.
* On native platforms, it uses MMKV for high-performance synchronous storage.
*/
import {MMKV} from '@bsky.app/react-native-mmkv'
import {create as createArchiveDB} from '#/storage/archive/db'
/**
* Interface for async storage compatible with @tanstack/query-async-storage-persister
@@ -23,17 +17,16 @@ export interface PersistedQueryStorage {
* @param id - Unique identifier for this storage instance (used as MMKV store id)
*/
export function createPersistedQueryStorage(id: string): PersistedQueryStorage {
const mmkv = new MMKV({id})
const store = createArchiveDB({id})
return {
getItem: async (key: string): Promise<string | null> => {
return mmkv.getString(key) ?? null
return (await store.get(key)) ?? null
},
setItem: async (key: string, value: string): Promise<void> => {
mmkv.set(key, value)
await store.set(key, value)
},
removeItem: async (key: string): Promise<void> => {
mmkv.delete(key)
await store.delete(key)
},
}
}
-82
View File
@@ -1,82 +0,0 @@
/**
* Persisted Query Storage - Web Implementation (IndexedDB)
*
* This module provides a storage abstraction for persisting react-query cache.
* On web platforms, it uses IndexedDB for efficient async storage.
*/
import {type DBSchema, type IDBPDatabase, openDB} from 'idb'
/**
* Interface for async storage compatible with @tanstack/query-async-storage-persister
*/
export interface PersistedQueryStorage {
getItem: (key: string) => Promise<string | null>
setItem: (key: string, value: string) => Promise<void>
removeItem: (key: string) => Promise<void>
}
interface PersistedQueryDB extends DBSchema {
queries: {
key: string
value: string
}
}
const DB_VERSION = 1
const STORE_NAME = 'queries'
const dbCache = new Map<string, Promise<IDBPDatabase<PersistedQueryDB>>>()
function getDB(dbName: string): Promise<IDBPDatabase<PersistedQueryDB>> {
let dbPromise = dbCache.get(dbName)
if (!dbPromise) {
dbPromise = openDB<PersistedQueryDB>(dbName, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
},
})
dbCache.set(dbName, dbPromise)
}
return dbPromise
}
/**
* 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 function createPersistedQueryStorage(id: string): PersistedQueryStorage {
const dbName = `bsky_${id}`
return {
getItem: async (key: string): Promise<string | null> => {
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<void> => {
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<void> => {
try {
const db = await getDB(dbName)
await db.delete(STORE_NAME, key)
} catch (e) {
console.error('Failed to remove item from IndexedDB:', e)
}
},
}
}
+4 -17
View File
@@ -6,10 +6,10 @@ import {
PersistQueryClientProvider,
type PersistQueryClientProviderProps,
} from '@tanstack/react-query-persist-client'
import {SuperJSON} from 'superjson'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
import {PERSISTED_QUERY_ROOT} from '#/state/queries'
import {IS_NATIVE, IS_WEB} from '#/env'
declare global {
@@ -18,18 +18,6 @@ declare global {
}
}
// any query keys in this array will be persisted to storage.
// keys are here for require cycle reasons ¯\_(ツ)_/¯
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
export const preferencesQueryKeyRoot = 'get-preferences'
export const pinnedFeedInfosQueryKeyRoot = 'pinned-feeds-infos'
const STORED_CACHE_QUERY_KEY_ROOTS = [
labelersDetailedInfoQueryKeyRoot,
preferencesQueryKeyRoot,
pinnedFeedInfosQueryKeyRoot,
]
async function checkIsOnline(): Promise<boolean> {
try {
const controller = new AbortController()
@@ -146,7 +134,8 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd
{
shouldDehydrateMutation: (_: any) => false,
shouldDehydrateQuery: query => {
return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0]))
const root = String(query.queryKey[0])
return root === PERSISTED_QUERY_ROOT
},
}
@@ -187,13 +176,11 @@ 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 storage = createPersistedQueryStorage('react-query-cache')
const asyncPersister = createAsyncStoragePersister({
storage,
key:
'queryClient-' + (currentDid ?? 'logged-out') + `-v${PERSIST_VERSION}`,
serialize: SuperJSON.stringify,
deserialize: SuperJSON.parse,
})
return {
persister: asyncPersister,
+15 -8
View File
@@ -20,10 +20,9 @@ import {
} from '@tanstack/react-query'
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
import {pinnedFeedInfosQueryKeyRoot} from '#/lib/react-query'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {STALE} from '#/state/queries'
import {PERSISTED_QUERY_ROOT, STALE} from '#/state/queries'
import {RQKEY as listQueryKey} from '#/state/queries/list'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
@@ -419,6 +418,12 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
contentMode: undefined,
}
const createPinnedFeedInfosQueryKeyRoot = (...args: any[]) => [
PERSISTED_QUERY_ROOT,
'feed-info',
...args,
]
export function usePinnedFeedsInfos() {
const {hasSession} = useSession()
const agent = useAgent()
@@ -428,11 +433,10 @@ export function usePinnedFeedsInfos() {
return useQuery({
staleTime: STALE.INFINITY,
enabled: !isLoadingPrefs,
queryKey: [
pinnedFeedInfosQueryKeyRoot,
(hasSession ? 'authed:' : 'unauthed:') +
pinnedItems.map(f => f.value).join(','),
],
queryKey: createPinnedFeedInfosQueryKeyRoot(
'pinned',
...pinnedItems.map(f => f.value),
),
queryFn: async () => {
if (!hasSession) {
return [PWI_DISCOVER_FEED_STUB]
@@ -536,7 +540,10 @@ export function useSavedFeeds() {
return useQuery({
staleTime: STALE.INFINITY,
enabled: !isLoadingPrefs,
queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems],
queryKey: createPinnedFeedInfosQueryKeyRoot(
'saved',
...savedItems.map(f => f.value),
),
placeholderData: previousData => {
return (
previousData || {
+16
View File
@@ -18,3 +18,19 @@ export const STALE = {
},
INFINITY: Infinity,
}
/**
* Root key for persisted queries.
*
* If the `querykey` of your query uses this at index 0, it will be
* persisted automatically by the `PersistQueryClientProvider` in
* `#/lib/react-query.tsx`.
*
* Be careful when using this, since it will change the query key and may
* break any cases where we call `invalidateQueries` or `refetchQueries`
* with the old key.
*
* Also, only use this for queries that are safe to persist between
* app launches (like user preferences).
*/
export const PERSISTED_QUERY_ROOT = 'PERSISTED'
+5 -5
View File
@@ -3,8 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {z} from 'zod'
import {MAX_LABELERS} from '#/lib/constants'
import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'
import {STALE} from '#/state/queries'
import {PERSISTED_QUERY_ROOT, STALE} from '#/state/queries'
import {
preferencesQueryKey,
usePreferencesQuery,
@@ -23,8 +22,9 @@ export const labelersInfoQueryKey = (dids: string[]) => [
dids.slice().sort(),
]
export const labelersDetailedInfoQueryKey = (dids: string[]) => [
labelersDetailedInfoQueryKeyRoot,
export const persistedLabelersDetailedInfoQueryKey = (dids: string[]) => [
PERSISTED_QUERY_ROOT,
'labelers-detailed-info',
dids,
]
@@ -65,7 +65,7 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
const agent = useAgent()
return useQuery({
enabled: !!dids.length,
queryKey: labelersDetailedInfoQueryKey(dids),
queryKey: persistedLabelersDetailedInfoQueryKey(dids),
gcTime: 1000 * 60 * 60 * 6, // 6 hours
staleTime: STALE.MINUTES.ONE,
queryFn: async () => {
+12 -4
View File
@@ -8,9 +8,8 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {replaceEqualDeep} from '#/lib/functions'
import {preferencesQueryKeyRoot} from '#/lib/react-query'
import {getAge} from '#/lib/strings/time'
import {STALE} from '#/state/queries'
import {PERSISTED_QUERY_ROOT, STALE} from '#/state/queries'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
@@ -30,13 +29,13 @@ export * from '#/state/queries/preferences/const'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/types'
export const preferencesQueryKey = [preferencesQueryKeyRoot]
export const preferencesQueryKey = [PERSISTED_QUERY_ROOT, 'getPreferences']
export function usePreferencesQuery() {
const agent = useAgent()
const aa = useAgeAssurance()
return useQuery({
const query = useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: replaceEqualDeep,
refetchOnWindowFocus: true,
@@ -92,6 +91,15 @@ export function usePreferencesQuery() {
[aa],
),
})
if (query.data?.birthDate) {
/**
* The persisted query cache stores dates as strings, but our code expects a `Date`.
*/
query.data.birthDate = new Date(query.data.birthDate)
}
return query
}
export function useClearPreferencesMutation() {
+4 -4
View File
@@ -7,16 +7,16 @@ export function create({id}: {id: string}): DB {
return {
async get(key: string): Promise<string | undefined> {
return store.getString(key) ?? undefined
return store.getString(key)
},
async set(key: string, value: string): Promise<void> {
store.set(key, value)
return store.set(key, value)
},
async delete(key: string): Promise<void> {
store.delete(key)
return store.delete(key)
},
async clear(): Promise<void> {
store.clearAll()
return store.clearAll()
},
}
}
+4 -4
View File
@@ -7,16 +7,16 @@ export function create({id}: {id: string}): DB {
return {
async get(key: string): Promise<string | undefined> {
return get(key, store) ?? undefined
return get(key, store)
},
async set(key: string, value: string): Promise<void> {
await set(key, value, store)
return set(key, value, store)
},
async delete(key: string): Promise<void> {
await del(key, store)
return del(key, store)
},
async clear(): Promise<void> {
await clear(store)
return clear(store)
},
}
}