package store import ( "database/sql" "embed" "fmt" "log" "sort" "strings" "time" ) //go:embed migrations/*.sql var migrationsFS embed.FS // devOnlyMigrations lists migration filenames that should only be applied // in development mode (--dev flag). var devOnlyMigrations = map[string]bool{ "004_dev_seed.sql": true, } // RunMigrations applies all pending SQL migrations embedded in the binary. // Migrations are sorted by filename (numeric prefix ensures order) and // executed within individual transactions. Each successful migration is // recorded in the _migrations table to prevent re-application. // // If devMode is false, migrations listed in devOnlyMigrations are skipped. func RunMigrations(db *sql.DB, devMode bool) error { // Create migrations tracking table _, err := db.Exec(` CREATE TABLE IF NOT EXISTS _migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, applied_at INTEGER NOT NULL ) `) if err != nil { return fmt.Errorf("create _migrations table: %w", err) } // Read all migration files from embedded filesystem entries, err := migrationsFS.ReadDir("migrations") if err != nil { return fmt.Errorf("read migrations dir: %w", err) } // Sort by filename to ensure order sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) applied := 0 skipped := 0 for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { continue } name := entry.Name() // Skip dev-only migrations unless in dev mode if devOnlyMigrations[name] && !devMode { log.Printf("store: skipping dev-only migration %s (not in dev mode)", name) continue } // Check if already applied var count int err := db.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count) if err != nil { return fmt.Errorf("check migration %s: %w", name, err) } if count > 0 { skipped++ continue } // Read migration SQL sqlBytes, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return fmt.Errorf("read migration %s: %w", name, err) } // Execute migration within a transaction tx, err := db.Begin() if err != nil { return fmt.Errorf("begin transaction for %s: %w", name, err) } if _, err := tx.Exec(string(sqlBytes)); err != nil { tx.Rollback() return fmt.Errorf("execute migration %s: %w", name, err) } // Record migration as applied now := time.Now().Unix() if _, err := tx.Exec( "INSERT INTO _migrations (name, applied_at) VALUES (?, ?)", name, now, ); err != nil { tx.Rollback() return fmt.Errorf("record migration %s: %w", name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", name, err) } applied++ log.Printf("store: applied migration %s", name) } if applied > 0 { log.Printf("store: %d migration(s) applied, %d already up-to-date", applied, skipped) } else if skipped > 0 { log.Printf("store: all %d migration(s) already applied", skipped) } return nil }