#!/usr/bin/env bash # Forward + rollback migration drill against an ephemeral PostgreSQL # container. Validates that: # 1. All committed migrations apply cleanly from an empty database # 2. The seed runs idempotently against the resulting schema # 3. Running the entire forward chain a second time is a no-op # (Prisma marks every migration as already applied) # # This is a production-rehearsal: catches migrations that fail under a # fresh DB (drift between dev shadow and production state) and asserts the # seed-after-migrate contract that prod deploys rely on. # # Usage: # ./scripts/drill-migration-rollback.sh # ./scripts/drill-migration-rollback.sh --no-seed # skip seed step # ./scripts/drill-migration-rollback.sh --keep-container # leave PG running # # Requires: docker, pnpm. Aborts with a documentation pointer if either # is missing. set -euo pipefail DRILL_NAME="hivegrid-rollback-drill-$$" DRILL_PORT="${DRILL_PG_PORT:-55842}" DRILL_USER="drill" DRILL_PASSWORD="drill" DRILL_DB="drill" DRILL_DATABASE_URL="postgresql://${DRILL_USER}:${DRILL_PASSWORD}@127.0.0.1:${DRILL_PORT}/${DRILL_DB}?schema=public" KEEP_CONTAINER=0 RUN_SEED=1 PG_IMAGE="${DRILL_PG_IMAGE:-postgres:16-alpine}" for arg in "$@"; do case "$arg" in --no-seed) RUN_SEED=0 ;; --keep-container) KEEP_CONTAINER=1 ;; --help|-h) sed -n '2,/^$/p' "$0" | sed 's/^# \?//' exit 0 ;; *) echo "Unknown arg: $arg" >&2; exit 64 ;; esac done require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then echo "MISSING $1" >&2 echo "" >&2 echo "Migration-rollback drill requires '$1' to be available locally." >&2 echo "See docs/operations/database-rollback.md for the manual procedure." >&2 exit 127 fi } require_cmd docker require_cmd pnpm cleanup() { local exit_code=$? if [ "$KEEP_CONTAINER" = "0" ]; then docker rm -f "$DRILL_NAME" >/dev/null 2>&1 || true else echo "" echo "KEEP container '$DRILL_NAME' is still running on port $DRILL_PORT (DATABASE_URL=$DRILL_DATABASE_URL)" >&2 fi exit "$exit_code" } trap cleanup EXIT echo "PHASE 0/4 starting ephemeral postgres ($PG_IMAGE on port $DRILL_PORT)" docker run --rm -d \ --name "$DRILL_NAME" \ -p "${DRILL_PORT}:5432" \ -e POSTGRES_USER="$DRILL_USER" \ -e POSTGRES_PASSWORD="$DRILL_PASSWORD" \ -e POSTGRES_DB="$DRILL_DB" \ "$PG_IMAGE" >/dev/null deadline=$(( $(date +%s) + 30 )) # Probe over TCP, not the unix socket. On first boot the postgres image starts a # temporary server that listens on the socket ONLY (initdb + init scripts), then # shuts it down before starting the real one. A socket-based pg_isready reports # ready during that window, so the very next psql can hit # "FATAL: the database system is shutting down". The temp server sets # listen_addresses='', so a TCP probe cannot succeed too early. until docker exec "$DRILL_NAME" pg_isready -h 127.0.0.1 -U "$DRILL_USER" -d "$DRILL_DB" >/dev/null 2>&1; do if [ "$(date +%s)" -gt "$deadline" ]; then echo "FAIL postgres did not become ready within 30s" >&2 exit 1 fi sleep 1 done echo "PASS postgres ready" echo "PHASE 1/4 applying forward migration chain" DATABASE_URL="$DRILL_DATABASE_URL" pnpm --filter @hivegrid/db prisma:generate >/dev/null DATABASE_URL="$DRILL_DATABASE_URL" pnpm exec prisma migrate deploy --schema prisma/schema/schema.prisma echo "PASS forward migration chain applied" if [ "$RUN_SEED" = "1" ]; then echo "PHASE 2/4 running production seed (idempotency probe 1/2)" DATABASE_URL="$DRILL_DATABASE_URL" \ SEED_ADMIN_EMAIL="drill-admin@example.com" \ SEED_ADMIN_PASSWORD="drill-bootstrap-password-do-not-reuse" \ SEED_DEMO_DATA="false" \ NODE_ENV=production \ APP_NAME="HiveGrid" \ APP_BASE_DOMAIN="localhost" \ WEB_APP_HOST="app.localhost" \ ADMIN_APP_HOST="admin.localhost" \ API_HOST="api.localhost" \ APP_PROTOCOL="http" \ SESSION_SECRET="drill-session-secret-minimum-32-chars" \ CRYPTO_SECRET="drill-crypto-secret-minimum-32-chars0" \ JWT_SECRET="drill-jwt-secret-minimum-32-chars0000" \ API_ACCESS_TOKEN="drill-api-access-token-minimum-32-ch" \ ANALYTICS_TOKEN="drill-analytics-token-minimum-32-cha" \ MCP_RESOURCE_URL="http://mcp.localhost:8080" \ MCP_AUTHORIZATION_SERVER_URL="http://app.localhost" \ pnpm --filter @hivegrid/db seed echo "PASS seed run 1" echo "PHASE 3/4 re-running seed (idempotency probe 2/2)" DATABASE_URL="$DRILL_DATABASE_URL" \ SEED_ADMIN_EMAIL="drill-admin@example.com" \ SEED_ADMIN_PASSWORD="drill-bootstrap-password-do-not-reuse" \ SEED_DEMO_DATA="false" \ NODE_ENV=production \ APP_NAME="HiveGrid" \ APP_BASE_DOMAIN="localhost" \ WEB_APP_HOST="app.localhost" \ ADMIN_APP_HOST="admin.localhost" \ API_HOST="api.localhost" \ APP_PROTOCOL="http" \ SESSION_SECRET="drill-session-secret-minimum-32-chars" \ CRYPTO_SECRET="drill-crypto-secret-minimum-32-chars0" \ JWT_SECRET="drill-jwt-secret-minimum-32-chars0000" \ API_ACCESS_TOKEN="drill-api-access-token-minimum-32-ch" \ ANALYTICS_TOKEN="drill-analytics-token-minimum-32-cha" \ MCP_RESOURCE_URL="http://mcp.localhost:8080" \ MCP_AUTHORIZATION_SERVER_URL="http://app.localhost" \ pnpm --filter @hivegrid/db seed echo "PASS seed run 2 (idempotent)" else echo "SKIP seed (--no-seed flag)" fi echo "PHASE 4/4 re-applying migrate deploy (chain idempotency)" output=$(DATABASE_URL="$DRILL_DATABASE_URL" pnpm exec prisma migrate deploy --schema prisma/schema/schema.prisma 2>&1) echo "$output" | tail -5 if echo "$output" | grep -qiE "no pending migrations|already in sync|database schema is up to date"; then echo "PASS migrate deploy is a no-op on already-migrated schema" else echo "FAIL second migrate deploy reported changes — chain is not idempotent" >&2 exit 1 fi echo "" echo "DRILL OK — migration chain + seed survive a fresh-database deployment rehearsal"