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:
parent
051169a18a
commit
35e81400f2
2 changed files with 37 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue