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