馃 distributed transcription service thistle.dunkirk.sh
1import { Database } from "bun:sqlite"; 2 3export const db = new Database("thistle.db"); 4 5// Schema version tracking 6db.run(` 7 CREATE TABLE IF NOT EXISTS schema_migrations ( 8 version INTEGER PRIMARY KEY, 9 applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 10 ) 11`); 12 13const migrations = [ 14 { 15 version: 1, 16 name: "Complete user schema", 17 sql: ` 18 CREATE TABLE IF NOT EXISTS users ( 19 id INTEGER PRIMARY KEY AUTOINCREMENT, 20 email TEXT UNIQUE NOT NULL, 21 password_hash TEXT NOT NULL, 22 name TEXT, 23 avatar TEXT DEFAULT 'd', 24 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 25 ); 26 27 CREATE TABLE IF NOT EXISTS sessions ( 28 id TEXT PRIMARY KEY, 29 user_id INTEGER NOT NULL, 30 ip_address TEXT, 31 user_agent TEXT, 32 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 33 expires_at INTEGER NOT NULL, 34 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 35 ); 36 37 CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); 38 CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); 39 `, 40 }, 41 { 42 version: 2, 43 name: "Add transcriptions table", 44 sql: ` 45 CREATE TABLE IF NOT EXISTS transcriptions ( 46 id TEXT PRIMARY KEY, 47 user_id INTEGER NOT NULL, 48 filename TEXT NOT NULL, 49 original_filename TEXT NOT NULL, 50 status TEXT NOT NULL DEFAULT 'uploading', 51 progress INTEGER NOT NULL DEFAULT 0, 52 transcript TEXT, 53 error_message TEXT, 54 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 55 updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 56 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 57 ); 58 59 CREATE INDEX IF NOT EXISTS idx_transcriptions_user_id ON transcriptions(user_id); 60 CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status); 61 `, 62 }, 63 { 64 version: 3, 65 name: "Add whisper_job_id to transcriptions", 66 sql: ` 67 ALTER TABLE transcriptions ADD COLUMN whisper_job_id TEXT; 68 CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id); 69 `, 70 }, 71 { 72 version: 4, 73 name: "Remove transcript column from transcriptions", 74 sql: ` 75 -- SQLite 3.35.0+ supports DROP COLUMN 76 ALTER TABLE transcriptions DROP COLUMN transcript; 77 `, 78 }, 79 { 80 version: 5, 81 name: "Add rate limiting table", 82 sql: ` 83 CREATE TABLE IF NOT EXISTS rate_limit_attempts ( 84 id INTEGER PRIMARY KEY AUTOINCREMENT, 85 key TEXT NOT NULL, 86 timestamp INTEGER NOT NULL 87 ); 88 89 CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp); 90 `, 91 }, 92 { 93 version: 6, 94 name: "Add role-based auth system", 95 sql: ` 96 -- Add role column (default to 'user') 97 ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; 98 99 -- Create index on role 100 CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 101 `, 102 }, 103 { 104 version: 7, 105 name: "Add WebAuthn passkey support", 106 sql: ` 107 CREATE TABLE IF NOT EXISTS passkeys ( 108 id TEXT PRIMARY KEY, 109 user_id INTEGER NOT NULL, 110 credential_id TEXT NOT NULL UNIQUE, 111 public_key TEXT NOT NULL, 112 counter INTEGER NOT NULL DEFAULT 0, 113 transports TEXT, 114 name TEXT, 115 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 116 last_used_at INTEGER, 117 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 118 ); 119 120 CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id); 121 CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id); 122 123 -- Make password optional for users who only use passkeys 124 CREATE TABLE users_new ( 125 id INTEGER PRIMARY KEY AUTOINCREMENT, 126 email TEXT UNIQUE NOT NULL, 127 password_hash TEXT, 128 name TEXT, 129 avatar TEXT DEFAULT 'd', 130 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 131 role TEXT NOT NULL DEFAULT 'user' 132 ); 133 134 INSERT INTO users_new SELECT * FROM users; 135 DROP TABLE users; 136 ALTER TABLE users_new RENAME TO users; 137 138 CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 139 `, 140 }, 141]; 142 143function getCurrentVersion(): number { 144 const result = db 145 .query<{ version: number }, []>( 146 "SELECT MAX(version) as version FROM schema_migrations", 147 ) 148 .get(); 149 return result?.version ?? 0; 150} 151 152function applyMigration( 153 version: number, 154 sql: string, 155 index: number, 156 total: number, 157) { 158 const current = getCurrentVersion(); 159 if (current >= version) return; 160 161 const isTTY = typeof process !== "undefined" && process.stdout?.isTTY; 162 const startMsg = `Applying migration ${index + 1} of ${total}`; 163 164 if (isTTY) { 165 process.stdout.write(`${startMsg}...`); 166 const start = performance.now(); 167 db.run(sql); 168 db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); 169 const duration = Math.round(performance.now() - start); 170 process.stdout.write(`\r${startMsg} (${duration}ms)\n`); 171 } else { 172 console.log(startMsg); 173 db.run(sql); 174 db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); 175 } 176} 177 178// Apply all migrations 179const current = getCurrentVersion(); 180const pending = migrations.filter((m) => m.version > current); 181 182for (const [index, migration] of pending.entries()) { 183 applyMigration(migration.version, migration.sql, index, pending.length); 184} 185 186export default db;