package store import ( "database/sql" "embed" "fmt" "io/fs" "sort" "strings" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var migrationFS embed.FS // DB wraps the SQLite connection and migrations. type DB struct { conn *sql.DB } // NewDB opens or creates the SQLite database and runs migrations. func NewDB(dbPath string) (*DB, error) { conn, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // SQLite writer serialization: one open conn avoids BUSY retries under parallel goroutines. conn.SetMaxOpenConns(1) // Test connection if err := conn.Ping(); err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } if _, err := conn.Exec("PRAGMA foreign_keys = ON"); err != nil { conn.Close() return nil, fmt.Errorf("enable foreign keys: %w", err) } db := &DB{conn: conn} // Run migrations if err := db.runMigrations(); err != nil { conn.Close() return nil, err } return db, nil } // runMigrations applies all SQL migrations from the migrations/ directory. // Each migration is applied exactly once — tracked via the schema_migrations table. // This is required because ALTER TABLE ADD COLUMN is not idempotent in SQLite. func (db *DB) runMigrations() error { if _, err := db.conn.Exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`); err != nil { return fmt.Errorf("failed to create schema_migrations: %w", err) } applied := make(map[string]bool) rows, err := db.conn.Query(`SELECT name FROM schema_migrations`) if err != nil { return fmt.Errorf("failed to read schema_migrations: %w", err) } for rows.Next() { var name string if err := rows.Scan(&name); err != nil { rows.Close() return fmt.Errorf("failed to scan migration name: %w", err) } applied[name] = true } rows.Close() entries, err := fs.ReadDir(migrationFS, "migrations") if err != nil { return fmt.Errorf("failed to read migrations: %w", err) } var files []string for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { files = append(files, entry.Name()) } } sort.Strings(files) for _, file := range files { if applied[file] { continue } content, err := fs.ReadFile(migrationFS, fmt.Sprintf("migrations/%s", file)) if err != nil { return fmt.Errorf("failed to read migration %s: %w", file, err) } tx, err := db.conn.Begin() if err != nil { return fmt.Errorf("failed to begin tx for %s: %w", file, err) } if _, err := tx.Exec(string(content)); err != nil { tx.Rollback() return fmt.Errorf("failed to execute migration %s: %w", file, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, file); err != nil { tx.Rollback() return fmt.Errorf("failed to record migration %s: %w", file, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit migration %s: %w", file, err) } } return nil } // Close closes the database connection. func (db *DB) Close() error { return db.conn.Close() } // Conn returns the underlying sql.DB connection. func (db *DB) Conn() *sql.DB { return db.conn }