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:
2026-09-04 12:06:52 +05:30
parent dad04e8cda
commit 5453c26e4c
11 changed files with 941 additions and 8 deletions

View File

@@ -26,9 +26,11 @@ import (
"github.com/loyaly/behavision-server/internal/assistant"
"github.com/loyaly/behavision-server/internal/blob"
"github.com/loyaly/behavision-server/internal/ingest"
"github.com/loyaly/behavision-server/internal/migrate"
"github.com/loyaly/behavision-server/internal/web"
"github.com/loyaly/behavision-server/internal/secret"
"github.com/loyaly/behavision-server/internal/store"
"github.com/loyaly/behavision-server/migrations"
)
var version = "dev"
@@ -44,6 +46,13 @@ func main() {
}
return
}
if len(os.Args) > 1 && os.Args[1] == "migrate" {
if err := runMigrate(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
if err := run(); err != nil {
log.Fatalf("behavision-server: %v", err)
}
@@ -79,6 +88,29 @@ func run() error {
st.UseLogger(logger)
logger.Print("database connected")
// Applied here, not by hand, because an upgrade of this product is "copy
// the new binary and restart it". A migration an operator has to remember
// to run is a migration that does not get run, and its failure is not a
// startup error - it is a query referencing a column that is not there,
// surfacing later on whichever endpoint touches it first.
//
// Refusing to start on failure is deliberate: a server running against a
// schema it does not match writes wrong data, and wrong data outlives the
// outage that stopping causes.
if os.Getenv("BEHAVISION_SKIP_MIGRATE") == "1" {
logger.Print("WARN BEHAVISION_SKIP_MIGRATE=1 - schema not checked")
} else {
applied, err := migrate.Apply(ctx, st.Pool(), migrations.FS)
if err != nil {
return fmt.Errorf("schema: %w", err)
}
if len(applied) == 0 {
logger.Print("schema up to date")
} else {
logger.Printf("schema: applied %s", strings.Join(applied, ", "))
}
}
// Without the key the server still ingests events and serves reports; only
// enrolment fails, and it fails with a message naming the missing variable.
// Refusing to start would take a working estate down over a feature that

View 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
}