/** * IndexedDB-backed React Query persistence. * ───────────────────────────────────────────────────────────────────────────── * WHY * On a cold start every Nostr query begins from an empty cache, so users land on * a blank map/profile list and watch it populate as the relay round-trips * complete (1–4s). By persisting the React Query cache to IndexedDB we can * REHYDRATE the last-seen spots/shops/events/profiles instantly on the next * launch — the app opens already populated and quietly refetches fresh data in * the background. * * WHAT WE PERSIST (allow-list, see `shouldDehydrateQuery`) * Only PUBLIC, plain-JSON, cold-start-relevant queries: skate spots, shops, * events, tours, crews, coaches, marketplace listings, author profiles, and a * few small stats. We DO NOT persist: * - Private / encrypted data (stash, DMs, private contacts) — must never touch * disk in plaintext. * - Per-interaction / volatile data (zaps, unread counts, RSVPs). * - Errored or still-pending queries. * This keeps the persisted blob small, safe, and genuinely useful. * * SAFETY * - `idb-keyval` writes to IndexedDB off the main thread; failures are swallowed * (private-mode / quota) so persistence can never break app boot. * - A `buster` invalidates the whole cache when the data shape changes. * - `maxAge` discards anything older than 24h so stale content can't linger. */ import { get, set, del } from 'idb-keyval'; // PersistedClient / Persister are defined in the persist-client CORE package. // The React wrapper (@tanstack/react-query-persist-client) only re-exports them // via `export *`, which tsc does not resolve for named *type* imports under // this project's config — import them from the canonical source instead. import type { PersistedClient, Persister, } from '@tanstack/query-persist-client-core'; import type { Query } from '@tanstack/react-query'; const IDB_KEY = 'skatespots:react-query-cache'; /** Bump when the persisted data shape changes to invalidate old caches. */ export const PERSIST_BUSTER = 'v7'; /** Discard any persisted cache older than this (ms). */ export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h /** * Query-key prefixes that are SAFE and USEFUL to persist for instant cold-start. * A query is persisted only if its first key segment is in this set (and it has * successfully-fetched data — see `shouldDehydrateQuery`). */ const PERSIST_ALLOW_LIST = new Set([ // Map + directory content (the stuff that makes the app look "loaded") 'skate-spots', 'skate-spot', 'user-skate-spots', 'skate-shops', 'shops', 'skate-events', 'skate-event', 'upcoming-skate-events', 'tours', 'skate-tours', 'skate-tour', 'user-skate-tours', 'crews', 'skate-crews', 'skate-crew', 'skate-crews-focus', 'skate-crews-member', 'shop-directory', 'coaches', 'lab-profiles', 'marketplace', 'marketplace-items', 'skaters', 'newsletter-issues', // Author profiles (kind 0) — avatars/names everywhere benefit hugely 'author', 'authors', // Small, non-sensitive aggregate stats 'growth-stats', 'community-stats', ]); /** * Query-key prefixes that must NEVER be persisted, even if they somehow match * the allow-list. Private, encrypted, signer-derived, or highly volatile data. */ const PERSIST_DENY_LIST = new Set([ 'stash', 'stash-showcase', 'direct-messages', 'dms', 'private-contacts', 'nwc', 'wallet', 'cashu', 'bitcoin', 'signer', ]); /** * Decide whether a single query should be written to disk. * Persist only: successful queries, with data, whose key prefix is explicitly * allow-listed and not deny-listed. */ export function shouldDehydrateQuery(query: Query): boolean { if (query.state.status !== 'success') return false; if (query.state.data === undefined || query.state.data === null) return false; // Never persist an empty list. A transient relay outage can legitimately // return 0 results, and caching that would wipe the last-known map on the next // cold start — the exact opposite of what persistence is for. if (Array.isArray(query.state.data) && query.state.data.length === 0) return false; // Never persist an empty plain object either — e.g. `useAuthor` returns `{}` // when every relay failed, and persisting that would overwrite a good cached // profile on the next cold start. if ( typeof query.state.data === 'object' && !Array.isArray(query.state.data) && Object.keys(query.state.data as Record).length === 0 ) return false; const prefix = Array.isArray(query.queryKey) ? query.queryKey[0] : query.queryKey; if (typeof prefix !== 'string') return false; if (PERSIST_DENY_LIST.has(prefix)) return false; return PERSIST_ALLOW_LIST.has(prefix); } /** * An async Persister backed by IndexedDB (via idb-keyval). All operations are * defensively wrapped so a storage failure never bubbles into React. */ export function createIDBPersister(): Persister { return { persistClient: async (client: PersistedClient) => { try { await set(IDB_KEY, client); } catch { // ignore — private mode / quota exceeded } }, restoreClient: async () => { try { return (await get(IDB_KEY)) ?? undefined; } catch { return undefined; } }, removeClient: async () => { try { await del(IDB_KEY); } catch { // ignore } }, }; }