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