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

187
CLAUDE.md
View File

@@ -1984,3 +1984,190 @@ JPEG downloaded → erase → presigned GET **404**, 0 templates, 0 profiles, la
setInput/forward; it runs once per track, so the lock costs nothing) and
`Gallery`/`IdentityStore`. Locks around shared JPEG state; SQLite in WAL.
- Tests are dependency-light (no camera or models needed) — keep it so.
## A shop PC that runs on its own
Until now every install was blocked on an enrolment code: the app opened on the
setup screen, and there was no way past it except a credential issued by a
server. That is wrong for the product it claims to be. Recognition, the
cameras, the tracker and the local gallery all run on the shop PC and need no
network at all — so a single-till shop with no head office was being refused
the thing the software is *for* until a component it does not need had blessed
it.
`RunStandalone()` is the second answer on that screen. It is persisted
(`Config.Standalone`), because a choice that lives only in memory puts the
enrolment-code screen back in front of a shop that has already answered, which
reads as the app forgetting it was ever set up.
- **"Not linked yet" and "not going to be linked" are different states**, and
the difference is load-bearing rather than cosmetic. An unclaimed PC keeps
its bridge running and queues every visit, deliberately: *"footfall from the
day it was installed is on disk waiting for credentials rather than lost."*
A standalone PC must not. Nothing is ever going to drain that queue, so it
would write up to `SpoolMax` visits — **each carrying a face template, which
is biometric personal data** — to disk for no purpose. `startPipeline`
returns early; the camera reconciler still runs, and reconciles against
nothing.
- **Cloud-only screens are hidden, not shown broken.** The customer record
lives on the server, so Customers disappears; Live and Cameras stay, because
they read the engine on loopback and always could.
- **Live says "Running on this PC only", not "Not linked to head office"** with
an idle dot beside a count of zero. The second is what a fault looks like.
- **Claiming later clears the flag and restarts the pipeline**, so a shop that
grows into a second branch loses nothing it recorded on its own.
`Session()` and `PipelineStatus()` both report `standalone` as
`cfg.Standalone && !cfg.Configured()` — one expression, in two places that must
never disagree, for the same reason the tray is a client of `EngineStatus()`
rather than a second copy of it.
## The camera make picker is now on both forms, from one file
`shared/cameraMakes.js`, imported by the head-office web app **and** the shop
PC's app rather than copied into each. A make that is right in one and stale in
the other is worse than not offering the list at all, because an installer
trusts a filled-in field.
It exists because the RTSP **path** is the one field nobody can look up: the
address is on a label and the password is in the installer's notes, but the
path is model-specific and undiscoverable, and getting it wrong produces
*"could not open stream"*, which reads like a password problem and is not.
The desktop form also picked up the autofill defence the web form already had:
a text input next to a password input is a sign-in form as far as a webview is
concerned, so without `autoComplete="new-password"` on the secret and a
non-login `name` on the account, the browser offers the operator's own
Behavision email as the camera's username — which then fails with a message
about credentials that points at the camera.
## The schema applies itself (`server/internal/migrate`)
The migrations were run by hand — `psql < 001.sql`, in order, by whoever
remembered — and **nothing anywhere recorded which had run**. Three
consequences, all of which had already happened:
- Re-running the setup script against an existing database failed on the first
`CREATE TABLE`, so it only ever worked once. Discovered by running it.
- Shipping a migration 008 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.
- An interrupted file left a schema nothing could describe.
Now: `schema_migrations`, and the server applies pending migrations at boot.
Applying at boot rather than as a deploy step is deliberate — an upgrade of
this product is *"copy the new binary and restart it"*, and a migration
somebody has to remember to run is one that does not get run. Failure **stops
the server**: one running against a schema it does not match writes wrong data,
and wrong data outlives the outage that stopping causes.
Decisions worth keeping:
- **One transaction per file, holding both the DDL and the row that records
it.** A migration that ran but was not recorded runs again next start; one
recorded but not run leaves a missing column nothing will ever add.
- **An advisory lock held on one connection for the whole run.** Two servers
starting at once is the normal shape of a rolling restart, and both deciding
008 is pending is not a hypothetical.
- **The content is checksummed.** An already-applied file that has since been
edited means the database does not contain what the repository says it does,
and running the new text now would apply half of it twice. It refuses and
names the file: the fix is a new migration, never an edited one.
- **Ordering is numeric, not alphabetical.** At ten migrations `010` sorts
before `009` as text, and the failure arrives on the day the project reaches
double figures. Two files claiming one version — the ordinary result of two
branches both adding `008` — is refused outright, because whichever ran first
would then be decided by the filesystem, which is not an order.
- **`-baseline N` adopts a database built before any of this existed**, marking
1..N applied without running them. Guessing was not an option: *"the clients
table exists"* does not say whether 007's index does. An operator states it
once, and the row is marked `baselined` so an adopted database never looks
like one this code built.
- **The migrations are `go:embed`ed**, so the schema travels inside the binary
it belongs to. The consequence to know: a stale binary reports "schema up to
date" about migrations it has never heard of. Rebuild, then migrate.
`behavision-server migrate [-status|-baseline N]` is the operator's view.
Verified on the live database: adopted 001–007, then applied 008 (two indexes
on `purchases`, found by asking the database which foreign keys had nothing
behind them and then checking what actually queries the table — the conversion
report filters `client_id` + `occurred_at`, which is precisely the estate-wide
case with no site to narrow it).
## The Windows package (`installer/`)
`installer/build.ps1` builds it and `installer/behavision.iss` lays it out.
**The build script must run on Windows, and that is not a preference.** Every
other artefact here cross-compiles from a Mac — the Go binaries with
`GOOS=windows`, the front ends with npm, verified — but PyInstaller freezes the
interpreter and the native wheels (onnxruntime, OpenCV) of the machine it runs
on. There is no cross-target flag and there never has been. So the engine .exe
is built on Windows or it is not built.
Installed layout, and why it is not flat:
```
C:\Program Files\Behavision\
Behavision.exe the app: window, tray, engine supervisor
behavision-agent.exe the headless agent, for an install with no UI
engine\behavision.exe the engine, plus ~150 native DLLs beside it
C:\ProgramData\Behavision\ everything written: database, logs, models, cameras
```
The engine keeps its own folder because it is a one-**folder** PyInstaller
build that brings its DLLs with it — and because Windows filenames are
case-insensitive, so `Behavision.exe` and `behavision.exe` could not share a
directory even if it were tidy to. `config.Defaults().EngineExe` names
`engine\behavision.exe` and `tests/test_installer.py` asserts the two agree:
if they ever disagree the app starts, shows a healthy window, and recognises
nobody.
- **Admin at install time, never at run time.** Program Files needs elevation;
spawning a child process does not. This is the same reason the product is not
a Windows service: a service runs in session 0 and cannot draw a tray icon.
- **Nothing writable under Program Files.** That split is `behavision/paths.py`
and `agent/pkg/paths`, and the installer must not contradict it — a seeded
writable file under `{app}` works for the administrator who installed it and
fails for the shop assistant who uses it.
- **Models are not bundled.** ~200 MB, downloaded resumably on first run;
bundling them quadruples the installer and forces a re-sign for a model
change. The wizard offers the download and a Start-menu shortcut repeats it,
because a shop PC being set up often has no working internet yet.
- **The WebView2 bootstrapper IS bundled.** Without the runtime the app opens
as an empty white rectangle — not an error, just nothing — which is the worst
failure to hand a shop. Present on Windows 11 and recent Windows 10, absent
on plenty of older machines, and a shop counter is exactly where an older
machine lives.
- **`CloseApplications=yes`.** Replacing the engine's DLLs while it holds the
SQLite WAL and the camera produces a half-upgraded install that fails on the
*next* start, long after anyone would connect the two events.
`tests/test_installer.py` is the same guard `tests/test_paths.py` is for the
PyInstaller spec: it asserts the installer and the build script name the same
files, that nothing writable is placed under the install root, and that no
model is bundled. It cannot prove the package works on Windows — only a Windows
box can — but it catches the class of mistake that would otherwise get that
far.
**Not yet done, and it needs a Windows machine:** no `wails build` has ever
run, no installer has been compiled, nothing is code-signed, and the frozen
engine has never been started. Unsigned, SmartScreen will warn on first launch.
## `run-local.sh` was hiding its own failures
Two bugs, both found by running it after a reboot rather than by reading it.
- **A reused container keeps the bind mount it was created with.** `bv-mqtt`
had been created while the working directory was somewhere else, so it came
back up with an empty `/mosquitto/config`, died with *"Unable to open config
file"*, and every `docker exec` after that failed for a reason having nothing
to do with what it was asked. The script now compares the mount source and
recreates the container when it has moved.
- **`>/dev/null 2>&1 || true` on the `mosquitto_passwd` call.** Under `set -e`
the script then exited at step 5 with **no output at all** — the single
hardest failure to diagnose, and it took three runs to find. stderr is no
longer discarded, and the broker is waited for and reported on if it will not
stay up. A failure there means the server cannot authenticate to its own
broker, which is exactly what this script exists to surface early.

61
RUN.md
View File

@@ -215,3 +215,64 @@ cd ../agent && GOOS=windows CGO_ENABLED=0 go build -o behavision-agent.exe .
These compile. They have never been RUN on Windows — no `wails build`, no
installer, no code signing.
---
## Building the Windows package
`installer/build.ps1` produces `dist\Behavision-Setup-<version>.exe`.
**It has to run on Windows.** The Go binaries and the front ends cross-compile
from a Mac (verified), but PyInstaller freezes the interpreter and native
wheels of the machine it runs on — there is no cross-target flag. The engine
.exe is built on Windows or not at all.
On the Windows box, with Python 3.11+, Go 1.21+, Node LTS and
[Inno Setup 6](https://jrsoftware.org/isdl.php) installed and on PATH:
```powershell
git clone <this repo> C:\src\Behavision
cd C:\src\Behavision
powershell -ExecutionPolicy Bypass -File installer\build.ps1 -Version 0.1.0
```
It creates the venv, runs the engine test suite (a package is not worth
building if the engine is broken), freezes the engine, builds the app and the
agent, downloads the WebView2 bootstrapper, stages `dist\Behavision\`, starts
the frozen engine once to prove it runs, and compiles the installer.
`-SkipInstaller` stops after staging, for testing without Inno Setup.
### What to check on the Windows box
1. Install as an administrator. Accept the model download.
2. `C:\Program Files\Behavision\engine\behavision.exe paths` — the state root
must be `C:\ProgramData\Behavision`, not anywhere under Program Files.
3. Launch from the Start menu. **Set this PC up on its own** — no code needed.
4. Cameras → Add camera → pick the make → Test connection → Save. The feed must
appear with no restart.
5. Check `/api/health` reports `recognition_model`. On a 16 GB machine the
166 MB r50 can lose the fallback chain to the 13 MB mbf, and embeddings are
model-tagged, so which one wins decides whether a gallery carries over.
6. Sign out of the tray (Quit) — recognition must stop with it. Reboot; the app
must come back on its own.
7. Only then link it to head office, from the sidebar.
Nothing is code-signed yet, so SmartScreen will warn on first launch.
## Schema
The server applies pending migrations when it starts and refuses to run against
a schema it does not match. Three ways to look at it by hand:
```bash
export DATABASE_URL=...
./bv-server migrate -status # what is applied, adopted, pending or CHANGED
./bv-server migrate # apply everything pending
./bv-server migrate -baseline 7 # adopt a database built before tracking existed
```
The migrations are compiled into the binary, so **rebuild before migrating** —
a stale binary honestly reports "schema up to date" about files it has never
seen. Never edit an applied migration: the checksum check will refuse it by
name, and the fix is a new file.

View File

@@ -0,0 +1,95 @@
package main
import (
"testing"
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
"github.com/loyaly/behavision-desktop/internal/cloud"
)
// newTestApp builds an App around a config on disk, without startup(): the
// supervisor, the bridge and the broker all belong to a running PC and none of
// them is what these assertions are about.
func newTestApp(t *testing.T, cfg agentcfg.Config) *App {
t.Helper()
t.Setenv("BEHAVISION_DATA_DIR", t.TempDir())
if err := cfg.Save(agentpaths.AgentConfig()); err != nil {
t.Fatalf("save config: %v", err)
}
return &App{cfg: cfg, cloud: cloud.New("https://example.invalid")}
}
// A fresh install shows the setup screen. That is the state the whole
// standalone option exists to offer a way out of.
func TestAFreshPCIsNeitherClaimedNorStandalone(t *testing.T) {
a := newTestApp(t, agentcfg.Defaults())
s := a.Session()
if s.Claimed || s.Standalone || s.LoggedIn {
t.Fatalf("a blank install reported %+v", s)
}
}
// The choice has to reach disk. One that only lives in memory puts the
// enrolment-code screen back in front of a shop that already answered "we have
// no head office", which reads as the app forgetting it was ever set up.
func TestRunStandaloneIsRememberedAcrossARestart(t *testing.T) {
a := newTestApp(t, agentcfg.Defaults())
s, err := a.RunStandalone()
if err != nil {
t.Fatalf("RunStandalone: %v", err)
}
if !s.Standalone {
t.Fatal("the session did not report standalone")
}
// What the next launch sees.
back, err := agentcfg.Load(agentpaths.AgentConfig())
if err != nil {
t.Fatalf("reload: %v", err)
}
if !back.Standalone {
t.Fatal("standalone was not persisted")
}
if (&App{cfg: back, cloud: cloud.New("https://example.invalid")}).
Session().Standalone != true {
t.Fatal("a reloaded standalone config did not report standalone")
}
}
// Claimed wins. A PC that has been linked is not standalone whatever the flag
// says, or the head-office screens stay hidden on the one machine that just
// earned them.
func TestBeingClaimedOverridesTheStandaloneFlag(t *testing.T) {
cfg := agentcfg.Defaults()
cfg.Standalone = true
cfg.ClientID, cfg.SiteID, cfg.BrokerURL = "acme", "chennai", "tls://broker:8883"
a := newTestApp(t, cfg)
s := a.Session()
if !s.Claimed {
t.Fatal("a configured PC did not report itself claimed")
}
if s.Standalone {
t.Fatal("a claimed PC still reported standalone")
}
if a.PipelineStatus().Standalone {
t.Fatal("the pipeline status still reported standalone")
}
}
// "Nothing is being sent because this PC is on its own" and "nothing is being
// sent and something is wrong" look identical from the counters alone, and only
// one of them is a fault.
func TestPipelineStatusSeparatesStandaloneFromUnclaimed(t *testing.T) {
unclaimed := newTestApp(t, agentcfg.Defaults()).PipelineStatus()
if unclaimed.Claimed || unclaimed.Standalone {
t.Fatalf("unclaimed PC reported %+v", unclaimed)
}
cfg := agentcfg.Defaults()
cfg.Standalone = true
alone := newTestApp(t, cfg).PipelineStatus()
if alone.Claimed || !alone.Standalone {
t.Fatalf("standalone PC reported %+v", alone)
}
}

View File

@@ -21,16 +21,23 @@ mkdir -p "$STATE/mosquitto"
step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
# Checked up front rather than discovered in the middle of step 2, where the
# failure is a bare "go: command not found" after two minutes of npm install.
for tool in docker go npm; do
command -v "$tool" >/dev/null 2>&1 || {
printf 'need %s on PATH.\n' "$tool" >&2
[ "$tool" = go ] && printf 'go is often installed outside the default PATH; try: export PATH="$HOME/go/bin:$PATH"\n' >&2
exit 1
}
done
step "1. Postgres (pgvector - migration 001 needs the extension)"
docker inspect bv-pg >/dev/null 2>&1 || docker run -d --name bv-pg \
-p "${PG_PORT}:5432" -e POSTGRES_PASSWORD=test -e POSTGRES_DB=behavision \
pgvector/pgvector:pg16 >/dev/null
docker start bv-pg >/dev/null 2>&1 || true
until docker exec bv-pg pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done
for f in server/migrations/*.sql; do
docker exec -i bv-pg psql -U postgres -d behavision -v ON_ERROR_STOP=1 -q < "$f"
done
echo " migrations applied"
echo " ready (the server applies the schema itself on start)"
step "2. Build (the web app builds INTO the Go module, so it goes first)"
(cd web && npm install --silent && npm run build >/dev/null)
@@ -58,6 +65,12 @@ fi
# shellcheck disable=SC1090
. "$STATE/env.sh"
step "3b. Schema"
# The server would do this itself on start, but provisioning below runs BEFORE
# it does and needs the tables to exist. One command either way, and it is the
# same code path the server uses.
"./$STATE/bv-server" migrate
step "4. Mosquitto"
if [ ! -f "$STATE/mosquitto/mosquitto.conf" ]; then
cat > "$STATE/mosquitto/mosquitto.conf" <<EOF
@@ -69,13 +82,43 @@ EOF
printf 'user behavision-server\ntopic read bv/#\n' > "$STATE/mosquitto/acl"
: > "$STATE/mosquitto/passwd"
fi
# A container is reused only if its config mount still points HERE. The bind
# source is baked in when the container is created, so one made while the
# checkout lived somewhere else - or by a run from another directory - comes
# back up with an empty /mosquitto/config and dies with "Unable to open config
# file", which the old `|| true` below then hid completely.
MQTT_CONF="$PWD/$STATE/mosquitto"
if docker inspect bv-mqtt >/dev/null 2>&1; then
MOUNTED=$(docker inspect bv-mqtt \
--format '{{range .Mounts}}{{if eq .Destination "/mosquitto/config"}}{{.Source}}{{end}}{{end}}')
if [ "$MOUNTED" != "$MQTT_CONF" ]; then
echo " recreating bv-mqtt (its config was mounted from ${MOUNTED:-nowhere})"
docker rm -f bv-mqtt >/dev/null
fi
fi
docker inspect bv-mqtt >/dev/null 2>&1 || docker run -d --name bv-mqtt \
-p "${MQTT_PORT}:1883" -v "$PWD/$STATE/mosquitto:/mosquitto/config" \
-p "${MQTT_PORT}:1883" -v "$MQTT_CONF:/mosquitto/config" \
eclipse-mosquitto:2 >/dev/null
docker start bv-mqtt >/dev/null 2>&1 || true
sleep 1
# Wait for it, and say so if it never arrives. `docker start` returning 0 only
# means the container was launched; mosquitto exits a moment later if it cannot
# read its config, and every `docker exec` after that fails for a reason that
# has nothing to do with what it was asked to do.
for _ in $(seq 1 20); do
docker exec bv-mqtt sh -c 'exit 0' >/dev/null 2>&1 && break
sleep 1
done
if ! docker exec bv-mqtt sh -c 'exit 0' >/dev/null 2>&1; then
echo " broker will not stay up:" >&2
docker logs --tail 5 bv-mqtt >&2
exit 1
fi
# stderr is NOT discarded here. A failure means the server cannot authenticate
# to its own broker, and the whole point of this script is that you find that
# out now rather than from an empty arrivals feed.
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd \
behavision-server "$MQTT_PASSWORD" >/dev/null 2>&1 || true
behavision-server "$MQTT_PASSWORD" >/dev/null
docker restart bv-mqtt >/dev/null
echo " broker on ${MQTT_PORT}"
@@ -101,7 +144,7 @@ SITE_OUT=$("./$STATE/bv-server" provision site -client tenext-retail -slug chenn
-name "TeNext Chennai" -tz Asia/Kolkata)
BUSER=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd \([^ ]*\) .*/\1/p")
BPASS=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd [^ ]* '\(.*\)'.*/\1/p")
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd "$BUSER" "$BPASS" >/dev/null 2>&1
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd "$BUSER" "$BPASS" >/dev/null
grep -q "^user $BUSER$" "$STATE/mosquitto/acl" || \
printf '\nuser %s\ntopic write bv/%s/#\n' "$BUSER" "$BUSER" >> "$STATE/mosquitto/acl"
docker restart bv-mqtt >/dev/null

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
}

View File

@@ -0,0 +1,285 @@
// Package migrate applies the schema and records what it applied.
//
// Until this existed the migrations were run by hand - `psql < 001.sql` for
// each file, in order, by whoever remembered - and nothing anywhere recorded
// which had run. Three consequences, all of which had already happened:
//
// - Re-running them against an existing database fails on the first CREATE
// TABLE, so the setup script only worked once.
// - Adding migration 008 to a release gave the operator no way to know
// whether a given estate had it. The failure of a missed migration is not
// a startup error; it is a query referencing a column that is not there,
// surfacing on whichever endpoint touches it first.
// - A half-applied migration - the file interrupted midway - left a schema
// nothing could describe.
//
// So: every file runs inside ONE transaction together with the row that
// records it. Either both happen or neither does, and a database can always
// say which version it is on.
package migrate
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// lockID namespaces the advisory lock this package takes. Two servers starting
// at the same moment - which is the normal shape of a rolling restart - must
// not both decide migration 008 is pending and both run it.
const lockID int64 = 7623094512340001
// File is one migration on disk.
type File struct {
Version int
Name string
SQL string
Checksum string
}
// Record is one migration as the database remembers it.
type Record struct {
Version int
Name string
Checksum string
Baselined bool
}
// Load reads and orders the migrations, rejecting anything it cannot place.
func Load(src fs.FS) ([]File, error) {
entries, err := fs.Glob(src, "*.sql")
if err != nil {
return nil, err
}
seen := map[int]string{}
out := make([]File, 0, len(entries))
for _, name := range entries {
version, err := versionOf(name)
if err != nil {
return nil, err
}
if other, dup := seen[version]; dup {
// Two files claiming one version means the order they run in is
// whatever the filesystem felt like, which is not an order.
return nil, fmt.Errorf("migrations %s and %s share version %d",
other, name, version)
}
seen[version] = name
body, err := fs.ReadFile(src, name)
if err != nil {
return nil, err
}
sum := sha256.Sum256(body)
out = append(out, File{
Version: version,
Name: name,
SQL: string(body),
Checksum: hex.EncodeToString(sum[:]),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
return out, nil
}
func versionOf(name string) (int, error) {
prefix, _, ok := strings.Cut(name, "_")
if !ok {
return 0, fmt.Errorf("migration %q is not named NNN_description.sql", name)
}
n, err := strconv.Atoi(prefix)
if err != nil || n <= 0 {
return 0, fmt.Errorf("migration %q does not start with a version number", name)
}
return n, nil
}
// Apply runs every migration the database has not recorded, in order.
//
// Returns the names it applied, so a caller can log "nothing to do" rather
// than staying silent about work it did not need to do.
func Apply(ctx context.Context, pool *pgxpool.Pool, src fs.FS) ([]string, error) {
files, err := Load(src)
if err != nil {
return nil, err
}
conn, err := pool.Acquire(ctx)
if err != nil {
return nil, err
}
defer conn.Release()
// Taken on this one connection and held for the whole run: a second server
// booting waits here rather than racing us, and the lock is released with
// the connection even if this process is killed.
if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, lockID); err != nil {
return nil, fmt.Errorf("migration lock: %w", err)
}
defer func() {
_, _ = conn.Exec(context.WithoutCancel(ctx),
`SELECT pg_advisory_unlock($1)`, lockID)
}()
if err := ensureTable(ctx, conn.Conn()); err != nil {
return nil, err
}
done, err := recorded(ctx, conn.Conn())
if err != nil {
return nil, err
}
var applied []string
for _, f := range files {
prev, ok := done[f.Version]
if ok {
// An already-applied file that has since been edited. The database
// does NOT contain what the repository says it does, and running
// the new text now would apply half of it twice. Refuse and say so:
// the fix is a new migration, never an edited one.
if prev.Checksum != f.Checksum && !prev.Baselined {
return applied, fmt.Errorf(
"migration %s has changed since it was applied "+
"(recorded %s, file %s) - add a new migration instead of editing one",
f.Name, short(prev.Checksum), short(f.Checksum))
}
continue
}
if err := applyOne(ctx, conn.Conn(), f); err != nil {
return applied, err
}
applied = append(applied, f.Name)
}
return applied, nil
}
// applyOne runs one file and records it in the SAME transaction. A migration
// that ran but was not recorded would run again on the next start; one that was
// recorded but did not run leaves a schema missing a column nothing will ever
// add.
func applyOne(ctx context.Context, conn *pgx.Conn, f File) error {
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, f.SQL); err != nil {
return fmt.Errorf("migration %s: %w", f.Name, err)
}
if _, err := tx.Exec(ctx,
`INSERT INTO schema_migrations (version, name, checksum) VALUES ($1, $2, $3)`,
f.Version, f.Name, f.Checksum); err != nil {
return fmt.Errorf("recording migration %s: %w", f.Name, err)
}
return tx.Commit(ctx)
}
// Baseline records migrations up to and including `through` as applied WITHOUT
// running them.
//
// For the one case that cannot be handled automatically: a database built
// before this package existed, whose schema is already there and whose history
// is not. Guessing is not an option - "the clients table exists" does not say
// whether migration 007's index does - so an operator states it, once, and it
// is recorded as a baseline rather than as a real application.
func Baseline(ctx context.Context, pool *pgxpool.Pool, src fs.FS, through int) ([]string, error) {
files, err := Load(src)
if err != nil {
return nil, err
}
conn, err := pool.Acquire(ctx)
if err != nil {
return nil, err
}
defer conn.Release()
if err := ensureTable(ctx, conn.Conn()); err != nil {
return nil, err
}
var marked []string
for _, f := range files {
if f.Version > through {
continue
}
tag, err := conn.Exec(ctx,
`INSERT INTO schema_migrations (version, name, checksum, baselined)
VALUES ($1, $2, $3, true) ON CONFLICT (version) DO NOTHING`,
f.Version, f.Name, f.Checksum)
if err != nil {
return marked, err
}
if tag.RowsAffected() == 1 {
marked = append(marked, f.Name)
}
}
return marked, nil
}
// Status reports what the database has recorded, oldest first.
func Status(ctx context.Context, pool *pgxpool.Pool) ([]Record, error) {
conn, err := pool.Acquire(ctx)
if err != nil {
return nil, err
}
defer conn.Release()
if err := ensureTable(ctx, conn.Conn()); err != nil {
return nil, err
}
done, err := recorded(ctx, conn.Conn())
if err != nil {
return nil, err
}
out := make([]Record, 0, len(done))
for _, r := range done {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
return out, nil
}
func ensureTable(ctx context.Context, conn *pgx.Conn) error {
_, err := conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version integer PRIMARY KEY,
name text NOT NULL,
checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now(),
-- true when the row records a migration that was NOT run here,
-- because the schema predates this table. Kept distinct so an
-- adopted database never looks like one this code built.
baselined boolean NOT NULL DEFAULT false
)`)
return err
}
func recorded(ctx context.Context, conn *pgx.Conn) (map[int]Record, error) {
rows, err := conn.Query(ctx,
`SELECT version, name, checksum, baselined FROM schema_migrations`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]Record{}
for rows.Next() {
var r Record
if err := rows.Scan(&r.Version, &r.Name, &r.Checksum, &r.Baselined); err != nil {
return nil, err
}
out[r.Version] = r
}
return out, rows.Err()
}
func short(sum string) string {
if len(sum) > 12 {
return sum[:12]
}
return sum
}

