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 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 };