diff --git a/internal/core/migrate.go b/internal/core/migrate.go index 5a6f9e5..cfbe8ae 100644 --- a/internal/core/migrate.go +++ b/internal/core/migrate.go @@ -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)) + 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 { n, err := migrationNumber(name) if err != nil { diff --git a/internal/core/migrate_test.go b/internal/core/migrate_test.go index 8d36952..8ea7ed1 100644 --- a/internal/core/migrate_test.go +++ b/internal/core/migrate_test.go @@ -3,6 +3,7 @@ package core import ( "context" "database/sql" + "fmt" "io" "io/fs" "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) { cases := map[string]int{ "0001_core.sql": 1,