a cache for slack profile pictures and emojis
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}