The schema applies itself, and the setup script stops hiding failures
Migrations were run by hand and nothing recorded which had run, so re-running the setup script against an existing database failed on the first CREATE TABLE, and shipping a new migration gave an operator no way to know whether an estate had it. A missed migration is not a startup error - it is a query referencing a column that is not there, surfacing later on whichever endpoint touches it first. server/internal/migrate applies pending migrations at boot and refuses to start against a schema it does not match. One transaction per file holding both the DDL and the row that records it; an advisory lock so two servers starting at once cannot both apply 008; checksums so an edited migration is refused by name rather than silently skipped; numeric ordering so 010 does not run before 009. `migrate -baseline N` adopts a database built before any of this existed, because "the clients table exists" does not say whether 007's index does. Verified on the live database: adopted 001-007, applied 008. 008 adds two indexes on `purchases`, found by asking the database which foreign keys had nothing behind them and then checking what queries the table. The conversion report filters client_id + occurred_at, which is exactly the estate-wide case with no site to narrow it. run-local.sh had two bugs, both found by running it rather than reading it: it reused a broker container whose bind mount pointed at a directory that no longer existed, and it discarded stderr on the mosquitto_passwd call, so under `set -e` it exited at step 5 with no output at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
107
server/cmd/behavision-server/migrate.go
Normal file
107
server/cmd/behavision-server/migrate.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/migrate"
|
||||
"github.com/loyaly/behavision-server/internal/store"
|
||||
"github.com/loyaly/behavision-server/migrations"
|
||||
)
|
||||
|
||||
// runMigrate is the operator's view of the schema.
|
||||
//
|
||||
// The server applies pending migrations itself at boot, so this exists for the
|
||||
// two things it cannot do: SAY what a database is on without changing it, and
|
||||
// adopt a database whose schema predates the tracking table.
|
||||
func runMigrate(args []string) error {
|
||||
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
|
||||
status := fs.Bool("status", false, "print what this database has recorded and exit")
|
||||
baseline := fs.Int("baseline", 0,
|
||||
"record migrations up to this version as applied WITHOUT running them")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprint(os.Stderr, `usage: behavision-server migrate [flags]
|
||||
|
||||
no flags apply everything this database has not recorded
|
||||
-status print the recorded versions and what is pending
|
||||
-baseline N adopt a database built before migration tracking existed:
|
||||
record 1..N as applied without running them. Use it once, on a
|
||||
database whose schema you know already matches those files.
|
||||
|
||||
DATABASE_URL is required.
|
||||
`)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
return fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
files, err := migrate.Load(migrations.FS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *baseline > 0 {
|
||||
marked, err := migrate.Baseline(ctx, st.Pool(), migrations.FS, *baseline)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(marked) == 0 {
|
||||
fmt.Println("nothing to adopt - every migration up to that version was already recorded")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("adopted without running: %s\n", strings.Join(marked, ", "))
|
||||
fmt.Println("run `migrate` (no flags) to apply anything after that.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if *status {
|
||||
done, err := migrate.Status(ctx, st.Pool())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[int]migrate.Record{}
|
||||
for _, r := range done {
|
||||
seen[r.Version] = r
|
||||
}
|
||||
for _, f := range files {
|
||||
r, ok := seen[f.Version]
|
||||
switch {
|
||||
case !ok:
|
||||
fmt.Printf(" pending %s\n", f.Name)
|
||||
case r.Baselined:
|
||||
fmt.Printf(" adopted %s (recorded, never run here)\n", f.Name)
|
||||
case r.Checksum != f.Checksum:
|
||||
fmt.Printf(" CHANGED %s (the file differs from what was applied)\n", f.Name)
|
||||
default:
|
||||
fmt.Printf(" applied %s\n", f.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
applied, err := migrate.Apply(ctx, st.Pool(), migrations.FS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(applied) == 0 {
|
||||
fmt.Println("schema up to date")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("applied: %s\n", strings.Join(applied, ", "))
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user