// Package db wraps database/sql with the gomail schema. No ORM — raw SQL with // prepared statements only, per the project's minimal-dependency principle. package db import ( "database/sql" "fmt" "log/slog" "strings" _ "github.com/mattn/go-sqlite3" ) // DB wraps *sql.DB with the driver name (some queries need driver-specific SQL, // e.g. placeholder syntax differs between sqlite/postgres/mysql). type DB struct { *sql.DB Driver string } // Open connects to the database using the configured driver. // SQLite is always available; postgres and mysql require build tags: // // go build -tags postgres . // go build -tags mysql . func Open(driver, dsn string) (*DB, error) { d := strings.ToLower(driver) var sqlDriverName string switch d { case "sqlite", "": sqlDriverName = "sqlite3" d = "sqlite" default: name, ok := driverRegistry[d] if !ok { available := []string{"sqlite"} for k := range driverRegistry { available = append(available, k) } return nil, fmt.Errorf("driver %q not compiled in; rebuild with -tags %s. Available: %v", driver, driver, available) } sqlDriverName = name } sqlDB, err := sql.Open(sqlDriverName, dsn) if err != nil { return nil, fmt.Errorf("opening database: %w", err) } if d == "sqlite" { // SQLite doesn't handle concurrent writers well — serialize via single conn. sqlDB.SetMaxOpenConns(1) if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil { return nil, fmt.Errorf("enabling WAL mode: %w", err) } if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil { return nil, fmt.Errorf("enabling foreign keys: %w", err) } if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil { return nil, fmt.Errorf("setting busy timeout: %w", err) } } if err := sqlDB.Ping(); err != nil { return nil, fmt.Errorf("ping database: %w", err) } return &DB{DB: sqlDB, Driver: d}, nil } // driverRegistry is populated by build-tag-gated driver_*.go files // (driver_postgres.go, driver_mysql.go) via init(). var driverRegistry = map[string]string{} func registerDriver(name, sqlDriverName string) { driverRegistry[strings.ToLower(name)] = sqlDriverName } // Migrate runs all pending schema migrations in order. Migrations are // idempotent (CREATE TABLE IF NOT EXISTS) so this is always safe to call at // startup. func (db *DB) Migrate() error { slog.Info("running database migrations") if _, err := db.Exec(migrationsTableSQL[db.Driver]); err != nil { return fmt.Errorf("creating migrations table: %w", err) } for _, m := range migrations { applied, err := db.migrationApplied(m.name) if err != nil { return fmt.Errorf("checking migration %s: %w", m.name, err) } if applied { continue } stmt := m.sql[db.Driver] if stmt == "" { stmt = m.sql["sqlite"] // fall back — most DDL is portable enough via driver quirks handled per-migration } slog.Info("applying migration", "name", m.name) tx, err := db.Begin() if err != nil { return fmt.Errorf("begin tx for %s: %w", m.name, err) } if _, err := tx.Exec(stmt); err != nil { tx.Rollback() return fmt.Errorf("applying migration %s: %w", m.name, err) } if _, err := tx.Exec(db.insertMigrationSQL(), m.name); err != nil { tx.Rollback() return fmt.Errorf("recording migration %s: %w", m.name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", m.name, err) } } slog.Info("migrations complete", "count", len(migrations)) return nil } func (db *DB) migrationApplied(name string) (bool, error) { var count int err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE name = "+db.placeholder(1), name).Scan(&count) return count > 0, err } func (db *DB) insertMigrationSQL() string { return "INSERT INTO schema_migrations (name, applied_at) VALUES (" + db.placeholder(1) + ", CURRENT_TIMESTAMP)" } // placeholder returns the driver-appropriate positional parameter syntax. // sqlite/mysql use "?", postgres uses "$1", "$2", ... func (db *DB) placeholder(n int) string { if db.Driver == "postgres" { return fmt.Sprintf("$%d", n) } return "?" } var migrationsTableSQL = map[string]string{ "sqlite": `CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at DATETIME NOT NULL )`, "postgres": `CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL )`, "mysql": `CREATE TABLE IF NOT EXISTS schema_migrations ( name VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL )`, }