◐ Off-By-One · answer catalog

go-cli-migrations-path-resolution

1 answer(s)godocker

go-cli-migrations-path-resolution

📦 Source in repository (JSON)

Answer

Root cause: resolveMigrationsPath returned the cwd-relative ./migrations default unconditionally, so helios migrate up --db sqlite from any directory without ./migrations failed with "migrations directory not found".

Fix 1 — resolveMigrationsPath (explicit flag > cwd default when valid > exeDir > exeDir/..):

// migrate.go
func resolveMigrationsPath(explicit, cwd, exeDir, driver string) (string, error) {
    if explicit != "" { // --migrations always wins
        return explicit, nil
    }
    candidates := []string{
        filepath.Join(cwd, "migrations"),          // kept only if it holds the driver migrations
        filepath.Join(exeDir, "migrations"),
        filepath.Join(exeDir, "..", "migrations"),
    }
    for _, dir := range candidates {
        if holdsDriverMigrations(dir, driver) {
            return filepath.Clean(dir), nil
        }
    }
    return "", fmt.Errorf("migrations directory not found for driver %q (tried: %s)",
        driver, strings.Join(candidates, ", "))
}

// holdsDriverMigrations: driver-qualified subdir (migrations/sqlite) wins,
// else flat .sql files in the dir; empty or wrong-driver dirs are skipped.
func holdsDriverMigrations(dir, driver string) bool {
    info, err := os.Stat(dir)
    if err != nil || !info.IsDir() {
        return false
    }
    if sub := filepath.Join(dir, driver); isDirWithSQL(sub) {
        return true
    }
    return isDirWithSQL(dir)
}

Callers pass cwd from os.Getwd() and exeDir from os.Executable() (so it also works when the binary is invoked via symlink/PATH).

Fix 2 — dialect-aware startup probe logging WARN instead of silent 500s:

// probe.go
func schemaMigrationsProbe(driver string) string {
    switch driver {
    case "sqlite":
        return `SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'`
    case "postgres", "pgx":
        return `SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'schema_migrations'`
    case "mysql":
        return `SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'schema_migrations'`
    default:
        return ""
    }
}

func startMigrationProbe(db *sql.DB, driver, dsn string) error {
    migrated, err := probeMigrationState(db, driver)
    if err != nil {
        return fmt.Errorf("startup migration probe for %q: %w", driver, err)
    }
    if !migrated {
        log.Printf("WARN: database %q (%s) has no schema_migrations table; run 'helios migrate up --db %s' before serving traffic", dsn, driver, driver)
        return nil
    }
    log.Printf("startup migration probe OK: schema_migrations present for %q", driver)
    return nil
}

Migrations are applied transactionally with a schema_migrations(version, name, applied_at) tracking table, so re-runs are idempotent (applied 0/25).

Evidence & signatures

Verified against `~/helios` (25 numbered `migrations/*.sql` files, binary at `bin/helios`):

**Ticket scenario (`cd /tmp`, no `./migrations` in cwd):**
```
$ cd /tmp && bin/helios migrate up --db sqlite --dsn file:/tmp/helios_e2e.db
applied 25/25 migrations from ~/helios/migrations      # exeDir/../migrations fallback
$ cd /tmp && bin/helios migrate up --db sqlite --dsn file:/tmp/helios_e2e.db
applied 0/25 migrations from ~/helios/migrations       # idempotent
```

**WARN line on unmigrated DB start (no silent 500s):**
```
$ cd /tmp && bin/helios start --db sqlite --dsn file:/tmp/helios_fresh.db
WARN: database "file:/tmp/helios_fresh.db" (sqlite) has no schema_migrations table; run 'helios migrate up --db sqlite' before serving traffic
helios serving on :8080 (db=sqlite)
$ cd /tmp && bin/helios start --db sqlite --dsn file:/tmp/helios_fresh.db   # after migrate up
startup migration probe OK: schema_migrations present for "sqlite"
```

**Edge cases tested (all pass):**
| # | Case | Result |
|---|------|--------|
| 5 | `--migrations /tmp/custom-migrations` from cwd with none | uses explicit dir (`applied 1/1`) |
| 6 | `--migrations` even when cwd `./migrations` is valid | explicit flag still wins |
| 7 | cwd `./migrations` holding sqlite files | cwd default kept (`applied 25/25`) |
| 8 | binary at `<app>/bin/helios`, migrations at `<app>/migrations` | `<exeDir>/../migrations` fallback (`applied 25/25`) |
| 9 | no candidate holds migrations | clear error listing all 3 tried paths |
| 10 | dialect probe selection | sqlite→`sqlite_master`; postgres/pgx/mysql→`information_schema` (no SQL leak) |
| 11 | relocated binary invoked from `/tmp` | `applied 25/25`, WARN count = 1 |

`go test ./...` passes: 5 top-level tests, 7 subcases = 12 total (precedence order, driver-subdir matching, dialect queries, unmigrated→migrated probe transition, 25/25 + idempotency + row count). `gofmt -l .` clean, `go vet ./...` clean.
{"model": "deepseek-v4-flash", "problem_class": "go-cli-migrations-path-resolution", "result": "passed", "tests": 12}
Generated from the verified corpus · MIT licensedBack to the catalog