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
-
Generate migration file:
just migrate new add_leagues_and_slap_versionCreates:
cmd/oslstats/migrations/20250124150030_add_leagues_and_slap_version.go -
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 }, ) } -
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 } -
Apply the migration:
just migrate up 1Output:
[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/migrationsto 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 errorsthrowBadRequest(s, w, r, msg, err)- 400 errorsthrowForbidden(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
- Never commit .env files, keys/, or generated files (*_templ.go, output.css)
- Database operations should use
bun.Txfor transaction safety - Templates are written in templ, not Go html/template - run
just templafter changes - Static files are embedded via
//go:embed- check internal/embedfs/ - Error messages should be descriptive and use errors.Wrap for context
- Security is critical - especially in OAuth flows (see pkg/oauth/state_test.go for examples)
- Air proxy runs on port 3000 during development; app runs on 3333
- Configuration uses ezconf pattern - see internal/*/ezconf.go files for examples
- When in plan mode, always use the interactive question tool if available