Testing Migrations

This commit is contained in:
Max committed 2026-11-03 12:53:17 -07:00
1 parent d9abeb7fb1
commit 9804771a5b
8 files changed
+1109

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
#
# Apply database migrations. Run from the repo root, INSIDE nix-shell (that is
# where the prisma CLI lives).
#
# nix-shell shell.nix --run ./migrateDatabase.sh
#
# Works both ways round: it applies pending migrations to an existing database,
# and builds a complete one from empty. Safe to re-run -- a second run is a
# no-op. Replaces migrateDatabase.sh.
#
# --- why this is a separate step -----------------------------------------
#
# The application connects as a least-privilege account (production uses `app`)
# holding DML rights only -- no CREATE, ALTER or DROP. That is deliberate: the
# server fetches untrusted URLs for link previews, and a service able to reshape
# its own tables is a far larger blast radius than that feature warrants.
#
# So the app never applies DDL. It verifies the schema at boot
# (server/helpers/Schema.js) and disables link previews if anything is missing.
#
# --- authoring a new migration -------------------------------------------
#
# NEVER run `prisma migrate dev`. It can decide the database has drifted and
# offer to RESET it, which on a box holding a production data restore would
# destroy real data. It also wants a shadow database. Instead:
#
# 1. Edit prisma/schema.prisma
# 2. NAME=add_whatever
# DIR="prisma/migrations/$(date +%Y%m%d%H%M%S)_$NAME"
# mkdir -p "$DIR"
# prisma migrate diff --from-config-datasource \
# --to-schema prisma/schema.prisma --script > "$DIR/migration.sql"
# 3. READ the generated SQL -- this review is what makes the approach safe
# 4. ./migrateDatabase.sh
#
# Note: Prisma emits `COLLATE utf8mb4_unicode_ci`, but this database is
# `utf8mb4_general_ci` throughout, and `prisma migrate diff` does NOT report the
# difference. Fix the collation in any generated migration by hand. This script
# checks for drift at the end.
#
# --- credentials ---------------------------------------------------------
#
# Same convention as applyLastBackup.sh / backupDatabase.sh: source ~/.env and
# connect as root with $PROD_DB_PASS. Falls back to passwordless root (the dev
# case, where shell.nix also exports DATABASE_URL). Override with:
#
# DB_ADMIN_USER=admin DB_ADMIN_PASS=secret ./migrateDatabase.sh
#
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$REPO"
DB_NAME="application" # hardcoded in server/config/database.js too
# --- preconditions -------------------------------------------------------
if ! command -v prisma >/dev/null 2>&1; then
echo "prisma is not on PATH -- run this inside nix-shell:" >&2
echo " nix-shell shell.nix --run ./migrateDatabase.sh" >&2
exit 1
fi
if ! command -v mariadb >/dev/null 2>&1 && ! command -v mysql >/dev/null 2>&1; then
echo "no mariadb/mysql client on PATH -- run this inside nix-shell." >&2
exit 1
fi
if command -v mariadb >/dev/null 2>&1; then MYSQL_BIN="mariadb"; else MYSQL_BIN="mysql"; fi
if ! command -v node >/dev/null 2>&1; then
echo "node is not on PATH -- needed to percent-encode the database password." >&2
exit 1
fi
if [ -f "$HOME/.env" ]; then
# shellcheck disable=SC1091
source "$HOME/.env"
fi
ADMIN_USER="${DB_ADMIN_USER:-root}"
ADMIN_PASS="${DB_ADMIN_PASS:-${PROD_DB_PASS:-}}"
mysql_args=(--user "$ADMIN_USER")
[ -n "$ADMIN_PASS" ] && mysql_args+=(--password="$ADMIN_PASS")
LOCAL_SOCKET="$REPO/.mysql/mysql.sock"
[ -S "$LOCAL_SOCKET" ] && mysql_args+=(--socket="$LOCAL_SOCKET")
# Prisma needs a URL, and it must carry the ADMIN credentials -- never the
# app account, which holds no DDL.
DB_ADMIN_HOST="${DB_ADMIN_HOST:-127.0.0.1}"
DB_ADMIN_PORT="${DB_ADMIN_PORT:-3306}"
#
# Percent-encode the password. Production passwords routinely contain @ : / ? #
# or %, every one of which silently corrupts a URL -- an unencoded `@` would
# truncate the host and produce a baffling connection error rather than an
# obvious one.
#
encode_component() {
node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$1"
}
ADMIN_USER_ENC="$(encode_component "$ADMIN_USER")"
if [ -n "$ADMIN_PASS" ]; then
ADMIN_PASS_ENC="$(encode_component "$ADMIN_PASS")"
export DATABASE_URL="mysql://${ADMIN_USER_ENC}:${ADMIN_PASS_ENC}@${DB_ADMIN_HOST}:${DB_ADMIN_PORT}/${DB_NAME}"
else
export DATABASE_URL="mysql://${ADMIN_USER_ENC}@${DB_ADMIN_HOST}:${DB_ADMIN_PORT}/${DB_NAME}"
fi
# Keep the CLI from phoning checkpoint.prisma.io for update checks.
export CHECKPOINT_DISABLE=1
if ! "$MYSQL_BIN" "${mysql_args[@]}" -e "SELECT 1" >/dev/null 2>&1; then
echo "Cannot connect to the database as '$ADMIN_USER'." >&2
echo "Set PROD_DB_PASS in ~/.env, or pass DB_ADMIN_USER / DB_ADMIN_PASS." >&2
exit 1
fi
#
# Collation is explicit because the server default is utf8mb4_unicode_ci while
# every application table is utf8mb4_general_ci. A table created later without
# an explicit COLLATE would otherwise inherit the wrong one, changing string
# comparison and FULLTEXT ranking -- and Prisma neither models collation nor
# reports the difference in `migrate diff`.
#
# For a database that already exists with the wrong default, the one-time
# non-destructive fix is:
# ALTER DATABASE application CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
#
"$MYSQL_BIN" "${mysql_args[@]}" -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` \
CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
sql() { "$MYSQL_BIN" "${mysql_args[@]}" -N -B "$DB_NAME" -e "$1" 2>/dev/null; }
echo '-------'
echo "Migrating '$DB_NAME' as '$ADMIN_USER'"
echo '-------'
# --- baseline an existing, pre-Prisma database ---------------------------
#
# This is the single most dangerous moment in adopting Prisma. A database that
# already has the schema but no _prisma_migrations table must have the baseline
# marked applied, NOT executed -- running 0_init against it would fail on
# existing tables. Detect and handle it here rather than leaving it as a manual
# step someone forgets.
#
HAS_MIGRATION_TABLE=$(sql "SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='_prisma_migrations'" || echo 0)
HAS_EXISTING_SCHEMA=$(sql "SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA='$DB_NAME' AND TABLE_NAME='attachment'" || echo 0)
if [ "${HAS_MIGRATION_TABLE:-0}" -eq 0 ] && [ "${HAS_EXISTING_SCHEMA:-0}" -gt 0 ]; then
echo "Existing schema found with no migration history -- baselining."
echo "Marking 0_init as already applied (NOT executing it)."
prisma migrate resolve --applied 0_init
echo ""
fi
# --- apply ---------------------------------------------------------------
prisma migrate deploy
# --- seed ----------------------------------------------------------------
if [ -f prisma/seed.sql ]; then
echo ""
echo "Applying reference data (prisma/seed.sql)..."
"$MYSQL_BIN" "${mysql_args[@]}" "$DB_NAME" < prisma/seed.sql
fi
# --- verify --------------------------------------------------------------
#
# Print proof rather than leaving the operator to trust us.
#
echo ""
echo '-------'
echo 'Verification'
echo '-------'
prisma migrate status
echo ""
"$MYSQL_BIN" "${mysql_args[@]}" "$DB_NAME" --table -e "
SELECT 'attachment FULLTEXT index' AS check_name,
IFNULL(MAX(INDEX_TYPE), 'MISSING') AS result
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME = 'attachment_text_index'
UNION ALL
SELECT 'tables',
CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations'
UNION ALL
SELECT 'attachment_type seed rows',
CAST(COUNT(*) AS CHAR) FROM attachment_type
UNION ALL
-- _prisma_migrations is Prisma's own bookkeeping table and is created with
-- its default collation; only application tables are checked here.
SELECT 'non-general_ci app tables (expect 0)',
CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME <> '_prisma_migrations'
AND TABLE_COLLATION <> 'utf8mb4_general_ci';
"
# The FULLTEXT index backs Attachment.textSearch. If a migration ever converts
# it to a BTREE index, attachment search silently returns nothing -- so fail
# loudly here rather than letting that ship.
FT=$(sql "SELECT IFNULL(MAX(INDEX_TYPE),'MISSING') FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA='$DB_NAME' AND INDEX_NAME='attachment_text_index'")
if [ "$FT" != "FULLTEXT" ]; then
echo "FAILED: attachment_text_index is '$FT', expected FULLTEXT." >&2
echo "Attachment search depends on MATCH..AGAINST and is now broken." >&2
exit 1
fi
echo ""
echo '-------'
echo 'Done. Restart the API so it re-verifies the schema:'
echo ' pm2 restart NoteServer'
echo '-------'