From 1bd3e235f4348f61b9257ac3ee7be9bbedbe4e6d Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:28:12 +0200 Subject: [PATCH] make things faster --- src/lib/atproto/server/feed.remote.ts | 1 + src/lib/reddit/feed-cache.ts | 40 ++++- src/lib/reddit/server/communities.remote.ts | 34 ++++- src/routes/api/refresh-follows/+server.ts | 2 +- src/routes/c/[handle]/+page.svelte | 60 ++++++-- src/routes/communities/+page.svelte | 140 +++++++++++------- .../app.bsky.feed.getFeedSkeleton/+server.ts | 2 +- 7 files changed, 200 insertions(+), 79 deletions(-) diff --git a/src/lib/atproto/server/feed.remote.ts b/src/lib/atproto/server/feed.remote.ts index 05c4c1d..112d390 100644 --- a/src/lib/atproto/server/feed.remote.ts +++ b/src/lib/atproto/server/feed.remote.ts @@ -379,3 +379,4 @@ export const getBookmarks = command( return { posts, cursor: data.cursor ?? null }; } ); + diff --git a/src/lib/reddit/feed-cache.ts b/src/lib/reddit/feed-cache.ts index 762be61..04d3354 100644 --- a/src/lib/reddit/feed-cache.ts +++ b/src/lib/reddit/feed-cache.ts @@ -224,19 +224,43 @@ export async function writeAllCommunityDids( * Read the list of all known community DIDs, edge-cached. Used by * `fetchViewerCommunityRelationships` to know which DIDs to ask bsky * about in each `getRelationships` batch. + * + * Optional `db` parameter enables the same reactive-rebuild pattern + * as `getCachedSortedList`: if both the edge cache and KV come up + * empty (fresh deploy, KV TTL expiry, dev without recent cron run), + * falls back to a direct D1 query and repopulates KV for next time. */ -export async function getAllCommunityDids(env: App.Platform['env']): Promise { +export async function getAllCommunityDids( + env: App.Platform['env'], + db?: D1Database +): Promise { const cached = await cfCache.match(COMMUNITY_DIDS_CACHE_KEY); if (cached) { try { - return (await cached.json()) as string[]; + const parsed = (await cached.json()) as string[]; + if (parsed.length > 0) return parsed; } catch { /* fall through */ } } const raw = await env.FEEDS_CACHE.get(COMMUNITY_DIDS_KV_KEY); - const dids: string[] = raw ? (JSON.parse(raw) as string[]) : []; + let dids: string[] = raw ? (JSON.parse(raw) as string[]) : []; + + // Reactive rebuild: KV empty and caller gave us a DB handle. + if (dids.length === 0 && db) { + try { + const rows = await listCommunities(db); + dids = rows.map((r) => r.did); + if (dids.length > 0) { + await env.FEEDS_CACHE.put(COMMUNITY_DIDS_KV_KEY, JSON.stringify(dids), { + expirationTtl: 3600 + }); + } + } catch (e) { + console.error('[feed-cache] community DIDs fallback failed', e); + } + } await cfCache.put( COMMUNITY_DIDS_CACHE_KEY, @@ -351,7 +375,8 @@ export async function fetchViewerCommunityRelationships( */ export async function getCachedViewerCommunityFollows( env: App.Platform['env'], - viewerDid: string + viewerDid: string, + db?: D1Database ): Promise { const cacheKey = viewerFollowsCacheKey(viewerDid); const cached = await cfCache.match(cacheKey); @@ -363,7 +388,7 @@ export async function getCachedViewerCommunityFollows( } } - const communityDids = await getAllCommunityDids(env); + const communityDids = await getAllCommunityDids(env, db); const followed = await fetchViewerCommunityRelationships(viewerDid, communityDids); await cfCache.put( @@ -393,14 +418,15 @@ export async function getCachedViewerCommunityFollows( */ export async function invalidateViewerCommunityFollows( env: App.Platform['env'], - viewerDid: string + viewerDid: string, + db?: D1Database ): Promise { const cacheKey = viewerFollowsCacheKey(viewerDid); await cfCache.delete(cacheKey); // Eagerly repopulate so the refresh round-trip includes the fresh // data — the UI can immediately use it (or at least knows the // bsky graph has propagated). - return getCachedViewerCommunityFollows(env, viewerDid); + return getCachedViewerCommunityFollows(env, viewerDid, db); } // --------------------------------------------------------------------------- diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts index bcc5506..20f2b05 100644 --- a/src/lib/reddit/server/communities.remote.ts +++ b/src/lib/reddit/server/communities.remote.ts @@ -19,7 +19,7 @@ import { refreshCommunityCache, removeCommunityPost } from '../bot'; -import { getCachedSortedList, FEED_CACHE_LIMIT } from '../feed-cache'; +import { getCachedSortedList, getCachedViewerCommunityFollows, FEED_CACHE_LIMIT } from '../feed-cache'; import { parseListUri } from '../list-uri'; import { ACCENT_COLORS, @@ -173,14 +173,40 @@ export const register = command( ); export const getCommunities = command( - v.object({}), - async (): Promise => { + v.object({ + limit: v.optional(v.number()), + offset: v.optional(v.number()) + }), + async (input): Promise => { const { platform } = getRequestEvent(); const env = platform?.env; if (!env || !env.DB) return []; const rows = await listCommunities(env.DB); - return rows.map(sanitize); + const offset = Math.max(0, input.offset ?? 0); + const limit = Math.max(1, Math.min(100, input.limit ?? 30)); + return rows.slice(offset, offset + limit).map(sanitize); + } +); + +/** + * Return the set of community DIDs the authenticated viewer follows + * on Bluesky. Backed by the same edge-cached + `getRelationships` + * lookup that the following-feed generator uses — scales with the + * number of communities (~100, stable), not with the user's follow + * count, and caches for 5 min per colo. + * + * Returns `{ dids: [] }` for signed-out viewers. + */ +export const getViewerCommunityFollows = command( + v.object({}), + async (): Promise<{ dids: string[] }> => { + const { platform, locals } = getRequestEvent(); + const env = platform?.env; + if (!env || !locals.did) return { dids: [] }; + + const dids = await getCachedViewerCommunityFollows(env, locals.did, env.DB); + return { dids }; } ); diff --git a/src/routes/api/refresh-follows/+server.ts b/src/routes/api/refresh-follows/+server.ts index aab6ddc..74b19b0 100644 --- a/src/routes/api/refresh-follows/+server.ts +++ b/src/routes/api/refresh-follows/+server.ts @@ -27,7 +27,7 @@ export const POST: RequestHandler = async ({ platform, locals }) => { // UI doesn't need the returned list today, but we include it in // the response in case it wants to render immediate feedback // ("you now follow N communities") without a second round-trip. - const followed = await invalidateViewerCommunityFollows(env, locals.did); + const followed = await invalidateViewerCommunityFollows(env, locals.did, env.DB); return json({ ok: true, followedCount: followed.length }); }; diff --git a/src/routes/c/[handle]/+page.svelte b/src/routes/c/[handle]/+page.svelte index af03a01..3c525f0 100644 --- a/src/routes/c/[handle]/+page.svelte +++ b/src/routes/c/[handle]/+page.svelte @@ -5,7 +5,7 @@ import { page } from '$app/state'; import { Avatar, Button } from '@foxui/core'; import { Loader2, Check, UserPlus, Plus, Pencil } from '@lucide/svelte'; - import { getCommunity, getCommunityPosts, removePost } from '$lib/reddit/server/communities.remote'; + import { getCommunity, getCommunityPosts, getViewerCommunityFollows, removePost } from '$lib/reddit/server/communities.remote'; import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote'; import { followUser, unfollowUser, getProfile, resolveProfiles } from '$lib/atproto/server/feed.remote'; import { user } from '$lib/atproto/auth.svelte'; @@ -144,29 +144,56 @@ community = null; posts = []; quoted = {}; + submitters = {}; hasMore = true; followUri = null; sort = 'hot'; try { - const info = await getCommunity({ handle }); + // Stage 1: community info + first page of posts in parallel. + // Both only need the handle so there's no data dependency. + const [info, rows] = await Promise.all([ + getCommunity({ handle }), + getCommunityPosts({ handle, limit: 50, sort: 'hot' }) + ]); if (!info) { loadError = 'Community not found'; loading = false; return; } community = info; - await loadPosts(handle, 'hot'); + posts = rows; + hasMore = rows.length >= 50; - // If the viewer is signed in, fetch viewer.following from the - // community's profile to seed the join-button state. + // Stage 2: hydrate quoted posts, resolve submitter profiles, + // and check follow state — all in parallel. Each is an + // independent server call that doesn't depend on the + // others' output. + const parallelOps: Promise[] = []; + if (rows.length > 0) { + parallelOps.push( + (async () => { + const [quotedRes, profileRes] = await Promise.all([ + getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }), + resolveProfiles({ dids: uniqueAuthorDids(rows) }) + ]); + quoted = quotedRes.posts; + submitters = profileRes.profiles; + })() + ); + } if (user.did) { - try { - const profile = await getProfile({ actor: info.did }); - followUri = profile.viewer?.following ?? null; - } catch (e) { - console.error('[community] getProfile failed', e); - } + parallelOps.push( + (async () => { + try { + const { dids } = await getViewerCommunityFollows({}); + followUri = dids.includes(info.did) ? 'pending' : null; + } catch (e) { + console.error('[community] getViewerCommunityFollows failed', e); + } + })() + ); } + await Promise.all(parallelOps); } catch (e) { console.error(e); loadError = 'Failed to load community'; @@ -208,8 +235,15 @@ if (joinLoading) return; joinLoading = true; try { - if (isFollowing && followUri) { - await unfollowUser({ followUri }); + if (isFollowing) { + // Lazy-resolve the follow-record URI via getProfile — + // we only store the DID set, not the AT-URIs, so the + // single extra call only fires on the rare unfollow path. + const profile = await getProfile({ actor: community.did }); + const resolvedUri = profile.viewer?.following; + if (resolvedUri) { + await unfollowUser({ followUri: resolvedUri }); + } followUri = null; } else { const result = await followUser({ did: community.did }); diff --git a/src/routes/communities/+page.svelte b/src/routes/communities/+page.svelte index 6c7c07e..015f56b 100644 --- a/src/routes/communities/+page.svelte +++ b/src/routes/communities/+page.svelte @@ -1,9 +1,9 @@