160 lines
5.1 KiB
Go
160 lines
5.1 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
|
|
"github.com/golang-migrate/migrate/v4"
|
|
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// DB wraps the database connection
|
|
// Note: No mutex needed - we only have one connection per process and SQLite with WAL mode
|
|
// handles concurrent access safely
|
|
type DB struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewDB creates a new database connection
|
|
func NewDB(dbPath string) (*DB, error) {
|
|
// Use WAL mode for better concurrency (allows readers and writers simultaneously)
|
|
// Add timeout and busy handler for better concurrent access
|
|
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
// Configure connection pool for better concurrency
|
|
// SQLite with WAL mode supports multiple concurrent readers and one writer
|
|
// Increasing pool size allows multiple HTTP requests to query the database simultaneously
|
|
// This prevents blocking when multiple requests come in (e.g., on page refresh)
|
|
db.SetMaxOpenConns(10) // Allow up to 10 concurrent connections
|
|
db.SetMaxIdleConns(5) // Keep 5 idle connections ready
|
|
db.SetConnMaxLifetime(0) // Connections don't expire
|
|
|
|
if err := db.Ping(); err != nil {
|
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
// Enable foreign keys for SQLite
|
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
|
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
|
|
}
|
|
|
|
// Enable WAL mode explicitly (in case the connection string didn't work)
|
|
if _, err := db.Exec("PRAGMA journal_mode = WAL"); err != nil {
|
|
log.Printf("Warning: Failed to enable WAL mode: %v", err)
|
|
}
|
|
|
|
database := &DB{db: db}
|
|
if err := database.migrate(); err != nil {
|
|
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
|
}
|
|
|
|
// Verify connection is still open after migration
|
|
if err := db.Ping(); err != nil {
|
|
return nil, fmt.Errorf("database connection closed after migration: %w", err)
|
|
}
|
|
|
|
return database, nil
|
|
}
|
|
|
|
// With executes a function with access to the database
|
|
// The function receives the underlying *sql.DB connection
|
|
// No mutex needed - single connection + WAL mode handles concurrency
|
|
func (db *DB) With(fn func(*sql.DB) error) error {
|
|
return fn(db.db)
|
|
}
|
|
|
|
// WithTx executes a function within a transaction
|
|
// The function receives a *sql.Tx transaction
|
|
// If the function returns an error, the transaction is rolled back
|
|
// If the function returns nil, the transaction is committed
|
|
// No mutex needed - single connection + WAL mode handles concurrency
|
|
func (db *DB) WithTx(fn func(*sql.Tx) error) error {
|
|
tx, err := db.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
|
|
if err := fn(tx); err != nil {
|
|
if rbErr := tx.Rollback(); rbErr != nil {
|
|
return fmt.Errorf("transaction error: %w, rollback error: %v", err, rbErr)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrate runs database migrations using golang-migrate
|
|
func (db *DB) migrate() error {
|
|
// Create SQLite driver instance
|
|
// Note: We use db.db directly since we're in the same package and this is called during initialization
|
|
driver, err := sqlite3.WithInstance(db.db, &sqlite3.Config{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create sqlite3 driver: %w", err)
|
|
}
|
|
|
|
// Create embedded filesystem source
|
|
migrationFS, err := fs.Sub(migrationsFS, "migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create migration filesystem: %w", err)
|
|
}
|
|
|
|
sourceDriver, err := iofs.New(migrationFS, ".")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create iofs source driver: %w", err)
|
|
}
|
|
|
|
// Create migrate instance
|
|
m, err := migrate.NewWithInstance("iofs", sourceDriver, "sqlite3", driver)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create migrate instance: %w", err)
|
|
}
|
|
|
|
// Run migrations
|
|
if err := m.Up(); err != nil {
|
|
// If the error is "no change", that's fine - database is already up to date
|
|
if err == migrate.ErrNoChange {
|
|
log.Printf("Database is already up to date")
|
|
// Don't close migrate instance - it may close the database connection
|
|
// The migrate instance will be garbage collected
|
|
return nil
|
|
}
|
|
// Don't close migrate instance on error either - it may close the DB
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
// Don't close the migrate instance - with sqlite3.WithInstance, closing it
|
|
// may close the underlying database connection. The migrate instance will
|
|
// be garbage collected when it goes out of scope.
|
|
// If we need to close it later, we can store it in the DB struct and close
|
|
// it when DB.Close() is called, but for now we'll let it be GC'd.
|
|
|
|
log.Printf("Database migrations completed successfully")
|
|
return nil
|
|
}
|
|
|
|
// Ping checks the database connection
|
|
func (db *DB) Ping() error {
|
|
return db.db.Ping()
|
|
}
|
|
|
|
// Close closes the database connection
|
|
func (db *DB) Close() error {
|
|
return db.db.Close()
|
|
}
|