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