// Package db provides database connection management and migration support. // Supports SQLite, MySQL, and PostgreSQL backends. package db import ( "database/sql" "errors" "fmt" "log/slog" "os" "path/filepath" "sort" "strings" "time" _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" _ "modernc.org/sqlite" ) // DBType constants. const ( TypeSQLite = "sqlite" TypeMySQL = "mysql" TypePostgres = "postgres" ) // Open opens a database connection based on the configuration. func Open(dbType, dbURL, dataDir string) (*sql.DB, error) { var driverName, dsn string switch dbType { case TypeSQLite: driverName = "sqlite" if dbURL == "" { if err := os.MkdirAll(dataDir, 0755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } dbURL = filepath.Join(dataDir, "hub.db") } dsn = dbURL + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" case TypeMySQL: driverName = "mysql" dsn = dbURL if dsn == "" { return nil, errors.New("DB_URL required for mysql") } case TypePostgres: driverName = "postgres" dsn = dbURL if dsn == "" { return nil, errors.New("DB_URL required for postgres") } default: return nil, fmt.Errorf("unsupported DB_TYPE: %s", dbType) } db, err := sql.Open(driverName, dsn) if err != nil { return nil, fmt.Errorf("open %s: %w", dbType, err) } db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(5 * time.Minute) db.SetConnMaxIdleTime(1 * time.Minute) if err := db.Ping(); err != nil { return nil, fmt.Errorf("ping %s: %w", dbType, err) } slog.Info("database connected", "type", dbType) return db, nil } // RunMigrations reads SQL migration files from the given directory and applies // them in filename order. Each file is executed within a transaction. // Migration filenames should follow the format: 000001_description.up.sql func RunMigrations(db *sql.DB, migrationsDir string) error { entries, err := os.ReadDir(migrationsDir) if err != nil { if os.IsNotExist(err) { slog.Warn("migrations directory not found, skipping", "dir", migrationsDir) return nil } return fmt.Errorf("read migrations dir: %w", err) } var files []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { files = append(files, e.Name()) } } sort.Strings(files) for _, f := range files { path := filepath.Join(migrationsDir, f) content, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read migration %s: %w", f, err) } // Skip empty files sql := strings.TrimSpace(string(content)) if sql == "" { continue } tx, err := db.Begin() if err != nil { return fmt.Errorf("begin tx for %s: %w", f, err) } if _, err := tx.Exec(sql); err != nil { tx.Rollback() return fmt.Errorf("execute migration %s: %w", f, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", f, err) } slog.Info("migration applied", "file", f) } return nil } // Close gracefully closes the database connection. func Close(db *sql.DB) { if db != nil { db.Close() } }