160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
/**
|
||
* 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<string>([
|
||
// 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<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
|
||
}
|
||
},
|
||
};
|
||
}
|