diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 6e4ebe3..e530e8a 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -659,11 +659,16 @@ function libraryFilter(sessionId: string, userId?: string): string { /** Returns all slugs the user has explicitly saved to their library. */ export async function getSavedSlugs(sessionId: string, userId?: string): Promise> { + const cacheKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`; + const cached = await cache.get(cacheKey); + if (cached) return new Set(cached); const rows = await listAll( 'user_library', libraryFilter(sessionId, userId) ); - return new Set(rows.map((r) => r.slug)); + const slugs = rows.map((r) => r.slug); + await cache.set(cacheKey, slugs, SAVED_SLUGS_TTL); + return new Set(slugs); } /** Returns whether a specific slug is saved. */ @@ -710,7 +715,11 @@ export async function saveBook( if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body }); + return; } + // Invalidate saved-slugs cache so the next discover load excludes this book. + const savedKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`; + await cache.invalidate(savedKey); } /** Remove a book from the user's library. */ @@ -2151,12 +2160,27 @@ function discoveryFilter(sessionId: string, userId?: string): string { return `session_id="${sessionId}"`; } +/** Cache TTL (seconds) for per-user voted/saved slug sets. Short — changes on every swipe. */ +const VOTED_SLUGS_TTL = 30; +const SAVED_SLUGS_TTL = 60; + export async function getVotedSlugs(sessionId: string, userId?: string): Promise> { + const cacheKey = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`; + const cached = await cache.get(cacheKey); + if (cached) return new Set(cached); const rows = await listAll( 'discovery_votes', discoveryFilter(sessionId, userId) ).catch(() => [] as DiscoveryVote[]); - return new Set(rows.map((r) => r.slug)); + const slugs = rows.map((r) => r.slug); + await cache.set(cacheKey, slugs, VOTED_SLUGS_TTL); + return new Set(slugs); +} + +/** Invalidate the voted-slugs cache entry after a vote is recorded. */ +async function invalidateVotedSlugsCache(sessionId: string, userId?: string): Promise { + const key = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`; + await cache.invalidate(key); } export async function upsertDiscoveryVote( @@ -2179,6 +2203,7 @@ export async function upsertDiscoveryVote( const res = await pbPost('/api/collections/discovery_votes/records', payload); if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status }); } + await invalidateVotedSlugsCache(sessionId, userId); } export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise { @@ -2189,6 +2214,7 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {}) ) ); + await invalidateVotedSlugsCache(sessionId, userId); } // ─── Ratings ────────────────────────────────────────────────────────────────── @@ -2283,10 +2309,13 @@ export async function getBooksForDiscovery( userId?: string, prefs?: DiscoveryPrefs ): Promise { - const [allBooks, votedSlugs, savedSlugs] = await Promise.all([ + // Fetch all 4 independent data sources in parallel — previously getAllRatings + // ran sequentially after the first group, adding it to the critical path. + const [allBooks, votedSlugs, savedSlugs, ratingRows] = await Promise.all([ listBooks(), getVotedSlugs(sessionId, userId), - getSavedSlugs(sessionId, userId) + getSavedSlugs(sessionId, userId), + getAllRatings(), ]); let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug)); @@ -2305,10 +2334,7 @@ export async function getBooksForDiscovery( if (sf.length >= 3) candidates = sf; } - // Fetch avg ratings for candidates, weight top-rated books to surface earlier. - // Fetch in one shot for all candidate slugs. Low-rated / unrated books still - // appear — they're just pushed further back via a stable sort before shuffle. - const ratingRows = await getAllRatings(); + // Build slug→avg rating map const ratingMap = new Map(); for (const r of ratingRows) { const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 }; @@ -2384,6 +2410,7 @@ export async function undoDiscoveryVote( if (row) { await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {}); } + await invalidateVotedSlugsCache(sessionId, userId); } // ─── User stats ────────────────────────────────────────────────────────────────