a cache for slack profile pictures and emojis

feat: add migration system and endpoint grouping migration

- Create migration manager to handle database schema and data changes
- Add endpoint grouping migration to fix analytics data
- Update cache to run migrations on startup
- Add migration documentation to README

🦊 Generated with Crush
Co-Authored-By: Crush <crush@charm.land>

dunkirk.sh 5f74e4a8 7c4eb984

verified
+37
README.md
··· 136 136 // { message: "User cache purged", userId: "U062UG485EE", success: true } 137 137 ``` 138 138 139 + ## Development 140 + 141 + ### Migrations 142 + 143 + The app includes a migration system to handle database schema and data changes between versions. Migrations are automatically run when the app starts. 144 + 145 + Previous versions are tracked in a `migrations` table in the database, which records each applied migration with its version number and timestamp. 146 + 147 + To create a new migration: 148 + 149 + ```typescript 150 + // src/migrations/myNewMigration.ts 151 + import { Database } from "bun:sqlite"; 152 + import { Migration } from "./types"; 153 + 154 + export const myNewMigration: Migration = { 155 + version: "0.3.2", // Should match package.json version 156 + description: "What this migration does", 157 + 158 + async up(db: Database): Promise<void> { 159 + // Migration code here 160 + db.run(`ALTER TABLE my_table ADD COLUMN new_column TEXT`); 161 + } 162 + }; 163 + 164 + // Then add to src/migrations/index.ts 165 + import { myNewMigration } from "./myNewMigration"; 166 + 167 + export const migrations: Migration[] = [ 168 + endpointGroupingMigration, 169 + myNewMigration, 170 + // Add new migrations here 171 + ]; 172 + ``` 173 + 174 + Remember to update the version in `package.json` when adding new migrations. 175 + 139 176 <p align="center"> 140 177 <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/master/.github/images/line-break.svg" /> 141 178 </p>
+1 -1
package.json
··· 1 1 { 2 2 "name": "cachet", 3 - "version": "0.3.0", 3 + "version": "0.3.1", 4 4 "scripts": { 5 5 "test": "echo \"Error: no test specified\" && exit 1", 6 6 "dev": "bun run --watch src/index.ts",
+25
src/cache.ts
··· 1 1 import { Database } from "bun:sqlite"; 2 2 import { schedule } from "node-cron"; 3 + import { MigrationManager } from "./migrations/migrationManager"; 4 + import { endpointGroupingMigration } from "./migrations/endpointGroupingMigration"; 3 5 4 6 /** 5 7 * @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. ··· 61 63 62 64 this.initDatabase(); 63 65 this.setupPurgeSchedule(); 66 + 67 + // Run migrations 68 + this.runMigrations(); 64 69 } 65 70 66 71 /** ··· 136 141 schedule("45 * * * *", async () => { 137 142 await this.purgeExpiredItems(); 138 143 }); 144 + } 145 + 146 + /** 147 + * Run database migrations 148 + * @private 149 + */ 150 + private async runMigrations() { 151 + try { 152 + const migrations = [endpointGroupingMigration]; 153 + const migrationManager = new MigrationManager(this.db, migrations); 154 + const result = await migrationManager.runMigrations(); 155 + 156 + if (result.migrationsApplied > 0) { 157 + console.log(`Applied ${result.migrationsApplied} migrations. Latest version: ${result.lastAppliedVersion}`); 158 + } else { 159 + console.log("No new migrations to apply"); 160 + } 161 + } catch (error) { 162 + console.error("Error running migrations:", error); 163 + } 139 164 } 140 165 141 166 /**
+66
src/migrations/endpointGroupingMigration.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { Migration } from "./types"; 3 + 4 + /** 5 + * Migration to fix endpoint grouping in analytics 6 + * This migration updates existing analytics data to use consistent endpoint grouping 7 + */ 8 + export const endpointGroupingMigration: Migration = { 9 + version: "0.3.1", 10 + description: "Fix endpoint grouping in analytics data", 11 + 12 + async up(db: Database): Promise<void> { 13 + console.log("Running endpoint grouping migration..."); 14 + 15 + // Get all request_analytics entries with specific URLs 16 + const results = db.query(` 17 + SELECT id, endpoint FROM request_analytics 18 + WHERE endpoint LIKE '/users/%' OR endpoint LIKE '/emojis/%' 19 + `).all() as Array<{ id: string; endpoint: string }>; 20 + 21 + console.log(`Found ${results.length} entries to update`); 22 + 23 + // Process each entry and update with the correct grouping 24 + for (const entry of results) { 25 + let newEndpoint = entry.endpoint; 26 + 27 + // Apply the same grouping logic we use in the analytics 28 + if (entry.endpoint.match(/^\/users\/[^\/]+$/)) { 29 + // Keep as is - these are already correctly grouped 30 + continue; 31 + } else if (entry.endpoint.match(/^\/users\/[^\/]+\/r$/)) { 32 + // Keep as is - these are already correctly grouped 33 + continue; 34 + } else if (entry.endpoint.match(/^\/emojis\/[^\/]+$/)) { 35 + // Keep as is - these are already correctly grouped 36 + continue; 37 + } else if (entry.endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 38 + // Keep as is - these are already correctly grouped 39 + continue; 40 + } else if (entry.endpoint.includes("/users/") && entry.endpoint.includes("/r")) { 41 + // This is a user redirect with a non-standard format 42 + newEndpoint = "/users/USER_ID/r"; 43 + } else if (entry.endpoint.includes("/users/")) { 44 + // This is a user data endpoint with a non-standard format 45 + newEndpoint = "/users/USER_ID"; 46 + } else if (entry.endpoint.includes("/emojis/") && entry.endpoint.includes("/r")) { 47 + // This is an emoji redirect with a non-standard format 48 + newEndpoint = "/emojis/EMOJI_NAME/r"; 49 + } else if (entry.endpoint.includes("/emojis/")) { 50 + // This is an emoji data endpoint with a non-standard format 51 + newEndpoint = "/emojis/EMOJI_NAME"; 52 + } 53 + 54 + // Only update if the endpoint has changed 55 + if (newEndpoint !== entry.endpoint) { 56 + db.run(` 57 + UPDATE request_analytics 58 + SET endpoint = ? 59 + WHERE id = ? 60 + `, [newEndpoint, entry.id]); 61 + } 62 + } 63 + 64 + console.log("Endpoint grouping migration completed"); 65 + } 66 + };
+12
src/migrations/index.ts
··· 1 + import { endpointGroupingMigration } from "./endpointGroupingMigration"; 2 + import { Migration } from "./types"; 3 + import { MigrationManager } from "./migrationManager"; 4 + 5 + // Export all migrations 6 + export const migrations: Migration[] = [ 7 + endpointGroupingMigration, 8 + // Add new migrations here 9 + ]; 10 + 11 + // Export the migration manager and types 12 + export { MigrationManager, Migration };
+225
src/migrations/migrationManager.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { version } from "../../package.json"; 3 + 4 + /** 5 + * Migration interface 6 + */ 7 + export interface Migration { 8 + version: string; 9 + description: string; 10 + up: (db: Database) => Promise<void>; 11 + down?: (db: Database) => Promise<void>; // Optional downgrade function 12 + } 13 + 14 + /** 15 + * Migration Manager for handling database schema and data migrations 16 + */ 17 + export class MigrationManager { 18 + private db: Database; 19 + private currentVersion: string; 20 + private migrations: Migration[]; 21 + 22 + /** 23 + * Creates a new MigrationManager 24 + * @param db SQLite database instance 25 + * @param migrations Array of migrations to apply 26 + */ 27 + constructor(db: Database, migrations: Migration[]) { 28 + this.db = db; 29 + this.currentVersion = version; 30 + this.migrations = migrations; 31 + this.initMigrationTable(); 32 + } 33 + 34 + /** 35 + * Initialize the migrations table if it doesn't exist 36 + */ 37 + private initMigrationTable() { 38 + this.db.run(` 39 + CREATE TABLE IF NOT EXISTS migrations ( 40 + id INTEGER PRIMARY KEY AUTOINCREMENT, 41 + version TEXT NOT NULL, 42 + applied_at INTEGER NOT NULL, 43 + description TEXT 44 + ) 45 + `); 46 + } 47 + 48 + /** 49 + * Get the last applied migration version 50 + * @returns The last applied migration version or null if no migrations have been applied 51 + */ 52 + private getLastAppliedMigration(): { version: string; applied_at: number } | null { 53 + const result = this.db.query(` 54 + SELECT version, applied_at FROM migrations 55 + ORDER BY applied_at DESC LIMIT 1 56 + `).get() as { version: string; applied_at: number } | null; 57 + 58 + return result; 59 + } 60 + 61 + /** 62 + * Check if a migration has been applied 63 + * @param version Migration version to check 64 + * @returns True if the migration has been applied, false otherwise 65 + */ 66 + private isMigrationApplied(version: string): boolean { 67 + const result = this.db.query(` 68 + SELECT COUNT(*) as count FROM migrations 69 + WHERE version = ? 70 + `).get(version) as { count: number }; 71 + 72 + return result.count > 0; 73 + } 74 + 75 + /** 76 + * Record a migration as applied 77 + * @param version Migration version 78 + * @param description Migration description 79 + */ 80 + private recordMigration(version: string, description: string) { 81 + this.db.run(` 82 + INSERT INTO migrations (version, applied_at, description) 83 + VALUES (?, ?, ?) 84 + `, [version, Date.now(), description]); 85 + } 86 + 87 + /** 88 + * Run migrations up to the current version 89 + * @returns Object containing migration results 90 + */ 91 + async runMigrations(): Promise<{ 92 + success: boolean; 93 + migrationsApplied: number; 94 + lastAppliedVersion: string | null; 95 + error?: string; 96 + }> { 97 + try { 98 + // Sort migrations by version (semver) 99 + const sortedMigrations = [...this.migrations].sort((a, b) => { 100 + return this.compareVersions(a.version, b.version); 101 + }); 102 + 103 + const lastApplied = this.getLastAppliedMigration(); 104 + let migrationsApplied = 0; 105 + let lastAppliedVersion = lastApplied?.version || null; 106 + 107 + console.log(`Current app version: ${this.currentVersion}`); 108 + console.log(`Last applied migration: ${lastAppliedVersion || 'None'}`); 109 + 110 + // Special case for first run: if no migrations table exists yet, 111 + // assume we're upgrading from the previous version without migrations 112 + if (!lastAppliedVersion) { 113 + // Record a "virtual" migration for the previous version 114 + // This prevents all migrations from running on existing installations 115 + const previousVersion = this.getPreviousVersion(this.currentVersion); 116 + if (previousVersion) { 117 + console.log(`No migrations table found. Assuming upgrade from ${previousVersion}`); 118 + this.recordMigration( 119 + previousVersion, 120 + "Virtual migration for existing installation" 121 + ); 122 + lastAppliedVersion = previousVersion; 123 + } 124 + } 125 + 126 + // Apply migrations that haven't been applied yet 127 + for (const migration of sortedMigrations) { 128 + // Skip if this migration has already been applied 129 + if (this.isMigrationApplied(migration.version)) { 130 + console.log(`Migration ${migration.version} already applied, skipping`); 131 + continue; 132 + } 133 + 134 + // Skip if this migration is for a future version 135 + if (this.compareVersions(migration.version, this.currentVersion) > 0) { 136 + console.log(`Migration ${migration.version} is for a future version, skipping`); 137 + continue; 138 + } 139 + 140 + // If we have a last applied migration, only apply migrations that are newer 141 + if (lastAppliedVersion && this.compareVersions(migration.version, lastAppliedVersion) <= 0) { 142 + console.log(`Migration ${migration.version} is older than last applied (${lastAppliedVersion}), skipping`); 143 + continue; 144 + } 145 + 146 + console.log(`Applying migration ${migration.version}: ${migration.description}`); 147 + 148 + // Run the migration inside a transaction 149 + this.db.transaction(() => { 150 + // Apply the migration 151 + migration.up(this.db); 152 + 153 + // Record the migration 154 + this.recordMigration(migration.version, migration.description); 155 + })(); 156 + 157 + migrationsApplied++; 158 + lastAppliedVersion = migration.version; 159 + console.log(`Migration ${migration.version} applied successfully`); 160 + } 161 + 162 + return { 163 + success: true, 164 + migrationsApplied, 165 + lastAppliedVersion 166 + }; 167 + } catch (error) { 168 + console.error('Error running migrations:', error); 169 + return { 170 + success: false, 171 + migrationsApplied: 0, 172 + lastAppliedVersion: null, 173 + error: error instanceof Error ? error.message : String(error) 174 + }; 175 + } 176 + } 177 + 178 + /** 179 + * Get the previous version from the current version 180 + * @param version Current version 181 + * @returns Previous version or null if can't determine 182 + */ 183 + private getPreviousVersion(version: string): string | null { 184 + const parts = version.split('.'); 185 + if (parts.length !== 3) return null; 186 + 187 + const [major, minor, patch] = parts.map(Number); 188 + 189 + // If patch > 0, decrement patch 190 + if (patch > 0) { 191 + return `${major}.${minor}.${patch - 1}`; 192 + } 193 + // If minor > 0, decrement minor and set patch to 0 194 + else if (minor > 0) { 195 + return `${major}.${minor - 1}.0`; 196 + } 197 + // If major > 0, decrement major and set minor and patch to 0 198 + else if (major > 0) { 199 + return `${major - 1}.0.0`; 200 + } 201 + 202 + return null; 203 + } 204 + 205 + /** 206 + * Compare two version strings (semver) 207 + * @param a First version 208 + * @param b Second version 209 + * @returns -1 if a < b, 0 if a = b, 1 if a > b 210 + */ 211 + private compareVersions(a: string, b: string): number { 212 + const partsA = a.split('.').map(Number); 213 + const partsB = b.split('.').map(Number); 214 + 215 + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { 216 + const partA = i < partsA.length ? partsA[i] : 0; 217 + const partB = i < partsB.length ? partsB[i] : 0; 218 + 219 + if (partA < partB) return -1; 220 + if (partA > partB) return 1; 221 + } 222 + 223 + return 0; 224 + } 225 + }
+11
src/migrations/types.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + 3 + /** 4 + * Migration interface 5 + */ 6 + export interface Migration { 7 + version: string; 8 + description: string; 9 + up: (db: Database) => Promise<void>; 10 + down?: (db: Database) => Promise<void>; // Optional downgrade function 11 + }