a cache for slack profile pictures and emojis
at main 6.7 kB view raw
1import type { Database } from "bun:sqlite"; 2import { version } from "../../package.json"; 3 4/** 5 * Migration interface 6 */ 7export 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 */ 17export 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(): { 53 version: string; 54 applied_at: number; 55 } | null { 56 const result = this.db 57 .query(` 58 SELECT version, applied_at FROM migrations 59 ORDER BY applied_at DESC LIMIT 1 60 `) 61 .get() as { version: string; applied_at: number } | null; 62 63 return result; 64 } 65 66 /** 67 * Check if a migration has been applied 68 * @param version Migration version to check 69 * @returns True if the migration has been applied, false otherwise 70 */ 71 private isMigrationApplied(version: string): boolean { 72 const result = this.db 73 .query(` 74 SELECT COUNT(*) as count FROM migrations 75 WHERE version = ? 76 `) 77 .get(version) as { count: number }; 78 79 return result.count > 0; 80 } 81 82 /** 83 * Record a migration as applied 84 * @param version Migration version 85 * @param description Migration description 86 */ 87 private recordMigration(version: string, description: string) { 88 this.db.run( 89 ` 90 INSERT INTO migrations (version, applied_at, description) 91 VALUES (?, ?, ?) 92 `, 93 [version, Date.now(), description], 94 ); 95 } 96 97 /** 98 * Run migrations up to the current version 99 * @returns Object containing migration results 100 */ 101 async runMigrations(): Promise<{ 102 success: boolean; 103 migrationsApplied: number; 104 lastAppliedVersion: string | null; 105 error?: string; 106 }> { 107 try { 108 // Sort migrations by version (semver) 109 const sortedMigrations = [...this.migrations].sort((a, b) => { 110 return this.compareVersions(a.version, b.version); 111 }); 112 113 const lastApplied = this.getLastAppliedMigration(); 114 let migrationsApplied = 0; 115 let lastAppliedVersion = lastApplied?.version || null; 116 117 console.log(`Current app version: ${this.currentVersion}`); 118 console.log(`Last applied migration: ${lastAppliedVersion || "None"}`); 119 120 // Special case for first run: if no migrations table exists yet, 121 // assume we're upgrading from the previous version without migrations 122 if (!lastAppliedVersion) { 123 // Record a "virtual" migration for the previous version 124 // This prevents all migrations from running on existing installations 125 const previousVersion = this.getPreviousVersion(this.currentVersion); 126 if (previousVersion) { 127 console.log( 128 `No migrations table found. Assuming upgrade from ${previousVersion}`, 129 ); 130 this.recordMigration( 131 previousVersion, 132 "Virtual migration for existing installation", 133 ); 134 lastAppliedVersion = previousVersion; 135 } 136 } 137 138 // Apply migrations that haven't been applied yet 139 for (const migration of sortedMigrations) { 140 // Skip if this migration has already been applied 141 if (this.isMigrationApplied(migration.version)) { 142 console.log( 143 `Migration ${migration.version} already applied, skipping`, 144 ); 145 continue; 146 } 147 148 // Skip if this migration is for a future version 149 if (this.compareVersions(migration.version, this.currentVersion) > 0) { 150 console.log( 151 `Migration ${migration.version} is for a future version, skipping`, 152 ); 153 continue; 154 } 155 156 // If we have a last applied migration, only apply migrations that are newer 157 if ( 158 lastAppliedVersion && 159 this.compareVersions(migration.version, lastAppliedVersion) <= 0 160 ) { 161 console.log( 162 `Migration ${migration.version} is older than last applied (${lastAppliedVersion}), skipping`, 163 ); 164 continue; 165 } 166 167 console.log( 168 `Applying migration ${migration.version}: ${migration.description}`, 169 ); 170 171 // Run the migration inside a transaction 172 this.db.transaction(() => { 173 // Apply the migration 174 migration.up(this.db); 175 176 // Record the migration 177 this.recordMigration(migration.version, migration.description); 178 })(); 179 180 migrationsApplied++; 181 lastAppliedVersion = migration.version; 182 console.log(`Migration ${migration.version} applied successfully`); 183 } 184 185 return { 186 success: true, 187 migrationsApplied, 188 lastAppliedVersion, 189 }; 190 } catch (error) { 191 console.error("Error running migrations:", error); 192 return { 193 success: false, 194 migrationsApplied: 0, 195 lastAppliedVersion: null, 196 error: error instanceof Error ? error.message : String(error), 197 }; 198 } 199 } 200 201 /** 202 * Get the previous version from the current version 203 * @param version Current version 204 * @returns Previous version or null if can't determine 205 */ 206 private getPreviousVersion(version: string): string | null { 207 const parts = version.split("."); 208 if (parts.length !== 3) return null; 209 210 const [major, minor, patch] = parts.map(Number); 211 212 // If patch > 0, decrement patch 213 if (patch > 0) { 214 return `${major}.${minor}.${patch - 1}`; 215 } 216 // If minor > 0, decrement minor and set patch to 0 217 else if (minor > 0) { 218 return `${major}.${minor - 1}.0`; 219 } 220 // If major > 0, decrement major and set minor and patch to 0 221 else if (major > 0) { 222 return `${major - 1}.0.0`; 223 } 224 225 return null; 226 } 227 228 /** 229 * Compare two version strings (semver) 230 * @param a First version 231 * @param b Second version 232 * @returns -1 if a < b, 0 if a = b, 1 if a > b 233 */ 234 private compareVersions(a: string, b: string): number { 235 const partsA = a.split(".").map(Number); 236 const partsB = b.split(".").map(Number); 237 238 for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { 239 const partA = i < partsA.length ? partsA[i] : 0; 240 const partB = i < partsB.length ? partsB[i] : 0; 241 242 if (partA < partB) return -1; 243 if (partA > partB) return 1; 244 } 245 246 return 0; 247 } 248}