a cache for slack profile pictures and emojis
at v0.1.4 10 kB view raw
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 cache for a specific user 141 * @param userId The Slack user ID to purge from cache 142 * @returns boolean indicating if any user was purged 143 */ 144 async purgeUserCache(userId: string): Promise<boolean> { 145 try { 146 const result = this.db.run("DELETE FROM users WHERE userId = ?", [ 147 userId.toUpperCase(), 148 ]); 149 return result.changes > 0; 150 } catch (error) { 151 console.error("Error purging user cache:", error); 152 return false; 153 } 154 } 155 156 /** 157 * Purges all items from the cache 158 * @returns Object containing purge results 159 */ 160 async purgeAll(): Promise<{ 161 message: string; 162 users: number; 163 emojis: number; 164 }> { 165 const result = this.db.run("DELETE FROM users"); 166 const result2 = this.db.run("DELETE FROM emojis"); 167 168 if (this.onEmojiExpired) { 169 if (result2.changes > 0) { 170 this.onEmojiExpired(); 171 } 172 } 173 174 return { 175 message: "Cache purged", 176 users: result.changes, 177 emojis: result2.changes, 178 }; 179 } 180 181 /** 182 * Checks if the cache is healthy by testing database connectivity 183 * @returns boolean indicating if cache is healthy 184 */ 185 async healthCheck(): Promise<boolean> { 186 try { 187 this.db.query("SELECT 1").get(); 188 return true; 189 } catch (error) { 190 console.error("Cache health check failed:", error); 191 return false; 192 } 193 } 194 195 /** 196 * Inserts a user into the cache 197 * @param userId Unique identifier for the user 198 * @param imageUrl URL of the user's image 199 * @param expirationHours Optional custom expiration time in hours 200 * @returns boolean indicating success 201 */ 202 async insertUser( 203 userId: string, 204 displayName: string, 205 pronouns: string, 206 imageUrl: string, 207 expirationHours?: number, 208 ) { 209 const id = crypto.randomUUID(); 210 const expiration = 211 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 212 213 try { 214 this.db.run( 215 `INSERT INTO users (id, userId, displayName, pronouns, imageUrl, expiration) 216 VALUES (?, ?, ?, ?, ?, ?) 217 ON CONFLICT(userId) 218 DO UPDATE SET imageUrl = ?, expiration = ?`, 219 [ 220 id, 221 userId.toUpperCase(), 222 displayName, 223 pronouns, 224 imageUrl, 225 expiration, 226 imageUrl, 227 expiration, 228 ], 229 ); 230 return true; 231 } catch (error) { 232 console.error("Error inserting/updating user:", error); 233 return false; 234 } 235 } 236 237 /** 238 * Inserts an emoji into the cache 239 * @param name Name of the emoji 240 * @param imageUrl URL of the emoji image 241 * @param expirationHours Optional custom expiration time in hours 242 * @returns boolean indicating success 243 */ 244 async insertEmoji( 245 name: string, 246 alias: string | null, 247 imageUrl: string, 248 expirationHours?: number, 249 ) { 250 const id = crypto.randomUUID(); 251 const expiration = 252 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 253 254 try { 255 this.db.run( 256 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 257 VALUES (?, ?, ?, ?, ?) 258 ON CONFLICT(name) 259 DO UPDATE SET imageUrl = ?, expiration = ?`, 260 [ 261 id, 262 name.toLowerCase(), 263 alias?.toLowerCase() || null, 264 imageUrl, 265 expiration, 266 imageUrl, 267 expiration, 268 ], 269 ); 270 return true; 271 } catch (error) { 272 console.error("Error inserting/updating emoji:", error); 273 return false; 274 } 275 } 276 277 /** 278 * Batch inserts multiple emojis into the cache 279 * @param emojis Array of {name, imageUrl} objects to insert 280 * @param expirationHours Optional custom expiration time in hours for all emojis 281 * @returns boolean indicating if all insertions were successful 282 */ 283 async batchInsertEmojis( 284 emojis: Array<{ name: string; imageUrl: string; alias: string | null }>, 285 expirationHours?: number, 286 ): Promise<boolean> { 287 try { 288 const expiration = 289 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 290 291 this.db.transaction(() => { 292 for (const emoji of emojis) { 293 const id = crypto.randomUUID(); 294 this.db.run( 295 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 296 VALUES (?, ?, ?, ?, ?) 297 ON CONFLICT(name) 298 DO UPDATE SET imageUrl = ?, expiration = ?`, 299 [ 300 id, 301 emoji.name.toLowerCase(), 302 emoji.alias?.toLowerCase() || null, 303 emoji.imageUrl, 304 expiration, 305 emoji.imageUrl, 306 expiration, 307 ], 308 ); 309 } 310 })(); 311 312 return true; 313 } catch (error) { 314 console.error("Error batch inserting emojis:", error); 315 return false; 316 } 317 } 318 319 /** 320 * Lists all emoji in the cache 321 * @returns Array of Emoji objects that haven't expired 322 */ 323 async listEmojis(): Promise<Emoji[]> { 324 const results = this.db 325 .query("SELECT * FROM emojis WHERE expiration > ?") 326 .all(Date.now()) as Emoji[]; 327 328 return results.map((result) => ({ 329 type: "emoji", 330 id: result.id, 331 name: result.name, 332 alias: result.alias || null, 333 imageUrl: result.imageUrl, 334 expiration: new Date(result.expiration), 335 })); 336 } 337 338 /** 339 * Retrieves a user from the cache 340 * @param userId Unique identifier of the user 341 * @returns User object if found and not expired, null otherwise 342 */ 343 async getUser(userId: string): Promise<User | null> { 344 const result = this.db 345 .query("SELECT * FROM users WHERE userId = ?") 346 .get(userId.toUpperCase()) as User; 347 348 if (!result) { 349 return null; 350 } 351 352 if (new Date(result.expiration).getTime() < Date.now()) { 353 this.db.run("DELETE FROM users WHERE userId = ?", [userId]); 354 return null; 355 } 356 357 return { 358 type: "user", 359 id: result.id, 360 userId: result.userId, 361 displayName: result.displayName, 362 pronouns: result.pronouns, 363 imageUrl: result.imageUrl, 364 expiration: new Date(result.expiration), 365 }; 366 } 367 368 /** 369 * Retrieves an emoji from the cache 370 * @param name Name of the emoji 371 * @returns Emoji object if found and not expired, null otherwise 372 */ 373 async getEmoji(name: string): Promise<Emoji | null> { 374 const result = this.db 375 .query("SELECT * FROM emojis WHERE name = ? AND expiration > ?") 376 .get(name.toLowerCase(), Date.now()) as Emoji; 377 378 return result 379 ? { 380 type: "emoji", 381 id: result.id, 382 name: result.name, 383 alias: result.alias || null, 384 imageUrl: result.imageUrl, 385 expiration: new Date(result.expiration), 386 } 387 : null; 388 } 389} 390 391export { Cache as SlackCache };