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