View File

@@ -0,0 +1,73 @@
package migrate
import (
"strings"
"testing"
"testing/fstest"
)
func TestMigrationsRunInNumericOrderNotAlphabetical(t *testing.T) {
// The bug this prevents: at ten migrations, "10_x.sql" sorts before
// "9_x.sql" as text, so the tenth would run before the ninth and the
// failure would be a column that does not exist yet - on the day the
// project happens to reach double figures.
src := fstest.MapFS{
"002_b.sql": {Data: []byte("select 2")},
"010_j.sql": {Data: []byte("select 10")},
"009_i.sql": {Data: []byte("select 9")},
"001_a.sql": {Data: []byte("select 1")},
}
files, err := Load(src)
if err != nil {
t.Fatalf("load: %v", err)
}
got := make([]int, len(files))
for i, f := range files {
got[i] = f.Version
}
want := []int{1, 2, 9, 10}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order was %v, want %v", got, want)
}
}
}
func TestTwoFilesCannotShareAVersion(t *testing.T) {
// Two people adding "008" on separate branches is the ordinary way this
// happens. Whichever runs first is then decided by the filesystem, which is
// not an order, and one of them silently never runs at all.
_, err := Load(fstest.MapFS{
"008_one.sql": {Data: []byte("select 1")},
"008_two.sql": {Data: []byte("select 2")},
})
if err == nil {
t.Fatal("duplicate versions were accepted")
}
if !strings.Contains(err.Error(), "share version 8") {
t.Fatalf("error does not name the collision: %v", err)
}
}
func TestAFileThatIsNotNumberedIsRejected(t *testing.T) {
for _, name := range []string{"schema.sql", "abc_x.sql", "0_zero.sql"} {
if _, err := Load(fstest.MapFS{name: {Data: []byte("select 1")}}); err == nil {
t.Fatalf("%s was accepted as a migration", name)
}
}
}
func TestTheChecksumChangesWithTheContent(t *testing.T) {
// What makes an edited-after-applying migration detectable at all.
a, err := Load(fstest.MapFS{"001_a.sql": {Data: []byte("select 1")}})
if err != nil {
t.Fatal(err)
}
b, err := Load(fstest.MapFS{"001_a.sql": {Data: []byte("select 2")}})
if err != nil {
t.Fatal(err)
}
if a[0].Checksum == b[0].Checksum {
t.Fatal("two different migrations hashed the same")
}
}

