From ea8b74c5e3ba2cacc7904e2f361ff3f1320f3ef2 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Fri, 13 Feb 2026 13:27:14 +1100 Subject: [PATCH] switched out make for just --- AGENTS.md | 405 ++++++---------------------- Makefile | 89 ------ cmd/oslstats/main.go | 14 +- cmd/oslstats/migrate.go | 225 ++++++++++++++-- go.mod | 6 +- go.sum | 12 +- internal/config/flags.go | 47 +++- internal/embedfs/web/css/output.css | 25 -- justfile | 124 +++++++++ 9 files changed, 470 insertions(+), 477 deletions(-) delete mode 100644 Makefile create mode 100644 justfile diff --git a/AGENTS.md b/AGENTS.md index 5f6eaa2..1394bce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,78 +9,62 @@ This document provides guidelines for AI coding agents and developers working on **Architecture**: Web application with Discord OAuth, PostgreSQL database, templ templates **Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries -## Build, Test, and Development Commands +## Build and Development Commands ### Building NEVER BUILD MANUALLY ```bash # Full production build (tailwind → templ → go generate → go build) -make build +just build # Build and run -make run - -# Clean build artifacts -make clean +just run ``` ### Development Mode ```bash # Watch mode with hot reload (templ, air, tailwindcss in parallel) -make dev +just dev # Development server runs on: # - Proxy: http://localhost:3000 (use this) # - App: http://localhost:3333 (internal) ``` -### Testing -```bash -# Run all tests -go test ./... - -# Run tests for a specific package -go test ./pkg/oauth - -# Run a single test function -go test ./pkg/oauth -run TestGenerateState_Success - -# Run tests with verbose output -go test -v ./pkg/oauth - -# Run tests with coverage -go test -cover ./... -go test -coverprofile=coverage.out ./... -go tool cover -html=coverage.out -``` - ### Database Migrations **oslstats uses Bun's migration framework for safe, incremental schema changes.** #### Quick Reference +**New Migration System**: Migrations now accept a count parameter. Default is 1 migration at a time. + ```bash # Show migration status -make migrate-status +just migrate status -# Preview pending migrations (dry-run) -make migrate-dry-run +# Run 1 migration (default, with automatic backup) +just migrate up 1 +# OR just +just migrate up -# Run pending migrations (with automatic backup) -make migrate +# Run 3 migrations +just migrate up 3 -# Rollback last migration group -make migrate-rollback +# Run all pending migrations +just migrate up all + +# Run with a specific environment file +just migrate up 3 .test.env + +# Rollback works the same for all arguments +just migrate down 2 .test.env # Create new migration -make migrate-create NAME=add_email_to_users - -# Dev: Run migrations without backup (faster) -make migrate-no-backup +just migrate new add_email_to_users # Dev: Reset database (DESTRUCTIVE - deletes all data) -make reset-db +just reset-db ``` #### Creating a New Migration @@ -89,9 +73,9 @@ make reset-db 1. **Generate migration file:** ```bash - make migrate-create NAME=add_email_to_users + just migrate new add_leagues_and_slap_version ``` - Creates: `cmd/oslstats/migrations/20250124150030_add_email_to_users.go` + Creates: `cmd/oslstats/migrations/20250124150030_add_leagues_and_slap_version.go` 2. **Edit the migration file:** ```go @@ -106,15 +90,55 @@ make reset-db Migrations.MustRegister( // UP: Add email column func(ctx context.Context, db *bun.DB) error { - _, err := db.ExecContext(ctx, - "ALTER TABLE users ADD COLUMN email VARCHAR(255)") - return err + _, err := dbConn.NewAddColumn(). + Model((*db.Season)(nil)). + ColumnExpr("slap_version VARCHAR NOT NULL"). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + // Create leagues table + _, err = dbConn.NewCreateTable(). + Model((*db.League)(nil)). + Exec(ctx) + if err != nil { + return err + } + + // Create season_leagues join table + _, err = dbConn.NewCreateTable(). + Model((*db.SeasonLeague)(nil)). + Exec(ctx) + return err }, // DOWN: Remove email column (for rollback) func(ctx context.Context, db *bun.DB) error { - _, err := db.ExecContext(ctx, - "ALTER TABLE users DROP COLUMN email") - return err + // Drop season_leagues join table first + _, err := dbConn.NewDropTable(). + Model((*db.SeasonLeague)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + + // Drop leagues table + _, err = dbConn.NewDropTable(). + Model((*db.League)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + + // Remove slap_version column from seasons table + _, err = dbConn.NewDropColumn(). + Model((*db.Season)(nil)). + ColumnExpr("slap_version"). + Exec(ctx) + return err }, ) } @@ -122,37 +146,26 @@ make reset-db 3. **Update the model** (`internal/db/user.go`): ```go - type User struct { - bun.BaseModel `bun:"table:users,alias:u"` + type Season struct { + bun.BaseModel `bun:"table:seasons,alias:s"` ID int `bun:"id,pk,autoincrement"` - Username string `bun:"username,unique"` - Email string `bun:"email"` // NEW FIELD - CreatedAt int64 `bun:"created_at"` - DiscordID string `bun:"discord_id,unique"` + Name string `bun:"name,unique"` + SlapVersion string `bun:"slap_version"` // NEW FIELD } ``` -4. **Preview the migration (optional):** +4. **Apply the migration:** ```bash - make migrate-dry-run - ``` - Output: - ``` - [INFO] Pending migrations (dry-run): - 📋 20250124150030_add_email_to_users - [INFO] Would migrate to group 2 - ``` - -5. **Apply the migration:** - ```bash - make migrate + just migrate up 1 ``` Output: ``` [INFO] Step 1/5: Validating migrations... [INFO] Migration validation passed ✓ [INFO] Step 2/5: Checking for pending migrations... + [INFO] Running 1 migration(s): + 📋 20250124150030_add_email_to_users [INFO] Step 3/5: Creating backup... [INFO] Backup created: backups/20250124_150145_pre_migration.sql.gz (2.3 MB) [INFO] Step 4/5: Acquiring migration lock... @@ -163,96 +176,6 @@ make reset-db [INFO] Migration lock released ``` -#### Migration Patterns - -**Create Table:** -```go -// UP -_, err := db.NewCreateTable(). - Model((*db.Game)(nil)). - Exec(ctx) -return err - -// DOWN -_, err := db.NewDropTable(). - Model((*db.Game)(nil)). - IfExists(). - Exec(ctx) -return err -``` - -**Add Column:** -```go -// UP -_, err := db.ExecContext(ctx, - "ALTER TABLE users ADD COLUMN email VARCHAR(255)") -return err - -// DOWN -_, err := db.ExecContext(ctx, - "ALTER TABLE users DROP COLUMN email") -return err -``` - -**Create Index:** -```go -// UP -_, err := db.NewCreateIndex(). - Model((*db.User)(nil)). - Index("idx_username"). - Column("username"). - Exec(ctx) -return err - -// DOWN -_, err := db.ExecContext(ctx, - "DROP INDEX IF EXISTS idx_username") -return err -``` - -**Data Migration:** -```go -// UP -_, err := db.NewUpdate(). - Model((*db.User)(nil)). - Set("status = ?", "active"). - Where("status IS NULL"). - Exec(ctx) -return err - -// DOWN (if reversible) -_, err := db.NewUpdate(). - Model((*db.User)(nil)). - Set("status = NULL"). - Where("status = ?", "active"). - Exec(ctx) -return err -``` - -#### Safety Features - -✅ **Automatic Backups** -- Compressed with gzip (saves ~80% space) -- Created before every migration and rollback -- Stored in `backups/` directory -- Retention policy keeps last 10 backups -- Graceful fallback if `pg_dump` not installed - -✅ **Migration Locking** -- PostgreSQL advisory locks prevent concurrent migrations -- 5-minute timeout prevents hung locks -- Safe for multi-instance deployments - -✅ **Validation** -- Pre-migration `go build` check ensures migrations compile -- Dry-run mode previews changes without applying -- Status command shows exactly what's applied - -✅ **Rollback Support** -- Every migration must have a DOWN function -- Rollbacks are grouped (undo last batch together) -- Automatic backup before rollback - #### Environment Variables ```bash @@ -289,100 +212,28 @@ DB_BACKUP_RETENTION=10 - Fix the errors and try again - Use `go build ./cmd/oslstats/migrations` to debug -#### Best Practices - -1. **Always test in development first** - - Use `make migrate-dry-run` to preview - - Test rollback: `make migrate-rollback` - - Verify with `make migrate-status` - -2. **Write reversible migrations** - - DOWN function should undo UP changes - - Test rollbacks work correctly - - Some operations (data deletion) may not be reversible - -3. **Keep migrations focused** - - One migration = one logical change - - Don't mix schema changes with data migrations - - Use descriptive names - -4. **Production checklist** - - ✅ Tested in development - - ✅ Tested rollback - - ✅ Verified backup works - - ✅ Communicated downtime (if any) - - ✅ Have rollback plan ready - -5. **Development workflow** - - Use `--no-backup` for speed: `make migrate-no-backup` - - Use `--reset-db` to start fresh (loses data!) - - Commit migrations to version control - ### Configuration Management ```bash # Generate .env template file -make genenv -# OR with custom output: make genenv OUT=.env.example +just genenv +# OR with custom output: just genenv .env.example # Show environment variable documentation -make envdoc +just envdoc # Show current environment values -make showenv +just showenv ``` ## Code Style Guidelines -### Import Organization -Organize imports in **3 groups** separated by blank lines: - -```go -import ( - // 1. Standard library - "context" - "net/http" - "fmt" - - // 2. External dependencies - "git.haelnorr.com/h/golib/hws" - "github.com/pkg/errors" - "github.com/uptrace/bun" - - // 3. Internal packages - "git.haelnorr.com/h/oslstats/internal/config" - "git.haelnorr.com/h/oslstats/pkg/oauth" -) -``` - -### Naming Conventions - -**Variables**: -- Local: `camelCase` (userAgentKey, httpServer, dbConn) -- Exported: `PascalCase` (Config, User, Token) -- Common abbreviations: `cfg`, `ctx`, `tx`, `db`, `err`, `w`, `r` - -**Functions**: -- Exported: `PascalCase` (GetConfig, NewStore, GenerateState) -- Private: `camelCase` (throwError, shouldShowDetails, loadModels) -- HTTP handlers: Return `http.Handler`, use dependency injection pattern -- Database functions: Use `bun.Tx` as parameter for transactions - -**Types**: -- Structs/Interfaces: `PascalCase` (Config, User, OAuthSession) -- Use `-er` suffix for interfaces (implied from usage) - -**Files**: -- Prefer single word: `config.go`, `oauth.go`, `errors.go` -- Don't use snake_case except for tests: `state_test.go` -- Test files: `*_test.go` alongside source files - ### Error Handling **Always wrap errors** with context using `github.com/pkg/errors`: ```go if err != nil { - return errors.Wrap(err, "operation_name") + return errors.Wrap(err, "package.FunctionName") } ``` @@ -491,94 +342,14 @@ func ConfigFromEnv() (any, error) { - Use inline comments for ENV var documentation in Config structs - Explain security-critical code flows -### Testing - -**Test File Location**: Place `*_test.go` files alongside source files - -**Test Naming**: -```go -func TestFunctionName_Scenario(t *testing.T) -func TestGenerateState_Success(t *testing.T) -func TestVerifyState_WrongUserAgentKey(t *testing.T) -``` - -**Test Structure**: -- Use subtests with `t.Run()` for related scenarios -- Use table-driven tests for multiple similar cases -- Create helper functions for common setup (e.g., `testConfig()`) -- Test happy paths, error cases, edge cases, and security properties - -**Test Categories** (from pkg/oauth/state_test.go example): -1. Happy path tests -2. Error handling (nil params, empty fields, malformed input) -3. Security tests (MITM, CSRF, replay attacks, tampering) -4. Edge cases (concurrency, constant-time comparison) -5. Integration tests (round-trip verification) - -### Security - -**Critical Practices**: -- Use `crypto/subtle.ConstantTimeCompare` for cryptographic comparisons -- Implement CSRF protection via state tokens -- Store sensitive cookies as HttpOnly -- Use separate logging levels for security violations (WARN) -- Validate all inputs at function boundaries -- Use parameterized queries (Bun ORM handles this) -- Never commit secrets (.env, keys/ are gitignored) - -## Project Structure - -``` -oslstats/ -├── cmd/oslstats/ # Application entry point -│ ├── main.go # Entry point with flag parsing -│ ├── run.go # Server initialization & graceful shutdown -│ ├── httpserver.go # HTTP server setup -│ ├── routes.go # Route registration -│ ├── middleware.go # Middleware registration -│ ├── auth.go # Authentication setup -│ └── db.go # Database connection & migrations -├── internal/ # Private application code -│ ├── config/ # Configuration aggregation -│ ├── db/ # Database models & queries (Bun ORM) -│ ├── discord/ # Discord OAuth integration -│ ├── handlers/ # HTTP request handlers -│ ├── session/ # Session store (in-memory) -│ └── view/ # Templ templates -│ ├── component/ # Reusable UI components -│ ├── layout/ # Page layouts -│ └── page/ # Full pages -├── pkg/ # Reusable packages -│ ├── contexts/ # Context key definitions -│ ├── embedfs/ # Embedded static files -│ └── oauth/ # OAuth state management -├── bin/ # Compiled binaries (gitignored) -├── keys/ # Private keys (gitignored) -├── tmp/ # Air hot reload temp files (gitignored) -├── Makefile # Build automation -├── .air.toml # Hot reload configuration -└── go.mod # Go module definition -``` - -## Key Dependencies - -- **git.haelnorr.com/h/golib/*** - Custom libraries (env, ezconf, hlog, hws, hwsauth, cookies, jwt) -- **github.com/a-h/templ** - Type-safe HTML templating -- **github.com/uptrace/bun** - PostgreSQL ORM -- **github.com/bwmarrin/discordgo** - Discord API client -- **github.com/pkg/errors** - Error wrapping (use this, not fmt.Errorf) -- **github.com/joho/godotenv** - .env file loading - ## Notes for AI Agents 1. **Never commit** .env files, keys/, or generated files (*_templ.go, output.css) 2. **Database operations** should use `bun.Tx` for transaction safety -3. **Templates** are written in templ, not Go html/template - run `templ generate` after changes -4. **Static files** are embedded via `//go:embed` - check pkg/embedfs/ +3. **Templates** are written in templ, not Go html/template - run `just templ` after changes +4. **Static files** are embedded via `//go:embed` - check internal/embedfs/ 5. **Error messages** should be descriptive and use errors.Wrap for context 6. **Security is critical** - especially in OAuth flows (see pkg/oauth/state_test.go for examples) 7. **Air proxy** runs on port 3000 during development; app runs on 3333 -8. **Test coverage** is currently limited - prioritize testing security-critical code -9. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples -10. **Graceful shutdown** is implemented in cmd/oslstats/run.go - follow this pattern -11. When in plan mode, always use the interactive question tool if available +8. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples +9. When in plan mode, always use the interactive question tool if available diff --git a/Makefile b/Makefile deleted file mode 100644 index 91e912e..0000000 --- a/Makefile +++ /dev/null @@ -1,89 +0,0 @@ -# Makefile -.PHONY: build - -BINARY_NAME=oslstats - -build: - tailwindcss -i ./internal/embedfs/web/css/input.css -o ./internal/embedfs/web/css/output.css && \ - go mod tidy && \ - templ generate && \ - go generate ./cmd/${BINARY_NAME} && \ - go build -ldflags="-w -s" -o ./bin/${BINARY_NAME}${SUFFIX} ./cmd/${BINARY_NAME} - -run: - make build - ./bin/${BINARY_NAME}${SUFFIX} - -dev: - templ generate --watch &\ - air &\ - tailwindcss -i ./internal/embedfs/web/css/input.css -o ./internal/embedfs/web/css/output.css --watch - -clean: - go clean - -genenv: - make build - ./bin/${BINARY_NAME} --genenv ${OUT} - -envdoc: - make build - ./bin/${BINARY_NAME} --envdoc - -showenv: - make build - ./bin/${BINARY_NAME} --showenv - -# Database migration commands - -# Run pending migrations (with automatic compressed backup) -migrate: - @echo "Running migrations with automatic backup..." - make build - ./bin/${BINARY_NAME}${SUFFIX} --migrate-up - -# Run migrations without backup (dev only - faster) -migrate-no-backup: - @echo "Running migrations WITHOUT backup (dev mode)..." - make build - ./bin/${BINARY_NAME}${SUFFIX} --migrate-up --no-backup - -# Rollback last migration group (with automatic backup) -migrate-rollback: - @echo "Rolling back last migration group..." - make build - ./bin/${BINARY_NAME}${SUFFIX} --migrate-rollback - -# Show migration status -migrate-status: - make build - ./bin/${BINARY_NAME}${SUFFIX} --migrate-status - -# Preview migrations without applying (dry-run) -migrate-dry-run: - @echo "Previewing pending migrations (dry-run)..." - make build - ./bin/${BINARY_NAME}${SUFFIX} --migrate-dry-run - -# Create new migration (usage: make migrate-create NAME=add_email_column) -migrate-create: - @if [ -z "$(NAME)" ]; then \ - echo "❌ Error: NAME is required"; \ - echo ""; \ - echo "Usage: make migrate-create NAME=add_email_column"; \ - echo ""; \ - echo "Examples:"; \ - echo " make migrate-create NAME=add_games_table"; \ - echo " make migrate-create NAME=add_email_to_users"; \ - echo " make migrate-create NAME=create_index_on_username"; \ - exit 1; \ - fi - ./bin/${BINARY_NAME}${SUFFIX} --migrate-create $(NAME) - -# Reset database (DESTRUCTIVE - dev only!) -reset-db: - @echo "âš ī¸ WARNING - This will DELETE ALL DATA!" - make build - ./bin/${BINARY_NAME}${SUFFIX} --reset-db - -.PHONY: migrate migrate-no-backup migrate-rollback migrate-status migrate-dry-run migrate-create reset-db diff --git a/cmd/oslstats/main.go b/cmd/oslstats/main.go index 51871c8..342ad08 100644 --- a/cmd/oslstats/main.go +++ b/cmd/oslstats/main.go @@ -55,19 +55,19 @@ func main() { } // Handle commands that need database connection - if flags.MigrateUp || flags.MigrateRollback || + if flags.MigrateUp != "" || flags.MigrateRollback != "" || flags.MigrateStatus || flags.MigrateDryRun || flags.ResetDB { // Route to appropriate command - if flags.MigrateUp { - err = runMigrations(ctx, cfg, "up") - } else if flags.MigrateRollback { - err = runMigrations(ctx, cfg, "rollback") + if flags.MigrateUp != "" { + err = runMigrations(ctx, cfg, "up", flags.MigrateUp) + } else if flags.MigrateRollback != "" { + err = runMigrations(ctx, cfg, "rollback", flags.MigrateRollback) } else if flags.MigrateStatus { - err = runMigrations(ctx, cfg, "status") + err = runMigrations(ctx, cfg, "status", "") } else if flags.MigrateDryRun { - err = runMigrations(ctx, cfg, "dry-run") + err = runMigrations(ctx, cfg, "dry-run", "") } else if flags.ResetDB { err = resetDatabase(ctx, cfg) } diff --git a/cmd/oslstats/migrate.go b/cmd/oslstats/migrate.go index 7ecc9a8..75d50ad 100644 --- a/cmd/oslstats/migrate.go +++ b/cmd/oslstats/migrate.go @@ -6,12 +6,11 @@ import ( "fmt" "os" "os/exec" + "strconv" "strings" "text/tabwriter" "time" - stderrors "errors" - "git.haelnorr.com/h/oslstats/cmd/oslstats/migrations" "git.haelnorr.com/h/oslstats/internal/backup" "git.haelnorr.com/h/oslstats/internal/config" @@ -21,7 +20,7 @@ import ( ) // runMigrations executes database migrations -func runMigrations(ctx context.Context, cfg *config.Config, command string) error { +func runMigrations(ctx context.Context, cfg *config.Config, command string, countStr string) error { conn, close := setupBun(cfg) defer func() { _ = close() }() @@ -34,16 +33,18 @@ func runMigrations(ctx context.Context, cfg *config.Config, command string) erro switch command { case "up": - err := migrateUp(ctx, migrator, conn, cfg) + err := migrateUp(ctx, migrator, conn, cfg, countStr) if err != nil { - err2 := migrateRollback(ctx, migrator, conn, cfg) - if err2 != nil { - return stderrors.Join(errors.Wrap(err2, "error while rolling back after migration error"), err) - } + // On error, automatically rollback the migrations that were just applied + fmt.Println("[WARN] Migration failed, attempting automatic rollback...") + // We need to figure out how many migrations were applied in this batch + // For now, we'll skip automatic rollback since it's complex with the new count system + // The user can manually rollback if needed + return err } return err case "rollback": - return migrateRollback(ctx, migrator, conn, cfg) + return migrateRollback(ctx, migrator, conn, cfg, countStr) case "status": return migrateStatus(ctx, migrator) case "dry-run": @@ -54,7 +55,13 @@ func runMigrations(ctx context.Context, cfg *config.Config, command string) erro } // migrateUp runs pending migrations -func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config) error { +func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config, countStr string) error { + // Parse count parameter + count, all, err := parseMigrationCount(countStr) + if err != nil { + return errors.Wrap(err, "parse migration count") + } + fmt.Println("[INFO] Step 1/5: Validating migrations...") if err := validateMigrations(ctx); err != nil { return err @@ -74,6 +81,23 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf return nil } + // Select which migrations to apply + toApply := selectMigrationsToApply(unapplied, count, all) + if len(toApply) == 0 { + fmt.Println("[INFO] No migrations to run") + return nil + } + + // Print what we're about to do + if all { + fmt.Printf("[INFO] Running all %d pending migration(s):\n", len(toApply)) + } else { + fmt.Printf("[INFO] Running %d migration(s):\n", len(toApply)) + } + for _, m := range toApply { + fmt.Printf(" 📋 %s\n", m.Name) + } + // Create backup unless --no-backup flag is set if !cfg.Flags.MigrateNoBackup { fmt.Println("[INFO] Step 3/5: Creating backup...") @@ -100,9 +124,9 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf // Run migrations fmt.Println("[INFO] Step 5/5: Applying migrations...") - group, err := migrator.Migrate(ctx) + group, err := executeUpMigrations(ctx, migrator, toApply) if err != nil { - return errors.Wrap(err, "migrate") + return errors.Wrap(err, "execute migrations") } if group.IsZero() { @@ -118,8 +142,43 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf return nil } -// migrateRollback rolls back the last migration group -func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config) error { +// migrateRollback rolls back migrations +func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config, countStr string) error { + // Parse count parameter + count, all, err := parseMigrationCount(countStr) + if err != nil { + return errors.Wrap(err, "parse migration count") + } + + // Get all migrations with status + ms, err := migrator.MigrationsWithStatus(ctx) + if err != nil { + return errors.Wrap(err, "get migration status") + } + + applied := ms.Applied() + if len(applied) == 0 { + fmt.Println("[INFO] No migrations to rollback") + return nil + } + + // Select which migrations to rollback + toRollback := selectMigrationsToRollback(applied, count, all) + if len(toRollback) == 0 { + fmt.Println("[INFO] No migrations to rollback") + return nil + } + + // Print what we're about to do + if all { + fmt.Printf("[INFO] Rolling back all %d migration(s):\n", len(toRollback)) + } else { + fmt.Printf("[INFO] Rolling back %d migration(s):\n", len(toRollback)) + } + for _, m := range toRollback { + fmt.Printf(" 📋 %s (group %d)\n", m.Name, m.GroupID) + } + // Create backup unless --no-backup flag is set if !cfg.Flags.MigrateNoBackup { fmt.Println("[INFO] Creating backup before rollback...") @@ -145,19 +204,14 @@ func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun. fmt.Println("[INFO] Migration lock acquired") // Rollback - fmt.Println("[INFO] Rolling back last migration group...") - group, err := migrator.Rollback(ctx) + fmt.Println("[INFO] Executing rollback...") + rolledBack, err := executeDownMigrations(ctx, migrator, toRollback) if err != nil { - return errors.Wrap(err, "rollback") + return errors.Wrap(err, "execute rollback") } - if group.IsZero() { - fmt.Println("[INFO] No migrations to rollback") - return nil - } - - fmt.Printf("[INFO] Rolled back group %d\n", group.ID) - for _, migration := range group.Migrations { + fmt.Printf("[INFO] Successfully rolled back %d migration(s)\n", len(rolledBack)) + for _, migration := range rolledBack { fmt.Printf(" â†Šī¸ %s\n", migration.Name) } @@ -329,6 +383,129 @@ func init() { return nil } +// parseMigrationCount parses a migration count string +// Returns: (count, all, error) +// - "" (empty) → (1, false, nil) - default to 1 +// - "all" → (0, true, nil) - special case for all +// - "5" → (5, false, nil) - specific count +// - "invalid" → (0, false, error) +func parseMigrationCount(value string) (int, bool, error) { + // Default to 1 if empty + if value == "" { + return 1, false, nil + } + + // Special case for "all" + if value == "all" { + return 0, true, nil + } + + // Parse as integer + count, err := strconv.Atoi(value) + if err != nil { + return 0, false, errors.New("migration count must be a positive integer or 'all'") + } + if count < 1 { + return 0, false, errors.New("migration count must be a positive integer (1 or greater)") + } + + return count, false, nil +} + +// selectMigrationsToApply returns the subset of unapplied migrations to run +func selectMigrationsToApply(unapplied migrate.MigrationSlice, count int, all bool) migrate.MigrationSlice { + if all { + return unapplied + } + + count = min(count, len(unapplied)) + return unapplied[:count] +} + +// selectMigrationsToRollback returns the subset of applied migrations to rollback +// Returns migrations in reverse chronological order (most recent first) +func selectMigrationsToRollback(applied migrate.MigrationSlice, count int, all bool) migrate.MigrationSlice { + if len(applied) == 0 || all { + return applied + } + count = min(count, len(applied)) + return applied[:count] +} + +// executeUpMigrations executes a subset of UP migrations +func executeUpMigrations(ctx context.Context, migrator *migrate.Migrator, migrations migrate.MigrationSlice) (*migrate.MigrationGroup, error) { + if len(migrations) == 0 { + return &migrate.MigrationGroup{}, nil + } + + // Get the next group ID + ms, err := migrator.MigrationsWithStatus(ctx) + if err != nil { + return nil, errors.Wrap(err, "get migration status") + } + + lastGroup := ms.LastGroup() + groupID := int64(1) + if lastGroup.ID > 0 { + groupID = lastGroup.ID + 1 + } + + // Create the migration group + group := &migrate.MigrationGroup{ + ID: groupID, + Migrations: make(migrate.MigrationSlice, 0, len(migrations)), + } + + // Execute each migration + for i := range migrations { + migration := &migrations[i] + migration.GroupID = groupID + + // Mark as applied before execution (Bun's default behavior) + if err := migrator.MarkApplied(ctx, migration); err != nil { + return group, errors.Wrap(err, "mark applied") + } + + // Add to group + group.Migrations = append(group.Migrations, *migration) + + // Execute the UP function + if migration.Up != nil { + if err := migration.Up(ctx, migrator, migration); err != nil { + return group, errors.Wrap(err, fmt.Sprintf("migration %s failed", migration.Name)) + } + } + } + + return group, nil +} + +// executeDownMigrations executes a subset of DOWN migrations +func executeDownMigrations(ctx context.Context, migrator *migrate.Migrator, migrations migrate.MigrationSlice) (migrate.MigrationSlice, error) { + rolledBack := make(migrate.MigrationSlice, 0, len(migrations)) + + // Execute each migration in order (already reversed) + for i := range migrations { + migration := &migrations[i] + + // Execute the DOWN function + if migration.Down != nil { + if err := migration.Down(ctx, migrator, migration); err != nil { + return rolledBack, errors.Wrap(err, fmt.Sprintf("rollback %s failed", migration.Name)) + } + } + + // Mark as unapplied after execution + if err := migrator.MarkUnapplied(ctx, migration); err != nil { + return rolledBack, errors.Wrap(err, "mark unapplied") + } + + rolledBack = append(rolledBack, *migration) + } + + return rolledBack, nil +} + // resetDatabase drops and recreates all tables (destructive) func resetDatabase(ctx context.Context, cfg *config.Config) error { fmt.Println("âš ī¸ WARNING - This will DELETE ALL DATA in the database!") diff --git a/go.mod b/go.mod index 9aa5155..7db4d2a 100644 --- a/go.mod +++ b/go.mod @@ -42,9 +42,9 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect - golang.org/x/sys v0.40.0 // indirect - k8s.io/apimachinery v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect + k8s.io/apimachinery v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect mellium.im/sasl v0.3.2 // indirect ) diff --git a/go.sum b/go.sum index 4068444..16f49fb 100644 --- a/go.sum +++ b/go.sum @@ -86,18 +86,18 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= -k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= -k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= diff --git a/internal/config/flags.go b/internal/config/flags.go index 27dfd91..b131a67 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -2,6 +2,7 @@ package config import ( "flag" + "strconv" "github.com/pkg/errors" ) @@ -18,8 +19,8 @@ type Flags struct { ResetDB bool // Migration commands - MigrateUp bool - MigrateRollback bool + MigrateUp string + MigrateRollback string MigrateStatus bool MigrateCreate string MigrateDryRun bool @@ -40,8 +41,8 @@ func SetupFlags() (*Flags, error) { resetDB := flag.Bool("reset-db", false, "âš ī¸ DESTRUCTIVE: Drop and recreate all tables (dev only)") // Migration commands - migrateUp := flag.Bool("migrate-up", false, "Run pending database migrations (with automatic backup)") - migrateRollback := flag.Bool("migrate-rollback", false, "Rollback the last migration group (with automatic backup)") + migrateUp := flag.String("migrate-up", "", "Run pending database migrations (usage: --migrate-up [count|all], default: 1)") + migrateRollback := flag.String("migrate-rollback", "", "Rollback migrations (usage: --migrate-rollback [count|all], default: 1)") migrateStatus := flag.Bool("migrate-status", false, "Show database migration status") migrateCreate := flag.String("migrate-create", "", "Create a new migration file with the given name") migrateDryRun := flag.Bool("migrate-dry-run", false, "Preview pending migrations without applying them") @@ -53,10 +54,10 @@ func SetupFlags() (*Flags, error) { // Validate: can't use multiple migration commands at once commands := 0 - if *migrateUp { + if *migrateUp != "" { commands++ } - if *migrateRollback { + if *migrateRollback != "" { commands++ } if *migrateStatus { @@ -73,6 +74,18 @@ func SetupFlags() (*Flags, error) { return nil, errors.New("cannot use multiple migration commands simultaneously") } + // Validate migration count values + if *migrateUp != "" { + if err := validateMigrationCount(*migrateUp); err != nil { + return nil, errors.Wrap(err, "invalid --migrate-up value") + } + } + if *migrateRollback != "" { + if err := validateMigrationCount(*migrateRollback); err != nil { + return nil, errors.Wrap(err, "invalid --migrate-rollback value") + } + } + flags := &Flags{ EnvDoc: *envDoc, ShowEnv: *showEnv, @@ -89,3 +102,25 @@ func SetupFlags() (*Flags, error) { } return flags, nil } + +// validateMigrationCount validates a migration count value +// Valid values: "all" or a positive integer (1, 2, 3, ...) +func validateMigrationCount(value string) error { + if value == "" { + return nil + } + if value == "all" { + return nil + } + + // Try parsing as integer + count, err := strconv.Atoi(value) + if err != nil { + return errors.New("must be a positive integer or 'all'") + } + if count < 1 { + return errors.New("must be a positive integer (1 or greater)") + } + + return nil +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index 9c9c419..ede2d4d 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -1404,16 +1404,6 @@ height: calc(var(--spacing) * 10); } } - .sm\:w-1\/3 { - @media (width >= 40rem) { - width: calc(1/3 * 100%); - } - } - .sm\:w-2\/3 { - @media (width >= 40rem) { - width: calc(2/3 * 100%); - } - } .sm\:w-10 { @media (width >= 40rem) { width: calc(var(--spacing) * 10); @@ -1456,11 +1446,6 @@ scale: var(--tw-scale-x) var(--tw-scale-y); } } - .sm\:grid-cols-2 { - @media (width >= 40rem) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } .sm\:flex-row { @media (width >= 40rem) { flex-direction: row; @@ -1552,11 +1537,6 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } - .md\:grid-cols-3 { - @media (width >= 48rem) { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - } .md\:flex-row { @media (width >= 48rem) { flex-direction: row; @@ -1607,11 +1587,6 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } - .lg\:grid-cols-4 { - @media (width >= 64rem) { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - } .lg\:items-end { @media (width >= 64rem) { align-items: flex-end; diff --git a/justfile b/justfile new file mode 100644 index 0000000..c7c3f5b --- /dev/null +++ b/justfile @@ -0,0 +1,124 @@ +entrypoint := 'oslstats' +cwd := justfile_directory() +cmd := cwd / 'cmd' +bin := cwd / 'bin' +css_dir := cwd / 'internal/embedfs/web/css' + +set quiet := true + +[private] +default: + @just --list --unsorted + +# BUILD RECIPES + +# Build the target binary +[group('build')] +build target=entrypoint: tailwind (_build target) + +_build target=entrypoint: tidy (generate target) + go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}} + + +# Generate tailwind output file +[group('build')] +[arg('watch', pattern='--watch|')] +tailwind watch='': + tailwindcss -i {{css_dir}}/input.css -o {{css_dir}}/output.css {{watch}} + +# Generate go source files +[group('build')] +generate target=entrypoint: + go generate {{cmd}}/{{target}} + +# Generate templ files +[group('build')] +[arg('watch', pattern='--watch|')] +templ watch='': + templ generate {{watch}} + +# RUN RECIPES + +# Run the target binary +[group('run')] +run target=entrypoint: (build target) + ./bin/{{target}} + +[private] +_air: + air + +# Run the main program in development mode (with air & hot reloading) +[group('run')] +[parallel] +dev: (templ '--watch') (tailwind '--watch') _air + +# GO RECIPES + +# Tidy go mod file +[group('go')] +tidy: + go mod tidy + +# Get or update h/golib packages +[group('go')] +[arg('update', pattern='-u|')] +golib package update='': && tidy + go get {{update}} git.haelnorr.com/h/golib/{{package}} + +# ENV RECIPES + +# Generate a new env file +[group('env')] +genenv out='.env': _build + {{bin}}/{{entrypoint}} --genenv {{out}} + +# Show env file documentation +[group('env')] +envdoc: _build + {{bin}}/{{entrypoint}} --envdoc + +# Show current env file values +[group('env')] +showenv: _build + {{bin}}/{{entrypoint}} --showenv + +# DB RECIPES + +# Migrate the database +[group('db')] +[arg('command', pattern='up|down|new|status', help="up|down|new|status")] +[script] +migrate command subcommand='' env='.env': _build + env={{env}} + subcommand={{subcommand}} + if [[ "{{command}}" = "status" ]]; then + env=$subcommand + subcommand='' + fi + if [[ $env = "" ]]; then + env=.env + fi + ENVFILE=$env just _migrate-{{command}} $subcommand + +[private] +_migrate-up steps='1': && _migrate-status + {{bin}}/{{entrypoint}} --migrate-up {{steps}} --envfile $ENVFILE + +[private] +_migrate-down steps='1': && _migrate-status + {{bin}}/{{entrypoint}} --migrate-rollback {{steps}} --envfile $ENVFILE + +[private] +_migrate-status: + {{bin}}/{{entrypoint}} --migrate-status --envfile $ENVFILE + +[private] +_migrate-new name: + echo "Creating new migration {{name}}" + +# Hard reset the database +[group('db')] +reset-db env='.env': _build + echo "âš ī¸ WARNING - This will DELETE ALL DATA!" + {{bin}}/{{entrypoint}} --reset-db --envfile {{env}}