// Package database manages the SQLite connection and schema migrations. package database import ( "database/sql" "embed" "fmt" "io/fs" "log/slog" "path/filepath" "sort" "strings" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var migrationsFS embed.FS // Open opens the SQLite database at path, applies pragmas suited for a // concurrent web application, and runs any pending migrations. func Open(path string) (*sql.DB, error) { dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } // SQLite only supports one writer at a time; a single connection avoids // SQLITE_BUSY under concurrent goroutines while WAL still allows // concurrent readers. db.SetMaxOpenConns(1) pragmas := []string{ "PRAGMA journal_mode = WAL", "PRAGMA foreign_keys = ON", "PRAGMA synchronous = NORMAL", "PRAGMA busy_timeout = 5000", } for _, p := range pragmas { if _, err := db.Exec(p); err != nil { db.Close() return nil, fmt.Errorf("set pragma %q: %w", p, err) } } if err := Migrate(db); err != nil { db.Close() return nil, fmt.Errorf("migrate: %w", err) } return db, nil } // Migrate applies any migrations that have not yet been recorded in the // schema_migrations table, in filename order, each inside its own // transaction. func Migrate(db *sql.DB) error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) )`); err != nil { return fmt.Errorf("create schema_migrations: %w", err) } applied := map[string]bool{} rows, err := db.Query(`SELECT version FROM schema_migrations`) if err != nil { return fmt.Errorf("query schema_migrations: %w", err) } for rows.Next() { var v string if err := rows.Scan(&v); err != nil { rows.Close() return err } applied[v] = true } if err := rows.Err(); err != nil { return err } rows.Close() entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return fmt.Errorf("read migrations dir: %w", err) } var names []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { names = append(names, e.Name()) } } sort.Strings(names) for _, name := range names { if applied[name] { continue } content, err := fs.ReadFile(migrationsFS, filepath.Join("migrations", name)) if err != nil { return fmt.Errorf("read migration %s: %w", name, err) } tx, err := db.Begin() if err != nil { return fmt.Errorf("begin tx for %s: %w", name, err) } if _, err := tx.Exec(string(content)); err != nil { tx.Rollback() return fmt.Errorf("apply migration %s: %w", name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil { tx.Rollback() return fmt.Errorf("record migration %s: %w", name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", name, err) } slog.Info("applied migration", "name", name) } return nil }