Phase 1: Go backend scaffold
- 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>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user