b6ecd613d4
- Project structure: cmd/boosapi, internal/, pkg/ - Config loader (all env vars from Node.js original) - Database layer with SQLite/MySQL/Postgres support - Migration runner (sequential SQL files) - Health check endpoint (GET /healthz) - HTTP middleware: logger, auth, CORS, panic recovery - chi router with /admin, /v1, /api route groups - Graceful shutdown (SIGINT/SIGTERM) - Multi-stage Dockerfile (alpine, ~20MB image) - Initial schema migration (users, api_keys, sites, usage_logs) Signed-off-by: Boos4721 <boos4721@icloud.com>
139 lines
3.1 KiB
Go
139 lines
3.1 KiB
Go
// 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()
|
|
}
|
|
}
|