← back to Costa Rica
scripts/apply-migrations.sh
92 lines
#!/usr/bin/env bash
# costa-rica — ordered, idempotent, logged migration runner.
# TK-10346. Closes the "manual go-live pass skips a migration" gap: applies every
# scripts/migrate_*.sql in ascending numeric order (sort -V), records each in a
# schema_migrations ledger, and is SAFE TO RE-RUN (already-applied files are skipped).
#
# Usage:
# DATABASE_URL=postgresql:///costa_rica_directory?host=/tmp scripts/apply-migrations.sh # apply pending
# DATABASE_URL=... scripts/apply-migrations.sh --dry-run # show what WOULD apply, touch nothing
# DATABASE_URL=... scripts/apply-migrations.sh --status # list applied vs pending
# DATABASE_URL=... scripts/apply-migrations.sh --baseline-through <file> # mark pending files <= <file> as applied
# # WITHOUT running them (adopt on a DB
# # already migrated up to <file>)
# DATABASE_URL=... FORCE=1 scripts/apply-migrations.sh --baseline # mark ALL pending as applied w/o running
# # (DANGEROUS — see the guard below)
#
# TRANSACTIONS: 004/008/009 are wrapped in BEGIN..COMMIT (atomic). 002/003/005/006/007 are
# NOT wrapped, but every migration is idempotent-authored (CREATE ... IF NOT EXISTS /
# DROP CONSTRAINT IF EXISTS), and the ledger is marked ONLY after a clean psql exit — so a
# partial failure leaves the file PENDING and a re-run recovers. ON_ERROR_STOP aborts on error.
set -euo pipefail
: "${DATABASE_URL:?set DATABASE_URL (e.g. postgresql:///costa_rica_directory?host=/tmp)}"
cd "$(dirname "$0")/.."
MODE="${1:-apply}"
THROUGH="${2:-}"
PSQL=(psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -qtA)
# Ledger — records which migration files have been applied.
"${PSQL[@]}" -c "CREATE TABLE IF NOT EXISTS schema_migrations (
filename text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);" >/dev/null
# SQL-safe: double any single quote in the filename before interpolation (works with psql -c,
# unlike :'var' which psql does not interpolate in a -c command string).
sqlq() { printf "%s" "${1//\'/\'\'}"; }
applied() { "${PSQL[@]}" -c "SELECT 1 FROM schema_migrations WHERE filename = '$(sqlq "$1")' LIMIT 1;"; }
mark() { "${PSQL[@]}" -c "INSERT INTO schema_migrations(filename) VALUES ('$(sqlq "$1")') ON CONFLICT DO NOTHING;" >/dev/null; }
files=(scripts/migrate_*.sql)
[ -e "${files[0]}" ] || { echo "no scripts/migrate_*.sql found"; exit 1; }
# ascending numeric order (sort -V: 010 sorts after 009, unlike lexicographic sort)
IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort -V)); unset IFS
# --baseline footgun guard: bare --baseline on a prod DB missing a critical migration would
# silently mark it applied WITHOUT running it (e.g. 008 double-book EXCLUDE / 009 integrity)
# → prod thinks it is protected but is not. Require FORCE=1 and print exactly what gets skipped.
if [ "$MODE" = "--baseline" ] && [ "${FORCE:-0}" != "1" ]; then
echo "REFUSING bare --baseline: it marks EVERY pending file as applied WITHOUT running it." >&2
echo "Pending files that would be skipped-without-running:" >&2
for f in "${files[@]}"; do b="$(basename "$f")"; [ -z "$(applied "$b")" ] && echo " $b" >&2; done
echo "If a DB is truly migrated only THROUGH some file, use: --baseline-through <that-file>" >&2
echo "To force full baseline anyway (you accept the above are NOT executed): FORCE=1 ... --baseline" >&2
exit 3
fi
pending=0 done=0
for f in "${files[@]}"; do
base="$(basename "$f")"
if [ -n "$(applied "$base")" ]; then
[ "$MODE" = "--status" ] && echo " applied $base"
continue
fi
pending=$((pending+1))
case "$MODE" in
--status|--dry-run) echo " PENDING $base" ;;
--baseline) mark "$base"; echo " baselined (NOT run) $base" ;;
--baseline-through)
[ -n "$THROUGH" ] || { echo "usage: --baseline-through <filename>" >&2; exit 2; }
if [[ "$base" < "$THROUGH" || "$base" == "$THROUGH" ]]; then
mark "$base"; echo " baselined (NOT run) $base"
else
echo " left PENDING $base"; pending=$((pending)) # remains pending, will need apply
fi ;;
apply)
echo " applying $base ..."
"${PSQL[@]}" -f "$f" >/dev/null
mark "$base"; done=$((done+1)); echo " ✓ applied $base" ;;
*) echo "unknown mode: $MODE"; exit 2 ;;
esac
done
case "$MODE" in
--status) echo "— $pending pending, $(( ${#files[@]} - pending )) applied";;
--dry-run) echo "— dry-run: $pending would apply, 0 changed";;
--baseline) echo "— baselined $pending file(s) as applied (none executed)";;
--baseline-through) echo "— baselined through $THROUGH (none executed); run 'apply' for the rest";;
apply) echo "— done: $done applied, $(( ${#files[@]} - done )) already present";;
esac