Refuse to start when the database is newer than the binary

Migrate only skipped migrations whose number was already applied (n <=
current). Point an older binary at a database a newer binary had already
migrated and it started happily, then read columns that had moved. For a
self-hosted app that is the worst failure mode: silent schema confusion
rather than a clean refusal.

Migrate now errors when PRAGMA user_version exceeds the highest embedded
migration, naming both numbers. Both callers already propagate the error,
so the binary exits at startup instead.

Verified red-green: removing the guard fails
TestMigrateRefusesDatabaseNewerThanBinary.
This commit is contained in:
mithral 2026-10-12 00:45:27 -04:00
parent 051169a18a
commit 35e81400f2
2 changed files with 37 additions and 0 deletions

View file

@ -31,6 +31,23 @@ func Migrate(ctx context.Context, db *sql.DB, fsys fs.FS, log *slog.Logger) erro
} }
log.Debug("schema version", "current", current, "available", len(names)) log.Debug("schema version", "current", current, "available", len(names))
highest := 0
for _, name := range names {
n, err := migrationNumber(name)
if err != nil {
return err
}
if n > highest {
highest = n
}
}
// A version above every embedded migration means this binary is older than
// the database. Without this, the loop below skips everything and starts up
// against a schema it cannot read.
if current > highest {
return fmt.Errorf("database schema is version %d but this binary only knows %d: this binary is older than the database — upgrade the binary or restore a backup", current, highest)
}
for _, name := range names { for _, name := range names {
n, err := migrationNumber(name) n, err := migrationNumber(name)
if err != nil { if err != nil {

View file

@ -3,6 +3,7 @@ package core
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"io" "io"
"io/fs" "io/fs"
"log/slog" "log/slog"
@ -104,6 +105,25 @@ func TestForeignKeyEnforcementIsOn(t *testing.T) {
} }
} }
func TestMigrateRefusesDatabaseNewerThanBinary(t *testing.T) {
ctx := context.Background()
db := openTempDB(t)
// A database migrated by a newer binary.
newer := highestMigrationNumber(t) + 1
if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", newer)); err != nil {
t.Fatalf("set user_version: %v", err)
}
err := Migrate(ctx, db, migrations.FS, discardLogger())
if err == nil {
t.Fatal("expected error when user_version is above the highest embedded migration, got nil")
}
if !strings.Contains(err.Error(), fmt.Sprintf("version %d", newer)) {
t.Errorf("error should name the database version, got: %v", err)
}
}
func TestMigrationNumber(t *testing.T) { func TestMigrationNumber(t *testing.T) {
cases := map[string]int{ cases := map[string]int{
"0001_core.sql": 1, "0001_core.sql": 1,