···804804 await processStories();
805805 console.log("Story processing completed");
806806807807- // Invalidate all caches after data update
807807+ // Invalidate all caches and reload them after data update
808808 invalidateAllCaches();
809809- console.log("All query caches invalidated");
809809+ console.log("All query caches invalidated and refreshed");
810810811811 console.log("Starting cleanup of expired stories...");
812812 // Clean up expired stories
813813 await cleanupExpiredStories();
814814 console.log("Cleanup completed");
815815816816- // Invalidate caches again after cleanup
816816+ // Invalidate caches again after cleanup and reload them
817817 invalidateAllCaches();
818818 } catch (error) {
819819 console.error("Error in checkHackerNews:", error);
···843843 });
844844 }
845845846846- // Run immediately on startup
847847- checkHackerNews();
846846+ // Run a few seconds after startup to give server time to initialize
847847+ setTimeout(() => {
848848+ console.log("Running initial data check...");
849849+ checkHackerNews();
850850+ }, 3000);
848851849852 // Initialize query cache
850853 console.log("Query cache initialized");
+91-225
src/index.ts
···33import setup from "./features";
44import { db } from "./libs/db";
55import { version, name } from "../package.json";
66+import { preloadCaches, invalidateAndRefreshCaches } from "./libs/cacheWarming";
77+import { queryCache, createCacheHeaders, compressResponse, createCachedEndpoint } from "./libs/cache";
68import root from "../public/index.html";
79import { count } from "drizzle-orm";
810import { stories } from "./libs/schema";
9111010-// Cache system for database queries
1111-type CacheItem<T> = {
1212- data: T;
1313- timestamp: number;
1414- expiresAt: number;
1515-};
1616-1717-class QueryCache {
1818- private cache: Map<string, CacheItem<unknown>> = new Map();
1919- private defaultTTL: number = 60 * 5; // 5 minutes in seconds
2020-2121- constructor(defaultTTL?: number) {
2222- if (defaultTTL) {
2323- this.defaultTTL = defaultTTL;
2424- }
2525- console.log(`Initialized query cache with ${this.defaultTTL}s TTL`);
2626- }
2727-2828- async get<T>(
2929- key: string,
3030- queryFn: () => Promise<T>,
3131- ttl: number = this.defaultTTL
3232- ): Promise<T> {
3333- const now = Math.floor(Date.now() / 1000);
3434- const cached = this.cache.get(key);
3535-3636- // Return cached value if it exists and is not expired
3737- if (cached && cached.expiresAt > now) {
3838- console.log(`Cache hit for ${key} (expires in ${cached.expiresAt - now}s)`);
3939- return cached.data as T;
4040- }
4141-4242- // Execute the query
4343- console.log(`Cache miss for ${key}, fetching from database...`);
4444- const data = await queryFn();
4545-4646- // Cache the result
4747- this.cache.set(key, {
4848- data,
4949- timestamp: now,
5050- expiresAt: now + ttl,
5151- });
5252-5353- return data;
5454- }
5555-5656- invalidate(key: string): void {
5757- if (this.cache.has(key)) {
5858- console.log(`Invalidating cache for ${key}`);
5959- this.cache.delete(key);
6060- }
6161- }
6262-6363- invalidateAll(): void {
6464- console.log("Invalidating entire cache");
6565- this.cache.clear();
6666- }
6767-}
6868-6969-// Create a global cache instance
7070-const queryCache = new QueryCache();
7171-7212const environment = process.env.NODE_ENV;
7313const commit = (() => {
7414 try {
···11151console.log("📦 Loading Slack App...");
11252console.log("🔑 Loading environment variables...");
113535454+// Initialize Slack app
11455const slackApp = new SlackApp({
11556 env: {
11657 SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN as string,
···12263const slackClient = slackApp.client;
1236412465await setup();
6666+await preloadCaches();
1256712668const server = Bun.serve({
12769 port: process.env.PORT || 3000,
12870 reusePort: true,
12971 routes: {
13072 "/": root,
131131- "/api/stories": async () => {
132132- try {
133133- // Get stories that reached the front page (leaderboard)
134134- const alerts = await queryCache.get(
135135- 'leaderboard_stories',
136136- async () => {
137137- const storyAlerts = await db.query.stories.findMany({
138138- where: (stories, { eq }) => eq(stories.isOnLeaderboard, true),
139139- orderBy: (stories, { asc }) => [asc(stories.position)],
140140- limit: 100,
141141- });
142142-143143- // Transform story data to match the format expected by the frontend
144144- return storyAlerts.map((story) => ({
145145- id: story.id,
146146- title: story.title,
147147- url: story.url || `https://news.ycombinator.com/item?id=${story.id}`,
148148- rank: story.position,
149149- peakRank: story.peakPosition,
150150- points: story.score,
151151- peakPoints: story.peakScore,
152152- comments: story.descendants,
153153- timestamp: story.enteredLeaderboardAt
154154- ? new Date(story.enteredLeaderboardAt * 1000).toISOString()
155155- : new Date(story.firstSeenAt * 1000).toISOString(),
156156- by: story.by,
157157- isFromMonitoredUser: story.isFromMonitoredUser,
158158- }));
159159- }
160160- );
7373+ "/api/stories": createCachedEndpoint("leaderboard_stories", async () => {
7474+ const storyAlerts = await db.query.stories.findMany({
7575+ where: (stories, { eq }) => eq(stories.isOnLeaderboard, true),
7676+ orderBy: (stories, { asc }) => [asc(stories.position)],
7777+ limit: 100,
7878+ });
16179162162- return new Response(JSON.stringify(alerts), {
163163- headers: {
164164- "Content-Type": "application/json",
165165- "Cache-Control": "max-age=300",
166166- "ETag": `"${version}-stories-${Date.now()}"`,
167167- },
168168- });
169169- } catch (error) {
170170- console.error("Failed to fetch alerts:", error);
171171- return new Response(
172172- JSON.stringify({ error: "Failed to fetch alerts" }),
173173- {
174174- status: 500,
175175- headers: { "Content-Type": "application/json" },
176176- },
177177- );
178178- }
179179- },
180180- "/api/stats/total-stories": async () => {
181181- try {
182182- // Count all stories in the database using the cache
183183- const totalCount = await queryCache.get(
184184- 'total_stories_count',
185185- async () => {
186186- const result = await db.select({ count: count() }).from(stories);
187187- return Number(result[0]?.count);
188188- }
189189- );
8080+ // Transform story data to match the format expected by the frontend
8181+ return storyAlerts.map((story) => ({
8282+ id: story.id,
8383+ title: story.title,
8484+ url:
8585+ story.url || `https://news.ycombinator.com/item?id=${story.id}`,
8686+ rank: story.position,
8787+ peakRank: story.peakPosition,
8888+ points: story.score,
8989+ peakPoints: story.peakScore,
9090+ comments: story.descendants,
9191+ timestamp: story.enteredLeaderboardAt
9292+ ? new Date(story.enteredLeaderboardAt * 1000).toISOString()
9393+ : new Date(story.firstSeenAt * 1000).toISOString(),
9494+ by: story.by,
9595+ isFromMonitoredUser: story.isFromMonitoredUser,
9696+ }));
9797+ }, 300),
9898+ "/api/stats/total-stories": createCachedEndpoint("total_stories_count", async () => {
9999+ const result = await db.select({ count: count() }).from(stories);
100100+ return {
101101+ count: Number(result[0]?.count),
102102+ timestamp: Math.floor(Date.now() / 1000),
103103+ };
104104+ }, 300),
105105+ "/api/stats/verified-users": createCachedEndpoint("verified_users_stats", async () => {
106106+ // Get stats for verified user stories
107107+ const verifiedStories = await db.query.stories.findMany({
108108+ where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true),
109109+ });
110110+ // Get count of verified users in the system
111111+ const verifiedUsersCount = await db.query.users
112112+ .findMany({
113113+ where: (users, { eq }) => eq(users.verified, true),
114114+ })
115115+ .then((users) => users.length);
190116191191- return new Response(
192192- JSON.stringify({
193193- count: totalCount,
194194- timestamp: Math.floor(Date.now() / 1000),
195195- }),
196196- {
197197- headers: {
198198- "Content-Type": "application/json",
199199- "Cache-Control": "max-age=300",
200200- "ETag": `"${version}-stats-${Date.now()}"`,
201201- },
202202- },
203203- );
204204- } catch (error) {
205205- console.error("Failed to count stories:", error);
206206- return new Response(
207207- JSON.stringify({ error: "Failed to count stories" }),
208208- {
209209- status: 500,
210210- headers: { "Content-Type": "application/json" },
211211- },
212212- );
213213- }
214214- },
215215- "/api/stats/verified-users": async () => {
216216- try {
217217- // Get stats for verified user stories using the cache
218218- const verifiedStats = await queryCache.get(
219219- 'verified_users_stats',
220220- async () => {
221221- // Get stats for verified user stories
222222- const verifiedStories = await db.query.stories.findMany({
223223- where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true),
224224- });
225225- // Get count of verified users in the system
226226- const verifiedUsersCount = await db.query.users
227227- .findMany({
228228- where: (users, { eq }) => eq(users.verified, true),
229229- })
230230- .then((users) => users.length);
231231-232232- // Count stories on front page (rank <= 30)
233233- const frontPageCount = verifiedStories.filter(
234234- (s) => s.isOnLeaderboard,
235235- ).length;
236236-237237- // Calculate average peak points for verified users
238238- let totalPeakPoints = 0;
239239- for (const s of verifiedStories) {
240240- if (s.peakScore) totalPeakPoints += s.peakScore;
241241- }
242242- const avgPeakPoints = verifiedStories.length
243243- ? Math.round(totalPeakPoints / verifiedStories.length)
244244- : 0;
245245-246246- return {
247247- totalCount: verifiedUsersCount,
248248- frontPageCount: frontPageCount,
249249- avgPeakPoints: avgPeakPoints,
250250- };
251251- }
252252- );
117117+ // Count stories on front page (rank <= 30)
118118+ const frontPageCount = verifiedStories.filter(
119119+ (s) => s.isOnLeaderboard,
120120+ ).length;
253121254254- return new Response(
255255- JSON.stringify({
256256- ...verifiedStats,
257257- timestamp: Math.floor(Date.now() / 1000),
258258- }),
259259- {
260260- headers: {
261261- "Content-Type": "application/json",
262262- "Cache-Control": "max-age=300",
263263- "ETag": `"${version}-verified-${Date.now()}"`,
264264- },
265265- },
266266- );
267267- } catch (error) {
268268- console.error("Failed to get verified user stats:", error);
269269- return new Response(
270270- JSON.stringify({ error: "Failed to get verified user stats" }),
271271- {
272272- status: 500,
273273- headers: { "Content-Type": "application/json" },
274274- },
275275- );
122122+ // Calculate average peak points for verified users
123123+ let totalPeakPoints = 0;
124124+ for (const s of verifiedStories) {
125125+ if (s.peakScore) totalPeakPoints += s.peakScore;
276126 }
277277- },
127127+ const avgPeakPoints = verifiedStories.length
128128+ ? Math.round(totalPeakPoints / verifiedStories.length)
129129+ : 0;
130130+131131+ return {
132132+ totalCount: verifiedUsersCount,
133133+ frontPageCount: frontPageCount,
134134+ avgPeakPoints: avgPeakPoints,
135135+ timestamp: Math.floor(Date.now() / 1000),
136136+ };
137137+ }, 300),
278138 "/api/story/:id/snapshots": async (req) => {
279139 try {
280140 const storyId = Number.parseInt(req.params.id as string);
···285145 });
286146 }
287147288288- // Get snapshots for the story using the cache
289289- const graphData = await queryCache.get(
148148+ // Create a cached endpoint handler dynamically based on the story ID
149149+ const handler = createCachedEndpoint(
290150 `story_snapshots_${storyId}`,
291151 async () => {
292152 // Get snapshots for the story
···294154 where: (snapshots, { eq }) => eq(snapshots.storyId, storyId),
295155 orderBy: (snapshots, { asc }) => [asc(snapshots.timestamp)],
296156 });
297297-157157+298158 // Transform snapshot data for frontend
299159 return snapshots.map((snapshot) => ({
300160 timestamp: snapshot.timestamp,
···306166 3600 // Cache story snapshots for 1 hour as they change less frequently
307167 );
308168309309- return new Response(JSON.stringify(graphData), {
310310- headers: {
311311- "Content-Type": "application/json",
312312- "Cache-Control": "max-age=3600",
313313- "ETag": `"${version}-snapshot-${storyId}-${Date.now()}"`,
314314- },
315315- });
169169+ // Execute the cached handler
170170+ return handler(req);
316171 } catch (error) {
317172 console.error("Failed to fetch snapshots for story:", error);
318173 return new Response(
···324179 );
325180 }
326181 },
327327- "/health": () => {
328328- return new Response(JSON.stringify({ status: "ok" }), {
329329- headers: {
182182+ "/health": (req) => {
183183+ const response = new Response(JSON.stringify({ status: "ok" }), {
184184+ headers: {
330185 "Content-Type": "application/json",
331331- "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
332332- "Pragma": "no-cache",
333333- "Expires": "0"
186186+ "Cache-Control":
187187+ "no-store, no-cache, must-revalidate, proxy-revalidate",
188188+ Pragma: "no-cache",
189189+ Expires: "0",
334190 },
335191 });
192192+ return compressResponse(req, response);
336193 },
337194 "/slack": (res: Request) => {
338195 return slackApp.run(res);
···346203 } milliseconds on version: ${version}@${commit}!\n\n----------------------------------\n`,
347204);
348205349349-// Function to invalidate all caches - call this when data is updated
206206+// Function to invalidate all caches and refresh them - call this when data is updated
350207function invalidateAllCaches() {
351351- console.log("Invalidating all query caches");
352352- queryCache.invalidateAll();
208208+ console.log("Invalidating all query caches and refreshing data");
209209+ invalidateAndRefreshCaches();
353210}
354211355355-export { slackApp, slackClient, version, name, environment, db, queryCache, invalidateAllCaches };
212212+export {
213213+ slackApp,
214214+ slackClient,
215215+ version,
216216+ name,
217217+ environment,
218218+ db,
219219+ queryCache,
220220+ invalidateAllCaches,
221221+};
+229
src/libs/cache.ts
···11+import { version } from "../../package.json";
22+33+/**
44+ * Creates consistent cache headers with stable ETags
55+ * @param key Cache key for the resource
66+ * @param maxAge Max age in seconds for the Cache-Control header
77+ * @returns Headers object with proper caching directives
88+ */
99+export function createCacheHeaders(
1010+ key: string,
1111+ maxAge = 300,
1212+): Record<string, string> {
1313+ // Generate stable ETag based on version and cache key
1414+ // Only changes when version changes or when cache TTL expires (divided by TTL)
1515+ const etag = `"${version}-${key}-${Math.floor(Date.now() / (maxAge * 1000))}"`;
1616+1717+ return {
1818+ "Content-Type": "application/json",
1919+ "Cache-Control": `public, max-age=${maxAge - 10}, stale-while-revalidate=30`,
2020+ ETag: etag,
2121+ };
2222+}
2323+2424+/**
2525+ * Applies compression to a Response if the client supports it
2626+ * @param request Original request to check Accept-Encoding
2727+ * @param response Response to potentially compress
2828+ * @returns Compressed response if possible, original otherwise
2929+ */
3030+export async function compressResponse(
3131+ request: Request,
3232+ response: Response,
3333+): Promise<Response> {
3434+ // Only compress JSON responses
3535+ const contentType = response.headers.get("Content-Type");
3636+ if (!contentType?.includes("application/json")) {
3737+ return response;
3838+ }
3939+4040+ // Check if client accepts compression
4141+ const acceptEncoding = request.headers.get("Accept-Encoding") || "";
4242+ if (acceptEncoding.includes("gzip")) {
4343+ // Clone the response
4444+ const body = await response.text();
4545+4646+ // Create compressed body with Bun's built-in gzip compression
4747+ const compressedBody = Bun.gzipSync(Buffer.from(body));
4848+4949+ // Create new response with compressed body and updated headers
5050+ return new Response(compressedBody, {
5151+ status: response.status,
5252+ headers: {
5353+ ...Object.fromEntries(response.headers.entries()),
5454+ "Content-Encoding": "gzip",
5555+ "Content-Length": compressedBody.length.toString(),
5656+ },
5757+ });
5858+ }
5959+ // Bun.deflateSync uses zlib format. If we wanted to support 'deflate':
6060+ if (acceptEncoding.includes("deflate")) {
6161+ const body = await response.text();
6262+ const compressedBody = Bun.deflateSync(Buffer.from(body));
6363+ return new Response(compressedBody, {
6464+ status: response.status,
6565+ headers: {
6666+ ...Object.fromEntries(response.headers.entries()),
6767+ "Content-Encoding": "deflate",
6868+ "Content-Length": compressedBody.length.toString(),
6969+ },
7070+ });
7171+ }
7272+7373+ // Return original response if compression not supported/needed
7474+ return response;
7575+}
7676+7777+// Cache system for database queries
7878+export type CacheItem<T> = {
7979+ data: T;
8080+ timestamp: number;
8181+ expiresAt: number;
8282+};
8383+8484+export class QueryCache {
8585+ private cache: Map<string, CacheItem<unknown>> = new Map();
8686+ private defaultTTL: number = 60 * 5; // 5 minutes in seconds
8787+ private prefetchQueue: Set<string> = new Set();
8888+8989+ constructor(defaultTTL?: number) {
9090+ if (defaultTTL) {
9191+ this.defaultTTL = defaultTTL;
9292+ }
9393+ console.log(`Initialized query cache with ${this.defaultTTL}s TTL`);
9494+ }
9595+9696+ async get<T>(
9797+ key: string,
9898+ queryFn: () => Promise<T>,
9999+ ttl: number = this.defaultTTL,
100100+ ): Promise<T> {
101101+ const now = Math.floor(Date.now() / 1000);
102102+ const cached = this.cache.get(key);
103103+104104+ // Return cached value if it exists and is not expired
105105+ if (cached && cached.expiresAt > now) {
106106+ console.log(
107107+ `Cache hit for ${key} (expires in ${cached.expiresAt - now}s)`,
108108+ );
109109+110110+ // Prefetch if approaching expiration (last 10% of TTL)
111111+ if (cached.expiresAt - now < ttl * 0.1 && !this.prefetchQueue.has(key)) {
112112+ this.prefetch(key, queryFn, ttl);
113113+ }
114114+115115+ return cached.data as T;
116116+ }
117117+118118+ // Execute the query
119119+ console.log(`Cache miss for ${key}, fetching from database...`);
120120+ const data = await queryFn();
121121+122122+ // Cache the result
123123+ this.cache.set(key, {
124124+ data,
125125+ timestamp: now,
126126+ expiresAt: now + ttl,
127127+ });
128128+129129+ return data;
130130+ }
131131+132132+ // Background prefetch to refresh cache before expiration
133133+ private prefetch<T>(
134134+ key: string,
135135+ queryFn: () => Promise<T>,
136136+ ttl: number,
137137+ ): void {
138138+ this.prefetchQueue.add(key);
139139+140140+ // Use setTimeout to run this outside the current request
141141+ setTimeout(async () => {
142142+ try {
143143+ console.log(`Prefetching ${key} before expiration`);
144144+ const data = await queryFn();
145145+ const now = Math.floor(Date.now() / 1000);
146146+147147+ this.cache.set(key, {
148148+ data,
149149+ timestamp: now,
150150+ expiresAt: now + ttl,
151151+ });
152152+153153+ console.log(`Successfully prefetched ${key}`);
154154+ } catch (error) {
155155+ console.error(`Error prefetching ${key}:`, error);
156156+ } finally {
157157+ this.prefetchQueue.delete(key);
158158+ }
159159+ }, 0);
160160+ }
161161+162162+ invalidate(key: string): void {
163163+ if (this.cache.has(key)) {
164164+ console.log(`Invalidating cache for ${key}`);
165165+ this.cache.delete(key);
166166+ }
167167+ }
168168+169169+ invalidateAll(): void {
170170+ console.log("Invalidating entire cache");
171171+ this.cache.clear();
172172+ }
173173+174174+ // Get cache stats for monitoring
175175+ getStats(): { size: number; keys: string[] } {
176176+ return {
177177+ size: this.cache.size,
178178+ keys: Array.from(this.cache.keys()),
179179+ };
180180+ }
181181+}
182182+183183+/**
184184+ * Factory function for creating consistent API endpoint handlers
185185+ * @param cacheKey Key for caching the response
186186+ * @param queryFn Function that performs the actual database query
187187+ * @param ttl Cache TTL in seconds
188188+ */
189189+export function createCachedEndpoint<T>(
190190+ cacheKey: string,
191191+ queryFn: () => Promise<T>,
192192+ ttl = 300,
193193+) {
194194+ return async (request: Request) => {
195195+ try {
196196+ // Get data from cache or execute query
197197+ const data = await queryCache.get(cacheKey, queryFn, ttl);
198198+199199+ // Create response with proper caching headers
200200+ const response = new Response(JSON.stringify(data), {
201201+ headers: createCacheHeaders(cacheKey, ttl),
202202+ });
203203+204204+ // Apply compression and return
205205+ return compressResponse(request, response);
206206+ } catch (error) {
207207+ // Log the error with context
208208+ console.error(`Error in endpoint ${cacheKey}:`, error);
209209+210210+ // Return consistent error response
211211+ return new Response(
212212+ JSON.stringify({
213213+ error: "An error occurred processing your request",
214214+ code: "INTERNAL_SERVER_ERROR",
215215+ }),
216216+ {
217217+ status: 500,
218218+ headers: { "Content-Type": "application/json" },
219219+ },
220220+ );
221221+ }
222222+ };
223223+}
224224+225225+// Create a global cache instance
226226+export const queryCache = new QueryCache();
227227+228228+// Import Sentry for error reporting
229229+import * as Sentry from "@sentry/bun";
+137
src/libs/cacheWarming.ts
···11+import * as Sentry from "@sentry/bun";
22+import { db } from "./db";
33+import { count } from "drizzle-orm";
44+import { stories, users } from "./schema";
55+import { queryCache } from "./cache";
66+77+/**
88+ * Proactively warms the cache by loading commonly accessed data
99+ * Call this after cron jobs update the database or at server startup
1010+ */
1111+export async function preloadCaches(): Promise<void> {
1212+ console.log("Preloading all caches for optimal performance...");
1313+1414+ try {
1515+ // Load critical caches sequentially to avoid database contention
1616+1717+ // 1. Leaderboard stories (most frequently accessed)
1818+ console.log("Preloading leaderboard stories cache...");
1919+ await queryCache.get('leaderboard_stories', async () => {
2020+ const storyAlerts = await db.query.stories.findMany({
2121+ where: (stories, { eq }) => eq(stories.isOnLeaderboard, true),
2222+ orderBy: (stories, { asc }) => [asc(stories.position)],
2323+ limit: 100,
2424+ });
2525+2626+ // Transform for frontend
2727+ return storyAlerts.map((story) => ({
2828+ id: story.id,
2929+ title: story.title,
3030+ url: story.url || `https://news.ycombinator.com/item?id=${story.id}`,
3131+ rank: story.position,
3232+ peakRank: story.peakPosition,
3333+ points: story.score,
3434+ peakPoints: story.peakScore,
3535+ comments: story.descendants,
3636+ timestamp: story.enteredLeaderboardAt
3737+ ? new Date(story.enteredLeaderboardAt * 1000).toISOString()
3838+ : new Date(story.firstSeenAt * 1000).toISOString(),
3939+ by: story.by,
4040+ isFromMonitoredUser: story.isFromMonitoredUser,
4141+ }));
4242+ });
4343+4444+ // 2. Total stories count
4545+ console.log("Preloading story count cache...");
4646+ await queryCache.get('total_stories_count', async () => {
4747+ const result = await db.select({ count: count() }).from(stories);
4848+ return Number(result[0]?.count);
4949+ });
5050+5151+ // 3. Verified users stats
5252+ console.log("Preloading verified users stats cache...");
5353+ await queryCache.get('verified_users_stats', async () => {
5454+ // Get stats for verified user stories
5555+ const verifiedStories = await db.query.stories.findMany({
5656+ where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true),
5757+ });
5858+5959+ // Get count of verified users in the system
6060+ const verifiedUsersCount = await db.query.users
6161+ .findMany({
6262+ where: (users, { eq }) => eq(users.verified, true),
6363+ })
6464+ .then((users) => users.length);
6565+6666+ // Count stories on front page (rank <= 30)
6767+ const frontPageCount = verifiedStories.filter(
6868+ (s) => s.isOnLeaderboard,
6969+ ).length;
7070+7171+ // Calculate average peak points for verified users
7272+ let totalPeakPoints = 0;
7373+ for (const s of verifiedStories) {
7474+ if (s.peakScore) totalPeakPoints += s.peakScore;
7575+ }
7676+ const avgPeakPoints = verifiedStories.length
7777+ ? Math.round(totalPeakPoints / verifiedStories.length)
7878+ : 0;
7979+8080+ return {
8181+ totalCount: verifiedUsersCount,
8282+ frontPageCount: frontPageCount,
8383+ avgPeakPoints: avgPeakPoints,
8484+ };
8585+ });
8686+8787+ // 4. Optional: Warm up top 5 story snapshots (preload most accessed story graphs)
8888+ // This is done with lower priority as it's less critical
8989+ console.log("Preloading top story snapshots (limited to 5)...");
9090+9191+ // Get IDs of top 5 stories to warm their snapshots
9292+ const topStories = await db.query.stories.findMany({
9393+ where: (stories, { eq }) => eq(stories.isOnLeaderboard, true),
9494+ orderBy: (stories, { asc }) => [asc(stories.position)],
9595+ limit: 5, // Reduced from 20 to 5 to minimize initial load
9696+ });
9797+9898+ // Preload snapshots for these stories sequentially
9999+ for (const story of topStories) {
100100+ await queryCache.get(`story_snapshots_${story.id}`, async () => {
101101+ // Get snapshots for the story
102102+ const snapshots = await db.query.leaderboardSnapshots.findMany({
103103+ where: (snapshots, { eq }) => eq(snapshots.storyId, story.id),
104104+ orderBy: (snapshots, { asc }) => [asc(snapshots.timestamp)],
105105+ });
106106+107107+ // Transform snapshot data for frontend
108108+ return snapshots.map((snapshot) => ({
109109+ timestamp: snapshot.timestamp,
110110+ position: snapshot.position,
111111+ score: snapshot.score,
112112+ date: new Date(snapshot.timestamp * 1000).toISOString(),
113113+ }));
114114+ }, 3600); // Cache story snapshots for 1 hour
115115+ }
116116+117117+ console.log("Cache preloading completed successfully");
118118+ } catch (error) {
119119+ console.error("Error during cache preloading:", error);
120120+ Sentry.captureException(error);
121121+ }
122122+}
123123+124124+/**
125125+ * Invalidates all caches and then immediately reloads them
126126+ * Call this after data updates (like the HN check cron job)
127127+ */
128128+export function invalidateAndRefreshCaches(): void {
129129+ console.log("Invalidating all query caches and refreshing data");
130130+ queryCache.invalidateAll();
131131+132132+ // Immediately refill the cache
133133+ preloadCaches().catch(err => {
134134+ console.error("Error during cache preloading after invalidation:", err);
135135+ Sentry.captureException(err);
136136+ });
137137+}
+16-2
src/libs/db.ts
···55// Use environment variable for the database path in production
66const dbPath = process.env.DATABASE_PATH || "./local.db";
7788-// Create a SQLite database instance using Bun's built-in driver
99-const sqlite = new Database(dbPath);
88+// Create a SQLite database instance using Bun's built-in driver with improved concurrency settings
99+const sqlite = new Database(dbPath, {
1010+ // Use WAL mode for better concurrency
1111+ readonly: false,
1212+ create: true
1313+});
1414+1515+// Set a longer busy timeout to reduce "database is locked" errors
1616+sqlite.exec("PRAGMA busy_timeout = 5000;");
1717+1818+// Enable Write-Ahead Logging mode for better concurrent performance
1919+sqlite.exec("PRAGMA journal_mode = WAL;");
2020+// Set synchronous mode for better performance (still safe in WAL mode)
2121+sqlite.exec("PRAGMA synchronous = NORMAL;");
2222+// Increase cache size for better performance
2323+sqlite.exec("PRAGMA cache_size = -16000;"); // Use ~16MB of memory for cache
10241125// Create a Drizzle instance with the database and schema
1226export const db = drizzle(sqlite, { schema });
+112-59
src/libs/schema.ts
···11-import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
11+import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
2233// Define the users table
44-export const users = sqliteTable("users", {
55- id: text("id").primaryKey(),
66- hackernewsUsername: text("hackernews_username"),
77- challenge: text("challenge"),
88- verified: integer("verified", { mode: "boolean" }).default(false).notNull(),
99-});
44+export const users = sqliteTable(
55+ "users",
66+ {
77+ id: text("id").primaryKey(),
88+ hackernewsUsername: text("hackernews_username"),
99+ challenge: text("challenge"),
1010+ verified: integer("verified", { mode: "boolean" }).default(false).notNull(),
1111+ },
1212+ (table) => ({
1313+ // Add index on verified status for faster lookup of verified users
1414+ verifiedIdx: index("idx_users_verified").on(table.verified),
1515+ // Add index on hackernews username for quick lookups
1616+ usernameIdx: index("idx_users_username").on(table.hackernewsUsername),
1717+ }),
1818+);
10191111-export const stories = sqliteTable("stories", {
1212- id: integer("id").primaryKey(),
1313- by: text("by").notNull(),
1414- title: text("title").notNull(),
1515- url: text("url"),
1616- text: text("text"),
1717- time: integer("time").notNull(),
1818- score: integer("score"),
1919- position: integer("position"),
2020- descendants: integer("descendants"),
2020+export const stories = sqliteTable(
2121+ "stories",
2222+ {
2323+ id: integer("id").primaryKey(),
2424+ by: text("by").notNull(),
2525+ title: text("title").notNull(),
2626+ url: text("url"),
2727+ text: text("text"),
2828+ time: integer("time").notNull(),
2929+ score: integer("score"),
3030+ position: integer("position"),
3131+ descendants: integer("descendants"),
21322222- // New tracking fields
2323- firstSeenAt: integer("first_seen_at").notNull(), // When we first saw it
2424- lastUpdatedAt: integer("last_updated_at").notNull(), // Last time we updated this record
2525- notifiedAt: integer("notified_at"), // When first notification was sent
3333+ // New tracking fields
3434+ firstSeenAt: integer("first_seen_at").notNull(), // When we first saw it
3535+ lastUpdatedAt: integer("last_updated_at").notNull(), // Last time we updated this record
3636+ notifiedAt: integer("notified_at"), // When first notification was sent
26372727- // Notification tracking flags - avoids duplicate notifications on restart
2828- notifiedNewStory: integer("notified_new_story", { mode: "boolean" }).default(
2929- false,
3030- ),
3131- notifiedFrontPage: integer("notified_front_page", {
3232- mode: "boolean",
3333- }).default(false),
3434- notifiedNumberOne: integer("notified_number_one", {
3535- mode: "boolean",
3636- }).default(false),
3838+ // Notification tracking flags - avoids duplicate notifications on restart
3939+ notifiedNewStory: integer("notified_new_story", {
4040+ mode: "boolean",
4141+ }).default(false),
4242+ notifiedFrontPage: integer("notified_front_page", {
4343+ mode: "boolean",
4444+ }).default(false),
4545+ notifiedNumberOne: integer("notified_number_one", {
4646+ mode: "boolean",
4747+ }).default(false),
37483838- // Leaderboard tracking
3939- isOnLeaderboard: integer("is_on_leaderboard", { mode: "boolean" }).default(
4040- false,
4141- ),
4242- enteredLeaderboardAt: integer("entered_leaderboard_at"),
4343- exitedLeaderboardAt: integer("exited_leaderboard_at"),
4444- peakPosition: integer("peak_position"),
4545- peakPositionAt: integer("peak_position_at"),
4646- peakScore: integer("peak_score"),
4747- peakScoreAt: integer("peak_score_at"),
4949+ // Leaderboard tracking
5050+ isOnLeaderboard: integer("is_on_leaderboard", { mode: "boolean" }).default(
5151+ false,
5252+ ),
5353+ enteredLeaderboardAt: integer("entered_leaderboard_at"),
5454+ exitedLeaderboardAt: integer("exited_leaderboard_at"),
5555+ peakPosition: integer("peak_position"),
5656+ peakPositionAt: integer("peak_position_at"),
5757+ peakScore: integer("peak_score"),
5858+ peakScoreAt: integer("peak_score_at"),
48594949- // Tracking if this is from a monitored user
5050- isFromMonitoredUser: integer("is_from_monitored_user", {
5151- mode: "boolean",
5252- }).default(false),
6060+ // Tracking if this is from a monitored user
6161+ isFromMonitoredUser: integer("is_from_monitored_user", {
6262+ mode: "boolean",
6363+ }).default(false),
53645454- // Cache management - TTL field for automatic cleanup
5555- expiresAt: integer("expires_at"), // NULL for monitored user stories (permanent)
5656-});
6565+ // Cache management - TTL field for automatic cleanup
6666+ expiresAt: integer("expires_at"), // NULL for monitored user stories (permanent)
6767+ },
6868+ (table) => {
6969+ return {
7070+ // Add index on leaderboard status
7171+ leaderboardIdx: index("idx_stories_leaderboard").on(
7272+ table.isOnLeaderboard,
7373+ ),
7474+ // Add index on monitored user stories
7575+ monitoredUserIdx: index("idx_stories_monitored_user").on(
7676+ table.isFromMonitoredUser,
7777+ ),
7878+ // Add compound index for sorting leaderboard stories by position
7979+ leaderboardPosIdx: index("idx_stories_leaderboard_position").on(
8080+ table.isOnLeaderboard,
8181+ table.position,
8282+ ),
8383+ // Add index on by field for user-specific queries
8484+ byIdx: index("idx_stories_by").on(table.by),
8585+ // Add index on expiration for cleanup queries
8686+ expiresIdx: index("idx_stories_expires").on(table.expiresAt),
8787+ // Add index on time for timeline queries
8888+ timeIdx: index("idx_stories_time").on(table.time),
8989+ };
9090+ },
9191+);
57925893// For tracking leaderboard positions
5959-export const leaderboardSnapshots = sqliteTable("leaderboard_snapshots", {
6060- id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
6161- storyId: integer("story_id")
6262- .notNull()
6363- .references(() => stories.id),
6464- timestamp: integer("timestamp").notNull(),
6565- position: integer("position").notNull(),
6666- score: integer("score").notNull(),
9494+export const leaderboardSnapshots = sqliteTable(
9595+ "leaderboard_snapshots",
9696+ {
9797+ id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
9898+ storyId: integer("story_id")
9999+ .notNull()
100100+ .references(() => stories.id),
101101+ timestamp: integer("timestamp").notNull(),
102102+ position: integer("position").notNull(),
103103+ score: integer("score").notNull(),
671046868- // TTL for cleanup
6969- expiresAt: integer("expires_at").notNull(),
7070-});
105105+ // TTL for cleanup
106106+ expiresAt: integer("expires_at").notNull(),
107107+ },
108108+ (table) => {
109109+ return {
110110+ // Add index on story ID for faster lookups of a story's snapshots
111111+ storyIdx: index("idx_snapshots_story").on(table.storyId),
112112+ // Add index on timestamp for chronological queries
113113+ timestampIdx: index("idx_snapshots_timestamp").on(table.timestamp),
114114+ // Add compound index for a story's chronological snapshots
115115+ storyTimeIdx: index("idx_snapshots_story_time").on(
116116+ table.storyId,
117117+ table.timestamp,
118118+ ),
119119+ // Add index on expiration for cleanup queries
120120+ expiresIdx: index("idx_snapshots_expires").on(table.expiresAt),
121121+ };
122122+ },
123123+);