#!/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 '-------'