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() }