// Package db provides a database connection helper that dispatches on the // configured driver (MySQL or SQLite). Switching engines is a single env var: // // DB_DRIVER=mysql (default) → DB_DSN is a full MySQL DSN // DB_DRIVER=sqlite → DB_DSN is the SQLite file path (or :memory:) package db import ( "database/sql" "fmt" "os" "strings" "time" // Register the database drivers with database/sql. _ "github.com/go-sql-driver/mysql" _ "modernc.org/sqlite" ) // Normalize canonicalizes a driver name. Empty → "mysql"; "sqlite3" → "sqlite". func Normalize(driver string) string { switch strings.ToLower(strings.TrimSpace(driver)) { case "", "mysql": return "mysql" case "sqlite", "sqlite3": return "sqlite" default: return strings.ToLower(strings.TrimSpace(driver)) } } // Driver returns the configured driver from DB_DRIVER (default "mysql"). func Driver() string { return Normalize(os.Getenv("DB_DRIVER")) } // Open opens a *sql.DB using the driver from DB_DRIVER (default mysql) and pings it. func Open(dsn string) (*sql.DB, error) { return OpenDriver(Driver(), dsn) } // OpenDriver opens a *sql.DB for the given driver and DSN, configures the pool, // and pings. For mysql the DSN must already carry parseTime=true and UTC params // (see internal/store.Open which builds them); for sqlite the DSN is a file path. func OpenDriver(driver, dsn string) (*sql.DB, error) { switch Normalize(driver) { case "sqlite": return openSQLite(dsn) default: return openMySQL(dsn) } } func openMySQL(dsn string) (*sql.DB, error) { database, err := sql.Open("mysql", dsn) if err != nil { return nil, fmt.Errorf("db.Open: %w", err) } database.SetMaxOpenConns(30) database.SetMaxIdleConns(10) database.SetConnMaxLifetime(5 * time.Minute) if err := database.Ping(); err != nil { database.Close() return nil, fmt.Errorf("db.Open ping: %w", err) } return database, nil } func openSQLite(dsn string) (*sql.DB, error) { database, err := sql.Open("sqlite", sqliteDSN(dsn)) if err != nil { return nil, fmt.Errorf("db.Open(sqlite): %w", err) } // SQLite is a single-writer engine; readers/writers serialize via the // busy_timeout pragma (set in the DSN). A modest pool is safe under WAL. database.SetMaxOpenConns(10) database.SetMaxIdleConns(10) database.SetConnMaxLifetime(0) if err := database.Ping(); err != nil { database.Close() return nil, fmt.Errorf("db.Open(sqlite) ping: %w", err) } return database, nil } // sqliteDSN appends the pragmas the server relies on (busy_timeout for write // contention, foreign_keys enforcement, WAL for file-backed DBs). An empty DSN // or ":memory:" maps to a shared-cache in-memory DB (so the pool's connections // see the same database — used by tests). func sqliteDSN(dsn string) string { // _txlock=immediate makes every transaction BEGIN IMMEDIATE (acquire the // write lock up front), giving the pessimistic exclusivity that the MySQL // "SELECT … FOR UPDATE" paths rely on. busy_timeout lets contended writers // wait instead of failing with SQLITE_BUSY. const filePragmas = "_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)" const memPragmas = "_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" if dsn == "" || dsn == ":memory:" { return "file::memory:?cache=shared&" + memPragmas } sep := "?" if strings.Contains(dsn, "?") { sep = "&" } return dsn + sep + filePragmas }