a cache for slack profile pictures and emojis
at v0.3.2 7.1 kB view raw
1import { 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(): { 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}