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