a tool for shared writing and social publishing
at feature/footnotes 100 lines 2.8 kB view raw
1"use server"; 2 3import { drizzle } from "drizzle-orm/node-postgres"; 4import { sql } from "drizzle-orm"; 5import { pool } from "supabase/pool"; 6import Client from "ioredis"; 7import { AtUri } from "@atproto/api"; 8import { supabaseServerClient } from "supabase/serverClient"; 9import { enrichDocumentToPost } from "./enrichPost"; 10import type { Post } from "./getReaderFeed"; 11 12let redisClient: Client | null = null; 13if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { 14 redisClient = new Client(process.env.REDIS_URL); 15} 16 17const CACHE_KEY = "hot_feed_v1"; 18const CACHE_TTL = 300; // 5 minutes 19 20export async function getHotFeed(): Promise<{ posts: Post[] }> { 21 // Check Redis cache 22 if (redisClient) { 23 const cached = await redisClient.get(CACHE_KEY); 24 if (cached) { 25 return JSON.parse(cached) as { posts: Post[] }; 26 } 27 } 28 29 // Run ranked SQL query to get top 50 URIs 30 const client = await pool.connect(); 31 const db = drizzle(client); 32 33 let uris: string[]; 34 try { 35 const ranked = await db.execute(sql` 36 SELECT uri 37 FROM documents 38 WHERE indexed = true 39 AND sort_date > now() - interval '7 days' 40 ORDER BY 41 (bsky_like_count + recommend_count * 5)::numeric 42 / power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC 43 LIMIT 50 44 `); 45 uris = ranked.rows.map((row: any) => row.uri as string); 46 } finally { 47 client.release(); 48 } 49 50 if (uris.length === 0) { 51 return { posts: [] }; 52 } 53 54 // Batch-fetch documents with publication joins and interaction counts 55 const { data: documents } = await supabaseServerClient 56 .from("documents") 57 .select( 58 `*, 59 comments_on_documents(count), 60 document_mentions_in_bsky(count), 61 recommends_on_documents(count), 62 documents_in_publications(publications(*))`, 63 ) 64 .in("uri", uris); 65 66 // Build lookup map for enrichment 67 const docMap = new Map((documents || []).map((d) => [d.uri, d])); 68 69 // Process in ranked order, deduplicating by identity key (DID/rkey) 70 const seen = new Set<string>(); 71 const orderedDocs: NonNullable<typeof documents>[number][] = []; 72 for (const uri of uris) { 73 try { 74 const parsed = new AtUri(uri); 75 const identityKey = `${parsed.host}/${parsed.rkey}`; 76 if (seen.has(identityKey)) continue; 77 seen.add(identityKey); 78 } catch { 79 // invalid URI, skip dedup check 80 } 81 const doc = docMap.get(uri); 82 if (doc) orderedDocs.push(doc); 83 } 84 85 // Enrich into Post[] 86 const posts = ( 87 await Promise.all( 88 orderedDocs.map((doc) => enrichDocumentToPost(doc as any)), 89 ) 90 ).filter((post): post is Post => post !== null); 91 92 const response = { posts }; 93 94 // Cache in Redis 95 if (redisClient) { 96 await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response)); 97 } 98 99 return response; 100}