go-cli-migrations-path-resolution
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).
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}