a cache for slack profile pictures and emojis
1import { Database } from "bun:sqlite"; 2import { schedule } from "node-cron"; 3 4/** 5 * @fileoverview This file contains the Cache class for storing user and emoji data with automatic expiration. To use the module in your project, import the default export and create a new instance of the Cache class. The class provides methods for inserting and retrieving user and emoji data from the cache. The cache automatically purges expired items every hour. 6 * @module cache 7 * @requires bun:sqlite 8 * @requires node-cron 9 */ 10 11/** 12 * Base interface for cached items 13 */ 14interface CacheItem { 15 id: string; 16 imageUrl: string; 17 expiration: Date; 18} 19 20/** 21 * Interface for cached user data 22 */ 23interface User extends CacheItem { 24 type: "user"; 25 userId: string; 26} 27 28/** 29 * Interface for cached emoji data 30 */ 31interface Emoji extends CacheItem { 32 type: "emoji"; 33 name: string; 34 alias: string | null; 35} 36 37type CacheTypes = User | Emoji; 38 39/** 40 * Cache class for storing user and emoji data with automatic expiration 41 */ 42class Cache { 43 private db: Database; 44 private defaultExpiration: number; // in hours 45 private onEmojiExpired?: () => void; 46 47 /** 48 * Creates a new Cache instance 49 * @param dbPath Path to SQLite database file 50 * @param defaultExpirationHours Default cache expiration in hours 51 * @param onEmojiExpired Optional callback function called when emojis expire 52 */ 53 constructor( 54 dbPath: string, 55 defaultExpirationHours = 24, 56 onEmojiExpired?: () => void, 57 ) { 58 this.db = new Database(dbPath); 59 this.defaultExpiration = defaultExpirationHours; 60 this.onEmojiExpired = onEmojiExpired; 61 62 this.initDatabase(); 63 this.setupPurgeSchedule(); 64 } 65 66 /** 67 * Initializes the database tables 68 * @private 69 */ 70 private initDatabase() { 71 // Create users table 72 this.db.run(` 73 CREATE TABLE IF NOT EXISTS users ( 74 id TEXT PRIMARY KEY, 75 userId TEXT UNIQUE, 76 imageUrl TEXT, 77 expiration INTEGER 78 ) 79 `); 80 81 // Create emojis table 82 this.db.run(` 83 CREATE TABLE IF NOT EXISTS emojis ( 84 id TEXT PRIMARY KEY, 85 name TEXT UNIQUE, 86 alias TEXT, 87 imageUrl TEXT, 88 expiration INTEGER 89 ) 90 `); 91 92 // check if there are any emojis in the db 93 if (this.onEmojiExpired) { 94 const result = this.db 95 .query("SELECT COUNT(*) as count FROM emojis WHERE expiration > ?") 96 .get(Date.now()) as { count: number }; 97 if (result.count === 0) { 98 this.onEmojiExpired(); 99 } 100 } 101 } 102 103 /** 104 * Sets up hourly purge of expired items 105 * @private 106 */ 107 private setupPurgeSchedule() { 108 // Run purge every hour 109 schedule("0 * * * *", async () => { 110 await this.purgeExpiredItems(); 111 }); 112 } 113 114 /* 115 * Purges expired items from the cache 116 * @returns int indicating number of items purged 117 */ 118 async purgeExpiredItems(): Promise<number> { 119 const result = this.db.run("DELETE FROM users WHERE expiration < ?", [ 120 Date.now(), 121 ]); 122 const result2 = this.db.run("DELETE FROM emojis WHERE expiration < ?", [ 123 Date.now(), 124 ]); 125 126 if (this.onEmojiExpired) { 127 if (result2.changes > 0) { 128 this.onEmojiExpired(); 129 } 130 } 131 132 return result.changes + result2.changes; 133 } 134 135 /* 136 * Purges all items from the cache 137 * @returns int indicating number of items purged 138 */ 139 async purgeAll(): Promise<number> { 140 const result = this.db.run("DELETE FROM users", [Date.now()]); 141 const result2 = this.db.run("DELETE FROM emojis", [Date.now()]); 142 143 if (this.onEmojiExpired) { 144 if (result2.changes > 0) { 145 this.onEmojiExpired(); 146 } 147 } 148 149 return result.changes + result2.changes; 150 } 151 152 /** 153 * Checks if the cache is healthy by testing database connectivity 154 * @returns boolean indicating if cache is healthy 155 */ 156 async healthCheck(): Promise<boolean> { 157 try { 158 this.db.query("SELECT 1").get(); 159 return true; 160 } catch (error) { 161 console.error("Cache health check failed:", error); 162 return false; 163 } 164 } 165 166 /** 167 * Inserts a user into the cache 168 * @param userId Unique identifier for the user 169 * @param imageUrl URL of the user's image 170 * @param expirationHours Optional custom expiration time in hours 171 * @returns boolean indicating success 172 */ 173 async insertUser(userId: string, imageUrl: string, expirationHours?: number) { 174 const id = crypto.randomUUID(); 175 const expiration = 176 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 177 178 try { 179 this.db.run( 180 `INSERT INTO users (id, userId, imageUrl, expiration) 181 VALUES (?, ?, ?, ?) 182 ON CONFLICT(userId) 183 DO UPDATE SET imageUrl = ?, expiration = ?`, 184 [id, userId, imageUrl, expiration, imageUrl, expiration], 185 ); 186 return true; 187 } catch (error) { 188 console.error("Error inserting/updating user:", error); 189 return false; 190 } 191 } 192 193 /** 194 * Inserts an emoji into the cache 195 * @param name Name of the emoji 196 * @param imageUrl URL of the emoji image 197 * @param expirationHours Optional custom expiration time in hours 198 * @returns boolean indicating success 199 */ 200 async insertEmoji( 201 name: string, 202 alias: string | null, 203 imageUrl: string, 204 expirationHours?: number, 205 ) { 206 const id = crypto.randomUUID(); 207 const expiration = 208 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 209 210 try { 211 this.db.run( 212 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 213 VALUES (?, ?, ?, ?, ?) 214 ON CONFLICT(name) 215 DO UPDATE SET imageUrl = ?, expiration = ?`, 216 [id, name, alias, imageUrl, expiration, imageUrl, expiration], 217 ); 218 return true; 219 } catch (error) { 220 console.error("Error inserting/updating emoji:", error); 221 return false; 222 } 223 } 224 225 /** 226 * Batch inserts multiple emojis into the cache 227 * @param emojis Array of {name, imageUrl} objects to insert 228 * @param expirationHours Optional custom expiration time in hours for all emojis 229 * @returns boolean indicating if all insertions were successful 230 */ 231 async batchInsertEmojis( 232 emojis: Array<{ name: string; imageUrl: string; alias: string | null }>, 233 expirationHours?: number, 234 ): Promise<boolean> { 235 try { 236 const expiration = 237 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 238 239 this.db.transaction(() => { 240 for (const emoji of emojis) { 241 const id = crypto.randomUUID(); 242 this.db.run( 243 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 244 VALUES (?, ?, ?, ?, ?) 245 ON CONFLICT(name) 246 DO UPDATE SET imageUrl = ?, expiration = ?`, 247 [ 248 id, 249 emoji.name, 250 emoji.alias, 251 emoji.imageUrl, 252 expiration, 253 emoji.imageUrl, 254 expiration, 255 ], 256 ); 257 } 258 })(); 259 260 return true; 261 } catch (error) { 262 console.error("Error batch inserting emojis:", error); 263 return false; 264 } 265 } 266 267 /** 268 * Lists all emoji in the cache 269 * @returns Array of Emoji objects that haven't expired 270 */ 271 async listEmojis(): Promise<Emoji[]> { 272 const results = this.db 273 .query("SELECT * FROM emojis WHERE expiration > ?") 274 .all(Date.now()) as Emoji[]; 275 276 return results.map((result) => ({ 277 type: "emoji", 278 id: result.id, 279 name: result.name, 280 alias: result.alias || null, 281 imageUrl: result.imageUrl, 282 expiration: new Date(result.expiration), 283 })); 284 } 285 286 /** 287 * Retrieves a user from the cache 288 * @param userId Unique identifier of the user 289 * @returns User object if found and not expired, null otherwise 290 */ 291 async getUser(userId: string): Promise<User | null> { 292 const result = this.db 293 .query("SELECT * FROM users WHERE userId = ?") 294 .get(userId) as User; 295 296 if (!result) { 297 return null; 298 } 299 300 if (new Date(result.expiration).getTime() < Date.now()) { 301 this.db.run("DELETE FROM users WHERE userId = ?", [userId]); 302 return null; 303 } 304 305 return { 306 type: "user", 307 id: result.id, 308 userId: result.userId, 309 imageUrl: result.imageUrl, 310 expiration: new Date(result.expiration), 311 }; 312 } 313 314 /** 315 * Retrieves an emoji from the cache 316 * @param name Name of the emoji 317 * @returns Emoji object if found and not expired, null otherwise 318 */ 319 async getEmoji(name: string): Promise<Emoji | null> { 320 const result = this.db 321 .query("SELECT * FROM emojis WHERE name = ? AND expiration > ?") 322 .get(name, Date.now()) as Emoji; 323 324 return result 325 ? { 326 type: "emoji", 327 id: result.id, 328 name: result.name, 329 alias: result.alias || null, 330 imageUrl: result.imageUrl, 331 expiration: new Date(result.expiration), 332 } 333 : null; 334 } 335} 336 337export { Cache as SlackCache };