a tool for shared writing and social publishing
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}