image cache on cloudflare r2
1import { Database } from "bun:sqlite";
2import { nanoid } from "nanoid";
3
4const DB_PATH = process.env.STATS_DB_PATH || "./data/stats.db";
5const db = new Database(DB_PATH, { create: true });
6
7// Generate realistic fake data
8const _imageKeys: string[] = [];
9const numImages = 500; // More images for variety
10
11// Generate fake image keys with varying introduction dates
12const now = Math.floor(Date.now() / 1000);
13const oneYearAgo = now - 365 * 86400;
14const _thirtyDaysAgo = now - 30 * 86400;
15const oneDayAgo = now - 86400;
16
17interface Image {
18 key: string;
19 introducedAt: number; // When this image first appeared
20 basePopularity: number; // Intrinsic popularity (0-1)
21 trendFactor: number; // How much popularity changes over time (-0.5 to 0.5)
22 viralPeak?: number; // Optional viral spike timestamp
23}
24
25const images: Image[] = [];
26
27// Create images with staggered introduction dates
28for (let i = 0; i < numImages; i++) {
29 const introDate = oneYearAgo + Math.random() * (now - oneYearAgo);
30 const basePopularity = Math.random() ** 1.5; // Skew toward lower popularity
31 const trendFactor = (Math.random() - 0.5) * 0.8; // -0.4 to 0.4
32
33 const image: Image = {
34 key: `${nanoid(12)}.webp`,
35 introducedAt: introDate,
36 basePopularity,
37 trendFactor,
38 };
39
40 // 10% chance of having a viral spike
41 if (Math.random() < 0.1) {
42 image.viralPeak = introDate + Math.random() * (now - introDate);
43 }
44
45 images.push(image);
46}
47
48console.log("Seeding database with fake data (1 year)...");
49
50// Seed all three tables
51const hourlyStmt = db.prepare(`
52 INSERT INTO image_stats (image_key, bucket_hour, hits)
53 VALUES (?1, ?2, ?3)
54 ON CONFLICT(image_key, bucket_hour) DO UPDATE SET hits = hits + ?3
55`);
56
57const dailyStmt = db.prepare(`
58 INSERT INTO image_stats_daily (image_key, bucket_day, hits)
59 VALUES (?1, ?2, ?3)
60 ON CONFLICT(image_key, bucket_day) DO UPDATE SET hits = hits + ?3
61`);
62
63console.log("Seeding hourly and daily data (1 year ago to 24 hours ago)...");
64let _totalHourlyHits = 0;
65let _totalDailyHits = 0;
66
67for (let timestamp = oneYearAgo; timestamp < oneDayAgo; timestamp += 3600) {
68 const bucketHour = timestamp - (timestamp % 3600);
69 const bucketDay = timestamp - (timestamp % 86400);
70
71 // Time-based factors
72 const date = new Date(timestamp * 1000);
73 const dayOfWeek = date.getUTCDay();
74 const hour = date.getUTCHours();
75 const month = date.getUTCMonth();
76
77 // Weekly pattern (weekdays busier)
78 const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
79 const weekdayMultiplier = isWeekend ? 0.6 : 1.0;
80
81 // Daily pattern (business hours busier, with some randomness)
82 const isBusinessHours = hour >= 9 && hour <= 17;
83 const hourMultiplier = isBusinessHours
84 ? 1.2 + Math.random() * 0.4 // 1.2-1.6
85 : 0.4 + Math.random() * 0.3; // 0.4-0.7
86
87 // Seasonal pattern (summer busier)
88 const isSummer = month >= 5 && month <= 8; // June-Sept
89 const seasonalMultiplier = isSummer ? 1.3 : 0.9;
90
91 // Overall growth trend (traffic increases over time)
92 const timeProgress = (timestamp - oneYearAgo) / (now - oneYearAgo);
93 const growthMultiplier = 0.7 + timeProgress * 0.6; // 0.7 to 1.3
94
95 // Random noise to break perfect cycles
96 const noiseMultiplier = 0.85 + Math.random() * 0.3; // 0.85-1.15
97
98 const baseActivity =
99 0.25 *
100 weekdayMultiplier *
101 hourMultiplier *
102 seasonalMultiplier *
103 growthMultiplier *
104 noiseMultiplier;
105
106 for (const image of images) {
107 // Skip if image doesn't exist yet
108 if (timestamp < image.introducedAt) continue;
109
110 // Calculate image-specific popularity at this time
111 const timeSinceIntro = timestamp - image.introducedAt;
112 const ageInDays = timeSinceIntro / 86400;
113
114 // Popularity changes over time (trend factor)
115 const trendProgress = Math.min(ageInDays / 180, 1); // Over 6 months
116 const trendedPopularity =
117 image.basePopularity + image.trendFactor * trendProgress;
118
119 // Viral spike (if any)
120 let viralBoost = 1;
121 if (image.viralPeak) {
122 const distanceFromPeak = Math.abs(timestamp - image.viralPeak);
123 const peakWindow = 7 * 86400; // 7 day spike
124 if (distanceFromPeak < peakWindow) {
125 viralBoost = 1 + 5 * (1 - distanceFromPeak / peakWindow); // Up to 6x boost
126 }
127 }
128
129 // New images get a temporary boost
130 const newImageBoost = ageInDays < 3 ? 1 + (3 - ageInDays) * 0.5 : 1;
131
132 const finalPopularity = trendedPopularity * viralBoost * newImageBoost;
133
134 if (Math.random() < baseActivity * finalPopularity) {
135 // Power law distribution for hit counts (most hits are small, some are large)
136 const hits = Math.max(1, Math.floor(Math.random() ** 3 * 200));
137 hourlyStmt.run(image.key, bucketHour, hits);
138 dailyStmt.run(image.key, bucketDay, hits);
139 _totalHourlyHits += hits;
140 _totalDailyHits += hits;
141 }
142 }
143
144 // Progress indicator every 30 days
145 if ((timestamp - oneYearAgo) % (30 * 86400) === 0) {
146 const daysProcessed = Math.floor((timestamp - oneYearAgo) / 86400);
147 console.log(` Processed ${daysProcessed} days...`);
148 }
149}
150
151// Seed 10-minute data for last 24 hours
152const tenMinStmt = db.prepare(`
153 INSERT INTO image_stats_10min (image_key, bucket_10min, hits)
154 VALUES (?1, ?2, ?3)
155 ON CONFLICT(image_key, bucket_10min) DO UPDATE SET hits = hits + ?3
156`);
157
158console.log("Seeding 10-minute, hourly, and daily data (last 24 hours)...");
159let _total10MinHits = 0;
160
161for (let timestamp = oneDayAgo; timestamp <= now; timestamp += 600) {
162 const bucket10Min = timestamp - (timestamp % 600);
163 const bucketHour = timestamp - (timestamp % 3600);
164 const bucketDay = timestamp - (timestamp % 86400);
165
166 // Recent data gets slightly higher activity
167 const recency = (timestamp - oneDayAgo) / (now - oneDayAgo);
168 const date = new Date(timestamp * 1000);
169 const dayOfWeek = date.getUTCDay();
170 const hour = date.getUTCHours();
171
172 const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
173 const weekdayMultiplier = isWeekend ? 0.6 : 1.0;
174
175 const isBusinessHours = hour >= 9 && hour <= 17;
176 const hourMultiplier = isBusinessHours
177 ? 1.3 + Math.random() * 0.4
178 : 0.5 + Math.random() * 0.3;
179
180 const noiseMultiplier = 0.85 + Math.random() * 0.3;
181
182 const baseActivity =
183 0.35 *
184 (1 + recency * 0.3) *
185 weekdayMultiplier *
186 hourMultiplier *
187 noiseMultiplier;
188
189 for (const image of images) {
190 if (timestamp < image.introducedAt) continue;
191
192 const timeSinceIntro = timestamp - image.introducedAt;
193 const ageInDays = timeSinceIntro / 86400;
194 const trendProgress = Math.min(ageInDays / 180, 1);
195 const trendedPopularity =
196 image.basePopularity + image.trendFactor * trendProgress;
197
198 let viralBoost = 1;
199 if (image.viralPeak) {
200 const distanceFromPeak = Math.abs(timestamp - image.viralPeak);
201 const peakWindow = 7 * 86400;
202 if (distanceFromPeak < peakWindow) {
203 viralBoost = 1 + 5 * (1 - distanceFromPeak / peakWindow);
204 }
205 }
206
207 const newImageBoost = ageInDays < 3 ? 1 + (3 - ageInDays) * 0.5 : 1;
208 const finalPopularity = trendedPopularity * viralBoost * newImageBoost;
209
210 if (Math.random() < baseActivity * finalPopularity) {
211 const hits = Math.max(1, Math.floor(Math.random() ** 3 * 100));
212 tenMinStmt.run(image.key, bucket10Min, hits);
213 hourlyStmt.run(image.key, bucketHour, hits);
214 dailyStmt.run(image.key, bucketDay, hits);
215 _total10MinHits += hits;
216 }
217 }
218}
219
220// Get summary stats
221const totalHitsHourly = db
222 .prepare(`SELECT SUM(hits) as total FROM image_stats`)
223 .get() as { total: number };
224const totalHitsDaily = db
225 .prepare(`SELECT SUM(hits) as total FROM image_stats_daily`)
226 .get() as { total: number };
227const totalHits10Min = db
228 .prepare(`SELECT SUM(hits) as total FROM image_stats_10min`)
229 .get() as { total: number };
230const uniqueImages = db
231 .prepare(`
232 SELECT COUNT(DISTINCT image_key) as count FROM (
233 SELECT image_key FROM image_stats
234 UNION
235 SELECT image_key FROM image_stats_10min
236 UNION
237 SELECT image_key FROM image_stats_daily
238 )
239`)
240 .get() as { count: number };
241const hourBuckets = db
242 .prepare(`SELECT COUNT(*) as count FROM image_stats`)
243 .get() as { count: number };
244const dayBuckets = db
245 .prepare(`SELECT COUNT(*) as count FROM image_stats_daily`)
246 .get() as { count: number };
247const tenMinBuckets = db
248 .prepare(`SELECT COUNT(*) as count FROM image_stats_10min`)
249 .get() as { count: number };
250const oldestHit = db
251 .prepare(`SELECT MIN(bucket_hour) as min FROM image_stats`)
252 .get() as { min: number };
253const newestHit = db
254 .prepare(`SELECT MAX(bucket_hour) as max FROM image_stats`)
255 .get() as { max: number };
256
257console.log("\nSeeding complete!");
258console.log(`- Total hits (hourly): ${totalHitsHourly.total.toLocaleString()}`);
259console.log(`- Total hits (daily): ${totalHitsDaily.total.toLocaleString()}`);
260console.log(`- Total hits (10-min): ${totalHits10Min.total.toLocaleString()}`);
261console.log(`- Unique images: ${uniqueImages.count}`);
262console.log(`- Hourly buckets: ${hourBuckets.count.toLocaleString()}`);
263console.log(`- Daily buckets: ${dayBuckets.count.toLocaleString()}`);
264console.log(`- 10-minute buckets: ${tenMinBuckets.count.toLocaleString()}`);
265console.log(
266 `- Time range: ${new Date(oldestHit.min * 1000).toISOString()} to ${new Date(newestHit.max * 1000).toISOString()}`,
267);
268console.log(
269 `- Days of data: ${Math.floor((newestHit.max - oldestHit.min) / 86400)}`,
270);