feat(db): move the bundled database to Postgres 18

The compose file runs Postgres 18. An install from v1.45 or earlier keeps
its Postgres 16 data in `pgdata`, which 18 cannot open, so a one-shot
`db-upgrade` service moves it on the first start: it starts Postgres 16 on
the old volume inside its own container, streams a dump into 18 in one
transaction, compares every table's row count, gathers planner statistics
and marks the old volume as moved. The app waits for it to succeed.

The old volume is never changed apart from the marker, so the way back is
the previous image and volume lines. A move cut short leaves a "moving"
marker and runs again from scratch; an emptied new volume after a finished
move is refused rather than refilled with the old state.

scripts/compose-db-upgrade-check.sh plays all of it against real
containers from the v1.45.0 compose file, and runs in CI on every change
to the compose file. Pull requests run the integration suite and e2e on
Postgres 18; the nightly integration run keeps 16 covered for installs
that bring their own database.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 11:18:14 +01:00
1 parent 6b3c1cc433
commit d4d91994e8
11 files changed
+445 -24

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Plays the Postgres 16 -> 18 move in docker-compose.yml end to end, against
# real containers, the way an install from v1.45 meets it on its first update.
#
# 1. Start the database exactly as the v1.45 compose file had it, apply every
# migration and add rows in a second schema.
# 2. Start the current compose file's `db-upgrade` and check the move: exit 0,
# Postgres 18, the same rows and the same migration history.
# 3. Start it again: nothing to do.
# 4. Pretend the move was cut short after the restore: the next start must
# empty the new database and run the move again.
# 5. Empty the new volume: it must refuse to import the old state again,
# and move it once more after the marker is removed as the guide says.
# 6. A new install with no old volume: nothing to do.
#
# Needs Docker and the repo's node_modules (for the migrations). Run it from
# anywhere in the repo: `bash scripts/compose-db-upgrade-check.sh`.
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
# The last release whose compose file ran Postgres 16.
OLD_REF=${OLD_REF:-v1.45.0}
PROJECT=hl-db-upgrade-check
PORT=${CHECK_DB_PORT:-55416}
WORK=$(mktemp -d)
export PRISMA_HIDE_UPDATE_MESSAGE=1
export POSTGRES_PASSWORD
POSTGRES_PASSWORD=$(openssl rand -hex 16)
# The app service is never started here, but compose reads its environment.
export ENCRYPTION_KEY API_TOKEN_HMAC_KEY
ENCRYPTION_KEY=$(openssl rand -hex 32)
API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)
git -C "$ROOT" show "$OLD_REF:docker-compose.yml" >"$WORK/old.yml"
cat >"$WORK/old-port.yml" <<EOF
services:
db:
ports:
- "127.0.0.1:$PORT:5432"
EOF
old() { docker compose -p "$PROJECT" -f "$WORK/old.yml" -f "$WORK/old-port.yml" "$@"; }
new() { docker compose -p "$PROJECT" -f "$ROOT/docker-compose.yml" "$@"; }
down_all() {
new down -v --remove-orphans >/dev/null 2>&1 || true
old down -v --remove-orphans >/dev/null 2>&1 || true
}
trap 'down_all; rm -rf "$WORK"' EXIT
fail() {
echo "FAIL: $*" >&2
docker logs healthlog-db-upgrade 2>&1 | tail -40 >&2 || true
exit 1
}
step() { echo "== $*"; }
sql() { docker exec healthlog-db psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$1"; }
# Starts db-upgrade (and the database it waits for), waits for it to exit and
# prints its exit code.
run_upgrade() {
new up -d db-upgrade >/dev/null 2>&1 || true
docker wait healthlog-db-upgrade
}
upgrade_log() { docker logs healthlog-db-upgrade 2>&1 | grep '^db-upgrade:' || true; }
down_all
step "a v1.45 database on Postgres 16"
old up -d --wait db >/dev/null 2>&1
DATABASE_URL="postgresql://healthlog:$POSTGRES_PASSWORD@127.0.0.1:$PORT/healthlog?schema=public" \
node "$ROOT/node_modules/prisma/build/index.js" migrate deploy --schema "$ROOT/prisma/schema.prisma" >/dev/null
old exec -T db psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -q <<'SQL'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE SCHEMA check_probe;
CREATE TABLE check_probe.rows AS
SELECT g AS id, md5(g::text) AS value FROM generate_series(1, 50000) AS g;
SQL
probe() {
"$@" psql -U healthlog -d healthlog -Atqc \
"SELECT md5(string_agg(value, ',' ORDER BY id)) FROM check_probe.rows"
}
migrations() {
"$@" psql -U healthlog -d healthlog -Atqc "SELECT count(*) FROM _prisma_migrations"
}
PROBE=$(probe old exec -T db)
MIGRATIONS=$(migrations old exec -T db)
[ "$MIGRATIONS" -gt 0 ] || fail "the migrations did not apply to the old database"
old stop db >/dev/null 2>&1
old rm -f db >/dev/null 2>&1
step "first start of the current compose file moves it"
CODE=$(run_upgrade)
upgrade_log
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE on the move"
upgrade_log | grep -q 'tables arrived with the same row counts' || fail "the move did not report its row-count check"
VERSION=$(sql "SHOW server_version_num")
[ "$VERSION" -ge 180000 ] || fail "the database runs $VERSION, expected Postgres 18"
[ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the probe rows differ after the move"
[ "$(migrations docker exec healthlog-db)" = "$MIGRATIONS" ] || fail "the migration history differs after the move"
[ "$(sql "SELECT count(*) FROM pg_stats WHERE schemaname = 'check_probe'")" -gt 0 ] ||
fail "the moved tables have no planner statistics"
step "second start: nothing to do"
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE on a start after the move"
upgrade_log | tail -1 | grep -q 'nothing to do' || fail "the second start did not say there was nothing to do"
step "a move cut short after the restore runs again from scratch"
new down >/dev/null 2>&1
docker run --rm -v "${PROJECT}_pgdata:/old" postgres:16.15-alpine \
mv /old/HEALTHLOG_MOVED_TO_POSTGRES_18 /old/HEALTHLOG_MOVING_TO_POSTGRES_18
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE when resuming a cut-short move"
upgrade_log | grep -q 'did not finish' || fail "the resumed move did not say it starts again"
[ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the probe rows differ after the resumed move"
step "an emptied Postgres 18 volume is not refilled from the old state"
new down >/dev/null 2>&1
docker volume rm "${PROJECT}_postgres-data" >/dev/null
CODE=$(run_upgrade)
[ "$CODE" = 1 ] || fail "db-upgrade exited $CODE with the moved marker and an empty database, expected 1"
upgrade_log | grep -q 'Not importing the old state again' || fail "the refusal did not explain itself"
step "starting over as the guide says brings the moved state back"
new run --rm --entrypoint rm db-upgrade /var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18 >/dev/null 2>&1
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE after the marker was removed"
[ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the probe rows differ after starting over"
step "a new install: nothing to do"
new down -v >/dev/null 2>&1
CODE=$(run_upgrade)
[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE on a new install"
upgrade_log | tail -1 | grep -q 'no Postgres 16 database to move' || fail "the new install did not say there was nothing to move"
echo "OK: the Postgres 16 -> 18 move behaves in all six cases"