View File

@@ -63,6 +63,14 @@ func Open(ctx context.Context, dsn string) (*Store, error) {
func (s *Store) Close() { s.pool.Close() }
// Pool exposes the connection pool for the schema migrator.
//
// Deliberately narrow in intent: the migrator has to run arbitrary DDL and
// take an advisory lock, neither of which belongs behind a typed store method.
// Nothing else should reach for this - a query that lives out here is a query
// nothing tenant-scopes.
func (s *Store) Pool() *pgxpool.Pool { return s.pool }
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// ResolveSite maps an authenticated MQTT username to a provisioned tenant.

View File

@@ -0,0 +1,27 @@
-- Two indexes on `purchases`, both for queries that already exist.
--
-- Found by asking the database which foreign keys had no index behind them and
-- then checking what actually queries the table, rather than by adding indexes
-- on principle: every one of them costs a write on the path that records a
-- sale.
--
-- 1. The conversion report filters `client_id` + `occurred_at`, with the site
-- optional - an owner comparing shops is the whole reason that report
-- exists, and that is precisely the case with no site to narrow it. The
-- existing purchases_site_time_idx cannot serve it. Today the table has a
-- handful of rows and a sequential scan is free; purchases is the table
-- that grows with a shop's trade, so this is the one that stops being free.
--
-- 2. purchases.visit_id is a foreign key with nothing behind it. Every delete
-- of a visit has to prove no purchase references it, which without an index
-- is a full scan per row - and erasing a customer deletes their visits.
BEGIN;
CREATE INDEX IF NOT EXISTS purchases_client_time_idx
ON purchases (client_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS purchases_visit_idx
ON purchases (visit_id);
COMMIT;

View File

@@ -0,0 +1,15 @@
// Package migrations carries the schema files themselves.
//
// It exists only so `go:embed` can reach them: embed cannot see outside its own
// package directory, and moving the .sql files into some internal/ folder would
// break every path that documents them - RUN.md, run-local.sh, the test that
// tells you how to build a database by hand.
package migrations
import "embed"
// FS holds every migration, named NNN_description.sql. The number is the
// version and must be unique; the rest is for humans.
//
//go:embed *.sql
var FS embed.FS