import { Database } from "bun:sqlite"; export const db = new Database("thistle.db"); // Schema version tracking db.run(` CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ) `); const migrations = [ { version: 1, name: "Complete schema with class system", sql: ` -- Users table CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password_hash TEXT, name TEXT, avatar TEXT DEFAULT 'd', role TEXT NOT NULL DEFAULT 'user', last_login INTEGER, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login); -- Sessions table CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), expires_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); -- Passkeys table CREATE TABLE IF NOT EXISTS passkeys ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, credential_id TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, transports TEXT, name TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), last_used_at INTEGER, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id); CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id); -- Rate limiting table CREATE TABLE IF NOT EXISTS rate_limit_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL, timestamp INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp); -- Classes table CREATE TABLE IF NOT EXISTS classes ( id TEXT PRIMARY KEY, course_code TEXT NOT NULL, name TEXT NOT NULL, professor TEXT NOT NULL, semester TEXT NOT NULL, year INTEGER NOT NULL, archived BOOLEAN DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); CREATE INDEX IF NOT EXISTS idx_classes_semester_year ON classes(semester, year); CREATE INDEX IF NOT EXISTS idx_classes_archived ON classes(archived); -- Class members table CREATE TABLE IF NOT EXISTS class_members ( class_id TEXT NOT NULL, user_id INTEGER NOT NULL, enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), PRIMARY KEY (class_id, user_id), FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_class_members_user_id ON class_members(user_id); CREATE INDEX IF NOT EXISTS idx_class_members_class_id ON class_members(class_id); -- Meeting times table CREATE TABLE IF NOT EXISTS meeting_times ( id TEXT PRIMARY KEY, class_id TEXT NOT NULL, label TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_meeting_times_class_id ON meeting_times(class_id); -- Transcriptions table CREATE TABLE IF NOT EXISTS transcriptions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, class_id TEXT, meeting_time_id TEXT, filename TEXT NOT NULL, original_filename TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', progress INTEGER NOT NULL DEFAULT 0, error_message TEXT, whisper_job_id TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_transcriptions_user_id ON transcriptions(user_id); CREATE INDEX IF NOT EXISTS idx_transcriptions_class_id ON transcriptions(class_id); CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status); CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id); `, }, ]; function getCurrentVersion(): number { const result = db .query<{ version: number }, []>( "SELECT MAX(version) as version FROM schema_migrations", ) .get(); return result?.version ?? 0; } function applyMigration( version: number, sql: string, index: number, total: number, ) { const current = getCurrentVersion(); if (current >= version) return; const isTTY = typeof process !== "undefined" && process.stdout?.isTTY; const startMsg = `Applying migration ${index + 1} of ${total}`; if (isTTY) { process.stdout.write(`${startMsg}...`); const start = performance.now(); db.run(sql); db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); const duration = Math.round(performance.now() - start); process.stdout.write(`\r${startMsg} (${duration}ms)\n`); } else { console.log(startMsg); db.run(sql); db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); } } // Apply all migrations const current = getCurrentVersion(); const pending = migrations.filter((m) => m.version > current); for (const [index, migration] of pending.entries()) { applyMigration(migration.version, migration.sql, index, pending.length); } export default db;