golang-migrate-dirty-migration-force-nilversion-recovery
I diagnosed the issue against the actual golang-migrate v4.20.1 source and built a verified drop-in fix.
Root causes confirmed from v4.20.1 source:
- runMigrations calls SetVersion(target, true) before running the body (migrate.go:738), so a dirty row names the exact failed migration.
- Force(0) sets bookkeeping to 0; Up()'s readUp(0, …) calls versionExists(0) and dies with no migration found for version 0 (migrate.go:776,807).
- Force(N)+Up() starts at Next(N) → skips migration N.
- Postgres SetVersion(-1, false) does TRUNCATE + no insert (postgres.go:361-380), i.e. Force(-1) resets to NilVersion correctly.
- ErrDirty only carries Version int.
Fix delivered (~/migrec/recover.go): RecoverUp — stale-build guard (DB > embedded max ⇒ fail closed), Force(database.NilVersion), bounded forward walk accepting only DDL duplicate-object signatures, Force clean past already-applied steps, and a fail-loud UPDATE schema_migrations … recipe naming the stuck row.
Verification (go test -v ./... — all pass, go vet clean):
- reproduces Force(0) failure
- proves Force(N) skips N
- partial-apply recovery lands clean at max
- non-duplicate failure emits the exact manual UPDATE
- stale-build guard mutates nothing
- conservative signature matching rejects unique_violation/syntax errors
Full write-up with analysis, complete code, boot integration, manual recipes, and test evidence: ~/SOLUTION.md. Working module: ~/migrec/.
# Evidence - Problem class: golang-migrate-dirty-migration-force-nilversion-recovery - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T11:29:36.111Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "golang-migrate v4 dirty-state recovery at boot: Force(0) is WRONG (Up() fails no migration found for version 0) - Force(database.NilVersion=-1) empties schema_migrations to restart from the first migration. ErrDirty has only Version int (v4.20.1). Force(N)+Up() applies N+1 onward (migration at bookkeeping version counts applied); mid-flight SQL failure marks its own version dirty before the body runs, so re-read position via m.Version(). Accept already-applied steps via duplicate-object error signatures, walk forward bounded, never force beyond embedded max (stale-build guard fatals on schema > embedded max). Fail loud with a manual UPDATE schema_migrations recipe naming the actually-stuck row.", "environment": "", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "golang-migrate-dirty-migration-force-nilversion-recovery", "provider": "openrouter", "solved_at": "2026-09-21T11:29:36.112Z", "version": ""}I diagnosed the issue against the actual golang-migrate v4.20.1 source and built a verified drop-in fix.
Root causes confirmed from v4.20.1 source:
- runMigrations calls SetVersion(target, true) before running the body (migrate.go:738), so a dirty row names the exact failed migration.
- Force(0) sets bookkeeping to 0; Up()'s readUp(0, …) calls versionExists(0) and dies with no migration found for version 0 (migrate.go:776,807).
- Force(N)+Up() starts at Next(N) → skips migration N.
- Postgres SetVersion(-1, false) does TRUNCATE + no insert (postgres.go:361-380), i.e. Force(-1) resets to NilVersion correctly.
- ErrDirty only carries Version int.
Fix delivered (~/migrec/recover.go): RecoverUp — stale-build guard (DB > embedded max ⇒ fail closed), Force(database.NilVersion), bounded forward walk accepting only DDL duplicate-object signatures, Force clean past already-applied steps, and a fail-loud UPDATE schema_migrations … recipe naming the stuck row.
Verification (go test -v ./... — all pass, go vet clean):
- reproduces Force(0) failure
- proves Force(N) skips N
- partial-apply recovery lands clean at max
- non-duplicate failure emits the exact manual UPDATE
- stale-build guard mutates nothing
- conservative signature matching rejects unique_violation/syntax errors
Full write-up with analysis, complete code, boot integration, manual recipes, and test evidence: ~/SOLUTION.md. Working module: ~/migrec/.
# Evidence - Problem class: golang-migrate-dirty-migration-force-nilversion-recovery - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-21T11:29:36.111Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "golang-migrate v4 dirty-state recovery at boot: Force(0) is WRONG (Up() fails no migration found for version 0) - Force(database.NilVersion=-1) empties schema_migrations to restart from the first migration. ErrDirty has only Version int (v4.20.1). Force(N)+Up() applies N+1 onward (migration at bookkeeping version counts applied); mid-flight SQL failure marks its own version dirty before the body runs, so re-read position via m.Version(). Accept already-applied steps via duplicate-object error signatures, walk forward bounded, never force beyond embedded max (stale-build guard fatals on schema > embedded max). Fail loud with a manual UPDATE schema_migrations recipe naming the actually-stuck row.", "environment": "", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "golang-migrate-dirty-migration-force-nilversion-recovery", "provider": "openrouter", "solved_at": "2026-09-21T11:29:36.112Z", "version": ""}