SkateSpots/src/lib/queryPersister.ts

150 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = 'v6';
/** 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<string>([
// Map + directory content (the stuff that makes the app look "loaded")
'skate-spots',
'skate-spot',
'user-skate-spots',
'skate-shops',
'shops',
'skate-events',
'upcoming-skate-events',
'tours',
'crews',
'coaches',
'lab-profiles',
'marketplace',
'marketplace-items',
'skaters',
// 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<string>([
'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<string, unknown>).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<PersistedClient>(IDB_KEY)) ?? undefined;
} catch {
return undefined;
}
},
removeClient: async () => {
try {
await del(IDB_KEY);
} catch {
// ignore
}
},
};
}