// SPDX-License-Identifier: AGPL-3.0-or-later // Package database manages the SQLite connection, PRAGMAs, and schema migrations. package database import ( "database/sql" "embed" "fmt" "log/slog" "os" "path/filepath" "sort" "strings" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var migrationsFS embed.FS // Open creates a new SQLite database connection with recommended PRAGMAs // for WAL mode, foreign keys, and performance tuning. func Open(dbPath string) (*sql.DB, error) { // Ensure the directory for the database file exists. dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0750); err != nil { return nil, fmt.Errorf("create db directory: %w", err) } db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // Apply recommended PRAGMAs. These must be set per-connection, and since // database/sql may open multiple connections, we use ConnInitHook via DSN // parameters where possible. However, journal_mode persists at the db level. pragmas := []string{ "PRAGMA journal_mode = WAL", "PRAGMA busy_timeout = 5000", "PRAGMA synchronous = NORMAL", "PRAGMA foreign_keys = ON", "PRAGMA cache_size = -20000", } for _, p := range pragmas { if _, err := db.Exec(p); err != nil { db.Close() return nil, fmt.Errorf("set pragma %q: %w", p, err) } } // SQLite works best with a single writer connection. db.SetMaxOpenConns(1) slog.Info("database opened", "path", dbPath) return db, nil } // Migrate runs all pending SQL migrations in order. Migrations are embedded // SQL files named with a numeric prefix (e.g. 001_initial.sql). Each migration // runs within a transaction. func Migrate(db *sql.DB) error { // Create the migrations tracking table if it doesn't exist. _, 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:%SZ', 'now')) )`) if err != nil { return fmt.Errorf("create schema_migrations table: %w", err) } // Read all migration files. entries, err := migrationsFS.ReadDir("migrations") if err != nil { return fmt.Errorf("read migrations dir: %w", err) } // Sort by filename to ensure correct order. sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { continue } name := entry.Name() // Check if this migration has already been applied. var count int if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", name).Scan(&count); err != nil { return fmt.Errorf("check migration %s: %w", name, err) } if count > 0 { continue } // Read and execute the migration in a transaction. content, err := migrationsFS.ReadFile("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("execute 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", "version", name) } return nil }