Files
oslstats/AGENTS.md
2026-02-13 13:27:14 +11:00

9.3 KiB

AGENTS.md - Developer Guide for oslstats

This document provides guidelines for AI coding agents and developers working on the oslstats codebase.

Project Overview

Module: git.haelnorr.com/h/oslstats
Language: Go 1.25.5
Architecture: Web application with Discord OAuth, PostgreSQL database, templ templates
Key Technologies: Bun ORM, templ, TailwindCSS, custom golib libraries

Build and Development Commands

Building

NEVER BUILD MANUALLY

# Full production build (tailwind → templ → go generate → go build)
just build

# Build and run
just run

Development Mode

# Watch mode with hot reload (templ, air, tailwindcss in parallel)
just dev

# Development server runs on:
# - Proxy: http://localhost:3000 (use this)
# - App: http://localhost:3333 (internal)

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.

# Show migration status
just migrate status

# Run 1 migration (default, with automatic backup)
just migrate up 1
# OR just
just migrate up

# Run 3 migrations
just migrate up 3

# 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
just migrate new add_email_to_users

# Dev: Reset database (DESTRUCTIVE - deletes all data)
just reset-db

Creating a New Migration

Example: Adding an email field to users table

  1. Generate migration file:

    just migrate new add_leagues_and_slap_version
    

    Creates: cmd/oslstats/migrations/20250124150030_add_leagues_and_slap_version.go

  2. Edit the migration file:

    package migrations
    
    import (
        "context"
        "github.com/uptrace/bun"
    )
    
    func init() {
        Migrations.MustRegister(
            // UP: Add email column
            func(ctx context.Context, db *bun.DB) error {
               _, 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 {
     		        // 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
            },
        )
    }
    
  3. Update the model (internal/db/user.go):

    type Season struct {
        bun.BaseModel `bun:"table:seasons,alias:s"`
    
        ID        int    `bun:"id,pk,autoincrement"`
        Name  string `bun:"name,unique"`
        SlapVersion     string `bun:"slap_version"`          // NEW FIELD
    }
    
  4. Apply the migration:

    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...
    [INFO] Migration lock acquired
    [INFO] Step 5/5: Applying migrations...
    [INFO] Migrated to group 2
      ✅ 20250124150030_add_email_to_users
    [INFO] Migration lock released
    

Environment Variables

# Backup directory (default: backups)
DB_BACKUP_DIR=backups

# Number of backups to keep (default: 10)
DB_BACKUP_RETENTION=10

Troubleshooting

"pg_dump not found"

  • Migrations will still run, but backups will be skipped
  • Install PostgreSQL client tools for backups:
    # Ubuntu/Debian
    sudo apt-get install postgresql-client
    
    # macOS
    brew install postgresql
    
    # Arch
    sudo pacman -S postgresql-libs
    

"migration already in progress"

  • Another instance is running migrations
  • Wait for it to complete (max 5 minutes)
  • If stuck, check for hung database connections

"migration build failed"

  • Migration file has syntax errors
  • Fix the errors and try again
  • Use go build ./cmd/oslstats/migrations to debug

Configuration Management

# Generate .env template file
just genenv
# OR with custom output: just genenv .env.example

# Show environment variable documentation
just envdoc

# Show current environment values
just showenv

Code Style Guidelines

Error Handling

Always wrap errors with context using github.com/pkg/errors:

if err != nil {
    return errors.Wrap(err, "package.FunctionName")
}

Validate inputs at function start:

func DoSomething(cfg *Config, data string) error {
    if cfg == nil {
        return errors.New("cfg cannot be nil")
    }
    if data == "" {
        return errors.New("data cannot be empty")
    }
    // ... rest of function
}

HTTP error helpers (in handlers package): Note: msg should be a frontend presentable message

  • throwInternalServiceError(s, w, r, msg, err) - 500 errors
  • throwBadRequest(s, w, r, msg, err) - 400 errors
  • throwForbidden(s, w, r, msg, err) - 403 errors (normal)
  • throwForbiddenSecurity(s, w, r, msg, err) - 403 security violations (WARN level)
  • throwUnauthorized(s, w, r, msg, err) - 401 errors (normal)
  • throwUnauthorizedSecurity(s, w, r, msg, err) - 401 security violations (WARN level)
  • throwNotFound(s, w, r, path) - 404 errors

Common Patterns

HTTP Handler Pattern:

func HandlerName(server *hws.Server, deps ...) http.Handler {
    return http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            // Handler logic here
        },
    )
}

Database Operation Pattern:

func GetSomething(ctx context.Context, tx bun.Tx, id int) (*Result, error) {
    result := new(Result)
    err := tx.NewSelect().
        Model(result).
        Where("id = ?", id).
        Scan(ctx)
    if err != nil {
        if err.Error() == "sql: no rows in result set" {
            return nil, nil  // Return nil, nil for not found
        }
        return nil, errors.Wrap(err, "tx.Select")
    }
    return result, nil
}

Setup Function Pattern (returns instance, cleanup func, error):

func setupSomething(ctx context.Context, cfg *Config) (*Type, func() error, error) {
    instance := newInstance()
    
    err := configure(instance)
    if err != nil {
        return nil, nil, errors.Wrap(err, "configure")
    }
    
    return instance, instance.Close, nil
}

Configuration Pattern (using ezconf):

type Config struct {
    Field string // ENV FIELD_NAME: Description (required/default: value)
}

func ConfigFromEnv() (any, error) {
    cfg := &Config{
        Field: env.String("FIELD_NAME", "default"),
    }
    // Validation here
    return cfg, nil
}

Formatting & Types

Formatting:

  • Use gofmt (standard Go formatting)

Types:

  • Prefer explicit types over inference when it improves clarity
  • Use struct tags for ORM and JSON marshaling:
    type User struct {
        bun.BaseModel `bun:"table:users,alias:u"`
        ID           int    `bun:"id,pk,autoincrement"`
        Username     string `bun:"username,unique"`
        AccessToken  string `json:"access_token"`
    }
    

Comments:

  • Document exported functions and types
  • Use inline comments for ENV var documentation in Config structs
  • Explain security-critical code flows

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 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. Configuration uses ezconf pattern - see internal/*/ezconf.go files for examples
  9. When in plan mode, always use the interactive question tool if available