SkateSpots/src/lib/queryPersister.ts
shakespeare.diy 8d464a05f3 fix: resolve pre-existing tsc errors blocking the test gate
Two type errors (surfaced by `npm run test` / tsc --noEmit on a full
toolchain; the in-browser bundler does not run tsc, so they were latent):

- useCVMClient.ts: remove a malformed cast
  `spots as typeof spots & { tags; created_at }[]` feeding
  deduplicateByIdentity(). The intersection dropped pubkey/kind and broke
  the reassignment to `spots`. deduplicateAddressable() already returns
  the correct shape, so the cast was both wrong and unnecessary.

- queryPersister.ts: import PersistedClient / Persister from
  @tanstack/query-persist-client-core (their canonical source) instead of
  @tanstack/react-query-persist-client, whose `export *` re-export tsc
  won't resolve for named type imports. Added the core package (5.102.8,
  matching the pinned wrapper) as an explicit dependency.

Both are pre-existing issues on the branch, unrelated to the audit
remediation commits.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:46:27 +07:00

138 lines
4.7 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 = 'v1';
/** 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;
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
}
},
};
}