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